fix(desktop): preserve registry route identity

This commit is contained in:
addel
2026-08-17 06:04:21 +10:00
committed by Teknium
parent c76b7e6343
commit decd6a73fa
16 changed files with 175 additions and 108 deletions
+8 -58
View File
@@ -215,15 +215,13 @@ import {
parentWatchdogEnv
} from './parent-process-identity'
import {
buildOpaqueProfileRoutes,
buildRegistryProfileRoutes,
type EffectiveSshRoute,
localRouteFallbackProfiles,
type ProfileRouteConfig,
registryGatewayWsUrl,
undialedSshRouteSeeds
} from './plugin-profile-routes'
import { selectPoolEvictions } from './pool-eviction'
import { poolTouchKeys } from './pool-touch-scope'
import { createKeepAwake } from './power-save'
import { FirstRunSetupResetError, runPrimaryBackendStartup } from './primary-backend-startup'
import { rehomePrimaryConnection } from './primary-connection-rehome'
@@ -8566,39 +8564,6 @@ function effectiveSshConfigFingerprint(sshConfig) {
return crypto.createHash('sha256').update(output).digest('hex')
}
function effectiveSshRouteForPlugin(config: ProfileRouteConfig): Promise<EffectiveSshRoute> {
const ssh =
process.platform === 'win32'
? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe')
: 'ssh'
const args = ['-G']
if (config.sshPort) {
args.push('-p', String(config.sshPort))
}
args.push('--', config.sshUser ? `${config.sshUser}@${config.sshHost}` : config.sshHost)
return new Promise((resolve, reject) => {
execFile(ssh, args, { encoding: 'utf8', timeout: 10_000, windowsHide: true }, (error, stdout) => {
if (error) {
reject(new Error('Could not resolve SSH route for profile.'))
return
}
const resolved = parseSshGOutput(stdout)
resolve({
hostname: resolved.hostname || config.sshHost,
port: resolved.port || config.sshPort || 22,
user: resolved.user || config.sshUser
})
})
})
}
async function bootstrapSshConnection(profile, sshConfig, reuseToken, source) {
const scope = sshScopeKey(profile)
const effectiveConfigFingerprint = effectiveSshConfigFingerprint(sshConfig)
@@ -9540,16 +9505,14 @@ async function stopRegistryConnectionBackends(connectionId) {
// renderer calls this when it opens a profile's chat WS and periodically while
// streaming, since the main process can't see the direct renderer↔backend WS.
function touchPoolBackend(profile) {
const key = profile && String(profile).trim() ? String(profile).trim() : null
for (const key of poolTouchKeys(profile)) {
const entry = backendPool.get(key)
if (!key) {
return
}
if (entry) {
entry.lastActiveAt = Date.now()
const entry = backendPool.get(key)
if (entry) {
entry.lastActiveAt = Date.now()
return
}
}
}
@@ -12059,20 +12022,7 @@ ipcMain.handle('hermes:plugin-profile-routes', async (_event, rawProfileNames) =
]
}
const config = readDesktopConnectionConfig()
const globalConfig = (await sanitizeDesktopConnectionConfig(config, null)) as ProfileRouteConfig
const localProfiles = agents.filter(agent => agent.connectionId === 'local').map(agent => agent.profile)
const legacyRoutes = await buildOpaqueProfileRoutes({
getProfileConfig: async profile => (await sanitizeDesktopConnectionConfig(config, profile)) as ProfileRouteConfig,
globalConfig,
installationId: desktopInstallationId,
primaryProfile: primaryProfileKey(),
profileNames: localProfiles,
resolveSsh: effectiveSshRouteForPlugin
})
return buildRegistryProfileRoutes({ agents, legacyRoutes, sources: registry.connections })
return buildRegistryProfileRoutes({ agents, sources: registry.connections })
})
ipcMain.handle('hermes:ssh-config:hosts', async () => ({ hosts: collectSshConfigHosts() }))
ipcMain.handle('hermes:ssh-config:resolve', async (_event, host) => {
@@ -235,7 +235,7 @@ describe('buildRegistryProfileRoutes', () => {
expect(new Set(routes.map(route => `${route.connectionId}/${route.profile}`))).toHaveLength(2)
})
it('preserves legacy v1 targetProfile correction for routes reached through local', () => {
it('keeps the registry local source genuinely local when legacy v1 routing is remote', () => {
const routes = buildRegistryProfileRoutes({
agents: [{ connectionId: 'local', profile: 'barry' }],
legacyRoutes: [
@@ -245,7 +245,7 @@ describe('buildRegistryProfileRoutes', () => {
})
expect(routes).toEqual([
{ connectionId: 'local', mode: 'remote', profile: 'barry', targetProfile: 'default' }
{ connectionId: 'local', mode: 'local', profile: 'barry', targetProfile: 'barry' }
])
})
@@ -244,17 +244,14 @@ export async function buildOpaqueProfileRoutes({
/**
* Project the union registry roster into the narrow plugin descriptor. Registry
* ids and profile names are routing identities; endpoint/auth/source fields are
* deliberately discarded here. The local source keeps the v1 resolver's mode
* and targetProfile semantics because getConnectionFor(local, profile) delegates
* to that compatibility path.
* deliberately discarded here. A registry source of kind `local` always means
* the actual local runtime, independently of legacy v1 global/profile routing.
*/
export function buildRegistryProfileRoutes({
agents,
legacyRoutes = [],
sources
}: BuildRegistryProfileRoutesOptions): OpaqueProfileRoute[] {
const sourceById = new Map(sources.map(source => [source.id, source]))
const legacyByProfile = new Map(legacyRoutes.map(route => [route.profile, route]))
const seen = new Set<string>()
const routes: OpaqueProfileRoute[] = []
@@ -270,13 +267,11 @@ export function buildRegistryProfileRoutes({
seen.add(key)
if (source.kind === 'local') {
const legacy = legacyByProfile.get(profile)
routes.push({
connectionId: source.id,
mode: legacy?.mode ?? 'local',
mode: 'local',
profile,
targetProfile: legacy?.targetProfile ?? profile
targetProfile: profile
})
continue
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { poolTouchKeys } from './pool-touch-scope'
describe('poolTouchKeys', () => {
it('falls back from an explicit local registry scope to its delegated bare profile', () => {
expect(poolTouchKeys('conn:local::research')).toEqual(['conn:local::research', 'research'])
})
it('does not alias non-local registry scopes', () => {
expect(poolTouchKeys('conn:homelab::research')).toEqual(['conn:homelab::research'])
})
})
+19
View File
@@ -0,0 +1,19 @@
export function poolTouchKeys(scope: unknown): string[] {
const key = String(scope ?? '').trim()
if (!key) {
return []
}
const localPrefix = 'conn:local::'
if (key.startsWith(localPrefix)) {
const delegatedProfile = key.slice(localPrefix.length)
if (delegatedProfile) {
return [key, delegatedProfile]
}
}
return [key]
}
@@ -19,8 +19,8 @@ import {
configureGatewayRegistry,
disposeSecondariesForConnection,
ensureGatewayForProfile,
isActivePrimary,
gatewayActivationEpoch,
isActivePrimary,
pruneSecondaryGateways,
reconnectSecondaryGateways,
reportPrimaryGatewayState,
@@ -1,5 +1,5 @@
import type { BillingBlock } from '@hermes/shared'
import { backendScopeKey } from '@hermes/shared'
import { registryBackendScopeKey } from '@hermes/shared'
import type { HermesSkin } from '@hermes/shared/skin'
import type { QueryClient } from '@tanstack/react-query'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
@@ -340,12 +340,12 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
// registered connection exposes a 'default' profile, so a bare profile
// comparison attributes gateway B's 'default' events to gateway A's
// 'default'. Compare the composite (connectionId, profile) scope with
// backendScopeKey — untagged (local/primary) events keep the legacy
// registryBackendScopeKey — untagged primary events keep the legacy
// bare-profile behavior byte-identical.
const fromActiveSource = (): boolean =>
(!event.profile || normalizeProfileKey(event.profile) === normalizeProfileKey($activeGatewayProfile.get())) &&
backendScopeKey(event.connectionId ?? null, event.profile ?? null) ===
backendScopeKey(activeGatewayConnectionId(), event.profile ?? null)
registryBackendScopeKey(event.connectionId ?? null, event.profile ?? null) ===
registryBackendScopeKey(activeGatewayConnectionId(), event.profile ?? null)
const occurredAt =
typeof payload?.timestamp === 'number' && Number.isFinite(payload.timestamp)
@@ -42,6 +42,7 @@ const {
closeSecondaryGateways,
configureGatewayRegistry,
ensureGatewayForAgent,
ensureGatewayForProfile,
isActivePrimary,
pruneSecondaryGateways,
setPrimaryGateway
@@ -104,6 +105,25 @@ describe('registry-agent scope eviction (activeGateway must never silently hit t
expect(gatewayMocks.connect).toHaveBeenCalledWith(agentConn.wsUrl)
})
it('keeps the primary active when a fresh registry activation cannot resolve', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
await ensureGatewayForProfile('default')
const publishedPrimary = $gateway.get()
installDesktop({
getConnection: vi.fn(async () => agentConn),
getConnectionFor: vi.fn(async () => {
throw new Error('source unreachable')
})
})
await expect(ensureGatewayForAgent('offline', 'research')).resolves.toBe(false)
expect(isActivePrimary()).toBe(true)
expect(activeGateway()).toBe(primary)
expect($gateway.get()).toBe(publishedPrimary)
})
it('closeSecondaryGateways re-points the active key at the primary instead of dangling', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
@@ -39,7 +39,8 @@ vi.mock('@/hermes', () => ({
constructor() {
secondaryGateways.push(this)
}
}
},
setApiRequestConnection: vi.fn()
}))
vi.mock('@/store/session', () => ({ setGatewayState: vi.fn() }))
vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() }))
@@ -232,15 +233,30 @@ describe('requestGatewayForAgent', () => {
expect($gateway.get()).toBe(primary)
})
it('falls back to the legacy profile path for the local registry connection', async () => {
it('routes an explicit local registry descriptor through getConnectionFor', async () => {
const primary = makePrimary()
const getConnection = vi.fn(async (profile: null | string) => ({ port: 5151, profile, token: 'legacy-token' }))
const getConnectionFor = vi.fn()
const getConnection = vi.fn(async (profile: null | string) => ({
mode: 'remote',
profile,
wsUrl: 'wss://legacy-remote.invalid/api/ws?token=legacy'
}))
const getConnectionFor = vi.fn(async ({ connectionId, profile }) => ({
connectionId,
mode: 'local',
port: 5151,
profile
}))
setPrimaryGateway(primary as never, 'default')
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
getConnection,
getConnectionFor,
getGatewayWsUrlFor: vi.fn(async ({ connectionId, profile }) => ({
ok: true as const,
wsUrl: `ws://${connectionId}/${profile}`
})),
touchBackend: vi.fn(async () => undefined)
}
await ensureGatewayForProfile('default')
@@ -249,8 +265,8 @@ describe('requestGatewayForAgent', () => {
method: 'profiles.list',
params: {}
})
expect(getConnection).toHaveBeenCalledWith('worker')
expect(getConnectionFor).not.toHaveBeenCalled()
expect(getConnectionFor).toHaveBeenCalledWith({ connectionId: 'local', profile: 'worker' })
expect(getConnection).not.toHaveBeenCalled()
expect($gateway.get()).toBe(primary)
})
+24 -19
View File
@@ -1,8 +1,8 @@
import { backendScopeKey, type ConnectionState, type GatewayEvent, resolveGatewayWsUrl } from '@hermes/shared'
import { type ConnectionState, type GatewayEvent, registryBackendScopeKey, resolveGatewayWsUrl } from '@hermes/shared'
import { atom } from 'nanostores'
import { HermesGateway, setApiRequestConnection } from '@/hermes'
import type { HermesConnection } from '@/global'
import { HermesGateway, setApiRequestConnection } from '@/hermes'
import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff'
import { markNativeNotifyBaseline } from '@/store/notify-baseline'
import { setConnection, setGatewayState } from '@/store/session'
@@ -31,7 +31,7 @@ interface RegistryConfig {
// ── Secondary (pool) backends ──────────────────────────────────────────────
interface Secondary {
/** Scope key from backendScopeKey(connectionId, profile). */
/** Scope key from registryBackendScopeKey(connectionId, profile). */
scope: string
profile: string
/** Registry connection serving this socket; null = the local/legacy path. */
@@ -355,7 +355,7 @@ function isMissingConnectionError(error: unknown): boolean {
function createSecondary(profile: string, connectionId: null | string = null): Secondary {
const gateway = new HermesGateway()
const scope = backendScopeKey(connectionId, profile)
const scope = registryBackendScopeKey(connectionId, profile)
const entry: Secondary = {
scope,
@@ -516,8 +516,8 @@ export async function requestGatewayForProfile<T>(
/**
* Send a gateway RPC through one registry source without activating it. The
* composite (connectionId, profile) pool key prevents same-named agents on two
* sources from sharing a socket. Local/empty ids deliberately retain the v1
* profile resolver, including shared-primary request scoping.
* sources from sharing a socket. Only null/empty ids retain the v1 profile
* resolver; explicit `local` is a registry source and must use getConnectionFor.
*/
export async function requestGatewayForAgent<T>(
connectionId: null | string,
@@ -526,7 +526,7 @@ export async function requestGatewayForAgent<T>(
params: Record<string, unknown> = {}
): Promise<T> {
const key = normKey(profile)
const scope = backendScopeKey(connectionId, key)
const scope = registryBackendScopeKey(connectionId, key)
if (scope === key) {
return requestGatewayForProfile<T>(key, method, params)
@@ -581,13 +581,13 @@ export async function openGatewayForProfile(profile: string): Promise<void> {
// ── Connection-scoped agents (multi-source roster) ─────────────────────────
// The (connectionId, profile) analogues of the profile functions above. A
// null/'local' connectionId falls straight through to the profile path, so
// callers can pass roster rows verbatim without special-casing the local
// source. Feature-detected: without the Electron getConnectionFor door these
// null connectionId falls straight through to the profile path. An explicit
// `local` id remains registry-scoped so it cannot inherit legacy remote v1
// routing. Feature-detected: without the Electron getConnectionFor door these
// throw, and roster surfaces disable non-local rows instead.
export async function openGatewayForAgent(connectionId: null | string, profile: string): Promise<void> {
const scope = backendScopeKey(connectionId, profile)
const scope = registryBackendScopeKey(connectionId, profile)
if (scope === normKey(profile)) {
return openGatewayForProfile(profile)
@@ -606,11 +606,13 @@ export async function openGatewayForAgent(connectionId: null | string, profile:
}
}
export async function ensureGatewayForAgent(connectionId: null | string, profile: string): Promise<void> {
const scope = backendScopeKey(connectionId, profile)
export async function ensureGatewayForAgent(connectionId: null | string, profile: string): Promise<boolean> {
const scope = registryBackendScopeKey(connectionId, profile)
if (scope === normKey(profile)) {
return ensureGatewayForProfile(profile)
await ensureGatewayForProfile(profile)
return true
}
if (!window.hermesDesktop?.getConnectionFor) {
@@ -641,14 +643,17 @@ export async function ensureGatewayForAgent(connectionId: null | string, profile
// A source edit/remove may dispose this entry while its dial is still in
// flight. Only the still-registered, still-owned activation may publish.
if (
const activated =
entry.wantOpen &&
g.secondaries.get(scope) === entry &&
applyActive(scope, activationEpoch) &&
entry.connection
) {
Boolean(entry.connection) &&
applyActive(scope, activationEpoch)
if (activated && entry.connection) {
publishActiveConnection(entry.connection)
}
return activated
}
// Make `profile` the active gateway, lazily opening its socket if needed. The
@@ -774,7 +779,7 @@ function restoreActiveToPrimaryIfEvicted(): void {
// Close + evict secondaries whose scope is neither active nor in `keep`
// (scopes with a running / needs-input session). Bounds cost to live work.
// `keep` carries PROFILE names for local/legacy entries and composite
// backendScopeKey(connectionId, profile) scopes for registry-sourced live
// registryBackendScopeKey(connectionId, profile) scopes for registry-sourced live
// work. A registry-scoped entry matches ONLY on its composite key: every
// source exposes a 'default' profile, so matching a non-local entry on the
// bare profile name kept gateway B's 'default' socket alive off gateway A's
@@ -14,7 +14,7 @@ import type { HermesConnection } from '@/global'
// without it, two rapid activations could complete out of order and the
// EARLIER setActive() landed last.
const ensureGatewayForAgent = vi.fn(async (_connectionId: null | string, _profile: string) => undefined)
const ensureGatewayForAgent = vi.fn(async (_connectionId: null | string, _profile: string) => true)
const ensureGatewayForProfile = vi.fn(async (_profile: string) => undefined)
const openGatewayForProfile = vi.fn(async (_profile: string) => undefined)
const $gateway = atom<unknown>({ id: 'live-socket' })
@@ -98,7 +98,17 @@ describe('ensureGatewayAgent → $connection / $activeGatewayProfile sync', () =
expect($connection.get()?.mode).toBe('local')
})
it('falls through to the profile path for a local/null connectionId', async () => {
it('does not republish a registry identity invalidated during activation', async () => {
ensureGatewayForAgent.mockResolvedValueOnce(false)
await ensureGatewayAgent('removed-source', 'research')
expect($activeGatewayProfile.get()).toBe('default')
expect($connection.get()?.mode).toBe('local')
expect(getConnectionFor).not.toHaveBeenCalled()
})
it('falls through to the profile path for a null connectionId', async () => {
getConnection.mockResolvedValue(agentConn({ mode: 'local', profile: 'research' }))
await ensureGatewayAgent(null, 'research')
@@ -107,6 +117,16 @@ describe('ensureGatewayAgent → $connection / $activeGatewayProfile sync', () =
expect(ensureGatewayForAgent).not.toHaveBeenCalled()
expect(getConnectionFor).not.toHaveBeenCalled()
})
it('keeps an explicit local registry id on the registry-aware path', async () => {
getConnectionFor.mockResolvedValue(localConn({ profile: 'research' }))
await ensureGatewayAgent('local', 'research')
expect(ensureGatewayForAgent).toHaveBeenCalledWith('local', 'research')
expect(ensureGatewayForProfile).not.toHaveBeenCalled()
expect(getConnectionFor).toHaveBeenCalledWith({ connectionId: 'local', profile: 'research' })
})
})
describe('ensureGatewayAgent shares the gatewaySwitch mutex with profile switches', () => {
@@ -120,6 +140,8 @@ describe('ensureGatewayAgent shares the gatewaySwitch mutex with profile switche
})
ensureGatewayForAgent.mockImplementation(async (_connectionId, profile) => {
order.push(`agent:${profile}`)
return true
})
getConnection.mockResolvedValue(localConn({ profile: 'worker' }))
getConnectionFor.mockResolvedValue(agentConn())
@@ -151,6 +173,8 @@ describe('ensureGatewayAgent shares the gatewaySwitch mutex with profile switche
ensureGatewayForAgent.mockImplementation(async (_connectionId, profile) => {
order.push(`agent:${profile}`)
await agentGate.promise
return true
})
ensureGatewayForProfile.mockImplementation(async (profile: string) => {
order.push(`profile:${profile}`)
+9 -4
View File
@@ -1,4 +1,3 @@
import { backendScopeKey } from '@hermes/shared'
import { atom, computed } from 'nanostores'
import { getProfiles, setApiRequestProfile, STARTUP_REQUEST_TIMEOUT_MS } from '@/hermes'
@@ -337,12 +336,13 @@ async function syncConnectionToActiveAgent(connectionId: string, profile: string
// - Activations share the gatewaySwitch mutex with profile switches, so a
// rapid agent↔profile (or agent↔agent) interleave can't finish out of
// order and leave the EARLIER setActive() as the last write.
// A local/null connectionId falls through to the profile path verbatim.
// Only a null connectionId falls through to the legacy profile path. Explicit
// `local` is a registry identity and must use the genuinely-local route.
export async function ensureGatewayAgent(connectionId: null | string, profile: string): Promise<void> {
const target = normalizeProfileKey(profile)
const connection = (connectionId ?? '').trim() || null
if (!connection || backendScopeKey(connection, target) === target) {
if (!connection) {
return ensureGatewayProfile(target)
}
@@ -353,7 +353,12 @@ export async function ensureGatewayAgent(connectionId: null | string, profile: s
$gatewaySwapTarget.set(target)
gatewaySwitch = (async () => {
await ensureGatewayForAgent(connection, target)
const activated = await ensureGatewayForAgent(connection, target)
if (!activated) {
return
}
$activeGatewayProfile.set(target)
// The active backend just changed; resync $connection so remote-aware
// paths (image.attach_bytes vs image.attach, /api/fs/*, /api/media) follow.
@@ -36,6 +36,13 @@ describe('liveSessionScopes', () => {
expect(liveSessionScopes()).toEqual(new Set(['conn:homelab::default']))
})
it('keeps an explicit local registry session on its composite scope', () => {
recordSessionEventScope({ connectionId: 'local', profile: 'default', session_id: 'rt-local' })
publishSessionState('rt-local', state({ busy: true }))
expect(liveSessionScopes()).toEqual(new Set(['conn:local::default']))
})
it('includes needs-input sessions and drops settled ones', () => {
recordSessionEventScope({ connectionId: 'homelab', profile: 'default', session_id: 'rt-1' })
publishSessionState('rt-1', state({ busy: false, needsInput: true }))
+2 -2
View File
@@ -16,7 +16,7 @@
* itself here as the delegate so tile UI stays dependency-light.
*/
import { backendScopeKey } from '@hermes/shared'
import { registryBackendScopeKey } from '@hermes/shared'
import { atom, computed } from 'nanostores'
import type { ClientSessionState } from '@/app/types'
@@ -70,7 +70,7 @@ const sessionScopeByRuntimeId = new Map<string, string>()
export function recordSessionEventScope(event: { connectionId?: string; profile?: string; session_id?: string }): void {
if (event.session_id && event.connectionId) {
sessionScopeByRuntimeId.set(event.session_id, backendScopeKey(event.connectionId, event.profile))
sessionScopeByRuntimeId.set(event.session_id, registryBackendScopeKey(event.connectionId, event.profile))
}
}
+12
View File
@@ -23,6 +23,18 @@ export function backendScopeKey(connectionId: null | string | undefined, profile
return `conn:${connection}::${profileKey}`
}
/** Scope a registry route without collapsing its explicit `local` source id.
* Null/empty ids still identify the legacy profile-only route. */
export function registryBackendScopeKey(
connectionId: null | string | undefined,
profile: null | string | undefined
): string {
const profileKey = String(profile ?? '').trim() || 'default'
const connection = String(connectionId ?? '').trim()
return connection ? `conn:${connection}::${profileKey}` : profileKey
}
/** All pool keys owned by a connection share this prefix (teardown on remove). */
export function backendScopePrefix(connectionId: string): string {
return `conn:${String(connectionId).trim()}::`
+2 -1
View File
@@ -1,7 +1,8 @@
export {
backendScopeKey,
backendScopePrefix,
LOCAL_CONNECTION_ID
LOCAL_CONNECTION_ID,
registryBackendScopeKey
} from './backend-scope'
export {
BILLING_REFUSAL_POLICY,