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>
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
listAllProfileSessions,
|
||||
listSessions,
|
||||
listSidebarSessions,
|
||||
pluginSocket,
|
||||
resetSidebarBatchCapability,
|
||||
setApiRequestProfile,
|
||||
speakText,
|
||||
@@ -529,3 +530,41 @@ describe('Hermes REST helpers', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pluginSocket', () => {
|
||||
let getConnection: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
getConnection = vi.fn().mockResolvedValue(null)
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { api: vi.fn(), getConnection }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(window, 'hermesDesktop')
|
||||
setApiRequestProfile(null)
|
||||
})
|
||||
|
||||
it('scopes the connection to the active profile, like pluginRest', async () => {
|
||||
setApiRequestProfile('work')
|
||||
|
||||
const dispose = pluginSocket('kanban', '/events', () => {})
|
||||
|
||||
await vi.waitFor(() => expect(getConnection).toHaveBeenCalled())
|
||||
expect(getConnection).toHaveBeenCalledWith('work')
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('passes null when no profile is scoped (single-profile / primary)', async () => {
|
||||
const dispose = pluginSocket('kanban', '/events', () => {})
|
||||
|
||||
await vi.waitFor(() => expect(getConnection).toHaveBeenCalled())
|
||||
expect(getConnection).toHaveBeenCalledWith(null)
|
||||
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { JsonRpcGatewayClient } from '@hermes/shared'
|
||||
|
||||
import type { HermesConnection } from '@/global'
|
||||
import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff'
|
||||
import { recordTranscriptTail } from '@/store/transcript-tail'
|
||||
import type {
|
||||
@@ -270,6 +271,41 @@ export function getApiRequestProfile(): null | string {
|
||||
return _apiProfile
|
||||
}
|
||||
|
||||
// Registry connection serving the active gateway (null → the local pool).
|
||||
// Pushed from store/gateway's setActive — the single seam BOTH
|
||||
// ensureGatewayProfile and ensureGatewayAgent funnel through — so WS calls
|
||||
// that dial their own backend (pluginSocket) resolve it through the SAME
|
||||
// source of truth those paths maintain for $connection. That makes the plugin
|
||||
// socket follow registry-agent activations too, not just profile switches.
|
||||
// Same no-store-import contract as _apiProfile (avoids a cycle).
|
||||
let _apiConnectionId: null | string = null
|
||||
|
||||
export function setApiRequestConnection(connectionId: null | string): void {
|
||||
_apiConnectionId = connectionId || null
|
||||
}
|
||||
|
||||
/** Registry connection id that connection-scoped WS calls should target
|
||||
* (null → the local pool). Read-only twin of setApiRequestConnection. */
|
||||
export function getApiRequestConnection(): null | string {
|
||||
return _apiConnectionId
|
||||
}
|
||||
|
||||
/** Resolve the ACTIVE backend's connection descriptor, (connectionId,
|
||||
* profile)-scoped — mirroring how store/profile resolves $connection: a
|
||||
* registry agent's descriptor comes from getConnectionFor (its SOURCE
|
||||
* connection), everything else from the profile-keyed local pool. The
|
||||
* getConnectionFor bridge is optional (older Desktop mains); without it the
|
||||
* profile-scoped pool lookup is the best available answer. */
|
||||
async function activeConnection(): Promise<HermesConnection> {
|
||||
const getConnectionFor = window.hermesDesktop.getConnectionFor
|
||||
|
||||
if (_apiConnectionId && getConnectionFor) {
|
||||
return getConnectionFor({ connectionId: _apiConnectionId, profile: _apiProfile })
|
||||
}
|
||||
|
||||
return window.hermesDesktop.getConnection(_apiProfile)
|
||||
}
|
||||
|
||||
/** Options for a plugin REST call — mirrors the app's own `hermesDesktop.api`
|
||||
* shape, minus the path (which is namespace-derived). */
|
||||
export interface PluginRestOptions {
|
||||
@@ -331,7 +367,7 @@ export function pluginSocket(pluginId: string, path: string, onMessage: (data: u
|
||||
let attempt = 0
|
||||
|
||||
const connect = async () => {
|
||||
const connection = await window.hermesDesktop.getConnection().catch(() => null)
|
||||
const connection = await activeConnection().catch(() => null)
|
||||
|
||||
// No bridge / OAuth cookie auth (WS tickets are single-use, core-managed):
|
||||
// stay on the polling fallback rather than half-working.
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesConnection } from '@/global'
|
||||
|
||||
// pluginSocket must dial the ACTIVE gateway's backend — resolved through the
|
||||
// same (connectionId, profile) source of truth ensureGatewayProfile /
|
||||
// ensureGatewayAgent maintain for $connection — not the unscoped primary
|
||||
// (#73044). Exercises the REAL hermes + store/gateway + store/profile chain:
|
||||
// 1. A profile switch routes the plugin socket to the pooled profile backend
|
||||
// (getConnection(profile), like pluginRest's profileScoped()).
|
||||
// 2. A registry-agent activation routes it to the agent's SOURCE connection
|
||||
// (getConnectionFor), not the local pool — the post-#87600 shape.
|
||||
|
||||
vi.mock('@/hermes', async importOriginal => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
// Stub only the socket class so gateway activations don't dial real WS.
|
||||
HermesGateway: class {
|
||||
connectionState = 'closed'
|
||||
connect = async (_wsUrl: string): Promise<void> => {
|
||||
this.connectionState = 'open'
|
||||
}
|
||||
close = (): void => {
|
||||
this.connectionState = 'closed'
|
||||
}
|
||||
onEvent = vi.fn(() => () => {})
|
||||
onState = vi.fn(() => () => {})
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.mock('@/lib/query-client', () => ({ invalidateProfileScopedQueries: vi.fn() }))
|
||||
vi.mock('@/store/starmap', () => ({ resetStarmapGraph: vi.fn() }))
|
||||
|
||||
const { pluginSocket, setApiRequestConnection, setApiRequestProfile } = await import('@/hermes')
|
||||
const { closeSecondaryGateways, configureGatewayRegistry, setPrimaryGateway } = await import('@/store/gateway')
|
||||
const { $activeGatewayProfile, ensureGatewayAgent, ensureGatewayProfile } = await import('@/store/profile')
|
||||
|
||||
// authMode 'oauth' makes pluginSocket stop after resolving the connection
|
||||
// (polling fallback), so the assertions cover resolution without a WS dial.
|
||||
const conn = (over: Partial<HermesConnection> = {}): HermesConnection =>
|
||||
({
|
||||
authMode: 'oauth',
|
||||
baseUrl: 'https://pool.invalid',
|
||||
mode: 'remote',
|
||||
token: 'fake-test-token',
|
||||
wsUrl: 'wss://pool.invalid/api/ws?token=fake-test-token',
|
||||
...over
|
||||
}) as HermesConnection
|
||||
|
||||
let getConnection: ReturnType<typeof vi.fn>
|
||||
let getConnectionFor: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
getConnection = vi.fn(async (profile?: null | string) => conn({ profile: profile ?? 'default' }))
|
||||
getConnectionFor = vi.fn(async ({ profile }: { connectionId?: null | string; profile?: null | string }) =>
|
||||
conn({ baseUrl: 'https://homelab.invalid', profile: profile ?? 'default' })
|
||||
)
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getConnection,
|
||||
getConnectionFor,
|
||||
getGatewayWsUrl: vi.fn(async () => 'wss://pool.invalid/api/ws?ticket=fake'),
|
||||
getGatewayWsUrlFor: vi.fn(async () => 'wss://homelab.invalid/api/ws?ticket=fake'),
|
||||
touchBackend: vi.fn(async () => ({ ok: true }))
|
||||
}
|
||||
})
|
||||
configureGatewayRegistry({ onEvent: vi.fn() })
|
||||
setPrimaryGateway({ connectionState: 'open' } as never, 'default')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
closeSecondaryGateways()
|
||||
$activeGatewayProfile.set('default')
|
||||
setApiRequestProfile(null)
|
||||
setApiRequestConnection(null)
|
||||
Reflect.deleteProperty(window, 'hermesDesktop')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('pluginSocket active-backend scoping (#73044)', () => {
|
||||
it('dials the active profile backend after a profile switch', async () => {
|
||||
await ensureGatewayProfile('work')
|
||||
getConnection.mockClear()
|
||||
getConnectionFor.mockClear()
|
||||
|
||||
const dispose = pluginSocket('kanban', '/events', () => {})
|
||||
|
||||
await vi.waitFor(() => expect(getConnection).toHaveBeenCalled())
|
||||
expect(getConnection).toHaveBeenCalledWith('work')
|
||||
expect(getConnectionFor).not.toHaveBeenCalled()
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("dials the agent's SOURCE connection after a registry-agent activation", async () => {
|
||||
await ensureGatewayAgent('homelab', 'research')
|
||||
getConnection.mockClear()
|
||||
getConnectionFor.mockClear()
|
||||
|
||||
const dispose = pluginSocket('kanban', '/events', () => {})
|
||||
|
||||
await vi.waitFor(() => expect(getConnectionFor).toHaveBeenCalled())
|
||||
expect(getConnectionFor).toHaveBeenCalledWith({ connectionId: 'homelab', profile: 'research' })
|
||||
expect(getConnection).not.toHaveBeenCalled()
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('falls back to the primary when no profile or connection is active', async () => {
|
||||
const dispose = pluginSocket('kanban', '/events', () => {})
|
||||
|
||||
await vi.waitFor(() => expect(getConnection).toHaveBeenCalled())
|
||||
expect(getConnection).toHaveBeenCalledWith(null)
|
||||
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,7 @@ const gatewayMocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
setApiRequestConnection: vi.fn(),
|
||||
HermesGateway: class {
|
||||
connectionState = 'closed'
|
||||
connect = async (wsUrl: string): Promise<void> => {
|
||||
|
||||
@@ -22,6 +22,7 @@ const gatewayMocks = vi.hoisted(() => {
|
||||
})
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
setApiRequestConnection: vi.fn(),
|
||||
HermesGateway: class {
|
||||
connectionState = 'closed'
|
||||
close = vi.fn(() => {
|
||||
|
||||
@@ -17,6 +17,7 @@ const gatewayMocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
setApiRequestConnection: vi.fn(),
|
||||
HermesGateway: class {
|
||||
connectionState = 'closed'
|
||||
wsUrl = ''
|
||||
|
||||
@@ -19,6 +19,7 @@ const gatewayMocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
setApiRequestConnection: vi.fn(),
|
||||
HermesGateway: class {
|
||||
connectionState = 'closed'
|
||||
connect = async (wsUrl: string): Promise<void> => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { backendScopeKey, type ConnectionState, type GatewayEvent, resolveGatewayWsUrl } from '@hermes/shared'
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { HermesGateway } from '@/hermes'
|
||||
import { HermesGateway, setApiRequestConnection } from '@/hermes'
|
||||
import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff'
|
||||
import { markNativeNotifyBaseline } from '@/store/notify-baseline'
|
||||
import { setConnection, setGatewayState } from '@/store/session'
|
||||
@@ -183,6 +183,11 @@ function setActive(profile: string): void {
|
||||
const gateway = activeGateway()
|
||||
g.$gateway.set(gateway)
|
||||
setGatewayState(gateway?.connectionState ?? 'closed')
|
||||
// Push the active scope's registry connection into the hermes module (null
|
||||
// for the local pool) so connection-building WS calls (pluginSocket) resolve
|
||||
// through the same source of truth every activation path maintains here —
|
||||
// registry-agent activations included, not just profile switches.
|
||||
setApiRequestConnection(activeGatewayConnectionId())
|
||||
}
|
||||
|
||||
function clearTimer(entry: Secondary): void {
|
||||
|
||||
Reference in New Issue
Block a user