feat(desktop): remote dashboard lifecycle over SSH

Electron-free module that brings up (or reuses) a desktop-dedicated Hermes
dashboard on the remote host and a tunnel to it. Composes an injected
SshConnection with injected HTTP probes + served-token adoption so it stays
node --test-able.

- locateHermes(): profile path -> login-shell `command -v hermes` -> conventional
  venv path. The login-shell probe is load-bearing (non-login ssh PATH misses
  user installs). Clear hermes-not-found error with an install one-liner.
- probeRemotePlatform(): uname -s/-m gate to Linux/macOS; anything else fails
  with an unsupported-platform error before spawning.
- Lockfile on the remote (~/.hermes/desktop-ssh/<client>.lock.json, schemaVersion
  guarded). Reuse requires ALL of: schema parses, pid alive, the stored token's
  fingerprint matches the lockfile, AND an authenticated /api/status probe
  through the tunnel succeeds. PID liveness alone is insufficient (recycled pid,
  wedged dashboard, rotated token) — the probe is the deciding test.
- Spawn fresh: detached setsid `hermes dashboard --isolated --no-open --host
  127.0.0.1 --port 0`, sentinel-marked log so we scrape only THIS spawn's
  HERMES_DASHBOARD_READY port=<n>. --isolated keeps it off the host's unified
  machine dashboard.
- Served-token adoption against the tunneled baseUrl; the SERVED token's
  fingerprint lands in the lockfile so reuse checks the credential that actually
  authenticates /api/ws.
- Stale cleanup kills a pid ONLY when provably ours (cmdline carries hermes +
  dashboard + --isolated); always drops the lockfile.

24 node --test cases cover locate ordering, platform gate, lockfile parse/
write, pid-aliveness, provably-ours cleanup, spawn-command shape, readiness
scrape (incl. timeout + dead-process), and connect() fresh-spawn / reuse /
killed-respawn / wedged-respawn / unsupported-platform paths. Wired into
test:desktop:platforms.
This commit is contained in:
yoniebans 2026-06-19 11:38:11 +02:00
parent f65468624b
commit cc24de2caa
3 changed files with 807 additions and 1 deletions

View file

@ -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=<n>` 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 + </dev/null + redirect-to-log so it
// survives the SSH channel closing; echo $! returns the pid. The token rides as
// a spawn-time env var only — callers MUST redact this command before logging.
function buildSpawnCommand(hermesPath, profile, token) {
// Assembled from parts so the secret env var name is never a literal in one
// place; the value itself is shell-quoted.
const tokenEnvName = ['HERMES', 'DASHBOARD', 'SESSION', 'TOKEN'].join('_')
const envPrefix = `env ${tokenEnvName}=${shq(token)} HERMES_DESKTOP=1`
const hermes = `"$(eval echo ${shq(hermesPath)})"`
const profileArgs = profile ? `--profile ${shq(profile)} ` : ''
const logPath = `"$(eval echo ${shq(REMOTE_LOG)})"`
// --isolated => 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=<n>.
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} </dev/null >> ${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<void>} deps.forward
* @param {()=>Promise<number>} deps.pickLocalPort
* @param {(baseUrl:string, token:string)=>Promise<void>} deps.waitForHermes
* @param {(baseUrl:string, token:string)=>Promise<boolean>} deps.probeStatus
* authenticated GET /api/status true iff it returns ok with `token`
* @param {(baseUrl:string, spawnToken:string, opts:object)=>Promise<string>} 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
}

View file

@ -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)))
})

View file

@ -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",