diff --git a/apps/desktop/electron/remote-lifecycle.cjs b/apps/desktop/electron/remote-lifecycle.cjs new file mode 100644 index 00000000000..d1a5255f4e9 --- /dev/null +++ b/apps/desktop/electron/remote-lifecycle.cjs @@ -0,0 +1,470 @@ +/** + * remote-lifecycle.cjs + * + * 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. + * + * Electron-free so it can be unit-tested with `node --test`. main.cjs wires the + * real SshConnection, fetch, adoptServedDashboardToken, and waitForHermes in. + * + * The minted HERMES_DASHBOARD_SESSION_TOKEN is the SPAWN credential. After + * readiness the caller (or connect() here) 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). + */ + +const crypto = require('node:crypto') + +const LOCKFILE_SCHEMA_VERSION = 1 +const READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m +// Remote log the detached dashboard appends to; also where we scrape readiness. +const REMOTE_LOG = '~/.hermes/logs/desktop-ssh.log' +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 + +// --------------------------------------------------------------------------- +// Small helpers +// --------------------------------------------------------------------------- + +function mintToken() { + return crypto.randomBytes(32).toString('hex') +} + +// Fingerprint a token for the lockfile — never store the raw secret on the +// remote. SHA256, truncated; comparison is constant-shape. +function fingerprintToken(token) { + return crypto.createHash('sha256').update(String(token || '')).digest('hex').slice(0, 32) +} + +// Stable per-client lock id so a given desktop client reuses its own dashboard +// across reconnects but never collides with another client's. +function clientLockId(clientId) { + const safe = String(clientId || 'default').replace(/[^A-Za-z0-9_.-]/g, '_') + return safe.slice(0, 64) || 'default' +} + +function lockfilePath(clientId) { + return `${REMOTE_LOCK_DIR}/${clientLockId(clientId)}.lock.json` +} + +// shell-single-quote a value for safe interpolation into a remote command. +function shq(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'` +} + +// --------------------------------------------------------------------------- +// Locate hermes on the remote +// --------------------------------------------------------------------------- + +// Try, in order: an explicit profile path; `command -v hermes` in a LOGIN +// shell (non-login `ssh host cmd` PATH frequently misses user installs — the +// login-shell probe is load-bearing, same pitfall ssh.py works around); the +// conventional venv path. Returns the resolved absolute path or throws an +// install-hint error. +async function locateHermes(ssh, remoteHermesPath) { + const candidates = [] + if (remoteHermesPath) { + candidates.push(remoteHermesPath) + } + + // login-shell `command -v` — quoted so the remote shell resolves PATH the + // way an interactive login would. + try { + const found = (await ssh.exec(`bash -lc ${shq('command -v hermes')}`)).trim() + if (found) { + candidates.push(found.split('\n').pop().trim()) + } + } catch { + // fall through to the explicit candidates below + } + + candidates.push('~/.hermes/hermes-agent/venv/bin/hermes') + + for (const candidate of candidates) { + if (!candidate) continue + try { + // -x test resolves ~ and verifies it's executable in one round trip. + const ok = (await ssh.exec(`[ -x "$(eval echo ${shq(candidate)})" ] && echo OK || true`)).trim() + if (ok === 'OK') { + return candidate + } + } catch { + // try the next candidate + } + } + + const err = 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 +} + +// --------------------------------------------------------------------------- +// Remote platform gate +// --------------------------------------------------------------------------- + +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 = 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 } +} + +// --------------------------------------------------------------------------- +// Lockfile (lives on the REMOTE, read/written via ssh.exec) +// --------------------------------------------------------------------------- + +async function readLockfile(ssh, clientId) { + const path = lockfilePath(clientId) + let raw + try { + raw = await ssh.exec(`cat "$(eval echo ${shq(path)})" 2>/dev/null || true`) + } catch { + return null + } + 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 + } + return parsed +} + +async function writeLockfile(ssh, clientId, lock) { + const path = lockfilePath(clientId) + const json = JSON.stringify({ ...lock, schemaVersion: LOCKFILE_SCHEMA_VERSION }) + await ssh.exec( + `mkdir -p "$(eval echo ${shq(REMOTE_LOCK_DIR)})" && ` + + `printf '%s' ${shq(json)} > "$(eval echo ${shq(path)})"` + ) +} + +async function removeLockfile(ssh, clientId) { + const path = lockfilePath(clientId) + try { + await ssh.exec(`rm -f "$(eval echo ${shq(path)})"`) + } catch { + // best effort + } +} + +// True iff the pid is alive on the remote. +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 { + return false + } +} + +// 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) { + if (!pid) return false + try { + // /proc on Linux; `ps` fallback covers macOS. Tolerate either being absent. + const out = await ssh.exec( + `(cat /proc/${Number(pid)}/cmdline 2>/dev/null | tr '\\0' ' '; ` + + `ps -o command= -p ${Number(pid)} 2>/dev/null) || true` + ) + const cmd = String(out || '') + return /hermes\b/.test(cmd) && /dashboard/.test(cmd) && /--isolated/.test(cmd) + } catch { + return false + } +} + +// Kill the stale dashboard ONLY if provably ours, then drop the lockfile. +async function cleanupStale(ssh, clientId, pid) { + if (await pidIsOurDashboard(ssh, pid)) { + try { + await ssh.exec(`kill ${Number(pid)} 2>/dev/null || true`) + } catch { + // best effort + } + } + await removeLockfile(ssh, clientId) +} + +// --------------------------------------------------------------------------- +// Spawn a fresh detached dashboard + scrape the readiness line +// --------------------------------------------------------------------------- + +// Build the detached spawn command. setsid + dedicated loopback dashboard, NOT routed into the host's + // unified machine dashboard. --port 0 => server picks a free port and prints + // HERMES_DASHBOARD_READY port=. + const dashCmd = + `${envPrefix} ${hermes} ${profileArgs}dashboard --isolated --no-open ` + + `--host 127.0.0.1 --port 0` + return ( + `mkdir -p "$(dirname ${logPath})" && ` + + `setsid sh -c ${shq(`${dashCmd} > ${logPath} 2>&1 & echo $!`)}` + ) +} + +// Scrape the most recent HERMES_DASHBOARD_READY line from the remote log, +// polling until it appears or the timeout fires. Returns the bound port. +// +// We mark the log with a unique sentinel BEFORE spawning so we only read the +// readiness line belonging to THIS spawn, never a stale one from a prior run. +async function scrapeReadyPort(ssh, sentinel, { timeoutMs = DEFAULT_READY_TIMEOUT_MS, isAlive } = {}) { + const deadline = Date.now() + timeoutMs + const logPath = `"$(eval echo ${shq(REMOTE_LOG)})"` + while (Date.now() < deadline) { + if (isAlive && !(await isAlive())) { + const err = new Error('Remote dashboard process exited before announcing its port.') + err.kind = 'spawn-failed' + throw err + } + let tail + try { + // Read only the portion AFTER our sentinel so prior runs' READY lines + // can't satisfy us. + tail = await ssh.exec( + `awk ${shq(`/${sentinel}/{seen=1; next} seen{print}`)} ${logPath} 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 = new Error(`Timed out waiting for the remote dashboard to announce its port (${timeoutMs}ms).`) + err.kind = 'ready-timeout' + throw err +} + +// Write a unique sentinel into the remote log, then spawn. Returns { pid, +// sentinel }. +async function spawnRemoteDashboard(ssh, { hermesPath, profile, token }) { + const sentinel = `HERMES_SSH_SPAWN_${Date.now()}_${crypto.randomBytes(4).toString('hex')}` + const logPath = `"$(eval echo ${shq(REMOTE_LOG)})"` + await ssh.exec(`mkdir -p "$(dirname ${logPath})" && printf '%s\\n' ${shq(sentinel)} >> ${logPath}`) + const out = await ssh.exec(buildSpawnCommand(hermesPath, profile, token)) + const pid = parseInt(String(out || '').trim().split('\n').pop(), 10) + if (!Number.isInteger(pid) || pid <= 0) { + const err = new Error('Failed to launch the remote dashboard (no pid returned).') + err.kind = 'spawn-failed' + throw err + } + return { pid, sentinel } +} + +// --------------------------------------------------------------------------- +// connect() — the orchestrator +// --------------------------------------------------------------------------- + +// 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 + } +} + +/** + * Establish (or reuse) a remote dashboard and a tunnel to it. + * + * @param {object} deps + * @param {object} deps.ssh an opened SshConnection + * @param {string} [deps.profile] hermes profile to launch + * @param {string} [deps.remoteHermesPath] explicit hermes path override + * @param {string} deps.clientId stable per-client id for the lockfile + * @param {(localPort:number, remotePort:number)=>Promise} deps.forward + * @param {()=>Promise} deps.pickLocalPort + * @param {(baseUrl:string, token:string)=>Promise} deps.waitForHermes + * @param {(baseUrl:string, token:string)=>Promise} deps.probeStatus + * authenticated GET /api/status — true iff it returns ok with `token` + * @param {(baseUrl:string, spawnToken:string, opts:object)=>Promise} deps.adoptServedToken + * @param {(msg:string)=>void} [deps.rememberLog] already redaction-wrapped by caller + * @param {number} [deps.readyTimeoutMs] + * @returns {Promise<{baseUrl, token, tokenFingerprint, remotePort, localPort, pid, reused, platform}>} + */ +async function connect(deps) { + const { + ssh, + profile = '', + remoteHermesPath = '', + clientId, + forward, + pickLocalPort, + waitForHermes, + probeStatus, + adoptServedToken, + rememberLog = () => {}, + readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS + } = deps + + const log = msg => rememberLog(`[ssh-lifecycle] ${msg}`) + + const platform = await probeRemotePlatform(ssh) + log(`remote platform ${platform.os}/${platform.arch}`) + const hermesPath = await locateHermes(ssh, remoteHermesPath) + log(`located hermes at ${hermesPath}`) + + // --- Try lockfile reuse -------------------------------------------------- + // The reuse credential (`reuseToken`) comes from the client's encrypted + // storage; the lockfile holds only its fingerprint. Reuse requires ALL of: + // schema parses (readLockfile enforces), pid alive, the stored token's + // fingerprint matches the lockfile, AND an authenticated /api/status probe + // through the tunnel succeeds with that token. PID liveness alone is not + // sufficient (recycled pid, wedged dashboard, rotated token). + const reuseToken = deps.reuseToken || '' + const lock = await readLockfile(ssh, clientId) + if (lock && lock.pid && lock.port) { + const pidAlive = await remotePidAlive(ssh, lock.pid) + const fpMatch = Boolean(reuseToken) && lock.tokenFingerprint === fingerprintToken(reuseToken) + if (pidAlive && fpMatch) { + const localPort = await pickLocalPort() + try { + await forward(localPort, lock.port) + const baseUrl = `http://127.0.0.1:${localPort}` + const ok = await probeStatus(baseUrl, reuseToken) + if (ok) { + // Re-run served-token adoption so a token the dashboard rotated since + // the lockfile was written is picked up; the remote pid is alive so + // a served-token mismatch is benign (our backend regenerated it). + const token = await adoptServedToken(baseUrl, reuseToken, { + childAlive: () => true, + label: 'reused remote dashboard' + }) + log(`reusing remote dashboard pid=${lock.pid} port=${lock.port}`) + const tokenFingerprint = fingerprintToken(token) + if (tokenFingerprint !== lock.tokenFingerprint) { + await writeLockfile(ssh, clientId, { ...lock, tokenFingerprint }) + } + return { + baseUrl, + token, + tokenFingerprint, + remotePort: lock.port, + localPort, + pid: lock.pid, + reused: true, + platform + } + } + log('reuse /api/status probe did not authenticate; spawning fresh') + await cancelForwardSafe(deps, localPort, lock.port) + } catch (error) { + log(`reuse probe failed (${error.message}); spawning fresh`) + await cancelForwardSafe(deps, localPort, lock.port) + } + } else { + log(`lockfile present but not reusable (pidAlive=${pidAlive}, fpMatch=${fpMatch})`) + } + // Any failed condition → cleanup (kill only if provably ours) and respawn. + await cleanupStale(ssh, clientId, lock.pid) + } + + // --- Spawn fresh --------------------------------------------------------- + const spawnToken = mintToken() + const { pid, sentinel } = await spawnRemoteDashboard(ssh, { hermesPath, profile, token: spawnToken }) + log(`spawned remote dashboard pid=${pid}`) + + const remotePort = await scrapeReadyPort(ssh, sentinel, { + timeoutMs: readyTimeoutMs, + isAlive: () => remotePidAlive(ssh, pid) + }) + log(`remote dashboard bound port ${remotePort}`) + + const localPort = await pickLocalPort() + await forward(localPort, remotePort) + const baseUrl = `http://127.0.0.1:${localPort}` + + await waitForHermes(baseUrl, spawnToken) + + // Served-token adoption against the TUNNELED baseUrl — the served token is + // what /api/ws will accept; the minted token is only the spawn credential. + const token = await adoptServedToken(baseUrl, spawnToken, { + childAlive: () => true, // liveness is the remote pid; the tunnel is the client side + label: 'remote dashboard' + }) + const tokenFingerprint = fingerprintToken(token) + + await writeLockfile(ssh, clientId, { + pid, + port: remotePort, + profile, + hermesPath, + tokenFingerprint, + startedAt: new Date().toISOString() + }) + + return { baseUrl, token, tokenFingerprint, remotePort, localPort, pid, reused: false, platform } +} + +module.exports = { + DEFAULT_READY_TIMEOUT_MS, + LOCKFILE_SCHEMA_VERSION, + READY_RE, + REMOTE_LOCK_DIR, + REMOTE_LOG, + SUPPORTED_REMOTE_OS, + buildSpawnCommand, + cleanupStale, + clientLockId, + connect, + fingerprintToken, + locateHermes, + lockfilePath, + mintToken, + pidIsOurDashboard, + probeRemotePlatform, + readLockfile, + remotePidAlive, + removeLockfile, + scrapeReadyPort, + shq, + spawnRemoteDashboard, + writeLockfile +} diff --git a/apps/desktop/electron/remote-lifecycle.test.cjs b/apps/desktop/electron/remote-lifecycle.test.cjs new file mode 100644 index 00000000000..2a7f41728b1 --- /dev/null +++ b/apps/desktop/electron/remote-lifecycle.test.cjs @@ -0,0 +1,336 @@ +/** + * Tests for electron/remote-lifecycle.cjs. + * + * Run with: node --test electron/remote-lifecycle.test.cjs + * (Wired into npm test:desktop:platforms in package.json.) + * + * Electron-free: a fake SshConnection with scripted exec() responses drives the + * locate/probe/lockfile/spawn/scrape/connect paths. No real ssh, no real + * dashboard. + */ + +const test = require('node:test') +const assert = require('node:assert/strict') + +const { + LOCKFILE_SCHEMA_VERSION, + buildSpawnCommand, + cleanupStale, + clientLockId, + connect, + fingerprintToken, + locateHermes, + lockfilePath, + pidIsOurDashboard, + probeRemotePlatform, + readLockfile, + remotePidAlive, + scrapeReadyPort, + spawnRemoteDashboard, + writeLockfile +} = require('./remote-lifecycle.cjs') + +// 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 = []) { + const calls = [] + 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 '' + } + } +} + +// --- locateHermes ----------------------------------------------------------- + +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 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 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 => { + 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)') +}) + +// --- probeRemotePlatform ---------------------------------------------------- + +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 => { + assert.equal(err.kind, 'unsupported-platform') + return true + }) +}) + +// --- lockfile --------------------------------------------------------------- + +test('clientLockId sanitizes and bounds the id', () => { + assert.equal(clientLockId('a/b c'), 'a_b_c') + assert.equal(clientLockId(''), 'default') + assert.ok(clientLockId('x'.repeat(200)).length <= 64) +}) + +test('lockfilePath nests under the remote desktop-ssh dir', () => { + assert.match(lockfilePath('client1'), /\.hermes\/desktop-ssh\/client1\.lock\.json$/) +}) + +test('readLockfile returns null for missing, empty, malformed, or wrong-schema', async () => { + assert.equal(await readLockfile(fakeSsh([[/cat/, '']]), 'c'), null) + assert.equal(await readLockfile(fakeSsh([[/cat/, 'not json']]), 'c'), null) + assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify({ schemaVersion: 999 })]]), 'c'), null) + const good = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 1, port: 2 } + assert.deepEqual(await readLockfile(fakeSsh([[/cat/, JSON.stringify(good)]]), 'c'), good) +}) + +test('writeLockfile mkdir -ps and stamps the schema version', async () => { + const ssh = fakeSsh([]) + await writeLockfile(ssh, 'c', { 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('pidIsOurDashboard requires hermes + dashboard + --isolated in the cmdline', async () => { + const ours = 'env H=1 /x/hermes dashboard --isolated --no-open --host 127.0.0.1 --port 0' + assert.equal(await pidIsOurDashboard(fakeSsh([[/cmdline|ps -o/, ours]]), 5), true) + // a different hermes process (gateway) is NOT ours to kill + assert.equal(await pidIsOurDashboard(fakeSsh([[/cmdline|ps -o/, '/x/hermes gateway']]), 5), false) + // an unrelated process is never ours + assert.equal(await pidIsOurDashboard(fakeSsh([[/cmdline|ps -o/, 'sshd: u@pts/0']]), 5), false) +}) + +test('cleanupStale kills ONLY a provably-ours pid, always drops the lockfile', async () => { + // not ours → no kill, lockfile removed + const notOurs = fakeSsh([[/cmdline|ps -o/, '/x/hermes gateway']]) + await cleanupStale(notOurs, 'c', 5) + 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))) + + // ours → killed + lockfile removed + const ours = fakeSsh([[/cmdline|ps -o/, '/x/hermes dashboard --isolated']]) + await cleanupStale(ours, 'c', 9) + assert.ok(ours.calls.some(c => /kill 9\b/.test(c))) + assert.ok(ours.calls.some(c => /rm -f/.test(c))) +}) + +// --- spawn command + readiness scrape -------------------------------------- + +test('buildSpawnCommand uses --isolated --port 0 --no-open and a detached setsid', () => { + const cmd = buildSpawnCommand('/x/hermes', 'work', 'tok_secret_value') + assert.match(cmd, /--isolated/) + assert.match(cmd, /--no-open/) + assert.match(cmd, /--host 127\.0\.0\.1 --port 0/) + assert.match(cmd, /--profile/) + assert.match(cmd, /work/) + assert.match(cmd, /setsid/) + assert.match(cmd, /<\/dev\/null/) + assert.match(cmd, /echo \$!/) +}) + +test('spawnRemoteDashboard writes a sentinel then returns the echoed pid', async () => { + const ssh = fakeSsh([ + [/printf '%s\\\\n'/, ''], // sentinel write + [/setsid/, '4242\n'] // spawn → pid + ]) + const { pid, sentinel } = await spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tk' }) + assert.equal(pid, 4242) + assert.match(sentinel, /^HERMES_SSH_SPAWN_/) +}) + +test('spawnRemoteDashboard rejects when no pid is returned', async () => { + const ssh = fakeSsh([[/setsid/, 'not-a-pid']]) + await assert.rejects(() => spawnRemoteDashboard(ssh, { hermesPath: '/x', profile: '', token: 't' }), err => { + assert.equal(err.kind, 'spawn-failed') + return true + }) +}) + +test('scrapeReadyPort parses the READY line that follows the sentinel', async () => { + const ssh = fakeSsh([[/awk/, 'some noise\nHERMES_DASHBOARD_READY port=51234\n']]) + const port = await scrapeReadyPort(ssh, 'SENT', { timeoutMs: 1000 }) + assert.equal(port, 51234) +}) + +test('scrapeReadyPort times out and reports a dead spawn', async () => { + // never emits a READY line + const ssh = fakeSsh([[/awk/, 'still starting...']]) + await assert.rejects(() => scrapeReadyPort(ssh, 'SENT', { timeoutMs: 60 }), err => { + assert.equal(err.kind, 'ready-timeout') + return true + }) + // dead process before announcement → spawn-failed + await assert.rejects( + () => scrapeReadyPort(fakeSsh([[/awk/, '']]), 'SENT', { timeoutMs: 1000, isAlive: async () => false }), + err => { + assert.equal(err.kind, 'spawn-failed') + return true + } + ) +}) + +// --- connect() orchestration ------------------------------------------------ + +function connectDeps(ssh, over = {}) { + return { + ssh, + clientId: 'client1', + profile: '', + forward: async () => {}, + cancelForward: async () => {}, + pickLocalPort: async () => 50001, + waitForHermes: async () => {}, + probeStatus: async () => true, + 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 + [/printf '%s\\\\n'/, ''], + [/setsid/, '777\n'], + [/kill -0 777/, 'ALIVE'], + [/awk/, '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 = { + schemaVersion: LOCKFILE_SCHEMA_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'] + ]) + 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 pid is dead (killed dashboard)', async () => { + const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 333, port: 40000, tokenFingerprint: fingerprintToken('t') } + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0 333/, 'DEAD'], + [/cmdline|ps -o/, ''], // not provably ours + [/setsid/, '888\n'], + [/kill -0 888/, 'ALIVE'], + [/awk/, '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, + 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'], + [/cmdline|ps -o/, '/x/hermes dashboard --isolated'], // ours → may kill + [/setsid/, '999\n'], + [/kill -0 999/, 'ALIVE'], + [/awk/, 'HERMES_DASHBOARD_READY port=43000\n'] + ]) + // probeStatus FAILS for the wedged dashboard → must respawn + const result = await connect( + connectDeps(ssh, { reuseToken, probeStatus: async () => false, 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 => { + assert.equal(err.kind, 'unsupported-platform') + return true + }) + assert.ok(!ssh.calls.some(c => /setsid/.test(c))) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8e81b61bf25..d5b0d55452e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -37,7 +37,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/ssh-connection.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/ssh-connection.test.cjs electron/remote-lifecycle.test.cjs electron/ssh-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix",