fix(desktop): preserve primary remote connection identity

This commit is contained in:
imMxts
2026-08-17 23:49:51 +02:00
committed by Teknium
parent cb337d39ba
commit 7e156c6d2e
7 changed files with 629 additions and 86 deletions

View File

@@ -0,0 +1,237 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { normalizeRegistry, REGISTRY_VERSION } from './connection-registry'
import { resolveDesktopRemoteRoute } from './desktop-remote-route'
const tokenA = { encoding: 'plain', value: 'token-a' }
const tokenB = { encoding: 'plain', value: 'token-b' }
function registry(primary: string, connections: Record<string, unknown>[]) {
return normalizeRegistry({
version: REGISTRY_VERSION,
primary,
connections: [{ id: 'local', kind: 'local', label: 'This device' }, ...connections]
})
}
test('profile remote wins precedence and carries one exact registry id', () => {
const route = resolveDesktopRemoteRoute({
config: {
mode: 'remote',
remote: { url: 'https://global.test', authMode: 'token', token: tokenB },
profiles: {
worker: { mode: 'remote', url: 'https://worker.test/', authMode: 'token', token: tokenA }
}
},
env: { url: 'https://env.test', token: 'env-token' },
profile: 'worker',
registry: registry('global', [
{ id: 'global', kind: 'remote', label: 'Global', url: 'https://global.test', token: tokenB },
{ id: 'worker', kind: 'remote', label: 'Worker', url: 'https://worker.test', token: tokenA }
])
})
assert.equal(route?.kind, 'remote')
assert.equal(route?.source, 'profile')
assert.equal(route?.connectionId, 'worker')
})
test('environment route wins over global but never claims a registry id', () => {
const route = resolveDesktopRemoteRoute({
config: { mode: 'remote', remote: { url: 'https://global.test', token: tokenB } },
env: { url: 'https://env.test', token: 'token-a' },
registry: registry('env', [{ id: 'env', kind: 'remote', label: 'Env', url: 'https://env.test', token: tokenA }])
})
assert.equal(route?.source, 'env')
assert.equal(route?.connectionId, undefined)
assert.equal(route?.kind === 'remote' ? route.url : null, 'https://env.test')
})
test('environment URL without its token keeps the existing error', () => {
assert.throws(
() =>
resolveDesktopRemoteRoute({
config: { mode: 'local' },
env: { url: 'https://env.test' },
registry: registry('local', [])
}),
/HERMES_DESKTOP_REMOTE_TOKEN is not/
)
})
test('global remote uses exact primary provenance when another row is identical', () => {
const route = resolveDesktopRemoteRoute({
config: { mode: 'remote', remote: { url: 'https://gateway.test/', authMode: 'token', token: tokenA } },
registry: registry('gateway-primary', [
{ id: 'gateway-primary', kind: 'remote', label: 'Primary', url: 'https://gateway.test', token: tokenA },
{ id: 'gateway-copy', kind: 'remote', label: 'Copy', url: 'https://gateway.test', token: tokenA }
])
})
assert.equal(route?.source, 'settings')
assert.equal(route?.connectionId, 'gateway-primary')
})
test('global route fails closed when primary differs, even if another row matches', () => {
const route = resolveDesktopRemoteRoute({
config: { mode: 'remote', remote: { url: 'https://gateway.test', authMode: 'token', token: tokenA } },
registry: registry('other', [
{ id: 'other', kind: 'remote', label: 'Other', url: 'https://other.test', token: tokenB },
{ id: 'matching', kind: 'remote', label: 'Matching', url: 'https://gateway.test', token: tokenA }
])
})
assert.equal(route?.connectionId, undefined)
})
test('profile SSH identity includes port, key, paths, and remote profile', () => {
const ssh = {
mode: 'ssh',
host: 'box.test',
user: 'hermes',
port: 2222,
keyPath: '/keys/a',
remoteHermesPath: '/srv/hermes',
remoteProfile: 'worker'
}
const route = resolveDesktopRemoteRoute({
config: { mode: 'local', profiles: { worker: ssh } },
profile: 'worker',
registry: registry('local', [
{ id: 'wrong-port', kind: 'ssh', label: 'Wrong port', ...ssh, port: 22 },
{ id: 'worker-ssh', kind: 'ssh', label: 'Worker SSH', ...ssh }
])
})
assert.equal(route?.kind, 'ssh')
assert.equal(route?.connectionId, 'worker-ssh')
})
test('profile SSH route fails closed when any dial field differs', () => {
const ssh = {
mode: 'ssh',
host: 'box.test',
user: 'hermes',
port: 2222,
keyPath: '/keys/a',
remoteHermesPath: '/srv/hermes',
remoteProfile: 'worker'
}
const variants = [
{ ...ssh, port: 2200 },
{ ...ssh, keyPath: '/keys/b' },
{ ...ssh, remoteHermesPath: '/opt/hermes' },
{ ...ssh, remoteProfile: 'default' },
{ ...ssh, user: 'other' }
]
for (const [index, variant] of variants.entries()) {
const route = resolveDesktopRemoteRoute({
config: { mode: 'local', profiles: { worker: ssh } },
profile: 'worker',
registry: registry('local', [{ id: `ssh-${index}`, kind: 'ssh', label: `SSH ${index}`, ...variant }])
})
assert.equal(route?.connectionId, undefined)
}
})
test('global SSH treats an omitted port as 22 and checks the primary route', () => {
const route = resolveDesktopRemoteRoute({
config: { mode: 'ssh', remote: { mode: 'ssh', host: 'box.test', user: 'hermes' } },
registry: registry('ssh-primary', [
{ id: 'ssh-primary', kind: 'ssh', label: 'SSH primary', host: 'box.test', user: 'hermes', port: 22 }
])
})
assert.equal(route?.kind, 'ssh')
assert.equal(route?.connectionId, 'ssh-primary')
})
test('profile route omits identity when two registry entries match exactly', () => {
const block = { mode: 'remote', url: 'https://worker.test', authMode: 'token', token: tokenA }
const route = resolveDesktopRemoteRoute({
config: { mode: 'local', profiles: { worker: block } },
profile: 'worker',
registry: registry('local', [
{ id: 'worker-a', kind: 'remote', label: 'Worker A', ...block },
{ id: 'worker-b', kind: 'remote', label: 'Worker B', ...block }
])
})
assert.equal(route?.connectionId, undefined)
})
test('kind, auth material, headers, and Cloud org stay part of route identity', () => {
const cloud = {
mode: 'cloud',
url: 'https://cloud.test',
authMode: 'oauth',
headers: { 'CF-Access': { encoding: 'plain', value: 'a' } },
org: 'org-a'
}
const route = resolveDesktopRemoteRoute({
config: { mode: 'cloud', remote: cloud },
registry: registry('cloud', [
{ id: 'cloud', kind: 'cloud', label: 'Cloud', ...cloud },
{ id: 'remote', kind: 'remote', label: 'Remote', ...cloud },
{ id: 'other-org', kind: 'cloud', label: 'Other org', ...cloud, org: 'org-b' }
])
})
assert.equal(route?.kind, 'cloud')
assert.equal(route?.connectionId, 'cloud')
})
test('URL route fails closed for different token, headers, kind, or Cloud org', () => {
const cases = [
{
config: { mode: 'remote', remote: { url: 'https://gateway.test', token: tokenA } },
primary: { kind: 'remote', url: 'https://gateway.test', token: tokenB }
},
{
config: {
mode: 'remote',
remote: {
url: 'https://gateway.test',
token: tokenA,
headers: { 'CF-Access': { encoding: 'plain', value: 'a' } }
}
},
primary: {
kind: 'remote',
url: 'https://gateway.test',
token: tokenA,
headers: { 'CF-Access': { encoding: 'plain', value: 'b' } }
}
},
{
config: { mode: 'remote', remote: { url: 'https://gateway.test', token: tokenA } },
primary: { kind: 'cloud', url: 'https://gateway.test', token: tokenA }
},
{
config: { mode: 'cloud', remote: { url: 'https://gateway.test', authMode: 'oauth', org: 'a' } },
primary: { kind: 'cloud', url: 'https://gateway.test', authMode: 'oauth', org: 'b' }
}
]
for (const [index, item] of cases.entries()) {
const route = resolveDesktopRemoteRoute({
config: item.config,
registry: registry('primary', [{ id: 'primary', label: `Primary ${index}`, ...item.primary }])
})
assert.equal(route?.connectionId, undefined)
}
})
test('local config without overrides returns null', () => {
assert.equal(resolveDesktopRemoteRoute({ config: { mode: 'local' }, registry: registry('local', []) }), null)
})

View File

@@ -0,0 +1,266 @@
/**
* Pure route planner for Desktop's legacy v1 connection config. It preserves
* v2 registry provenance before URL normalization, OAuth, or SSH tunnelling
* discard dial details. Environment routes deliberately remain unregistered.
*/
import {
connectionScopeKey,
modeIsRemoteLike,
normalizeRemoteBaseUrl,
normalizeRemoteHeaders,
normalizeSshConfig,
normAuthMode,
profileRemoteOverride,
profileSshOverride
} from './connection-config'
import type { ConnectionRegistry, RegistryConnection } from './connection-registry'
type RouteSource = 'env' | 'profile' | 'settings'
interface SshRouteConfig {
host: string
keyPath?: string
mode: 'ssh'
port?: number
remoteHermesPath?: string
remoteProfile?: string
user?: string
}
export type DesktopRemoteRoute =
| {
authMode: 'oauth' | 'token'
connectionId?: string
headers?: Record<string, unknown>
kind: 'cloud' | 'remote'
org?: string
source: RouteSource
token?: unknown
url: string
}
| {
connectionId?: string
kind: 'ssh'
source: Exclude<RouteSource, 'env'>
ssh: SshRouteConfig
token?: unknown
}
export interface DesktopRemoteRouteInput {
config: Record<string, any>
env?: { token?: null | string; url?: null | string }
profile?: null | string
registry: ConnectionRegistry
}
type StoredRoute =
| {
authMode?: unknown
headers?: Record<string, unknown>
kind: 'cloud' | 'remote'
org?: unknown
token?: unknown
url?: unknown
}
| ({ kind: 'ssh' } & Partial<SshRouteConfig>)
function stableValue(value: unknown): string {
if (!value || typeof value !== 'object') {
return JSON.stringify(value ?? null)
}
if (Array.isArray(value)) {
return `[${value.map(stableValue).join(',')}]`
}
return `{${Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => `${JSON.stringify(key)}:${stableValue(item)}`)
.join(',')}}`
}
function canonicalHeaders(headers: unknown): Record<string, unknown> {
return Object.fromEntries(
Object.entries(normalizeRemoteHeaders(headers))
.map(([name, value]): [string, unknown] => [name.toLowerCase(), value])
.sort(([left], [right]) => left.localeCompare(right))
)
}
function routeIdentity(route: StoredRoute): null | string {
if (route.kind === 'ssh') {
const ssh = normalizeSshConfig({ ...route, mode: 'ssh' })
if (!ssh) {
return null
}
return stableValue({
host: ssh.host,
keyPath: ssh.keyPath || '',
kind: 'ssh',
port: ssh.port || 22,
remoteHermesPath: ssh.remoteHermesPath || '',
remoteProfile: ssh.remoteProfile || '',
user: ssh.user || ''
})
}
try {
const authMode = normAuthMode(route.authMode)
return stableValue({
authMode,
headers: canonicalHeaders(route.headers),
kind: route.kind,
org: route.kind === 'cloud' ? String(route.org || '').trim() : '',
token: authMode === 'token' ? (route.token ?? null) : null,
url: normalizeRemoteBaseUrl(route.url)
})
} catch {
return null
}
}
function registryRoute(connection: RegistryConnection): null | StoredRoute {
if (connection.kind === 'local') {
return null
}
return connection as StoredRoute
}
function matchingConnectionId(
registry: ConnectionRegistry,
route: StoredRoute,
strategy: 'primary' | 'unique'
): undefined | string {
const identity = routeIdentity(route)
if (!identity) {
return undefined
}
if (strategy === 'primary') {
const primary = registry.connections.find(connection => connection.id === registry.primary)
const candidate = primary && registryRoute(primary)
return candidate && routeIdentity(candidate) === identity ? primary.id : undefined
}
const matches = registry.connections.filter(connection => {
const candidate = registryRoute(connection)
return candidate ? routeIdentity(candidate) === identity : false
})
return matches.length === 1 ? matches[0].id : undefined
}
function withConnectionId<T extends object>(route: T, connectionId?: string): T & { connectionId?: string } {
return connectionId ? { ...route, connectionId } : route
}
/**
* Select one remote route with the existing precedence and freeze any exact
* registry identity before I/O. A null result means the profile resolves
* locally. Invalid dial data remains the dialler's error, except the existing
* env-pair validation which belongs to selection.
*/
export function resolveDesktopRemoteRoute({
config,
env = {},
profile,
registry
}: DesktopRemoteRouteInput): DesktopRemoteRoute | null {
const profileKey = connectionScopeKey(profile)
const profileConfig = profileKey ? config.profiles?.[profileKey] : null
const sshOverride = profileSshOverride(config, profile)
if (sshOverride) {
const route = { ...sshOverride, kind: 'ssh' as const }
return withConnectionId(
{
kind: 'ssh' as const,
source: 'profile' as const,
ssh: sshOverride,
token: profileConfig?.token
},
matchingConnectionId(registry, route, 'unique')
)
}
const override = profileRemoteOverride(config, profile)
if (override) {
const kind = profileConfig?.mode === 'cloud' ? 'cloud' : 'remote'
const authMode = override.authMode === 'oauth' ? 'oauth' : 'token'
const route = { ...profileConfig, kind } as StoredRoute
return withConnectionId(
{
authMode,
headers: override.headers,
kind,
org: kind === 'cloud' ? String(profileConfig?.org || '').trim() || undefined : undefined,
source: 'profile' as const,
token: override.token,
url: override.url
},
matchingConnectionId(registry, route, 'unique')
)
}
const envUrl = String(env.url || '').trim()
if (envUrl) {
const envToken = String(env.token || '').trim()
if (!envToken) {
throw new Error(
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
'Both must be provided to connect to a remote Hermes backend.'
)
}
return { authMode: 'token', kind: 'remote', source: 'env', token: envToken, url: envUrl }
}
if (config.mode === 'ssh') {
const ssh = normalizeSshConfig({ mode: 'ssh', ...(config.remote || {}) })
if (!ssh) {
throw new Error('SSH remote mode is selected but no host is configured.')
}
const route = { ...ssh, kind: 'ssh' as const }
return withConnectionId(
{ kind: 'ssh' as const, source: 'settings' as const, ssh, token: config.remote?.token },
matchingConnectionId(registry, route, 'primary')
)
}
if (!modeIsRemoteLike(config.mode)) {
return null
}
const kind = config.mode === 'cloud' ? 'cloud' : 'remote'
const authMode = normAuthMode(config.remote?.authMode)
const route = { ...config.remote, kind } as StoredRoute
return withConnectionId(
{
authMode,
headers: config.remote?.headers,
kind,
org: kind === 'cloud' ? String(config.remote?.org || '').trim() || undefined : undefined,
source: 'settings' as const,
token: config.remote?.token,
url: String(config.remote?.url || '')
},
matchingConnectionId(registry, route, 'primary')
)
}

View File

@@ -116,6 +116,7 @@ import { describeCrashReason, installCrashForensics } from './crash-forensics'
import { adoptServedDashboardToken } from './dashboard-token'
import { loadOrCreateInstallationId, sshOwnershipId } from './desktop-installation'
import { formatDesktopLogLine } from './desktop-log-line'
import { resolveDesktopRemoteRoute } from './desktop-remote-route'
import {
buildPosixCleanupScript,
buildWindowsCleanupScript,
@@ -239,7 +240,11 @@ import { selectPoolEvictions } from './pool-eviction'
import { createPoolStopper } from './pool-stop'
import { poolTouchKeys } from './pool-touch-scope'
import { createKeepAwake } from './power-save'
import { FirstRunSetupResetError, runPrimaryBackendStartup } from './primary-backend-startup'
import {
createPrimaryRemoteConnection,
FirstRunSetupResetError,
runPrimaryBackendStartup
} from './primary-backend-startup'
import { rehomePrimaryConnection } from './primary-connection-rehome'
import {
assertLocalProfileCanStart,
@@ -9061,80 +9066,46 @@ function persistSshConnectionToken(profile, source, token) {
async function resolveRemoteBackend(profile) {
const config = readDesktopConnectionConfig()
// 1. Per-profile override — "a profile with its own remote host". Wins even
// over the env override so an explicitly-configured profile always
// reaches its intended backend.
const sshOverride = profileSshOverride(config, profile)
const route = resolveDesktopRemoteRoute({
config,
env: {
token: process.env.HERMES_DESKTOP_REMOTE_TOKEN,
url: process.env.HERMES_DESKTOP_REMOTE_URL
},
profile,
registry: readDesktopConnectionsRegistry()
})
if (sshOverride) {
const reuseToken = decryptDesktopSecret(config.profiles?.[connectionScopeKey(profile)]?.token)
return bootstrapSshConnection(profile, sshOverride, reuseToken, 'profile')
}
const override = profileRemoteOverride(config, profile)
if (override) {
const token = override.authMode === 'oauth' ? null : decryptDesktopSecret(override.token)
return buildRemoteConnection(
override.url,
override.authMode,
token,
'profile',
undefined,
config.profiles?.[connectionScopeKey(profile)]?.mode === 'cloud' ? 'cloud' : 'url',
undefined,
override.headers
)
}
// 2. Env override (global, token-auth only).
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
if (rawEnvUrl) {
if (!rawEnvToken) {
throw new Error(
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
'Both must be provided to connect to a remote Hermes backend.'
)
}
return buildRemoteConnection(rawEnvUrl, 'token', rawEnvToken, 'env')
}
// 3. Global remote.
if (config.mode === 'ssh') {
const ssh = normalizeSshConfig({ mode: 'ssh', ...(config.remote || {}) })
if (!ssh) {
throw new Error('SSH remote mode is selected but no host is configured.')
}
const reuseToken = decryptDesktopSecret(config.remote?.token)
return bootstrapSshConnection(null, ssh, reuseToken, 'settings')
}
// Cloud resolves through the existing URL/OAuth path.
if (!modeIsRemoteLike(config.mode)) {
if (!route) {
return null
}
const authMode = normAuthMode(config.remote?.authMode)
const token = authMode === 'oauth' ? null : decryptDesktopSecret(config.remote?.token)
let connection
return buildRemoteConnection(
config.remote?.url,
authMode,
token,
'settings',
undefined,
config.mode === 'cloud' ? 'cloud' : 'url',
undefined,
config.remote?.headers
)
if (route.kind === 'ssh') {
connection = await bootstrapSshConnection(
route.source === 'profile' ? profile : null,
route.ssh,
decryptDesktopSecret(route.token),
route.source
)
} else {
const token =
route.authMode === 'oauth' ? null : route.source === 'env' ? route.token : decryptDesktopSecret(route.token)
connection = await buildRemoteConnection(
route.url,
route.authMode,
token,
route.source,
undefined,
route.kind === 'cloud' ? 'cloud' : 'url',
undefined,
route.headers
)
}
return route.connectionId ? { ...connection, connectionId: route.connectionId } : connection
}
// A remote profile's sessions live on its remote host's state.db, not on a local
@@ -10293,19 +10264,7 @@ async function startHermes() {
error: null
})
return {
baseUrl: remote.baseUrl,
mode: 'remote',
source: remote.source,
authMode: remote.authMode || 'token',
remoteHost: remote.remoteHost,
remoteKind: remote.remoteKind,
remoteHermesVersion: remote.remoteHermesVersion,
token: remote.token,
wsUrl: remote.wsUrl,
logs: hermesLog.slice(-80),
...getWindowState()
}
return createPrimaryRemoteConnection(remote, hermesLog.slice(-80), getWindowState())
}
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)

View File

@@ -3,7 +3,11 @@ import assert from 'node:assert/strict'
import { test, vi } from 'vitest'
import { createFirstRunSetupGate } from './first-run-setup-gate'
import { FirstRunSetupResetError, runPrimaryBackendStartup } from './primary-backend-startup'
import {
createPrimaryRemoteConnection,
FirstRunSetupResetError,
runPrimaryBackendStartup
} from './primary-backend-startup'
const bootstrapBackend = {
activeRoot: '/tmp/hermes-home/hermes-agent',
@@ -23,6 +27,42 @@ function startupOptions(overrides: Record<string, unknown> = {}) {
}
}
test('primary remote descriptor preserves a resolved registry connection id', () => {
const connection = createPrimaryRemoteConnection(
{
authMode: 'token',
baseUrl: 'https://gateway.example.com',
connectionId: 'skateway',
remoteKind: 'url',
source: 'settings',
token: 'secret',
wsUrl: 'wss://gateway.example.com/api/ws'
},
['ready'],
{ isFullscreen: false }
)
assert.equal(connection.connectionId, 'skateway')
assert.equal(connection.mode, 'remote')
assert.deepEqual(connection.logs, ['ready'])
assert.equal(connection.isFullscreen, false)
})
test('primary remote descriptor keeps legacy unregistered routes unqualified', () => {
const connection = createPrimaryRemoteConnection(
{
baseUrl: 'https://env.example.com',
source: 'env',
token: 'secret',
wsUrl: 'wss://env.example.com/api/ws'
},
[],
{}
)
assert.equal('connectionId' in connection, false)
})
test('remote apply re-resolves the saved connection without ensuring a local runtime', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const savedRemote = { baseUrl: 'https://gateway.example.com/hermes' }

View File

@@ -12,6 +12,44 @@ export interface PrimaryBackendStartupOptions<Backend, RuntimeBackend, Remote, C
export type PrimaryBackendStartupResult<RuntimeBackend, Connection> =
{ kind: 'local'; backend: RuntimeBackend } | { kind: 'remote'; connection: Connection }
interface ResolvedPrimaryRemote {
authMode?: 'oauth' | 'token'
baseUrl: string
connectionId?: string
remoteHermesVersion?: string
remoteHost?: string
remoteKind?: 'cloud' | 'ssh' | 'url'
source?: string
token: unknown
wsUrl: string
}
/**
* Build the renderer-facing primary remote descriptor without dropping route
* identity. Tests cross this same seam, so adding a field to the resolved
* remote cannot silently disappear during primary startup.
*/
export function createPrimaryRemoteConnection<State extends object>(
remote: ResolvedPrimaryRemote,
logs: string[],
windowState: State
) {
return {
baseUrl: remote.baseUrl,
mode: 'remote' as const,
source: remote.source,
authMode: remote.authMode || 'token',
remoteHost: remote.remoteHost,
remoteKind: remote.remoteKind,
remoteHermesVersion: remote.remoteHermesVersion,
...(remote.connectionId ? { connectionId: remote.connectionId } : {}),
token: remote.token,
wsUrl: remote.wsUrl,
logs,
...windowState
}
}
export class FirstRunSetupResetError extends Error {
readonly firstRunSetupReset = true

View File

@@ -618,8 +618,9 @@ export interface HermesConnection {
// Set for pool (non-primary) backends so the renderer knows which profile a
// connection belongs to.
profile?: string
// The registry connection this descriptor was resolved through (absent on
// legacy v1/primary paths). Set by getConnectionFor.
// The registry connection this descriptor resolves to. Registry-scoped
// secondaries carry it directly; legacy primary remotes preserve it from
// their selected stored route before dialing.
connectionId?: string
// True only when `profile` is a request scope on the shared primary backend.
// A pooled backend also carries `profile`, so presence alone cannot identify

View File

@@ -0,0 +1,2 @@
matsvarn
# remote-primary Bot Mode roster regression