diff --git a/apps/desktop/electron/dashboard-token.cjs b/apps/desktop/electron/dashboard-token.cjs index 12f1dd400d3..ed55f9dd52b 100644 --- a/apps/desktop/electron/dashboard-token.cjs +++ b/apps/desktop/electron/dashboard-token.cjs @@ -78,10 +78,32 @@ async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) return servedToken || fallbackToken } +/** + * Decide whether a served-token mismatch means we are talking to a backend we + * did NOT spawn. + * + * The desktop pins HERMES_DASHBOARD_SESSION_TOKEN on every backend it spawns, + * and the dashboard honors that env at import — so a LIVE child of ours always + * serves our token. The only way the served token differs while our child is + * dead is that the readiness probe (public /api/status) answered from a + * different process: an orphaned dashboard or port squatter that won the bind + * race while our child exited. Adopting that process's token would silently + * authenticate the renderer against a foreign backend (possibly the wrong + * profile), so callers must fail loudly instead. + * + * A mismatch with a live child is the benign case the served-token fallback + * exists for: our own child served a regenerated token because the env pin + * did not survive the spawn (e.g. shell-wrapped CLI shims). + */ +function isForeignBackendToken({ servedToken, spawnToken, childAlive }) { + return Boolean(servedToken) && servedToken !== spawnToken && !childAlive +} + module.exports = { DEFAULT_TOKEN_FETCH_TIMEOUT_MS, dashboardIndexUrl, extractInjectedDashboardToken, fetchPublicText, + isForeignBackendToken, resolveServedDashboardToken } diff --git a/apps/desktop/electron/dashboard-token.test.cjs b/apps/desktop/electron/dashboard-token.test.cjs index 2a7e05c4fed..54e69f3997b 100644 --- a/apps/desktop/electron/dashboard-token.test.cjs +++ b/apps/desktop/electron/dashboard-token.test.cjs @@ -12,6 +12,7 @@ const { dashboardIndexUrl, extractInjectedDashboardToken, fetchPublicText, + isForeignBackendToken, resolveServedDashboardToken } = require('./dashboard-token.cjs') @@ -87,3 +88,23 @@ test('resolveServedDashboardToken propagates fetch errors so callers can fall ba test('fetchPublicText rejects unsupported protocols', async () => { await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/) }) + +test('isForeignBackendToken flags a mismatched token from a dead child', () => { + assert.equal(isForeignBackendToken({ servedToken: 'other', spawnToken: 'mine', childAlive: false }), true) +}) + +test('isForeignBackendToken trusts a mismatched token while our child is alive', () => { + // Live child + different token = our own backend regenerated the token + // because the env pin did not survive the spawn. Adopting it is correct. + assert.equal(isForeignBackendToken({ servedToken: 'other', spawnToken: 'mine', childAlive: true }), false) +}) + +test('isForeignBackendToken trusts a matching token regardless of liveness', () => { + assert.equal(isForeignBackendToken({ servedToken: 'mine', spawnToken: 'mine', childAlive: false }), false) + assert.equal(isForeignBackendToken({ servedToken: 'mine', spawnToken: 'mine', childAlive: true }), false) +}) + +test('isForeignBackendToken ignores an absent served token', () => { + assert.equal(isForeignBackendToken({ servedToken: null, spawnToken: 'mine', childAlive: false }), false) + assert.equal(isForeignBackendToken({ servedToken: '', spawnToken: 'mine', childAlive: false }), false) +}) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 2c6aa425cf2..4ab82a10e41 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -29,7 +29,7 @@ const { runBootstrap } = require('./bootstrap-runner.cjs') const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs') const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') -const { resolveServedDashboardToken } = require('./dashboard-token.cjs') +const { isForeignBackendToken, resolveServedDashboardToken } = require('./dashboard-token.cjs') const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') const { readDirForIpc } = require('./fs-read-dir.cjs') @@ -4599,6 +4599,21 @@ async function spawnPoolBackend(profile, entry) { rememberLog(`[boot] could not read served dashboard token for profile "${profile}": ${error.message}`) return token }) + if ( + isForeignBackendToken({ + servedToken: authToken, + spawnToken: token, + childAlive: child.exitCode === null && !child.killed + }) + ) { + // Our child is dead and the port answers with someone else's token: + // /api/status readiness was a false positive from a process we did not + // spawn. Fail loudly rather than authenticate against a foreign backend. + backendPool.delete(profile) + throw new Error( + `Hermes backend for profile "${profile}" exited and port ${port} is served by a different process; refusing its session token.` + ) + } entry.token = authToken return { @@ -4831,6 +4846,20 @@ async function startHermes() { rememberLog(`[boot] could not read served dashboard token: ${error.message}`) return token }) + if ( + isForeignBackendToken({ + servedToken: authToken, + spawnToken: token, + childAlive: hermesProcess.exitCode === null && !hermesProcess.killed + }) + ) { + // Our child is dead and the port answers with someone else's token: + // /api/status readiness was a false positive from a process we did not + // spawn. Fail loudly rather than authenticate against a foreign backend. + throw new Error( + `Hermes backend exited and port ${port} is served by a different process; refusing its session token.` + ) + } updateBootProgress({ phase: 'backend.ready', message: 'Hermes backend is ready. Finalizing desktop startup',