diff --git a/apps/desktop/electron/desktop-remote-route.test.ts b/apps/desktop/electron/desktop-remote-route.test.ts index f68b10d3d6..2a731b77bb 100644 --- a/apps/desktop/electron/desktop-remote-route.test.ts +++ b/apps/desktop/electron/desktop-remote-route.test.ts @@ -305,3 +305,90 @@ test('local route does not inherit an unrelated registry SSH connection', () => test('local config without overrides returns null', () => { assert.equal(resolveDesktopRemoteRoute({ config: { mode: 'local' }, registry: registry('local', []) }), null) }) + +// --- Registry-primary transport gating (#91564 / #90316) --- +// +// "Make primary" on a registered remote gateway only writes connections.json; +// the v1 config.mode stays 'local'. The route resolver must still expose that +// remote transport, or startHermes() spawns a loopback `hermes serve` the +// desktop never uses (duplicated MCP sets, port squat, respawn-on-poll). + +test('falls back to a REMOTE registry primary when the v1 mode is local (#91564/#90316)', () => { + const route = resolveDesktopRemoteRoute({ + config: { mode: 'local' }, + profile: null, + registry: registry('gw-b', [ + { id: 'gw-b', kind: 'remote', label: 'Gateway B', url: 'https://gw-b.test', authMode: 'token', token: tokenB } + ]) + }) + + assert.equal(route?.kind, 'remote') + assert.equal(route?.source, 'registry') + assert.equal(route?.connectionId, 'gw-b') + assert.equal((route as any)?.url, 'https://gw-b.test') + assert.deepEqual((route as any)?.token, tokenB) +}) + +test('falls back to a CLOUD registry primary when the v1 mode is local', () => { + const route = resolveDesktopRemoteRoute({ + config: { mode: 'local' }, + profile: null, + registry: registry('cloud-1', [ + { + id: 'cloud-1', + kind: 'cloud', + label: 'Hermes Cloud', + url: 'https://agent.hermes.cloud', + authMode: 'oauth', + org: 'nous' + } + ]) + }) + + assert.equal(route?.kind, 'cloud') + assert.equal(route?.source, 'registry') + assert.equal((route as any)?.authMode, 'oauth') + assert.equal((route as any)?.org, 'nous') +}) + +test('falls back to an SSH registry primary when the v1 mode is local', () => { + const route = resolveDesktopRemoteRoute({ + config: { mode: 'local' }, + profile: null, + registry: registry('spark', [ + { id: 'spark', kind: 'ssh', label: 'Spark', host: 'spark1', user: 'tek', port: 2222, token: tokenA } + ]) + }) + + assert.equal(route?.kind, 'ssh') + assert.equal(route?.source, 'registry') + assert.equal(route?.connectionId, 'spark') + assert.equal((route as any)?.ssh?.host, 'spark1') + assert.equal((route as any)?.ssh?.port, 2222) +}) + +test('a LOCAL registry primary keeps resolving local (null route)', () => { + const route = resolveDesktopRemoteRoute({ + config: { mode: 'local' }, + profile: null, + registry: registry('local', [ + { id: 'gw-b', kind: 'remote', label: 'Gateway B', url: 'https://gw-b.test', authMode: 'token', token: tokenB } + ]) + }) + + assert.equal(route, null) +}) + +test('the v1 global remote still outranks the registry primary', () => { + const route = resolveDesktopRemoteRoute({ + config: { mode: 'remote', remote: { url: 'https://global.test', authMode: 'token', token: tokenA } }, + profile: null, + registry: registry('gw-b', [ + { id: 'global', kind: 'remote', label: 'Global', url: 'https://global.test', token: tokenA }, + { id: 'gw-b', kind: 'remote', label: 'Gateway B', url: 'https://gw-b.test', authMode: 'token', token: tokenB } + ]) + }) + + assert.equal(route?.source, 'settings') + assert.equal((route as any)?.url, 'https://global.test') +}) diff --git a/apps/desktop/electron/desktop-remote-route.ts b/apps/desktop/electron/desktop-remote-route.ts index c7c8a5cbc6..e41df9b199 100644 --- a/apps/desktop/electron/desktop-remote-route.ts +++ b/apps/desktop/electron/desktop-remote-route.ts @@ -9,7 +9,7 @@ import { import type { ConnectionRegistry } from './connection-registry' import { matchingConnectionId, type StoredRoute } from './connection-route-identity' -type RouteSource = 'env' | 'profile' | 'settings' +type RouteSource = 'env' | 'profile' | 'registry' | 'settings' interface SshRouteConfig { host: string @@ -133,7 +133,14 @@ export function resolveDesktopRemoteRoute({ } if (!modeIsRemoteLike(config.mode)) { - return null + // Registry-primary fallback (#91564/#90316): "Make primary" on a + // registered remote/cloud/ssh gateway only rewrites connections.json — + // the v1 config.mode stays 'local'. Without this rung the primary boot + // resolves local and spawns a loopback `hermes serve` the desktop never + // uses (it dials the registry primary separately): duplicated MCP sets, + // port squat, and a respawn on every poll. A 'local' registry primary + // still resolves null, so genuinely-local desktops are untouched. + return resolveRegistryPrimaryRoute(registry) } const kind = config.mode === 'cloud' ? 'cloud' : 'remote' @@ -153,3 +160,53 @@ export function resolveDesktopRemoteRoute({ matchingConnectionId(registry, route, 'primary') ) } + +/** + * Lowest-precedence rung: the v2 registry PRIMARY's own transport. Returns + * null unless the primary names a remote/cloud/ssh entry — i.e. only when the + * user explicitly made a non-local registered gateway their primary. + */ +function resolveRegistryPrimaryRoute(registry: ConnectionRegistry): DesktopRemoteRoute | null { + const primaryId = String(registry?.primary || '').trim() + + if (!primaryId) { + return null + } + + const entry = (registry.connections || []).find(connection => connection.id === primaryId) + + if (!entry) { + return null + } + + if (entry.kind === 'ssh') { + const ssh = normalizeSshConfig({ ...entry, mode: 'ssh' }) + + if (!ssh) { + return null + } + + return { connectionId: entry.id, kind: 'ssh', source: 'registry', ssh, token: entry.token } + } + + if (entry.kind !== 'remote' && entry.kind !== 'cloud') { + return null + } + + const url = String(entry.url || '').trim() + + if (!url) { + return null + } + + return { + authMode: normAuthMode(entry.authMode), + connectionId: entry.id, + headers: entry.headers, + kind: entry.kind, + org: entry.kind === 'cloud' ? String(entry.org || '').trim() || undefined : undefined, + source: 'registry', + token: entry.token, + url + } +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index fcaff6ceb1..a2a0b71f9d 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -10092,7 +10092,31 @@ function globalRemoteActive() { const mode = readDesktopConnectionConfig().mode - return modeIsRemoteLike(mode) || mode === 'ssh' + if (modeIsRemoteLike(mode) || mode === 'ssh') { + return true + } + + // Registry-primary transport (#91564/#90316): a registered remote/cloud/ssh + // gateway promoted to primary via connections.json makes the primary + // backend remote even while the v1 config.mode still says 'local'. Every + // consumer of this flag ("one remote host serves every profile") must see + // that, or the local-entry routes delegate into a primary that now dials + // remote — respawning the exact loopback children the resolver rung in + // desktop-remote-route.ts eliminates. + return registryPrimaryIsRemote() +} + +// True when the v2 registry PRIMARY names a non-local connection. Mirrors the +// registry fallback rung in resolveDesktopRemoteRoute. +function registryPrimaryIsRemote() { + try { + const registry = readDesktopConnectionsRegistry() + const entry = registry.connections.find(c => c.id === registry.primary) + + return Boolean(entry && (entry.kind === 'remote' || entry.kind === 'cloud' || entry.kind === 'ssh')) + } catch { + return false + } } // True when the PRIMARY profile's backend resolves to a remote/cloud host —