/** * remote-lifecycle.ts * * Pure, electron-free remote Hermes dashboard lifecycle over SSH for Desktop * SSH remote mode. Composes an SshConnection (injected) with HTTP probes * through the established tunnel (injected fetch) and the served-token adoption * step (injected). Knows how to: * * - locate the Hermes install on the remote (login-shell probe), * - gate the remote platform to Linux/macOS via `uname`, * - reuse an existing desktop-dedicated dashboard via a lockfile + an * AUTHENTICATED /api/status probe (pid liveness alone is insufficient), * - spawn a fresh detached `--isolated --port 0` dashboard and scrape its * `HERMES_DASHBOARD_READY port=` readiness line, * - adopt the token the dashboard actually serves (served-token adoption), * - clean up a stale dashboard only when it is provably ours. * * No `import 'electron'` so it's unit-testable with `node --test`. main.ts wires * the real SshConnection, fetch, adoptServedDashboardToken, and waitForHermes in. * * The minted HERMES_DASHBOARD_SESSION_TOKEN is the SPAWN credential. After * readiness the caller runs served-token adoption against the tunneled baseUrl * and the SERVED token's fingerprint is what lands in the lockfile — so the * reuse probe checks the credential that actually authenticates /api/ws, not * the minted one (which the dashboard may regen). */ import crypto from 'node:crypto' import { parseRemoteProfileListing } from './connection-registry' import { assertBootstrapNotSuperseded } from './ssh-connection' const LOCKFILE_SCHEMA_VERSION = 2 // Bumped when the desktop<->dashboard reuse contract changes in a way that makes // an old running dashboard unsafe to reattach to (token handling, readiness/spawn // args, served-token reconciliation). A mismatch forces a clean respawn. const PROTOCOL_VERSION = 1 const READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)/m const REMOTE_LOCK_DIR = '~/.hermes/desktop-ssh' const SUPPORTED_REMOTE_OS = new Set(['Linux', 'Darwin']) const DEFAULT_READY_TIMEOUT_MS = 45_000 const READY_POLL_INTERVAL_MS = 750 // macOS sshd starts non-interactive shells with a 256-FD soft limit even when // the hard limit is unlimited. A Desktop backend can legitimately exceed that // while serving several profiles/tools, so raise only the child process limit. // Keep startup portable: restricted hosts retain their existing limit. const REMOTE_NOFILE_SOFT_LIMIT = 65_536 function classifySshReuseProof(proof, spawnNonce) { return proof?.ok === true && proof.sshOwnerNonce === spawnNonce && proof.protocolVersion === PROTOCOL_VERSION && proof.runtimeIntact !== false ? 'authenticated-ok' : 'authenticated-stale' } function mintToken() { return crypto.randomBytes(32).toString('hex') } // Fingerprint a token for the lockfile — never store the raw secret on the // remote. SHA256, truncated. function fingerprintToken(token) { return crypto .createHash('sha256') .update(String(token || '')) .digest('hex') .slice(0, 32) } function validateOwnershipId(ownershipId) { const value = String(ownershipId || '') if (!/^[0-9a-f]{32}$/.test(value)) { throw new Error('SSH ownership ID is invalid.') } return value } function validateSpawnNonce(spawnNonce) { const value = String(spawnNonce || '') if (!/^[0-9a-f]{16}$/.test(value)) { throw new Error('SSH spawn nonce is invalid.') } return value } function ownershipDirectory(ownershipId) { return `${REMOTE_LOCK_DIR}/${validateOwnershipId(ownershipId)}` } function lockfilePath(ownershipId) { return `${ownershipDirectory(ownershipId)}/backend.lock.json` } // #95532 fail-closed skew sentinel. A backend.lock.json that EXISTS but does // not match what this build writes (unknown schemaVersion, missing/foreign // ownershipId, truncated JSON, malformed shape) is "skew" — most likely a // different desktop build (fork) owns this remote, or the file is corrupt. // Skew must never be conflated with "no lockfile": every reap/cleanup path // (#78872 ownership guard) must SKIP on skew, because killing or overwriting // on unparseable/foreign state is exactly the wrong-way failure — it murders // a live tunnel some other build is depending on. function lockfileSkew(reason) { return { skew: true, reason: String(reason) } } function isLockfileSkew(lock) { return Boolean(lock) && (lock as any).skew === true } function connectReservationPath(ownershipId) { return `${ownershipDirectory(ownershipId)}/.connect.lock` } function spawnLogPath(ownershipId, spawnNonce) { return `${ownershipDirectory(ownershipId)}/${validateSpawnNonce(spawnNonce)}.log` } function spawnTokenPath(ownershipId, spawnNonce) { return `${ownershipDirectory(ownershipId)}/${validateSpawnNonce(spawnNonce)}.token` } // shell-single-quote a value for safe interpolation into a remote command. function shq(value) { return `'${String(value).replace(/'/g, `'\\''`)}'` } function validateRemotePath(p) { const s = String(p || '') if (!s) { throw new Error('Remote path must not be empty.') } // eslint-disable-next-line no-control-regex -- deliberately reject NUL in remote paths if (/[\x00\n\r]/.test(s)) { throw new Error('Unsafe remote path: contains NUL or newline.') } if (s === '~' || s.startsWith('~/') || s.startsWith('/')) { return } throw new Error(`Remote path must be absolute or start with ~/: "${s}"`) } function expandRemotePath(p) { validateRemotePath(p) if (p === '~') { return '"$HOME"' } if (p.startsWith('~/')) { return '"$HOME"' + shq(p.slice(1)) } return shq(p) } // Resolve the remote hermes executable. An EXPLICIT path is honored strictly // (throws a path-naming error if not executable — never silently falls back to a // different install). A BLANK path auto-detects: login-shell `command -v` (a // non-login `ssh host cmd` PATH misses user installs), then known install paths. async function locateHermes(ssh, remoteHermesPath) { const resolveLauncher = async (candidate: string) => { // Return the candidate path directly. The hermes binary or wrapper script // is executable and handles argument forwarding (e.g. `exec