mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
97b44e5401
commit
85c8848fa9
2 changed files with 75 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue