diff --git a/apps/desktop/electron/desktop-installation.test.ts b/apps/desktop/electron/desktop-installation.test.ts new file mode 100644 index 00000000000..e53bf5681f8 --- /dev/null +++ b/apps/desktop/electron/desktop-installation.test.ts @@ -0,0 +1,74 @@ +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' + +const ID_A = '11111111-1111-4111-8111-111111111111' +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 { + fs.rmSync(directory, { recursive: true, force: true }) + } +} + +test('parseInstallationId accepts only a version-4 UUID record', () => { + assert.equal(parseInstallationId(JSON.stringify({ installationId: ID_A.toUpperCase() })), ID_A) + assert.equal(parseInstallationId(JSON.stringify({ installationId: 'not-an-id' })), '') + assert.equal(parseInstallationId('{}'), '') + 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 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 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 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, '') + assert.match(global, /^[0-9a-f]{32}$/) + assert.equal(global, sshOwnershipId(ID_A, '')) + assert.notEqual(global, sshOwnershipId(ID_A, 'worker')) + assert.ok(!global.includes(ID_A.slice(0, 8))) + assert.throws(() => sshOwnershipId('bad', '')) +}) diff --git a/apps/desktop/electron/desktop-installation.ts b/apps/desktop/electron/desktop-installation.ts new file mode 100644 index 00000000000..8b321f62229 --- /dev/null +++ b/apps/desktop/electron/desktop-installation.ts @@ -0,0 +1,82 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' + +const INSTALLATION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +function parseInstallationId(raw) { + try { + const value = JSON.parse(String(raw || ''))?.installationId + return INSTALLATION_ID_RE.test(value) ? value.toLowerCase() : '' + } catch { + return '' + } +} + +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) + return parseInstallationId(fs.readFileSync(filePath, 'utf8')) + } catch { + return '' + } +} + +function waitForRepair() { + const buffer = new SharedArrayBuffer(4) + Atomics.wait(new Int32Array(buffer), 0, 0, 25) +} + +function loadOrCreateInstallationId(filePath, randomUUID = crypto.randomUUID) { + const existing = readInstallationId(filePath) + 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.') + + 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 + const winner = readInstallationId(filePath) + if (winner) return winner + waitForRepair() + continue + } + + try { + const winner = readInstallationId(filePath) + 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.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 + } + 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 {} + } + } + 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) +} + +export { INSTALLATION_ID_RE, loadOrCreateInstallationId, parseInstallationId, readInstallationId, sshOwnershipId } diff --git a/apps/desktop/electron/remote-lifecycle.test.ts b/apps/desktop/electron/remote-lifecycle.test.ts new file mode 100644 index 00000000000..47df3f2fc34 --- /dev/null +++ b/apps/desktop/electron/remote-lifecycle.test.ts @@ -0,0 +1,850 @@ +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, + lockfilePath, + openForward, + ownershipDirectory, + pidIsOurDashboard, + probeRemotePlatform, + readLockfile, + remotePidAlive, + remoteSupportsSshOwnership, + scrapeReadyPort, + spawnRemoteDashboard, + spawnLogPath, + validateRemotePath, + writeLockfile +} from './remote-lifecycle' + +const OWNERSHIP_ID = '0123456789abcdef0123456789abcdef' +const SPAWN_NONCE = '0123456789abcdef' + +function ownedLock(over: any = {}) { + return { + schemaVersion: LOCKFILE_SCHEMA_VERSION, + protocolVersion: PROTOCOL_VERSION, + ownershipId: OWNERSHIP_ID, + spawnNonce: SPAWN_NONCE, + pid: 333, + port: 40000, + profile: '', + hermesPath: '~/.local/bin/hermes', + hermesHome: '~/.hermes', + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), + tokenFingerprint: fingerprintToken('stored-token'), + startedAt: '2026-07-14T00:00:00.000Z', + ...over + } +} + +// A fake SshConnection whose exec() is matched against an ordered list of +// [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 + 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') +}) + +test('locateHermes throws (no silent fallback) when an EXPLICIT path is not executable', async () => { + // command -v WOULD find a different install, but an explicit path must not + // silently fall back to it — that is the "connected to the wrong hermes" bug. + const ssh = fakeSsh([ + [/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 + } + ) +}) + +test('locateHermes falls back to the login-shell command -v probe', async () => { + const ssh = fakeSsh([ + [/command -v hermes/, '/home/u/.local/bin/hermes\n'], + [/\[ -x .*\.local\/bin\/hermes/, 'OK'] + ]) + assert.equal(await locateHermes(ssh, ''), '/home/u/.local/bin/hermes') +}) + +test('locateHermes canonicalizes an installer wrapper to its executable target', async () => { + const ssh = fakeSsh([ + [/command -v hermes/, '/home/u/.local/bin/hermes\n'], + [/\[ -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') +}) + +test('locateHermes falls back to ~/.local/bin/hermes when the login-shell probe misses', async () => { + // ~/.local/bin is the non-root installer's command location (scripts/install.sh). + const ssh = fakeSsh([ + [/command -v hermes/, ''], + [/\[ -x .*\.local\/bin\/hermes/, 'OK'] + ]) + assert.equal(await locateHermes(ssh, ''), '~/.local/bin/hermes') +}) + +test('locateHermes tries the conventional venv path last', async () => { + const ssh = fakeSsh([[/\[ -x .*venv\/bin\/hermes/, 'OK']]) + assert.equal(await locateHermes(ssh, ''), '~/.hermes/hermes-agent/venv/bin/hermes') +}) + +test('locateHermes throws a hermes-not-found error with an install hint', async () => { + const ssh = fakeSsh([]) // nothing is executable + await assert.rejects( + () => locateHermes(ssh, ''), + (err: any) => { + assert.equal(err.kind, 'hermes-not-found') + assert.match(err.message, /install/i) + return true + } + ) +}) + +test('locateHermes uses a login shell for the command -v probe', async () => { + const ssh = fakeSsh([ + [/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)') +}) + + +test('probeRemotePlatform accepts Linux and macOS', async () => { + assert.deepEqual(await probeRemotePlatform(fakeSsh([[/uname/, 'Linux\nx86_64']])), { + os: 'Linux', + arch: 'x86_64' + }) + assert.deepEqual(await probeRemotePlatform(fakeSsh([[/uname/, 'Darwin\narm64']])), { + os: 'Darwin', + arch: 'arm64' + }) +}) + +test('probeRemotePlatform rejects unsupported remote platforms', async () => { + await assert.rejects( + () => 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`) + assert.equal(spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), `~/.hermes/desktop-ssh/${OWNERSHIP_ID}/${SPAWN_NONCE}.log`) +}) + +test('readLockfile returns null for missing, empty, malformed, or wrong-schema', async () => { + assert.equal(await readLockfile(fakeSsh([[/cat/, '']]), OWNERSHIP_ID), null) + assert.equal(await readLockfile(fakeSsh([[/cat/, 'not json']]), OWNERSHIP_ID), null) + assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify({ schemaVersion: 999 })]]), OWNERSHIP_ID), null) + const good = ownedLock({ pid: 1, port: 2 }) + assert.deepEqual(await readLockfile(fakeSsh([[/cat/, JSON.stringify(good)]]), OWNERSHIP_ID), good) +}) + +test('writeLockfile mkdir -ps and stamps the schema version', async () => { + const ssh = fakeSsh([]) + await writeLockfile(ssh, OWNERSHIP_ID, ownedLock({ pid: 7, port: 9 })) + const cmd = ssh.calls.join('\n') + assert.match(cmd, /mkdir -p/) + assert.match(cmd, new RegExp(`"schemaVersion":${LOCKFILE_SCHEMA_VERSION}`)) +}) + +test('remotePidAlive maps kill -0 ALIVE/DEAD', async () => { + assert.equal(await remotePidAlive(fakeSsh([[/kill -0/, 'ALIVE']]), 123), true) + assert.equal(await remotePidAlive(fakeSsh([[/kill -0/, 'DEAD']]), 123), false) + assert.equal(await remotePidAlive(fakeSsh([]), null), false) +}) + +test('metadata and process proof transport failures remain indeterminate', async () => { + const failure = new Error('connection reset') + await assert.rejects( + () => readLockfile(fakeSsh([[/cat/, failure]]), OWNERSHIP_ID), + (error: any) => error.kind === 'transient-transport-error' + ) + await assert.rejects( + () => remotePidAlive(fakeSsh([[/kill -0/, failure]]), 123), + (error: any) => error.kind === 'transient-transport-error' + ) + await assert.rejects( + () => pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, failure]]), 5, SPAWN_NONCE, '/x/hermes'), + (error: any) => error.kind === 'transient-transport-error' + ) +}) + +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"/, '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) }) + 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) }) + 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/) + assert.match(cmd, /--host 127\.0\.0\.1 --port 0/) + assert.doesNotMatch(cmd, /--skip-build|--no-open/) + assert.doesNotMatch(cmd, /\bdashboard\b/) + assert.match(cmd, /--profile/) + assert.match(cmd, /work/) + assert.match(cmd, /setsid/) + assert.match(cmd, /<\/dev\/null/) + assert.match(cmd, /echo \$!/) + assert.ok(!cmd.includes('tok_secret_value'), 'token must not appear in spawn command') + assert.ok(!cmd.includes('HERMES_DASHBOARD_SESSION_TOKEN'), 'token env var must not appear') +}) + +test('buildSpawnCommand always uses serve (legacy dashboard path removed)', () => { + const cmd = buildSpawnCommand('/x/hermes', 'work', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) }) + assert.match(cmd, /serve --isolated/) + assert.match(cmd, /--host 127\.0\.0\.1 --port 0/) + assert.doesNotMatch(cmd, /dashboard/) + assert.doesNotMatch(cmd, /--skip-build/) + assert.match(cmd, /setsid/) +}) + +test('spawnRemoteDashboard returns exact ownership artifacts', async () => { + const ssh = fakeSsh([ + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/printf '%s\\n'/, ''], + [/setsid|nohup/, '4242\n'] + ]) + 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)) +}) + +test('spawnRemoteDashboard always spawns serve (legacy dashboard path removed)', async () => { + const ssh = fakeSsh([ + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/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/) + assert.doesNotMatch(spawn, /\bdashboard\b/) +}) + +test('READY_RE accepts both serve and dashboard sentinels', () => { + assert.equal(READY_RE.exec('HERMES_BACKEND_READY port=4321')?.[1], '4321') + assert.equal(READY_RE.exec('HERMES_DASHBOARD_READY port=8765')?.[1], '8765') +}) + +test('spawnRemoteDashboard rejects when no pid is returned', async () => { + const ssh = fakeSsh([ + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/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 + } + ) +}) + +test('scrapeReadyPort reads only the named spawn log', async () => { + const logPath = spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) + const ssh = fakeSsh([[/cat/, 'some noise\nHERMES_DASHBOARD_READY port=51234\n']]) + const port = await scrapeReadyPort(ssh, logPath, { timeoutMs: 1000 }) + assert.equal(port, 51234) + assert.ok(ssh.calls.every(call => !call.includes('desktop-ssh.log'))) +}) + +test('scrapeReadyPort times out and reports a dead spawn', async () => { + // never emits a READY line + const ssh = fakeSsh([[/cat .*\.log/, 'still starting...']]) + await assert.rejects( + () => 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 }), + (err: any) => { + assert.equal(err.kind, 'spawn-failed') + return true + } + ) +}) + + +function connectDeps(ssh, over: any = {}) { + return { + ssh, + ownershipId: OWNERSHIP_ID, + profile: '', + forward: async () => {}, + cancelForward: async () => {}, + pickLocalPort: async () => 50001, + waitForHermes: async () => {}, + probeReuseProof: async () => 'authenticated-ok', + adoptServedToken: async (_baseUrl, spawn) => spawn || 'served-token', + rememberLog: () => {}, + readyTimeoutMs: 2000, + ...over + } +} + +test('connect() spawns fresh when there is no lockfile, adopts the served token', async () => { + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, ''], // no lockfile + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], // token file write + [/printf '%s\\n'/, ''], + [/setsid/, '777\n'], + [/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) + assert.equal(result.localPort, 50001) + assert.equal(result.pid, 777) + assert.equal(result.token, 'the-served-token') + assert.equal(result.baseUrl, 'http://127.0.0.1:50001') + assert.equal(result.tokenFingerprint, fingerprintToken('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'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/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) + assert.equal(result.remotePort, 40000) + // never spawned + assert.ok(!ssh.calls.some(c => /setsid/.test(c)), 'reuse path must not spawn a new dashboard') +}) + +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'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'FOREIGN\n'], + [/--version/, 'Hermes Agent v0.18.2\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '890\n'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=52050\n'] + ]) + 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') +}) + +test('connect() respawns when the lockfile protocolVersion is incompatible', async () => { + const reuseToken = 'stored-token' + const lock = { + schemaVersion: LOCKFILE_SCHEMA_VERSION, + protocolVersion: PROTOCOL_VERSION + 99, + pid: 333, + port: 40000, + tokenFingerprint: fingerprintToken(reuseToken) + } + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0 333/, 'ALIVE'], + [/print\("OWNED"/, 'FOREIGN\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '901\n'], + [/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) +}) + +test('connect() fresh spawn writes hermesHome + protocolVersion into the lockfile', async () => { + const writes: string[] = [] + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, ''], // no lockfile + [/HERMES_HOME/, '/home/alice/.hermes\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/printf '%s\\n'/, ''], + [/setsid/, '700\n'], + [/kill -0 700/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=45500\n'], + [ + /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}`)) + assert.match(lockWrite, /"hermesHome":"\/home\/alice\/\.hermes"/) +}) + +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'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0 333/, 'DEAD'], + [/print\("OWNED"/, 'FOREIGN\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '888\n'], + [/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) +}) + +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, + pid: 333, + port: 40000, + tokenFingerprint: fingerprintToken(reuseToken) + } + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'FOREIGN\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '999\n'], + [/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' }) + ) + assert.equal(result.reused, false) + assert.equal(result.pid, 999) + assert.equal(result.remotePort, 43000) +}) + +test('connect() aborts on an unsupported remote platform before doing anything else', async () => { + const ssh = fakeSsh([[/uname/, 'SunOS\nsun4v']]) + await assert.rejects( + () => connect(connectDeps(ssh)), + (err: any) => { + assert.equal(err.kind, 'unsupported-platform') + return true + } + ) + assert.ok(!ssh.calls.some(c => /setsid/.test(c))) +}) + +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) + assert.equal(localPort, 41002) + assert.deepEqual(calls, [41001, 41002]) + assert.equal(isForwardBindCollision(new Error('Permission denied')), false) +}) + +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'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'OWNED\n'] + ]) + 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')) +}) + +test('validateRemotePath accepts ~/ prefix paths', () => { + assert.doesNotThrow(() => validateRemotePath('~/bin/hermes')) + assert.doesNotThrow(() => validateRemotePath('~/.hermes/logs/desktop-ssh.log')) + assert.doesNotThrow(() => validateRemotePath('~')) +}) + +test('validateRemotePath accepts paths with spaces and quotes', () => { + assert.doesNotThrow(() => validateRemotePath('/home/user/my project/hermes')) + assert.doesNotThrow(() => validateRemotePath("~/path with 'quotes'/file")) + assert.doesNotThrow(() => validateRemotePath('/path with "double quotes"/file')) +}) + +test('validateRemotePath rejects relative paths', () => { + assert.throws(() => validateRemotePath('hermes'), /absolute|relative/i) + assert.throws(() => validateRemotePath('./bin/hermes'), /absolute|relative/i) + assert.throws(() => validateRemotePath('../etc/passwd'), /absolute|relative/i) +}) + +test('validateRemotePath rejects NUL and newline', () => { + assert.throws(() => validateRemotePath('/usr/bin/hermes\x00'), /unsafe/i) + assert.throws(() => validateRemotePath('/usr/bin/hermes\n'), /unsafe/i) + assert.throws(() => validateRemotePath('/usr/bin/hermes\r'), /unsafe/i) +}) + +test('validateRemotePath preserves shell metacharacters as path data', () => { + for (const p of ['/usr/$(whoami)/hermes', '/usr/`id`/hermes', '/usr/a;b|c&df']) { + assert.doesNotThrow(() => validateRemotePath(p)) + assert.match(expandRemotePath(p), /^'/) + } +}) + +test('expandRemotePath expands ~/ to "$HOME"/', () => { + const result = expandRemotePath('~/.hermes/logs/desktop-ssh.log') + assert.match(result, /\$HOME/) + assert.ok(!result.includes('eval'), 'must not use eval') + assert.ok(!result.includes('echo'), 'must not use echo for expansion') +}) + +test('expandRemotePath returns quoted absolute paths unchanged', () => { + const result = expandRemotePath('/usr/local/bin/hermes') + assert.ok(result.includes('/usr/local/bin/hermes')) + assert.ok(!result.includes('eval')) +}) + +test('expandRemotePath preserves spaces as data', () => { + const result = expandRemotePath('/home/user/my project/hermes') + assert.ok(result.includes('my project'), 'spaces must be preserved, not split') +}) + +test('buildSpawnCommand does not embed the token in the command string', () => { + const cmd = buildSpawnCommand('/x/hermes', 'work', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) }) + assert.ok(!cmd.includes('super_secret_token_value'), 'token must not appear in the spawn command') + assert.ok(!cmd.includes('HERMES_DASHBOARD_SESSION_TOKEN'), 'env var name must not appear') +}) + +test('buildSpawnCommand includes --ssh-session-token-file when tokenFilePath is provided', () => { + const cmd = buildSpawnCommand('/x/hermes', 'work', { + tokenFilePath: `~/.hermes/desktop-ssh/${OWNERSHIP_ID}/${SPAWN_NONCE}.token`, + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), + spawnNonce: SPAWN_NONCE + }) + assert.match(cmd, /--ssh-session-token-file/) + assert.match(cmd, /\.hermes\/desktop-ssh\//) +}) + +test('buildSpawnCommand always uses serve, never dashboard', () => { + const cmd = buildSpawnCommand('/x/hermes', '', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) }) + assert.match(cmd, /serve --isolated/) + assert.doesNotMatch(cmd, /\bdashboard\b/) + assert.doesNotMatch(cmd, /--skip-build/) + assert.doesNotMatch(cmd, /--no-open/) +}) + +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/ + ) + assert.ok(ssh.calls.some(command => /rm -f .*\.token/.test(command))) +}) + +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 '' + return '' + } + } + const { pid } = await spawnRemoteDashboard(ssh as any, { + 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') + for (const cmd of calls) { + assert.ok(!cmd.includes('secret_token_val'), `token leaked into command: ${cmd}`) + } +}) + +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 '' + return '' + } + } + await spawnRemoteDashboard(ssh as any, { + 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') + assert.match(uploadCmd, /O_EXCL/, 'upload must use O_EXCL to reject existing files') + assert.match(uploadCmd, /O_NOFOLLOW/, 'upload must use O_NOFOLLOW to reject symlinks') + assert.match(uploadCmd, /O_WRONLY/, 'upload must open write-only') + assert.match(uploadCmd, /dir_fd=dd/, 'upload must create relative to the opened parent directory') + assert.match(uploadCmd, /os\.fstat\(dd\)/, 'upload must validate the opened parent directory') + assert.ok(!uploadCmd.includes('tk'), 'token must not appear in the upload command') +}) + +test('readLockfile rejects lock with non-integer pid', async () => { + const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 'not-a-number', port: 8080 } + assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID), null) +}) + +test('readLockfile rejects lock with pid <= 0', async () => { + const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: -1, port: 8080 } + assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID), null) +}) + +test('readLockfile rejects lock with port out of range', async () => { + const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 100, port: 99999 } + assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID), null) + const lock2 = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 100, port: 0 } + assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock2)]]), OWNERSHIP_ID), null) +}) + +test('readLockfile accepts a complete owned lock', async () => { + const lock = ownedLock({ pid: 42, port: 51234 }) + const result = await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID) + assert.deepEqual(result, lock) +}) + +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'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/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') +}) + +test('spawnRemoteDashboard fails with update-required when remote lacks --ssh-session-token-file', async () => { + 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 + } + ) +}) + +test('readLockfile rejects a log path outside the exact ownership and spawn path', async () => { + const lock = ownedLock({ logPath: '~/.hermes/desktop-ssh/other.log' }) + const ssh = fakeSsh([[/cat .*lock\.json/, JSON.stringify(lock)]]) + assert.equal(await readLockfile(ssh, OWNERSHIP_ID), null) +}) + +test('cleanupStale never deletes a lock-supplied unexpected log path', async () => { + const ssh = fakeSsh([[/print\("OWNED"/, 'OWNED\n']]) + await cleanupStale(ssh, OWNERSHIP_ID, ownedLock({ logPath: '~/.hermes/unrelated.log' })) + assert.ok(!ssh.calls.some(command => command.includes('unrelated.log'))) +}) + +test('pidIsOurDashboard requires an exact nonce option value', async () => { + const prefix = `/x/hermes serve --isolated --ssh-owner-nonce ${SPAWN_NONCE}ff` + const suffix = `/x/hermes serve --isolated --ssh-owner-nonce xx${SPAWN_NONCE}` + assert.equal(await pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, 'FOREIGN\n']]), 5, SPAWN_NONCE, '/x/hermes'), false) + assert.equal(await pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, 'FOREIGN\n']]), 5, SPAWN_NONCE, '/x/hermes'), false) +}) + +test('connect removes the token file when a fresh backend fails after returning a pid', async () => { + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, ''], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/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))) +}) + +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'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/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') + assert.ok(!ssh.calls.some(command => /kill 333\b/.test(command))) + assert.ok(!ssh.calls.some(command => /rm -f .*backend\.lock\.json/.test(command))) +}) + +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'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0 333/, 'ALIVE'], + [/print\("OWNED"/, 'OWNED\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '999\n'], + [/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' + })) + 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' + } + ]]) + assert.equal(await remoteSupportsSshOwnership(supported, '/x/hermes'), true) + assert.match(helpProbe, /ssh-session-token-file/) + assert.match(helpProbe, /ssh-owner-nonce/) + + const unsupported = fakeSsh([[/serve --help/, 'NO\n']]) + assert.equal(await remoteSupportsSshOwnership(unsupported, '/x/hermes'), false) +}) diff --git a/apps/desktop/electron/remote-lifecycle.ts b/apps/desktop/electron/remote-lifecycle.ts new file mode 100644 index 00000000000..c649ad50b82 --- /dev/null +++ b/apps/desktop/electron/remote-lifecycle.ts @@ -0,0 +1,718 @@ +/** + * remote-lifecycle.ts + * + * Pure, electron-free remote Hermes dashboard lifecycle over SSH for Desktop + * SSH remote mode. Composes an SshConnection (injected) with HTTP probes + * through the established tunnel (injected fetch) and the served-token adoption + * step (injected). Knows how to: + * + * - locate the Hermes install on the remote (login-shell probe), + * - gate the remote platform to Linux/macOS via `uname`, + * - reuse an existing desktop-dedicated dashboard via a lockfile + an + * AUTHENTICATED /api/status probe (pid liveness alone is insufficient), + * - spawn a fresh detached `--isolated --port 0` dashboard and scrape its + * `HERMES_DASHBOARD_READY port=` readiness line, + * - adopt the token the dashboard actually serves (served-token adoption), + * - clean up a stale dashboard only when it is provably ours. + * + * No `import 'electron'` so it's unit-testable with `node --test`. main.ts wires + * the real SshConnection, fetch, adoptServedDashboardToken, and waitForHermes in. + * + * The minted HERMES_DASHBOARD_SESSION_TOKEN is the SPAWN credential. After + * readiness the caller runs served-token adoption against the tunneled baseUrl + * and the SERVED token's fingerprint is what lands in the lockfile — so the + * reuse probe checks the credential that actually authenticates /api/ws, not + * the minted one (which the dashboard may regen). + */ + +import crypto from 'node:crypto' + +const LOCKFILE_SCHEMA_VERSION = 2 +// Bumped when the desktop<->dashboard reuse contract changes in a way that makes +// an old running dashboard unsafe to reattach to (token handling, readiness/spawn +// args, served-token reconciliation). A mismatch forces a clean respawn. +const PROTOCOL_VERSION = 1 +const READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)/m +const REMOTE_LOCK_DIR = '~/.hermes/desktop-ssh' +const SUPPORTED_REMOTE_OS = new Set(['Linux', 'Darwin']) +const DEFAULT_READY_TIMEOUT_MS = 45_000 +const READY_POLL_INTERVAL_MS = 750 + +function mintToken() { + return crypto.randomBytes(32).toString('hex') +} + +// 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) +} + +function validateOwnershipId(ownershipId) { + const value = String(ownershipId || '') + 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.') + return value +} + +function ownershipDirectory(ownershipId) { + return `${REMOTE_LOCK_DIR}/${validateOwnershipId(ownershipId)}` +} + +function lockfilePath(ownershipId) { + return `${ownershipDirectory(ownershipId)}/backend.lock.json` +} + +function spawnLogPath(ownershipId, spawnNonce) { + return `${ownershipDirectory(ownershipId)}/${validateSpawnNonce(spawnNonce)}.log` +} + +// shell-single-quote a value for safe interpolation into a remote command. +function shq(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'` +} + +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 + 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)) + return shq(p) +} + +// Resolve the remote hermes executable. An EXPLICIT path is honored strictly +// (throws a path-naming error if not executable — never silently falls back to a +// different install). A BLANK path auto-detects: login-shell `command -v` (a +// non-login `ssh host cmd` PATH misses user installs), then known install paths. +async function locateHermes(ssh, remoteHermesPath) { + const resolveLauncher = async (candidate: string) => { + const script = + 'import os,shlex,sys\n' + + `p=os.path.expanduser(${shq(candidate)})\n` + + 'out=p\n' + + 'try:\n' + + ' data=open(p,"r",encoding="utf-8",errors="ignore").read(4096)\n' + + ' for line in data.splitlines():\n' + + ' words=shlex.split(line)\n' + + ' if len(words)>1 and words[0]=="exec":\n' + + ' target=os.path.expanduser(words[1])\n' + + ' if os.path.isabs(target) and os.access(target,os.X_OK):out=target\n' + + ' break\n' + + 'except (OSError,ValueError):pass\n' + + 'print(out)' + const resolved = (await ssh.exec(`python3 -c ${shq(script)}`)).trim() + return resolved || candidate + } + + const isExecutable = async (candidate: string) => { + try { + validateRemotePath(candidate) + const ok = (await ssh.exec(`[ -x ${expandRemotePath(candidate)} ] && echo OK || true`)).trim() + return ok === 'OK' + } catch { + return false + } + } + + if (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') + candidates.push('/usr/local/bin/hermes') + candidates.push('~/.hermes/hermes-agent/venv/bin/hermes') + + for (const candidate of candidates) { + if (!candidate) continue + if (await isExecutable(candidate)) { + return resolveLauncher(candidate) + } + } + + const err: any = new Error( + 'Hermes is not installed on the remote host (could not find a `hermes` executable). ' + + '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 +} + +// Probe the resolved binary's version string (first line of ` --version`, +// e.g. "Hermes Agent v0.18.2 ..."), or '' on failure. Surfaces WHICH hermes a +// connection uses, so a stale/unexpected install is visible. +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 '' + } +} + +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 and macOS remote hosts only.` + ) + err.kind = 'unsupported-platform' + throw err + } + return { os: osName, arch } +} + +// The HERMES_HOME the remote dashboard will use (explicit env wins, else +// ~/.hermes). Recorded in the lockfile so a future reuse can tell it's the same +// state store; best-effort. +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.') + error.kind = 'transient-transport-error' + error.cause = cause + throw error + } +} + +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) { + const error: any = new Error('Could not read the SSH backend ownership record.') + error.kind = 'transient-transport-error' + error.cause = cause + throw error + } + const text = String(raw || '').trim() + 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(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 +} + +async function writeLockfile(ssh, ownershipId, lock) { + const directory = ownershipDirectory(ownershipId) + const lpath = lockfilePath(ownershipId) + const temporaryPath = `${directory}/.${crypto.randomBytes(8).toString('hex')}.lock.tmp` + const json = JSON.stringify({ ...lock, schemaVersion: LOCKFILE_SCHEMA_VERSION }) + await ssh.exec( + `umask 077 && mkdir -p ${expandRemotePath(directory)} && ` + + `printf '%s' ${shq(json)} > ${expandRemotePath(temporaryPath)} && ` + + `mv -f ${expandRemotePath(temporaryPath)} ${expandRemotePath(lpath)}` + ) +} + +async function removeLockfile(ssh, ownershipId) { + const lpath = lockfilePath(ownershipId) + try { + await ssh.exec(`rm -f ${expandRemotePath(lpath)}`) + } catch { + // best effort + } +} + +async function remotePidAlive(ssh, pid) { + 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.') + error.kind = 'transient-transport-error' + error.cause = cause + throw error + } +} + +// 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 + try { + const script = + 'import os,shlex,subprocess,sys\n' + + `pid=${Number(pid)}\n` + + `expected=os.path.expanduser(${shq(hermesPath)})\n` + + `nonce=${shq(spawnNonce)}\n` + + 'try:\n' + + ' raw=open(f"/proc/{pid}/cmdline","rb").read()\n' + + ' args=[x.decode("utf-8","surrogateescape") for x in raw.split(b"\\0") if x]\n' + + 'except OSError:\n' + + ' line=subprocess.check_output(["ps","-o","command=","-p",str(pid)],text=True).strip()\n' + + ' args=shlex.split(line)\n' + + 'ok=False\n' + + 'try:\n' + + ' serve=args.index("serve")\n' + + ' owner=args.index("--ssh-owner-nonce",serve+1)\n' + + ' direct=args[0]==expected\n' + + ' python_entry=len(args)>1 and args[1]==expected and os.path.basename(args[0]).startswith("python")\n' + + ' 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.') + error.kind = 'transient-transport-error' + error.cause = cause + throw error + } +} + +// Kill the stale dashboard ONLY if provably ours, then drop the lockfile. +async function cleanupStale(ssh, ownershipId, lock) { + if (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() + void result + } catch (cause) { + const error: any = new Error('Could not terminate the stale SSH backend.') + error.kind = 'transient-transport-error' + error.cause = cause + throw error + } + } + const expectedLogPath = lock?.spawnNonce ? spawnLogPath(ownershipId, lock.spawnNonce) : '' + if (lock?.logPath === expectedLogPath) { + try { await ssh.exec(`rm -f ${expandRemotePath(lock.logPath)}`) } catch {} + } + await removeLockfile(ssh, ownershipId) +} + +// Detach so the backend survives the SSH channel closing: setsid (Linux) +// starts a new session; macOS has no setsid, so fall back to nohup (HUP-immune; +// fd-detachment is already handled by > ${logPath} 2>&1 & echo $!`)}` + ) +} + +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') +} + +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 +} + +async function spawnRemoteDashboard(ssh, { hermesPath, profile, token, ownershipId }) { + if (!(await remoteSupportsSshOwnership(ssh, hermesPath))) { + const err: any = new Error( + '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 + } + + const spawnNonce = crypto.randomBytes(8).toString('hex') + const tokenDir = ownershipDirectory(ownershipId) + const tokenFilePath = `${tokenDir}/${spawnNonce}.token` + const logPath = spawnLogPath(ownershipId, spawnNonce) + + const tokenUploadPy = + 'import os,sys,stat\n' + + `p=os.path.expanduser(${shq(tokenFilePath)})\n` + + 'd=os.path.dirname(p)\n' + + 'n=os.path.basename(p)\n' + + 'os.makedirs(d,mode=0o700,exist_ok=True)\n' + + 'df=os.O_RDONLY|getattr(os,"O_DIRECTORY",0)|getattr(os,"O_NOFOLLOW",0)\n' + + 'dd=os.open(d,df)\n' + + 'try:\n' + + ' s=os.fstat(dd)\n' + + ' if not stat.S_ISDIR(s.st_mode):raise SystemExit("unsafe token directory")\n' + + ' if hasattr(os,"getuid") and s.st_uid!=os.getuid():raise SystemExit("token directory owner mismatch")\n' + + ' if (s.st_mode&0o777)!=0o700:os.fchmod(dd,0o700)\n' + + ' fl=os.O_WRONLY|os.O_CREAT|os.O_EXCL|getattr(os,"O_NOFOLLOW",0)\n' + + ' now=__import__("time").time()\n' + + ' for stale in os.listdir(dd):\n' + + ' if stale.endswith(".token") and len(stale)==22:\n' + + ' try:\n' + + ' ss=os.stat(stale,dir_fd=dd,follow_symlinks=False)\n' + + ' if stat.S_ISREG(ss.st_mode) and now-ss.st_mtime>3600:os.unlink(stale,dir_fd=dd)\n' + + ' except OSError:pass\n' + + ' fd=os.open(n,fl,0o600,dir_fd=dd)\n' + + ' try:os.write(fd,sys.stdin.buffer.read())\n' + + ' except BaseException:\n' + + ' try:os.unlink(n,dir_fd=dd)\n' + + ' except OSError:pass\n' + + ' 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 {} + throw error + } + + let out + try { + out = await ssh.exec( + buildSpawnCommand(hermesPath, profile, { spawnNonce, tokenFilePath, logPath }) + ) + } catch (error) { + try { await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`) } catch {} + throw error + } + 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 {} + 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 + try { + await deps.cancelForward(localPort, remotePort) + } catch { + // best effort + } +} + +function assertNotAborted(signal) { + if (signal?.aborted) { + const error: any = new Error('SSH bootstrap was cancelled.') + error.kind = 'superseded' + throw error + } +} + +function isForwardBindCollision(error) { + return /address already in use|cannot listen to port|bind.*failed/i.test(String(error?.message || 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 + } + } + throw lastError +} + +/** + * Establish (or reuse) a remote dashboard and a tunnel to it. `deps` injects the + * opened SshConnection, forward/pickLocalPort/waitForHermes, a token-gated + * probeReuseProof, and adoptServedToken. Returns the connection descriptor + * { baseUrl, token, tokenFingerprint, remotePort, localPort, pid, reused, platform }. + */ +async function adoptOwnedServedToken(adoptServedToken, baseUrl, expectedToken, ssh, pid, label) { + const token = await adoptServedToken(baseUrl, expectedToken, { + 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 +} + +async function connect(deps) { + const { + ssh, + profile = '', + remoteHermesPath = '', + ownershipId, + forward, + pickLocalPort, + waitForHermes, + probeReuseProof, + adoptServedToken, + rememberLog = () => {}, + readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS, + signal + } = deps + + const log = msg => rememberLog(`[ssh-lifecycle] ${msg}`) + + assertNotAborted(signal) + const platform = await probeRemotePlatform(ssh) + log(`remote platform ${platform.os}/${platform.arch}`) + const hermesPath = await locateHermes(ssh, remoteHermesPath) + log(`located hermes at ${hermesPath}`) + const hermesVersion = await probeHermesVersion(ssh, hermesPath) + 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 = await pidIsOurDashboard(ssh, lock.pid, lock.spawnNonce, lock.hermesPath) + const reusable = pidAlive && owned && Boolean(reuseToken) && + lock.tokenFingerprint === fingerprintToken(reuseToken) && + 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) { + const error: any = new Error('Could not verify the existing SSH backend.') + error.kind = 'transient-transport-error' + 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' + ) + assertNotAborted(signal) + log(`reusing remote dashboard pid=${lock.pid} port=${lock.port}`) + return { + baseUrl, + token, + tokenFingerprint: fingerprintToken(token), + remotePort: lock.port, + localPort, + pid: lock.pid, + reused: true, + platform, + hermesPath, + hermesVersion, + ownershipId, + spawnNonce: lock.spawnNonce, + logPath: lock.logPath + } + } else { + const error: any = new Error('SSH reuse proof returned an invalid classification.') + error.kind = 'transient-transport-error' + throw error + } + } catch (error) { + await cancelForwardSafe(deps, localPort, lock.port) + throw error + } + } else { + assertNotAborted(signal) + await cleanupStale(ssh, ownershipId, lock) + } + } + + 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 = { + ownershipId, + spawnNonce, + pid, + port: 0, + profile, + hermesPath, + hermesHome, + logPath, + tokenFingerprint: fingerprintToken(spawnToken), + protocolVersion: PROTOCOL_VERSION, + startedAt: new Date().toISOString() + } + let localPort = 0 + let remotePort = 0 + try { + remotePort = await scrapeReadyPort(ssh, logPath, { + timeoutMs: readyTimeoutMs, + isAlive: () => remotePidAlive(ssh, pid), + signal + }) + assertNotAborted(signal) + log(`remote dashboard bound port ${remotePort}`) + + localPort = await openForward(deps, remotePort) + assertNotAborted(signal) + const baseUrl = `http://127.0.0.1:${localPort}` + await waitForHermes(baseUrl, spawnToken) + assertNotAborted(signal) + + 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 }) + assertNotAborted(signal) + + return { + baseUrl, + token, + tokenFingerprint, + remotePort, + localPort, + pid, + reused: false, + platform, + hermesPath, + hermesVersion, + ownershipId, + spawnNonce, + logPath + } + } catch (error) { + if (localPort && remotePort) await cancelForwardSafe(deps, localPort, remotePort) + try { await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`) } catch {} + 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, + expandRemotePath, + fingerprintToken, + locateHermes, + lockfilePath, + ownershipDirectory, + spawnLogPath, + isForwardBindCollision, + openForward, + mintToken, + pidIsOurDashboard, + probeRemotePlatform, + probeHermesVersion, + probeRemoteHermesHome, + remoteSupportsSshOwnership, + readLockfile, + remotePidAlive, + removeLockfile, + scrapeReadyPort, + shq, + spawnRemoteDashboard, + validateRemotePath, + writeLockfile +} diff --git a/apps/desktop/electron/ssh-bootstrap-coordinator.test.ts b/apps/desktop/electron/ssh-bootstrap-coordinator.test.ts new file mode 100644 index 00000000000..88285272805 --- /dev/null +++ b/apps/desktop/electron/ssh-bootstrap-coordinator.test.ts @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict' +import { test } from 'vitest' + +import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator' + +function deferred() { + let resolve + let reject + const promise = new Promise((ok, fail) => { + resolve = ok + reject = fail + }) + return { promise, reject, resolve } +} + +const config = { host: 'box', user: 'alice', port: 22, keyPath: '/key', remoteHermesPath: '/hermes' } + +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' })) { + assert.notEqual(base, sshConfigFingerprint('', { ...config, [field]: value })) + } + assert.notEqual(base, sshConfigFingerprint('profile', config)) +}) + +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') + assert.equal(runs, 1) +}) + +test('changed fingerprint waits for old rollback before starting', async () => { + const coordinator = createBootstrapCoordinator() + const gate = deferred() + const events: string[] = [] + let oldLease + const oldPromise = coordinator.start('', 'old', async lease => { + oldLease = lease + events.push('old-start') + await gate.promise + 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']) + gate.resolve() + await assert.rejects(oldPromise, (error: any) => error.kind === 'superseded') + assert.equal(await newPromise, 'new') + assert.deepEqual(events, ['old-start', 'old-rollback', 'new-start']) +}) + +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++ }) + await gate.promise + }) + await Promise.resolve() + await coordinator.forceCleanupAll() + assert.equal(cleaned, 1) + gate.resolve() + await promise +}) + +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() + })) + assert.equal(coordinator.promises().length, 2) + coordinator.cancelAll() + gates.forEach(gate => gate.resolve()) + const results = await Promise.allSettled(promises) + assert.ok(results.every(result => result.status === 'rejected' && (result.reason as any).kind === 'superseded')) +}) + +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 }) + await Promise.resolve() + assert.equal(drained, false) + firstGate.resolve() + await drain + await assert.rejects(first, (error: any) => error.kind === 'superseded') + assert.equal(coordinator.pending.has('second'), true) + secondGate.resolve() + assert.equal(await second, 'second') +}) + +test('a generation started during cancelAndWait cannot run before the drain completes', async () => { + 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() + await drain + await assert.rejects(old, (error: any) => error.kind === 'superseded') + assert.equal(await next, 'new') + assert.deepEqual(events, ['old-start', 'new-start']) +}) diff --git a/apps/desktop/electron/ssh-bootstrap-coordinator.ts b/apps/desktop/electron/ssh-bootstrap-coordinator.ts new file mode 100644 index 00000000000..0be1e350d51 --- /dev/null +++ b/apps/desktop/electron/ssh-bootstrap-coordinator.ts @@ -0,0 +1,89 @@ +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') +} + +function createBootstrapCoordinator() { + const active = new Set() + const pending = new Map() + const generations = new Map() + const drains = new Map>() + + function start(scope, fingerprint, run) { + const current = pending.get(scope) + 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, + assertCurrent() { + if (!this.isCurrent()) { + const error: any = new Error('SSH bootstrap was superseded by newer connection settings.') + error.kind = 'superseded' + throw error + } + } + } + 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) + }) + entry.promise = promise + active.add(entry) + pending.set(scope, entry) + return promise + } + + function cancel(scope) { + pending.get(scope)?.controller.abort() + } + + async function cancelAndWait(scope) { + let release + 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() + try { + await Promise.allSettled(entries.map(entry => entry.promise)) + } finally { + if (drains.get(scope) === barrier) drains.delete(scope) + release() + } + } + + function cancelAll() { + for (const entry of active) entry.controller.abort() + } + + async function forceCleanupAll() { + const cleanups = [...active].flatMap(entry => [...entry.forceCleanups]) + await Promise.allSettled(cleanups.map(cleanup => cleanup())) + } + + function promises() { + return [...active].map(entry => entry.promise) + } + + return { active, cancel, cancelAll, cancelAndWait, forceCleanupAll, pending, promises, start } +} + +export { createBootstrapCoordinator, sshConfigFingerprint } diff --git a/apps/desktop/electron/ssh-config.test.ts b/apps/desktop/electron/ssh-config.test.ts new file mode 100644 index 00000000000..50e9c67725c --- /dev/null +++ b/apps/desktop/electron/ssh-config.test.ts @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict' +import { test } from 'vitest' + +import { collectSshConfigHosts, parseSshConfigHosts, parseSshConfigIncludes, parseSshGOutput } from './ssh-config' + +test('parseSshConfigHosts keeps literal aliases and drops wildcard/negated patterns', () => { + const cfg = [ + 'Host devbox', + ' HostName 10.0.0.5', + 'Host *.internal prod !staging glob*', + 'Host alpha beta', + '# Host commented-out', + 'host lower-case' + ].join('\n') + assert.deepEqual(parseSshConfigHosts(cfg), ['devbox', 'prod', 'alpha', 'beta', 'lower-case']) +}) + +test('parseSshConfigHosts de-duplicates', () => { + assert.deepEqual(parseSshConfigHosts('Host box\nHost box\nHost box other'), ['box', 'other']) +}) + +test('parseSshConfigIncludes extracts include tokens', () => { + const cfg = 'Include ~/.ssh/config.d/*\nInclude work_hosts personal_hosts\n# Include ignored' + assert.deepEqual(parseSshConfigIncludes(cfg), ['~/.ssh/config.d/*', 'work_hosts', 'personal_hosts']) +}) + +test('collectSshConfigHosts follows Include directives (read-only)', () => { + const files = { + '/home/u/.ssh/config': 'Host main\nInclude work\nInclude ~/abs_inc', + '/home/u/.ssh/work': 'Host work-box\nInclude nested', + '/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()) +}) + +test('collectSshConfigHosts tolerates a missing config file', () => { + assert.deepEqual(collectSshConfigHosts('/nope/config', { homeDir: '/home/u', readFile: () => null }), []) +}) + +test('collectSshConfigHosts does not loop on a self-include cycle', () => { + const files = { + '/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']) +}) + +test('collectSshConfigHosts expands globbed includes via injected globSync', () => { + const files = { + '/home/u/.ssh/config': 'Host root\nInclude config.d/*', + '/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()) +}) + +test('parseSshGOutput pulls hostname/user/port/identityfile', () => { + const out = [ + 'host devbox', + 'hostname 10.0.0.5', + 'user alice', + 'port 2222', + 'identityfile ~/.ssh/id_ed25519', + 'forwardagent no' + ].join('\n') + assert.deepEqual(parseSshGOutput(out), { + hostname: '10.0.0.5', + user: 'alice', + port: 2222, + identityFile: '~/.ssh/id_ed25519' + }) +}) + +test('parseSshGOutput takes the FIRST identityfile and tolerates missing keys', () => { + const out = 'hostname box\nidentityfile ~/.ssh/a\nidentityfile ~/.ssh/b' + const parsed = parseSshGOutput(out) + assert.equal(parsed.identityFile, '~/.ssh/a') + assert.equal(parsed.user, null) + assert.equal(parsed.port, null) +}) diff --git a/apps/desktop/electron/ssh-config.ts b/apps/desktop/electron/ssh-config.ts new file mode 100644 index 00000000000..6be33dc9aaa --- /dev/null +++ b/apps/desktop/electron/ssh-config.ts @@ -0,0 +1,119 @@ +/** + * ssh-config.ts + * + * Pure, electron-free helpers for reading the user's OpenSSH client config: + * `Host` aliases for the settings UI's suggestions, `Include` traversal + * (read-only), and `ssh -G` output parsing. No `import 'electron'` so it's + * unit-testable without Electron; main.ts wires the fs + `ssh -G` exec in. + */ + +import fs from 'node:fs' +import os from 'node:os' +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 + const m = /^host\s+(.+)$/i.exec(line) + 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 + const m = /^include\s+(.+)$/i.exec(line) + if (!m) continue + for (const token of m[1].split(/\s+/)) { + if (token) includes.push(token) + } + } + return includes +} + +function collectSshConfigHosts(rootPath = '', deps: any = {}) { + const readFile = + deps.readFile || + (p => { + try { + return fs.readFileSync(p, 'utf8') + } catch { + return null + } + }) + const homeDir = deps.homeDir || os.homedir() + const root = rootPath || path.join(homeDir, '.ssh', 'config') + const sshDir = path.join(homeDir, '.ssh') + + const out: string[] = [] + const seen = new Set() + const visited = new Set() + + const resolveIncludePath = 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 + visited.add(filePath) + const text = readFile(filePath) + 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) + } + } + } + + walk(root, 0) + return out +} + +function parseSshGOutput(text) { + const out: { hostname: string | null; user: string | null; port: number | null; identityFile: string | null } = { + hostname: null, + user: null, + port: null, + identityFile: null + } + for (const rawLine of String(text || '').split('\n')) { + const line = rawLine.trim() + if (!line) continue + const sp = line.indexOf(' ') + 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 + } + return out +} + +export { collectSshConfigHosts, parseSshConfigHosts, parseSshConfigIncludes, parseSshGOutput } diff --git a/apps/desktop/electron/ssh-connection.test.ts b/apps/desktop/electron/ssh-connection.test.ts new file mode 100644 index 00000000000..d62499bd352 --- /dev/null +++ b/apps/desktop/electron/ssh-connection.test.ts @@ -0,0 +1,692 @@ +import assert from 'node:assert/strict' +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, + buildInteractiveSshArgs, + buildMasterArgs, + classifySshError, + controlSocketPath, + createSshProbeConnection, + forwardSpec, + hostArgs, + redactSecrets, + runSsh, + stopTunnelChild, + sshErrorMessage, + 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) + assert.ok(!out.includes('abc123deadbeef')) + assert.match(out, /HERMES_DASHBOARD_SESSION_TOKEN=/) + // non-secret env vars are preserved + assert.match(out, /HERMES_DESKTOP=1/) +}) + +test('redactSecrets scrubs ?token= and ?ticket= URL params', () => { + assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?token=supersecret'), /\?token=/) + assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?ticket=onetimeticket'), /\?ticket=/) + assert.match(redactSecrets('GET /x?a=1&token=zzz HTTP'), /&token=/) + assert.ok(!redactSecrets('?token=supersecret').includes('supersecret')) +}) + +test('redactSecrets scrubs Authorization and X-Hermes-Session-Token headers', () => { + assert.match(redactSecrets('Authorization: Bearer tok_9999'), /Authorization: Bearer /) + assert.ok(!redactSecrets('Authorization: Bearer tok_9999').includes('tok_9999')) + assert.match(redactSecrets('X-Hermes-Session-Token: hdr_888'), /X-Hermes-Session-Token: ?/) + assert.ok(!redactSecrets('X-Hermes-Session-Token: hdr_888').includes('hdr_888')) +}) + +test('redactSecrets handles null/undefined and non-secret text untouched', () => { + assert.equal(redactSecrets(null), '') + assert.equal(redactSecrets(undefined), '') + 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') + const b = controlSocketPath('me', 'box2', 22, '/tmp/d') + assert.equal(a, a2, 'same triple → same socket (ControlMaster reuse)') + assert.notEqual(a, b, 'different host → different socket') + // 16 hex chars + .sock keeps the basename short for sun_path 104-byte limit + assert.match(a, /\/[0-9a-f]{16}\.sock$/) +}) + +test('controlSocketPath default base stays under sun_path even with the temp-listener suffix', () => { + // OpenSSH binds a temporary listener at `.<16 random chars>` (a + // 17-byte suffix) while opening the master. The macOS regression was the + // default base under os.tmpdir() (/var/folders/.../T/) pushing it over 104. + const p = controlSocketPath('hermes', 'remote-build-server', 22) // no baseDir → default + const worstCase = `${p}.0123456789abcdef` // mimic the .<16-char> temp suffix + assert.ok( + worstCase.length <= 104, + `default control socket + temp suffix must fit sun_path (got ${worstCase.length}: ${worstCase})` + ) + // And it must NOT live under the deeply-nested macOS per-user temp dir. + 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(' ') + assert.match(joined, /ControlPath=\/tmp\/x\.sock/) + assert.match(joined, /ControlMaster=auto/) + assert.match(joined, /ControlPersist=\d+/) + assert.match(joined, /BatchMode=yes/) + assert.match(joined, /StrictHostKeyChecking=accept-new/) + assert.match(joined, /ExitOnForwardFailure=yes/) + assert.match(joined, /ConnectTimeout=15/) + assert.ok(!joined.includes('StrictHostKeyChecking=no'), 'never disables host-key checking') +}) + +test('hostArgs adds -p only for non-default port and -i only with a key', () => { + assert.deepEqual(hostArgs({ port: 22 }), []) + assert.deepEqual(hostArgs({ port: 2222 }), ['-p', '2222']) + assert.deepEqual(hostArgs({ port: 22, keyPath: '/k' }), ['-i', '/k']) + assert.deepEqual(hostArgs({ port: 2200, keyPath: '/k' }), ['-p', '2200', '-i', '/k']) +}) + +test('target builds user@host or bare host', () => { + assert.equal(target('me', 'box'), 'me@box') + assert.equal(target('', 'box'), 'box') +}) + +test('buildExecArgs ends with host then the remote command', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildExecArgs(conn, 'command -v hermes', 15000) + assert.equal(args[args.length - 1], 'command -v hermes') + assert.equal(args[args.length - 2], 'me@box') + assert.ok(args.includes('BatchMode=yes')) +}) + +test('buildControlArgs places -O first and never appends a remote command', () => { + const conn = { user: 'me', host: 'box', port: 2222, keyPath: '/k', controlPath: '/tmp/x.sock' } + const args = buildControlArgs(conn, 'forward', ['-L', forwardSpec(5000, 6000)], 15000) + assert.equal(args[0], '-O') + assert.equal(args[1], 'forward') + assert.ok(args.includes('-L')) + assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000')) + assert.equal(args[args.length - 1], 'me@box') +}) + +test('buildMasterArgs requests a backgrounded master (-M -N -f)', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildMasterArgs(conn, 15000) + assert.ok(args.includes('-M')) + assert.ok(args.includes('-N')) + assert.ok(args.includes('-f')) +}) + +test('forwardSpec binds the local end to 127.0.0.1 only', () => { + assert.equal(forwardSpec(5000, 6000), '127.0.0.1:5000:127.0.0.1:6000') + assert.ok(forwardSpec(5000, 6000).startsWith('127.0.0.1:')) + assert.ok(!forwardSpec(5000, 6000).startsWith('0.0.0.0')) +}) + +test('buildInteractiveSshArgs requests a PTY, reuses the control master, execs a login shell', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildInteractiveSshArgs(conn, '', 15000) + assert.equal(args[0], '-tt', 'forces a PTY so the remote sees a real terminal') + assert.ok(args.join(' ').includes('ControlPath=/tmp/x.sock'), 'reuses the existing master (no new auth)') + assert.equal(args[args.length - 2], 'me@box') + assert.equal(args[args.length - 1], 'exec "$SHELL" -l') +}) + +test('buildInteractiveSshArgs cds into the remote cwd (best-effort) before the shell', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildInteractiveSshArgs(conn, '/home/me/project', 15000) + const remoteCmd = args[args.length - 1] + assert.match(remoteCmd, /^cd '\/home\/me\/project' 2>\/dev\/null; exec "\$SHELL" -l$/) +}) + +test('buildInteractiveSshArgs single-quotes a cwd with quotes safely', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildInteractiveSshArgs(conn, "/tmp/a'b", 15000) + // the embedded quote must be escaped, not break out of the quoting + assert.ok(args[args.length - 1].startsWith("cd '/tmp/a'")) + 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('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) +}) + +test('classifySshError detects auth failure', () => { + assert.equal(classifySshError('Permission denied (publickey).'), SSH_ERROR.AUTH_FAILED) + assert.equal(classifySshError('Too many authentication failures'), SSH_ERROR.AUTH_FAILED) +}) + +test('classifySshError detects unreachable', () => { + assert.equal(classifySshError('ssh: Could not resolve hostname nope'), SSH_ERROR.UNREACHABLE) + assert.equal(classifySshError('connect to host x port 22: Connection refused'), SSH_ERROR.UNREACHABLE) +}) + +test('sshErrorMessage gives actionable guidance for auth and host-key-change', () => { + const conn = { user: 'me', host: 'box', port: 22 } + assert.match(sshErrorMessage(SSH_ERROR.AUTH_FAILED, conn, 'Permission denied'), /ssh-agent|ssh-add|IdentityFile/) + 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)) + child.emit('close', code) + }) + return child +} + +// Build a spawnFn that returns scripted children per ssh invocation, recording +// the args it was called with. +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 +} + +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' } + 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') +}) + +test('open() is a no-op when the master is already alive', async () => { + const ops: string[] = [] + const spawnFn = scriptedSpawn(args => { + ops.push(args.includes('check') ? 'check' : 'master') + return { code: 0 } // check succeeds → already alive + }) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) + await conn.open() + assert.deepEqual(ops, ['check'], 'alive master → no second spawn to open it') +}) + +test('open() creates the control-socket directory if it does not exist', async () => { + const dir = path.join(os.tmpdir(), `hermes-ssh-test-${process.pid}-${Date.now()}`) + 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') + } finally { + try { + fs.rmSync(dir, { recursive: true, force: true }) + } catch { + /* ignore */ + } + } +}) + +test('open() surfaces a classified auth error', async () => { + const spawnFn = scriptedSpawn(args => { + 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 + } + ) +}) + +test('exec() returns stdout on success and rejects (classified) on failure', async () => { + const okSpawn = scriptedSpawn([{ code: 0, stdout: 'Linux\n' }]) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: okSpawn, controlDir: '/tmp/d' }) + assert.equal((await conn.exec('uname -s')).trim(), 'Linux') + + const failSpawn = scriptedSpawn([{ code: 1, stderr: 'ssh: Could not resolve hostname box' }]) + const conn2 = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: failSpawn, controlDir: '/tmp/d' }) + await assert.rejects( + () => conn2.exec('uname -s'), + (err: any) => { + assert.equal(err.kind, SSH_ERROR.UNREACHABLE) + return true + } + ) +}) + +test('exec() treats a hung ssh as a timeout (half-open connection)', async () => { + const spawnFn = scriptedSpawn([{ hang: true }]) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) + await assert.rejects( + () => conn.exec('uname -s', { timeoutMs: 30 }), + (err: any) => { + assert.equal(err.kind, SSH_ERROR.TIMEOUT) + return true + } + ) +}) + +test('forward() issues -O forward with a loopback-bound -L spec', async () => { + const spawnFn = scriptedSpawn([{ code: 0 }]) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) + await conn.forward(5000, 6000) + const args = spawnFn.calls[0] + assert.equal(args[0], '-O') + assert.equal(args[1], 'forward') + assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000')) +}) + +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}`) + } +}) + +test('no-mux: open() verifies auth with a one-shot exec, no -M master', async () => { + const spawnFn = scriptedSpawn({ code: 0 }) + 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] === 'true'), 'liveness/openness via one-shot exec') +}) + +test('SSH probe never creates or closes a ControlMaster', async () => { + const spawnFn = scriptedSpawn({ code: 0 }) + const conn = createSshProbeConnection({ host: 'box', user: 'me' }, { spawnFn }) + await conn.open() + await conn.close() + const args = spawnFn.calls.flat() + assert.ok(!args.includes('-M')) + assert.ok(!args.includes('-O')) + assert.ok(!args.some(value => /Control(?:Master|Path|Persist)/.test(value))) +}) + +test('no-mux: open() classifies auth failure', async () => { + const spawnFn = scriptedSpawn([{ code: 255, stderr: 'me@box: Permission denied (publickey).' }]) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false }) + await assert.rejects(conn.open(), (err: any) => err.kind === 'auth-failed') +}) + +test('no-mux: forward spawns a persistent -N -L child; cancel + close kill it', async () => { + // Real listener stands in for the tunnel's local end so waitForLocalPort sees it. + const net = await import('node:net') + const srv = net.createServer() + 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)) + 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') + assert.ok(tunnels[0].args.includes('-L'), 'tunnel child carries -L spec') + assert.ok(!tunnels[0].args.some(a => /ControlPath/.test(a))) + + await conn.cancelForward(localPort, 9119) + assert.ok(tunnels[0].child._killed, 'cancelForward kills the tunnel child') + + conn._opened = true + await conn.close() // no-mux close never runs ssh -O exit; must not throw + srv.close() +}) + +test('no-mux: forward fails fast when the tunnel child dies (bad spec/auth)', async () => { + const spawnFn: any = (_cmd, args) => { + 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') +}) + +test('no-mux: an unrelated listener cannot mask a delayed bind failure', async () => { + const net = await import('node:net') + 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`)) + child.exitCode = 255 + 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() +}) + +test('no-mux: tunnel death after readiness makes the connection unhealthy', async () => { + const net = await import('node:net') + const srv = net.createServer() + 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)) + return child + } + const conn = new SshConnection({ host: 'box' }, { spawnFn, mux: false }) + await conn.open() + await conn.forward(localPort, 9119) + tunnel.emit('exit', 255) + assert.equal(await conn.isAlive(), false) + 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) +}) + +test('validateSshTarget rejects control characters in host', () => { + assert.throws(() => validateSshTarget('host\x00evil', '', 22), /unsafe/i) + assert.throws(() => validateSshTarget('host\nnewline', '', 22), /unsafe/i) + assert.throws(() => validateSshTarget('host\ttab', '', 22), /unsafe/i) +}) + +test('validateSshTarget rejects control characters in user', () => { + assert.throws(() => validateSshTarget('box', 'me\x00root', 22), /unsafe/i) + assert.throws(() => validateSshTarget('box', '-oForward=yes', 22), /unsafe/i) +}) + +test('validateSshTarget rejects ports outside 1-65535', () => { + assert.throws(() => validateSshTarget('box', '', 0), /port/i) + assert.throws(() => validateSshTarget('box', '', 65536), /port/i) + assert.throws(() => validateSshTarget('box', '', -1), /port/i) + assert.throws(() => validateSshTarget('box', '', NaN), /port/i) +}) + +test('validateSshTarget accepts valid targets', () => { + assert.doesNotThrow(() => validateSshTarget('my-host.example.com', 'alice', 22)) + assert.doesNotThrow(() => validateSshTarget('192.168.1.1', '', 2222)) + assert.doesNotThrow(() => validateSshTarget('::1', 'root', 22)) +}) + +test('SshConnection constructor rejects hostile host/user/port', () => { + assert.throws(() => new SshConnection({ host: '-oProxyCommand=evil' }), /unsafe/i) + assert.throws(() => new SshConnection({ host: 'box', user: '-oForward' }), /unsafe/i) + assert.throws(() => new SshConnection({ host: 'box', port: 99999 }), /port/i) +}) + +test('buildExecArgs inserts -- before the destination', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildExecArgs(conn, 'uname -s', 15000) + const ddIdx = args.indexOf('--') + assert.ok(ddIdx >= 0, 'must contain --') + assert.equal(args[ddIdx + 1], 'me@box', '-- immediately precedes the destination') + assert.equal(args[ddIdx + 2], 'uname -s', 'remote command follows destination') +}) + +test('buildMasterArgs inserts -- before the destination', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildMasterArgs(conn, 15000) + const ddIdx = args.indexOf('--') + assert.ok(ddIdx >= 0, 'must contain --') + assert.equal(args[ddIdx + 1], 'me@box') +}) + +test('buildControlArgs inserts -- before the destination', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildControlArgs(conn, 'check', [], 15000) + const ddIdx = args.indexOf('--') + assert.ok(ddIdx >= 0, 'must contain --') + assert.equal(args[ddIdx + 1], 'me@box') +}) + +test('buildInteractiveSshArgs inserts -- before the destination', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildInteractiveSshArgs(conn, '', 15000) + const ddIdx = args.indexOf('--') + assert.ok(ddIdx >= 0, 'must contain --') + assert.equal(args[ddIdx + 1], 'me@box') +}) + +test('hostArgs rejects a keyPath with control characters', () => { + assert.throws(() => hostArgs({ keyPath: '/tmp/key\x00inject' }), /unsafe/i) +}) + +test('hostArgs rejects a keyPath starting with a dash', () => { + assert.throws(() => hostArgs({ keyPath: '-oProxyCommand=evil' }), /unsafe/i) +}) + +test('hostArgs accepts valid key paths', () => { + assert.deepEqual(hostArgs({ keyPath: '/home/user/.ssh/id_ed25519' }), ['-i', '/home/user/.ssh/id_ed25519']) + assert.deepEqual(hostArgs({ keyPath: '~/.ssh/id_rsa' }), ['-i', '~/.ssh/id_rsa']) +}) + +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) } + } + 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') +}) + +test('open() rejects a control-dir that is a symlink', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-test-')) + const real = path.join(tmp, 'real') + const link = path.join(tmp, 'link') + fs.mkdirSync(real, { mode: 0o700 }) + fs.symlinkSync(real, link) + const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 })) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: link }) + await assert.rejects(conn.open(), /symlink|unsafe/i) + fs.rmSync(tmp, { recursive: true, force: true }) +}) + +test('open() enforces 0700 on an existing control dir with lax permissions', async () => { + 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 }) + const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 })) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir }) + await conn.open() + const stat = fs.statSync(dir) + assert.equal(stat.mode & 0o777, 0o700, 'control dir must be tightened to 0700') + fs.rmSync(tmp, { recursive: true, force: true }) +}) + +test('control socket identity separates installation scope and key identity', () => { + const base = controlSocketPath('me', 'box', 22, '/tmp/d', { + ownershipId: 'installation-a', + 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' + })) +}) + +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' + }) + first._opened = true + second._opened = true + await first.close() + assert.notEqual(first.controlPath, second.controlPath) + assert.ok(firstSpawn.calls[0].includes(`ControlPath=${first.controlPath}`)) + assert.ok(!firstSpawn.calls[0].includes(`ControlPath=${second.controlPath}`)) + assert.equal(second._opened, true) +}) + +test('failed ControlMaster close remains retryable', async () => { + const spawnFn = scriptedSpawn([{ code: 255, stderr: 'master refused exit' }, { code: 0 }]) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) + conn._opened = true + await conn.close() + assert.equal(conn._opened, true) + await conn.close() + assert.equal(conn._opened, false) + assert.equal(spawnFn.calls.length, 2) +}) + +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 }) + 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 new file mode 100644 index 00000000000..3d8257fa9a1 --- /dev/null +++ b/apps/desktop/electron/ssh-connection.ts @@ -0,0 +1,664 @@ +/** + * ssh-connection.ts + * + * Pure, electron-free OpenSSH ControlMaster connection manager for Desktop SSH + * remote mode. Uses the system `ssh` client (not a JS SSH library) so it + * inherits ~/.ssh/config, the agent, jump hosts (ProxyJump), and hardware keys + * for free — the same rationale as tools/environments/ssh.py. + * + * No `import 'electron'` so it is unit-testable without Electron. main.ts + * wires it into the electron-coupled lifecycle. + * + * Conventions mirrored from tools/environments/ssh.py: + * - ControlMaster=auto + ControlPersist so one TCP/auth handshake is reused + * across exec/forward operations. + * - Hashed control-socket filename under a short tmpdir to stay under the + * 104-byte sun_path limit macOS enforces on Unix domain sockets. + * - BatchMode=yes for every programmatic invocation — a spawned ssh must + * never hang on an interactive prompt (passphrase / 2FA). If auth needs + * interactivity we fail fast and tell the user to load the key into their + * agent. + * + * Host-key policy: StrictHostKeyChecking=accept-new (trust-on-first-use, log + * the fingerprint), never `no`. A host-key *change* fails closed with the + * verbatim OpenSSH error surfaced to the UI. + * + * Every operation is raced against a hard timeout. A half-open TCP connection + * after laptop sleep can leave ssh hanging indefinitely rather than erroring; + * timeout is treated as connection-dead so the caller does a full reconnect + * rather than retrying in place. + */ + +import { spawn } from 'node:child_process' +import crypto from 'node:crypto' +import fs from 'node:fs' +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' + +const DEFAULT_CONNECT_TIMEOUT_MS = 15_000 +const DEFAULT_EXEC_TIMEOUT_MS = 20_000 +const DEFAULT_FORWARD_TIMEOUT_MS = 15_000 +const CONTROL_PERSIST_SECONDS = 300 + +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 (_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}").`) + } +} + +// Token / secret redaction + +const _REDACTIONS: Array<[RegExp, string]> = [ + [/(HERMES_DASHBOARD_SESSION_TOKEN=)(\S+)/g, '$1'], + [/(X-Hermes-Session-Token["']?\s*[:=]\s*["']?)([^\s"'&]+)/gi, '$1'], + [/(Authorization["']?\s*:\s*Bearer\s+)(\S+)/gi, '$1'], + [/([?&](?:token|ticket)=)([^\s&"']+)/gi, '$1'] +] + +function redactSecrets(text) { + let out = String(text == null ? '' : text) + for (const [re, repl] of _REDACTIONS) { + out = out.replace(re, repl) + } + return out +} + +// Control-socket path + +// Hash user@host:port to a short, stable, filesystem-safe socket id — stable +// across reconnects so ControlMaster reuse works, short so the full path stays +// under sun_path's 104-byte limit. +// +// CRITICAL (macOS): the base dir must be SHORT. os.tmpdir() on macOS is the +// per-user `/var/folders/xx/yyyy…/T/` (~49 bytes), and OpenSSH binds a +// TEMPORARY listener at `.<16 random chars>` while establishing +// the master — so a path that itself fits 104 still overflows at bind time. We +// root under a short per-user base (`~/.hermes/desktop-ssh`) so even worst case +// (~72 bytes on macOS) stays clear. Windows has no AF_UNIX sun_path limit. +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 id = crypto.createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16) + return path.join(dir, `${id}.sock`) +} + +function defaultControlDir() { + // POSIX: a SHORT, PER-USER base stays under the socket limit AND avoids a + // world-shared /tmp dir (no symlink-hijack surface). Created 0700 in open(). + if (process.platform === 'win32') { + return path.join(os.tmpdir(), 'hermes-desktop-ssh') + } + return path.join(os.homedir(), '.hermes', 'desktop-ssh') +} + +// Command construction (pure — the unit tests exercise these directly) + +// Mux (POSIX): ControlMaster options so exec/forward share one authenticated +// connection. No-mux (Windows OpenSSH never implemented mux sockets): plain +// 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}`] + : [] + return [ + ...mux, + '-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 +} + +function target(user, host) { + return user ? `${user}@${host}` : host +} + +function buildExecArgs(conn, remoteCommand, connectTimeoutMs?) { + 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)] +} + +// 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)] +} + +// Interactive `ssh -tt` for the INTERIM remote terminal (SSH mode only). Reuses +// the existing ControlMaster socket so NO new auth handshake happens — the +// master is already open, so this attaches instantly and never prompts. +// +// 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?) { + const args = ['-tt', ...baseSshOptions(conn.controlPath, connectTimeoutMs), ...hostArgs(conn), '--', target(conn.user, conn.host)] + const cwd = String(remoteCwd || '').trim() + if (cwd) { + // cd then exec a login shell; quote the path; tolerate a missing dir. + const q = `'${cwd.replace(/'/g, `'\\''`)}'` + args.push(`cd ${q} 2>/dev/null; exec "$SHELL" -l`) + } else { + args.push('exec "$SHELL" -l') + } + return args +} + +// Bind the local end to 127.0.0.1 ONLY — never 0.0.0.0 — so the tunnel does not +// re-expose the remote dashboard to the client's LAN. +function forwardSpec(localPort, remotePort, remoteHost = '127.0.0.1') { + return `127.0.0.1:${localPort}:${remoteHost}:${remotePort}` +} + +// Error classification — distinct, actionable messages for the UI + +const SSH_ERROR = { + UNREACHABLE: 'unreachable', + AUTH_FAILED: 'auth-failed', + HOST_KEY_CHANGED: 'host-key-changed', + TIMEOUT: 'timeout', + UNKNOWN: 'unknown' +} + +// Order matters: the host-key-change banner also contains "WARNING"/"Offending", +// 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)) { + return SSH_ERROR.HOST_KEY_CHANGED + } + 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)) { + 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 ( + `The host key for ${host} has CHANGED since you last connected. ` + + `This could be a man-in-the-middle attack, or the server was reinstalled. ` + + `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 ` + + `(BatchMode), so a key requiring a passphrase or 2FA must be loaded into your ` + + `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'}` + } +} + +// Spawn helper — runs an ssh invocation, races it against a hard timeout + +// Resolves { code, stdout, stderr }. On timeout the child is SIGKILLed and the +// promise rejects with err.kind = TIMEOUT. `spawnFn` is injectable for tests. +function runSsh(args, { timeoutMs, spawnFn = spawn, stdin = 'ignore', stdinData }: any = {}) { + 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 + } + + if (stdinData != null && child.stdin) { + child.stdin.end(stdinData) + } + + let stdout = '' + let stderr = '' + let settled = false + + const timer = setTimeout(() => { + 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) + }, timeoutMs) + + child.stdout?.on('data', d => { + stdout += d.toString() + }) + child.stderr?.on('data', d => { + stderr += d.toString() + }) + child.on('error', error => { + if (settled) return + settled = true + clearTimeout(timer) + reject(error) + }) + child.on('close', code => { + if (settled) return + settled = true + clearTimeout(timer) + resolve({ code, stdout, stderr }) + }) + }) +} + +function stopTunnelChild(child, timeoutMs = 5_000) { + 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 + 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.')) + } catch (error) { + finish(error) + } + }) +} + +// SshConnection — the public manager + +class SshConnection { + host: string + user: string + port: number + keyPath: string + controlPath: string + _spawnFn: any + _log: (msg: string) => void + _connectTimeoutMs: number + _execTimeoutMs: number + _forwardTimeoutMs: number + _opened: boolean + _mux: boolean + _tunnels: Map + + constructor(cfg, opts: any = {}) { + 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) + this.host = cfg.host + this.user = cfg.user || '' + this.port = port + this.keyPath = cfg.keyPath || '' + // Windows OpenSSH has no ControlMaster (mux sockets were never implemented + // on Win32) — fall back to one ssh invocation per operation and a + // persistent `ssh -N -L` child per tunnel. Empty controlPath routes the + // pure builders onto their no-mux form. + this._mux = opts.mux ?? process.platform !== 'win32' + this.controlPath = this._mux + ? controlSocketPath(this.user, this.host, this.port, opts.controlDir, { + keyPath: this.keyPath, + ownershipId: opts.ownershipId, + scope: opts.scope, + effectiveConfigFingerprint: opts.effectiveConfigFingerprint + }) + : '' + 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 + this._forwardTimeoutMs = opts.forwardTimeoutMs ?? DEFAULT_FORWARD_TIMEOUT_MS + this._opened = false + } + + // Lifecycle logging — ALWAYS through redaction. + _logLine(msg) { + this._log(redactSecrets(`[ssh] ${msg}`)) + } + + _fail(stderrOrErr, fallbackKind = SSH_ERROR.UNKNOWN) { + 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 + } + + // Open the connection. Mux: start the persistent ControlMaster (idempotent — + // a live master is a no-op). No-mux: there is no master; validate auth + + // reachability with a one-shot `ssh true` so failures classify identically. + async open() { + if (await this.isAlive()) { + this._opened = true + return + } + if (!this._mux) { + this._logLine(`connecting (no-mux) to ${target(this.user, this.host)}:${this.port}`) + let result + try { + result = await runSsh(buildExecArgs(this, 'true', this._connectTimeoutMs), { + 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('connection verified (no-mux; per-operation ssh)') + return + } + const controlDir = path.dirname(this.controlPath) + try { + fs.mkdirSync(controlDir, { recursive: true, mode: 0o700 }) + } catch {} + 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') + } + + // 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 + const args = this._mux + ? buildControlArgs(this, 'check', [], this._connectTimeoutMs) + : buildExecArgs(this, 'true', this._connectTimeoutMs) + try { + const result: any = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn }) + return result.code === 0 + } catch { + return false + } + } + + // One-shot remote command over the control connection. Resolves stdout; + // rejects with a classified error on non-zero exit or timeout. + async exec(remoteCommand, { timeoutMs, stdinData }: any = {}) { + const args = buildExecArgs(this, remoteCommand, this._connectTimeoutMs) + let result + try { + result = await runSsh(args, { + timeoutMs: timeoutMs ?? this._execTimeoutMs, + spawnFn: this._spawnFn, + ...(stdinData != null ? { stdinData } : {}) + }) + } catch (error) { + throw this._fail(error) + } + if (result.code !== 0) { + throw this._fail(result.stderr) + } + return result.stdout + } + + // Establish a local→remote forward. Mux: `-O forward` against the master. + // No-mux: spawn a persistent `ssh -N -L` child that IS the tunnel; ready when + // the local port accepts. The child dying = tunnel down (isAlive of the + // backend catches it upstream). + 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 child = this._spawnFn('ssh', args, { stdio: ['ignore', 'ignore', 'pipe'] }) + const tunnel = { child, alive: true } + this._tunnels.set(spec, tunnel) + let stderr = '' + 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 + stderr = `${stderr}${String(d)}`.slice(-16_384) + if (readyPattern.test(stderr)) { + readyConfirmed = true + readyResolve() + } + }) + child.on('error', error => { + tunnel.alive = false + readyReject(error) + }) + child.on('exit', code => { + tunnel.alive = false + readyReject(new Error(`tunnel process exited with code ${code}`)) + }) + child.on('close', code => { + tunnel.alive = false + 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) + }) + ]) + } catch (error: any) { + try { + await stopTunnelChild(child) + this._tunnels.delete(spec) + } 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) + } + } + + // Cancel a previously-established forward. Best-effort: a failure here is + // 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}`) + } catch (error: any) { + this._logLine(`cancelForward failed (ignored): ${error.message}`) + } + } + + // 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._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) + this._logLine('control master closed') + this._opened = false + } catch (error: any) { + this._logLine(`close failed (retryable): ${error.message}`) + } + } +} + +// Free local port for the tunnel's local end. Bind 127.0.0.1:0, read the +// kernel-assigned port, release. The benign TOCTOU window (release → forward +// grabs it) is caught upstream and retried with a fresh port. + +function pickLocalPort() { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.unref() + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as net.AddressInfo + server.close(() => resolve(port)) + }) + }) +} + +function createSshProbeConnection(config, options: any = {}) { + return new SshConnection(config, { ...options, mux: false }) +} + +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, + controlSocketPath, + createSshProbeConnection, + forwardSpec, + hostArgs, + pickLocalPort, + redactSecrets, + runSsh, + stopTunnelChild, + sshErrorMessage, + target, + validateKeyPath, + validateSshTarget +}