From 1292b893eb4214dd9b1c677fd6a250a5557457b2 Mon Sep 17 00:00:00 2001 From: andrexibiza <84248988+andrexibiza@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:14:20 -0500 Subject: [PATCH] fix(desktop): treat headless-serve 404 token fetch as benign Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com> --- apps/desktop/electron/dashboard-token.test.ts | 83 +++++++++++++++++++ apps/desktop/electron/dashboard-token.ts | 24 ++++++ 2 files changed, 107 insertions(+) diff --git a/apps/desktop/electron/dashboard-token.test.ts b/apps/desktop/electron/dashboard-token.test.ts index e0a45eb36cc6..df3faf7001c5 100644 --- a/apps/desktop/electron/dashboard-token.test.ts +++ b/apps/desktop/electron/dashboard-token.test.ts @@ -15,6 +15,7 @@ import { extractInjectedDashboardToken, fetchPublicText, isForeignBackendToken, + isHeadlessServe404, resolveServedDashboardToken } from './dashboard-token' @@ -145,3 +146,85 @@ test('adoptServedDashboardToken falls back to the spawn token when the fetch fai assert.equal(logs.length, 1) assert.match(logs[0], /could not read served dashboard token \(Hermes backend\): boom/) }) + +test('isHeadlessServe404 matches only the headless-serve 404 signature', () => { + assert.equal( + isHeadlessServe404(new Error('404: {"error":"Headless backend (hermes serve): web UI disabled — use `hermes dashboard` for the browser UI."}')), + true + ) + // Wrong status with the headless body is still an error. + assert.equal(isHeadlessServe404(new Error('500: {"error":"Headless backend (hermes serve): web UI disabled"}')), false) + // Right status without the headless body is still an error. + assert.equal(isHeadlessServe404(new Error('404: Not Found')), false) + // Network-level failures are not responses. + assert.equal(isHeadlessServe404(new TypeError('fetch failed')), false) + assert.equal(isHeadlessServe404('404: Headless backend'), false) +}) + +test('adoptServedDashboardToken treats a headless-serve 404 as benign and uses the spawn token', async () => { + const logs = [] + + const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { + childAlive: () => true, + fetchText: async () => { + throw new Error('404: {"error":"Headless backend (hermes serve): web UI disabled — use `hermes dashboard` for the browser UI."}') + }, + rememberLog: line => logs.push(line) + }) + + assert.equal(token, 'spawn-token') + assert.equal(logs.length, 1) + assert.match(logs[0], /headless Hermes backend/) + assert.doesNotMatch(logs[0], /could not read served dashboard token/) +}) + +test('adoptServedDashboardToken keeps error logging for a 404 without the headless signature', async () => { + const logs = [] + + const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { + childAlive: () => true, + fetchText: async () => { + throw new Error('404: Not Found') + }, + rememberLog: line => logs.push(line) + }) + + assert.equal(token, 'spawn-token') + assert.equal(logs.length, 1) + assert.match(logs[0], /could not read served dashboard token \(Hermes backend\): 404: Not Found/) +}) + +test('adoptServedDashboardToken keeps error logging for network failures', async () => { + const logs = [] + + const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { + childAlive: () => true, + fetchText: async () => { + throw new TypeError('fetch failed') + }, + rememberLog: line => logs.push(line) + }) + + assert.equal(token, 'spawn-token') + assert.equal(logs.length, 1) + assert.match(logs[0], /could not read served dashboard token \(Hermes backend\): fetch failed/) +}) + +test('adoptServedDashboardToken does not flag a headless 404 as a foreign backend when the child is dead', async () => { + // Foreign-backend detection only applies to a SERVED token that differs + // from the spawn token; a headless 404 serves no token at all, so it must + // resolve to the spawn token even with a dead child. + const logs = [] + + const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { + childAlive: () => false, + fetchText: async () => { + throw new Error('404: {"error":"Headless backend (hermes serve): web UI disabled — use `hermes dashboard` for the browser UI."}') + }, + rememberLog: line => logs.push(line) + }) + + assert.equal(token, 'spawn-token') + assert.equal(logs.length, 1) + assert.match(logs[0], /headless Hermes backend/) +}) diff --git a/apps/desktop/electron/dashboard-token.ts b/apps/desktop/electron/dashboard-token.ts index 42f214853434..14c3856f56ad 100644 --- a/apps/desktop/electron/dashboard-token.ts +++ b/apps/desktop/electron/dashboard-token.ts @@ -80,6 +80,23 @@ function isForeignBackendToken({ servedToken, spawnToken, childAlive }) { return Boolean(servedToken) && servedToken !== spawnToken && !childAlive } +/** + * A 404 whose body is the headless-serve SPA-off message is a known-absent + * response, not an error: in headless mode (HERMES_SERVE_HEADLESS=1) the + * backend never serves the web UI, so the root-document fetch 404s BY DESIGN + * and the session token can only come from the spawn env. Matches the error + * thrown by fetchPublicText for a non-ok response: `${status}: ${body}`. + */ +function isHeadlessServe404(error) { + if (!(error instanceof Error)) { + return false + } + + const message = error.message + + return message.startsWith('404:') && message.includes('Headless backend') && message.includes('web UI disabled') +} + /** * Resolve the token the backend actually serves, adopting benign drift and * failing loudly on a foreign backend. `childAlive` is a thunk so liveness is @@ -87,6 +104,12 @@ function isForeignBackendToken({ servedToken, spawnToken, childAlive }) { */ async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) { const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => { + if (isHeadlessServe404(error)) { + options.rememberLog?.('[boot] headless Hermes backend: using spawn session token (no web UI served)') + + return spawnToken + } + options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`) return spawnToken @@ -108,5 +131,6 @@ export { extractInjectedDashboardToken, fetchPublicText, isForeignBackendToken, + isHeadlessServe404, resolveServedDashboardToken }