fix(desktop): harden remote lifecycle — skip-build spawn + adoption liveness

- Remote dashboard spawn now passes --skip-build so a headless SSH bootstrap
  never triggers an npm web-UI build; if no built dist exists the backend fails
  loudly (scraped from the readiness log) instead of hanging on a build.
- Served-token adoption no longer asserts childAlive: () => true. Fresh spawn
  confirms the spawned remote pid is still alive (remotePidAlive) at adoption
  time; the reuse path reuses the pid-alive gate it already computed. This
  restores the foreign-backend guard: a served token from a DIFFERENT backend
  that grabbed the same forwarded port after the dashboard exited is rejected.
- Tests: --skip-build asserted in buildSpawnCommand.

(Note: the awaited before-quit SSH teardown landed in cfc0082b2 with the scoping
fixes — preventDefault + bounded await + one-shot guard so local forwards do not
linger after quit.)
This commit is contained in:
yoniebans 2026-06-19 16:49:31 +02:00
parent cc96ac617a
commit bb898b80f9
2 changed files with 14 additions and 4 deletions

View file

@ -252,10 +252,12 @@ function buildSpawnCommand(hermesPath, profile, token) {
const logPath = `"$(eval echo ${shq(REMOTE_LOG)})"`
// --isolated => dedicated loopback dashboard, NOT routed into the host's
// unified machine dashboard. --port 0 => server picks a free port and prints
// HERMES_DASHBOARD_READY port=<n>.
// HERMES_DASHBOARD_READY port=<n>. --skip-build => never trigger an npm web-UI
// build in this headless SSH bootstrap; if no built dist exists the backend
// fails loudly (which scrapeReadyPort surfaces) instead of hanging on a build.
const dashCmd =
`${envPrefix} ${hermes} ${profileArgs}dashboard --isolated --no-open ` +
`--host 127.0.0.1 --port 0`
`--host 127.0.0.1 --port 0 --skip-build`
return (
`mkdir -p "$(dirname ${logPath})" && ` +
`setsid sh -c ${shq(`${dashCmd} </dev/null >> ${logPath} 2>&1 & echo $!`)}`
@ -395,7 +397,9 @@ async function connect(deps) {
// the lockfile was written is picked up; the remote pid is alive so
// a served-token mismatch is benign (our backend regenerated it).
const token = await adoptServedToken(baseUrl, reuseToken, {
childAlive: () => true,
// pidAlive was checked above as the reuse gate; reuse it for the
// foreign-backend guard rather than asserting () => true.
childAlive: () => pidAlive,
label: 'reused remote dashboard'
})
log(`reusing remote dashboard pid=${lock.pid} port=${lock.port}`)
@ -446,8 +450,13 @@ async function connect(deps) {
// Served-token adoption against the TUNNELED baseUrl — the served token is
// what /api/ws will accept; the minted token is only the spawn credential.
// Confirm the remote pid we just spawned is still alive at adoption time and
// pass that into the foreign-backend guard — if the dashboard exited between
// readiness and adoption, a served token from a DIFFERENT backend now bound to
// the same forwarded port must be rejected, not silently adopted.
const spawnedAlive = await remotePidAlive(ssh, pid)
const token = await adoptServedToken(baseUrl, spawnToken, {
childAlive: () => true, // liveness is the remote pid; the tunnel is the client side
childAlive: () => spawnedAlive,
label: 'remote dashboard'
})
const tokenFingerprint = fingerprintToken(token)

View file

@ -171,6 +171,7 @@ test('buildSpawnCommand uses --isolated --port 0 --no-open and a detached setsid
assert.match(cmd, /--isolated/)
assert.match(cmd, /--no-open/)
assert.match(cmd, /--host 127\.0\.0\.1 --port 0/)
assert.match(cmd, /--skip-build/)
assert.match(cmd, /--profile/)
assert.match(cmd, /work/)
assert.match(cmd, /setsid/)