From 6cf12eef4e665cb584c3ef1826215cf299b19977 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 16 Jun 2026 13:52:58 +1000 Subject: [PATCH] feat(desktop): drop legacy session token for the local spawned backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop's local backend binds to loopback, where the gateway now enforces no identity token (REST via Phase 2, WS via Phase 4 — the peer-IP + Host/Origin guard is the boundary). So the desktop's local token machinery is dead weight and is removed: - stop generating HERMES_DASHBOARD_SESSION_TOKEN + passing it to the two local-spawn child envs - fetchJson omits X-Hermes-Session-Token when the token is falsy - the local connection uses token:null + a credential-free WS URL (new buildGatewayWsUrlNoAuth helper, electron-free + unit-tested) - delete dashboard-token.cjs (+ its test): it existed solely to reconcile the served __HERMES_SESSION_TOKEN__ drift for the local backend, which the server now ignores on loopback The REMOTE auth modes are untouched: 'token' (user-saved token for a remote loopback/--insecure gateway, still sent as X-Hermes-Session-Token + ?token=) and 'oauth' (cookie + ?ticket=) both work exactly as before. Co-authored-by: Hermes subagent Note: windows-child-process.test.cjs has one pre-existing failure on origin/main (a stale source-scan needle 'execFileSync(pyExe'); unrelated to this change and left as-is. --- apps/desktop/electron/connection-config.cjs | 19 +++ .../electron/connection-config.test.cjs | 19 +++ apps/desktop/electron/dashboard-token.cjs | 99 ------------ .../desktop/electron/dashboard-token.test.cjs | 142 ------------------ apps/desktop/electron/main.cjs | 44 +++--- apps/desktop/package.json | 2 +- 6 files changed, 59 insertions(+), 266 deletions(-) delete mode 100644 apps/desktop/electron/dashboard-token.cjs delete mode 100644 apps/desktop/electron/dashboard-token.test.cjs diff --git a/apps/desktop/electron/connection-config.cjs b/apps/desktop/electron/connection-config.cjs index f9eaaa65e9e..1c102213396 100644 --- a/apps/desktop/electron/connection-config.cjs +++ b/apps/desktop/electron/connection-config.cjs @@ -78,6 +78,24 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) { return `${wsScheme}://${parsed.host}${prefix}/api/ws?ticket=${encodeURIComponent(ticket)}` } +/** + * Build a credential-free WS URL for the LOCAL spawned backend. The desktop's + * own dashboard binds to loopback (127.0.0.1), where the gateway gates the WS + * upgrade purely on the peer-IP + Host/Origin guard and IGNORES any token. So + * the local renderer connects to a bare `ws(s)://host/prefix/api/ws` with no + * `?token=` — there is no credential to send. + * + * This is distinct from buildGatewayWsUrl (the REMOTE `token` auth mode, which + * still appends `?token=` for a user-saved remote-gateway token). + */ +function buildGatewayWsUrlNoAuth(baseUrl) { + const parsed = new URL(baseUrl) + const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws' + const prefix = parsed.pathname.replace(/\/+$/, '') + + return `${wsScheme}://${parsed.host}${prefix}/api/ws` +} + /** * Build the WS URL the renderer would connect with, so the connection test can * exercise the same transport the app actually uses. @@ -274,6 +292,7 @@ module.exports = { RT_COOKIE_VARIANTS, authModeFromStatus, buildGatewayWsUrl, + buildGatewayWsUrlNoAuth, buildGatewayWsUrlWithTicket, connectionScopeKey, cookiesHaveSession, diff --git a/apps/desktop/electron/connection-config.test.cjs b/apps/desktop/electron/connection-config.test.cjs index 1c7330e78d0..d8d324c1e6d 100644 --- a/apps/desktop/electron/connection-config.test.cjs +++ b/apps/desktop/electron/connection-config.test.cjs @@ -18,6 +18,7 @@ const { RT_COOKIE_VARIANTS, authModeFromStatus, buildGatewayWsUrl, + buildGatewayWsUrlNoAuth, buildGatewayWsUrlWithTicket, connectionScopeKey, cookiesHaveSession, @@ -201,6 +202,24 @@ test('buildGatewayWsUrl url-encodes the token', () => { assert.equal(buildGatewayWsUrl('https://host', 'a/b c+d'), 'wss://host/api/ws?token=a%2Fb%20c%2Bd') }) +// --- buildGatewayWsUrlNoAuth (local loopback, credential-free) --- + +test('buildGatewayWsUrlNoAuth builds a bare ws URL with no token param', () => { + assert.equal(buildGatewayWsUrlNoAuth('http://127.0.0.1:9119'), 'ws://127.0.0.1:9119/api/ws') +}) + +test('buildGatewayWsUrlNoAuth uses wss for https', () => { + assert.equal(buildGatewayWsUrlNoAuth('https://gw.example.com'), 'wss://gw.example.com/api/ws') +}) + +test('buildGatewayWsUrlNoAuth honors a path prefix and never adds a credential', () => { + const url = buildGatewayWsUrlNoAuth('http://127.0.0.1:9119/hermes/') + assert.equal(url, 'ws://127.0.0.1:9119/hermes/api/ws') + assert.ok(!url.includes('token=')) + assert.ok(!url.includes('ticket=')) + assert.ok(!url.includes('?')) +}) + // --- buildGatewayWsUrlWithTicket (oauth) --- test('buildGatewayWsUrlWithTicket uses ?ticket= not ?token=', () => { diff --git a/apps/desktop/electron/dashboard-token.cjs b/apps/desktop/electron/dashboard-token.cjs deleted file mode 100644 index 1a9ca50ad9c..00000000000 --- a/apps/desktop/electron/dashboard-token.cjs +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Helpers for local dashboard session-token discovery. - * - * The desktop main process can pass HERMES_DASHBOARD_SESSION_TOKEN when it - * spawns the local dashboard, but the dashboard is the source of truth for the - * token it actually serves to the renderer. If those drift, HTTP readiness - * probes still pass while /api/ws rejects the renderer's token. - */ - -const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000 - -async function fetchPublicText(url, options = {}) { - const { protocol } = new URL(url) - if (protocol !== 'http:' && protocol !== 'https:') { - throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`) - } - - const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS - const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => { - if (error.name === 'TimeoutError') { - throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`) - } - throw error - }) - const text = await res.text() - - if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`) - - return text -} - -function extractInjectedDashboardToken(html) { - const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || '')) - if (!match) return null - try { - return JSON.parse(match[1]) - } catch { - return null - } -} - -function dashboardIndexUrl(baseUrl) { - return `${String(baseUrl || '').replace(/\/+$/, '')}/` -} - -async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) { - const fetchText = options.fetchText || fetchPublicText - const html = await fetchText(dashboardIndexUrl(baseUrl), { - timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS - }) - const servedToken = extractInjectedDashboardToken(html) - - if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') { - options.rememberLog('[boot] dashboard served a different session token; using served token for WebSocket auth') - } - - return servedToken || fallbackToken -} - -/** - * A served token that differs from our spawn token while our child is DEAD - * came from a process we did not spawn (orphan/port squatter that satisfied - * the public /api/status readiness probe). With a live child the mismatch is - * benign: our own backend regenerated the token because the env pin did not - * survive the spawn. - */ -function isForeignBackendToken({ servedToken, spawnToken, childAlive }) { - return Boolean(servedToken) && servedToken !== spawnToken && !childAlive -} - -/** - * Resolve the token the backend actually serves, adopting benign drift and - * failing loudly on a foreign backend. `childAlive` is a thunk so liveness is - * sampled after the fetch, not before. - */ -async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) { - const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => { - options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`) - return spawnToken - }) - - if (isForeignBackendToken({ servedToken, spawnToken, childAlive: childAlive() })) { - throw new Error( - `${label} exited and ${dashboardIndexUrl(baseUrl)} is served by a process we did not spawn; refusing its session token.` - ) - } - - return servedToken -} - -module.exports = { - DEFAULT_TOKEN_FETCH_TIMEOUT_MS, - adoptServedDashboardToken, - dashboardIndexUrl, - extractInjectedDashboardToken, - fetchPublicText, - isForeignBackendToken, - resolveServedDashboardToken -} diff --git a/apps/desktop/electron/dashboard-token.test.cjs b/apps/desktop/electron/dashboard-token.test.cjs deleted file mode 100644 index d598ffc2bc1..00000000000 --- a/apps/desktop/electron/dashboard-token.test.cjs +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Tests for electron/dashboard-token.cjs. - * - * Run with: node --test electron/dashboard-token.test.cjs - * (Wired into npm test:desktop:platforms in package.json.) - */ - -const test = require('node:test') -const assert = require('node:assert/strict') - -const { - adoptServedDashboardToken, - dashboardIndexUrl, - extractInjectedDashboardToken, - fetchPublicText, - isForeignBackendToken, - resolveServedDashboardToken -} = require('./dashboard-token.cjs') - -test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => { - const html = '' - assert.equal(extractInjectedDashboardToken(html), 'served-token') -}) - -test('extractInjectedDashboardToken handles escaped token strings', () => { - const html = '' - assert.equal(extractInjectedDashboardToken(html), 'served\\token"quoted') -}) - -test('extractInjectedDashboardToken returns null for missing or malformed values', () => { - assert.equal(extractInjectedDashboardToken(''), null) - assert.equal(extractInjectedDashboardToken(''), null) -}) - -test('dashboardIndexUrl preserves dashboard path prefixes', () => { - assert.equal(dashboardIndexUrl('http://127.0.0.1:9120'), 'http://127.0.0.1:9120/') - assert.equal(dashboardIndexUrl('https://host.example/hermes/'), 'https://host.example/hermes/') -}) - -test('resolveServedDashboardToken uses the served token and logs when it differs', async () => { - const logs = [] - const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { - fetchText: async url => { - assert.equal(url, 'http://127.0.0.1:9120/') - return '' - }, - rememberLog: line => logs.push(line) - }) - - assert.equal(token, 'served-token') - assert.equal(logs.length, 1) - assert.match(logs[0], /served a different session token/) -}) - -test('resolveServedDashboardToken falls back when the served HTML has no token', async () => { - const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { - fetchText: async () => '', - rememberLog: () => { - throw new Error('should not log when no served token is present') - } - }) - - assert.equal(token, 'spawn-token') -}) - -test('resolveServedDashboardToken does not log when served token matches fallback', async () => { - const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'same-token', { - fetchText: async () => '', - rememberLog: () => { - throw new Error('should not log when token already matches') - } - }) - - assert.equal(token, 'same-token') -}) - -test('resolveServedDashboardToken propagates fetch errors so callers can fall back explicitly', async () => { - await assert.rejects( - () => - resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { - fetchText: async () => { - throw new Error('boom') - } - }), - /boom/ - ) -}) - -test('fetchPublicText rejects unsupported protocols', async () => { - await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/) -}) - -test('isForeignBackendToken only flags a mismatched token from a dead child', () => { - const cases = [ - [{ servedToken: 'other', spawnToken: 'mine', childAlive: false }, true], - // Live child + drift = our backend regenerated the token (env pin lost). - [{ servedToken: 'other', spawnToken: 'mine', childAlive: true }, false], - [{ servedToken: 'mine', spawnToken: 'mine', childAlive: false }, false], - [{ servedToken: 'mine', spawnToken: 'mine', childAlive: true }, false], - [{ servedToken: null, spawnToken: 'mine', childAlive: false }, false], - [{ servedToken: '', spawnToken: 'mine', childAlive: false }, false] - ] - for (const [input, expected] of cases) { - assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input)) - } -}) - -test('adoptServedDashboardToken adopts drift from a live child', async () => { - const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { - childAlive: () => true, - fetchText: async () => '' - }) - - assert.equal(token, 'served-token') -}) - -test('adoptServedDashboardToken refuses a foreign token when our child is dead', async () => { - await assert.rejects( - () => - adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { - childAlive: () => false, - fetchText: async () => '', - label: 'Hermes backend for profile "work"' - }), - /profile "work".*process we did not spawn/ - ) -}) - -test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => { - const logs = [] - const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { - childAlive: () => true, - fetchText: async () => { - throw new Error('boom') - }, - 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\): boom/) -}) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 19096c61357..fa8c82c9241 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -34,7 +34,6 @@ const { } = require('./session-windows.cjs') const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') -const { adoptServedDashboardToken } = require('./dashboard-token.cjs') const { waitForDashboardPort } = require('./backend-ready.cjs') const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') @@ -57,6 +56,7 @@ const { isPackagedInstallPath: isPackagedInstallPathUnderRoots } = require('./wo const { authModeFromStatus, buildGatewayWsUrl, + buildGatewayWsUrlNoAuth, buildGatewayWsUrlWithTicket, connectionScopeKey, cookiesHaveSession, @@ -2638,7 +2638,10 @@ function fetchJson(url, token, options = {}) { method: options.method || 'GET', headers: { 'Content-Type': 'application/json', - 'X-Hermes-Session-Token': token, + // The LOCAL loopback backend needs no credential — the server ignores + // any identity token there — so a null/empty token omits the header + // entirely. The REMOTE 'token' auth mode still sends its token. + ...(token ? { 'X-Hermes-Session-Token': token } : {}), ...(body ? { 'Content-Length': String(body.length) } : {}) } }, @@ -3268,6 +3271,9 @@ function closePreviewWatchers() { } } +// Poll /api/status until the backend answers. `token` is optional: the LOCAL +// loopback backend sends no credential (the server ignores it there), so it's +// omitted; the REMOTE 'token' auth mode still passes its user-saved token. async function waitForHermes(baseUrl, token) { const deadline = Date.now() + 45_000 let lastError = null @@ -4696,7 +4702,6 @@ async function spawnPoolBackend(profile, entry) { } } - const token = crypto.randomBytes(32).toString('base64url') // --profile wins over the inherited HERMES_HOME env (see _apply_profile_override // step 3 in hermes_cli/main.py), so the child re-homes to this profile. // --port 0: the OS assigns an ephemeral port; the child announces it on stdout. @@ -4720,7 +4725,6 @@ async function spawnPoolBackend(profile, entry) { // the child process. Inherited TERMINAL_CWD (or a stale config bridge) // can still point at the install dir even when spawn cwd is home. TERMINAL_CWD: hermesCwd, - HERMES_DASHBOARD_SESSION_TOKEN: token, // Marks this dashboard backend as desktop-spawned so it runs the cron // scheduler tick loop (the gateway isn't running under the app). HERMES_DESKTOP: '1', @@ -4731,7 +4735,6 @@ async function spawnPoolBackend(profile, entry) { }) ) entry.process = child - entry.token = token child.stdout.on('data', rememberLog) child.stderr.on('data', rememberLog) @@ -4761,23 +4764,20 @@ async function spawnPoolBackend(profile, entry) { entry.port = port const baseUrl = `http://127.0.0.1:${port}` - await Promise.race([waitForHermes(baseUrl, token), startFailed]) + await Promise.race([waitForHermes(baseUrl), startFailed]) ready = true - const authToken = await adoptServedDashboardToken(baseUrl, token, { - childAlive: () => child.exitCode === null && !child.killed, - label: `Hermes backend for profile "${profile}"`, - rememberLog - }) - entry.token = authToken return { baseUrl, mode: 'local', source: 'local', + // The local backend binds to loopback, where the gateway ignores any + // identity token (peer-IP + Host/Origin guard is the boundary). No + // credential is sent: REST omits X-Hermes-Session-Token, WS omits ?token=. authMode: 'token', - token: authToken, + token: null, profile, - wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`, + wsUrl: buildGatewayWsUrlNoAuth(baseUrl), logs: hermesLog.slice(-80), ...getWindowState() } @@ -4899,7 +4899,6 @@ async function startHermes() { } } - const token = crypto.randomBytes(32).toString('base64url') // --port 0: the OS assigns an ephemeral port; the child announces it on stdout. const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0'] // Pin the desktop's chosen profile via the global --profile flag. This is @@ -4937,7 +4936,6 @@ async function startHermes() { HERMES_HOME, ...backend.env, TERMINAL_CWD: hermesCwd, - HERMES_DASHBOARD_SESSION_TOKEN: token, // Marks this dashboard backend as desktop-spawned so it runs the cron // scheduler tick loop (the gateway isn't running under the app). HERMES_DESKTOP: '1', @@ -5001,13 +4999,8 @@ async function startHermes() { const baseUrl = `http://127.0.0.1:${port}` await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90) - await Promise.race([waitForHermes(baseUrl, token), backendStartFailed]) + await Promise.race([waitForHermes(baseUrl), backendStartFailed]) backendReady = true - const authToken = await adoptServedDashboardToken(baseUrl, token, { - // The exit/error handlers null hermesProcess when the child dies. - childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed, - rememberLog - }) updateBootProgress({ phase: 'backend.ready', message: 'Hermes backend is ready. Finalizing desktop startup', @@ -5020,9 +5013,12 @@ async function startHermes() { baseUrl, mode: 'local', source: 'local', + // The local backend binds to loopback, where the gateway ignores any + // identity token (peer-IP + Host/Origin guard is the boundary). No + // credential is sent: REST omits X-Hermes-Session-Token, WS omits ?token=. authMode: 'token', - token: authToken, - wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`, + token: null, + wsUrl: buildGatewayWsUrlNoAuth(baseUrl), logs: hermesLog.slice(-80), ...getWindowState() } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 08080188a53..03cc179f429 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -37,7 +37,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix",