fix(desktop): guard setPrimaryGatewayConnectionId against non-primary active scopes

Hardening follow-up to #95396 (#95628): ignore primary connection-id writes
while the active key is a composite secondary scope, so future
presentation-layer writes cannot relabel the primary socket and poison
new-session routing. Regression test is sabotage-proven (fails with the
guard reverted).
This commit is contained in:
Teknium
2026-08-26 09:10:18 -07:00
parent 10746a53ba
commit 1a5547c5c5
2 changed files with 31 additions and 0 deletions
@@ -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', () => {
+11
View File
@@ -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) {