mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
f65468624b
commit
cc24de2caa
3 changed files with 807 additions and 1 deletions
470
apps/desktop/electron/remote-lifecycle.cjs
Normal file
470
apps/desktop/electron/remote-lifecycle.cjs
Normal 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
|
||||
}
|
||||
336
apps/desktop/electron/remote-lifecycle.test.cjs
Normal file
336
apps/desktop/electron/remote-lifecycle.test.cjs
Normal 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)))
|
||||
})
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue