diff --git a/apps/desktop/src/store/gateway-connection-scope.test.ts b/apps/desktop/src/store/gateway-connection-scope.test.ts index fbae09fafd..5fb959a00b 100644 --- a/apps/desktop/src/store/gateway-connection-scope.test.ts +++ b/apps/desktop/src/store/gateway-connection-scope.test.ts @@ -45,6 +45,7 @@ const { closeSecondaryGateways, configureGatewayRegistry, ensureGatewayForAgent, + ensureGatewayForProfile, openGatewayForAgent, pruneSecondaryGateways, setPrimaryGateway, @@ -106,6 +107,25 @@ describe('primary gateway registry scope', () => { expect(activeGatewayConnectionId()).toBeNull() expect(setApiRequestConnection).toHaveBeenLastCalledWith(null) }) + + it('ignores primary connection-id writes while a secondary registry scope is active (#95628 hardening)', async () => { + setPrimaryGateway({ connectionState: 'open' } as never, 'default') + setPrimaryGatewayConnectionId('primary-vps') + + // Foreground Gateway B's composite scope (connectionId 'homelab'). + await expect(ensureGatewayForAgent('homelab', 'default')).resolves.toBe(true) + + // Presentation-layer write while the secondary is foregrounded: the id + // describes the secondary, not the primary. It must be dropped — accepting + // it relabels the primary socket and poisons ambient routing. + setPrimaryGatewayConnectionId('homelab') + + // Back on the primary route, its registry identity is intact. + await ensureGatewayForProfile('default') + + expect(activeGatewayConnectionId()).toBe('primary-vps') + expect(setApiRequestConnection).toHaveBeenLastCalledWith('primary-vps') + }) }) describe('pruneSecondaryGateways with registry-scoped entries', () => { diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index 60481bd559..c2075f1e4e 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -261,6 +261,17 @@ export function setPrimaryGateway(gateway: HermesGateway | null, profile = 'defa } export function setPrimaryGatewayConnectionId(connectionId: null | string | undefined): void { + // Hardening for #95628: while the active route is a secondary scope, the + // window is looking at a NON-primary socket — any connection id flowing + // through presentation-layer code at that moment describes the secondary, + // not the primary. Accepting it would relabel the primary socket, so every + // ambient API/WebSocket helper (and new-session routing) silently lands on + // the wrong backend. The primary's own identity is (re)published by its + // boot/reconnect path, which runs with the primary route active. + if (!isActivePrimary()) { + return + } + g.primaryConnectionId = (connectionId ?? '').trim() || null if (g.activeKey === g.primaryProfile) {