From e96fe06e4968ebaf1c2df0a2a9c9fc9fc730b6cf Mon Sep 17 00:00:00 2001 From: Jeff Date: Wed, 10 Jun 2026 14:36:00 -0400 Subject: [PATCH] fix(desktop): use served dashboard token for websocket auth (cherry picked from commit f8209f91d3f5d876ff9c2c4843da01256e7cbb39) (cherry picked from commit 72290f0809ad5dec91a657cd4f4bcd4b999a692d) --- apps/desktop/electron/dashboard-token.cjs | 87 ++++++++++++++++++ .../desktop/electron/dashboard-token.test.cjs | 89 +++++++++++++++++++ apps/desktop/electron/main.cjs | 18 +++- .../electron/windows-child-process.test.cjs | 2 +- apps/desktop/package.json | 2 +- 5 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/electron/dashboard-token.cjs create mode 100644 apps/desktop/electron/dashboard-token.test.cjs diff --git a/apps/desktop/electron/dashboard-token.cjs b/apps/desktop/electron/dashboard-token.cjs new file mode 100644 index 00000000000..12f1dd400d3 --- /dev/null +++ b/apps/desktop/electron/dashboard-token.cjs @@ -0,0 +1,87 @@ +/** + * 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 http = require('node:http') +const https = require('node:https') + +const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000 + +function fetchPublicText(url, options = {}) { + return new Promise((resolve, reject) => { + let parsed + try { + parsed = new URL(url) + } catch (error) { + reject(new Error(`Invalid URL: ${error.message}`)) + return + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) + return + } + + const client = parsed.protocol === 'https:' ? https : http + const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS + const req = client.request(parsed, { method: options.method || 'GET' }, res => { + const chunks = [] + res.on('data', chunk => chunks.push(chunk)) + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8') + if ((res.statusCode || 500) >= 400) { + reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`)) + return + } + resolve(text) + }) + }) + + req.on('error', reject) + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) + }) + req.end() + }) +} + +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 +} + +module.exports = { + DEFAULT_TOKEN_FETCH_TIMEOUT_MS, + dashboardIndexUrl, + extractInjectedDashboardToken, + fetchPublicText, + resolveServedDashboardToken +} diff --git a/apps/desktop/electron/dashboard-token.test.cjs b/apps/desktop/electron/dashboard-token.test.cjs new file mode 100644 index 00000000000..2a7e05c4fed --- /dev/null +++ b/apps/desktop/electron/dashboard-token.test.cjs @@ -0,0 +1,89 @@ +/** + * 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 { + dashboardIndexUrl, + extractInjectedDashboardToken, + fetchPublicText, + 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/) +}) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index bfa5e178d2f..2c6aa425cf2 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -29,6 +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 { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') const { readDirForIpc } = require('./fs-read-dir.cjs') @@ -4594,15 +4595,20 @@ async function spawnPoolBackend(profile, entry) { const baseUrl = `http://127.0.0.1:${port}` await Promise.race([waitForHermes(baseUrl, token), startFailed]) ready = true + const authToken = await resolveServedDashboardToken(baseUrl, token, { rememberLog }).catch(error => { + rememberLog(`[boot] could not read served dashboard token for profile "${profile}": ${error.message}`) + return token + }) + entry.token = authToken return { baseUrl, mode: 'local', source: 'local', authMode: 'token', - token, + token: authToken, profile, - wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`, + wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`, logs: hermesLog.slice(-80), ...getWindowState() } @@ -4821,6 +4827,10 @@ async function startHermes() { await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90) await Promise.race([waitForHermes(baseUrl, token), backendStartFailed]) backendReady = true + const authToken = await resolveServedDashboardToken(baseUrl, token, { rememberLog }).catch(error => { + rememberLog(`[boot] could not read served dashboard token: ${error.message}`) + return token + }) updateBootProgress({ phase: 'backend.ready', message: 'Hermes backend is ready. Finalizing desktop startup', @@ -4834,8 +4844,8 @@ async function startHermes() { mode: 'local', source: 'local', authMode: 'token', - token, - wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`, + token: authToken, + wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`, logs: hermesLog.slice(-80), ...getWindowState() } diff --git a/apps/desktop/electron/windows-child-process.test.cjs b/apps/desktop/electron/windows-child-process.test.cjs index 6bcc58a0a33..92989c978bb 100644 --- a/apps/desktop/electron/windows-child-process.test.cjs +++ b/apps/desktop/electron/windows-child-process.test.cjs @@ -8,7 +8,7 @@ const path = require('node:path') const ELECTRON_DIR = __dirname function readElectronFile(name) { - return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8') + return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n') } function requireHiddenChildOptions(source, needle) { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d247476ebeb..6d2e1b0ce1c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -35,7 +35,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-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", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.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", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix",