diff --git a/apps/desktop/src/store/connection-registry-state.ts b/apps/desktop/src/store/connection-registry-state.ts new file mode 100644 index 0000000000..6b7071a134 --- /dev/null +++ b/apps/desktop/src/store/connection-registry-state.ts @@ -0,0 +1,11 @@ +import { atom } from 'nanostores' + +import type { DesktopConnectionsRegistry } from '@/global' + +/** Null only for the legacy profile-only Desktop topology. Once Electron has + * published a registry, profile names are source-local and are not owners. */ +export const $connectionsRegistry = atom(null) + +export function hasRegistryTopology(): boolean { + return $connectionsRegistry.get() !== null +} diff --git a/apps/desktop/src/store/connections.ts b/apps/desktop/src/store/connections.ts index 59e26cdda0..8a0e242ef2 100644 --- a/apps/desktop/src/store/connections.ts +++ b/apps/desktop/src/store/connections.ts @@ -3,6 +3,7 @@ import { atom, computed } from 'nanostores' import type { DesktopConnectionsRegistry } from '@/global' import { persistStringRecord, storedStringRecord } from '@/lib/storage' import { isTimeoutError, withTimeout } from '@/lib/with-timeout' +import { $connectionsRegistry } from '@/store/connection-registry-state' import { beginGatewaySwitch, endGatewaySwitch, @@ -32,7 +33,7 @@ const SWITCH_DIAL_TIMEOUT_MS = 20_000 const SWITCH_COMMIT_TIMEOUT_MS = 20_000 const SWITCH_REMEMBER_TIMEOUT_MS = 5_000 -export const $connectionsRegistry = atom(null) +export { $connectionsRegistry } from '@/store/connection-registry-state' // Use only the resolved descriptor identity Electron publishes. `primary` // means the registry default, not necessarily the source this window is using; diff --git a/apps/desktop/src/store/session-owner-resolution.test.ts b/apps/desktop/src/store/session-owner-resolution.test.ts new file mode 100644 index 0000000000..36e4198cfc --- /dev/null +++ b/apps/desktop/src/store/session-owner-resolution.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { $connectionsRegistry } from './connections' +import { $profiles } from './profile' +import { + ambientGatewayOwnsEverySession, + assertSessionOwnerResolved, + sessionOwnerIsKnown +} from './session-owner-resolution' + +const registry = (...ids: string[]) => + ({ + connections: ids.map(id => ({ id })), + lastUsed: ids[0] ?? null, + launchMode: 'primary', + primary: ids[0] ?? null + }) as never + +beforeEach(() => { + $connectionsRegistry.set(null) + $profiles.set([]) +}) + +describe('session owner topology', () => { + it('fails closed on an unknown owner in registry topology while preserving legacy profile routes', () => { + // A connection registry means the ambient gateway is never provably the + // sole backend, even with one profile listed: an unknown owner fails + // closed. A bare profile still names a backend — the legacy profile door + // (a pick on the primary / explicit `local` source) mints sessions owned + // by that profile's pool socket in every topology. + $connectionsRegistry.set(registry('local')) + $profiles.set([{ name: 'default' }] as never) + + expect(sessionOwnerIsKnown('default')).toBe(true) + expect(ambientGatewayOwnsEverySession()).toBe(false) + expect(() => + assertSessionOwnerResolved('default', { method: 'session.resume', sessionId: 'registry-profile' }) + ).not.toThrow() + expect(() => assertSessionOwnerResolved(null, { method: 'session.resume', sessionId: 'unknown-owner' })).toThrow( + /could not be resolved/i + ) + + $connectionsRegistry.set(registry('local', 'homelab')) + expect(sessionOwnerIsKnown(null)).toBe(false) + expect(ambientGatewayOwnsEverySession()).toBe(false) + expect(() => assertSessionOwnerResolved(null, { method: 'session.resume', sessionId: 'unknown-owner' })).toThrow( + /could not be resolved/i + ) + + $connectionsRegistry.set(null) + expect(sessionOwnerIsKnown('default')).toBe(true) + expect(ambientGatewayOwnsEverySession()).toBe(true) + expect(() => + assertSessionOwnerResolved(null, { method: 'session.resume', sessionId: 'legacy-single-profile' }) + ).not.toThrow() + + $profiles.set([{ name: 'default' }, { name: 'loki' }] as never) + expect(sessionOwnerIsKnown('loki')).toBe(true) + expect(ambientGatewayOwnsEverySession()).toBe(false) + expect(() => + assertSessionOwnerResolved('loki', { method: 'session.resume', sessionId: 'legacy-profile-owner' }) + ).not.toThrow() + }) +}) diff --git a/apps/desktop/src/store/session-owner-resolution.ts b/apps/desktop/src/store/session-owner-resolution.ts index ae280979ac..028428bd82 100644 --- a/apps/desktop/src/store/session-owner-resolution.ts +++ b/apps/desktop/src/store/session-owner-resolution.ts @@ -13,12 +13,12 @@ * session is left untouched for the next correctly-routed attempt. * * The ONE case where the ambient gateway is not a fallback but the owner by - * construction: no registry source is live (legacy v1 primary) AND at most + * construction: no registry topology exists (legacy v1 primary) AND at most * one profile exists — a single backend serves every session, so there is * nothing to misroute to. Older single-profile backends omit `profile` on * their rows entirely; those users keep working unchanged. */ -import { activeGatewayConnectionId } from './gateway' +import { hasRegistryTopology } from './connection-registry-state' import { $profiles } from './profile' import { isSessionOwnerRoute, type SessionOwnerScope } from './session-request-router' @@ -44,13 +44,19 @@ export function isSessionOwnerResolutionError(error: unknown): error is SessionO } /** True when the ambient gateway is provably the only backend any session - * can live on (legacy single-backend Desktop): no registry source is active - * and there is at most one profile. Everything else has somewhere to misroute. */ + * can live on (legacy single-backend Desktop): Electron has published no + * connection registry and there is at most one profile. The active route is + * presentation state; a null active connection does not prove sole topology. */ export function ambientGatewayOwnsEverySession(): boolean { - return activeGatewayConnectionId() === null && $profiles.get().length <= 1 + return !hasRegistryTopology() && $profiles.get().length <= 1 } -/** True when `owner` names a backend (an exact route or a profile). */ +/** True when `owner` names a backend: an exact connection route, or a bare + * profile. A bare profile stays an owner in registry topology too — a profile + * pick on the primary or the explicit `local` source takes the legacy + * profile-only door (store/profile activateOnCurrentSource, so a per-profile + * remote override resolves), and a session minted there is owned by that + * profile's pool socket, which requestForSessionProfile dials by name. */ export function sessionOwnerIsKnown(owner: SessionOwnerScope): boolean { if (isSessionOwnerRoute(owner)) { return Boolean(owner.connectionId.trim()) diff --git a/apps/desktop/src/store/session-request-router.test.ts b/apps/desktop/src/store/session-request-router.test.ts index 913d1930ab..3ec852e284 100644 --- a/apps/desktop/src/store/session-request-router.test.ts +++ b/apps/desktop/src/store/session-request-router.test.ts @@ -78,6 +78,7 @@ const { } = await import('./gateway') const { requestForSessionProfile, sessionRpcNeedsProfileRoute } = await import('./session-request-router') +const { $connectionsRegistry } = await import('./connection-registry-state') function installDesktop(): void { ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { @@ -103,6 +104,7 @@ function makePrimary() { beforeEach(() => { secondaryGateways.length = 0 promptAckStatus = null + $connectionsRegistry.set(null) configureGatewayRegistry({ onEvent: vi.fn() }) closeSecondaryGateways() }) @@ -177,6 +179,26 @@ describe('sessionRpcNeedsProfileRoute', () => { }) describe('requestForSessionProfile', () => { + it('keeps routing a bare profile owner through its legacy profile pool when a connection registry exists', async () => { + // A profile pick on the primary or the explicit `local` source takes the + // legacy profile-only door (store/profile activateOnCurrentSource), so a + // session minted there is owned by that profile's pool socket in every + // topology — a registry does not turn the bare profile into a guess. + const primary = makePrimary() + setPrimaryGateway(primary as never, 'default') + installDesktop() + $connectionsRegistry.set({ connections: [{ id: 'local' }] } as never) + const ambient = vi.fn(async () => ({ ambient: true })) + + await expect( + requestForSessionProfile('loki', ambient as never, 'session.resume', { session_id: 'stored-a' }) + ).resolves.toEqual({ method: 'session.resume', params: { session_id: 'stored-a' } }) + expect(window.hermesDesktop!.getConnection).toHaveBeenCalledWith('loki') + expect(secondaryGateways).toHaveLength(1) + expect(primary.request).not.toHaveBeenCalled() + expect(ambient).not.toHaveBeenCalled() + }) + it('keeps concurrent same-name requests pinned while foreground activation changes', async () => { const primary = makePrimary() setPrimaryGateway(primary as never, 'default') diff --git a/apps/desktop/src/store/session-request-router.ts b/apps/desktop/src/store/session-request-router.ts index 65649fd034..3cb1993c33 100644 --- a/apps/desktop/src/store/session-request-router.ts +++ b/apps/desktop/src/store/session-request-router.ts @@ -131,6 +131,8 @@ async function withRoutedTurnLease( * * A KNOWN owner (route or profile name) always needs its own socket: the * session belongs to that profile regardless of what the window is showing. + * A bare profile names the legacy profile door's pool socket in every + * topology (a pick on the primary / explicit `local` source dials it). * There is deliberately NO comparison against the active profile — "active" is * presentation state, never a routing authority. Only a null/empty owner (a * fresh draft with no session, or global chrome) routes ambient.