Files
hermes-agent/apps/desktop/src/store/gateway-shared-remote.test.ts
T
pierrenode 2eb9d29280 fix(desktop): scope pluginSocket's connection to the active profile
pluginSocket (hermes.ts) is documented as "the live twin of pluginRest,
scoped the same way", but it calls window.hermesDesktop.getConnection()
with no profile argument, while pluginRest passes the active profile via
profileScoped(). getConnection's IPC handler (ensureBackend in
electron/main.ts) falls back to the primary profile whenever the profile
argument is empty, so an unscoped call always resolves to the primary
profile's backend regardless of which profile is actually active.

For a plugin used from a non-primary profile (e.g. kanban), this means REST
calls go to the correct pooled backend while the plugin's WebSocket silently
connects to the wrong one — a multi-profile user sees one profile's data
with another profile's live events.

Fix (adapted to the post-#87600 registry-agent store shape during salvage):
resolve the plugin socket's connection through the same (connectionId,
profile) source of truth ensureGatewayProfile/ensureGatewayAgent maintain
for $connection — store/gateway's setActive now pushes the active scope's
registry connection id into the hermes module (setApiRequestConnection,
the no-store-import twin of setApiRequestProfile), and pluginSocket
resolves via getConnectionFor for registry-agent scopes and
getConnection(profile) for the local pool. The plugin socket therefore
follows registry-agent activations too, not just profile switches.

voice-playback.ts's resolveSpeakStreamUrl had the same gap originally, but
main has since fixed it independently (via the getApiRequestProfile()
getter rather than direct store access) — dropped from this PR as
redundant, keeping only the still-open pluginSocket gap.

Co-authored-by: Hermes Agent <hermes@nousresearch.com>
2026-08-16 11:00:17 -07:00

143 lines
4.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// The global-remote share (backend routing case 3): every profile is served
// by the PRIMARY backend over one host, and getConnection() explicitly tags
// the shared descriptor with `sharedPrimary`. Dialing a second WebSocket at it
// used to fail over SSH (per-backend tunnel/ticket) and poison the active
// gateway with a closed socket — "Hermes gateway is not connected" for every
// profile except the primary. Pooled backends (own-remote override, local
// named profile) also carry `profile` for WS URL minting, so `profile` alone
// cannot identify the shared-primary route. These tests pin the fix: only a
// `sharedPrimary` descriptor activates the primary socket; a pooled descriptor
// that also carries `profile` must still dial its own socket.
const gatewayMocks = vi.hoisted(() => ({
connect: vi.fn(async (_wsUrl: string): Promise<void> => {
throw new Error('dialed a socket for a shared-primary profile')
}),
setConnection: vi.fn()
}))
vi.mock('@/hermes', () => ({
setApiRequestConnection: vi.fn(),
HermesGateway: class {
connectionState = 'closed'
connect = async (wsUrl: string): Promise<void> => {
await gatewayMocks.connect(wsUrl)
this.connectionState = 'open'
}
close = vi.fn()
onEvent = vi.fn(() => () => {})
onState = vi.fn(() => () => {})
}
}))
vi.mock('@/store/session', () => ({
setConnection: gatewayMocks.setConnection,
setGatewayState: vi.fn()
}))
vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() }))
const {
$gateway,
closeSecondaryGateways,
configureGatewayRegistry,
ensureActiveGatewayOpen,
ensureGatewayForProfile,
setPrimaryGateway
} = await import('./gateway')
type DesktopStub = { getConnection: ReturnType<typeof vi.fn> }
function installDesktop(stub: DesktopStub): void {
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = stub
}
function makePrimary(): { connectionState: string } {
// Only connectionState is consulted by setActive/isOpen for these paths.
return { connectionState: 'open' }
}
beforeEach(() => {
configureGatewayRegistry({
onEvent: vi.fn(),
primaryProfile: 'default'
} as never)
})
afterEach(() => {
closeSecondaryGateways()
vi.clearAllMocks()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
describe('ensureGatewayForProfile under a shared global remote', () => {
it('activates the primary socket for an explicitly shared-primary descriptor', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
installDesktop({
// Shared descriptor: primary connection tagged with the profile scope
// AND the explicit sharedPrimary marker.
getConnection: vi.fn(async () => ({ port: 4242, profile: 'venture', sharedPrimary: true, token: 't' }))
})
await ensureGatewayForProfile('venture')
expect(gatewayMocks.connect).not.toHaveBeenCalled()
expect($gateway.get()).toBe(primary)
})
it('dials the exact WebSocket URL for a pooled profile descriptor that carries profile', async () => {
const primary = makePrimary()
const remoteWsUrl = 'wss://remote.invalid/api/ws?token=fake-test-token'
setPrimaryGateway(primary as never, 'default')
installDesktop({
// Pooled descriptor: carries `profile` for WS URL minting but is NOT
// shared-primary (no marker) — it must dial its own socket, not reuse
// the primary. This is the local named / own-remote profile case.
getConnection: vi.fn(async () => ({
authMode: 'token',
baseUrl: 'https://remote.invalid',
mode: 'remote',
profile: 'worker',
token: 'fake-test-token',
wsUrl: remoteWsUrl
}))
})
gatewayMocks.connect.mockResolvedValueOnce(undefined)
await ensureGatewayForProfile('worker')
expect(gatewayMocks.connect).toHaveBeenCalledOnce()
expect(gatewayMocks.connect).toHaveBeenCalledWith(remoteWsUrl)
expect($gateway.get()).not.toBe(primary)
})
it('refreshes the active connection after a pooled profile reconnect succeeds', async () => {
const connection = {
authMode: 'token',
baseUrl: 'https://worker.invalid',
mode: 'remote',
profile: 'worker',
token: 'fake-test-token',
wsUrl: 'wss://worker.invalid/api/ws?token=fake-test-token'
}
const getConnection = vi.fn(async () => connection)
setPrimaryGateway(makePrimary() as never, 'default')
installDesktop({ getConnection })
gatewayMocks.connect.mockRejectedValueOnce(new Error('temporarily offline')).mockResolvedValueOnce(undefined)
await ensureGatewayForProfile('worker')
expect(gatewayMocks.setConnection).not.toHaveBeenCalled()
await ensureActiveGatewayOpen()
expect(gatewayMocks.setConnection).toHaveBeenCalledOnce()
expect(gatewayMocks.setConnection).toHaveBeenCalledWith(connection)
})
})