fix(desktop): lockfile records hermesHome + protocolVersion

The remote reuse lockfile omitted hermesHome and protocolVersion. protocolVersion
is load-bearing: it gates reuse across incompatible dashboard reuse-contracts —
without it, a future desktop could reattach to a dashboard whose token/spawn/
adoption semantics it no longer understands.

- Add PROTOCOL_VERSION (=1); reuse now requires lock.protocolVersion === current
  in addition to pid-alive + fingerprint-match + authenticated probe. A missing
  or mismatched protocolVersion fails closed -> clean respawn.
- probeRemoteHermesHome() records the remote HERMES_HOME (explicit env, else
  ~/.hermes; best-effort) in the lockfile.
- Tests: protocol-mismatch forces respawn; fresh spawn writes both fields. 26 pass.
This commit is contained in:
yoniebans 2026-06-19 16:02:39 +02:00
parent 97b44e5401
commit 85c8848fa9
2 changed files with 75 additions and 2 deletions

View file

@ -28,6 +28,11 @@
const crypto = require('node:crypto')
const LOCKFILE_SCHEMA_VERSION = 1
// Bumped when the desktop<->dashboard reuse contract changes in a way that
// makes an old running dashboard unsafe to reattach to (token handling, the
// readiness/spawn args, the served-token reconciliation). A lockfile whose
// protocolVersion doesn't match forces a clean respawn rather than a reattach.
const PROTOCOL_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'
@ -134,6 +139,18 @@ async function probeRemotePlatform(ssh) {
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 (a probe failure falls back to '~/.hermes').
async function probeRemoteHermesHome(ssh) {
try {
const out = (await ssh.exec('echo "${HERMES_HOME:-$HOME/.hermes}"')).trim().split('\n').pop()
return out || '~/.hermes'
} catch {
return '~/.hermes'
}
}
// ---------------------------------------------------------------------------
// Lockfile (lives on the REMOTE, read/written via ssh.exec)
// ---------------------------------------------------------------------------
@ -363,7 +380,11 @@ async function connect(deps) {
if (lock && lock.pid && lock.port) {
const pidAlive = await remotePidAlive(ssh, lock.pid)
const fpMatch = Boolean(reuseToken) && lock.tokenFingerprint === fingerprintToken(reuseToken)
if (pidAlive && fpMatch) {
// A lockfile written by an incompatible protocol (older/newer reuse
// contract) is not safe to reattach to — treat it like a stale lock and
// respawn. Absent protocolVersion (pre-versioning) also fails closed.
const protoMatch = lock.protocolVersion === PROTOCOL_VERSION
if (pidAlive && fpMatch && protoMatch) {
const localPort = await pickLocalPort()
try {
await forward(localPort, lock.port)
@ -400,7 +421,7 @@ async function connect(deps) {
await cancelForwardSafe(deps, localPort, lock.port)
}
} else {
log(`lockfile present but not reusable (pidAlive=${pidAlive}, fpMatch=${fpMatch})`)
log(`lockfile present but not reusable (pidAlive=${pidAlive}, fpMatch=${fpMatch}, protoMatch=${protoMatch})`)
}
// Any failed condition → cleanup (kill only if provably ours) and respawn.
await cleanupStale(ssh, clientId, lock.pid)
@ -431,12 +452,15 @@ async function connect(deps) {
})
const tokenFingerprint = fingerprintToken(token)
const hermesHome = await probeRemoteHermesHome(ssh)
await writeLockfile(ssh, clientId, {
pid,
port: remotePort,
profile,
hermesPath,
hermesHome,
tokenFingerprint,
protocolVersion: PROTOCOL_VERSION,
startedAt: new Date().toISOString()
})
@ -446,6 +470,7 @@ async function connect(deps) {
module.exports = {
DEFAULT_READY_TIMEOUT_MS,
LOCKFILE_SCHEMA_VERSION,
PROTOCOL_VERSION,
READY_RE,
REMOTE_LOCK_DIR,
REMOTE_LOG,
@ -460,6 +485,7 @@ module.exports = {
mintToken,
pidIsOurDashboard,
probeRemotePlatform,
probeRemoteHermesHome,
readLockfile,
remotePidAlive,
removeLockfile,

View file

@ -14,6 +14,7 @@ const assert = require('node:assert/strict')
const {
LOCKFILE_SCHEMA_VERSION,
PROTOCOL_VERSION,
buildSpawnCommand,
cleanupStale,
clientLockId,
@ -261,6 +262,7 @@ test('connect() reuses a healthy dashboard when fingerprint + probe pass', async
const reuseToken = 'stored-token'
const lock = {
schemaVersion: LOCKFILE_SCHEMA_VERSION,
protocolVersion: PROTOCOL_VERSION,
pid: 333,
port: 40000,
tokenFingerprint: fingerprintToken(reuseToken)
@ -281,6 +283,50 @@ test('connect() reuses a healthy dashboard when fingerprint + probe pass', async
assert.ok(!ssh.calls.some(c => /setsid/.test(c)), 'reuse path must not spawn a new dashboard')
})
test('connect() respawns when the lockfile protocolVersion is incompatible', async () => {
const reuseToken = 'stored-token'
// alive pid, matching fingerprint, but a protocolVersion we no longer accept
const lock = {
schemaVersion: LOCKFILE_SCHEMA_VERSION,
protocolVersion: PROTOCOL_VERSION + 99,
pid: 333,
port: 40000,
tokenFingerprint: fingerprintToken(reuseToken)
}
const ssh = fakeSsh([
[/uname/, 'Linux\nx86_64'],
[/\[ -x/, 'OK'],
[/cat .*lock\.json/, JSON.stringify(lock)],
[/kill -0 333/, 'ALIVE'],
[/cmdline|ps -o/, ''], // not provably ours → not killed, lockfile dropped
[/setsid/, '901\n'],
[/kill -0 901/, 'ALIVE'],
[/awk/, 'HERMES_DASHBOARD_READY port=44100\n']
])
const result = await connect(connectDeps(ssh, { reuseToken, adoptServedToken: async () => 'fresh' }))
assert.equal(result.reused, false, 'incompatible protocol must force a fresh spawn, not a reattach')
assert.equal(result.pid, 901)
})
test('connect() fresh spawn writes hermesHome + protocolVersion into the lockfile', async () => {
const writes = []
const ssh = fakeSsh([
[/uname/, 'Linux\nx86_64'],
[/\[ -x/, 'OK'],
[/cat .*lock\.json/, ''], // no lockfile
[/HERMES_HOME/, '/home/jonny/.hermes\n'], // probeRemoteHermesHome
[/printf '%s\\\\n'/, ''],
[/setsid/, '700\n'],
[/kill -0 700/, 'ALIVE'],
[/awk/, 'HERMES_DASHBOARD_READY port=45500\n'],
[/printf '%s' '/, c => { writes.push(c); return '' }] // writeLockfile printf
])
await connect(connectDeps(ssh, { adoptServedToken: async () => 'fresh' }))
const lockWrite = writes.find(c => c.includes('schemaVersion')) || ''
assert.match(lockWrite, new RegExp(`"protocolVersion":${PROTOCOL_VERSION}`))
assert.match(lockWrite, /"hermesHome":"\/home\/jonny\/\.hermes"/)
})
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([
@ -303,6 +349,7 @@ test('connect() respawns when the dashboard is wedged (alive pid, probe fails)',
const reuseToken = 'stored'
const lock = {
schemaVersion: LOCKFILE_SCHEMA_VERSION,
protocolVersion: PROTOCOL_VERSION,
pid: 333,
port: 40000,
tokenFingerprint: fingerprintToken(reuseToken)