From 7e156c6d2ebcaf7d0eea5deb663ea5c5bd420197 Mon Sep 17 00:00:00 2001 From: imMxts Date: Mon, 17 Aug 2026 23:49:51 +0200 Subject: [PATCH] fix(desktop): preserve primary remote connection identity --- .../electron/desktop-remote-route.test.ts | 237 ++++++++++++++++ apps/desktop/electron/desktop-remote-route.ts | 266 ++++++++++++++++++ apps/desktop/electron/main.ts | 125 +++----- .../electron/primary-backend-startup.test.ts | 42 ++- .../electron/primary-backend-startup.ts | 38 +++ apps/desktop/src/global.d.ts | 5 +- contributors/emails/m.varnskuehler@gmail.com | 2 + 7 files changed, 629 insertions(+), 86 deletions(-) create mode 100644 apps/desktop/electron/desktop-remote-route.test.ts create mode 100644 apps/desktop/electron/desktop-remote-route.ts create mode 100644 contributors/emails/m.varnskuehler@gmail.com diff --git a/apps/desktop/electron/desktop-remote-route.test.ts b/apps/desktop/electron/desktop-remote-route.test.ts new file mode 100644 index 0000000000..a7a11e67a6 --- /dev/null +++ b/apps/desktop/electron/desktop-remote-route.test.ts @@ -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[]) { + 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) +}) diff --git a/apps/desktop/electron/desktop-remote-route.ts b/apps/desktop/electron/desktop-remote-route.ts new file mode 100644 index 0000000000..16f44d05ef --- /dev/null +++ b/apps/desktop/electron/desktop-remote-route.ts @@ -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 + kind: 'cloud' | 'remote' + org?: string + source: RouteSource + token?: unknown + url: string + } + | { + connectionId?: string + kind: 'ssh' + source: Exclude + ssh: SshRouteConfig + token?: unknown + } + +export interface DesktopRemoteRouteInput { + config: Record + env?: { token?: null | string; url?: null | string } + profile?: null | string + registry: ConnectionRegistry +} + +type StoredRoute = + | { + authMode?: unknown + headers?: Record + kind: 'cloud' | 'remote' + org?: unknown + token?: unknown + url?: unknown + } + | ({ kind: 'ssh' } & Partial) + +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 { + 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(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') + ) +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index aab2489660..bd7b25b80b 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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) diff --git a/apps/desktop/electron/primary-backend-startup.test.ts b/apps/desktop/electron/primary-backend-startup.test.ts index 6d3f5c8f44..a9f01371a5 100644 --- a/apps/desktop/electron/primary-backend-startup.test.ts +++ b/apps/desktop/electron/primary-backend-startup.test.ts @@ -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 = {}) { } } +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' } diff --git a/apps/desktop/electron/primary-backend-startup.ts b/apps/desktop/electron/primary-backend-startup.ts index b6e2ba8718..3336298244 100644 --- a/apps/desktop/electron/primary-backend-startup.ts +++ b/apps/desktop/electron/primary-backend-startup.ts @@ -12,6 +12,44 @@ export interface PrimaryBackendStartupOptions = { 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( + 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 diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index aafc15d77f..1c0446082b 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -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 diff --git a/contributors/emails/m.varnskuehler@gmail.com b/contributors/emails/m.varnskuehler@gmail.com new file mode 100644 index 0000000000..d6937f12a7 --- /dev/null +++ b/contributors/emails/m.varnskuehler@gmail.com @@ -0,0 +1,2 @@ +matsvarn +# remote-primary Bot Mode roster regression