beb212dcc5
1. Quoting: the spawn payload wrapped expandRemotePath() output -- already
a shell-quoted fragment like "$HOME"'/...' -- in shq() again, so the
reservation/lock/owner_file variables hold the quote characters
literally and every mkdir "$reservation" fails forever (~5 min per
attempt spinning in the reservation loop while holding the box-global
update mutex; queued spawns starve behind it). The same double quoting
sits in the stale-reaper identity guards, making every reap REFUSE.
The lockfile-reuse path masks the bug for existing backends, so it
only bites on fresh spawns.
2. Bashism: lockfile publication used ${var//__PID__/$child} -- bash-only
substitution in a payload run under plain sh (dash on Ubuntu), which
aborts the script AFTER the serve was spawned. The client then saw an
unknown failure, ran its error cleanup (deleting the token file), and
the just-booted serve died on the missing token -- orphaning one serve
per attempt. Replaced with a POSIX sed substitution.
Adds two regression tests: payload variables must keep $HOME expandable
(no re-quoting), and the pid substitution must be POSIX sh. Both fail
against the previous code; all 89 remote-lifecycle tests pass with the
fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1692 lines
58 KiB
TypeScript
1692 lines
58 KiB
TypeScript
/**
|
|
* 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=<n>` 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 <python> <script> "$@"`)
|
|
// correctly on its own. Previously, this function followed `exec` wrappers and
|
|
// returned only the python interpreter, which broke:
|
|
// - version checking: `<python> --version` printed "Python x.y.z" instead of
|
|
// the Hermes version, and
|
|
// - capability probing: `<python> serve --help` failed entirely.
|
|
// See https://github.com/NousResearch/hermes-agent/issues/74411
|
|
return candidate
|
|
}
|
|
|
|
const isExecutable = async (candidate: string) => {
|
|
try {
|
|
validateRemotePath(candidate)
|
|
const ok = (await ssh.exec(`[ -x ${expandRemotePath(candidate)} ] && echo OK || true`)).trim()
|
|
|
|
return ok === 'OK'
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
if (remoteHermesPath) {
|
|
if (await isExecutable(remoteHermesPath)) {
|
|
return resolveLauncher(remoteHermesPath)
|
|
}
|
|
|
|
const err: any = new Error(
|
|
`The Hermes path you set is not an executable on the remote host: "${remoteHermesPath}". ` +
|
|
'Check the path (it must be the full path to the `hermes` binary on the remote, e.g. ' +
|
|
'~/hermes-agent/.venv/bin/hermes), or clear it to auto-detect.'
|
|
)
|
|
|
|
err.kind = 'hermes-not-found'
|
|
throw err
|
|
}
|
|
|
|
const candidates: string[] = []
|
|
|
|
try {
|
|
const found = (await ssh.exec(`bash -lc ${shq('command -v hermes')}`)).trim()
|
|
|
|
if (found) {
|
|
candidates.push(found.split('\n').pop().trim())
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
// Fallback candidates when the login-shell probe misses: the installer's
|
|
// command locations (scripts/install.sh) — per-user, root/FHS, legacy venv.
|
|
candidates.push('~/.local/bin/hermes')
|
|
candidates.push('/usr/local/bin/hermes')
|
|
candidates.push('~/.hermes/hermes-agent/venv/bin/hermes')
|
|
|
|
for (const candidate of candidates) {
|
|
if (!candidate) {
|
|
continue
|
|
}
|
|
|
|
if (await isExecutable(candidate)) {
|
|
return resolveLauncher(candidate)
|
|
}
|
|
}
|
|
|
|
const err: any = new Error(
|
|
'Hermes is not installed on the remote host (could not find a `hermes` executable). ' +
|
|
'Install it on the remote with: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh ' +
|
|
'— or set the Hermes path explicitly in the SSH connection settings.'
|
|
)
|
|
|
|
err.kind = 'hermes-not-found'
|
|
throw err
|
|
}
|
|
|
|
// Probe the resolved binary's version string (first line of `<hermes> --version`,
|
|
// e.g. "Hermes Agent v0.18.2 ..."), or '' on failure. Surfaces WHICH hermes a
|
|
// connection uses, so a stale/unexpected install is visible.
|
|
async function probeHermesVersion(ssh, hermesPath) {
|
|
try {
|
|
const out = (await ssh.exec(`${expandRemotePath(hermesPath)} --version 2>&1`)).trim()
|
|
|
|
return (out.split('\n')[0] || '').trim()
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
async function probeRemotePlatform(ssh) {
|
|
const out = (await ssh.exec('uname -s; uname -m')).trim().split('\n')
|
|
const osName = (out[0] || '').trim()
|
|
const arch = (out[1] || '').trim()
|
|
|
|
if (!SUPPORTED_REMOTE_OS.has(osName)) {
|
|
const err: any = new Error(
|
|
`Unsupported remote platform "${osName || 'unknown'}". Hermes Desktop SSH mode supports Linux, macOS, and Windows remote hosts.`
|
|
)
|
|
|
|
err.kind = 'unsupported-platform'
|
|
throw err
|
|
}
|
|
|
|
return { os: osName, arch }
|
|
}
|
|
|
|
// The HERMES_HOME the remote dashboard will use (explicit env wins, else
|
|
// ~/.hermes). Recorded in the lockfile so a future reuse can tell it's the same
|
|
// state store; best-effort.
|
|
async function probeRemoteHermesHome(ssh) {
|
|
try {
|
|
const out = (await ssh.exec('echo "${HERMES_HOME:-$HOME/.hermes}"')).trim().split('\n').pop()
|
|
|
|
return out || '~/.hermes'
|
|
} catch (cause) {
|
|
const error: any = new Error('Could not resolve the remote Hermes home.')
|
|
error.kind = 'transient-transport-error'
|
|
error.cause = cause
|
|
throw error
|
|
}
|
|
}
|
|
|
|
const REMOTE_UPDATE_MARKER_PROBE = String.raw`
|
|
import errno,os,re,sys
|
|
from pathlib import Path
|
|
|
|
home=Path(os.path.expanduser(sys.argv[1]))
|
|
if home.parent.name=='profiles':home=home.parent.parent
|
|
marker=home/'.hermes-update-in-progress'
|
|
try:
|
|
with marker.open('rb') as stream:raw=stream.read(257)
|
|
except FileNotFoundError:
|
|
print('CLEAR');raise SystemExit
|
|
except OSError:
|
|
print('UNCERTAIN');raise SystemExit
|
|
if len(raw)>256:
|
|
print('UNCERTAIN');raise SystemExit
|
|
match=re.fullmatch(rb'([1-9][0-9]*)\r?\n([0-9]+)(?:\r?\n)?',raw)
|
|
if not match:
|
|
print('UNCERTAIN');raise SystemExit
|
|
try:
|
|
owner=int(match.group(1));lease=int(match.group(2))
|
|
if owner<1 or owner>4294967295 or lease>9007199254740991:raise ValueError()
|
|
except ValueError:
|
|
print('UNCERTAIN');raise SystemExit
|
|
try:
|
|
os.kill(owner,0)
|
|
except ProcessLookupError:
|
|
print('CLEAR')
|
|
except PermissionError:
|
|
print('LIVE:'+str(owner))
|
|
except OSError as error:
|
|
if error.errno==errno.ESRCH:print('CLEAR')
|
|
elif error.errno==errno.EPERM:print('LIVE:'+str(owner))
|
|
else:print('UNCERTAIN')
|
|
else:
|
|
print('LIVE:'+str(owner))
|
|
`
|
|
|
|
/**
|
|
* Refuse normal SSH reuse/spawn while the remote install is being mutated.
|
|
*
|
|
* This probe intentionally uses only the host's system Python and raw marker
|
|
* bytes; it never imports or executes code from the changing Hermes checkout.
|
|
* Absence or a well-formed, confirmed-dead owner is clear. Every parse, read,
|
|
* probe, or transport uncertainty fails closed so a Desktop relaunch cannot
|
|
* start `serve` beside an updater that survived the old app process.
|
|
*/
|
|
async function assertRemoteInstallUpdateClear(ssh, hermesHome) {
|
|
const home = assertSafeRemoteHome(hermesHome)
|
|
let observation = ''
|
|
|
|
try {
|
|
observation =
|
|
String(await ssh.exec(`python3 -c ${shq(REMOTE_UPDATE_MARKER_PROBE)} ${expandRemotePath(home)}`))
|
|
.trim()
|
|
.split(/\r?\n/)
|
|
.pop() || ''
|
|
} catch (cause) {
|
|
const error: any = new Error('Could not prove that the remote Hermes install is clear for SSH startup.')
|
|
error.kind = 'update-in-progress'
|
|
error.cause = cause
|
|
throw error
|
|
}
|
|
|
|
if (observation === 'CLEAR') {
|
|
return
|
|
}
|
|
|
|
const live = /^LIVE:([1-9][0-9]*)$/.exec(observation)
|
|
|
|
const error: any = new Error(
|
|
live
|
|
? `Remote Hermes update process ${live[1]} is still running; SSH startup is paused.`
|
|
: 'The remote Hermes update marker is unreadable or malformed; refusing SSH startup.'
|
|
)
|
|
|
|
error.kind = 'update-in-progress'
|
|
throw error
|
|
}
|
|
|
|
async function listRemoteHermesProfiles(ssh) {
|
|
const home = assertSafeRemoteHome(await probeRemoteHermesHome(ssh))
|
|
const dir = expandRemotePath(`${home}/profiles`)
|
|
let listing = ''
|
|
|
|
try {
|
|
listing = await ssh.exec(`if [ -d ${dir} ]; then ls -1 ${dir}; fi`)
|
|
} catch (cause) {
|
|
const error: any = new Error('Could not list remote Hermes profiles.')
|
|
error.kind = 'transient-transport-error'
|
|
error.cause = cause
|
|
throw error
|
|
}
|
|
|
|
return parseRemoteProfileListing(listing)
|
|
}
|
|
|
|
function assertSafeRemoteHome(home) {
|
|
const value = String(home || '').trim()
|
|
|
|
if (!/^(\/|~\/)[A-Za-z0-9._/+-]+$/.test(value) || value.includes('..')) {
|
|
const error: any = new Error('Unsafe remote Hermes home.')
|
|
error.kind = 'unsafe-path'
|
|
throw error
|
|
}
|
|
|
|
return value.replace(/\/+$/, '')
|
|
}
|
|
|
|
function remoteInstallRoot(home) {
|
|
const value = assertSafeRemoteHome(home)
|
|
const profile = value.match(/^(.*)\/profiles\/[^/]+$/)
|
|
|
|
return profile ? profile[1] : value
|
|
}
|
|
|
|
async function readLockfile(ssh, ownershipId) {
|
|
const lpath = lockfilePath(ownershipId)
|
|
let raw
|
|
|
|
try {
|
|
raw = await ssh.exec(`if [ ! -e ${expandRemotePath(lpath)} ]; then exit 0; fi; cat ${expandRemotePath(lpath)}`)
|
|
} catch (cause) {
|
|
const error: any = new Error('Could not read the SSH backend ownership record.')
|
|
error.kind = 'transient-transport-error'
|
|
error.cause = cause
|
|
throw error
|
|
}
|
|
|
|
const text = String(raw || '').trim()
|
|
|
|
if (!text) {
|
|
return null
|
|
}
|
|
|
|
let parsed
|
|
|
|
try {
|
|
parsed = JSON.parse(text)
|
|
} catch {
|
|
// Exists but doesn't parse: truncated write or a foreign format. NOT the
|
|
// same as "no lockfile" — see lockfileSkew().
|
|
return lockfileSkew('unparseable-json')
|
|
}
|
|
|
|
if (!parsed || typeof parsed !== 'object') {
|
|
return lockfileSkew('non-object')
|
|
}
|
|
|
|
if (parsed.schemaVersion !== LOCKFILE_SCHEMA_VERSION) {
|
|
return lockfileSkew(`schema-version ${JSON.stringify(parsed.schemaVersion ?? null)}`)
|
|
}
|
|
|
|
const pid = parsed.pid
|
|
const port = parsed.port
|
|
|
|
if (!Number.isInteger(pid) || pid <= 0 || pid > 4194304) {
|
|
return lockfileSkew('malformed-pid')
|
|
}
|
|
|
|
// port 0 = spawn-in-progress record (written before readiness); valid
|
|
// ownership proof for cleanup, but never reusable.
|
|
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
return lockfileSkew('malformed-port')
|
|
}
|
|
|
|
if (parsed.ownershipId !== ownershipId || !/^[0-9a-f]{16}$/.test(parsed.spawnNonce || '')) {
|
|
return lockfileSkew('ownership-mismatch')
|
|
}
|
|
|
|
if (!/^[0-9a-f]{32}$/.test(parsed.tokenFingerprint || '')) {
|
|
return lockfileSkew('malformed-token-fingerprint')
|
|
}
|
|
|
|
if (parsed.protocolVersion !== PROTOCOL_VERSION) {
|
|
// Fully validated ownership (our schema, our ownershipId, our shape) from
|
|
// a protocol-incompatible build of OUR OWN lineage: the record is not
|
|
// reusable and readLockfile keeps its historical contract of hiding it,
|
|
// which routes connect() to a fresh spawn.
|
|
return null
|
|
}
|
|
|
|
if (parsed.logPath !== spawnLogPath(ownershipId, parsed.spawnNonce)) {
|
|
return lockfileSkew('log-path-mismatch')
|
|
}
|
|
|
|
for (const field of ['profile', 'hermesPath', 'hermesHome', 'logPath', 'startedAt']) {
|
|
if (typeof parsed[field] !== 'string' || parsed[field].length > 1024) {
|
|
return lockfileSkew(`malformed-field ${field}`)
|
|
}
|
|
}
|
|
|
|
if (
|
|
parsed.creationTime !== undefined &&
|
|
(typeof parsed.creationTime !== 'string' || !/^(?:linux:[0-9]+|darwin:[A-Za-z0-9 :+-]+)$/.test(parsed.creationTime))
|
|
) {
|
|
return null
|
|
}
|
|
|
|
return parsed
|
|
}
|
|
|
|
async function writeLockfile(ssh, ownershipId, lock) {
|
|
const directory = ownershipDirectory(ownershipId)
|
|
const lpath = lockfilePath(ownershipId)
|
|
const temporaryPath = `${directory}/.${crypto.randomBytes(8).toString('hex')}.lock.tmp`
|
|
const json = JSON.stringify({ ...lock, schemaVersion: LOCKFILE_SCHEMA_VERSION })
|
|
await ssh.exec(
|
|
`umask 077 && mkdir -p ${expandRemotePath(directory)} && ` +
|
|
`printf '%s' ${shq(json)} > ${expandRemotePath(temporaryPath)} && ` +
|
|
`mv -f ${expandRemotePath(temporaryPath)} ${expandRemotePath(lpath)}`
|
|
)
|
|
}
|
|
|
|
async function removeLockfile(ssh, ownershipId) {
|
|
const lpath = lockfilePath(ownershipId)
|
|
|
|
try {
|
|
await ssh.exec(`rm -f ${expandRemotePath(lpath)}`)
|
|
} catch {
|
|
// best effort
|
|
}
|
|
}
|
|
|
|
async function remotePidAlive(ssh, pid) {
|
|
if (!pid || !Number.isInteger(Number(pid))) {
|
|
return false
|
|
}
|
|
|
|
try {
|
|
const out = (await ssh.exec(`kill -0 ${Number(pid)} 2>/dev/null && echo ALIVE || echo DEAD`)).trim()
|
|
|
|
return out === 'ALIVE'
|
|
} catch (cause) {
|
|
const error: any = new Error('Could not verify the SSH backend process.')
|
|
error.kind = 'transient-transport-error'
|
|
error.cause = cause
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// Stable kernel process-start identity used to fence a later managed-update
|
|
// termination against PID recycling. Linux exposes boot-relative start ticks;
|
|
// Darwin's `ps lstart` is only second-resolution, so the signal boundary also
|
|
// re-reads the complete argv and random ownership nonce in the same remote
|
|
// command. A same-second PID reuse with a forged/repeated nonce remains a
|
|
// residual limitation. Failure is represented as an empty string so SSH mode
|
|
// remains compatible on unusual POSIX hosts, but a managed update then refuses
|
|
// to kill that unproved serve.
|
|
async function remoteProcessCreationTime(ssh, pid) {
|
|
if (!pid || !Number.isInteger(Number(pid))) {
|
|
return ''
|
|
}
|
|
|
|
const script =
|
|
'import subprocess,sys\n' +
|
|
`pid=${Number(pid)}\n` +
|
|
'value=""\n' +
|
|
'if sys.platform.startswith("linux"):\n' +
|
|
' try:\n' +
|
|
' raw=open(f"/proc/{pid}/stat","r",encoding="ascii").read()\n' +
|
|
' fields=raw[raw.rfind(")")+2:].split()\n' +
|
|
' value="linux:"+fields[19]\n' +
|
|
' except (OSError,IndexError,UnicodeError):pass\n' +
|
|
'elif sys.platform=="darwin":\n' +
|
|
' try:\n' +
|
|
' started=subprocess.check_output(["ps","-o","lstart=","-p",str(pid)],text=True).strip()\n' +
|
|
' if started:value="darwin:"+started\n' +
|
|
' except (OSError,subprocess.CalledProcessError):pass\n' +
|
|
'print(value)'
|
|
|
|
try {
|
|
const value = String(await ssh.exec(`python3 -c ${shq(script)}`)).trim()
|
|
|
|
return /^(?:linux:[0-9]+|darwin:[A-Za-z0-9 :+-]+)$/.test(value) ? value : ''
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
// A pid is "provably ours" only if its remote cmdline carries our dashboard
|
|
// args — never kill a pid we can't positively identify as our dashboard.
|
|
async function pidIsOurDashboard(
|
|
ssh,
|
|
pid,
|
|
spawnNonce,
|
|
hermesPath = '',
|
|
hermesHome = '',
|
|
ownershipId = '',
|
|
profile = ''
|
|
) {
|
|
if (!pid || !/^[0-9a-f]{16}$/.test(String(spawnNonce || '')) || !hermesPath) {
|
|
return false
|
|
}
|
|
|
|
try {
|
|
const script =
|
|
'import os,shlex,subprocess,sys\n' +
|
|
`pid=${Number(pid)}\n` +
|
|
`expected=os.path.expanduser(${shq(hermesPath)})\n` +
|
|
// The installer-facing launcher is intentionally preserved for invocation
|
|
// (#74411), but it may `exec python <install-dir>/hermes`, leaving neither
|
|
// launcher nor HERMES_HOME-derived entrypoint in argv. The ownership-scoped
|
|
// token path + random nonce + exact profile below are the alternative proof.
|
|
`hermes_home=os.path.expanduser(${shq(hermesHome)}) if ${shq(hermesHome)} else ""\n` +
|
|
'expected_entries={expected}\n' +
|
|
'if hermes_home:\n' +
|
|
' expected_entries.add(os.path.join(hermes_home,"hermes-agent","venv","bin","hermes"))\n' +
|
|
`expected_token=os.path.expanduser(${shq(ownershipId ? spawnTokenPath(ownershipId, spawnNonce) : '')})\n` +
|
|
`expected_profile=${shq(profile)}\n` +
|
|
`nonce=${shq(spawnNonce)}\n` +
|
|
'try:\n' +
|
|
' raw=open(f"/proc/{pid}/cmdline","rb").read()\n' +
|
|
' args=[x.decode("utf-8","surrogateescape") for x in raw.split(b"\\0") if x]\n' +
|
|
'except OSError:\n' +
|
|
' try:\n' +
|
|
' line=subprocess.check_output(["ps","-ww","-o","command=","-p",str(pid)],text=True).strip()\n' +
|
|
' except subprocess.CalledProcessError:\n' +
|
|
' # pid already gone — a dead process is FOREIGN, not a transport error\n' +
|
|
' print("FOREIGN");sys.exit(0)\n' +
|
|
' args=shlex.split(line)\n' +
|
|
'ok=False\n' +
|
|
'try:\n' +
|
|
' serve=args.index("serve")\n' +
|
|
' owner=args.index("--ssh-owner-nonce",serve+1)\n' +
|
|
' token=args.index("--ssh-session-token-file",serve+1) if expected_token else -1\n' +
|
|
' isolated=args.index("--isolated",serve+1)\n' +
|
|
' profile_arg=args.index("--profile") if expected_profile else -1\n' +
|
|
' serve_count=args.count("serve")\n' +
|
|
' owner_count=args.count("--ssh-owner-nonce")\n' +
|
|
' token_count=args.count("--ssh-session-token-file")\n' +
|
|
' isolated_count=args.count("--isolated")\n' +
|
|
' profile_count=args.count("--profile")\n' +
|
|
' direct=args[0] in expected_entries\n' +
|
|
' python_entry=len(args)>1 and args[1] in expected_entries and os.path.basename(args[0]).startswith("python")\n' +
|
|
' token_ok=not expected_token or args[token+1]==expected_token\n' +
|
|
' isolated_ok=isolated_count==1 and isolated>serve\n' +
|
|
' profile_ok=(profile_count==1 and profile_arg<serve and args[profile_arg+1]==expected_profile) if expected_profile else profile_count==0\n' +
|
|
' spawn_proof=bool(expected_token) and owner_count==1 and token_count==1 and token_ok and profile_ok\n' +
|
|
' ok=(direct or python_entry or spawn_proof) and serve_count==1 and isolated_ok and owner_count==1 and args[owner+1]==nonce and token_ok and profile_ok\n' +
|
|
'except (ValueError,IndexError):pass\n' +
|
|
'print("OWNED" if ok else "FOREIGN")'
|
|
|
|
const out = await ssh.exec(`python3 -c ${shq(script)}`)
|
|
|
|
return String(out || '').trim() === 'OWNED'
|
|
} catch (cause) {
|
|
const error: any = new Error('Could not verify SSH backend process ownership.')
|
|
error.kind = 'transient-transport-error'
|
|
error.cause = cause
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// Kill the stale dashboard ONLY if provably ours, then drop the lockfile.
|
|
async function cleanupStale(ssh, ownershipId, lock, pidAlive = true) {
|
|
// Defense in depth (#95532): a skew sentinel is foreign/corrupt state, not
|
|
// an ownership record — never reap or remove anything based on it.
|
|
if (isLockfileSkew(lock)) {
|
|
return
|
|
}
|
|
|
|
if (
|
|
pidAlive &&
|
|
lock &&
|
|
(await pidIsOurDashboard(
|
|
ssh,
|
|
lock.pid,
|
|
lock.spawnNonce,
|
|
lock.hermesPath,
|
|
lock.hermesHome,
|
|
ownershipId,
|
|
lock.profile
|
|
))
|
|
) {
|
|
try {
|
|
const result = (
|
|
await ssh.exec(
|
|
`kill ${Number(lock.pid)} && ` +
|
|
`i=0; while kill -0 ${Number(lock.pid)} 2>/dev/null; do ` +
|
|
`i=$((i+1)); [ "$i" -ge 50 ] && exit 1; sleep 0.1; done`
|
|
)
|
|
).trim()
|
|
|
|
void result
|
|
} catch {
|
|
// A backend mid-turn (in-flight LLM call, live MCP children) can ride
|
|
// out SIGTERM past the 5s graceful wait — and before-quit races this
|
|
// whole teardown against 6s before closing SSH, so giving up here
|
|
// reparents the still-running serve to pid 1: the #91668 leak, now on
|
|
// the quit-during-active-turn path. Escalate to SIGKILL and require a
|
|
// confirmed exit before treating the record as reclaimed.
|
|
try {
|
|
await ssh.exec(
|
|
`kill -9 ${Number(lock.pid)} 2>/dev/null; ` +
|
|
`i=0; while kill -0 ${Number(lock.pid)} 2>/dev/null; do ` +
|
|
`i=$((i+1)); [ "$i" -ge 20 ] && exit 1; sleep 0.1; done`
|
|
)
|
|
} catch (cause) {
|
|
// Even SIGKILL could not confirm death (D-state, permissions). Keep
|
|
// the lockfile so the next connect's reap pass retries.
|
|
const error: any = new Error('Could not terminate the stale SSH backend.')
|
|
error.kind = 'transient-transport-error'
|
|
error.cause = cause
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
const expectedLogPath = lock?.spawnNonce ? spawnLogPath(ownershipId, lock.spawnNonce) : ''
|
|
|
|
if (lock?.logPath === expectedLogPath) {
|
|
try {
|
|
await ssh.exec(`rm -f ${expandRemotePath(lock.logPath)}`)
|
|
} catch {
|
|
void 0
|
|
}
|
|
}
|
|
|
|
await removeLockfile(ssh, ownershipId)
|
|
}
|
|
|
|
// Normal disconnect (quit, connection switch): reuse cleanupStale so we
|
|
// kill only a provably-owned serve --isolated and drop our lockfile.
|
|
// Closing the SSH transport first is not enough — spawn detaches with
|
|
// setsid/nohup, so the backend reparents to pid 1 and keeps state.db
|
|
// open (#91668).
|
|
async function disconnect(ssh, ownershipId) {
|
|
if (!ssh || !ownershipId) {
|
|
return
|
|
}
|
|
|
|
const lock = await readLockfile(ssh, ownershipId)
|
|
|
|
if (!lock || isLockfileSkew(lock)) {
|
|
// Skew (#95532): fail closed — this is not our record, so there is
|
|
// nothing we may safely reap or remove here.
|
|
return
|
|
}
|
|
|
|
const pidAlive = await remotePidAlive(ssh, lock.pid)
|
|
await cleanupStale(ssh, ownershipId, lock, pidAlive)
|
|
}
|
|
|
|
function buildOwnedStaleTerminationCommand(lock, ownershipId) {
|
|
const pid = Number(lock.pid)
|
|
// expandRemotePath() output is already a shell-quoted fragment; embed it
|
|
// raw so $HOME expands at assignment. Double-quoting stores the quote
|
|
// characters in the variable and every identity match below REFUSEs.
|
|
const expectedPath = expandRemotePath(lock.hermesPath)
|
|
const expectedHome = lock.hermesHome ? expandRemotePath(lock.hermesHome) : "''"
|
|
const expectedToken = expandRemotePath(spawnTokenPath(ownershipId, lock.spawnNonce))
|
|
const nonce = shq(lock.spawnNonce)
|
|
const profile = shq(lock.profile || '')
|
|
const command = `$(ps -ww -o command= -p ${pid} 2>/dev/null || true)`
|
|
|
|
const executableMatch = lock.hermesHome
|
|
? `case "$cmd" in *"$path"*|*"$home"*) ;; *) printf REFUSED; exit 0;; esac; `
|
|
: `case "$cmd" in *"$path"*) ;; *) printf REFUSED; exit 0;; esac; `
|
|
|
|
const identity =
|
|
`cmd=${command}; ` +
|
|
`path=${expectedPath}; home=${expectedHome}; token=${expectedToken}; nonce=${nonce}; profile=${profile}; ` +
|
|
executableMatch +
|
|
`case "$cmd" in *" serve"*|*" serve "*) ;; *) printf REFUSED; exit 0;; esac; ` +
|
|
`case "$cmd" in *"--ssh-owner-nonce $nonce"*) ;; *) printf REFUSED; exit 0;; esac; ` +
|
|
`case "$cmd" in *"--ssh-session-token-file $token"*) ;; *) printf REFUSED; exit 0;; esac; ` +
|
|
`[ -n "$profile" ] && case "$cmd" in *"--profile $profile"*) ;; *) printf REFUSED; exit 0;; esac; `
|
|
|
|
// Legacy records do not have creationTime. Re-read argv immediately before
|
|
// signaling in this same shell command; never use the earlier probe's PID
|
|
// verdict as authority for the kill.
|
|
return (
|
|
`${identity} kill ${pid} && ` +
|
|
`i=0; while kill -0 ${pid} 2>/dev/null; do ` +
|
|
`i=$((i+1)); [ "$i" -ge 50 ] && printf TIMEOUT && exit 0; sleep 0.1; done; printf TERMINATED`
|
|
)
|
|
}
|
|
|
|
function lockMatchesManagedUpdateScope(lock, expected) {
|
|
return Boolean(
|
|
lock &&
|
|
expected &&
|
|
lock.ownershipId === expected.ownershipId &&
|
|
lock.pid === expected.pid &&
|
|
lock.spawnNonce === expected.spawnNonce &&
|
|
lock.startedAt === expected.startedAt &&
|
|
lock.creationTime === expected.creationTime &&
|
|
lock.profile === expected.profile &&
|
|
lock.hermesPath === expected.hermesPath &&
|
|
lock.hermesHome === expected.hermesHome
|
|
)
|
|
}
|
|
|
|
function buildOwnedTerminationCommand(lock, ownershipId) {
|
|
const pid = Number(lock.pid)
|
|
const py = value => JSON.stringify(String(value || ''))
|
|
const expectedToken = spawnTokenPath(ownershipId, lock.spawnNonce)
|
|
|
|
const script = `
|
|
import os,select,shlex,signal,subprocess,sys,time
|
|
pid=${pid}
|
|
expected_creation=${py(lock.creationTime)}
|
|
expected_path=os.path.expanduser(${py(lock.hermesPath)})
|
|
hermes_home=os.path.expanduser(${py(lock.hermesHome)})
|
|
expected_entries={expected_path,os.path.join(hermes_home,"hermes-agent","venv","bin","hermes")}
|
|
expected_token=os.path.expanduser(${py(expectedToken)})
|
|
expected_profile=${py(lock.profile)}
|
|
nonce=${py(lock.spawnNonce)}
|
|
|
|
def creation():
|
|
if sys.platform.startswith("linux"):
|
|
try:
|
|
raw=open(f"/proc/{pid}/stat","r",encoding="ascii").read()
|
|
return "linux:"+raw[raw.rfind(")")+2:].split()[19]
|
|
except (OSError,IndexError,UnicodeError):return ""
|
|
if sys.platform=="darwin":
|
|
try:
|
|
value=subprocess.check_output(["ps","-o","lstart=","-p",str(pid)],text=True).strip()
|
|
return "darwin:"+value if value else ""
|
|
except (OSError,subprocess.CalledProcessError):return ""
|
|
return ""
|
|
|
|
def argv():
|
|
try:
|
|
raw=open(f"/proc/{pid}/cmdline","rb").read()
|
|
return [part.decode("utf-8","surrogateescape") for part in raw.split(b"\\0") if part]
|
|
except OSError:
|
|
try:return shlex.split(subprocess.check_output(["ps","-ww","-o","command=","-p",str(pid)],text=True).strip())
|
|
except (OSError,subprocess.CalledProcessError,ValueError):return []
|
|
|
|
def identity_before_signal():
|
|
# Darwin's lstart has one-second resolution. Read the start time and the
|
|
# complete argv in one ps call immediately before signalling; the random
|
|
# ownership nonce is the discriminator for a same-second PID reuse. A
|
|
# same-second reuse with a forged/repeated nonce remains a residual limitation.
|
|
if sys.platform=="darwin":
|
|
try:
|
|
line=subprocess.check_output(["ps","-ww","-p",str(pid),"-o","lstart=","-o","command="],text=True).strip()
|
|
prefix=expected_creation.removeprefix("darwin:")
|
|
if not prefix or not line.startswith(prefix):return "",[]
|
|
return "darwin:"+prefix,shlex.split(line[len(prefix):].strip())
|
|
except (OSError,subprocess.CalledProcessError,ValueError):return "",[]
|
|
return creation(),argv()
|
|
|
|
def owned(args):
|
|
try:
|
|
serve=args.index("serve")
|
|
owner=args.index("--ssh-owner-nonce",serve+1)
|
|
token=args.index("--ssh-session-token-file",serve+1)
|
|
isolated=args.index("--isolated",serve+1)
|
|
profile_arg=args.index("--profile") if expected_profile else -1
|
|
direct=args[0] in expected_entries
|
|
python_entry=len(args)>1 and args[1] in expected_entries and os.path.basename(args[0]).startswith("python")
|
|
profile_ok=(args.count("--profile")==1 and profile_arg<serve and args[profile_arg+1]==expected_profile) if expected_profile else args.count("--profile")==0
|
|
return ((direct or python_entry or (args[token+1]==expected_token and profile_ok)) and
|
|
args.count("serve")==1 and args.count("--ssh-owner-nonce")==1 and
|
|
args.count("--ssh-session-token-file")==1 and args.count("--isolated")==1 and
|
|
isolated>serve and args[owner+1]==nonce and args[token+1]==expected_token and profile_ok)
|
|
except (ValueError,IndexError):return False
|
|
|
|
pidfd=None
|
|
if sys.platform.startswith("linux"):
|
|
if not hasattr(os,"pidfd_open") or not hasattr(signal,"pidfd_send_signal"):
|
|
print("UNAVAILABLE");sys.exit(2)
|
|
try:pidfd=os.pidfd_open(pid,0)
|
|
except ProcessLookupError:print("ALREADY_STOPPED");sys.exit(0)
|
|
except (OSError,PermissionError):print("UNAVAILABLE");sys.exit(2)
|
|
|
|
try:
|
|
live_creation,live_args=identity_before_signal()
|
|
if live_creation!=expected_creation or not owned(live_args):
|
|
print("REFUSED");sys.exit(3)
|
|
if (sys.platform=="darwin"):
|
|
# Darwin has no pidfd-style signal binding. Refuse instead of accepting the
|
|
# residual PID-reuse window between ps and os.kill; reconnect will surface
|
|
# the still-running remote owner for an explicit retry.
|
|
print("DARWIN_UNAVAILABLE");sys.exit(2)
|
|
try:
|
|
if pidfd is not None:signal.pidfd_send_signal(pidfd,signal.SIGTERM)
|
|
else:os.kill(pid,signal.SIGTERM)
|
|
except ProcessLookupError:print("ALREADY_STOPPED");sys.exit(0)
|
|
if pidfd is not None:
|
|
poller=select.poll();poller.register(pidfd,select.POLLIN)
|
|
if not poller.poll(10000):print("TIMEOUT");sys.exit(4)
|
|
else:
|
|
deadline=time.monotonic()+10
|
|
while time.monotonic()<deadline:
|
|
try:os.kill(pid,0)
|
|
except ProcessLookupError:break
|
|
except PermissionError:print("UNAVAILABLE");sys.exit(2)
|
|
time.sleep(.1)
|
|
else:print("TIMEOUT");sys.exit(4)
|
|
print("TERMINATED")
|
|
finally:
|
|
if pidfd is not None:os.close(pidfd)
|
|
`.trim()
|
|
|
|
return `python3 -c ${shq(script)}`
|
|
}
|
|
|
|
// The updater's Python _MarkerMutex uses the marker's .mutex sidecar and an
|
|
// advisory flock. Keep that same descriptor locked while the remote shell does
|
|
// the marker check, spawns the backend, and publishes its initial lockfile.
|
|
// Python keeps the descriptor close-on-exec by default and passes it explicitly
|
|
// only to the intended outer shell; each detached child closes it before
|
|
// execing Hermes.
|
|
function withRemoteUpdateMutex(command, mutexPath) {
|
|
const script = `
|
|
import fcntl,os,subprocess,sys
|
|
mutex_path=sys.argv[1]
|
|
payload=sys.argv[2]
|
|
parent=os.path.dirname(mutex_path)
|
|
if parent:os.makedirs(parent,exist_ok=True)
|
|
fd=os.open(mutex_path,os.O_RDWR|os.O_CREAT|os.O_CLOEXEC,0o600)
|
|
fcntl.flock(fd,fcntl.LOCK_EX)
|
|
result=None
|
|
try:
|
|
result=subprocess.run(["sh","-c",payload,"hermes-update-mutex",str(fd)],pass_fds=(fd,),check=False)
|
|
finally:
|
|
os.close(fd)
|
|
sys.exit(result.returncode if result is not None else 1)
|
|
`.trim()
|
|
|
|
return `python3 -c ${shq(script)} ${shq(mutexPath)} ${shq(command)}`
|
|
}
|
|
|
|
/**
|
|
* Stop one Desktop-owned POSIX serve before an install update.
|
|
*
|
|
* This is deliberately stricter than stale cleanup. The in-memory scope is a
|
|
* snapshot of the ownership record that established the forward; immediately
|
|
* before signalling we re-read that record, compare its PID + kernel creation
|
|
* identity and random argv nonce, and prove the live argv is the exact
|
|
* isolated serve Desktop launched. Any absence, parse failure, replacement,
|
|
* or transport uncertainty refuses the kill. The lock is intentionally left
|
|
* behind: the post-update reconnect reclaims the now-dead exact record, while
|
|
* an old cleanup can never unlink a replacement owner.
|
|
*/
|
|
async function terminateOwnedDashboardForUpdate(ssh, expected) {
|
|
const ownershipId = validateOwnershipId(expected?.ownershipId)
|
|
|
|
if (!expected?.creationTime) {
|
|
const error: any = new Error('The remote POSIX serve has no process creation-time proof.')
|
|
error.kind = 'ownership-changed'
|
|
throw error
|
|
}
|
|
|
|
let lock = await readLockfile(ssh, ownershipId)
|
|
|
|
if (!lock || !lockMatchesManagedUpdateScope(lock, expected)) {
|
|
const error: any = new Error('The remote POSIX ownership record changed before the managed update.')
|
|
error.kind = 'ownership-changed'
|
|
throw error
|
|
}
|
|
|
|
if (!(await remotePidAlive(ssh, lock.pid))) {
|
|
return { pid: lock.pid, terminated: false, alreadyStopped: true }
|
|
}
|
|
|
|
if ((await remoteProcessCreationTime(ssh, lock.pid)) !== lock.creationTime) {
|
|
const error: any = new Error('The remote POSIX PID creation time no longer matches its ownership record.')
|
|
error.kind = 'ownership-changed'
|
|
throw error
|
|
}
|
|
|
|
if (
|
|
!(await pidIsOurDashboard(
|
|
ssh,
|
|
lock.pid,
|
|
lock.spawnNonce,
|
|
lock.hermesPath,
|
|
lock.hermesHome,
|
|
ownershipId,
|
|
lock.profile
|
|
))
|
|
) {
|
|
const error: any = new Error('Refusing to terminate a remote process whose Desktop ownership is unproven.')
|
|
error.kind = 'foreign-backend'
|
|
throw error
|
|
}
|
|
|
|
// Re-read after the argv proof. A concurrent/replacement writer cannot turn
|
|
// proof of the old record into authority over its new PID.
|
|
lock = await readLockfile(ssh, ownershipId)
|
|
|
|
if (!lock || !lockMatchesManagedUpdateScope(lock, expected)) {
|
|
const error: any = new Error('The remote POSIX ownership record changed during process verification.')
|
|
error.kind = 'ownership-changed'
|
|
throw error
|
|
}
|
|
|
|
if (
|
|
(await remoteProcessCreationTime(ssh, lock.pid)) !== lock.creationTime ||
|
|
!(await pidIsOurDashboard(
|
|
ssh,
|
|
lock.pid,
|
|
lock.spawnNonce,
|
|
lock.hermesPath,
|
|
lock.hermesHome,
|
|
ownershipId,
|
|
lock.profile
|
|
))
|
|
) {
|
|
const error: any = new Error('The remote POSIX process identity changed during managed update drain.')
|
|
error.kind = 'ownership-changed'
|
|
throw error
|
|
}
|
|
|
|
try {
|
|
const result = String(await ssh.exec(buildOwnedTerminationCommand(lock, ownershipId))).trim()
|
|
|
|
if (result === 'ALREADY_STOPPED') {
|
|
return { pid: lock.pid, terminated: false, alreadyStopped: true }
|
|
}
|
|
|
|
if (result !== 'TERMINATED') {
|
|
const error: any = new Error(
|
|
result === 'REFUSED'
|
|
? 'The remote POSIX process identity changed at the signal boundary.'
|
|
: result === 'DARWIN_UNAVAILABLE'
|
|
? 'Darwin cannot atomically bind a signal to the verified PID; refusing termination.'
|
|
: 'The remote POSIX signal boundary was unavailable.'
|
|
)
|
|
|
|
error.kind =
|
|
result === 'REFUSED' || result === 'DARWIN_UNAVAILABLE' ? 'ownership-changed' : 'transient-transport-error'
|
|
throw error
|
|
}
|
|
} catch (cause: any) {
|
|
if (cause?.kind === 'ownership-changed') {
|
|
throw cause
|
|
}
|
|
|
|
const error: any = new Error('Could not terminate the Desktop-owned remote serve for update.')
|
|
error.kind = 'transient-transport-error'
|
|
error.cause = cause
|
|
throw error
|
|
}
|
|
|
|
return { pid: lock.pid, terminated: true, alreadyStopped: false }
|
|
}
|
|
|
|
// Detach so the backend survives the SSH channel closing: setsid (Linux)
|
|
// starts a new session; macOS has no setsid, so fall back to nohup (HUP-immune;
|
|
// fd-detachment is already handled by </dev/null + redirect + &).
|
|
function buildSpawnCommand(hermesPath, profile, opts: any = {}) {
|
|
const hermes = expandRemotePath(hermesPath)
|
|
const profileArgs = profile ? `--profile ${shq(profile)} ` : ''
|
|
const logPath = expandRemotePath(opts.logPath)
|
|
const tokenFilePath = opts.tokenFilePath
|
|
const tokenArg = tokenFilePath ? ` --ssh-session-token-file ${expandRemotePath(tokenFilePath)}` : ''
|
|
const ownerArg = opts.spawnNonce ? ` --ssh-owner-nonce ${validateSpawnNonce(opts.spawnNonce)}` : ''
|
|
const subCmd = `serve --isolated --host 127.0.0.1 --port 0${tokenArg}${ownerArg}`
|
|
const marker = expandRemotePath(`${remoteInstallRoot(opts.hermesHome || '~/.hermes')}/.hermes-update-in-progress`)
|
|
|
|
const updateMutex = expandRemotePath(
|
|
`${remoteInstallRoot(opts.hermesHome || '~/.hermes')}/.hermes-update-in-progress.mutex`
|
|
)
|
|
|
|
// The marker probe, ownership reservation, process creation, and initial
|
|
// lockfile publication must be one remote command. A second Desktop process
|
|
// can therefore never observe an empty lock and spawn before this one records
|
|
// its PID. The reservation is an atomic mkdir and is reclaimed only when its
|
|
// owning remote shell is dead.
|
|
const markerClear =
|
|
`marker_clear() { if [ ! -e ${marker} ]; then return 0; fi; ` +
|
|
`if [ ! -r ${marker} ]; then return 1; fi; ` +
|
|
`owner=$(IFS= read -r owner < ${marker} && printf '%s' "$owner"); ` +
|
|
`case "$owner" in ''|*[!0-9]*) return 1;; esac; if kill -0 "$owner" 2>/dev/null; then return 1; fi; return 0; }`
|
|
|
|
const dashCmd =
|
|
`ulimit -n ${REMOTE_NOFILE_SOFT_LIMIT} 2>/dev/null || true; ` +
|
|
`exec env HERMES_DESKTOP=1 ${hermes} ${profileArgs}${subCmd}`
|
|
|
|
const detachedShell = `eval "exec $1>&-"; ${dashCmd} </dev/null >> ${logPath} 2>&1 & echo $!`
|
|
const detachedSpawn = `child=$("$(command -v setsid || echo nohup)" sh -c ${shq(detachedShell)} hermes-update-child "$1" & echo $!)`
|
|
|
|
if (!opts.ownershipId || !opts.lockMetadata) {
|
|
return withRemoteUpdateMutex(
|
|
`${markerClear}; marker_clear || exit 75; ` +
|
|
`mkdir -p "$(dirname ${logPath})" && ` +
|
|
`${detachedSpawn}; ` +
|
|
`marker_clear || { kill "$child" 2>/dev/null || true; wait "$child" 2>/dev/null || true; exit 75; }; echo "$child"`,
|
|
updateMutex
|
|
)
|
|
}
|
|
|
|
const reservation = expandRemotePath(connectReservationPath(opts.ownershipId))
|
|
const lockPath = expandRemotePath(lockfilePath(opts.ownershipId))
|
|
const tokenPath = tokenFilePath ? expandRemotePath(tokenFilePath) : ''
|
|
const ownerPath = `${reservation}/owner`
|
|
const metadata = JSON.stringify({ schemaVersion: LOCKFILE_SCHEMA_VERSION, ...opts.lockMetadata, pid: '__PID__' })
|
|
const reservationNonce = validateSpawnNonce(opts.reservationNonce || crypto.randomBytes(8).toString('hex'))
|
|
|
|
return withRemoteUpdateMutex(
|
|
`umask 077 && mkdir -p "$(dirname ${reservation})"; ` +
|
|
// reservation/lockPath/ownerPath are expandRemotePath() output — already
|
|
// shell-quoted fragments ("$HOME"'/…'). Embed raw so the assignment
|
|
// expands $HOME; shq() here would store the quote characters literally
|
|
// and every mkdir/cat against the variable fails forever.
|
|
`reservation=${reservation}; lock=${lockPath}; owner_file=${ownerPath}; ` +
|
|
`reservation_nonce=${shq(reservationNonce)}; ` +
|
|
`i=0; while ! mkdir "$reservation" 2>/dev/null; do ` +
|
|
`owner_data=$(cat "$owner_file" 2>/dev/null || true); owner_pid=${'${owner_data%%:*}'}; ` +
|
|
`case "$owner_pid" in ''|*[!0-9]*) ;; *) kill -0 "$owner_pid" 2>/dev/null || { rm -rf "$reservation"; continue; };; esac; ` +
|
|
`i=$((i+1)); [ "$i" -ge 600 ] && exit 75; sleep 0.05; done; ` +
|
|
`printf '%s:%s' "$$" "$reservation_nonce" > "$owner_file"; ` +
|
|
`trap 'rm -rf "$reservation"' EXIT; ` +
|
|
`if [ -f "$lock" ]; then ` +
|
|
`existing_pid=$(sed -n 's/.*"pid":\\([0-9][0-9]*\\).*/\\1/p' "$lock" | head -n 1); ` +
|
|
`case "$existing_pid" in ''|*[!0-9]*) rm -f "$lock";; *) ` +
|
|
`if kill -0 "$existing_pid" 2>/dev/null; then ${tokenPath ? `rm -f ${tokenPath}; ` : ''}printf EXISTING; exit 0; fi; rm -f "$lock";; esac; fi; ` +
|
|
`${markerClear}; marker_clear || exit 75; mkdir -p "$(dirname ${logPath})" && ` +
|
|
`${detachedSpawn}; ` +
|
|
`marker_clear || { kill "$child" 2>/dev/null || true; wait "$child" 2>/dev/null || true; exit 75; }; ` +
|
|
// ${var//pat/rep} is a bashism — this payload runs under plain sh (dash
|
|
// on Ubuntu), which aborts the whole script on it with "Bad
|
|
// substitution" AFTER the child was spawned, orphaning the backend and
|
|
// skipping the lockfile publication. Substitute with sed instead.
|
|
`lock_json=$(printf '%s' ${shq(metadata)} | sed "s/__PID__/\${child}/"); ` +
|
|
`temporary_lock="\${lock}.${reservationNonce}.tmp"; ` +
|
|
`printf '%s' "$lock_json" > "$temporary_lock" && mv -f "$temporary_lock" "$lock" || { kill "$child" 2>/dev/null || true; wait "$child" 2>/dev/null || true; exit 76; }; ` +
|
|
`echo "$child"`,
|
|
updateMutex
|
|
)
|
|
}
|
|
|
|
async function remoteSupportsSshOwnership(ssh, hermesPath) {
|
|
const hermes = expandRemotePath(hermesPath)
|
|
|
|
const out = await ssh.exec(
|
|
`help="$(${hermes} serve --help 2>&1)"; ` +
|
|
`printf '%s' "$help" | grep -q ssh-session-token-file && ` +
|
|
`printf '%s' "$help" | grep -q ssh-owner-nonce && echo YES || echo NO`
|
|
)
|
|
|
|
return String(out || '')
|
|
.trim()
|
|
.endsWith('YES')
|
|
}
|
|
|
|
async function scrapeReadyPort(ssh, logPath, { timeoutMs = DEFAULT_READY_TIMEOUT_MS, isAlive, signal }: any = {}) {
|
|
const deadline = Date.now() + timeoutMs
|
|
const remoteLog = expandRemotePath(logPath)
|
|
|
|
while (Date.now() < deadline) {
|
|
assertBootstrapNotSuperseded(signal)
|
|
|
|
if (isAlive && !(await isAlive())) {
|
|
const err: any = new Error('Remote dashboard process exited before announcing its port.')
|
|
err.kind = 'spawn-failed'
|
|
throw err
|
|
}
|
|
|
|
let tail
|
|
|
|
try {
|
|
tail = await ssh.exec(`cat ${remoteLog} 2>/dev/null || true`)
|
|
} catch {
|
|
tail = ''
|
|
}
|
|
|
|
const m = READY_RE.exec(String(tail || ''))
|
|
|
|
if (m) {
|
|
return parseInt(m[1], 10)
|
|
}
|
|
|
|
await new Promise(r => setTimeout(r, READY_POLL_INTERVAL_MS))
|
|
}
|
|
|
|
const err: any = new Error(`Timed out waiting for the remote dashboard to announce its port (${timeoutMs}ms).`)
|
|
err.kind = 'ready-timeout'
|
|
throw err
|
|
}
|
|
|
|
async function spawnRemoteDashboard(
|
|
ssh,
|
|
{ hermesPath, profile, token, ownershipId, hermesHome = '~/.hermes', assertInstallClear = async () => {} }
|
|
) {
|
|
if (!(await remoteSupportsSshOwnership(ssh, hermesPath))) {
|
|
const err: any = new Error(
|
|
'The remote Hermes install does not support --ssh-session-token-file and --ssh-owner-nonce. ' +
|
|
'Update Hermes on the remote host to continue using Desktop SSH mode.'
|
|
)
|
|
|
|
err.kind = 'update-required'
|
|
throw err
|
|
}
|
|
|
|
const spawnNonce = crypto.randomBytes(8).toString('hex')
|
|
const tokenFilePath = spawnTokenPath(ownershipId, spawnNonce)
|
|
const logPath = spawnLogPath(ownershipId, spawnNonce)
|
|
|
|
const tokenUploadPy =
|
|
'import os,sys,stat\n' +
|
|
`p=os.path.expanduser(${shq(tokenFilePath)})\n` +
|
|
'd=os.path.dirname(p)\n' +
|
|
'n=os.path.basename(p)\n' +
|
|
'os.makedirs(d,mode=0o700,exist_ok=True)\n' +
|
|
'df=os.O_RDONLY|getattr(os,"O_DIRECTORY",0)|getattr(os,"O_NOFOLLOW",0)\n' +
|
|
'dd=os.open(d,df)\n' +
|
|
'try:\n' +
|
|
' s=os.fstat(dd)\n' +
|
|
' if not stat.S_ISDIR(s.st_mode):raise SystemExit("unsafe token directory")\n' +
|
|
' if hasattr(os,"getuid") and s.st_uid!=os.getuid():raise SystemExit("token directory owner mismatch")\n' +
|
|
' if (s.st_mode&0o777)!=0o700:os.fchmod(dd,0o700)\n' +
|
|
' fl=os.O_WRONLY|os.O_CREAT|os.O_EXCL|getattr(os,"O_NOFOLLOW",0)\n' +
|
|
' now=__import__("time").time()\n' +
|
|
' for stale in os.listdir(dd):\n' +
|
|
' if stale.endswith(".token") and len(stale)==22:\n' +
|
|
' try:\n' +
|
|
' ss=os.stat(stale,dir_fd=dd,follow_symlinks=False)\n' +
|
|
' if stat.S_ISREG(ss.st_mode) and now-ss.st_mtime>3600:os.unlink(stale,dir_fd=dd)\n' +
|
|
' except OSError:pass\n' +
|
|
' fd=os.open(n,fl,0o600,dir_fd=dd)\n' +
|
|
' try:os.write(fd,sys.stdin.buffer.read())\n' +
|
|
' except BaseException:\n' +
|
|
' try:os.unlink(n,dir_fd=dd)\n' +
|
|
' except OSError:pass\n' +
|
|
' raise\n' +
|
|
' finally:os.close(fd)\n' +
|
|
'finally:os.close(dd)'
|
|
|
|
try {
|
|
await ssh.exec(`python3 -c ${shq(tokenUploadPy)}`, { stdinData: token })
|
|
} catch (error) {
|
|
try {
|
|
await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`)
|
|
} catch {
|
|
void 0
|
|
}
|
|
|
|
throw error
|
|
}
|
|
|
|
let out
|
|
|
|
try {
|
|
// Close the marker race after the token-file write and immediately before
|
|
// process creation. The caller's probe imports no changing checkout code.
|
|
await assertInstallClear()
|
|
out = await ssh.exec(
|
|
buildSpawnCommand(hermesPath, profile, {
|
|
spawnNonce,
|
|
tokenFilePath,
|
|
logPath,
|
|
hermesHome,
|
|
ownershipId,
|
|
reservationNonce: spawnNonce,
|
|
lockMetadata: {
|
|
ownershipId,
|
|
spawnNonce,
|
|
port: 0,
|
|
profile,
|
|
hermesPath,
|
|
hermesHome,
|
|
logPath,
|
|
tokenFingerprint: fingerprintToken(token),
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
startedAt: new Date().toISOString()
|
|
}
|
|
})
|
|
)
|
|
} catch (error) {
|
|
try {
|
|
await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`)
|
|
} catch {
|
|
void 0
|
|
}
|
|
|
|
throw error
|
|
}
|
|
|
|
const outputLines = String(out || '')
|
|
.trim()
|
|
.split('\n')
|
|
.map(line => line.trim())
|
|
.filter(Boolean)
|
|
|
|
if (outputLines.at(-1) === 'EXISTING') {
|
|
return { existing: true }
|
|
}
|
|
|
|
const pid = parseInt(outputLines.at(-1) || '', 10)
|
|
|
|
if (!Number.isInteger(pid) || pid <= 0) {
|
|
try {
|
|
await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`)
|
|
} catch {
|
|
void 0
|
|
}
|
|
|
|
const err: any = new Error('Failed to launch the remote dashboard (no pid returned).')
|
|
err.kind = 'spawn-failed'
|
|
throw err
|
|
}
|
|
|
|
return { pid, spawnNonce, logPath, tokenFilePath }
|
|
}
|
|
|
|
// Best-effort forward teardown when a reuse attempt fails mid-flight, so we
|
|
// don't leak a forward before respawning. `deps.cancelForward` is optional.
|
|
async function cancelForwardSafe(deps, localPort, remotePort) {
|
|
if (typeof deps.cancelForward !== 'function') {
|
|
return
|
|
}
|
|
|
|
try {
|
|
await deps.cancelForward(localPort, remotePort)
|
|
} catch {
|
|
// best effort
|
|
}
|
|
}
|
|
|
|
function isForwardBindCollision(error) {
|
|
return /address already in use|cannot listen to port|bind.*failed/i.test(String(error?.message || error || ''))
|
|
}
|
|
|
|
async function openForward(deps, remotePort, attempts = 3) {
|
|
let lastError
|
|
|
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
const localPort = await deps.pickLocalPort()
|
|
|
|
try {
|
|
await deps.forward(localPort, remotePort)
|
|
|
|
return localPort
|
|
} catch (error) {
|
|
lastError = error
|
|
|
|
if (!isForwardBindCollision(error) || attempt === attempts - 1) {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
throw lastError
|
|
}
|
|
|
|
/**
|
|
* Establish (or reuse) a remote dashboard and a tunnel to it. `deps` injects the
|
|
* opened SshConnection, forward/pickLocalPort/waitForHermes, a token-gated
|
|
* probeReuseProof, and adoptServedToken. Returns the connection descriptor
|
|
* { baseUrl, token, tokenFingerprint, remotePort, localPort, pid, reused, platform }.
|
|
*/
|
|
async function adoptOwnedServedToken(adoptServedToken, baseUrl, expectedToken, ssh, pid, label) {
|
|
const token = await adoptServedToken(baseUrl, expectedToken, {
|
|
childAlive: () => true,
|
|
label
|
|
})
|
|
|
|
if (!(await remotePidAlive(ssh, pid))) {
|
|
const error: any = new Error(`${label} exited while its served token was being resolved.`)
|
|
error.kind = token === expectedToken ? 'spawn-failed' : 'foreign-backend'
|
|
throw error
|
|
}
|
|
|
|
return token
|
|
}
|
|
|
|
async function waitForRemoteSpawnCompletion(ssh, ownershipId, timeoutMs) {
|
|
const deadline = Date.now() + timeoutMs
|
|
|
|
while (Date.now() < deadline) {
|
|
const lock = await readLockfile(ssh, ownershipId)
|
|
|
|
if (!lock) {
|
|
return false
|
|
}
|
|
|
|
if (lock.port > 0) {
|
|
return true
|
|
}
|
|
|
|
await new Promise(resolve => setTimeout(resolve, READY_POLL_INTERVAL_MS))
|
|
}
|
|
|
|
const error: any = new Error('Timed out waiting for the concurrent SSH connection to publish its backend.')
|
|
error.kind = 'spawn-failed'
|
|
throw error
|
|
}
|
|
|
|
async function connect(deps) {
|
|
const {
|
|
ssh,
|
|
profile = '',
|
|
remoteHermesPath = '',
|
|
ownershipId,
|
|
forward,
|
|
pickLocalPort,
|
|
waitForHermes,
|
|
probeReuseProof,
|
|
adoptServedToken,
|
|
rememberLog = () => {},
|
|
readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS,
|
|
signal
|
|
} = deps
|
|
|
|
const log = msg => rememberLog(`[ssh-lifecycle] ${msg}`)
|
|
|
|
assertBootstrapNotSuperseded(signal)
|
|
const platform = await probeRemotePlatform(ssh)
|
|
log(`remote platform ${platform.os}/${platform.arch}`)
|
|
const hermesHome = await probeRemoteHermesHome(ssh)
|
|
await assertRemoteInstallUpdateClear(ssh, hermesHome)
|
|
const hermesPath = await locateHermes(ssh, remoteHermesPath)
|
|
log(`located hermes at ${hermesPath}`)
|
|
const hermesVersion = await probeHermesVersion(ssh, hermesPath)
|
|
|
|
if (hermesVersion) {
|
|
log(`remote hermes version: ${hermesVersion}`)
|
|
}
|
|
|
|
const reuseToken = deps.reuseToken || ''
|
|
const lock = await readLockfile(ssh, ownershipId)
|
|
|
|
if (isLockfileSkew(lock)) {
|
|
// #95532: the lockfile exists but was written by a different (fork) build
|
|
// or is corrupt. FAIL CLOSED: no reap, no removal, no overwrite, no spawn
|
|
// on top of foreign live state — reaping here is how live tunnels die.
|
|
const lpath = lockfilePath(ownershipId)
|
|
log(
|
|
`lockfile schema/ownership skew (${lock.reason}) at ${lpath} — failing closed: skipping reap, leaving remote state untouched`
|
|
)
|
|
|
|
const error: any = new Error(
|
|
`The remote ownership record ${lpath} does not match this Hermes Desktop build (${lock.reason}). ` +
|
|
'It was probably written by a different or modified desktop build sharing this remote, or the file is corrupt. ' +
|
|
'Refusing to reap or overwrite it — that could kill a live SSH backend owned by another build. ' +
|
|
'If nothing else uses this remote, delete that file on the remote host and reconnect.'
|
|
)
|
|
|
|
error.kind = 'remote-lockfile-skew'
|
|
throw error
|
|
}
|
|
|
|
if (lock) {
|
|
const pidAlive = await remotePidAlive(ssh, lock.pid)
|
|
|
|
const owned =
|
|
pidAlive &&
|
|
(await pidIsOurDashboard(
|
|
ssh,
|
|
lock.pid,
|
|
lock.spawnNonce,
|
|
lock.hermesPath,
|
|
lock.hermesHome,
|
|
ownershipId,
|
|
lock.profile
|
|
))
|
|
|
|
const reusable =
|
|
pidAlive &&
|
|
owned &&
|
|
lock.port > 0 &&
|
|
lock.profile === profile &&
|
|
Boolean(reuseToken) &&
|
|
lock.tokenFingerprint === fingerprintToken(reuseToken) &&
|
|
lock.hermesPath === hermesPath &&
|
|
lock.hermesHome === hermesHome
|
|
|
|
if (reusable) {
|
|
const creationTime = lock.creationTime || (await remoteProcessCreationTime(ssh, lock.pid))
|
|
|
|
if (creationTime && !lock.creationTime) {
|
|
await writeLockfile(ssh, ownershipId, { ...lock, creationTime })
|
|
lock.creationTime = creationTime
|
|
}
|
|
|
|
assertBootstrapNotSuperseded(signal)
|
|
await assertRemoteInstallUpdateClear(ssh, hermesHome)
|
|
const localPort = await openForward(deps, lock.port)
|
|
|
|
try {
|
|
const baseUrl = `http://127.0.0.1:${localPort}`
|
|
let reuseClassification
|
|
|
|
try {
|
|
reuseClassification = await probeReuseProof(baseUrl, reuseToken, lock.spawnNonce)
|
|
} catch (cause) {
|
|
const error: any = new Error('Could not verify the existing SSH backend.')
|
|
error.kind = 'transient-transport-error'
|
|
error.cause = cause
|
|
throw error
|
|
}
|
|
|
|
if (reuseClassification === 'authenticated-stale') {
|
|
assertBootstrapNotSuperseded(signal)
|
|
await cancelForwardSafe(deps, localPort, lock.port)
|
|
await assertRemoteInstallUpdateClear(ssh, hermesHome)
|
|
await cleanupStale(ssh, ownershipId, lock)
|
|
} else if (reuseClassification === 'authenticated-ok') {
|
|
const token = await adoptOwnedServedToken(
|
|
adoptServedToken,
|
|
baseUrl,
|
|
reuseToken,
|
|
ssh,
|
|
lock.pid,
|
|
'reused remote dashboard'
|
|
)
|
|
|
|
assertBootstrapNotSuperseded(signal)
|
|
log(`reusing remote dashboard pid=${lock.pid} port=${lock.port}`)
|
|
|
|
return {
|
|
baseUrl,
|
|
token,
|
|
tokenFingerprint: fingerprintToken(token),
|
|
remotePort: lock.port,
|
|
localPort,
|
|
pid: lock.pid,
|
|
reused: true,
|
|
platform,
|
|
hermesPath,
|
|
hermesVersion,
|
|
ownershipId,
|
|
spawnNonce: lock.spawnNonce,
|
|
logPath: lock.logPath,
|
|
hermesHome,
|
|
startedAt: lock.startedAt,
|
|
creationTime: lock.creationTime || ''
|
|
}
|
|
} else {
|
|
const error: any = new Error('SSH reuse proof returned an invalid classification.')
|
|
error.kind = 'transient-transport-error'
|
|
throw error
|
|
}
|
|
} catch (error) {
|
|
await cancelForwardSafe(deps, localPort, lock.port)
|
|
throw error
|
|
}
|
|
} else {
|
|
assertBootstrapNotSuperseded(signal)
|
|
await assertRemoteInstallUpdateClear(ssh, hermesHome)
|
|
await cleanupStale(ssh, ownershipId, lock, pidAlive)
|
|
}
|
|
}
|
|
|
|
assertBootstrapNotSuperseded(signal)
|
|
await assertRemoteInstallUpdateClear(ssh, hermesHome)
|
|
const spawnToken = mintToken()
|
|
|
|
const spawned = await spawnRemoteDashboard(ssh, {
|
|
hermesPath,
|
|
profile,
|
|
token: spawnToken,
|
|
ownershipId,
|
|
hermesHome,
|
|
assertInstallClear: () => assertRemoteInstallUpdateClear(ssh, hermesHome)
|
|
})
|
|
|
|
if (spawned.existing) {
|
|
if (!reuseToken) {
|
|
const error: any = new Error(
|
|
'Another SSH connection owns this remote dashboard; a session token is required to reuse it.'
|
|
)
|
|
|
|
error.kind = 'remote-ownership-contended'
|
|
throw error
|
|
}
|
|
|
|
const published = await waitForRemoteSpawnCompletion(ssh, ownershipId, readyTimeoutMs)
|
|
|
|
if (!published) {
|
|
return connect({ ...deps, reuseToken })
|
|
}
|
|
|
|
return connect({ ...deps, reuseToken })
|
|
}
|
|
|
|
const { pid, spawnNonce, logPath, tokenFilePath } = spawned
|
|
log(`spawned remote dashboard pid=${pid}`)
|
|
const creationTime = await remoteProcessCreationTime(ssh, pid)
|
|
|
|
const ownedSpawn = {
|
|
ownershipId,
|
|
spawnNonce,
|
|
pid,
|
|
port: 0,
|
|
profile,
|
|
hermesPath,
|
|
hermesHome,
|
|
logPath,
|
|
tokenFingerprint: fingerprintToken(spawnToken),
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
startedAt: new Date().toISOString(),
|
|
...(creationTime ? { creationTime } : {})
|
|
}
|
|
|
|
let localPort = 0
|
|
let remotePort = 0
|
|
|
|
try {
|
|
// Write the ownership record IMMEDIATELY (port=0): a supersede between
|
|
// spawn and readiness whose cleanup cannot reach the box must not leave a
|
|
// lockless orphan — the next connect reaps it by exact ownership via this
|
|
// record. Inside the try: if this write itself fails, the catch still
|
|
// kills the just-spawned process via the in-memory record.
|
|
await writeLockfile(ssh, ownershipId, ownedSpawn)
|
|
remotePort = await scrapeReadyPort(ssh, logPath, {
|
|
timeoutMs: readyTimeoutMs,
|
|
isAlive: () => remotePidAlive(ssh, pid),
|
|
signal
|
|
})
|
|
assertBootstrapNotSuperseded(signal)
|
|
log(`remote dashboard bound port ${remotePort}`)
|
|
|
|
localPort = await openForward(deps, remotePort)
|
|
assertBootstrapNotSuperseded(signal)
|
|
const baseUrl = `http://127.0.0.1:${localPort}`
|
|
await waitForHermes(baseUrl, spawnToken)
|
|
assertBootstrapNotSuperseded(signal)
|
|
|
|
const token = await adoptOwnedServedToken(adoptServedToken, baseUrl, spawnToken, ssh, pid, 'remote dashboard')
|
|
|
|
assertBootstrapNotSuperseded(signal)
|
|
const tokenFingerprint = fingerprintToken(token)
|
|
await writeLockfile(ssh, ownershipId, { ...ownedSpawn, port: remotePort, tokenFingerprint })
|
|
assertBootstrapNotSuperseded(signal)
|
|
|
|
return {
|
|
baseUrl,
|
|
token,
|
|
tokenFingerprint,
|
|
remotePort,
|
|
localPort,
|
|
pid,
|
|
reused: false,
|
|
platform,
|
|
hermesPath,
|
|
hermesVersion,
|
|
ownershipId,
|
|
spawnNonce,
|
|
logPath,
|
|
hermesHome,
|
|
startedAt: ownedSpawn.startedAt,
|
|
creationTime: ownedSpawn.creationTime || ''
|
|
}
|
|
} catch (error) {
|
|
if (localPort && remotePort) {
|
|
await cancelForwardSafe(deps, localPort, remotePort)
|
|
}
|
|
|
|
try {
|
|
await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`)
|
|
} catch {
|
|
void 0
|
|
}
|
|
|
|
await cleanupStale(ssh, ownershipId, ownedSpawn)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export {
|
|
adoptOwnedServedToken,
|
|
assertRemoteInstallUpdateClear,
|
|
buildSpawnCommand,
|
|
classifySshReuseProof,
|
|
cleanupStale,
|
|
connect,
|
|
connectReservationPath,
|
|
DEFAULT_READY_TIMEOUT_MS,
|
|
disconnect,
|
|
expandRemotePath,
|
|
fingerprintToken,
|
|
isForwardBindCollision,
|
|
isLockfileSkew,
|
|
listRemoteHermesProfiles,
|
|
locateHermes,
|
|
LOCKFILE_SCHEMA_VERSION,
|
|
lockfilePath,
|
|
mintToken,
|
|
openForward,
|
|
ownershipDirectory,
|
|
pidIsOurDashboard,
|
|
probeHermesVersion,
|
|
probeRemoteHermesHome,
|
|
probeRemotePlatform,
|
|
PROTOCOL_VERSION,
|
|
readLockfile,
|
|
READY_RE,
|
|
REMOTE_LOCK_DIR,
|
|
remotePidAlive,
|
|
remoteProcessCreationTime,
|
|
remoteSupportsSshOwnership,
|
|
removeLockfile,
|
|
scrapeReadyPort,
|
|
shq,
|
|
spawnLogPath,
|
|
spawnRemoteDashboard,
|
|
spawnTokenPath,
|
|
SUPPORTED_REMOTE_OS,
|
|
terminateOwnedDashboardForUpdate,
|
|
validateRemotePath,
|
|
writeLockfile
|
|
}
|