diff --git a/apps/desktop/src/store/gateway-profile-request.test.ts b/apps/desktop/src/store/gateway-profile-request.test.ts index 5c2c16a28d..fd4d99230c 100644 --- a/apps/desktop/src/store/gateway-profile-request.test.ts +++ b/apps/desktop/src/store/gateway-profile-request.test.ts @@ -564,3 +564,75 @@ describe('retainGatewayForAgent (#93602)', () => { expect(secondaryGateways[0].close).toHaveBeenCalledOnce() }) }) + +describe('attached shared-remote group turns (#96493)', () => { + function installAttachedSharedRemote() { + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { + getConnection: vi.fn(async (profile: null | string) => ({ port: 4242, profile, token: 't' })), + getConnectionFor: vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({ + connectionId, + port: 9119, + profile, + sharedRemote: true + })), + getGatewayWsUrlFor: vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({ + ok: true as const, + wsUrl: `ws://${connectionId}/${profile}` + })), + touchBackend: vi.fn(async () => undefined) + } + } + + it('reuses the primary socket for a named profile on the attached shared remote', async () => { + const primary = makePrimary() + setPrimaryGateway(primary as never, 'default') + setPrimaryGatewayConnection({ connectionId: 'homelab' }) + installAttachedSharedRemote() + await ensureGatewayForProfile('default') + + const release = await retainGatewayForAgent('homelab', 'voter') + await requestGatewayForAgent('homelab', 'voter', 'session.create', { title: 'Group: room' }) + await requestGatewayForAgent('homelab', 'voter', 'prompt.submit', { session_id: 'rt-1', text: 'hi' }) + + expect(secondaryGateways).toHaveLength(0) + expect(primary.request).toHaveBeenCalledTimes(2) + expect(primary.request).toHaveBeenNthCalledWith(1, 'session.create', { + title: 'Group: room', + profile: 'voter' + }) + expect(primary.request).toHaveBeenNthCalledWith(2, 'prompt.submit', { + session_id: 'rt-1', + text: 'hi', + profile: 'voter' + }) + + release() + expect(secondaryGateways).toHaveLength(0) + }) + + it('still dials a secondary when the attached source is not a shared remote', async () => { + const primary = makePrimary() + setPrimaryGateway(primary as never, 'default') + setPrimaryGatewayConnection({ connectionId: 'homelab' }) + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { + getConnection: vi.fn(async (profile: null | string) => ({ port: 4242, profile, token: 't' })), + getConnectionFor: vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({ + connectionId, + port: 5151, + profile, + sharedRemote: false + })), + getGatewayWsUrlFor: vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({ + ok: true as const, + wsUrl: `ws://${connectionId}/${profile}` + })), + touchBackend: vi.fn(async () => undefined) + } + await ensureGatewayForProfile('default') + + await requestGatewayForAgent('homelab', 'voter', 'session.create', { title: 'g' }) + + expect(secondaryGateways).toHaveLength(1) + expect(primary.request).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index b2c1d0ddbf..3c8088c01a 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -296,6 +296,60 @@ function isPrimaryRegistryRoute(connectionId: null | string, profile: string): b ) } +/** True when `connectionId` is the window's already-attached source AND that + * source is a one-host-many-profiles remote (`sharedRemote`). Named member + * profiles on that host must reuse the primary socket — a registry secondary + * dials a second WebSocket at the same Tailscale URL, which accept/closes in + * ~30ms (`messages=1`) and never runs `session.create` (#96493). Isolated + * SSH/pooled backends (`sharedRemote: false`) still get their own secondary. */ +async function isAttachedSharedRemote(connectionId: null | string, profile: string): Promise { + const id = String(connectionId ?? '').trim() + const key = normKey(profile) + + if (!id || !g.primaryConnectionId || id !== g.primaryConnectionId) { + return false + } + + if (isPrimaryRegistryRoute(id, key)) { + return false + } + + const desktop = window.hermesDesktop + + if (!desktop?.getConnectionFor) { + return false + } + + try { + const conn = await withTimeout( + desktop.getConnectionFor({ connectionId: id, profile: key }), + RECONNECT_ATTEMPT_TIMEOUT_MS, + `Timed out resolving shared-remote route for "${key}"` + ) + + return Boolean(conn && typeof conn === 'object' && (conn as { sharedRemote?: boolean }).sharedRemote === true) + } catch { + return false + } +} + +async function requestOnPrimaryGateway( + method: string, + params: Record, + timeoutMs?: number, + signal?: AbortSignal +): Promise { + const gateway = g.primaryGateway + + if (!gateway) { + throw new Error('Hermes gateway unavailable') + } + + return timeoutMs === undefined && signal === undefined + ? gateway.request(method, params) + : gateway.request(method, params, timeoutMs, signal) +} + export function isActivePrimary(): boolean { return g.activeKey === g.primaryProfile } @@ -870,6 +924,10 @@ export async function requestGatewayForAgent( return requestGatewayForProfile(key, method, params, timeoutMs, signal) } + if (await isAttachedSharedRemote(connectionId, key)) { + return requestOnPrimaryGateway(method, { ...params, profile: key }, timeoutMs, signal) + } + if (!window.hermesDesktop?.getConnectionFor) { throw new Error('This Desktop build cannot dial registry connections. Update Hermes Desktop.') } @@ -1054,6 +1112,11 @@ export async function retainGatewayForAgent(connectionId: null | string, profile return route.release } + if (isPrimaryRegistryRoute(connectionId, key) || (await isAttachedSharedRemote(connectionId, key))) { + // Primary socket stays open for the window lifetime — no secondary to hold. + return () => undefined + } + if (!window.hermesDesktop?.getConnectionFor) { // No registry dialing in this build — nothing to hold; the request path // will throw its own actionable error. @@ -1268,6 +1331,10 @@ export async function openGatewayForAgent( return openGatewayForProfile(profile) } + if (await isAttachedSharedRemote(connectionId, profile)) { + return + } + if (!window.hermesDesktop?.getConnectionFor) { throw new Error('This Desktop build cannot dial registry connections. Update Hermes Desktop.') } @@ -1314,6 +1381,10 @@ export async function ensureGatewayForAgent( return !signal?.aborted } + if (await isAttachedSharedRemote(connectionId, profile)) { + return !signal?.aborted + } + if (!window.hermesDesktop?.getConnectionFor) { throw new Error('This Desktop build cannot dial registry connections. Update Hermes Desktop.') } diff --git a/contributors/emails/paula@smfworks.com b/contributors/emails/paula@smfworks.com new file mode 100644 index 0000000000..f11e568f74 --- /dev/null +++ b/contributors/emails/paula@smfworks.com @@ -0,0 +1,2 @@ +smfworks +# SMF Works contributor