From bb898b80f9a8f17557737d32a2f106629c32258b Mon Sep 17 00:00:00 2001 From: yoniebans Date: Fri, 19 Jun 2026 16:49:31 +0200 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20harden=20remote=20lifecycle=20?= =?UTF-8?q?=E2=80=94=20skip-build=20spawn=20+=20adoption=20liveness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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.) --- apps/desktop/electron/remote-lifecycle.cjs | 17 +++++++++++++---- apps/desktop/electron/remote-lifecycle.test.cjs | 1 + 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/desktop/electron/remote-lifecycle.cjs b/apps/desktop/electron/remote-lifecycle.cjs index b0a2b5213aa..507a7eb4375 100644 --- a/apps/desktop/electron/remote-lifecycle.cjs +++ b/apps/desktop/electron/remote-lifecycle.cjs @@ -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=. + // HERMES_DASHBOARD_READY port=. --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} > ${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) diff --git a/apps/desktop/electron/remote-lifecycle.test.cjs b/apps/desktop/electron/remote-lifecycle.test.cjs index dbdc669bcc3..274b70691d3 100644 --- a/apps/desktop/electron/remote-lifecycle.test.cjs +++ b/apps/desktop/electron/remote-lifecycle.test.cjs @@ -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/)