From faa3bce6dcbbcee9387d64e8817b48edf20a442b Mon Sep 17 00:00:00 2001 From: yoniebans Date: Mon, 20 Jul 2026 23:01:49 +0200 Subject: [PATCH] style(desktop): satisfy merged eslint/prettier config The SSH modules predate the stricter lint config that landed on main (curly, no-empty, perfectionist sorting, prettier). Mechanical lint:fix + fmt pass, empty catch blocks filled with the codebase's void-0 convention, and inline no-control-regex disables on the three deliberate control-char patterns (same pattern as lib/ansi.ts). --- .../desktop/electron/connection-apply.test.ts | 77 +++-- apps/desktop/electron/connection-apply.ts | 21 +- .../electron/connection-config.test.ts | 26 +- apps/desktop/electron/connection-config.ts | 74 ++++- .../electron/desktop-installation.test.ts | 99 ++++-- apps/desktop/electron/desktop-installation.ts | 83 ++++- apps/desktop/electron/main.ts | 282 ++++++++++++---- .../desktop/electron/remote-lifecycle.test.ts | 311 ++++++++++++++---- apps/desktop/electron/remote-lifecycle.ts | 300 +++++++++++++---- .../ssh-bootstrap-coordinator.test.ts | 57 +++- .../electron/ssh-bootstrap-coordinator.ts | 71 +++- apps/desktop/electron/ssh-config.test.ts | 9 + apps/desktop/electron/ssh-config.ts | 86 ++++- apps/desktop/electron/ssh-connection.test.ts | 282 ++++++++++++---- apps/desktop/electron/ssh-connection.ts | 286 +++++++++++++--- .../electron/windows-remote-lifecycle.test.ts | 50 ++- .../electron/windows-remote-lifecycle.ts | 275 +++++++++++++--- .../electron/windows-remote-live.test.ts | 135 +++++--- apps/desktop/src/app/contrib/wiring.tsx | 2 +- .../gateway/hooks/use-gateway-boot.test.tsx | 5 +- .../hooks/use-message-stream/gateway-event.ts | 1 + .../session/hooks/use-message-stream/index.ts | 29 +- .../interim-sealing.test.tsx | 13 +- .../hooks/use-session-actions/index.ts | 6 +- .../src/app/settings/gateway-settings.tsx | 299 ++++++++++++++--- .../app/settings/ssh-host-selection.test.ts | 12 +- .../src/app/settings/ssh-host-selection.ts | 12 +- .../app/shell/hooks/use-statusbar-items.tsx | 23 +- .../src/components/boot-failure-overlay.tsx | 8 +- .../components/boot-failure-reauth.test.ts | 1 + .../src/components/boot-failure-reauth.ts | 52 ++- apps/desktop/src/i18n/en.ts | 3 +- apps/desktop/src/i18n/ja.ts | 3 +- apps/desktop/src/i18n/zh-hant.ts | 3 +- apps/desktop/src/i18n/zh.ts | 3 +- apps/desktop/src/lib/chat-messages.test.ts | 14 +- apps/desktop/src/lib/chat-messages.ts | 5 +- apps/desktop/src/lib/desktop-fs.test.ts | 28 +- apps/desktop/src/lib/desktop-fs.ts | 8 +- 39 files changed, 2395 insertions(+), 659 deletions(-) diff --git a/apps/desktop/electron/connection-apply.test.ts b/apps/desktop/electron/connection-apply.test.ts index 58b7f74e2b3e..ccf697a9284b 100644 --- a/apps/desktop/electron/connection-apply.test.ts +++ b/apps/desktop/electron/connection-apply.test.ts @@ -4,45 +4,63 @@ import { applyConnectionChange, commitConnectionFailure, resolveTerminalConnecti function deferred() { let resolve!: () => void - const promise = new Promise(done => { resolve = done }) + + const promise = new Promise(done => { + resolve = done + }) + return { promise, resolve } } describe('applyConnectionChange', () => { - it.each([ - ['SSH A to SSH B'], - ['SSH to Cloud'], - ['Cloud to SSH'] - ])('serializes %s behind bootstrap rollback before teardown and apply', async () => { - const gate = deferred() - const events: string[] = [] - const run = applyConnectionChange({ - cancelAndWait: async () => { events.push('cancel'); await gate.promise; events.push('drained') }, - isPrimary: true, - scope: '', - sendApplied: () => events.push('applied'), - stopPool: vi.fn(), - teardownPrimary: async () => { events.push('primary') }, - teardownSsh: async () => { events.push('ssh') } - }) + it.each([['SSH A to SSH B'], ['SSH to Cloud'], ['Cloud to SSH']])( + 'serializes %s behind bootstrap rollback before teardown and apply', + async () => { + const gate = deferred() + const events: string[] = [] - await Promise.resolve() - expect(events).toEqual(['cancel']) - gate.resolve() - await run - expect(events).toEqual(['cancel', 'drained', 'ssh', 'primary', 'applied']) - }) + const run = applyConnectionChange({ + cancelAndWait: async () => { + events.push('cancel') + await gate.promise + events.push('drained') + }, + isPrimary: true, + scope: '', + sendApplied: () => events.push('applied'), + stopPool: vi.fn(), + teardownPrimary: async () => { + events.push('primary') + }, + teardownSsh: async () => { + events.push('ssh') + } + }) + + await Promise.resolve() + expect(events).toEqual(['cancel']) + gate.resolve() + await run + expect(events).toEqual(['cancel', 'drained', 'ssh', 'primary', 'applied']) + } + ) it('tears down only a non-primary scope without applying the primary connection', async () => { const events: string[] = [] await applyConnectionChange({ - cancelAndWait: async scope => { events.push(`cancel:${scope}`) }, + cancelAndWait: async scope => { + events.push(`cancel:${scope}`) + }, isPrimary: false, scope: 'worker', sendApplied: () => events.push('applied'), stopPool: scope => events.push(`pool:${scope}`), - teardownPrimary: async () => { events.push('primary') }, - teardownSsh: async scope => { events.push(`ssh:${scope}`) } + teardownPrimary: async () => { + events.push('primary') + }, + teardownSsh: async scope => { + events.push(`ssh:${scope}`) + } }) expect(events).toEqual(['cancel:worker', 'ssh:worker', 'pool:worker']) }) @@ -59,7 +77,12 @@ describe('resolveTerminalConnection', () => { }) it('does not start a local terminal while configured SSH remains unavailable', async () => { - await expect(resolveTerminalConnection(() => 'pending', async () => undefined)).rejects.toThrow('not ready') + await expect( + resolveTerminalConnection( + () => 'pending', + async () => undefined + ) + ).rejects.toThrow('not ready') }) }) diff --git a/apps/desktop/electron/connection-apply.ts b/apps/desktop/electron/connection-apply.ts index 048bc08e871d..5764b9091e73 100644 --- a/apps/desktop/electron/connection-apply.ts +++ b/apps/desktop/electron/connection-apply.ts @@ -9,26 +9,41 @@ async function applyConnectionChange({ }) { await cancelAndWait(scope) await teardownSsh(scope) + if (!isPrimary) { stopPool(scope) + return } + await teardownPrimary() sendApplied() } function commitConnectionFailure(current, starting, commit) { - if (current !== starting) return false + if (current !== starting) { + return false + } + commit() + return true } async function resolveTerminalConnection(getTarget, ensureBackend) { let target = getTarget() - if (target !== 'pending') return target + + if (target !== 'pending') { + return target + } + await ensureBackend() target = getTarget() - if (target === 'pending') throw new Error('Remote connection is not ready yet. Try again in a moment.') + + if (target === 'pending') { + throw new Error('Remote connection is not ready yet. Try again in a moment.') + } + return target } diff --git a/apps/desktop/electron/connection-config.test.ts b/apps/desktop/electron/connection-config.test.ts index 3453f9b6e96f..912340fb3686 100644 --- a/apps/desktop/electron/connection-config.test.ts +++ b/apps/desktop/electron/connection-config.test.ts @@ -23,19 +23,19 @@ import { cookiesHaveLiveSession, cookiesHavePrivySession, cookiesHaveSession, + localProfileEntry, modeIsRemoteLike, normalizeRemoteBaseUrl, normalizeSshConfig, - localProfileEntry, normAuthMode, pathWithGlobalRemoteProfile, profileHasRemoteConnection, profileRemoteOverride, profileSshOverride, - savedProfileSsh, resolveAuthMode, resolveTestWsUrl, RT_COOKIE_VARIANTS, + savedProfileSsh, tokenPreview } from './connection-config' @@ -134,22 +134,31 @@ test('SSH remains separate from URL-shaped remote modes', () => { const config = { profiles: { coder: { mode: 'ssh', host: 'alice@box:2222', keyPath: '/key' } } } assert.equal(profileRemoteOverride(config, 'coder'), null) assert.deepEqual(profileSshOverride(config, 'coder'), { - mode: 'ssh', host: 'box', user: 'alice', port: 2222, keyPath: '/key' + mode: 'ssh', + host: 'box', + user: 'alice', + port: 2222, + keyPath: '/key' }) }) test('normalizeSshConfig handles IPv6 and strict port bounds', () => { assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: '::1', port: 22 }), { - mode: 'ssh', host: '::1' + mode: 'ssh', + host: '::1' }) assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: '[::1]:2222' }), { - mode: 'ssh', host: '::1', port: 2222 + mode: 'ssh', + host: '::1', + port: 2222 }) assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', port: '2222junk' }), { - mode: 'ssh', host: 'box' + mode: 'ssh', + host: 'box' }) assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', port: 65536 }), { - mode: 'ssh', host: 'box' + mode: 'ssh', + host: 'box' }) }) @@ -157,7 +166,8 @@ test('localProfileEntry preserves inactive SSH drafts but drops Cloud state', () const ssh = { mode: 'ssh', host: 'box', user: 'alice', remoteHermesPath: '/hermes' } assert.deepEqual(localProfileEntry(ssh), { mode: 'local', savedSsh: ssh }) assert.deepEqual(localProfileEntry({ mode: 'local', savedSsh: ssh }), { - mode: 'local', savedSsh: ssh + mode: 'local', + savedSsh: ssh }) assert.equal(localProfileEntry({ mode: 'cloud', url: 'https://agent' }), null) }) diff --git a/apps/desktop/electron/connection-config.ts b/apps/desktop/electron/connection-config.ts index 1df4a655e892..b5164f64dd9d 100644 --- a/apps/desktop/electron/connection-config.ts +++ b/apps/desktop/electron/connection-config.ts @@ -171,52 +171,91 @@ function modeIsRemoteLike(mode) { } function normalizeSshConfig(entry) { - if (!entry || typeof entry !== 'object' || entry.mode !== 'ssh') return null + if (!entry || typeof entry !== 'object' || entry.mode !== 'ssh') { + return null + } + let host = String(entry.host || '').trim() - if (!host) return null + + if (!host) { + return null + } + let parsedUser let parsedPort const at = host.indexOf('@') + if (at > 0) { parsedUser = host.slice(0, at) host = host.slice(at + 1) } + const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(host) + if (bracketed) { host = bracketed[1] - if (bracketed[2]) parsedPort = Number(bracketed[2]) + + if (bracketed[2]) { + parsedPort = Number(bracketed[2]) + } } else if ((host.match(/:/g) || []).length === 1) { const [name, rawPort] = host.split(':') + if (/^\d+$/.test(rawPort)) { host = name parsedPort = Number(rawPort) } } - if (!host) return null + + if (!host) { + return null + } + const out: any = { mode: 'ssh', host } const user = String(entry.user || '').trim() || parsedUser || '' - if (user) out.user = user + + if (user) { + out.user = user + } + const rawExplicitPort = String(entry.port ?? '').trim() const explicitPort = /^\d+$/.test(rawExplicitPort) ? Number(rawExplicitPort) : null const port = explicitPort ?? parsedPort - if (Number.isInteger(port) && port > 0 && port <= 65535 && port !== 22) out.port = port + + if (Number.isInteger(port) && port > 0 && port <= 65535 && port !== 22) { + out.port = port + } + const keyPath = String(entry.keyPath || '').trim() - if (keyPath) out.keyPath = keyPath + + if (keyPath) { + out.keyPath = keyPath + } + const remoteHermesPath = String(entry.remoteHermesPath || '').trim() - if (remoteHermesPath) out.remoteHermesPath = remoteHermesPath + + if (remoteHermesPath) { + out.remoteHermesPath = remoteHermesPath + } + return out } function profileSshOverride(config, profile) { const key = connectionScopeKey(profile) const entry = key ? config?.profiles?.[key] : null + return normalizeSshConfig(entry) } function savedProfileSsh(config, profile) { const key = connectionScopeKey(profile) const entry = key ? config?.profiles?.[key] : null - if (!entry || entry.mode !== 'local') return null + + if (!entry || entry.mode !== 'local') { + return null + } + return normalizeSshConfig(entry.savedSsh) } @@ -226,15 +265,24 @@ function profileHasRemoteConnection(config, profile) { function localProfileEntry(existing) { const ssh = normalizeSshConfig(existing) || normalizeSshConfig(existing?.savedSsh) + return ssh ? { mode: 'local', savedSsh: ssh } : null } function hostLabelFromBaseUrl(baseUrl) { const raw = String(baseUrl || '').trim() - if (!raw) return null + + if (!raw) { + return null + } + try { const parsed = new URL(raw) - if (!parsed.hostname) return null + + if (!parsed.hostname) { + return null + } + return parsed.port && parsed.port !== '80' && parsed.port !== '443' ? `${parsed.hostname}:${parsed.port}` : parsed.hostname @@ -411,19 +459,19 @@ export { cookiesHavePrivySession, cookiesHaveSession, hostLabelFromBaseUrl, + localProfileEntry, modeIsRemoteLike, normalizeRemoteBaseUrl, normalizeSshConfig, - localProfileEntry, normAuthMode, pathWithGlobalRemoteProfile, PRIVY_SESSION_COOKIE_VARIANTS, profileHasRemoteConnection, profileRemoteOverride, profileSshOverride, - savedProfileSsh, resolveAuthMode, resolveTestWsUrl, RT_COOKIE_VARIANTS, + savedProfileSsh, tokenPreview } diff --git a/apps/desktop/electron/desktop-installation.test.ts b/apps/desktop/electron/desktop-installation.test.ts index e53bf5681f89..c8e8c78a2884 100644 --- a/apps/desktop/electron/desktop-installation.test.ts +++ b/apps/desktop/electron/desktop-installation.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' + import { test } from 'vitest' import { loadOrCreateInstallationId, parseInstallationId, sshOwnershipId } from './desktop-installation' @@ -11,6 +12,7 @@ const ID_B = '22222222-2222-4222-8222-222222222222' function withTempDir(run) { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-installation-')) + try { return run(directory) } finally { @@ -25,44 +27,73 @@ test('parseInstallationId accepts only a version-4 UUID record', () => { assert.equal(parseInstallationId('{'), '') }) -test('loadOrCreateInstallationId persists and reuses one installation ID', () => withTempDir(directory => { - const filePath = path.join(directory, 'desktop-installation.json') - assert.equal(loadOrCreateInstallationId(filePath, () => ID_A), ID_A) - assert.equal(loadOrCreateInstallationId(filePath, () => ID_B), ID_A) - assert.equal(fs.statSync(filePath).mode & 0o777, 0o600) -})) +test('loadOrCreateInstallationId persists and reuses one installation ID', () => + withTempDir(directory => { + const filePath = path.join(directory, 'desktop-installation.json') + assert.equal( + loadOrCreateInstallationId(filePath, () => ID_A), + ID_A + ) + assert.equal( + loadOrCreateInstallationId(filePath, () => ID_B), + ID_A + ) + assert.equal(fs.statSync(filePath).mode & 0o777, 0o600) + })) -test('loadOrCreateInstallationId tightens an existing identity file', () => withTempDir(directory => { - const filePath = path.join(directory, 'desktop-installation.json') - fs.writeFileSync(filePath, JSON.stringify({ installationId: ID_A }), { mode: 0o644 }) - assert.equal(loadOrCreateInstallationId(filePath, () => ID_B), ID_A) - if (process.platform !== 'win32') assert.equal(fs.statSync(filePath).mode & 0o777, 0o600) -})) +test('loadOrCreateInstallationId tightens an existing identity file', () => + withTempDir(directory => { + const filePath = path.join(directory, 'desktop-installation.json') + fs.writeFileSync(filePath, JSON.stringify({ installationId: ID_A }), { mode: 0o644 }) + assert.equal( + loadOrCreateInstallationId(filePath, () => ID_B), + ID_A + ) -test('loadOrCreateInstallationId replaces a malformed existing record', () => withTempDir(directory => { - const filePath = path.join(directory, 'desktop-installation.json') - fs.writeFileSync(filePath, '{', { mode: 0o600 }) - assert.equal(loadOrCreateInstallationId(filePath, () => ID_A), ID_A) - assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).installationId, ID_A) -})) + if (process.platform !== 'win32') { + assert.equal(fs.statSync(filePath).mode & 0o777, 0o600) + } + })) -test('loadOrCreateInstallationId replaces an existing symlink', () => withTempDir(directory => { - if (process.platform === 'win32') return - const target = path.join(directory, 'target.json') - const filePath = path.join(directory, 'desktop-installation.json') - fs.writeFileSync(target, JSON.stringify({ installationId: ID_B }), { mode: 0o600 }) - fs.symlinkSync(target, filePath) - assert.equal(loadOrCreateInstallationId(filePath, () => ID_A), ID_A) - assert.equal(fs.lstatSync(filePath).isSymbolicLink(), false) - assert.equal(JSON.parse(fs.readFileSync(target, 'utf8')).installationId, ID_B) -})) +test('loadOrCreateInstallationId replaces a malformed existing record', () => + withTempDir(directory => { + const filePath = path.join(directory, 'desktop-installation.json') + fs.writeFileSync(filePath, '{', { mode: 0o600 }) + assert.equal( + loadOrCreateInstallationId(filePath, () => ID_A), + ID_A + ) + assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).installationId, ID_A) + })) -test('loadOrCreateInstallationId replaces a malformed destination without a repair lock', () => withTempDir(directory => { - const filePath = path.join(directory, 'desktop-installation.json') - fs.writeFileSync(filePath, '{', { mode: 0o600 }) - assert.equal(loadOrCreateInstallationId(filePath, () => ID_A), ID_A) - assert.equal(fs.existsSync(`${filePath}.lock`), false) -})) +test('loadOrCreateInstallationId replaces an existing symlink', () => + withTempDir(directory => { + if (process.platform === 'win32') { + return + } + + const target = path.join(directory, 'target.json') + const filePath = path.join(directory, 'desktop-installation.json') + fs.writeFileSync(target, JSON.stringify({ installationId: ID_B }), { mode: 0o600 }) + fs.symlinkSync(target, filePath) + assert.equal( + loadOrCreateInstallationId(filePath, () => ID_A), + ID_A + ) + assert.equal(fs.lstatSync(filePath).isSymbolicLink(), false) + assert.equal(JSON.parse(fs.readFileSync(target, 'utf8')).installationId, ID_B) + })) + +test('loadOrCreateInstallationId replaces a malformed destination without a repair lock', () => + withTempDir(directory => { + const filePath = path.join(directory, 'desktop-installation.json') + fs.writeFileSync(filePath, '{', { mode: 0o600 }) + assert.equal( + loadOrCreateInstallationId(filePath, () => ID_A), + ID_A + ) + assert.equal(fs.existsSync(`${filePath}.lock`), false) + })) test('sshOwnershipId is stable, scoped, and does not disclose the UUID', () => { const global = sshOwnershipId(ID_A, '') diff --git a/apps/desktop/electron/desktop-installation.ts b/apps/desktop/electron/desktop-installation.ts index 8b321f62229b..2a067ca96df5 100644 --- a/apps/desktop/electron/desktop-installation.ts +++ b/apps/desktop/electron/desktop-installation.ts @@ -7,6 +7,7 @@ const INSTALLATION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f] function parseInstallationId(raw) { try { const value = JSON.parse(String(raw || ''))?.installationId + return INSTALLATION_ID_RE.test(value) ? value.toLowerCase() : '' } catch { return '' @@ -16,9 +17,19 @@ function parseInstallationId(raw) { function readInstallationId(filePath) { try { const stat = fs.lstatSync(filePath) - if (!stat.isFile() || stat.isSymbolicLink()) return '' - if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) return '' - if (process.platform !== 'win32' && (stat.mode & 0o777) !== 0o600) fs.chmodSync(filePath, 0o600) + + if (!stat.isFile() || stat.isSymbolicLink()) { + return '' + } + + if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) { + return '' + } + + if (process.platform !== 'win32' && (stat.mode & 0o777) !== 0o600) { + fs.chmodSync(filePath, 0o600) + } + return parseInstallationId(fs.readFileSync(filePath, 'utf8')) } catch { return '' @@ -32,51 +43,95 @@ function waitForRepair() { function loadOrCreateInstallationId(filePath, randomUUID = crypto.randomUUID) { const existing = readInstallationId(filePath) - if (existing) return existing + + if (existing) { + return existing + } fs.mkdirSync(path.dirname(filePath), { recursive: true }) const installationId = randomUUID().toLowerCase() - if (!INSTALLATION_ID_RE.test(installationId)) throw new Error('Could not generate a valid desktop installation ID.') + + if (!INSTALLATION_ID_RE.test(installationId)) { + throw new Error('Could not generate a valid desktop installation ID.') + } const repairPath = `${filePath}.repair.lock` + for (let attempt = 0; attempt < 40; attempt++) { let repairFd + try { repairFd = fs.openSync(repairPath, 'wx', 0o600) } catch (error: any) { - if (error?.code !== 'EEXIST') throw error + if (error?.code !== 'EEXIST') { + throw error + } + const winner = readInstallationId(filePath) - if (winner) return winner + + if (winner) { + return winner + } + waitForRepair() + continue } try { const winner = readInstallationId(filePath) - if (winner) return winner + + if (winner) { + return winner + } + try { const stat = fs.lstatSync(filePath) - if (!stat.isFile() && !stat.isSymbolicLink()) throw new Error('Desktop installation ID path is not a regular file.') + + if (!stat.isFile() && !stat.isSymbolicLink()) { + throw new Error('Desktop installation ID path is not a regular file.') + } + if (!stat.isSymbolicLink() && typeof process.getuid === 'function' && stat.uid !== process.getuid()) { throw new Error('Desktop installation ID is owned by another user.') } + fs.unlinkSync(filePath) } catch (error: any) { - if (error?.code !== 'ENOENT') throw error + if (error?.code !== 'ENOENT') { + throw error + } } + fs.writeFileSync(filePath, JSON.stringify({ installationId }), { encoding: 'utf8', flag: 'wx', mode: 0o600 }) + return installationId } finally { - if (repairFd !== undefined) fs.closeSync(repairFd) - try { fs.unlinkSync(repairPath) } catch {} + if (repairFd !== undefined) { + fs.closeSync(repairFd) + } + + try { + fs.unlinkSync(repairPath) + } catch { + void 0 + } } } + throw new Error('Could not repair the desktop installation ID.') } function sshOwnershipId(installationId, scope) { - if (!INSTALLATION_ID_RE.test(String(installationId || ''))) throw new Error('Desktop installation ID is invalid.') - return crypto.createHash('sha256').update(`${installationId}\0${String(scope || '')}`).digest('hex').slice(0, 32) + if (!INSTALLATION_ID_RE.test(String(installationId || ''))) { + throw new Error('Desktop installation ID is invalid.') + } + + return crypto + .createHash('sha256') + .update(`${installationId}\0${String(scope || '')}`) + .digest('hex') + .slice(0, 32) } export { INSTALLATION_ID_RE, loadOrCreateInstallationId, parseInstallationId, readInstallationId, sshOwnershipId } diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 33843339e5ae..a15b3adccafe 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -48,27 +48,22 @@ import { cookiesHavePrivySession, cookiesHaveSession, hostLabelFromBaseUrl, + localProfileEntry, modeIsRemoteLike, normalizeRemoteBaseUrl, normalizeSshConfig, - localProfileEntry, normAuthMode, pathWithGlobalRemoteProfile, profileHasRemoteConnection, profileRemoteOverride, profileSshOverride, - savedProfileSsh, resolveAuthMode, resolveTestWsUrl, + savedProfileSsh, tokenPreview } from './connection-config' import { adoptServedDashboardToken } from './dashboard-token' import { loadOrCreateInstallationId, sshOwnershipId } from './desktop-installation' -import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator' -import { SshConnection, buildInteractiveSshArgs, createSshProbeConnection, pickLocalPort, redactSecrets } from './ssh-connection' -import * as remoteLifecycle from './remote-lifecycle' -import { collectSshConfigHosts, parseSshGOutput } from './ssh-config' -import { buildWindowsInteractiveCommand, connectWindowsRemote, detectRemotePlatform, helper } from './windows-remote-lifecycle' import { buildPosixCleanupScript, buildWindowsCleanupScript, @@ -119,6 +114,7 @@ import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle import { ensureMainWindow } from './main-window-lifecycle' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' +import * as remoteLifecycle from './remote-lifecycle' import { buildSessionWindowUrl, chatWindowWebPreferences, @@ -127,6 +123,15 @@ import { SESSION_WINDOW_MIN_WIDTH } from './session-windows' import { ensureSpawnHelperExecutable } from './spawn-helper-perms' +import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator' +import { collectSshConfigHosts, parseSshGOutput } from './ssh-config' +import { + buildInteractiveSshArgs, + createSshProbeConnection, + pickLocalPort, + redactSecrets, + SshConnection +} from './ssh-connection' import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width' import { resolveBehindCount, shouldCountCommits } from './update-count' import { readLiveUpdateMarker, writeUpdateMarker } from './update-marker' @@ -157,6 +162,12 @@ import { getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path' +import { + buildWindowsInteractiveCommand, + connectWindowsRemote, + detectRemotePlatform, + helper +} from './windows-remote-lifecycle' import { alreadyHasNoSandbox, buildNoSandboxRelaunchArgs, @@ -4607,6 +4618,7 @@ async function waitForHermes(baseUrl, token, signal?) { error.kind = 'superseded' throw error } + try { await fetchJson(`${baseUrl}/api/status`, token) @@ -4615,12 +4627,16 @@ async function waitForHermes(baseUrl, token, signal?) { lastError = error await new Promise((resolve, reject) => { const timer = setTimeout(resolve, 500) - signal?.addEventListener('abort', () => { - clearTimeout(timer) - const aborted: any = new Error('SSH bootstrap was superseded by newer connection settings.') - aborted.kind = 'superseded' - reject(aborted) - }, { once: true }) + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer) + const aborted: any = new Error('SSH bootstrap was superseded by newer connection settings.') + aborted.kind = 'superseded' + reject(aborted) + }, + { once: true } + ) }) } } @@ -4948,7 +4964,9 @@ function setAndPersistZoomLevel(window, zoomLevel) { // downgrade or JSON read failure still finds a sane value). window.webContents .executeJavaScript( - `try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}` + `try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch { + void 0 + }` ) .catch(error => rememberLog(`[zoom] persist failed: ${error?.message || error}`)) } @@ -6056,10 +6074,15 @@ function sanitizeConnectionProfiles(raw: Record) { if (entry.mode === 'ssh') { const ssh = normalizeSshConfig(entry) + if (ssh) { - if (entry.token && typeof entry.token === 'object') ssh.token = entry.token + if (entry.token && typeof entry.token === 'object') { + ssh.token = entry.token + } + out[name] = ssh } + continue } @@ -6076,7 +6099,10 @@ function sanitizeConnectionProfiles(raw: Record) { if (cleaned.mode === 'local') { const savedSsh = normalizeSshConfig(entry.savedSsh) - if (savedSsh) cleaned.savedSsh = savedSsh + + if (savedSsh) { + cleaned.savedSsh = savedSsh + } } const url = String(entry.url || '').trim() @@ -6205,9 +6231,9 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon const envOverride = key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL) const savedMode = key ? scoped?.mode : config.mode const ssh = savedMode === 'ssh' ? normalizeSshConfig(block) : null - const savedSsh = savedMode === 'local' - ? key ? savedProfileSsh(config, key) : normalizeSshConfig(block) - : null + + const savedSsh = savedMode === 'local' ? (key ? savedProfileSsh(config, key) : normalizeSshConfig(block)) : null + const remoteToken = decryptDesktopSecret(block.token) const authMode = normAuthMode(block.authMode) const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '') @@ -6314,14 +6340,17 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo if (mode === 'ssh') { const sshBlock = buildSshBlock(input, savedProfileSsh(existing, key) || rawExistingBlock) + if (key) { const profiles = { ...(existing.profiles || {}), [key]: sshBlock } + return { mode: existing.mode === 'ssh' || modeIsRemoteLike(existing.mode) ? existing.mode : 'local', remote: existing.remote || {}, profiles } } + return { mode: 'ssh', remote: sshBlock, profiles: existing.profiles || {} } } @@ -6335,8 +6364,12 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo profiles[key] = { mode, ...buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg) } } else { const localEntry = localProfileEntry(rawExistingBlock) - if (localEntry) profiles[key] = localEntry - else delete profiles[key] + + if (localEntry) { + profiles[key] = localEntry + } else { + delete profiles[key] + } } return { @@ -6371,14 +6404,17 @@ function buildSshBlock(input: any, existingBlock: any = {}) { keyPath: input.sshKeyPath ?? existingBlock.keyPath, remoteHermesPath: input.sshRemoteHermesPath ?? existingBlock.remoteHermesPath }) + if (!merged) { throw new Error('SSH host is required.') } + // Carry forward an already-adopted dashboard token unless the host changed // (a different host invalidates the old dashboard's token). if (existingBlock.token && existingBlock.host === merged.host) { merged.token = existingBlock.token } + return merged } @@ -6387,7 +6423,15 @@ function buildSshBlock(input: any, existingBlock: any = {}) { // and is shared by the per-profile, env, and global resolution paths. `token` // is the DECRYPTED static token (or null in OAuth mode). `source` is a label // for diagnostics ('profile' | 'env' | 'settings'). -async function buildRemoteConnection(rawUrl, authMode, token, source, remoteHost?, remoteKind = 'url', remoteIdentity?) { +async function buildRemoteConnection( + rawUrl, + authMode, + token, + source, + remoteHost?, + remoteKind = 'url', + remoteIdentity? +) { const baseUrl = normalizeRemoteBaseUrl(rawUrl) // For token/oauth remotes the meaningful host is the real backend URL; for // SSH remotes the caller passes the entered/resolved host explicitly (the @@ -6483,11 +6527,15 @@ function sshRememberLog(chunk) { async function sshProbeReuseProof(baseUrl, token, spawnNonce) { try { const proof: any = await fetchJson(`${baseUrl}/api/ssh/ownership`, token) + return proof?.ok === true && proof.sshOwnerNonce === spawnNonce && proof.protocolVersion === 1 ? 'authenticated-ok' : 'authenticated-stale' } catch (error: any) { - if (/^(401|403|404):/.test(String(error?.message || ''))) return 'authenticated-stale' + if (/^(401|403|404):/.test(String(error?.message || ''))) { + return 'authenticated-stale' + } + throw error } } @@ -6495,13 +6543,19 @@ async function sshProbeReuseProof(baseUrl, token, spawnNonce) { async function teardownSshConnection(profile) { const scope = sshScopeKey(profile) const state = sshConnections.get(scope) - if (!state) return + + if (!state) { + return + } + sshConnections.delete(scope) + for (const [id, info] of [...terminalSessions.entries()]) { if (info.sshScope === scope) { disposeTerminalSession(id) } } + try { if (state.localPort && state.remotePort) { await state.ssh.cancelForward(state.localPort, state.remotePort) @@ -6509,6 +6563,7 @@ async function teardownSshConnection(profile) { } catch { // best effort } + try { await state.ssh.close() } catch { @@ -6527,30 +6582,46 @@ function activeSshTerminalTarget() { if (profileSshOverride(config, profile)) { const scope = sshScopeKey(profile) const state = sshConnections.get(scope) + return state && state.ssh ? { ssh: state.ssh, scope } : 'pending' } + if (profileRemoteOverride(config, profile)) { return null } + if (process.env.HERMES_DESKTOP_REMOTE_URL) { return null } + if (config.mode === 'ssh') { const state = sshConnections.get('') + return state && state.ssh ? { ssh: state.ssh, scope: '' } : 'pending' } + return null } function effectiveSshConfigFingerprint(sshConfig) { - const ssh = process.platform === 'win32' - ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe') - : 'ssh' + const ssh = + process.platform === 'win32' + ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe') + : 'ssh' + const args = ['-G'] - if (sshConfig.port) args.push('-p', String(sshConfig.port)) - if (sshConfig.keyPath) args.push('-i', sshConfig.keyPath) + + if (sshConfig.port) { + args.push('-p', String(sshConfig.port)) + } + + if (sshConfig.keyPath) { + args.push('-i', sshConfig.keyPath) + } + args.push('--', sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host) const output = execFileSync(ssh, args, { encoding: 'utf8', timeout: 10_000, windowsHide: true }) + return crypto.createHash('sha256').update(output).digest('hex') } @@ -6559,6 +6630,7 @@ async function bootstrapSshConnection(profile, sshConfig, reuseToken, source) { const effectiveConfigFingerprint = effectiveSshConfigFingerprint(sshConfig) const resolvedConfig = { ...sshConfig, effectiveConfigFingerprint } const fingerprint = sshConfigFingerprint(scope, resolvedConfig) + return sshBootstrapCoordinator.start(scope, fingerprint, lease => bootstrapSshConnectionInner(profile, resolvedConfig, reuseToken, source, fingerprint, lease) ) @@ -6568,18 +6640,28 @@ async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, sourc const scope = sshScopeKey(profile) const hostLabel = sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host const existing = sshConnections.get(scope) - if (existing && existing.fingerprint !== fingerprint) await teardownSshConnection(profile) + + if (existing && existing.fingerprint !== fingerprint) { + await teardownSshConnection(profile) + } let ssh = sshConnections.get(scope)?.ssh + if (ssh && !(await ssh.isAlive())) { try { await ssh.close() - } catch {} + } catch { + void 0 + } + ssh = null sshConnections.delete(scope) } + const created = !ssh + let removeForceCleanup = () => {} + if (created) { ssh = new SshConnection( { host: sshConfig.host, user: sshConfig.user, port: sshConfig.port, keyPath: sshConfig.keyPath }, @@ -6595,6 +6677,7 @@ async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, sourc } let result + try { const platform = await detectRemotePlatform(ssh, sshConfig.remoteHermesPath || '') const lifecycle = platform.os === 'Windows' ? connectWindowsRemote : remoteLifecycle.connect @@ -6615,8 +6698,13 @@ async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, sourc }) } catch (error: any) { if (created) { - try { await ssh.close() } catch {} + try { + await ssh.close() + } catch { + void 0 + } } + const err = new Error(error.message) as any err.sshError = error.kind || 'unknown' err.isSshBootstrap = true @@ -6629,7 +6717,10 @@ async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, sourc try { await ssh.cancelForward(result.localPort, result.remotePort) await ssh.close() - } catch {} + } catch { + void 0 + } + throw error } @@ -6655,8 +6746,15 @@ async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, sourc ) const connection = await buildRemoteConnection( - result.baseUrl, 'token', result.token, source, hostLabel, 'ssh', result.ownershipId + result.baseUrl, + 'token', + result.token, + source, + hostLabel, + 'ssh', + result.ownershipId ) + return { ...connection, remoteHermesVersion: result.hermesVersion || '' } } @@ -6664,8 +6762,10 @@ function persistSshConnectionToken(profile, source, token) { try { const config = readDesktopConnectionConfig() const encrypted = encryptDesktopSecret(token) + if (source === 'profile') { const key = connectionScopeKey(profile) + if (key && config.profiles?.[key]?.mode === 'ssh') { config.profiles[key].token = encrypted writeDesktopConnectionConfig(config) @@ -6693,8 +6793,10 @@ async function resolveRemoteBackend(profile) { // over the env override so an explicitly-configured profile always // reaches its intended backend. const sshOverride = profileSshOverride(config, profile) + if (sshOverride) { const reuseToken = decryptDesktopSecret(config.profiles?.[connectionScopeKey(profile)]?.token) + return bootstrapSshConnection(profile, sshOverride, reuseToken, 'profile') } @@ -6704,7 +6806,12 @@ async function resolveRemoteBackend(profile) { 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' + override.url, + override.authMode, + token, + 'profile', + undefined, + config.profiles?.[connectionScopeKey(profile)]?.mode === 'cloud' ? 'cloud' : 'url' ) } @@ -6726,8 +6833,13 @@ async function resolveRemoteBackend(profile) { // 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.') + + 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') } @@ -6740,7 +6852,12 @@ async function resolveRemoteBackend(profile) { const token = authMode === 'oauth' ? null : decryptDesktopSecret(config.remote?.token) return buildRemoteConnection( - config.remote?.url, authMode, token, 'settings', undefined, config.mode === 'cloud' ? 'cloud' : 'url' + config.remote?.url, + authMode, + token, + 'settings', + undefined, + config.mode === 'cloud' ? 'cloud' : 'url' ) } @@ -6768,6 +6885,7 @@ function globalRemoteActive() { } const mode = readDesktopConnectionConfig().mode + return modeIsRemoteLike(mode) || mode === 'ssh' } @@ -6865,19 +6983,29 @@ async function probeRemoteAuthMode(rawUrl) { async function testDesktopConnectionConfig(input: any = {}) { if (input.mode === 'ssh') { const sshConfig = normalizeSshConfig({ - mode: 'ssh', host: input.sshHost, user: input.sshUser, port: input.sshPort, - keyPath: input.sshKeyPath, remoteHermesPath: input.sshRemoteHermesPath + mode: 'ssh', + host: input.sshHost, + user: input.sshUser, + port: input.sshPort, + keyPath: input.sshKeyPath, + remoteHermesPath: input.sshRemoteHermesPath }) - if (!sshConfig) return { reachable: false, sshError: 'unreachable', error: 'SSH host is required.' } + + if (!sshConfig) { + return { reachable: false, sshError: 'unreachable', error: 'SSH host is required.' } + } + const ssh = createSshProbeConnection( { host: sshConfig.host, user: sshConfig.user, port: sshConfig.port, keyPath: sshConfig.keyPath }, { rememberLog: sshRememberLog } ) + try { // One bounded retry on TIMEOUT only: a cold Windows backend's first // PowerShell exec can exceed the budget (observed live), and a timeout is // indeterminate — unlike auth/host-key/unreachable, which are verdicts. let attempt = 0 + for (;;) { try { await ssh.open() @@ -6885,6 +7013,7 @@ async function testDesktopConnectionConfig(input: any = {}) { let hermesPath let hermesVersion let supported + if (platform.os === 'Windows') { const runtime = platform hermesPath = runtime.hermesPath @@ -6896,28 +7025,43 @@ async function testDesktopConnectionConfig(input: any = {}) { hermesVersion = await remoteLifecycle.probeHermesVersion(ssh, hermesPath) supported = await remoteLifecycle.remoteSupportsSshOwnership(ssh, hermesPath) } + if (!supported) { - return { reachable: false, sshError: 'update-required', error: 'Update Hermes on the remote host before connecting with Desktop SSH.' } + return { + reachable: false, + sshError: 'update-required', + error: 'Update Hermes on the remote host before connecting with Desktop SSH.' + } } + return { - reachable: true, sshError: null, error: null, + reachable: true, + sshError: null, + error: null, remotePlatform: `${platform.os}/${platform.arch}`, - remoteHermesPath: hermesPath, remoteHermesVersion: hermesVersion, + remoteHermesPath: hermesPath, + remoteHermesVersion: hermesVersion, host: sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host } } catch (error: any) { if (error?.kind === 'timeout' && attempt === 0) { attempt += 1 sshRememberLog('[ssh] test probe timed out once; retrying') + continue } + throw error } } } catch (error: any) { return { reachable: false, sshError: error.kind || 'unknown', error: error.message } } finally { - try { await ssh.close() } catch {} + try { + await ssh.close() + } catch { + void 0 + } } } @@ -8183,6 +8327,7 @@ ipcMain.handle('hermes:connection:revalidate', async () => { try { await fetchPublicJson(`${base}/api/status`, { timeoutMs: 10_000 }) revalidateTimeoutStreak = 0 + return { ok: true, rebuilt: false } } catch (error: any) { // A timeout is indeterminate (slow VM, idle tunnel re-establishing) — not @@ -8190,20 +8335,26 @@ ipcMain.handle('hermes:connection:revalidate', async () => { // bounded streak of timeouts, so one slow answer can't destroy a healthy // transport and cancel in-flight bootstraps. const refused = /ECONNREFUSED/i.test(String(error?.code || error?.cause?.code || error?.message || '')) + if (!refused) { revalidateTimeoutStreak += 1 + if (revalidateTimeoutStreak < 3) { rememberLog(`Remote liveness probe indeterminate (${revalidateTimeoutStreak}/3); keeping connection.`) + return { ok: true, rebuilt: false } } } + revalidateTimeoutStreak = 0 rememberLog('Cached remote Hermes backend failed liveness probe; dropping stale connection.') + if (conn.remoteKind === 'ssh') { const profile = primaryProfileKey() await sshBootstrapCoordinator.cancelAndWait(sshScopeKey(profile)) await teardownSshConnection(profile) } + resetHermesConnection() return { ok: true, rebuilt: true } @@ -8438,28 +8589,44 @@ ipcMain.handle('hermes:connection-config:get', async (_event, profile) => ipcMain.handle('hermes:ssh-config:hosts', async () => ({ hosts: collectSshConfigHosts() })) ipcMain.handle('hermes:ssh-config:resolve', async (_event, host) => { const value = String(host || '').trim() - if (!value) throw new Error('SSH host is required.') - const ssh = process.platform === 'win32' - ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe') - : 'ssh' + + if (!value) { + throw new Error('SSH host is required.') + } + + const ssh = + process.platform === 'win32' + ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe') + : 'ssh' + return new Promise((resolve, reject) => { const child = spawn(ssh, ['-G', '--', value], hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'pipe'] })) let stdout = '' let stderr = '' + const timer = setTimeout(() => { child.kill() reject(new Error('SSH config resolution timed out.')) }, 10_000) - child.stdout.on('data', chunk => { stdout += String(chunk) }) - child.stderr.on('data', chunk => { stderr += String(chunk) }) + + child.stdout.on('data', chunk => { + stdout += String(chunk) + }) + child.stderr.on('data', chunk => { + stderr += String(chunk) + }) child.once('error', error => { clearTimeout(timer) reject(error) }) child.once('close', code => { clearTimeout(timer) - if (code !== 0) reject(new Error(stderr.trim() || 'Could not resolve SSH host.')) - else resolve(parseSshGOutput(stdout)) + + if (code !== 0) { + reject(new Error(stderr.trim() || 'Could not resolve SSH host.')) + } else { + resolve(parseSshGOutput(stdout)) + } }) }) }) @@ -9565,9 +9732,12 @@ ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { const sshTarget = await resolveTerminalConnection(activeSshTerminalTarget, () => ensureBackend(primaryProfileKey())) const remote = Boolean(sshTarget) const remoteState = remote ? sshConnections.get(sshTarget.scope) : null - const remoteCommand = remoteState?.remotePlatform === 'Windows' - ? buildWindowsInteractiveCommand(String(payload?.cwd || '').trim()) - : undefined + + const remoteCommand = + remoteState?.remotePlatform === 'Windows' + ? buildWindowsInteractiveCommand(String(payload?.cwd || '').trim()) + : undefined + const ptyProcess = remote ? nodePty.spawn( process.platform === 'win32' @@ -10132,10 +10302,12 @@ app.on('before-quit', event => { event.preventDefault() sshBootstrapCoordinator.cancelAll() const scopes = [...sshConnections.keys()] + const pending = Promise.allSettled([ ...scopes.map(scope => teardownSshConnection(scope || null)), ...sshBootstrapCoordinator.promises() ]) + void Promise.race([pending, new Promise(resolve => setTimeout(resolve, 4_000))]).then(async () => { await sshBootstrapCoordinator.forceCleanupAll() sshQuitTeardownDone = true diff --git a/apps/desktop/electron/remote-lifecycle.test.ts b/apps/desktop/electron/remote-lifecycle.test.ts index 014d5692e250..b421b0ef0c31 100644 --- a/apps/desktop/electron/remote-lifecycle.test.ts +++ b/apps/desktop/electron/remote-lifecycle.test.ts @@ -1,28 +1,29 @@ import assert from 'node:assert/strict' + import { test } from 'vitest' import { - LOCKFILE_SCHEMA_VERSION, - PROTOCOL_VERSION, - READY_RE, buildSpawnCommand, cleanupStale, connect, expandRemotePath, fingerprintToken, - locateHermes, isForwardBindCollision, + locateHermes, + LOCKFILE_SCHEMA_VERSION, lockfilePath, openForward, ownershipDirectory, pidIsOurDashboard, probeRemotePlatform, + PROTOCOL_VERSION, readLockfile, + READY_RE, remotePidAlive, remoteSupportsSshOwnership, scrapeReadyPort, - spawnRemoteDashboard, spawnLogPath, + spawnRemoteDashboard, validateRemotePath, writeLockfile } from './remote-lifecycle' @@ -52,24 +53,31 @@ function ownedLock(over: any = {}) { // [regex|fn, response|fn] rules. First match wins; unmatched commands return ''. function fakeSsh(rules: any[] = []) { const calls: string[] = [] + return { calls, async exec(cmd) { calls.push(cmd) + for (const [matcher, resp] of rules) { const hit = typeof matcher === 'function' ? matcher(cmd) : matcher.test(cmd) + if (hit) { const out = typeof resp === 'function' ? resp(cmd) : resp - if (out instanceof Error) throw out + + if (out instanceof Error) { + throw out + } + return out } } + return '' } } } - test('locateHermes prefers the explicit profile path when executable', async () => { const ssh = fakeSsh([[/\[ -x .*\/opt\/hermes/, 'OK']]) assert.equal(await locateHermes(ssh, '/opt/hermes'), '/opt/hermes') @@ -82,11 +90,13 @@ test('locateHermes throws (no silent fallback) when an EXPLICIT path is not exec [/command -v hermes/, '/home/u/.local/bin/hermes\n'], [/\[ -x .*\.local\/bin\/hermes/, 'OK'] ]) + await assert.rejects( () => locateHermes(ssh, '/bad/path/hermes'), (err: any) => { assert.equal(err.kind, 'hermes-not-found') assert.match(err.message, /\/bad\/path\/hermes/) + return true } ) @@ -97,6 +107,7 @@ test('locateHermes falls back to the login-shell command -v probe', async () => [/command -v hermes/, '/home/u/.local/bin/hermes\n'], [/\[ -x .*\.local\/bin\/hermes/, 'OK'] ]) + assert.equal(await locateHermes(ssh, ''), '/home/u/.local/bin/hermes') }) @@ -106,6 +117,7 @@ test('locateHermes canonicalizes an installer wrapper to its executable target', [/\[ -x .*\.local\/bin\/hermes/, 'OK'], [/python3 -c/, '/home/u/.hermes/hermes-agent/venv/bin/hermes\n'] ]) + assert.equal(await locateHermes(ssh, ''), '/home/u/.hermes/hermes-agent/venv/bin/hermes') }) @@ -115,6 +127,7 @@ test('locateHermes falls back to ~/.local/bin/hermes when the login-shell probe [/command -v hermes/, ''], [/\[ -x .*\.local\/bin\/hermes/, 'OK'] ]) + assert.equal(await locateHermes(ssh, ''), '~/.local/bin/hermes') }) @@ -130,6 +143,7 @@ test('locateHermes throws a hermes-not-found error with an install hint', async (err: any) => { assert.equal(err.kind, 'hermes-not-found') assert.match(err.message, /install/i) + return true } ) @@ -140,10 +154,13 @@ test('locateHermes uses a login shell for the command -v probe', async () => { [/command -v hermes/, '/x/hermes'], [/\[ -x/, 'OK'] ]) - await locateHermes(ssh, '') - assert.ok(ssh.calls.some(c => /bash -lc/.test(c)), 'must probe in a login shell (PATH pitfall)') -}) + await locateHermes(ssh, '') + assert.ok( + ssh.calls.some(c => /bash -lc/.test(c)), + 'must probe in a login shell (PATH pitfall)' + ) +}) test('probeRemotePlatform accepts Linux and macOS', async () => { assert.deepEqual(await probeRemotePlatform(fakeSsh([[/uname/, 'Linux\nx86_64']])), { @@ -161,13 +178,12 @@ test('probeRemotePlatform rejects unsupported remote platforms', async () => { () => probeRemotePlatform(fakeSsh([[/uname/, 'MINGW64_NT\nx86_64']])), (err: any) => { assert.equal(err.kind, 'unsupported-platform') + return true } ) }) - - test('ownership paths are isolated by ownership ID and spawn nonce', () => { assert.equal(ownershipDirectory(OWNERSHIP_ID), `~/.hermes/desktop-ssh/${OWNERSHIP_ID}`) assert.equal(lockfilePath(OWNERSHIP_ID), `~/.hermes/desktop-ssh/${OWNERSHIP_ID}/backend.lock.json`) @@ -215,23 +231,40 @@ test('metadata and process proof transport failures remain indeterminate', async test('pidIsOurDashboard requires the exact serve ownership nonce', async () => { const ours = `/x/hermes serve --isolated --ssh-owner-nonce ${SPAWN_NONCE}` assert.equal(await pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, 'OWNED\n']]), 5, SPAWN_NONCE, '/x/hermes'), true) - assert.equal(await pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, command => command.includes('fedcba9876543210') ? 'FOREIGN\n' : 'OWNED\n']]), 5, 'fedcba9876543210', '/x/hermes'), false) + assert.equal( + await pidIsOurDashboard( + fakeSsh([[/print\("OWNED"/, command => (command.includes('fedcba9876543210') ? 'FOREIGN\n' : 'OWNED\n')]]), + 5, + 'fedcba9876543210', + '/x/hermes' + ), + false + ) assert.equal(await pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, 'FOREIGN\n']]), 5, SPAWN_NONCE, '/x/hermes'), false) }) test('cleanupStale kills ONLY a provably-ours pid, always drops the lockfile', async () => { const notOurs = fakeSsh([[/print\("OWNED"/, 'FOREIGN\n']]) - await cleanupStale(notOurs, OWNERSHIP_ID, { pid: 5, spawnNonce: SPAWN_NONCE, hermesPath: '/x/hermes', logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) }) + await cleanupStale(notOurs, OWNERSHIP_ID, { + pid: 5, + spawnNonce: SPAWN_NONCE, + hermesPath: '/x/hermes', + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) + }) assert.ok(!notOurs.calls.some(c => /kill 5\b/.test(c)), 'must not kill a pid that is not our dashboard') assert.ok(notOurs.calls.some(c => /rm -f/.test(c))) const ours = fakeSsh([[/print\("OWNED"/, 'OWNED\n']]) - await cleanupStale(ours, OWNERSHIP_ID, { pid: 9, spawnNonce: SPAWN_NONCE, hermesPath: '/x/hermes', logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) }) + await cleanupStale(ours, OWNERSHIP_ID, { + pid: 9, + spawnNonce: SPAWN_NONCE, + hermesPath: '/x/hermes', + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) + }) assert.ok(ours.calls.some(c => /kill 9\b/.test(c))) assert.ok(ours.calls.some(c => /rm -f/.test(c))) }) - test('buildSpawnCommand is headless serve, detached, token not in argv', () => { const cmd = buildSpawnCommand('/x/hermes', 'work', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) }) assert.match(cmd, /serve --isolated/) @@ -263,7 +296,14 @@ test('spawnRemoteDashboard returns exact ownership artifacts', async () => { [/printf '%s\\n'/, ''], [/setsid|nohup/, '4242\n'] ]) - const { pid, spawnNonce, logPath } = await spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tk', ownershipId: OWNERSHIP_ID }) + + const { pid, spawnNonce, logPath } = await spawnRemoteDashboard(ssh, { + hermesPath: '/x/hermes', + profile: '', + token: 'tk', + ownershipId: OWNERSHIP_ID + }) + assert.equal(pid, 4242) assert.match(spawnNonce, /^[0-9a-f]{16}$/) assert.equal(logPath, spawnLogPath(OWNERSHIP_ID, spawnNonce)) @@ -276,6 +316,7 @@ test('spawnRemoteDashboard always spawns serve (legacy dashboard path removed)', [/printf '%s\\n'/, ''], [/setsid|nohup/, '4242\n'] ]) + await spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tk', ownershipId: OWNERSHIP_ID }) const spawn = ssh.calls.find(c => /setsid|nohup/.test(c)) assert.match(spawn, /serve --isolated/) @@ -294,10 +335,12 @@ test('spawnRemoteDashboard rejects when no pid is returned', async () => { [/printf '%s\\n'/, ''], [/setsid|nohup/, 'not-a-pid'] ]) + await assert.rejects( () => spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 't', ownershipId: OWNERSHIP_ID }), (err: any) => { assert.equal(err.kind, 'spawn-failed') + return true } ) @@ -318,20 +361,25 @@ test('scrapeReadyPort times out and reports a dead spawn', async () => { () => scrapeReadyPort(ssh, spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), { timeoutMs: 60 }), (err: any) => { assert.equal(err.kind, 'ready-timeout') + return true } ) // dead process before announcement → spawn-failed await assert.rejects( - () => scrapeReadyPort(fakeSsh([[/cat/, '']]), spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), { timeoutMs: 1000, isAlive: async () => false }), + () => + scrapeReadyPort(fakeSsh([[/cat/, '']]), spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), { + timeoutMs: 1000, + isAlive: async () => false + }), (err: any) => { assert.equal(err.kind, 'spawn-failed') + return true } ) }) - function connectDeps(ssh, over: any = {}) { return { ssh, @@ -361,6 +409,7 @@ test('connect() spawns fresh when there is no lockfile, adopts the served token' [/kill -0 777/, 'ALIVE'], [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=51999\n'] ]) + const result = await connect(connectDeps(ssh, { adoptServedToken: async () => 'the-served-token' })) assert.equal(result.reused, false) assert.equal(result.remotePort, 51999) @@ -374,6 +423,7 @@ test('connect() spawns fresh when there is no lockfile, adopts the served token' test('connect() reuses a healthy dashboard when fingerprint + probe pass', async () => { const reuseToken = 'stored-token' const lock = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) }) + const ssh = fakeSsh([ [/uname/, 'Linux\nx86_64'], [/\[ -x/, 'OK'], @@ -381,6 +431,7 @@ test('connect() reuses a healthy dashboard when fingerprint + probe pass', async [/kill -0/, 'ALIVE'], [/print\("OWNED"/, 'OWNED\n'] ]) + const result = await connect(connectDeps(ssh, { reuseToken, adoptServedToken: async (_b, t) => t })) assert.equal(result.reused, true) assert.equal(result.pid, 333) @@ -392,6 +443,7 @@ test('connect() reuses a healthy dashboard when fingerprint + probe pass', async test('connect() respawns when the lockfile hermesPath differs from the resolved path', async () => { const reuseToken = 'stored-token' const lock = ownedLock({ hermesPath: '/old/stale/hermes', tokenFingerprint: fingerprintToken(reuseToken) }) + const ssh = fakeSsh([ [/uname/, 'Linux\nx86_64'], [/\[ -x/, 'OK'], @@ -404,13 +456,21 @@ test('connect() respawns when the lockfile hermesPath differs from the resolved [/setsid/, '890\n'], [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=52050\n'] ]) - const result = await connect(connectDeps(ssh, { reuseToken, remoteHermesPath: '/new/hermes', adoptServedToken: async () => 'fresh' })) + + const result = await connect( + connectDeps(ssh, { reuseToken, remoteHermesPath: '/new/hermes', adoptServedToken: async () => 'fresh' }) + ) + assert.equal(result.reused, false, 'must respawn, not reuse the old-path dashboard') - assert.ok(ssh.calls.some(c => /setsid/.test(c)), 'a fresh dashboard must be spawned') + assert.ok( + ssh.calls.some(c => /setsid/.test(c)), + 'a fresh dashboard must be spawned' + ) }) test('connect() respawns when the lockfile protocolVersion is incompatible', async () => { const reuseToken = 'stored-token' + const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, protocolVersion: PROTOCOL_VERSION + 99, @@ -418,6 +478,7 @@ test('connect() respawns when the lockfile protocolVersion is incompatible', asy port: 40000, tokenFingerprint: fingerprintToken(reuseToken) } + const ssh = fakeSsh([ [/uname/, 'Linux\nx86_64'], [/\[ -x/, 'OK'], @@ -430,6 +491,7 @@ test('connect() respawns when the lockfile protocolVersion is incompatible', asy [/kill -0 901/, 'ALIVE'], [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=44100\n'] ]) + const result = await connect(connectDeps(ssh, { reuseToken, adoptServedToken: async () => 'fresh' })) assert.equal(result.reused, false, 'incompatible protocol must force a fresh spawn, not a reattach') assert.equal(result.pid, 901) @@ -437,6 +499,7 @@ test('connect() respawns when the lockfile protocolVersion is incompatible', asy test('connect() fresh spawn writes hermesHome + protocolVersion into the lockfile', async () => { const writes: string[] = [] + const ssh = fakeSsh([ [/uname/, 'Linux\nx86_64'], [/\[ -x/, 'OK'], @@ -452,10 +515,12 @@ test('connect() fresh spawn writes hermesHome + protocolVersion into the lockfil /printf '%s' '/, c => { writes.push(c) + return '' } ] ]) + await connect(connectDeps(ssh, { adoptServedToken: async () => 'fresh' })) const lockWrite = writes.find(c => c.includes('schemaVersion')) || '' assert.match(lockWrite, new RegExp(`"protocolVersion":${PROTOCOL_VERSION}`)) @@ -464,6 +529,7 @@ test('connect() fresh spawn writes hermesHome + protocolVersion into the lockfil test('connect() respawns when the lockfile pid is dead (killed dashboard)', async () => { const lock = ownedLock({ tokenFingerprint: fingerprintToken('t') }) + const ssh = fakeSsh([ [/uname/, 'Linux\nx86_64'], [/\[ -x/, 'OK'], @@ -476,16 +542,20 @@ test('connect() respawns when the lockfile pid is dead (killed dashboard)', asyn [/kill -0 888/, 'ALIVE'], [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=42000\n'] ]) + const result = await connect(connectDeps(ssh, { reuseToken: 't', adoptServedToken: async () => 'fresh' })) assert.equal(result.reused, false) assert.equal(result.pid, 888) assert.equal(result.remotePort, 42000) - assert.ok(!ssh.calls.some(command => command.includes('pid=333') && command.includes('print("OWNED"')), - 'a dead pid has no process identity to verify') + assert.ok( + !ssh.calls.some(command => command.includes('pid=333') && command.includes('print("OWNED"')), + 'a dead pid has no process identity to verify' + ) }) test('connect() respawns when the dashboard is wedged (alive pid, probe fails)', async () => { const reuseToken = 'stored' + const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, protocolVersion: PROTOCOL_VERSION, @@ -493,6 +563,7 @@ test('connect() respawns when the dashboard is wedged (alive pid, probe fails)', port: 40000, tokenFingerprint: fingerprintToken(reuseToken) } + const ssh = fakeSsh([ [/uname/, 'Linux\nx86_64'], [/\[ -x/, 'OK'], @@ -505,9 +576,15 @@ test('connect() respawns when the dashboard is wedged (alive pid, probe fails)', [/kill -0 999/, 'ALIVE'], [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=43000\n'] ]) + const result = await connect( - connectDeps(ssh, { reuseToken, probeReuseProof: async () => 'authenticated-stale', adoptServedToken: async () => 'fresh' }) + connectDeps(ssh, { + reuseToken, + probeReuseProof: async () => 'authenticated-stale', + adoptServedToken: async () => 'fresh' + }) ) + assert.equal(result.reused, false) assert.equal(result.pid, 999) assert.equal(result.remotePort, 43000) @@ -519,6 +596,7 @@ test('connect() aborts on an unsupported remote platform before doing anything e () => connect(connectDeps(ssh)), (err: any) => { assert.equal(err.kind, 'unsupported-platform') + return true } ) @@ -528,13 +606,21 @@ test('connect() aborts on an unsupported remote platform before doing anything e test('openForward retries bind collisions only', async () => { const ports = [41001, 41002] const calls: number[] = [] - const localPort = await openForward({ - pickLocalPort: async () => ports.shift(), - forward: async port => { - calls.push(port) - if (calls.length === 1) throw new Error('bind: Address already in use') - } - }, 9119) + + const localPort = await openForward( + { + pickLocalPort: async () => ports.shift(), + forward: async port => { + calls.push(port) + + if (calls.length === 1) { + throw new Error('bind: Address already in use') + } + } + }, + 9119 + ) + assert.equal(localPort, 41002) assert.deepEqual(calls, [41001, 41002]) assert.equal(isForwardBindCollision(new Error('Permission denied')), false) @@ -543,6 +629,7 @@ test('openForward retries bind collisions only', async () => { test('connect() preserves an owned backend when a reuse transport throws', async () => { const reuseToken = 'stored-token' const lock = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) }) + const ssh = fakeSsh([ [/uname/, 'Linux\nx86_64'], [/\[ -x/, 'OK'], @@ -550,14 +637,22 @@ test('connect() preserves an owned backend when a reuse transport throws', async [/kill -0/, 'ALIVE'], [/print\("OWNED"/, 'OWNED\n'] ]) - await assert.rejects(() => connect(connectDeps(ssh, { - reuseToken, - forward: async () => { throw new Error('network reset') } - })), /network reset/) + + await assert.rejects( + () => + connect( + connectDeps(ssh, { + reuseToken, + forward: async () => { + throw new Error('network reset') + } + }) + ), + /network reset/ + ) assert.ok(!ssh.calls.some(cmd => /kill 333\b/.test(cmd))) }) - test('validateRemotePath accepts absolute POSIX paths', () => { assert.doesNotThrow(() => validateRemotePath('/usr/bin/hermes')) assert.doesNotThrow(() => validateRemotePath('/home/user/.hermes/hermes-agent/venv/bin/hermes')) @@ -624,6 +719,7 @@ test('buildSpawnCommand includes --ssh-session-token-file when tokenFilePath is logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), spawnNonce: SPAWN_NONCE }) + assert.match(cmd, /--ssh-session-token-file/) assert.match(cmd, /\.hermes\/desktop-ssh\//) }) @@ -638,11 +734,13 @@ test('buildSpawnCommand always uses serve, never dashboard', () => { test('spawnRemoteDashboard removes a token file when upload reporting fails', async () => { const failure = new Error('channel closed') + const ssh = fakeSsh([ [/grep -q ssh-session-token-file/, 'YES\n'], [command => /python3 -c/.test(command) && !/rm -f/.test(command), failure], [/rm -f/, ''] ]) + await assert.rejects( () => spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tok', ownershipId: OWNERSHIP_ID }), /channel closed/ @@ -653,24 +751,50 @@ test('spawnRemoteDashboard removes a token file when upload reporting fails', as test('spawnRemoteDashboard streams the token over stdin, not argv/env', async () => { const stdinCalls: string[] = [] const calls: string[] = [] + const ssh = { calls, async exec(cmd, opts?) { calls.push(cmd) - if (opts?.stdinData) stdinCalls.push(opts.stdinData) - if (/grep -q ssh-session-token-file/.test(cmd)) return 'YES\n' - if (/python3 -c/.test(cmd)) return '' - if (/setsid|nohup/.test(cmd)) return '4242\n' - if (/printf '%s\\n'/.test(cmd)) return '' + + if (opts?.stdinData) { + stdinCalls.push(opts.stdinData) + } + + if (/grep -q ssh-session-token-file/.test(cmd)) { + return 'YES\n' + } + + if (/python3 -c/.test(cmd)) { + return '' + } + + if (/setsid|nohup/.test(cmd)) { + return '4242\n' + } + + if (/printf '%s\\n'/.test(cmd)) { + return '' + } + return '' } } + const { pid } = await spawnRemoteDashboard(ssh as any, { - hermesPath: '/x/hermes', profile: '', token: 'secret_token_val', ownershipId: OWNERSHIP_ID + hermesPath: '/x/hermes', + profile: '', + token: 'secret_token_val', + ownershipId: OWNERSHIP_ID }) + assert.equal(pid, 4242) assert.ok(stdinCalls.length > 0, 'token must be sent via stdin') - assert.ok(stdinCalls.some(d => d === 'secret_token_val'), 'stdin must contain the token') + assert.ok( + stdinCalls.some(d => d === 'secret_token_val'), + 'stdin must contain the token' + ) + for (const cmd of calls) { assert.ok(!cmd.includes('secret_token_val'), `token leaked into command: ${cmd}`) } @@ -678,19 +802,37 @@ test('spawnRemoteDashboard streams the token over stdin, not argv/env', async () test('spawnRemoteDashboard upload uses exclusive-create and O_NOFOLLOW', async () => { const calls: string[] = [] + const ssh = { calls, async exec(cmd, opts?) { calls.push(cmd) - if (/grep -q ssh-session-token-file/.test(cmd)) return 'YES\n' - if (/python3 -c/.test(cmd)) return '' - if (/setsid|nohup/.test(cmd)) return '4242\n' - if (/printf '%s\\n'/.test(cmd)) return '' + + if (/grep -q ssh-session-token-file/.test(cmd)) { + return 'YES\n' + } + + if (/python3 -c/.test(cmd)) { + return '' + } + + if (/setsid|nohup/.test(cmd)) { + return '4242\n' + } + + if (/printf '%s\\n'/.test(cmd)) { + return '' + } + return '' } } + await spawnRemoteDashboard(ssh as any, { - hermesPath: '/x/hermes', profile: '', token: 'tk', ownershipId: OWNERSHIP_ID + hermesPath: '/x/hermes', + profile: '', + token: 'tk', + ownershipId: OWNERSHIP_ID }) const uploadCmd = calls.find(c => /python3 -c/.test(c)) assert.ok(uploadCmd, 'must use python3 -c for token upload') @@ -728,6 +870,7 @@ test('readLockfile accepts a complete owned lock', async () => { test('connect() reuse path does not write a token file', async () => { const reuseToken = 'stored-token' const lock = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) }) + const ssh = fakeSsh([ [/uname/, 'Linux\nx86_64'], [/\[ -x/, 'OK'], @@ -735,21 +878,21 @@ test('connect() reuse path does not write a token file', async () => { [/kill -0/, 'ALIVE'], [/print\("OWNED"/, 'OWNED\n'] ]) + const result = await connect(connectDeps(ssh, { reuseToken, adoptServedToken: async (_b, t) => t })) assert.equal(result.reused, true) - assert.ok(!ssh.calls.some(c => /sys\.stdin\.buffer\.read/.test(c)), - 'reuse must not upload a token file') + assert.ok(!ssh.calls.some(c => /sys\.stdin\.buffer\.read/.test(c)), 'reuse must not upload a token file') }) test('spawnRemoteDashboard fails with update-required when remote lacks --ssh-session-token-file', async () => { - const ssh = fakeSsh([ - [/--ssh-session-token-file/, 'NO\n'] - ]) + const ssh = fakeSsh([[/--ssh-session-token-file/, 'NO\n']]) + await assert.rejects( () => spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tk', ownershipId: OWNERSHIP_ID }), (err: any) => { assert.match(err.message, /update|upgrade/i) assert.equal(err.kind, 'update-required') + return true } ) @@ -784,6 +927,7 @@ test('connect removes the token file when a fresh backend fails after returning [/setsid/, '999\n'], [/kill -0 999/, 'DEAD'] ]) + await assert.rejects(() => connect(connectDeps(ssh)), /exited before announcing/i) assert.ok(ssh.calls.some(command => /rm -f .*\.token/.test(command))) }) @@ -791,6 +935,7 @@ test('connect removes the token file when a fresh backend fails after returning test('connect preserves an exact-owned backend when reuse proof transport fails', async () => { const reuseToken = 'stored-token' const lock = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) }) + const ssh = fakeSsh([ [/uname/, 'Linux\nx86_64'], [/\[ -x/, 'OK'], @@ -798,10 +943,19 @@ test('connect preserves an exact-owned backend when reuse proof transport fails' [/kill -0/, 'ALIVE'], [/print\("OWNED"/, 'OWNED\n'] ]) - await assert.rejects(() => connect(connectDeps(ssh, { - reuseToken, - probeReuseProof: async () => { throw new Error('connection reset') } - })), (error: any) => error.kind === 'transient-transport-error') + + await assert.rejects( + () => + connect( + connectDeps(ssh, { + reuseToken, + probeReuseProof: async () => { + throw new Error('connection reset') + } + }) + ), + (error: any) => error.kind === 'transient-transport-error' + ) assert.ok(!ssh.calls.some(command => /kill 333\b/.test(command))) assert.ok(!ssh.calls.some(command => /rm -f .*backend\.lock\.json/.test(command))) }) @@ -809,6 +963,7 @@ test('connect preserves an exact-owned backend when reuse proof transport fails' test('connect replaces an exact-owned backend only after authenticated stale proof', async () => { const reuseToken = 'stored-token' const lock = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) }) + const ssh = fakeSsh([ [/uname/, 'Linux\nx86_64'], [/\[ -x/, 'OK'], @@ -821,28 +976,38 @@ test('connect replaces an exact-owned backend only after authenticated stale pro [/kill -0 999/, 'ALIVE'], [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=43000\n'] ]) - const result = await connect(connectDeps(ssh, { - reuseToken, - probeReuseProof: async (_baseUrl, token, nonce) => { - assert.equal(token, reuseToken) - assert.equal(nonce, SPAWN_NONCE) - return 'authenticated-stale' - }, - adoptServedToken: async () => 'fresh' - })) + + const result = await connect( + connectDeps(ssh, { + reuseToken, + probeReuseProof: async (_baseUrl, token, nonce) => { + assert.equal(token, reuseToken) + assert.equal(nonce, SPAWN_NONCE) + + return 'authenticated-stale' + }, + adoptServedToken: async () => 'fresh' + }) + ) + assert.equal(result.reused, false) assert.ok(ssh.calls.some(command => /kill 333\b/.test(command))) }) test('remote SSH ownership capability requires both secure bootstrap flags', async () => { let helpProbe = '' - const supported = fakeSsh([[ - /serve --help/, - command => { - helpProbe = command - return 'YES\n' - } - ]]) + + const supported = fakeSsh([ + [ + /serve --help/, + command => { + helpProbe = command + + return 'YES\n' + } + ] + ]) + assert.equal(await remoteSupportsSshOwnership(supported, '/x/hermes'), true) assert.match(helpProbe, /ssh-session-token-file/) assert.match(helpProbe, /ssh-owner-nonce/) diff --git a/apps/desktop/electron/remote-lifecycle.ts b/apps/desktop/electron/remote-lifecycle.ts index 6f83bee12093..705c80000b92 100644 --- a/apps/desktop/electron/remote-lifecycle.ts +++ b/apps/desktop/electron/remote-lifecycle.ts @@ -45,18 +45,30 @@ function mintToken() { // Fingerprint a token for the lockfile — never store the raw secret on the // remote. SHA256, truncated. function fingerprintToken(token) { - return crypto.createHash('sha256').update(String(token || '')).digest('hex').slice(0, 32) + return crypto + .createHash('sha256') + .update(String(token || '')) + .digest('hex') + .slice(0, 32) } function validateOwnershipId(ownershipId) { const value = String(ownershipId || '') - if (!/^[0-9a-f]{32}$/.test(value)) throw new Error('SSH ownership ID is invalid.') + + if (!/^[0-9a-f]{32}$/.test(value)) { + throw new Error('SSH ownership ID is invalid.') + } + return value } function validateSpawnNonce(spawnNonce) { const value = String(spawnNonce || '') - if (!/^[0-9a-f]{16}$/.test(value)) throw new Error('SSH spawn nonce is invalid.') + + if (!/^[0-9a-f]{16}$/.test(value)) { + throw new Error('SSH spawn nonce is invalid.') + } + return value } @@ -79,16 +91,34 @@ function shq(value) { function validateRemotePath(p) { const s = String(p || '') - if (!s) throw new Error('Remote path must not be empty.') - if (/[\x00\n\r]/.test(s)) throw new Error('Unsafe remote path: contains NUL or newline.') - if (s === '~' || s.startsWith('~/') || s.startsWith('/')) return + + if (!s) { + throw new Error('Remote path must not be empty.') + } + + // eslint-disable-next-line no-control-regex -- deliberately reject NUL in remote paths + if (/[\x00\n\r]/.test(s)) { + throw new Error('Unsafe remote path: contains NUL or newline.') + } + + if (s === '~' || s.startsWith('~/') || s.startsWith('/')) { + return + } + throw new Error(`Remote path must be absolute or start with ~/: "${s}"`) } function expandRemotePath(p) { validateRemotePath(p) - if (p === '~') return '"$HOME"' - if (p.startsWith('~/')) return '"$HOME"' + shq(p.slice(1)) + + if (p === '~') { + return '"$HOME"' + } + + if (p.startsWith('~/')) { + return '"$HOME"' + shq(p.slice(1)) + } + return shq(p) } @@ -112,7 +142,9 @@ async function locateHermes(ssh, remoteHermesPath) { ' break\n' + 'except (OSError,ValueError):pass\n' + 'print(out)' + const resolved = (await ssh.exec(`python3 -c ${shq(script)}`)).trim() + return resolved || candidate } @@ -120,6 +152,7 @@ async function locateHermes(ssh, remoteHermesPath) { try { validateRemotePath(candidate) const ok = (await ssh.exec(`[ -x ${expandRemotePath(candidate)} ] && echo OK || true`)).trim() + return ok === 'OK' } catch { return false @@ -130,24 +163,29 @@ async function locateHermes(ssh, remoteHermesPath) { if (await isExecutable(remoteHermesPath)) { return resolveLauncher(remoteHermesPath) } + const err: any = new Error( `The Hermes path you set is not an executable on the remote host: "${remoteHermesPath}". ` + 'Check the path (it must be the full path to the `hermes` binary on the remote, e.g. ' + '~/hermes-agent/.venv/bin/hermes), or clear it to auto-detect.' ) + err.kind = 'hermes-not-found' throw err } const candidates: string[] = [] + try { const found = (await ssh.exec(`bash -lc ${shq('command -v hermes')}`)).trim() + if (found) { candidates.push(found.split('\n').pop().trim()) } } catch { // ignore } + // Fallback candidates when the login-shell probe misses: the installer's // command locations (scripts/install.sh) — per-user, root/FHS, legacy venv. candidates.push('~/.local/bin/hermes') @@ -155,7 +193,10 @@ async function locateHermes(ssh, remoteHermesPath) { candidates.push('~/.hermes/hermes-agent/venv/bin/hermes') for (const candidate of candidates) { - if (!candidate) continue + if (!candidate) { + continue + } + if (await isExecutable(candidate)) { return resolveLauncher(candidate) } @@ -166,6 +207,7 @@ async function locateHermes(ssh, remoteHermesPath) { 'Install it on the remote with: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh ' + '— or set the Hermes path explicitly in the SSH connection settings.' ) + err.kind = 'hermes-not-found' throw err } @@ -176,6 +218,7 @@ async function locateHermes(ssh, remoteHermesPath) { async function probeHermesVersion(ssh, hermesPath) { try { const out = (await ssh.exec(`${expandRemotePath(hermesPath)} --version 2>&1`)).trim() + return (out.split('\n')[0] || '').trim() } catch { return '' @@ -186,13 +229,16 @@ async function probeRemotePlatform(ssh) { const out = (await ssh.exec('uname -s; uname -m')).trim().split('\n') const osName = (out[0] || '').trim() const arch = (out[1] || '').trim() + if (!SUPPORTED_REMOTE_OS.has(osName)) { const err: any = new Error( `Unsupported remote platform "${osName || 'unknown'}". Hermes Desktop SSH mode supports Linux, macOS, and Windows remote hosts.` ) + err.kind = 'unsupported-platform' throw err } + return { os: osName, arch } } @@ -202,6 +248,7 @@ async function probeRemotePlatform(ssh) { async function probeRemoteHermesHome(ssh) { try { const out = (await ssh.exec('echo "${HERMES_HOME:-$HOME/.hermes}"')).trim().split('\n').pop() + return out || '~/.hermes' } catch (cause) { const error: any = new Error('Could not resolve the remote Hermes home.') @@ -214,6 +261,7 @@ async function probeRemoteHermesHome(ssh) { async function readLockfile(ssh, ownershipId) { const lpath = lockfilePath(ownershipId) let raw + try { raw = await ssh.exec(`if [ ! -e ${expandRemotePath(lpath)} ]; then exit 0; fi; cat ${expandRemotePath(lpath)}`) } catch (cause) { @@ -222,30 +270,60 @@ async function readLockfile(ssh, ownershipId) { error.cause = cause throw error } + const text = String(raw || '').trim() - if (!text) return null + + if (!text) { + return null + } + let parsed + try { parsed = JSON.parse(text) } catch { return null } + if (!parsed || parsed.schemaVersion !== LOCKFILE_SCHEMA_VERSION) { return null } + const pid = parsed.pid const port = parsed.port - if (!Number.isInteger(pid) || pid <= 0 || pid > 4194304) return null + + if (!Number.isInteger(pid) || pid <= 0 || pid > 4194304) { + return null + } + // port 0 = spawn-in-progress record (written before readiness); valid // ownership proof for cleanup, but never reusable. - if (!Number.isInteger(port) || port < 0 || port > 65535) return null - if (parsed.ownershipId !== ownershipId || !/^[0-9a-f]{16}$/.test(parsed.spawnNonce || '')) return null - if (!/^[0-9a-f]{32}$/.test(parsed.tokenFingerprint || '')) return null - if (parsed.protocolVersion !== PROTOCOL_VERSION) return null - if (parsed.logPath !== spawnLogPath(ownershipId, parsed.spawnNonce)) return null - for (const field of ['profile', 'hermesPath', 'hermesHome', 'logPath', 'startedAt']) { - if (typeof parsed[field] !== 'string' || parsed[field].length > 1024) return null + if (!Number.isInteger(port) || port < 0 || port > 65535) { + return null } + + if (parsed.ownershipId !== ownershipId || !/^[0-9a-f]{16}$/.test(parsed.spawnNonce || '')) { + return null + } + + if (!/^[0-9a-f]{32}$/.test(parsed.tokenFingerprint || '')) { + return null + } + + if (parsed.protocolVersion !== PROTOCOL_VERSION) { + return null + } + + if (parsed.logPath !== spawnLogPath(ownershipId, parsed.spawnNonce)) { + return null + } + + for (const field of ['profile', 'hermesPath', 'hermesHome', 'logPath', 'startedAt']) { + if (typeof parsed[field] !== 'string' || parsed[field].length > 1024) { + return null + } + } + return parsed } @@ -263,6 +341,7 @@ async function writeLockfile(ssh, ownershipId, lock) { async function removeLockfile(ssh, ownershipId) { const lpath = lockfilePath(ownershipId) + try { await ssh.exec(`rm -f ${expandRemotePath(lpath)}`) } catch { @@ -271,9 +350,13 @@ async function removeLockfile(ssh, ownershipId) { } async function remotePidAlive(ssh, pid) { - if (!pid || !Number.isInteger(Number(pid))) return false + if (!pid || !Number.isInteger(Number(pid))) { + return false + } + try { const out = (await ssh.exec(`kill -0 ${Number(pid)} 2>/dev/null && echo ALIVE || echo DEAD`)).trim() + return out === 'ALIVE' } catch (cause) { const error: any = new Error('Could not verify the SSH backend process.') @@ -286,7 +369,10 @@ async function remotePidAlive(ssh, pid) { // A pid is "provably ours" only if its remote cmdline carries our dashboard // args — never kill a pid we can't positively identify as our dashboard. async function pidIsOurDashboard(ssh, pid, spawnNonce, hermesPath = '') { - if (!pid || !/^[0-9a-f]{16}$/.test(String(spawnNonce || '')) || !hermesPath) return false + if (!pid || !/^[0-9a-f]{16}$/.test(String(spawnNonce || '')) || !hermesPath) { + return false + } + try { const script = 'import os,shlex,subprocess,sys\n' + @@ -308,7 +394,9 @@ async function pidIsOurDashboard(ssh, pid, spawnNonce, hermesPath = '') { ' ok=(direct or python_entry) and "--isolated" in args[serve+1:] and args[owner+1]==nonce\n' + 'except (ValueError,IndexError):pass\n' + 'print("OWNED" if ok else "FOREIGN")' + const out = await ssh.exec(`python3 -c ${shq(script)}`) + return String(out || '').trim() === 'OWNED' } catch (cause) { const error: any = new Error('Could not verify SSH backend process ownership.') @@ -320,13 +408,16 @@ async function pidIsOurDashboard(ssh, pid, spawnNonce, hermesPath = '') { // Kill the stale dashboard ONLY if provably ours, then drop the lockfile. async function cleanupStale(ssh, ownershipId, lock, pidAlive = true) { - if (pidAlive && lock && await pidIsOurDashboard(ssh, lock.pid, lock.spawnNonce, lock.hermesPath)) { + if (pidAlive && lock && (await pidIsOurDashboard(ssh, lock.pid, lock.spawnNonce, lock.hermesPath))) { try { - const result = (await ssh.exec( - `kill ${Number(lock.pid)} && ` + - `i=0; while kill -0 ${Number(lock.pid)} 2>/dev/null; do ` + - `i=$((i+1)); [ "$i" -ge 50 ] && exit 1; sleep 0.1; done` - )).trim() + const result = ( + await ssh.exec( + `kill ${Number(lock.pid)} && ` + + `i=0; while kill -0 ${Number(lock.pid)} 2>/dev/null; do ` + + `i=$((i+1)); [ "$i" -ge 50 ] && exit 1; sleep 0.1; done` + ) + ).trim() + void result } catch (cause) { const error: any = new Error('Could not terminate the stale SSH backend.') @@ -335,10 +426,17 @@ async function cleanupStale(ssh, ownershipId, lock, pidAlive = true) { throw error } } + const expectedLogPath = lock?.spawnNonce ? spawnLogPath(ownershipId, lock.spawnNonce) : '' + if (lock?.logPath === expectedLogPath) { - try { await ssh.exec(`rm -f ${expandRemotePath(lock.logPath)}`) } catch {} + try { + await ssh.exec(`rm -f ${expandRemotePath(lock.logPath)}`) + } catch { + void 0 + } } + await removeLockfile(ssh, ownershipId) } @@ -354,6 +452,7 @@ function buildSpawnCommand(hermesPath, profile, opts: any = {}) { const ownerArg = opts.spawnNonce ? ` --ssh-owner-nonce ${validateSpawnNonce(opts.spawnNonce)}` : '' const subCmd = `serve --isolated --host 127.0.0.1 --port 0${tokenArg}${ownerArg}` const dashCmd = `env HERMES_DESKTOP=1 ${hermes} ${profileArgs}${subCmd}` + return ( `mkdir -p "$(dirname ${logPath})" && ` + `"$(command -v setsid || echo nohup)" sh -c ${shq(`${dashCmd} > ${logPath} 2>&1 & echo $!`)}` @@ -362,36 +461,48 @@ function buildSpawnCommand(hermesPath, profile, opts: any = {}) { async function remoteSupportsSshOwnership(ssh, hermesPath) { const hermes = expandRemotePath(hermesPath) + const out = await ssh.exec( `help="$(${hermes} serve --help 2>&1)"; ` + `printf '%s' "$help" | grep -q ssh-session-token-file && ` + `printf '%s' "$help" | grep -q ssh-owner-nonce && echo YES || echo NO` ) - return String(out || '').trim().endsWith('YES') + + return String(out || '') + .trim() + .endsWith('YES') } async function scrapeReadyPort(ssh, logPath, { timeoutMs = DEFAULT_READY_TIMEOUT_MS, isAlive, signal }: any = {}) { const deadline = Date.now() + timeoutMs const remoteLog = expandRemotePath(logPath) + while (Date.now() < deadline) { assertNotAborted(signal) + if (isAlive && !(await isAlive())) { const err: any = new Error('Remote dashboard process exited before announcing its port.') err.kind = 'spawn-failed' throw err } + let tail + try { tail = await ssh.exec(`cat ${remoteLog} 2>/dev/null || true`) } catch { tail = '' } + const m = READY_RE.exec(String(tail || '')) + if (m) { return parseInt(m[1], 10) } + await new Promise(r => setTimeout(r, READY_POLL_INTERVAL_MS)) } + const err: any = new Error(`Timed out waiting for the remote dashboard to announce its port (${timeoutMs}ms).`) err.kind = 'ready-timeout' throw err @@ -403,6 +514,7 @@ async function spawnRemoteDashboard(ssh, { hermesPath, profile, token, ownership 'The remote Hermes install does not support --ssh-session-token-file and --ssh-owner-nonce. ' + 'Update Hermes on the remote host to continue using Desktop SSH mode.' ) + err.kind = 'update-required' throw err } @@ -441,36 +553,63 @@ async function spawnRemoteDashboard(ssh, { hermesPath, profile, token, ownership ' raise\n' + ' finally:os.close(fd)\n' + 'finally:os.close(dd)' + try { await ssh.exec(`python3 -c ${shq(tokenUploadPy)}`, { stdinData: token }) } catch (error) { - try { await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`) } catch {} + try { + await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`) + } catch { + void 0 + } + throw error } let out + try { - out = await ssh.exec( - buildSpawnCommand(hermesPath, profile, { spawnNonce, tokenFilePath, logPath }) - ) + out = await ssh.exec(buildSpawnCommand(hermesPath, profile, { spawnNonce, tokenFilePath, logPath })) } catch (error) { - try { await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`) } catch {} + try { + await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`) + } catch { + void 0 + } + throw error } - const pid = parseInt(String(out || '').trim().split('\n').pop(), 10) + + const pid = parseInt( + String(out || '') + .trim() + .split('\n') + .pop(), + 10 + ) + if (!Number.isInteger(pid) || pid <= 0) { - try { await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`) } catch {} + try { + await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`) + } catch { + void 0 + } + const err: any = new Error('Failed to launch the remote dashboard (no pid returned).') err.kind = 'spawn-failed' throw err } + return { pid, spawnNonce, logPath, tokenFilePath } } // Best-effort forward teardown when a reuse attempt fails mid-flight, so we // don't leak a forward before respawning. `deps.cancelForward` is optional. async function cancelForwardSafe(deps, localPort, remotePort) { - if (typeof deps.cancelForward !== 'function') return + if (typeof deps.cancelForward !== 'function') { + return + } + try { await deps.cancelForward(localPort, remotePort) } catch { @@ -492,16 +631,23 @@ function isForwardBindCollision(error) { async function openForward(deps, remotePort, attempts = 3) { let lastError + for (let attempt = 0; attempt < attempts; attempt++) { const localPort = await deps.pickLocalPort() + try { await deps.forward(localPort, remotePort) + return localPort } catch (error) { lastError = error - if (!isForwardBindCollision(error) || attempt === attempts - 1) throw error + + if (!isForwardBindCollision(error) || attempt === attempts - 1) { + throw error + } } } + throw lastError } @@ -516,11 +662,13 @@ async function adoptOwnedServedToken(adoptServedToken, baseUrl, expectedToken, s childAlive: () => true, label }) + if (!(await remotePidAlive(ssh, pid))) { const error: any = new Error(`${label} exited while its served token was being resolved.`) error.kind = token === expectedToken ? 'spawn-failed' : 'foreign-backend' throw error } + return token } @@ -548,23 +696,36 @@ async function connect(deps) { const hermesPath = await locateHermes(ssh, remoteHermesPath) log(`located hermes at ${hermesPath}`) const hermesVersion = await probeHermesVersion(ssh, hermesPath) - if (hermesVersion) log(`remote hermes version: ${hermesVersion}`) + + if (hermesVersion) { + log(`remote hermes version: ${hermesVersion}`) + } const reuseToken = deps.reuseToken || '' const hermesHome = await probeRemoteHermesHome(ssh) const lock = await readLockfile(ssh, ownershipId) + if (lock) { const pidAlive = await remotePidAlive(ssh, lock.pid) - const owned = pidAlive && await pidIsOurDashboard(ssh, lock.pid, lock.spawnNonce, lock.hermesPath) - const reusable = pidAlive && owned && lock.port > 0 && Boolean(reuseToken) && + const owned = pidAlive && (await pidIsOurDashboard(ssh, lock.pid, lock.spawnNonce, lock.hermesPath)) + + const reusable = + pidAlive && + owned && + lock.port > 0 && + Boolean(reuseToken) && lock.tokenFingerprint === fingerprintToken(reuseToken) && - lock.hermesPath === hermesPath && lock.hermesHome === hermesHome + lock.hermesPath === hermesPath && + lock.hermesHome === hermesHome + if (reusable) { assertNotAborted(signal) const localPort = await openForward(deps, lock.port) + try { const baseUrl = `http://127.0.0.1:${localPort}` let reuseClassification + try { reuseClassification = await probeReuseProof(baseUrl, reuseToken, lock.spawnNonce) } catch (cause) { @@ -573,16 +734,24 @@ async function connect(deps) { error.cause = cause throw error } + if (reuseClassification === 'authenticated-stale') { assertNotAborted(signal) await cancelForwardSafe(deps, localPort, lock.port) await cleanupStale(ssh, ownershipId, lock) } else if (reuseClassification === 'authenticated-ok') { const token = await adoptOwnedServedToken( - adoptServedToken, baseUrl, reuseToken, ssh, lock.pid, 'reused remote dashboard' + adoptServedToken, + baseUrl, + reuseToken, + ssh, + lock.pid, + 'reused remote dashboard' ) + assertNotAborted(signal) log(`reusing remote dashboard pid=${lock.pid} port=${lock.port}`) + return { baseUrl, token, @@ -615,12 +784,14 @@ async function connect(deps) { assertNotAborted(signal) const spawnToken = mintToken() + const { pid, spawnNonce, logPath, tokenFilePath } = await spawnRemoteDashboard(ssh, { hermesPath, profile, token: spawnToken, ownershipId }) + log(`spawned remote dashboard pid=${pid}`) const ownedSpawn = { @@ -636,8 +807,10 @@ async function connect(deps) { protocolVersion: PROTOCOL_VERSION, startedAt: new Date().toISOString() } + let localPort = 0 let remotePort = 0 + try { // Write the ownership record IMMEDIATELY (port=0): a supersede between // spawn and readiness whose cleanup cannot reach the box must not leave a @@ -659,9 +832,8 @@ async function connect(deps) { await waitForHermes(baseUrl, spawnToken) assertNotAborted(signal) - const token = await adoptOwnedServedToken( - adoptServedToken, baseUrl, spawnToken, ssh, pid, 'remote dashboard' - ) + const token = await adoptOwnedServedToken(adoptServedToken, baseUrl, spawnToken, ssh, pid, 'remote dashboard') + assertNotAborted(signal) const tokenFingerprint = fingerprintToken(token) await writeLockfile(ssh, ownershipId, { ...ownedSpawn, port: remotePort, tokenFingerprint }) @@ -683,44 +855,52 @@ async function connect(deps) { logPath } } catch (error) { - if (localPort && remotePort) await cancelForwardSafe(deps, localPort, remotePort) - try { await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`) } catch {} + if (localPort && remotePort) { + await cancelForwardSafe(deps, localPort, remotePort) + } + + try { + await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`) + } catch { + void 0 + } + await cleanupStale(ssh, ownershipId, ownedSpawn) throw error } } export { - DEFAULT_READY_TIMEOUT_MS, adoptOwnedServedToken, - LOCKFILE_SCHEMA_VERSION, - PROTOCOL_VERSION, - READY_RE, - REMOTE_LOCK_DIR, - SUPPORTED_REMOTE_OS, buildSpawnCommand, cleanupStale, connect, + DEFAULT_READY_TIMEOUT_MS, expandRemotePath, fingerprintToken, - locateHermes, - lockfilePath, - ownershipDirectory, - spawnLogPath, isForwardBindCollision, - openForward, + locateHermes, + LOCKFILE_SCHEMA_VERSION, + lockfilePath, mintToken, + openForward, + ownershipDirectory, pidIsOurDashboard, - probeRemotePlatform, probeHermesVersion, probeRemoteHermesHome, - remoteSupportsSshOwnership, + probeRemotePlatform, + PROTOCOL_VERSION, readLockfile, + READY_RE, + REMOTE_LOCK_DIR, remotePidAlive, + remoteSupportsSshOwnership, removeLockfile, scrapeReadyPort, shq, + spawnLogPath, spawnRemoteDashboard, + SUPPORTED_REMOTE_OS, validateRemotePath, writeLockfile } diff --git a/apps/desktop/electron/ssh-bootstrap-coordinator.test.ts b/apps/desktop/electron/ssh-bootstrap-coordinator.test.ts index 882852728059..cf5c24e97270 100644 --- a/apps/desktop/electron/ssh-bootstrap-coordinator.test.ts +++ b/apps/desktop/electron/ssh-bootstrap-coordinator.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' + import { test } from 'vitest' import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator' @@ -6,10 +7,12 @@ import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstra function deferred() { let resolve let reject + const promise = new Promise((ok, fail) => { resolve = ok reject = fail }) + return { promise, reject, resolve } } @@ -18,9 +21,18 @@ const config = { host: 'box', user: 'alice', port: 22, keyPath: '/key', remoteHe test('sshConfigFingerprint covers scope and every connection field', () => { const base = sshConfigFingerprint('', config) assert.equal(base, sshConfigFingerprint('', { ...config })) - for (const [field, value] of Object.entries({ host: 'other', user: 'bob', port: 2222, keyPath: '/other', remoteHermesPath: '/other-hermes', effectiveConfigFingerprint: 'changed-config' })) { + + for (const [field, value] of Object.entries({ + host: 'other', + user: 'bob', + port: 2222, + keyPath: '/other', + remoteHermesPath: '/other-hermes', + effectiveConfigFingerprint: 'changed-config' + })) { assert.notEqual(base, sshConfigFingerprint('', { ...config, [field]: value })) } + assert.notEqual(base, sshConfigFingerprint('profile', config)) }) @@ -28,14 +40,19 @@ test('same scope and fingerprint share one bootstrap', async () => { const coordinator = createBootstrapCoordinator() const gate = deferred() let runs = 0 + const first = coordinator.start('', 'same', async () => { runs++ + return gate.promise }) + const second = coordinator.start('', 'same', async () => { runs++ + return 'wrong' }) + assert.equal(first, second) gate.resolve('done') assert.equal(await second, 'done') @@ -47,6 +64,7 @@ test('changed fingerprint waits for old rollback before starting', async () => { const gate = deferred() const events: string[] = [] let oldLease + const oldPromise = coordinator.start('', 'old', async lease => { oldLease = lease events.push('old-start') @@ -54,12 +72,16 @@ test('changed fingerprint waits for old rollback before starting', async () => { events.push('old-rollback') lease.assertCurrent() }) + await Promise.resolve() + const newPromise = coordinator.start('', 'new', async lease => { events.push('new-start') lease.assertCurrent() + return 'new' }) + assert.equal(oldLease.signal.aborted, true) await Promise.resolve() assert.deepEqual(events, ['old-start']) @@ -73,10 +95,14 @@ test('forceCleanupAll runs registered pending resource cleanup', async () => { const coordinator = createBootstrapCoordinator() const gate = deferred() let cleaned = 0 + const promise = coordinator.start('', 'x', async lease => { - lease.onForceCleanup(async () => { cleaned++ }) + lease.onForceCleanup(async () => { + cleaned++ + }) await gate.promise }) + await Promise.resolve() await coordinator.forceCleanupAll() assert.equal(cleaned, 1) @@ -87,10 +113,14 @@ test('forceCleanupAll runs registered pending resource cleanup', async () => { test('cancelAll invalidates every pending scope and exposes promises for quit', async () => { const coordinator = createBootstrapCoordinator() const gates = [deferred(), deferred()] - const promises = gates.map((gate, index) => coordinator.start(String(index), 'x', async lease => { - await gate.promise - lease.assertCurrent() - })) + + const promises = gates.map((gate, index) => + coordinator.start(String(index), 'x', async lease => { + await gate.promise + lease.assertCurrent() + }) + ) + assert.equal(coordinator.promises().length, 2) coordinator.cancelAll() gates.forEach(gate => gate.resolve()) @@ -102,18 +132,26 @@ test('cancelAndWait drains only the requested scope', async () => { const coordinator = createBootstrapCoordinator() const firstGate = deferred() const secondGate = deferred() + const first = coordinator.start('first', 'x', async lease => { await firstGate.promise lease.assertCurrent() }) + const second = coordinator.start('second', 'x', async lease => { await secondGate.promise lease.assertCurrent() + return 'second' }) + await Promise.resolve() let drained = false - const drain = coordinator.cancelAndWait('first').then(() => { drained = true }) + + const drain = coordinator.cancelAndWait('first').then(() => { + drained = true + }) + await Promise.resolve() assert.equal(drained, false) firstGate.resolve() @@ -128,17 +166,22 @@ test('a generation started during cancelAndWait cannot run before the drain comp const coordinator = createBootstrapCoordinator() const oldGate = deferred() const events: string[] = [] + const old = coordinator.start('scope', 'old', async lease => { events.push('old-start') await oldGate.promise lease.assertCurrent() }) + await Promise.resolve() const drain = coordinator.cancelAndWait('scope') + const next = coordinator.start('scope', 'new', async () => { events.push('new-start') + return 'new' }) + await Promise.resolve() assert.deepEqual(events, ['old-start']) oldGate.resolve() diff --git a/apps/desktop/electron/ssh-bootstrap-coordinator.ts b/apps/desktop/electron/ssh-bootstrap-coordinator.ts index 0be1e350d519..827e9a4e7a18 100644 --- a/apps/desktop/electron/ssh-bootstrap-coordinator.ts +++ b/apps/desktop/electron/ssh-bootstrap-coordinator.ts @@ -1,8 +1,20 @@ import crypto from 'node:crypto' function sshConfigFingerprint(scope, config) { - const parts = [scope, config.host, config.user, config.port, config.keyPath, config.remoteHermesPath, config.effectiveConfigFingerprint] - return crypto.createHash('sha256').update(JSON.stringify(parts.map(value => value ?? ''))).digest('hex') + const parts = [ + scope, + config.host, + config.user, + config.port, + config.keyPath, + config.remoteHermesPath, + config.effectiveConfigFingerprint + ] + + return crypto + .createHash('sha256') + .update(JSON.stringify(parts.map(value => value ?? ''))) + .digest('hex') } function createBootstrapCoordinator() { @@ -13,17 +25,23 @@ function createBootstrapCoordinator() { function start(scope, fingerprint, run) { const current = pending.get(scope) - if (current?.fingerprint === fingerprint) return current.promise + + if (current?.fingerprint === fingerprint) { + return current.promise + } + current?.controller.abort() const generation = (generations.get(scope) || 0) + 1 generations.set(scope, generation) const controller = new AbortController() const forceCleanups = new Set<() => any>() + const lease = { signal: controller.signal, onForceCleanup(cleanup) { forceCleanups.add(cleanup) + return () => forceCleanups.delete(cleanup) }, isCurrent: () => !controller.signal.aborted && generations.get(scope) === generation, @@ -35,20 +53,30 @@ function createBootstrapCoordinator() { } } } + const drain = drains.get(scope) || Promise.resolve() const predecessor = current ? Promise.allSettled([current.promise, drain]) : drain const entry: any = { controller, fingerprint, forceCleanups, generation, promise: null, scope } - const promise = predecessor.then(() => { - lease.assertCurrent() - return run(lease) - }).finally(() => { - forceCleanups.clear() - active.delete(entry) - if (pending.get(scope)?.generation === generation) pending.delete(scope) - }) + + const promise = predecessor + .then(() => { + lease.assertCurrent() + + return run(lease) + }) + .finally(() => { + forceCleanups.clear() + active.delete(entry) + + if (pending.get(scope)?.generation === generation) { + pending.delete(scope) + } + }) + entry.promise = promise active.add(entry) pending.set(scope, entry) + return promise } @@ -58,20 +86,33 @@ function createBootstrapCoordinator() { async function cancelAndWait(scope) { let release - const barrier = new Promise(resolve => { release = resolve }) + + const barrier = new Promise(resolve => { + release = resolve + }) + drains.set(scope, barrier) const entries = [...active].filter(entry => entry.scope === scope) - for (const entry of entries) entry.controller.abort() + + for (const entry of entries) { + entry.controller.abort() + } + try { await Promise.allSettled(entries.map(entry => entry.promise)) } finally { - if (drains.get(scope) === barrier) drains.delete(scope) + if (drains.get(scope) === barrier) { + drains.delete(scope) + } + release() } } function cancelAll() { - for (const entry of active) entry.controller.abort() + for (const entry of active) { + entry.controller.abort() + } } async function forceCleanupAll() { diff --git a/apps/desktop/electron/ssh-config.test.ts b/apps/desktop/electron/ssh-config.test.ts index 50e9c67725c5..01eb89910558 100644 --- a/apps/desktop/electron/ssh-config.test.ts +++ b/apps/desktop/electron/ssh-config.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' + import { test } from 'vitest' import { collectSshConfigHosts, parseSshConfigHosts, parseSshConfigIncludes, parseSshGOutput } from './ssh-config' @@ -12,6 +13,7 @@ test('parseSshConfigHosts keeps literal aliases and drops wildcard/negated patte '# Host commented-out', 'host lower-case' ].join('\n') + assert.deepEqual(parseSshConfigHosts(cfg), ['devbox', 'prod', 'alpha', 'beta', 'lower-case']) }) @@ -31,10 +33,12 @@ test('collectSshConfigHosts follows Include directives (read-only)', () => { '/home/u/.ssh/nested': 'Host deep', '/home/u/abs_inc': 'Host home-abs' } + const hosts = collectSshConfigHosts('/home/u/.ssh/config', { homeDir: '/home/u', readFile: p => files[p] ?? null }) + assert.deepEqual(hosts.sort(), ['deep', 'home-abs', 'main', 'work-box'].sort()) }) @@ -47,10 +51,12 @@ test('collectSshConfigHosts does not loop on a self-include cycle', () => { '/home/u/.ssh/config': 'Host a\nInclude loop', '/home/u/.ssh/loop': 'Host b\nInclude config' // points back at config } + const hosts = collectSshConfigHosts('/home/u/.ssh/config', { homeDir: '/home/u', readFile: p => files[p] ?? null }) + assert.deepEqual(hosts.sort(), ['a', 'b']) }) @@ -60,12 +66,14 @@ test('collectSshConfigHosts expands globbed includes via injected globSync', () '/home/u/.ssh/config.d/10-work': 'Host work', '/home/u/.ssh/config.d/20-home': 'Host home' } + const hosts = collectSshConfigHosts('/home/u/.ssh/config', { homeDir: '/home/u', readFile: p => files[p] ?? null, globSync: pattern => pattern.endsWith('config.d/*') ? ['/home/u/.ssh/config.d/10-work', '/home/u/.ssh/config.d/20-home'] : [pattern] }) + assert.deepEqual(hosts.sort(), ['home', 'root', 'work'].sort()) }) @@ -78,6 +86,7 @@ test('parseSshGOutput pulls hostname/user/port/identityfile', () => { 'identityfile ~/.ssh/id_ed25519', 'forwardagent no' ].join('\n') + assert.deepEqual(parseSshGOutput(out), { hostname: '10.0.0.5', user: 'alice', diff --git a/apps/desktop/electron/ssh-config.ts b/apps/desktop/electron/ssh-config.ts index 6be33dc9aaa5..e22a6a9d83aa 100644 --- a/apps/desktop/electron/ssh-config.ts +++ b/apps/desktop/electron/ssh-config.ts @@ -14,35 +14,58 @@ import path from 'node:path' function parseSshConfigHosts(text) { const hosts: string[] = [] const seen = new Set() + for (const rawLine of String(text || '').split('\n')) { const line = rawLine.trim() - if (!line || line.startsWith('#')) continue + + if (!line || line.startsWith('#')) { + continue + } + const m = /^host\s+(.+)$/i.exec(line) - if (!m) continue + + if (!m) { + continue + } + for (const pattern of m[1].split(/\s+/)) { if (!pattern || pattern.includes('*') || pattern.includes('?') || pattern.startsWith('!')) { continue } + if (!seen.has(pattern)) { seen.add(pattern) hosts.push(pattern) } } } + return hosts } function parseSshConfigIncludes(text) { const includes: string[] = [] + for (const rawLine of String(text || '').split('\n')) { const line = rawLine.trim() - if (!line || line.startsWith('#')) continue + + if (!line || line.startsWith('#')) { + continue + } + const m = /^include\s+(.+)$/i.exec(line) - if (!m) continue + + if (!m) { + continue + } + for (const token of m[1].split(/\s+/)) { - if (token) includes.push(token) + if (token) { + includes.push(token) + } } } + return includes } @@ -56,6 +79,7 @@ function collectSshConfigHosts(rootPath = '', deps: any = {}) { return null } }) + const homeDir = deps.homeDir || os.homedir() const root = rootPath || path.join(homeDir, '.ssh', 'config') const sshDir = path.join(homeDir, '.ssh') @@ -65,25 +89,40 @@ function collectSshConfigHosts(rootPath = '', deps: any = {}) { const visited = new Set() const resolveIncludePath = token => { - if (token.startsWith('~/')) return path.join(homeDir, token.slice(2)) - if (path.isAbsolute(token)) return token + if (token.startsWith('~/')) { + return path.join(homeDir, token.slice(2)) + } + + if (path.isAbsolute(token)) { + return token + } + return path.join(sshDir, token) } const walk = (filePath, depth) => { - if (depth > 8 || visited.has(filePath)) return + if (depth > 8 || visited.has(filePath)) { + return + } + visited.add(filePath) const text = readFile(filePath) - if (text == null) return + + if (text == null) { + return + } + for (const host of parseSshConfigHosts(text)) { if (!seen.has(host)) { seen.add(host) out.push(host) } } + for (const token of parseSshConfigIncludes(text)) { const target = resolveIncludePath(token) const expanded = deps.globSync ? deps.globSync(target) : [target] + for (const p of expanded) { walk(p, depth + 1) } @@ -91,6 +130,7 @@ function collectSshConfigHosts(rootPath = '', deps: any = {}) { } walk(root, 0) + return out } @@ -101,18 +141,34 @@ function parseSshGOutput(text) { port: null, identityFile: null } + for (const rawLine of String(text || '').split('\n')) { const line = rawLine.trim() - if (!line) continue + + if (!line) { + continue + } + const sp = line.indexOf(' ') - if (sp === -1) continue + + if (sp === -1) { + continue + } + const key = line.slice(0, sp).toLowerCase() const value = line.slice(sp + 1).trim() - if (key === 'hostname' && !out.hostname) out.hostname = value - else if (key === 'user' && !out.user) out.user = value - else if (key === 'port' && !out.port) out.port = Number.parseInt(value, 10) || null - else if (key === 'identityfile' && !out.identityFile) out.identityFile = value + + if (key === 'hostname' && !out.hostname) { + out.hostname = value + } else if (key === 'user' && !out.user) { + out.user = value + } else if (key === 'port' && !out.port) { + out.port = Number.parseInt(value, 10) || null + } else if (key === 'identityfile' && !out.identityFile) { + out.identityFile = value + } } + return out } diff --git a/apps/desktop/electron/ssh-connection.test.ts b/apps/desktop/electron/ssh-connection.test.ts index 62cbc29b08d7..a3d6d731bd56 100644 --- a/apps/desktop/electron/ssh-connection.test.ts +++ b/apps/desktop/electron/ssh-connection.test.ts @@ -3,11 +3,10 @@ import { EventEmitter } from 'node:events' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' + import { test } from 'vitest' import { - SSH_ERROR, - SshConnection, baseSshOptions, buildControlArgs, buildExecArgs, @@ -20,13 +19,14 @@ import { hostArgs, redactSecrets, runSsh, - stopTunnelChild, + SSH_ERROR, + SshConnection, sshErrorMessage, + stopTunnelChild, target, validateSshTarget } from './ssh-connection' - test('redactSecrets scrubs the spawn-time session token env var', () => { const line = 'setsid env HERMES_DASHBOARD_SESSION_TOKEN=abc123deadbeef HERMES_DESKTOP=1 hermes dashboard' const out = redactSecrets(line) @@ -56,7 +56,6 @@ test('redactSecrets handles null/undefined and non-secret text untouched', () => assert.equal(redactSecrets('uname -s -m'), 'uname -s -m') }) - test('controlSocketPath is stable, short, and host-distinct', () => { const a = controlSocketPath('me', 'box1', 22, '/tmp/d') const a2 = controlSocketPath('me', 'box1', 22, '/tmp/d') @@ -81,7 +80,6 @@ test('controlSocketPath default base stays under sun_path even with the temp-lis assert.ok(!p.includes('/var/folders/'), 'default base must not be os.tmpdir() on macOS') }) - test('baseSshOptions carries the house ControlMaster/BatchMode/accept-new policy', () => { const opts = baseSshOptions('/tmp/x.sock', 15000) const joined = opts.join(' ') @@ -163,9 +161,11 @@ test('buildInteractiveSshArgs single-quotes a cwd with quotes safely', () => { assert.ok(args[args.length - 1].includes('exec "$SHELL" -l')) }) - test('classifySshError detects a changed host key (fail-closed)', () => { - assert.equal(classifySshError('@@@@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@@'), SSH_ERROR.HOST_KEY_CHANGED) + assert.equal( + classifySshError('@@@@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@@'), + SSH_ERROR.HOST_KEY_CHANGED + ) assert.equal(classifySshError('Host key verification failed.'), SSH_ERROR.HOST_KEY_CHANGED) assert.equal(classifySshError('Offending ECDSA key in /home/u/.ssh/known_hosts:5'), SSH_ERROR.HOST_KEY_CHANGED) }) @@ -186,27 +186,38 @@ test('sshErrorMessage gives actionable guidance for auth and host-key-change', ( assert.match(sshErrorMessage(SSH_ERROR.HOST_KEY_CHANGED, conn, 'CHANGED'), /ssh-keygen -R box/) }) - // A fake child process that emits a scripted result on next tick. function fakeChild({ code = 0, stdout = '', stderr = '', errorEvent = null, hang = false }: any = {}) { const child: any = new EventEmitter() child.stdout = new EventEmitter() child.stderr = new EventEmitter() + child.kill = () => { child._killed = true } + if (hang) { return child // never emits close → drives the timeout path } + process.nextTick(() => { if (errorEvent) { child.emit('error', errorEvent) + return } - if (stdout) child.stdout.emit('data', Buffer.from(stdout)) - if (stderr) child.stderr.emit('data', Buffer.from(stderr)) + + if (stdout) { + child.stdout.emit('data', Buffer.from(stdout)) + } + + if (stderr) { + child.stderr.emit('data', Buffer.from(stderr)) + } + child.emit('close', code) }) + return child } @@ -215,13 +226,17 @@ function fakeChild({ code = 0, stdout = '', stderr = '', errorEvent = null, hang function scriptedSpawn(scripts) { const calls: any[] = [] let i = 0 + const fn: any = (_cmd, args) => { calls.push(args) const script = typeof scripts === 'function' ? scripts(args, i) : scripts[Math.min(i, scripts.length - 1)] i += 1 + return fakeChild(script || {}) } + fn.calls = calls + return fn } @@ -229,11 +244,17 @@ test('open() establishes the master when not already alive', async () => { // `-O check` fails first (not alive) → master opens (code 0). Track which // ssh ops ran rather than re-probing with the same always-failing check. const ops: string[] = [] + const spawnFn = scriptedSpawn(args => { ops.push(args.includes('check') ? 'check' : args.includes('-M') ? 'master' : 'other') - if (args.includes('check')) return { code: 255, stderr: 'no control path' } + + if (args.includes('check')) { + return { code: 255, stderr: 'no control path' } + } + return { code: 0 } }) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) await conn.open() assert.deepEqual(ops, ['check', 'master'], 'probes liveness first, then opens the master') @@ -241,10 +262,13 @@ test('open() establishes the master when not already alive', async () => { test('open() is a no-op when the master is already alive and execs verify', async () => { const ops: string[] = [] + const spawnFn = scriptedSpawn(args => { ops.push(args.includes('check') ? 'check' : args.includes('exit 0') ? 'verify' : 'master') + return { code: 0 } // check succeeds → alive; verify exec succeeds → trusted }) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) await conn.open() assert.deepEqual(ops, ['check', 'verify'], 'alive master is exec-verified, then trusted without reopening') @@ -255,29 +279,57 @@ test('open() evicts a wedged master (check passes, exec hangs) and dials fresh', // every exec through it hangs. open() must verify, evict (-O exit), and // establish a fresh master instead of trusting the corpse. const ops: string[] = [] + const spawnFn = scriptedSpawn(args => { - if (args.includes('check')) { ops.push('check'); return { code: 0 } } - if (args.includes('exit 0')) { ops.push('verify'); return { hang: true } } - if (args.includes('-O')) { ops.push('evict'); return { code: 0 } } - ops.push('master'); return { code: 0 } + if (args.includes('check')) { + ops.push('check') + + return { code: 0 } + } + + if (args.includes('exit 0')) { + ops.push('verify') + + return { hang: true } + } + + if (args.includes('-O')) { + ops.push('evict') + + return { code: 0 } + } + + ops.push('master') + + return { code: 0 } }) - const conn = new SshConnection( - { host: 'box', user: 'me' }, - { spawnFn, controlDir: '/tmp/d', connectTimeoutMs: 50 } - ) + + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d', connectTimeoutMs: 50 }) + await conn.open() - assert.deepEqual(ops, ['check', 'verify', 'evict', 'master'], - 'wedged master: verified, evicted, then a fresh master is dialed') + assert.deepEqual( + ops, + ['check', 'verify', 'evict', 'master'], + 'wedged master: verified, evicted, then a fresh master is dialed' + ) }) test('close() removes the control socket when -O exit fails', async () => { const dir = path.join(os.tmpdir(), `hermes-ssh-close-${process.pid}-${Date.now()}`) fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) + const spawnFn = scriptedSpawn(args => { - if (args.includes('check')) return { code: 255 } // not alive → open dials master - if (args.includes('-M')) return { code: 0 } + if (args.includes('check')) { + return { code: 255 } + } // not alive → open dials master + + if (args.includes('-M')) { + return { code: 0 } + } + return { code: 255, stderr: 'mux: master gone' } // -O exit fails }) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir }) await conn.open() fs.writeFileSync(conn.controlPath, '') // simulate the lingering socket file @@ -291,6 +343,7 @@ test('open() creates the control-socket directory if it does not exist', async ( assert.ok(!fs.existsSync(dir), 'precondition: control dir absent') const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 })) const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir }) + try { await conn.open() assert.ok(fs.existsSync(dir), 'open() created the control-socket directory before spawning ssh') @@ -305,15 +358,20 @@ test('open() creates the control-socket directory if it does not exist', async ( test('open() surfaces a classified auth error', async () => { const spawnFn = scriptedSpawn(args => { - if (args.includes('check')) return { code: 255 } + if (args.includes('check')) { + return { code: 255 } + } + return { code: 255, stderr: 'Permission denied (publickey).' } }) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) await assert.rejects( () => conn.open(), (err: any) => { assert.equal(err.kind, SSH_ERROR.AUTH_FAILED) assert.match(err.message, /ssh-agent|ssh-add/) + return true } ) @@ -330,6 +388,7 @@ test('exec() returns stdout on success and rejects (classified) on failure', asy () => conn2.exec('uname -s'), (err: any) => { assert.equal(err.kind, SSH_ERROR.UNREACHABLE) + return true } ) @@ -342,6 +401,7 @@ test('exec() treats a hung ssh as a timeout (half-open connection)', async () => () => conn.exec('uname -s', { timeoutMs: 30 }), (err: any) => { assert.equal(err.kind, SSH_ERROR.TIMEOUT) + return true } ) @@ -360,23 +420,27 @@ test('forward() issues -O forward with a loopback-bound -L spec', async () => { test('lifecycle logging passes through redaction', async () => { const logs: string[] = [] const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 })) + const conn = new SshConnection( { host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d', rememberLog: l => logs.push(l) } ) + await conn.open() + // none of the emitted log lines may carry a raw token-shaped secret for (const line of logs) { assert.ok(!/token=[^<]/.test(line)) } + assert.ok(logs.some(l => l.includes('[ssh]'))) }) - test('no-mux: ssh args carry no ControlMaster/ControlPath options', async () => { const spawnFn = scriptedSpawn({ code: 0 }) const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false }) await conn.open() + for (const args of spawnFn.calls) { assert.ok(!args.some(a => /ControlMaster|ControlPath|ControlPersist/.test(a)), `mux option leaked: ${args}`) } @@ -387,7 +451,10 @@ test('no-mux: open() verifies auth with a one-shot exec, no -M master', async () const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false }) await conn.open() assert.ok(!spawnFn.calls.some(args => args.includes('-M')), 'no master should be spawned') - assert.ok(spawnFn.calls.some(args => args[args.length - 1] === 'exit 0'), 'liveness/openness via one-shot exec') + assert.ok( + spawnFn.calls.some(args => args[args.length - 1] === 'exit 0'), + 'liveness/openness via one-shot exec' + ) }) test('SSH probe never creates or closes a ControlMaster', async () => { @@ -414,26 +481,37 @@ test('no-mux: forward spawns a persistent -N -L child; cancel + close kill it', await new Promise(r => srv.listen(0, '127.0.0.1', () => r())) const localPort = (srv.address() as any).port const tunnels: any[] = [] + const spawnFn: any = (_cmd, args) => { const child: any = new EventEmitter() child.stderr = new EventEmitter() child.exitCode = null + child.kill = () => { child._killed = true child.exitCode = 0 process.nextTick(() => child.emit('exit', 0)) + return true } + if (args.includes('-N')) { tunnels.push({ args, child }) - process.nextTick(() => child.stderr.emit('data', Buffer.from(`Local forwarding listening on 127.0.0.1 port ${localPort}.`))) - } else process.nextTick(() => child.emit('close', 0)) + process.nextTick(() => + child.stderr.emit('data', Buffer.from(`Local forwarding listening on 127.0.0.1 port ${localPort}.`)) + ) + } else { + process.nextTick(() => child.emit('close', 0)) + } + if (!args.includes('-N')) { child.stdout = new EventEmitter() process.nextTick(() => child.emit('close', 0)) } + return child } + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false }) await conn.forward(localPort, 9119) assert.equal(tunnels.length, 1, 'one persistent tunnel child') @@ -453,15 +531,19 @@ test('no-mux: forward fails fast when the tunnel child dies (bad spec/auth)', as const child: any = new EventEmitter() child.stderr = new EventEmitter() child.exitCode = null + child.kill = () => {} + if (args.includes('-N')) { process.nextTick(() => { child.stderr.emit('data', Buffer.from('Permission denied (publickey).')) child.exitCode = 255 }) } + return child } + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false, forwardTimeoutMs: 2000 }) await assert.rejects(conn.forward(1, 9119), (err: any) => err.kind === 'auth-failed') }) @@ -471,11 +553,14 @@ test('no-mux: an unrelated listener cannot mask a delayed bind failure', async ( const srv = net.createServer() await new Promise(resolve => srv.listen(0, '127.0.0.1', resolve)) const localPort = (srv.address() as any).port + const spawnFn: any = (_cmd, args) => { const child: any = new EventEmitter() child.stderr = new EventEmitter() child.exitCode = null + child.kill = () => {} + if (args.includes('-N')) { setTimeout(() => { child.stderr.emit('data', Buffer.from(`bind [127.0.0.1]:${localPort}: Address already in use`)) @@ -483,8 +568,10 @@ test('no-mux: an unrelated listener cannot mask a delayed bind failure', async ( child.emit('exit', 255) }, 20) } + return child } + const conn = new SshConnection({ host: 'box' }, { spawnFn, mux: false, forwardTimeoutMs: 1000 }) await assert.rejects(conn.forward(localPort, 9119), /address already in use/i) srv.close() @@ -496,18 +583,27 @@ test('no-mux: tunnel death after readiness makes the connection unhealthy', asyn await new Promise(resolve => srv.listen(0, '127.0.0.1', resolve)) const localPort = (srv.address() as any).port let tunnel + const spawnFn: any = (_cmd, args) => { const child: any = new EventEmitter() child.stdout = new EventEmitter() child.stderr = new EventEmitter() child.exitCode = null + child.kill = () => {} + if (args.includes('-N')) { tunnel = child - process.nextTick(() => child.stderr.emit('data', Buffer.from(`Local forwarding listening on 127.0.0.1 port ${localPort}.`))) - } else process.nextTick(() => child.emit('close', 0)) + process.nextTick(() => + child.stderr.emit('data', Buffer.from(`Local forwarding listening on 127.0.0.1 port ${localPort}.`)) + ) + } else { + process.nextTick(() => child.emit('close', 0)) + } + return child } + const conn = new SshConnection({ host: 'box' }, { spawnFn, mux: false }) await conn.open() await conn.forward(localPort, 9119) @@ -516,7 +612,6 @@ test('no-mux: tunnel death after readiness makes the connection unhealthy', asyn srv.close() }) - test('validateSshTarget rejects a host starting with a dash (option injection)', () => { assert.throws(() => validateSshTarget('-oProxyCommand=evil', '', 22), /unsafe/i) assert.throws(() => validateSshTarget('--version', '', 22), /unsafe/i) @@ -600,18 +695,24 @@ test('hostArgs accepts valid key paths', () => { test('runSsh delivers stdinData to the child and does not log it', async () => { let stdinWritten = '' + const spawnFn: any = (_cmd, _args, opts) => { const child: any = new EventEmitter() child.stdout = new EventEmitter() child.stderr = new EventEmitter() + child.kill = () => {} child.stdin = { - end(data) { stdinWritten = String(data) } + end(data) { + stdinWritten = String(data) + } } assert.equal(opts.stdio[0], 'pipe', 'stdin must be pipe when stdinData is provided') process.nextTick(() => child.emit('close', 0)) + return child } + await runSsh(['host', 'cat'], { timeoutMs: 5000, spawnFn, stdinData: 'secret-token-value' }) assert.equal(stdinWritten, 'secret-token-value', 'stdinData must be written to child.stdin') }) @@ -629,7 +730,10 @@ test('open() rejects a control-dir that is a symlink', async () => { }) test('open() enforces 0700 on an existing control dir with lax permissions', async () => { - if (process.platform === 'win32') return + if (process.platform === 'win32') { + return + } + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-test-')) const dir = path.join(tmp, 'ctrl') fs.mkdirSync(dir, { mode: 0o755 }) @@ -647,49 +751,74 @@ test('control socket identity separates installation scope and key identity', () scope: 'primary', keyPath: '/keys/id' }) - assert.equal(base, controlSocketPath('me', 'box', 22, '/tmp/d', { - ownershipId: 'installation-a', - scope: 'primary', - keyPath: '/keys/./id' - })) - assert.notEqual(base, controlSocketPath('me', 'box', 22, '/tmp/d', { - ownershipId: 'installation-a', - scope: 'worker', - keyPath: '/keys/id' - })) - assert.notEqual(base, controlSocketPath('me', 'box', 22, '/tmp/d', { - ownershipId: 'installation-b', - scope: 'primary', - keyPath: '/keys/id' - })) - assert.notEqual(base, controlSocketPath('me', 'box', 22, '/tmp/d', { - ownershipId: 'installation-a', - scope: 'primary', - keyPath: '/keys/other' - })) - assert.notEqual(base, controlSocketPath('me', 'box', 22, '/tmp/d', { - ownershipId: 'installation-a', - scope: 'primary', - keyPath: '/keys/id', - effectiveConfigFingerprint: 'changed-config' - })) + + assert.equal( + base, + controlSocketPath('me', 'box', 22, '/tmp/d', { + ownershipId: 'installation-a', + scope: 'primary', + keyPath: '/keys/./id' + }) + ) + assert.notEqual( + base, + controlSocketPath('me', 'box', 22, '/tmp/d', { + ownershipId: 'installation-a', + scope: 'worker', + keyPath: '/keys/id' + }) + ) + assert.notEqual( + base, + controlSocketPath('me', 'box', 22, '/tmp/d', { + ownershipId: 'installation-b', + scope: 'primary', + keyPath: '/keys/id' + }) + ) + assert.notEqual( + base, + controlSocketPath('me', 'box', 22, '/tmp/d', { + ownershipId: 'installation-a', + scope: 'primary', + keyPath: '/keys/other' + }) + ) + assert.notEqual( + base, + controlSocketPath('me', 'box', 22, '/tmp/d', { + ownershipId: 'installation-a', + scope: 'primary', + keyPath: '/keys/id', + effectiveConfigFingerprint: 'changed-config' + }) + ) }) test('closing one scope addresses only that scope control master', async () => { const firstSpawn = scriptedSpawn({ code: 0 }) const secondSpawn = scriptedSpawn({ code: 0 }) - const first = new SshConnection({ host: 'box', user: 'me' }, { - spawnFn: firstSpawn, - controlDir: '/tmp/d', - ownershipId: 'installation', - scope: 'first' - }) - const second = new SshConnection({ host: 'box', user: 'me' }, { - spawnFn: secondSpawn, - controlDir: '/tmp/d', - ownershipId: 'installation', - scope: 'second' - }) + + const first = new SshConnection( + { host: 'box', user: 'me' }, + { + spawnFn: firstSpawn, + controlDir: '/tmp/d', + ownershipId: 'installation', + scope: 'first' + } + ) + + const second = new SshConnection( + { host: 'box', user: 'me' }, + { + spawnFn: secondSpawn, + controlDir: '/tmp/d', + ownershipId: 'installation', + scope: 'second' + } + ) + first._opened = true second._opened = true await first.close() @@ -715,15 +844,22 @@ test('failed ControlMaster close disowns the master instead of retrying it', asy test('stopTunnelChild waits for process exit', async () => { const child: any = new EventEmitter() child.exitCode = null + child.kill = () => { process.nextTick(() => { child.exitCode = 0 child.emit('exit', 0) }) + return true } + let stopped = false - const stopping = stopTunnelChild(child).then(() => { stopped = true }) + + const stopping = stopTunnelChild(child).then(() => { + stopped = true + }) + assert.equal(stopped, false) await stopping assert.equal(stopped, true) diff --git a/apps/desktop/electron/ssh-connection.ts b/apps/desktop/electron/ssh-connection.ts index d120ada74fab..9b535c69c5b5 100644 --- a/apps/desktop/electron/ssh-connection.ts +++ b/apps/desktop/electron/ssh-connection.ts @@ -41,35 +41,46 @@ const DEFAULT_EXEC_TIMEOUT_MS = 20_000 const DEFAULT_FORWARD_TIMEOUT_MS = 15_000 const CONTROL_PERSIST_SECONDS = 300 +// eslint-disable-next-line no-control-regex -- deliberately reject control chars in ssh targets const _CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/ function validateSshTarget(host, user, port) { if (!host || typeof host !== 'string') { throw new Error('Unsafe SSH target: host is required.') } + if (host.startsWith('-')) { throw new Error(`Unsafe SSH target: host must not start with a dash ("${host}").`) } + if (_CONTROL_CHAR_RE.test(host)) { throw new Error('Unsafe SSH target: host contains control characters.') } + if (user && _CONTROL_CHAR_RE.test(user)) { throw new Error('Unsafe SSH target: user contains control characters.') } + if (user && user.startsWith('-')) { throw new Error(`Unsafe SSH target: user must not start with a dash ("${user}").`) } + const p = Number(port) + if (!Number.isInteger(p) || p < 1 || p > 65535) { throw new Error(`Unsafe SSH port: ${port} (must be 1-65535).`) } } function validateKeyPath(keyPath) { - if (!keyPath) return + if (!keyPath) { + return + } + if (_CONTROL_CHAR_RE.test(keyPath)) { throw new Error('Unsafe SSH key path: contains control characters.') } + if (keyPath.startsWith('-')) { throw new Error(`Unsafe SSH key path: must not start with a dash ("${keyPath}").`) } @@ -86,9 +97,11 @@ const _REDACTIONS: Array<[RegExp, string]> = [ function redactSecrets(text) { let out = String(text == null ? '' : text) + for (const [re, repl] of _REDACTIONS) { out = out.replace(re, repl) } + return out } @@ -107,8 +120,19 @@ function redactSecrets(text) { function controlSocketPath(user, host, port, baseDir?, identity: any = {}) { const dir = baseDir || defaultControlDir() const keyPathIdentity = path.normalize(String(identity.keyPath || '')) - const parts = [identity.ownershipId || '', identity.scope || '', user || '', host, Number(port), keyPathIdentity, identity.effectiveConfigFingerprint || ''] + + const parts = [ + identity.ownershipId || '', + identity.scope || '', + user || '', + host, + Number(port), + keyPathIdentity, + identity.effectiveConfigFingerprint || '' + ] + const id = crypto.createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16) + return path.join(dir, `${id}.sock`) } @@ -118,6 +142,7 @@ function defaultControlDir() { if (process.platform === 'win32') { return path.join(os.tmpdir(), 'hermes-desktop-ssh') } + return path.join(os.homedir(), '.hermes', 'desktop-ssh') } @@ -128,28 +153,44 @@ function defaultControlDir() { // per-invocation options — each ssh call authenticates on its own. function baseSshOptions(controlPath, connectTimeoutMs?) { const connectSecs = Math.max(1, Math.round((connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS) / 1000)) + const mux = controlPath - ? ['-o', `ControlPath=${controlPath}`, '-o', 'ControlMaster=auto', '-o', `ControlPersist=${CONTROL_PERSIST_SECONDS}`] + ? [ + '-o', + `ControlPath=${controlPath}`, + '-o', + 'ControlMaster=auto', + '-o', + `ControlPersist=${CONTROL_PERSIST_SECONDS}` + ] : [] + return [ ...mux, - '-o', 'BatchMode=yes', - '-o', 'StrictHostKeyChecking=accept-new', - '-o', 'ExitOnForwardFailure=yes', - '-o', `ConnectTimeout=${connectSecs}` + '-o', + 'BatchMode=yes', + '-o', + 'StrictHostKeyChecking=accept-new', + '-o', + 'ExitOnForwardFailure=yes', + '-o', + `ConnectTimeout=${connectSecs}` ] } // Non-default port and explicit identity file, shared by exec/master/forward. function hostArgs({ port, keyPath }: { port?: number | string; keyPath?: string } = {}) { const args: string[] = [] + if (port && Number(port) !== 22) { args.push('-p', String(port)) } + if (keyPath) { validateKeyPath(keyPath) args.push('-i', keyPath) } + return args } @@ -158,18 +199,40 @@ function target(user, host) { } function buildExecArgs(conn, remoteCommand, connectTimeoutMs?) { - return [...baseSshOptions(conn.controlPath, connectTimeoutMs), ...hostArgs(conn), '--', target(conn.user, conn.host), remoteCommand] + return [ + ...baseSshOptions(conn.controlPath, connectTimeoutMs), + ...hostArgs(conn), + '--', + target(conn.user, conn.host), + remoteCommand + ] } function buildControlArgs(conn, op, extra: string[] = [], connectTimeoutMs?) { - return ['-O', op, ...extra, ...baseSshOptions(conn.controlPath, connectTimeoutMs), ...hostArgs(conn), '--', target(conn.user, conn.host)] + return [ + '-O', + op, + ...extra, + ...baseSshOptions(conn.controlPath, connectTimeoutMs), + ...hostArgs(conn), + '--', + target(conn.user, conn.host) + ] } // Open the master explicitly: `-M -N -f` backgrounds ssh once the master is up, // so the spawn resolves when the connection is established (or fails fast under // BatchMode if auth is non-interactive-only). function buildMasterArgs(conn, connectTimeoutMs?) { - return ['-M', '-N', '-f', ...baseSshOptions(conn.controlPath, connectTimeoutMs), ...hostArgs(conn), '--', target(conn.user, conn.host)] + return [ + '-M', + '-N', + '-f', + ...baseSshOptions(conn.controlPath, connectTimeoutMs), + ...hostArgs(conn), + '--', + target(conn.user, conn.host) + ] } // Interactive `ssh -tt` for the INTERIM remote terminal (SSH mode only). Reuses @@ -179,18 +242,29 @@ function buildMasterArgs(conn, connectTimeoutMs?) { // NOTE(remote-terminal): interim until the dashboard /api/terminal WebSocket // lands (specs/desktop-remote-terminal.md); delete this path then. function buildInteractiveSshArgs(conn, remoteCwd, connectTimeoutMs?, remoteCommand?) { - const args = ['-tt', ...baseSshOptions(conn.controlPath, connectTimeoutMs), ...hostArgs(conn), '--', target(conn.user, conn.host)] + const args = [ + '-tt', + ...baseSshOptions(conn.controlPath, connectTimeoutMs), + ...hostArgs(conn), + '--', + target(conn.user, conn.host) + ] + if (remoteCommand) { args.push(remoteCommand) + return args } + const cwd = String(remoteCwd || '').trim() + if (cwd) { const q = `'${cwd.replace(/'/g, `'\\''`)}'` args.push(`cd ${q} 2>/dev/null; exec "$SHELL" -l`) } else { args.push('exec "$SHELL" -l') } + return args } @@ -214,20 +288,37 @@ const SSH_ERROR = { // so check it before generic auth. function classifySshError(stderr) { const text = String(stderr || '') - if (/REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed|Offending (?:key|ECDSA|RSA|ED25519)/i.test(text)) { + + if ( + /REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed|Offending (?:key|ECDSA|RSA|ED25519)/i.test( + text + ) + ) { return SSH_ERROR.HOST_KEY_CHANGED } - if (/Permission denied|Too many authentication failures|no matching host key|publickey|password|keyboard-interactive/i.test(text)) { + + if ( + /Permission denied|Too many authentication failures|no matching host key|publickey|password|keyboard-interactive/i.test( + text + ) + ) { return SSH_ERROR.AUTH_FAILED } - if (/Could not resolve hostname|Connection refused|Connection timed out|No route to host|Network is unreachable|Operation timed out|port \d+: Connection/i.test(text)) { + + if ( + /Could not resolve hostname|Connection refused|Connection timed out|No route to host|Network is unreachable|Operation timed out|port \d+: Connection/i.test( + text + ) + ) { return SSH_ERROR.UNREACHABLE } + return SSH_ERROR.UNKNOWN } function sshErrorMessage(kind, conn, stderr?) { const host = target(conn.user, conn.host) + switch (kind) { case SSH_ERROR.HOST_KEY_CHANGED: return ( @@ -236,6 +327,7 @@ function sshErrorMessage(kind, conn, stderr?) { `SSH refused to connect. Verify the change is expected, then remove the old key ` + `with \`ssh-keygen -R ${conn.host}\` and reconnect.\n\n${String(stderr || '').trim()}` ) + case SSH_ERROR.AUTH_FAILED: return ( `SSH authentication to ${host} failed. Desktop runs ssh non-interactively ` + @@ -243,10 +335,13 @@ function sshErrorMessage(kind, conn, stderr?) { `ssh-agent first (e.g. \`ssh-add ~/.ssh/id_ed25519\`), or set an IdentityFile in ` + `~/.ssh/config. Original error: ${String(stderr || '').trim()}` ) + case SSH_ERROR.UNREACHABLE: return `Could not reach ${host} over SSH. Check the host, port, and your network. Original error: ${String(stderr || '').trim()}` + case SSH_ERROR.TIMEOUT: return `SSH operation to ${host} timed out. The connection may be half-open (e.g. after sleep); reconnecting.` + default: return `SSH error connecting to ${host}: ${String(stderr || '').trim() || 'unknown failure'}` } @@ -260,10 +355,12 @@ function runSsh(args, { timeoutMs, spawnFn = spawn, stdin = 'ignore', stdinData return new Promise((resolve, reject) => { const useStdinPipe = stdinData != null || stdin !== 'ignore' let child + try { child = spawnFn('ssh', args, { stdio: [useStdinPipe ? 'pipe' : 'ignore', 'pipe', 'pipe'] }) } catch (error) { reject(error) + return } @@ -276,13 +373,18 @@ function runSsh(args, { timeoutMs, spawnFn = spawn, stdin = 'ignore', stdinData let settled = false const timer = setTimeout(() => { - if (settled) return + if (settled) { + return + } + settled = true + try { child.kill('SIGKILL') } catch { // already gone } + const err: any = new Error(`ssh timed out after ${timeoutMs}ms`) err.kind = SSH_ERROR.TIMEOUT reject(err) @@ -295,13 +397,19 @@ function runSsh(args, { timeoutMs, spawnFn = spawn, stdin = 'ignore', stdinData stderr += d.toString() }) child.on('error', error => { - if (settled) return + if (settled) { + return + } + settled = true clearTimeout(timer) reject(error) }) child.on('close', code => { - if (settled) return + if (settled) { + return + } + settled = true clearTimeout(timer) resolve({ code, stdout, stderr }) @@ -310,24 +418,35 @@ function runSsh(args, { timeoutMs, spawnFn = spawn, stdin = 'ignore', stdinData } function stopTunnelChild(child, timeoutMs = 5_000) { - if (!child || child.exitCode != null || child.signalCode != null) return Promise.resolve() + if (!child || child.exitCode != null || child.signalCode != null) { + return Promise.resolve() + } + return new Promise((resolve, reject) => { let settled = false + const finish = (error?: unknown) => { - if (settled) return + if (settled) { + return + } + settled = true clearTimeout(timer) child.off?.('exit', onExit) child.off?.('error', onError) error ? reject(error) : resolve() } + const onExit = () => finish() const onError = error => finish(error) const timer = setTimeout(() => finish(new Error('SSH tunnel did not exit after termination.')), timeoutMs) child.once('exit', onExit) child.once('error', onError) + try { - if (!child.kill()) finish(new Error('SSH tunnel termination was refused.')) + if (!child.kill()) { + finish(new Error('SSH tunnel termination was refused.')) + } } catch (error) { finish(error) } @@ -355,9 +474,14 @@ class SshConnection { if (!cfg || !cfg.host) { throw new Error('SshConnection requires a host.') } + const port = cfg.port ? Number(cfg.port) : 22 validateSshTarget(cfg.host, cfg.user || '', port) - if (cfg.keyPath) validateKeyPath(cfg.keyPath) + + if (cfg.keyPath) { + validateKeyPath(cfg.keyPath) + } + this.host = cfg.host this.user = cfg.user || '' this.port = port @@ -378,6 +502,7 @@ class SshConnection { this._tunnels = new Map() this._spawnFn = opts.spawnFn || spawn + this._log = typeof opts.rememberLog === 'function' ? opts.rememberLog : () => {} this._connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS this._execTimeoutMs = opts.execTimeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS @@ -394,12 +519,15 @@ class SshConnection { if (stderrOrErr && stderrOrErr.kind === SSH_ERROR.TIMEOUT) { const err: any = new Error(sshErrorMessage(SSH_ERROR.TIMEOUT, this)) err.kind = SSH_ERROR.TIMEOUT + return err } + const stderr = typeof stderrOrErr === 'string' ? stderrOrErr : stderrOrErr?.message || '' const kind = stderr ? classifySshError(stderr) : fallbackKind const err: any = new Error(sshErrorMessage(kind, this, stderr)) err.kind = kind + return err } @@ -414,14 +542,18 @@ class SshConnection { // a real exec before trusting it; on failure, evict and dial fresh. if (!this._mux || (await this._verifyMuxChannel())) { this._opened = true + return } + this._logLine('existing control master failed exec verification; evicting stale master') await this._evictStaleMaster() } + if (!this._mux) { this._logLine(`connecting (no-mux) to ${target(this.user, this.host)}:${this.port}`) let result + try { result = await runSsh(buildExecArgs(this, 'exit 0', this._connectTimeoutMs), { timeoutMs: this._connectTimeoutMs, @@ -430,43 +562,59 @@ class SshConnection { } catch (error) { throw this._fail(error, SSH_ERROR.UNREACHABLE) } + if (result.code !== 0) { throw this._fail(result.stderr, SSH_ERROR.UNREACHABLE) } + this._opened = true this._logLine('connection verified (no-mux; per-operation ssh)') + return } + const controlDir = path.dirname(this.controlPath) + try { fs.mkdirSync(controlDir, { recursive: true, mode: 0o700 }) - } catch {} + } catch { + void 0 + } + if (process.platform !== 'win32') { const st = fs.lstatSync(controlDir) + if (st.isSymbolicLink()) { throw new Error(`Unsafe SSH control dir: ${controlDir} is a symlink.`) } + if (!st.isDirectory()) { throw new Error(`Unsafe SSH control dir: ${controlDir} is not a directory.`) } + if (st.uid !== process.getuid!()) { throw new Error(`Unsafe SSH control dir: ${controlDir} is owned by uid ${st.uid}, not ${process.getuid!()}.`) } + if ((st.mode & 0o777) !== 0o700) { fs.chmodSync(controlDir, 0o700) } } + const args = buildMasterArgs(this, this._connectTimeoutMs) this._logLine(`opening control master to ${target(this.user, this.host)}:${this.port}`) let result + try { result = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn }) } catch (error) { throw this._fail(error, SSH_ERROR.UNREACHABLE) } + if (result.code !== 0) { throw this._fail(result.stderr, SSH_ERROR.UNREACHABLE) } + this._opened = true this._logLine('control master established') } @@ -474,12 +622,17 @@ class SshConnection { // Liveness. Mux: `-O check` against the master socket. No-mux: a cheap // one-shot exec — "alive" means "we can still authenticate and run". async isAlive() { - if ([...this._tunnels.values()].some(tunnel => tunnel.alive === false)) return false + if ([...this._tunnels.values()].some(tunnel => tunnel.alive === false)) { + return false + } + const args = this._mux ? buildControlArgs(this, 'check', [], this._connectTimeoutMs) : buildExecArgs(this, 'exit 0', this._connectTimeoutMs) + try { const result: any = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn }) + return result.code === 0 } catch { return false @@ -494,6 +647,7 @@ class SshConnection { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn }) + return result.code === 0 } catch { return false @@ -510,11 +664,16 @@ class SshConnection { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn }) - } catch {} + } catch { + void 0 + } + try { fs.unlinkSync(this.controlPath) } catch (error: any) { - if (error?.code !== 'ENOENT') this._logLine(`could not remove stale control socket (${error.code}); a fresh master may not dial`) + if (error?.code !== 'ENOENT') { + this._logLine(`could not remove stale control socket (${error.code}); a fresh master may not dial`) + } } } @@ -523,6 +682,7 @@ class SshConnection { async exec(remoteCommand, { timeoutMs, stdinData }: any = {}) { const args = buildExecArgs(this, remoteCommand, this._connectTimeoutMs) let result + try { result = await runSsh(args, { timeoutMs: timeoutMs ?? this._execTimeoutMs, @@ -532,9 +692,11 @@ class SshConnection { } catch (error) { throw this._fail(error) } + if (result.code !== 0) { throw this._fail(result.stderr) } + return result.stdout } @@ -545,8 +707,19 @@ class SshConnection { async forward(localPort, remotePort, remoteHost = '127.0.0.1') { const spec = forwardSpec(localPort, remotePort, remoteHost) this._logLine(`forwarding 127.0.0.1:${localPort} -> ${remoteHost}:${remotePort}`) + if (!this._mux) { - const args = [...baseSshOptions('', this._connectTimeoutMs), ...hostArgs(this), '-v', '-N', '-L', spec, '--', target(this.user, this.host)] + const args = [ + ...baseSshOptions('', this._connectTimeoutMs), + ...hostArgs(this), + '-v', + '-N', + '-L', + spec, + '--', + target(this.user, this.host) + ] + const child = this._spawnFn('ssh', args, { stdio: ['ignore', 'ignore', 'pipe'] }) const tunnel = { child, alive: true } this._tunnels.set(spec, tunnel) @@ -554,14 +727,20 @@ class SshConnection { let readyConfirmed = false let readyResolve let readyReject + const ready = new Promise((resolve, reject) => { readyResolve = resolve readyReject = reject }) + const readyPattern = new RegExp(`Local forwarding listening on .* port ${localPort}\\b`) child.stderr?.on('data', d => { - if (readyConfirmed) return + if (readyConfirmed) { + return + } + stderr = `${stderr}${String(d)}`.slice(-16_384) + if (readyPattern.test(stderr)) { readyConfirmed = true readyResolve() @@ -580,11 +759,15 @@ class SshConnection { readyReject(new Error(`tunnel process closed with code ${code}`)) }) let readyTimeout + try { await Promise.race([ ready, new Promise((_, reject) => { - readyTimeout = setTimeout(() => reject(new Error('tunnel did not confirm local forwarding')), this._forwardTimeoutMs) + readyTimeout = setTimeout( + () => reject(new Error('tunnel did not confirm local forwarding')), + this._forwardTimeoutMs + ) }) ]) } catch (error: any) { @@ -594,19 +777,24 @@ class SshConnection { } catch (stopError) { throw this._fail(stopError, SSH_ERROR.UNKNOWN) } + throw this._fail(stderr || error, SSH_ERROR.UNKNOWN) } finally { clearTimeout(readyTimeout) } + return } + const args = buildControlArgs(this, 'forward', ['-L', spec], this._connectTimeoutMs) let result + try { result = await runSsh(args, { timeoutMs: this._forwardTimeoutMs, spawnFn: this._spawnFn }) } catch (error) { throw this._fail(error) } + if (result.code !== 0) { throw this._fail(result.stderr) } @@ -616,16 +804,21 @@ class SshConnection { // logged but not thrown (close tears everything down anyway). async cancelForward(localPort, remotePort, remoteHost = '127.0.0.1') { const spec = forwardSpec(localPort, remotePort, remoteHost) + if (!this._mux) { const tunnel = this._tunnels.get(spec) + if (tunnel) { await stopTunnelChild(tunnel.child) this._tunnels.delete(spec) this._logLine(`cancelled forward 127.0.0.1:${localPort}`) } + return } + const args = buildControlArgs(this, 'cancel', ['-L', spec], this._connectTimeoutMs) + try { await runSsh(args, { timeoutMs: this._forwardTimeoutMs, spawnFn: this._spawnFn }) this._logLine(`cancelled forward 127.0.0.1:${localPort}`) @@ -637,28 +830,45 @@ class SshConnection { // Tear down. Mux: exit the master (drops every forward with it). No-mux: // kill the tunnel children. Best-effort; never throws. async close() { - if (!this._opened) return + if (!this._opened) { + return + } + if (!this._mux) { for (const [spec, tunnel] of this._tunnels) { await stopTunnelChild(tunnel.child) this._tunnels.delete(spec) } + this._opened = false this._logLine('connection closed (no-mux tunnels killed)') + return } + const args = buildControlArgs(this, 'exit', [], this._connectTimeoutMs) + try { const result: any = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn }) - if (result.code !== 0) throw this._fail(result.stderr) + + if (result.code !== 0) { + throw this._fail(result.stderr) + } + this._logLine('control master closed') } catch (error: any) { // A master that refuses -O exit is the wedge that poisons re-attach; // disown it. (Without its socket the orphan is inert; ControlPersist may // not reap it if a wedged channel never idles.) this._logLine(`close failed; removing control socket: ${error.message}`) - try { fs.unlinkSync(this.controlPath) } catch {} + + try { + fs.unlinkSync(this.controlPath) + } catch { + void 0 + } } + this._opened = false } } @@ -684,27 +894,27 @@ function createSshProbeConnection(config, options: any = {}) { } export { - CONTROL_PERSIST_SECONDS, - DEFAULT_CONNECT_TIMEOUT_MS, - DEFAULT_EXEC_TIMEOUT_MS, - DEFAULT_FORWARD_TIMEOUT_MS, - SSH_ERROR, - SshConnection, baseSshOptions, buildControlArgs, buildExecArgs, buildInteractiveSshArgs, buildMasterArgs, classifySshError, + CONTROL_PERSIST_SECONDS, controlSocketPath, createSshProbeConnection, + DEFAULT_CONNECT_TIMEOUT_MS, + DEFAULT_EXEC_TIMEOUT_MS, + DEFAULT_FORWARD_TIMEOUT_MS, forwardSpec, hostArgs, pickLocalPort, redactSecrets, runSsh, - stopTunnelChild, + SSH_ERROR, + SshConnection, sshErrorMessage, + stopTunnelChild, target, validateKeyPath, validateSshTarget diff --git a/apps/desktop/electron/windows-remote-lifecycle.test.ts b/apps/desktop/electron/windows-remote-lifecycle.test.ts index 5485f3d9e078..56cfb4d45d28 100644 --- a/apps/desktop/electron/windows-remote-lifecycle.test.ts +++ b/apps/desktop/electron/windows-remote-lifecycle.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' + import { test } from 'vitest' import { @@ -26,11 +27,25 @@ test('PowerShell transport uses UTF-16LE encoded commands and literal escaping', test('platform detection preserves POSIX and falls back to Windows PowerShell', async () => { assert.deepEqual(await detectRemotePlatform(sshWith(async () => 'Linux\nx86_64\n')), { os: 'Linux', arch: 'x86_64' }) const calls: string[] = [] - const result = await detectRemotePlatform(sshWith(async command => { - calls.push(command) - if (command.startsWith('uname ')) throw new Error('PowerShell does not recognize uname') - return JSON.stringify({ os: 'Windows', arch: 'ARM64', hermesHome: 'C:\\h', hermesPath: 'C:\\h\\hermes.exe', python: 'C:\\h\\python.exe' }) - })) + + const result = await detectRemotePlatform( + sshWith(async command => { + calls.push(command) + + if (command.startsWith('uname ')) { + throw new Error('PowerShell does not recognize uname') + } + + return JSON.stringify({ + os: 'Windows', + arch: 'ARM64', + hermesHome: 'C:\\h', + hermesPath: 'C:\\h\\hermes.exe', + python: 'C:\\h\\python.exe' + }) + }) + ) + assert.equal(result.os, 'Windows') assert.match(calls[1], /EncodedCommand/) }) @@ -41,22 +56,34 @@ test('platform detection surfaces transport failures as themselves, not unsuppor const transportErr: any = new Error('SSH connection timed out') transportErr.kind = 'timeout' await assert.rejects( - detectRemotePlatform(sshWith(async () => { throw transportErr })), + detectRemotePlatform( + sshWith(async () => { + throw transportErr + }) + ), (err: any) => err.kind === 'timeout' ) // Probe genuinely failing on a reachable host still classifies unsupported, // and carries the probe detail for diagnosis. await assert.rejects( - detectRemotePlatform(sshWith(async command => { - if (command.startsWith('uname ')) throw new Error('not recognized') - throw new Error('Hermes is not installed on the remote Windows host.') - })), + detectRemotePlatform( + sshWith(async command => { + if (command.startsWith('uname ')) { + throw new Error('not recognized') + } + + throw new Error('Hermes is not installed on the remote Windows host.') + }) + ), (err: any) => err.kind === 'unsupported-platform' && /Hermes is not installed/.test(err.message) ) }) test('helper command uses the fixed remote Python entry point and quotes path data', () => { - const command = helperCommand({ python: "C:\\Program Files\\Hermes's\\python.exe" }, 'inspect', ["C:\\x y\\hermes.exe"]) + const command = helperCommand({ python: "C:\\Program Files\\Hermes's\\python.exe" }, 'inspect', [ + 'C:\\x y\\hermes.exe' + ]) + const encoded = command.split(' ').pop()! const script = Buffer.from(encoded, 'base64').toString('utf16le') assert.match(script, /-m' 'hermes_cli\.windows_ssh_runtime' 'inspect'/) @@ -77,6 +104,7 @@ test('Windows lock validation is scoped and exact', () => { hermesPath: 'C:\\h\\hermes.exe', hermesHome: 'C:\\h' } + assert.equal(validLock(lock, ownershipId), true) assert.equal(validLock({ ...lock, ownershipId: 'b'.repeat(32) }, ownershipId), false) assert.equal(validLock({ ...lock, creationTimeNs: '0' }, ownershipId), false) diff --git a/apps/desktop/electron/windows-remote-lifecycle.ts b/apps/desktop/electron/windows-remote-lifecycle.ts index 525cc644016b..ff03d6a8c791 100644 --- a/apps/desktop/electron/windows-remote-lifecycle.ts +++ b/apps/desktop/electron/windows-remote-lifecycle.ts @@ -1,6 +1,6 @@ import crypto from 'node:crypto' -import { SSH_ERROR, redactSecrets } from './ssh-connection' +import { redactSecrets, SSH_ERROR } from './ssh-connection' const LOCKFILE_SCHEMA_VERSION = 2 const PROTOCOL_VERSION = 1 @@ -21,6 +21,7 @@ function powerShellCommand(script) { async function probeWindowsRemote(ssh, explicitHermesPath = '') { const explicit = psLiteral(explicitHermesPath) + const script = [ '$ErrorActionPreference="Stop"', `$explicit=${explicit}`, @@ -39,14 +40,21 @@ async function probeWindowsRemote(ssh, explicitHermesPath = '') { 'if(-not (Test-Path -LiteralPath $python -PathType Leaf)){throw "The remote Hermes Python runtime was not found."}', '[ordered]@{os="Windows";arch=$env:PROCESSOR_ARCHITECTURE;hermesHome=$hermesHome;hermesPath=$hermes;python=$python}|ConvertTo-Json -Compress' ].join(';') + return JSON.parse((await ssh.exec(powerShellCommand(script))).trim()) } -const TRANSPORT_KINDS = new Set([SSH_ERROR.AUTH_FAILED, SSH_ERROR.HOST_KEY_CHANGED, SSH_ERROR.TIMEOUT, SSH_ERROR.UNREACHABLE]) +const TRANSPORT_KINDS = new Set([ + SSH_ERROR.AUTH_FAILED, + SSH_ERROR.HOST_KEY_CHANGED, + SSH_ERROR.TIMEOUT, + SSH_ERROR.UNREACHABLE +]) async function detectRemotePlatform(ssh, explicitHermesPath = '') { try { const output = (await ssh.exec('uname -s; uname -m')).trim().split('\n') + if (output[0] === 'Linux' || output[0] === 'Darwin') { return { os: output[0], arch: output[1] || '' } } @@ -54,17 +62,28 @@ async function detectRemotePlatform(ssh, explicitHermesPath = '') { // uname failing is the expected Windows fall-through; a TRANSPORT failure // (auth/host-key/timeout/unreachable) is not a platform verdict — surface it // as itself instead of letting the probe chain end in 'unsupported-platform'. - if (TRANSPORT_KINDS.has(error?.kind)) throw error + if (TRANSPORT_KINDS.has(error?.kind)) { + throw error + } } + try { return await probeWindowsRemote(ssh, explicitHermesPath) } catch (cause: any) { - if (TRANSPORT_KINDS.has(cause?.kind)) throw cause + if (TRANSPORT_KINDS.has(cause?.kind)) { + throw cause + } + // detail is remote-controlled output headed for the UI: redact + strip control chars. - const detail = redactSecrets(String(cause?.message || cause || '')).replace(/[\x00-\x1f\x7f]/g, ' ').trim() + const detail = redactSecrets(String(cause?.message || cause || '')) + // eslint-disable-next-line no-control-regex -- deliberately strip control chars from remote output + .replace(/[\x00-\x1f\x7f]/g, ' ') + .trim() + const error: any = new Error( `The remote operating system is not supported by Desktop SSH.${detail ? ` (probe: ${detail.slice(0, 300)})` : ''}` ) + error.kind = 'unsupported-platform' error.cause = cause throw error @@ -73,30 +92,61 @@ async function detectRemotePlatform(ssh, explicitHermesPath = '') { function helperCommand(runtime, operation, args = []) { const argv = [runtime.python, '-m', 'hermes_cli.windows_ssh_runtime', operation, ...args] - const script = ['$ErrorActionPreference="Stop"', `& ${argv.map(psLiteral).join(' ')}`, 'if($LASTEXITCODE -ne 0){exit $LASTEXITCODE}'].join(';') + + const script = [ + '$ErrorActionPreference="Stop"', + `& ${argv.map(psLiteral).join(' ')}`, + 'if($LASTEXITCODE -ne 0){exit $LASTEXITCODE}' + ].join(';') + return powerShellCommand(script) } async function helper(ssh, runtime, operation, args = [], stdinData?) { const output = await ssh.exec(helperCommand(runtime, operation, args), stdinData == null ? {} : { stdinData }) - const lines = String(output || '').replace(/^\uFEFF/, '').trim().split(/\r?\n/).filter(Boolean) + + const lines = String(output || '') + .replace(/^\uFEFF/, '') + .trim() + .split(/\r?\n/) + .filter(Boolean) + const parsed = JSON.parse(lines[lines.length - 1] || 'null') - if (parsed?.error) throw new Error(parsed.error) + + if (parsed?.error) { + throw new Error(parsed.error) + } + return parsed } function fingerprintToken(token) { - return crypto.createHash('sha256').update(String(token || '')).digest('hex').slice(0, 32) + return crypto + .createHash('sha256') + .update(String(token || '')) + .digest('hex') + .slice(0, 32) } function validLock(lock, ownershipId) { // port 0 = spawn-in-progress record (written before readiness); a valid // ownership proof for cleanup, but never reusable. - return Boolean(lock && lock.schemaVersion === LOCKFILE_SCHEMA_VERSION && lock.protocolVersion === PROTOCOL_VERSION && - lock.ownershipId === ownershipId && /^[0-9a-f]{16}$/.test(lock.spawnNonce || '') && - Number.isInteger(lock.pid) && lock.pid > 0 && /^[0-9]{10,20}$/.test(lock.creationTimeNs || '') && - Number.isInteger(lock.port) && lock.port >= 0 && lock.port <= 65535 && /^[0-9a-f]{32}$/.test(lock.tokenFingerprint || '') && - typeof lock.hermesPath === 'string' && typeof lock.hermesHome === 'string') + return Boolean( + lock && + lock.schemaVersion === LOCKFILE_SCHEMA_VERSION && + lock.protocolVersion === PROTOCOL_VERSION && + lock.ownershipId === ownershipId && + /^[0-9a-f]{16}$/.test(lock.spawnNonce || '') && + Number.isInteger(lock.pid) && + lock.pid > 0 && + /^[0-9]{10,20}$/.test(lock.creationTimeNs || '') && + Number.isInteger(lock.port) && + lock.port >= 0 && + lock.port <= 65535 && + /^[0-9a-f]{32}$/.test(lock.tokenFingerprint || '') && + typeof lock.hermesPath === 'string' && + typeof lock.hermesHome === 'string' + ) } function assertCurrent(signal) { @@ -108,94 +158,185 @@ function assertCurrent(signal) { } async function processState(ssh, runtime, lock) { - return helper(ssh, runtime, 'process-state', [String(lock.pid), String(lock.creationTimeNs), lock.hermesPath, lock.spawnNonce]) + return helper(ssh, runtime, 'process-state', [ + String(lock.pid), + String(lock.creationTimeNs), + lock.hermesPath, + lock.spawnNonce + ]) } async function cleanupOwned(ssh, runtime, ownershipId, lock) { - const attempt = async fn => { try { await fn() } catch {} } + const attempt = async fn => { + try { + await fn() + } catch { + void 0 + } + } + if (lock) { const state = await processState(ssh, runtime, lock) + if (state.alive && state.owned) { // Deliberately not attempt()-wrapped: a thrown terminate must abort before // remove-lock, or a live backend is orphaned with no lock to reclaim it. - await helper(ssh, runtime, 'terminate', [String(lock.pid), String(lock.creationTimeNs), lock.hermesPath, lock.spawnNonce]) + await helper(ssh, runtime, 'terminate', [ + String(lock.pid), + String(lock.creationTimeNs), + lock.hermesPath, + lock.spawnNonce + ]) } + if (lock.spawnNonce) { await attempt(() => helper(ssh, runtime, 'remove-token', [ownershipId, lock.spawnNonce])) await attempt(() => helper(ssh, runtime, 'remove-log', [ownershipId, lock.spawnNonce])) } } + await attempt(() => helper(ssh, runtime, 'remove-lock', [ownershipId])) } async function waitReady(ssh, runtime, ownershipId, lock, timeoutMs, signal) { const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { assertCurrent(signal) let state + try { state = await processState(ssh, runtime, lock) } catch { await new Promise(resolve => setTimeout(resolve, READY_POLL_INTERVAL_MS)) + continue } + if (!state.indeterminate && (!state.alive || !state.owned)) { let detail = '' - try { detail = (await helper(ssh, runtime, 'read-log', [ownershipId, lock.spawnNonce]))?.content || '' } catch {} - const error: any = new Error(`Remote Windows backend exited before announcing its port. state=${JSON.stringify(state)} ${detail.slice(-2000)}`) + + try { + detail = (await helper(ssh, runtime, 'read-log', [ownershipId, lock.spawnNonce]))?.content || '' + } catch { + void 0 + } + + const error: any = new Error( + `Remote Windows backend exited before announcing its port. state=${JSON.stringify(state)} ${detail.slice(-2000)}` + ) + error.kind = 'spawn-failed' throw error } + let content = '' - try { content = (await helper(ssh, runtime, 'read-log', [ownershipId, lock.spawnNonce]))?.content || '' } catch {} + + try { + content = (await helper(ssh, runtime, 'read-log', [ownershipId, lock.spawnNonce]))?.content || '' + } catch { + void 0 + } + let port - for (const match of content.matchAll(READY_RE)) port = Number(match[1]) - if (port) return port + + for (const match of content.matchAll(READY_RE)) { + port = Number(match[1]) + } + + if (port) { + return port + } + await new Promise(resolve => setTimeout(resolve, READY_POLL_INTERVAL_MS)) } + const error: any = new Error(`Timed out waiting for the remote Windows backend (${timeoutMs}ms).`) error.kind = 'ready-timeout' throw error } async function connectWindowsRemote(deps) { - const { ssh, ownershipId, profile = '', remoteHermesPath = '', reuseToken = '', signal, - pickLocalPort, forward, cancelForward, waitForHermes, probeReuseProof, rememberLog = () => {}, readyTimeoutMs = 45_000 } = deps + const { + ssh, + ownershipId, + profile = '', + remoteHermesPath = '', + reuseToken = '', + signal, + pickLocalPort, + forward, + cancelForward, + waitForHermes, + probeReuseProof, + rememberLog = () => {}, + readyTimeoutMs = 45_000 + } = deps + assertCurrent(signal) const runtime = await probeWindowsRemote(ssh, remoteHermesPath) const inspection = await helper(ssh, runtime, 'inspect', [runtime.hermesPath]) + if (!inspection.supported) { const error: any = new Error('Update Hermes on the remote Windows host before connecting with Desktop SSH.') error.kind = 'update-required' throw error } + runtime.hermesPath = inspection.path const hermesVersion = inspection.version || '' rememberLog(`[ssh-lifecycle] remote platform Windows/${runtime.arch}`) rememberLog(`[ssh-lifecycle] located hermes at ${runtime.hermesPath}`) const lock = await helper(ssh, runtime, 'read-lock', [ownershipId]) + if (validLock(lock, ownershipId)) { const state = await processState(ssh, runtime, lock) + if (state.indeterminate) { const error: any = new Error('Could not determine the state of the existing remote backend.') error.kind = 'transient-transport-error' throw error } - const reusable = state.alive && state.owned && lock.port > 0 && Boolean(reuseToken) && - lock.tokenFingerprint === fingerprintToken(reuseToken) && lock.hermesPath === runtime.hermesPath && lock.hermesHome === runtime.hermesHome + + const reusable = + state.alive && + state.owned && + lock.port > 0 && + Boolean(reuseToken) && + lock.tokenFingerprint === fingerprintToken(reuseToken) && + lock.hermesPath === runtime.hermesPath && + lock.hermesHome === runtime.hermesHome + if (reusable) { const localPort = await pickLocalPort() await forward(localPort, lock.port) + try { const baseUrl = `http://127.0.0.1:${localPort}` const classification = await probeReuseProof(baseUrl, reuseToken, lock.spawnNonce) + if (classification === 'authenticated-ok') { - return { baseUrl, token: reuseToken, remotePort: lock.port, localPort, pid: lock.pid, reused: true, - platform: { os: 'Windows', arch: runtime.arch }, hermesPath: runtime.hermesPath, hermesVersion, - ownershipId, spawnNonce: lock.spawnNonce, creationTimeNs: lock.creationTimeNs } + return { + baseUrl, + token: reuseToken, + remotePort: lock.port, + localPort, + pid: lock.pid, + reused: true, + platform: { os: 'Windows', arch: runtime.arch }, + hermesPath: runtime.hermesPath, + hermesVersion, + ownershipId, + spawnNonce: lock.spawnNonce, + creationTimeNs: lock.creationTimeNs + } } - if (classification !== 'authenticated-stale') throw new Error('Invalid SSH reuse classification.') + + if (classification !== 'authenticated-stale') { + throw new Error('Invalid SSH reuse classification.') + } + await cancelForward(localPort, lock.port) await cleanupOwned(ssh, runtime, ownershipId, lock) } catch (error) { @@ -214,17 +355,38 @@ async function connectWindowsRemote(deps) { const spawnNonce = crypto.randomBytes(8).toString('hex') await helper(ssh, runtime, 'upload-token', [ownershipId, spawnNonce], token) let spawned + try { - spawned = await helper(ssh, runtime, 'spawn', [], JSON.stringify({ ownershipId, spawnNonce, profile, hermesPath: runtime.hermesPath })) + spawned = await helper( + ssh, + runtime, + 'spawn', + [], + JSON.stringify({ ownershipId, spawnNonce, profile, hermesPath: runtime.hermesPath }) + ) } catch (error) { await helper(ssh, runtime, 'remove-token', [ownershipId, spawnNonce]) throw error } - const owned = { schemaVersion: LOCKFILE_SCHEMA_VERSION, protocolVersion: PROTOCOL_VERSION, ownershipId, spawnNonce, - pid: spawned.pid, creationTimeNs: spawned.creationTimeNs, port: 0, profile, hermesPath: runtime.hermesPath, - hermesHome: runtime.hermesHome, tokenFingerprint: fingerprintToken(token), startedAt: new Date().toISOString() } + + const owned = { + schemaVersion: LOCKFILE_SCHEMA_VERSION, + protocolVersion: PROTOCOL_VERSION, + ownershipId, + spawnNonce, + pid: spawned.pid, + creationTimeNs: spawned.creationTimeNs, + port: 0, + profile, + hermesPath: runtime.hermesPath, + hermesHome: runtime.hermesHome, + tokenFingerprint: fingerprintToken(token), + startedAt: new Date().toISOString() + } + let localPort = 0 let remotePort = 0 + try { // Write the ownership record IMMEDIATELY (port=0): if this attempt is // superseded before readiness and cleanup cannot reach the box, the next @@ -239,11 +401,26 @@ async function connectWindowsRemote(deps) { await waitForHermes(baseUrl, token) assertCurrent(signal) await helper(ssh, runtime, 'write-lock', [ownershipId], JSON.stringify({ ...owned, port: remotePort })) - return { baseUrl, token, remotePort, localPort, pid: spawned.pid, reused: false, - platform: { os: 'Windows', arch: runtime.arch }, hermesPath: runtime.hermesPath, hermesVersion, - ownershipId, spawnNonce, creationTimeNs: spawned.creationTimeNs } + + return { + baseUrl, + token, + remotePort, + localPort, + pid: spawned.pid, + reused: false, + platform: { os: 'Windows', arch: runtime.arch }, + hermesPath: runtime.hermesPath, + hermesVersion, + ownershipId, + spawnNonce, + creationTimeNs: spawned.creationTimeNs + } } catch (error) { - if (localPort && remotePort) await cancelForward(localPort, remotePort) + if (localPort && remotePort) { + await cancelForward(localPort, remotePort) + } + await cleanupOwned(ssh, runtime, ownershipId, owned) throw error } @@ -252,9 +429,27 @@ async function connectWindowsRemote(deps) { function buildWindowsInteractiveCommand(remoteCwd = '') { const cwd = String(remoteCwd || '').trim() const script = ['$ErrorActionPreference="Stop"'] - if (cwd) script.push(`if(Test-Path -LiteralPath ${psLiteral(cwd)} -PathType Container){Set-Location -LiteralPath ${psLiteral(cwd)}}`) + + if (cwd) { + script.push( + `if(Test-Path -LiteralPath ${psLiteral(cwd)} -PathType Container){Set-Location -LiteralPath ${psLiteral(cwd)}}` + ) + } + script.push('$host.UI.RawUI.WindowTitle="Hermes SSH"', 'powershell.exe -NoLogo') + return powerShellCommand(script.join(';')) } -export { buildWindowsInteractiveCommand, connectWindowsRemote, detectRemotePlatform, encodedPowerShell, helper, helperCommand, powerShellCommand, probeWindowsRemote, psLiteral, validLock } +export { + buildWindowsInteractiveCommand, + connectWindowsRemote, + detectRemotePlatform, + encodedPowerShell, + helper, + helperCommand, + powerShellCommand, + probeWindowsRemote, + psLiteral, + validLock +} diff --git a/apps/desktop/electron/windows-remote-live.test.ts b/apps/desktop/electron/windows-remote-live.test.ts index c5b3488cbd23..8a499f31ec66 100644 --- a/apps/desktop/electron/windows-remote-live.test.ts +++ b/apps/desktop/electron/windows-remote-live.test.ts @@ -1,7 +1,8 @@ import assert from 'node:assert/strict' + import { test } from 'vitest' -import { SshConnection, pickLocalPort } from './ssh-connection' +import { pickLocalPort, SshConnection } from './ssh-connection' import { connectWindowsRemote } from './windows-remote-lifecycle' // Live test against a real Windows host over SSH. Opt-in: set the env trio to @@ -16,60 +17,90 @@ const ownershipId = '89abcdef0123456789abcdef01234567' function fetchJson(url, token, path) { return fetch(`${url}${path}`, { headers: { 'X-Hermes-Session-Token': token } }).then(async response => { - if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`) + if (!response.ok) { + throw new Error(`${response.status}: ${await response.text()}`) + } + return response.json() }) } test.skipIf(!liveHost || !liveUser || !configuredHermes)( - 'live Windows remote lifecycle spawns, authenticates, reuses, and cleans exact ownership', async () => { - const ssh = new SshConnection({ host: liveHost, user: liveUser, port: 22, keyPath: '' }, { mux: true }) - await ssh.open() - const common = { - ssh, - ownershipId, - profile: '', - remoteHermesPath: configuredHermes, - pickLocalPort, - forward: (local, remote) => ssh.forward(local, remote), - cancelForward: (local, remote) => ssh.cancelForward(local, remote), - waitForHermes: async (baseUrl, token) => { - for (let i = 0; i < 40; i++) { - try { await fetchJson(baseUrl, token, '/api/status'); return } catch {} - await new Promise(resolve => setTimeout(resolve, 250)) - } - throw new Error('status timeout') - }, - probeReuseProof: async (baseUrl, token, nonce) => { - const body: any = await fetchJson(baseUrl, token, '/api/ssh/ownership') - return body.sshOwnerNonce === nonce ? 'authenticated-ok' : 'authenticated-stale' - }, - rememberLog: () => {} - } - let first - let second - try { - first = await connectWindowsRemote(common) - assert.equal(first.platform.os, 'Windows') - assert.equal(first.reused, false) - const status: any = await fetchJson(first.baseUrl, first.token, '/api/status') - assert.ok(status) - await ssh.cancelForward(first.localPort, first.remotePort) - second = await connectWindowsRemote({ ...common, reuseToken: first.token }) - assert.equal(second.reused, true) - assert.equal(second.pid, first.pid) - assert.equal(second.spawnNonce, first.spawnNonce) - } finally { - if (second) await ssh.cancelForward(second.localPort, second.remotePort) - const runtimeScript = `& '${configuredHermes.replace('hermes.exe', 'python.exe')}' -m hermes_cli.windows_ssh_runtime read-lock '${ownershipId}'` - const lock: any = JSON.parse(await ssh.exec(`powershell.exe -NoProfile -NonInteractive -Command "${runtimeScript}"`)) - if (lock) { - const python = configuredHermes.replace('hermes.exe', 'python.exe') - const terminate = `& '${python}' -m hermes_cli.windows_ssh_runtime terminate '${lock.pid}' '${lock.creationTimeNs}' '${lock.hermesPath}' '${lock.spawnNonce}'` - await ssh.exec(`powershell.exe -NoProfile -NonInteractive -Command "${terminate}"`) - await ssh.exec(`powershell.exe -NoProfile -NonInteractive -Command "& '${python}' -m hermes_cli.windows_ssh_runtime remove-lock '${ownershipId}'"`) - await ssh.exec(`powershell.exe -NoProfile -NonInteractive -Command "& '${python}' -m hermes_cli.windows_ssh_runtime remove-log '${ownershipId}' '${lock.spawnNonce}'"`) + 'live Windows remote lifecycle spawns, authenticates, reuses, and cleans exact ownership', + async () => { + const ssh = new SshConnection({ host: liveHost, user: liveUser, port: 22, keyPath: '' }, { mux: true }) + await ssh.open() + + const common = { + ssh, + ownershipId, + profile: '', + remoteHermesPath: configuredHermes, + pickLocalPort, + forward: (local, remote) => ssh.forward(local, remote), + cancelForward: (local, remote) => ssh.cancelForward(local, remote), + waitForHermes: async (baseUrl, token) => { + for (let i = 0; i < 40; i++) { + try { + await fetchJson(baseUrl, token, '/api/status') + + return + } catch { + void 0 + } + + await new Promise(resolve => setTimeout(resolve, 250)) + } + + throw new Error('status timeout') + }, + probeReuseProof: async (baseUrl, token, nonce) => { + const body: any = await fetchJson(baseUrl, token, '/api/ssh/ownership') + + return body.sshOwnerNonce === nonce ? 'authenticated-ok' : 'authenticated-stale' + }, + rememberLog: () => {} } - await ssh.close() - } -}, 90_000) + + let first + let second + + try { + first = await connectWindowsRemote(common) + assert.equal(first.platform.os, 'Windows') + assert.equal(first.reused, false) + const status: any = await fetchJson(first.baseUrl, first.token, '/api/status') + assert.ok(status) + await ssh.cancelForward(first.localPort, first.remotePort) + second = await connectWindowsRemote({ ...common, reuseToken: first.token }) + assert.equal(second.reused, true) + assert.equal(second.pid, first.pid) + assert.equal(second.spawnNonce, first.spawnNonce) + } finally { + if (second) { + await ssh.cancelForward(second.localPort, second.remotePort) + } + + const runtimeScript = `& '${configuredHermes.replace('hermes.exe', 'python.exe')}' -m hermes_cli.windows_ssh_runtime read-lock '${ownershipId}'` + + const lock: any = JSON.parse( + await ssh.exec(`powershell.exe -NoProfile -NonInteractive -Command "${runtimeScript}"`) + ) + + if (lock) { + const python = configuredHermes.replace('hermes.exe', 'python.exe') + const terminate = `& '${python}' -m hermes_cli.windows_ssh_runtime terminate '${lock.pid}' '${lock.creationTimeNs}' '${lock.hermesPath}' '${lock.spawnNonce}'` + await ssh.exec(`powershell.exe -NoProfile -NonInteractive -Command "${terminate}"`) + await ssh.exec( + `powershell.exe -NoProfile -NonInteractive -Command "& '${python}' -m hermes_cli.windows_ssh_runtime remove-lock '${ownershipId}'"` + ) + await ssh.exec( + `powershell.exe -NoProfile -NonInteractive -Command "& '${python}' -m hermes_cli.windows_ssh_runtime remove-log '${ownershipId}' '${lock.spawnNonce}'"` + ) + } + + await ssh.close() + } + }, + 90_000 +) diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 7812bf5f0dae..47beed69bcbc 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -70,8 +70,8 @@ import { ModelPickerOverlay } from '../model-picker-overlay' import { ModelVisibilityOverlay } from '../model-visibility-overlay' import { PetGenerateOverlay } from '../pet-generate/pet-generate-overlay' import { FileActionDialogs } from '../right-sidebar/file-actions' -import { resetProjectTreeState } from '../right-sidebar/files/use-project-tree' import { RemoteFolderPicker } from '../right-sidebar/files/remote-picker' +import { resetProjectTreeState } from '../right-sidebar/files/use-project-tree' import { PersistentTerminal } from '../right-sidebar/terminal/persistent' import { closeAllTerminals } from '../right-sidebar/terminal/terminals' import { CRON_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE, syncWorkspaceIsPage } from '../routes' diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx index 04f24cab6d9d..74828222551e 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx @@ -100,7 +100,10 @@ function fakeDesktop() { onBackendExit: vi.fn(() => () => undefined), onConnectionApplied: vi.fn(callback => { connectionApplied = callback - return () => { connectionApplied = null } + + return () => { + connectionApplied = null + } }), onPowerResume: vi.fn(() => () => undefined), onWindowStateChanged: vi.fn(() => () => undefined), diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 1533181af11c..09ba6bdb52ad 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -409,6 +409,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { if (sessionId) { flushQueuedDeltas(sessionId) const text = coerceGatewayText(payload?.text) + if (text) { finalizeInterimAssistantMessage(sessionId, text) } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index 7331ca946267..11a7878af069 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -379,6 +379,7 @@ export function useMessageStream({ } const authoritativeText = renderMediaTags(text).trim() + if (!authoritativeText) { return state } @@ -386,29 +387,30 @@ export function useMessageStream({ const streamId = state.streamId const replaceTextPart = (parts: ChatMessagePart[]) => { - const visibleText = stripGeneratedImageEchoes( - authoritativeText, generatedImageEchoSources(parts) - ).trim() + const visibleText = stripGeneratedImageEchoes(authoritativeText, generatedImageEchoSources(parts)).trim() + return mergeFinalAssistantText(parts, visibleText) } let nextMessages = state.messages + if (streamId && nextMessages.some(m => m.id === streamId)) { // Finalize the existing streaming bubble in place nextMessages = nextMessages.map(m => - m.id === streamId - ? { ...m, parts: replaceTextPart(m.parts), pending: false } - : m + m.id === streamId ? { ...m, parts: replaceTextPart(m.parts), pending: false } : m ) } else { // No streaming bubble — create a standalone interim message - nextMessages = [...nextMessages, { - id: `assistant-interim-${Date.now()}`, - role: 'assistant' as const, - parts: [assistantTextPart(authoritativeText)], - pending: false, - branchGroupId: state.pendingBranchGroup ?? undefined - }] + nextMessages = [ + ...nextMessages, + { + id: `assistant-interim-${Date.now()}`, + role: 'assistant' as const, + parts: [assistantTextPart(authoritativeText)], + pending: false, + branchGroupId: state.pendingBranchGroup ?? undefined + } + ] } return { @@ -451,6 +453,7 @@ export function useMessageStream({ const replaceTextPart = (parts: ChatMessagePart[]) => { const visibleFinalText = stripGeneratedImageEchoes(finalText, generatedImageEchoSources(parts)).trim() + return mergeFinalAssistantText(parts, visibleFinalText) } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx index a31d20a9b4fe..2ee5bb720418 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx @@ -4,9 +4,9 @@ import { useEffect, useRef } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ClientSessionState } from '@/app/types' -import { createClientSessionState } from '@/lib/chat-runtime' import { chatMessageText } from '@/lib/chat-messages' -import { $todosBySession, clearSessionTodos, setSessionTodos } from '@/store/todos' +import { createClientSessionState } from '@/lib/chat-runtime' +import { clearSessionTodos } from '@/store/todos' import type { RpcEvent } from '@/types/hermes' import { useMessageStream } from './index' @@ -55,10 +55,13 @@ async function mountStream() { const start = () => act(() => handleEvent!({ payload: {}, session_id: SID, type: 'message.start' })) const delta = (text: string) => act(() => handleEvent!({ payload: { text }, session_id: SID, type: 'message.delta' })) + const interim = (text: string) => act(() => handleEvent!({ payload: { text, already_streamed: true }, session_id: SID, type: 'message.interim' })) + const complete = (text: string) => act(() => handleEvent!({ payload: { text }, session_id: SID, type: 'message.complete' })) + const completePreviewed = (text: string) => act(() => handleEvent!({ payload: { text, response_previewed: true }, session_id: SID, type: 'message.complete' })) @@ -69,11 +72,13 @@ function getState(): ClientSessionState { function assistantText(): string { const state = getState() const last = [...state.messages].reverse().find(m => m.role === 'assistant' && !m.hidden) + return last ? chatMessageText(last) : '' } function assistantMessages(): string[] { const state = getState() + return state.messages .filter(m => m.role === 'assistant' && !m.hidden) .map(m => chatMessageText(m)) @@ -222,7 +227,9 @@ describe('useMessageStream interim text sealing', () => { // Empty text await act(() => handleEvent!({ payload: { text: '' }, session_id: SID, type: 'message.interim' } as RpcEvent)) // Undefined text - await act(() => handleEvent!({ payload: { text: undefined }, session_id: SID, type: 'message.interim' } as RpcEvent)) + await act(() => + handleEvent!({ payload: { text: undefined }, session_id: SID, type: 'message.interim' } as RpcEvent) + ) // Turn continues without finalizing or throwing expect(getState().busy).toBe(true) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index b4049f648719..f789402db297 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -269,7 +269,11 @@ export function useSessionActions({ // rebind race, so leaving the old id here could revive it on a very fast // New Chat -> Enter sequence. onFreshDraftRouteIntent?.() - if (!preserveRoute) navigate(NEW_CHAT_ROUTE, { replace: replaceRoute }) + + if (!preserveRoute) { + navigate(NEW_CHAT_ROUTE, { replace: replaceRoute }) + } + setActiveSessionId(null) activeSessionIdRef.current = null setSelectedStoredSessionId(null) diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index 66080c399a75..008bd44d2ead 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -8,7 +8,19 @@ import { Tip } from '@/components/ui/tooltip' import type { DesktopAuthProvider, DesktopCloudAgent, DesktopCloudOrg, DesktopConnectionProbeResult } from '@/global' import { useI18n } from '@/i18n' import { ExternalLink } from '@/lib/external-link' -import { AlertCircle, Check, Cloud, FileText, Globe, HelpCircle, Loader2, LogIn, Monitor, RefreshCw, Terminal } from '@/lib/icons' +import { + AlertCircle, + Check, + Cloud, + FileText, + Globe, + HelpCircle, + Loader2, + LogIn, + Monitor, + RefreshCw, + Terminal +} from '@/lib/icons' import { selectableCardClass } from '@/lib/selectable-card' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' @@ -365,17 +377,30 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { // instantly snapped the just-clicked-Custom (empty-host) input back to the // dropdown, making a raw-IP host impossible to type. The way back to the // dropdown is the input's onBlur (empty host + suggestions). - if (state.sshHost && !sshHostSuggestions.includes(state.sshHost)) setSshCustomHost(true) + if (state.sshHost && !sshHostSuggestions.includes(state.sshHost)) { + setSshCustomHost(true) + } }, [state.sshHost, sshHostSuggestions]) useEffect(() => { - if (state.mode !== 'ssh' || !window.hermesDesktop?.sshConfigHosts) return + if (state.mode !== 'ssh' || !window.hermesDesktop?.sshConfigHosts) { + return + } + let cancelled = false - void window.hermesDesktop.sshConfigHosts().then(result => { - if (!cancelled) setSshHostSuggestions(result.hosts) - }).catch(() => { - if (!cancelled) setSshHostSuggestions([]) - }) + void window.hermesDesktop + .sshConfigHosts() + .then(result => { + if (!cancelled) { + setSshHostSuggestions(result.hosts) + } + }) + .catch(() => { + if (!cancelled) { + setSshHostSuggestions([]) + } + }) + return () => void (cancelled = true) }, [state.mode]) @@ -417,6 +442,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { const save = async (apply: boolean) => { const seq = ++saveSeq.current + if (state.mode === 'remote' && !canUseRemote) { notify({ kind: 'warning', @@ -433,7 +459,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { const next = apply ? await window.hermesDesktop.applyConnectionConfig(payload()) : await window.hermesDesktop.saveConnectionConfig(payload()) - if (seq !== saveSeq.current) return + + if (seq !== saveSeq.current) { + return + } acceptSavedConfig(next) setRemoteToken('') @@ -443,8 +472,12 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { message: apply ? g.restartingMessage : g.savedMessage }) } catch (err) { - if (seq !== saveSeq.current) return + if (seq !== saveSeq.current) { + return + } + const sshError = err && typeof err === 'object' && 'sshError' in err ? String(err.sshError) : '' + const errors = { 'auth-failed': g.sshErrAuth, 'hermes-not-found': g.sshErrNotInstalled, @@ -454,6 +487,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { 'unsupported-platform': g.sshErrPlatform, 'update-required': g.sshErrUpdateRequired } + if (state.mode === 'ssh' && sshError) { notify({ kind: 'error', @@ -464,7 +498,9 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { notifyError(err, apply ? g.applyFailed : g.saveFailed) } } finally { - if (seq === saveSeq.current) setSaving(false) + if (seq === saveSeq.current) { + setSaving(false) + } } } @@ -473,6 +509,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { // refresh the connection status from the saved config once it completes. const signIn = async () => { const seq = ++signingSeq.current + if (!trimmedUrl) { notify({ kind: 'warning', title: g.incompleteTitle, message: g.enterUrlFirst }) @@ -490,12 +527,18 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { remoteAuthMode: 'oauth', remoteUrl: trimmedUrl }) - if (seq !== signingSeq.current) return + + if (seq !== signingSeq.current) { + return + } acceptSavedConfig(saved) const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl) - if (seq !== signingSeq.current) return + + if (seq !== signingSeq.current) { + return + } if (result.connected) { const refreshed = await window.hermesDesktop.getConnectionConfig(scope) @@ -509,9 +552,13 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { }) } } catch (err) { - if (seq === signingSeq.current) notifyError(err, g.signInFailed) + if (seq === signingSeq.current) { + notifyError(err, g.signInFailed) + } } finally { - if (seq === signingSeq.current) setSigningIn(false) + if (seq === signingSeq.current) { + setSigningIn(false) + } } } @@ -522,13 +569,21 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { try { await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined) const refreshed = await window.hermesDesktop.getConnectionConfig(scope) - if (seq !== signingSeq.current) return + + if (seq !== signingSeq.current) { + return + } + acceptSavedConfig(refreshed) notify({ kind: 'success', title: g.signedOutTitle, message: g.signedOutMessage }) } catch (err) { - if (seq === signingSeq.current) notifyError(err, g.signOutFailed) + if (seq === signingSeq.current) { + notifyError(err, g.signOutFailed) + } } finally { - if (seq === signingSeq.current) setSigningIn(false) + if (seq === signingSeq.current) { + setSigningIn(false) + } } } @@ -550,7 +605,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { try { const result = await desktop.cloud.discover(org) - if (seq !== contextSeq.current) return + + if (seq !== contextSeq.current) { + return + } if ('needsOrgSelection' in result && result.needsOrgSelection) { // Multi-org user with no org chosen yet: show the picker. Don't clear a @@ -579,7 +637,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { setCloudDiscover('done') } catch (err) { - if (seq !== contextSeq.current) return + if (seq !== contextSeq.current) { + return + } + setCloudAgents([]) setCloudDiscover('error') @@ -674,16 +735,24 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { try { const result = await desktop.cloud.login() - if (seq !== signingSeq.current) return + + if (seq !== signingSeq.current) { + return + } + setCloudSignedIn(result.signedIn) if (result.signedIn) { await discoverCloud() } } catch (err) { - if (seq === signingSeq.current) notifyError(err, g.cloudSignInFailed) + if (seq === signingSeq.current) { + notifyError(err, g.cloudSignInFailed) + } } finally { - if (seq === signingSeq.current) setCloudSigningIn(false) + if (seq === signingSeq.current) { + setCloudSigningIn(false) + } } } @@ -699,7 +768,11 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { try { await desktop.cloud.logout() - if (seq !== signingSeq.current) return + + if (seq !== signingSeq.current) { + return + } + setCloudSignedIn(false) setCloudAgents([]) setCloudOrgs([]) @@ -707,9 +780,13 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { setCloudDiscover('idle') notify({ kind: 'success', title: g.cloudSignedOutTitle, message: g.cloudSignedOutMessage }) } catch (err) { - if (seq === signingSeq.current) notifyError(err, g.signOutFailed) + if (seq === signingSeq.current) { + notifyError(err, g.signOutFailed) + } } finally { - if (seq === signingSeq.current) setCloudSigningIn(false) + if (seq === signingSeq.current) { + setCloudSigningIn(false) + } } } @@ -718,6 +795,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { // connection pointed at its dashboardUrl and apply it (soft-reconnects in place). const connectCloudAgent = async (agent: DesktopCloudAgent) => { const seq = contextSeq.current + if (!agent.dashboardUrl) { return } @@ -732,7 +810,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { try { const result = await desktop.cloud.agentSignIn(agent.dashboardUrl) - if (seq !== contextSeq.current) return + + if (seq !== contextSeq.current) { + return + } if (!result.connected) { notify({ @@ -755,28 +836,44 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { remoteUrl: agent.dashboardUrl, cloudOrg: cloudOrgRef.current ?? undefined }) - if (seq !== contextSeq.current) return + + if (seq !== contextSeq.current) { + return + } acceptSavedConfig(next) notify({ kind: 'success', title: g.cloudConnectedTitle, message: g.cloudConnectedTo(agent.name) }) } catch (err) { - if (seq !== contextSeq.current) return + if (seq !== contextSeq.current) { + return + } + if (err && typeof err === 'object' && 'needsCloudLogin' in err) { setCloudSignedIn(false) } notifyError(err, g.cloudConnectFailed) } finally { - if (seq === contextSeq.current) setCloudConnectingId(null) + if (seq === contextSeq.current) { + setCloudConnectingId(null) + } } } const resolveSshHost = async (host: string) => { - if (!host || !window.hermesDesktop?.sshResolveHost) return + if (!host || !window.hermesDesktop?.sshResolveHost) { + return + } + const seq = ++sshResolveSeq.current + try { const resolved = await window.hermesDesktop.sshResolveHost(host) - if (seq !== sshResolveSeq.current) return + + if (seq !== sshResolveSeq.current) { + return + } + setState(current => enrichSelectedSshHost(current, host, resolved)) } catch { return @@ -787,8 +884,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { if (value === SSH_HOST_CUSTOM) { setSshCustomHost(true) setState(current => selectSshHost(current, '')) + return } + setSshCustomHost(false) setState(current => selectSshHost(current, value)) void resolveSshHost(value) @@ -796,15 +895,23 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { const testSsh = async () => { const seq = ++sshTestSeq.current + if (!state.sshHost.trim()) { notify({ kind: 'warning', title: g.incompleteTitle, message: g.sshIncompleteHost }) + return } + setTesting(true) setLastTest(null) + try { const result = await window.hermesDesktop.testConnectionConfig(payload()) - if (seq !== sshTestSeq.current) return + + if (seq !== sshTestSeq.current) { + return + } + if (!result.reachable) { const errors = { 'auth-failed': g.sshErrAuth, @@ -816,20 +923,27 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { 'update-required': g.sshErrUpdateRequired, unknown: g.sshErrUnknown } + throw new Error(errors[result.sshError || 'unknown'] || result.error || g.sshErrUnknown) } + const message = g.sshReachable(result.host || state.sshHost, result.remotePlatform || '?') setLastTest(message) notify({ kind: 'success', title: g.reachableTitle, message }) } catch (err) { - if (seq === sshTestSeq.current) notifyError(err, g.testFailed) + if (seq === sshTestSeq.current) { + notifyError(err, g.testFailed) + } } finally { - if (seq === sshTestSeq.current) setTesting(false) + if (seq === sshTestSeq.current) { + setTesting(false) + } } } const testRemote = async () => { const seq = ++sshTestSeq.current + if (!canUseRemote) { notify({ kind: 'warning', @@ -851,15 +965,22 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined, remoteUrl: trimmedUrl }) - if (seq !== sshTestSeq.current) return + + if (seq !== sshTestSeq.current) { + return + } const message = g.connectedTo(result.baseUrl || trimmedUrl, result.version ?? undefined) setLastTest(message) notify({ kind: 'success', title: g.reachableTitle, message }) } catch (err) { - if (seq === sshTestSeq.current) notifyError(err, g.testFailed) + if (seq === sshTestSeq.current) { + notifyError(err, g.testFailed) + } } finally { - if (seq === sshTestSeq.current) setTesting(false) + if (seq === sshTestSeq.current) { + setTesting(false) + } } } @@ -1199,10 +1320,19 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { {sshHostSuggestions.length > 0 && !sshCustomHost ? ( - + @@ -1212,20 +1342,79 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { /> ) : ( { - // Empty host on blur with suggestions available = the user backed - // out of Custom; return to the dropdown. - if (!state.sshHost.trim() && sshHostSuggestions.length > 0) { setSshCustomHost(false); return } - void resolveSshHost(state.sshHost) - }} onChange={event => setState(current => selectSshHost(current, event.target.value))} value={state.sshHost} />} + action={ + { + // Empty host on blur with suggestions available = the user backed + // out of Custom; return to the dropdown. + if (!state.sshHost.trim() && sshHostSuggestions.length > 0) { + setSshCustomHost(false) + + return + } + + void resolveSshHost(state.sshHost) + }} + onChange={event => setState(current => selectSshHost(current, event.target.value))} + value={state.sshHost} + /> + } description={g.sshHostDesc} title={g.sshHostTitle} /> )} - setState(current => ({ ...current, sshUser: event.target.value }))} placeholder={g.sshUserPlaceholder} value={state.sshUser} />} description={g.sshUserDesc} title={g.sshUserTitle} /> - setState(current => ({ ...current, sshPort: event.target.value ? Number(event.target.value) : null }))} placeholder="22" value={state.sshPort ?? ''} />} description={g.sshPortDesc} title={g.sshPortTitle} /> - setState(current => ({ ...current, sshKeyPath: event.target.value }))} value={state.sshKeyPath} />} description={g.sshKeyDesc} title={g.sshKeyTitle} /> - setState(current => ({ ...current, sshRemoteHermesPath: event.target.value }))} placeholder={g.sshHermesPathPlaceholder} value={state.sshRemoteHermesPath} />} description={g.sshHermesPathDesc} title={g.sshHermesPathTitle} /> + setState(current => ({ ...current, sshUser: event.target.value }))} + placeholder={g.sshUserPlaceholder} + value={state.sshUser} + /> + } + description={g.sshUserDesc} + title={g.sshUserTitle} + /> + + setState(current => ({ ...current, sshPort: event.target.value ? Number(event.target.value) : null })) + } + placeholder="22" + value={state.sshPort ?? ''} + /> + } + description={g.sshPortDesc} + title={g.sshPortTitle} + /> + setState(current => ({ ...current, sshKeyPath: event.target.value }))} + value={state.sshKeyPath} + /> + } + description={g.sshKeyDesc} + title={g.sshKeyTitle} + /> + setState(current => ({ ...current, sshRemoteHermesPath: event.target.value }))} + placeholder={g.sshHermesPathPlaceholder} + value={state.sshRemoteHermesPath} + /> + } + description={g.sshHermesPathDesc} + title={g.sshHermesPathTitle} + /> ) : null} @@ -1248,7 +1437,13 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { {g.testRemote} ) : state.mode === 'ssh' ? ( - diff --git a/apps/desktop/src/app/settings/ssh-host-selection.test.ts b/apps/desktop/src/app/settings/ssh-host-selection.test.ts index 68583bcc23e0..9598e2827fba 100644 --- a/apps/desktop/src/app/settings/ssh-host-selection.test.ts +++ b/apps/desktop/src/app/settings/ssh-host-selection.test.ts @@ -29,11 +29,13 @@ describe('selectSshHost', () => { it('enriches only the host that produced the ssh config result', () => { const selected = selectSshHost(state, 'mac-box') - expect(enrichSelectedSshHost(selected, 'mac-box', { - identityFile: '~/.ssh/id_ed25519', - port: 22, - user: 'hermes' - })).toMatchObject({ + expect( + enrichSelectedSshHost(selected, 'mac-box', { + identityFile: '~/.ssh/id_ed25519', + port: 22, + user: 'hermes' + }) + ).toMatchObject({ sshHost: 'mac-box', sshUser: 'hermes', sshPort: null, diff --git a/apps/desktop/src/app/settings/ssh-host-selection.ts b/apps/desktop/src/app/settings/ssh-host-selection.ts index d641aaf186d0..56516a1b146d 100644 --- a/apps/desktop/src/app/settings/ssh-host-selection.ts +++ b/apps/desktop/src/app/settings/ssh-host-selection.ts @@ -13,7 +13,10 @@ type ResolvedSshHost = { } function selectSshHost(state: T, host: string): T { - if (host === state.sshHost) return state + if (host === state.sshHost) { + return state + } + return { ...state, sshHost: host, @@ -25,11 +28,14 @@ function selectSshHost(state: T, host: string): T { } function enrichSelectedSshHost(state: T, host: string, resolved: ResolvedSshHost): T { - if (state.sshHost !== host) return state + if (state.sshHost !== host) { + return state + } + return { ...state, sshUser: state.sshUser || resolved.user || '', - sshPort: state.sshPort ?? (resolved.port === 22 ? null : resolved.port ?? null), + sshPort: state.sshPort ?? (resolved.port === 22 ? null : (resolved.port ?? null)), sshKeyPath: state.sshKeyPath || resolved.identityFile || '' } } diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 3b7a5716dc96..f66e40c4aafe 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -290,15 +290,30 @@ export function useStatusbarItems({ ]) const connectionItem = useMemo(() => { - if (connection?.mode !== 'remote' || !connection.remoteHost) return null + if (connection?.mode !== 'remote' || !connection.remoteHost) { + return null + } + const ssh = connection.remoteKind === 'ssh' const cloud = connection.remoteKind === 'cloud' + return { - className: cn('px-2 -ml-1 font-medium', ssh ? 'bg-primary text-primary-foreground' : 'bg-accent text-accent-foreground'), + className: cn( + 'px-2 -ml-1 font-medium', + ssh ? 'bg-primary text-primary-foreground' : 'bg-accent text-accent-foreground' + ), icon: , id: 'connection', - label: ssh ? copy.connectionSsh(connection.remoteHost) : cloud ? copy.connectionCloud(connection.remoteHost) : copy.connectionRemote(connection.remoteHost), - title: ssh ? copy.connectionSshTooltip(connection.remoteHost) : cloud ? copy.connectionCloudTooltip(connection.remoteHost) : copy.connectionRemoteTooltip(connection.remoteHost), + label: ssh + ? copy.connectionSsh(connection.remoteHost) + : cloud + ? copy.connectionCloud(connection.remoteHost) + : copy.connectionRemote(connection.remoteHost), + title: ssh + ? copy.connectionSshTooltip(connection.remoteHost) + : cloud + ? copy.connectionCloudTooltip(connection.remoteHost) + : copy.connectionRemoteTooltip(connection.remoteHost), to: `${SETTINGS_ROUTE}?tab=gateway` } }, [connection?.mode, connection?.remoteHost, connection?.remoteKind, copy]) diff --git a/apps/desktop/src/components/boot-failure-overlay.tsx b/apps/desktop/src/components/boot-failure-overlay.tsx index abbca990339c..ea5ca27a1066 100644 --- a/apps/desktop/src/components/boot-failure-overlay.tsx +++ b/apps/desktop/src/components/boot-failure-overlay.tsx @@ -13,7 +13,13 @@ import { notify, notifyError } from '@/store/notifications' import { $desktopOnboarding } from '@/store/onboarding' import type { RemoteReauth } from './boot-failure-reauth' -import { deriveProviderShape, isRemoteConfig, isRemoteReauthFailure, signInLabel, sshFailureMessage } from './boot-failure-reauth' +import { + deriveProviderShape, + isRemoteConfig, + isRemoteReauthFailure, + signInLabel, + sshFailureMessage +} from './boot-failure-reauth' // The recovery "Gateway settings" view embeds the real Settings → Gateway panel // (identical URL/auth/test/save controls — no parallel form to drift). Lazy so diff --git a/apps/desktop/src/components/boot-failure-reauth.test.ts b/apps/desktop/src/components/boot-failure-reauth.test.ts index 796991a25a4c..42527604b14f 100644 --- a/apps/desktop/src/components/boot-failure-reauth.test.ts +++ b/apps/desktop/src/components/boot-failure-reauth.test.ts @@ -41,6 +41,7 @@ describe('isRemoteConfig', () => { const ssh = config({ mode: 'ssh' as never, remoteUrl: '', remoteAuthMode: 'token' }) as DesktopConnectionConfig & { sshHost: string } + ssh.sshHost = 'remote-box' expect(isRemoteConfig(ssh)).toBe(true) diff --git a/apps/desktop/src/components/boot-failure-reauth.ts b/apps/desktop/src/components/boot-failure-reauth.ts index b5ff3012c010..680445752132 100644 --- a/apps/desktop/src/components/boot-failure-reauth.ts +++ b/apps/desktop/src/components/boot-failure-reauth.ts @@ -31,10 +31,16 @@ const DEFAULT_SIGN_IN_COPY: SignInCopy = { // Gateway (edit URL / token / sign in) — the local Retry/Repair buttons target // the bundled backend and can't help. Drives the escape-hatch emphasis. export function isRemoteConfig(config: DesktopConnectionConfig | null | undefined): boolean { - if (!config) return false + if (!config) { + return false + } + const ssh = config as DesktopConnectionConfig & { sshHost?: string } - return ((config.mode === 'remote' || config.mode === 'cloud') && Boolean(config.remoteUrl)) || + + return ( + ((config.mode === 'remote' || config.mode === 'cloud') && Boolean(config.remoteUrl)) || ((config.mode as string) === 'ssh' && Boolean(ssh.sshHost)) + ) } // True when a boot error is auth-shaped — the refresh token was rejected or the @@ -73,15 +79,41 @@ export function sshFailureMessage( } ): string { const raw = String(error || '') - if (config?.mode !== 'ssh') return raw + + if (config?.mode !== 'ssh') { + return raw + } + const text = raw.toLowerCase() - if (text.includes('host key')) return copy.sshErrHostKey || raw - if (text.includes('auth')) return copy.sshErrAuth || raw - if (text.includes('not installed') || text.includes('not found')) return copy.sshErrNotInstalled || raw - if (text.includes('unsupported')) return copy.sshErrPlatform || raw - if (text.includes('timed out') || text.includes('timeout')) return copy.sshErrTimeout || raw - if (text.includes('update')) return copy.sshErrUpdateRequired || raw - if (text.includes('unreachable') || text.includes('could not reach')) return copy.sshErrUnreachable || raw + + if (text.includes('host key')) { + return copy.sshErrHostKey || raw + } + + if (text.includes('auth')) { + return copy.sshErrAuth || raw + } + + if (text.includes('not installed') || text.includes('not found')) { + return copy.sshErrNotInstalled || raw + } + + if (text.includes('unsupported')) { + return copy.sshErrPlatform || raw + } + + if (text.includes('timed out') || text.includes('timeout')) { + return copy.sshErrTimeout || raw + } + + if (text.includes('update')) { + return copy.sshErrUpdateRequired || raw + } + + if (text.includes('unreachable') || text.includes('could not reach')) { + return copy.sshErrUnreachable || raw + } + return copy.sshErrUnknown || raw } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 9ab41d3bd9c2..1aaf1befa745 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -676,7 +676,8 @@ export const en: Translations = { 'The host key has CHANGED since you last connected. Verify this is expected, then run ssh-keygen -R and reconnect.', sshErrNotInstalled: 'Hermes is not installed on the remote host. Install it there (curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh) or set the Hermes path.', - sshErrPlatform: 'Unsupported remote platform. Hermes Desktop SSH mode supports Linux, macOS, and Windows remote hosts.', + sshErrPlatform: + 'Unsupported remote platform. Hermes Desktop SSH mode supports Linux, macOS, and Windows remote hosts.', sshErrTimeout: 'SSH connection timed out. The host may be unreachable or asleep.', sshErrUpdateRequired: 'Update Hermes on the remote host before connecting with Desktop SSH.', sshErrUnknown: 'SSH connection failed.' diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 55a67155131b..018a60854665 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -748,7 +748,8 @@ export const ja = defineLocale({ '前回の接続以降、ホスト鍵が変更されています。想定どおりか確認し、ssh-keygen -R を実行してから再接続してください。', sshErrNotInstalled: 'リモートホストに Hermes がインストールされていません。リモートでインストールする(curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh)か、Hermes パスを設定してください。', - sshErrPlatform: 'サポートされていないリモートプラットフォームです。Hermes Desktop の SSH モードは Linux、macOS、Windows のリモートホストに対応しています。', + sshErrPlatform: + 'サポートされていないリモートプラットフォームです。Hermes Desktop の SSH モードは Linux、macOS、Windows のリモートホストに対応しています。', sshErrTimeout: 'SSH 接続がタイムアウトしました。ホストが到達不能、またはスリープ中の可能性があります。', sshErrUpdateRequired: 'Desktop SSH で接続する前に、リモートホストの Hermes を更新してください。', sshErrUnknown: 'SSH 接続に失敗しました。' diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 8354072d2854..14bf7171a6de 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -723,8 +723,7 @@ export const zhHant = defineLocale({ sshErrUnreachable: '無法透過 SSH 連線到該主機。請檢查主機、連接埠和網路。', sshErrAuth: 'SSH 驗證失敗。請將金鑰載入 ssh-agent(ssh-add),或在 ~/.ssh/config 中設定 IdentityFile——Hermes 以非互動方式執行 ssh。', - sshErrHostKey: - '自上次連線以來主機金鑰已變更。請確認這是預期的,然後執行 ssh-keygen -R 並重新連線。', + sshErrHostKey: '自上次連線以來主機金鑰已變更。請確認這是預期的,然後執行 ssh-keygen -R 並重新連線。', sshErrNotInstalled: '遠端主機上未安裝 Hermes。請在遠端安裝(curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh)或設定 Hermes 路徑。', sshErrPlatform: '不支援的遠端平台。Hermes Desktop 的 SSH 模式支援 Linux、macOS 和 Windows 遠端主機。', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index de52b5a10544..f79945409f53 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -866,8 +866,7 @@ export const zh: Translations = { sshErrUnreachable: '无法通过 SSH 连接到该主机。请检查主机、端口和网络。', sshErrAuth: 'SSH 认证失败。请将密钥加载到 ssh-agent(ssh-add),或在 ~/.ssh/config 中设置 IdentityFile——Hermes 以非交互方式运行 ssh。', - sshErrHostKey: - '自上次连接以来主机密钥已更改。请确认这是预期的,然后运行 ssh-keygen -R 并重新连接。', + sshErrHostKey: '自上次连接以来主机密钥已更改。请确认这是预期的,然后运行 ssh-keygen -R 并重新连接。', sshErrNotInstalled: '远程主机上未安装 Hermes。请在远程安装(curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh)或设置 Hermes 路径。', sshErrPlatform: '不支持的远程平台。Hermes Desktop 的 SSH 模式支持 Linux、macOS 和 Windows 远程主机。', diff --git a/apps/desktop/src/lib/chat-messages.test.ts b/apps/desktop/src/lib/chat-messages.test.ts index 64dc414642be..e63fae09cb89 100644 --- a/apps/desktop/src/lib/chat-messages.test.ts +++ b/apps/desktop/src/lib/chat-messages.test.ts @@ -804,10 +804,7 @@ describe('mergeFinalAssistantText', () => { }) it('drops reasoning that the final text fully covers (reasoning ⊆ final)', () => { - const parts = [ - reasoningPart('Let me check the files.'), - { type: 'text' as const, text: 'streamed' } - ] + const parts = [reasoningPart('Let me check the files.'), { type: 'text' as const, text: 'streamed' }] const result = mergeFinalAssistantText(parts, 'Let me check the files. Everything looks good.') @@ -819,7 +816,9 @@ describe('mergeFinalAssistantText', () => { // #61447: a short final ("Done.") must NOT swallow a longer reasoning block // that merely starts with it. const parts = [ - reasoningPart('Done. The root cause was a bare catch block swallowing Stripe errors. The fix adds proper error logging.'), + reasoningPart( + 'Done. The root cause was a bare catch block swallowing Stripe errors. The fix adds proper error logging.' + ), { type: 'text' as const, text: 'streamed' } ] @@ -842,10 +841,7 @@ describe('mergeFinalAssistantText', () => { }) it('handles empty final text', () => { - const parts = [ - { type: 'text' as const, text: 'streamed' }, - reasoningPart('some reasoning') - ] + const parts = [{ type: 'text' as const, text: 'streamed' }, reasoningPart('some reasoning')] const result = mergeFinalAssistantText(parts, '') diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index 221f6a16a064..4bc50d32569a 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -152,10 +152,7 @@ const normalizeWs = (value: string) => value.replace(/\s+/g, ' ').trim() * - Keeps all other part types (tool-call, image, etc.). * - Appends the final text as a new text part. */ -export function mergeFinalAssistantText( - parts: ChatMessagePart[], - finalText: string -): ChatMessagePart[] { +export function mergeFinalAssistantText(parts: ChatMessagePart[], finalText: string): ChatMessagePart[] { const dedupeReference = normalizeWs(finalText) const kept = parts.filter(part => { diff --git a/apps/desktop/src/lib/desktop-fs.test.ts b/apps/desktop/src/lib/desktop-fs.test.ts index 79d1bda4d1cb..bd9ad2df98de 100644 --- a/apps/desktop/src/lib/desktop-fs.test.ts +++ b/apps/desktop/src/lib/desktop-fs.test.ts @@ -125,12 +125,18 @@ describe('desktop filesystem facade', () => { it('keys SSH filesystem caches by stable host identity instead of the forwarded port', () => { $connection.set({ - mode: 'remote', remoteKind: 'ssh', remoteHost: 'operator@remote-box', baseUrl: 'http://127.0.0.1:41001' + mode: 'remote', + remoteKind: 'ssh', + remoteHost: 'operator@remote-box', + baseUrl: 'http://127.0.0.1:41001' } as never) const first = desktopFsCacheKey() $connection.set({ - mode: 'remote', remoteKind: 'ssh', remoteHost: 'operator@remote-box', baseUrl: 'http://127.0.0.1:52002' + mode: 'remote', + remoteKind: 'ssh', + remoteHost: 'operator@remote-box', + baseUrl: 'http://127.0.0.1:52002' } as never) expect(desktopFsCacheKey()).toBe(first) @@ -140,15 +146,27 @@ describe('desktop filesystem facade', () => { it('separates SSH filesystem caches by ownership and profile', () => { $connection.set({ - mode: 'remote', remoteKind: 'ssh', remoteHost: 'host-a', remoteIdentity: 'owner-a', profile: 'one' + mode: 'remote', + remoteKind: 'ssh', + remoteHost: 'host-a', + remoteIdentity: 'owner-a', + profile: 'one' } as never) const first = desktopFsCacheKey() $connection.set({ - mode: 'remote', remoteKind: 'ssh', remoteHost: 'host-a', remoteIdentity: 'owner-b', profile: 'one' + mode: 'remote', + remoteKind: 'ssh', + remoteHost: 'host-a', + remoteIdentity: 'owner-b', + profile: 'one' } as never) const otherOwner = desktopFsCacheKey() $connection.set({ - mode: 'remote', remoteKind: 'ssh', remoteHost: 'host-a', remoteIdentity: 'owner-a', profile: 'two' + mode: 'remote', + remoteKind: 'ssh', + remoteHost: 'host-a', + remoteIdentity: 'owner-a', + profile: 'two' } as never) expect(otherOwner).not.toBe(first) diff --git a/apps/desktop/src/lib/desktop-fs.ts b/apps/desktop/src/lib/desktop-fs.ts index 5e4d52b35354..c290786f5d53 100644 --- a/apps/desktop/src/lib/desktop-fs.ts +++ b/apps/desktop/src/lib/desktop-fs.ts @@ -21,9 +21,11 @@ function connectionCacheKey(connection: HermesConnection | null) { return 'local:' } - const target = connection.remoteKind === 'ssh' - ? connection.remoteIdentity || connection.remoteHost || '' - : connection.baseUrl || '' + const target = + connection.remoteKind === 'ssh' + ? connection.remoteIdentity || connection.remoteHost || '' + : connection.baseUrl || '' + return `${connection.mode || 'local'}:${connection.remoteKind || ''}:${connection.profile || ''}:${target}` }