fix(desktop): preserve primary remote connection identity
This commit is contained in:
237
apps/desktop/electron/desktop-remote-route.test.ts
Normal file
237
apps/desktop/electron/desktop-remote-route.test.ts
Normal 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)
|
||||
})
|
||||
266
apps/desktop/electron/desktop-remote-route.ts
Normal file
266
apps/desktop/electron/desktop-remote-route.ts
Normal 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')
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
5
apps/desktop/src/global.d.ts
vendored
5
apps/desktop/src/global.d.ts
vendored
@@ -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
|
||||
|
||||
2
contributors/emails/m.varnskuehler@gmail.com
Normal file
2
contributors/emails/m.varnskuehler@gmail.com
Normal file
@@ -0,0 +1,2 @@
|
||||
matsvarn
|
||||
# remote-primary Bot Mode roster regression
|
||||
Reference in New Issue
Block a user