diff --git a/apps/desktop/electron/remote-lifecycle.cjs b/apps/desktop/electron/remote-lifecycle.cjs index d1a5255f4e9..b0a2b5213aa 100644 --- a/apps/desktop/electron/remote-lifecycle.cjs +++ b/apps/desktop/electron/remote-lifecycle.cjs @@ -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, diff --git a/apps/desktop/electron/remote-lifecycle.test.cjs b/apps/desktop/electron/remote-lifecycle.test.cjs index 2a7f41728b1..dbdc669bcc3 100644 --- a/apps/desktop/electron/remote-lifecycle.test.cjs +++ b/apps/desktop/electron/remote-lifecycle.test.cjs @@ -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)