From e96fe06e4968ebaf1c2df0a2a9c9fc9fc730b6cf Mon Sep 17 00:00:00 2001 From: Jeff Date: Wed, 10 Jun 2026 14:36:00 -0400 Subject: [PATCH 1/6] 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", From 7a2d498b9dd2f1f16d80ac975c3e0800f82e385b Mon Sep 17 00:00:00 2001 From: Evis Date: Thu, 11 Jun 2026 18:11:52 +0800 Subject: [PATCH 2/6] fix(desktop): route profile session reads (cherry picked from commit 64aaf58f5e51cc0905ad5d0e7f7daa3a37f9668f) --- apps/desktop/src/app/artifacts/index.tsx | 6 +- .../app/chat/sidebar/session-actions-menu.tsx | 2 +- .../desktop/src/app/command-palette/index.tsx | 8 +-- apps/desktop/src/app/desktop-controller.tsx | 4 +- .../app/session/hooks/use-session-actions.ts | 65 ++++++++++++++++--- .../src/app/settings/sessions-settings.tsx | 6 +- .../desktop/src/components/session-picker.tsx | 4 +- apps/desktop/src/hermes.test.ts | 13 +++- apps/desktop/src/hermes.ts | 3 +- apps/desktop/src/lib/session-export.ts | 4 +- 10 files changed, 88 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index fd1569d7caf..8e98dd9d40d 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -18,7 +18,7 @@ import { } from '@/components/ui/pagination' import { TextTab, TextTabMeta } from '@/components/ui/text-tab' import { Tip } from '@/components/ui/tooltip' -import { getSessionMessages, listSessions } from '@/hermes' +import { getSessionMessages, listAllProfileSessions } from '@/hermes' import { type Translations, useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link' @@ -388,8 +388,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . setRefreshing(true) try { - const sessions = (await listSessions(30, 1)).sessions - const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id))) + const sessions = (await listAllProfileSessions(30, 1)).sessions + const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id, session.profile))) const nextArtifacts: ArtifactRecord[] = [] results.forEach((result, index) => { diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx index 4d7ebf946ce..3d51ab8f8bb 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -88,7 +88,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o label: r.export, onSelect: () => { triggerHaptic('selection') - void exportSession(sessionId, { title }) + void exportSession(sessionId, { profile, title }) } }, { diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 3872d24d5f9..2e3a45d771e 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -8,7 +8,7 @@ import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/ap import { setTerminalTakeover } from '@/app/right-sidebar/store' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { KbdGroup } from '@/components/ui/kbd' -import { getHermesConfigRecord, listSessions } from '@/hermes' +import { getHermesConfigRecord, listAllProfileSessions } from '@/hermes' import { useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' import { @@ -119,7 +119,7 @@ const paletteFilter = (value: string, search: string, keywords?: string[]): numb return needle.split(/\s+/).every(term => haystack.includes(term)) ? 1 : 0 } -type SessionRow = Awaited>['sessions'][number] +type SessionRow = Awaited>['sessions'][number] const toSessionEntry = (session: SessionRow): SessionEntry => ({ id: session.id, @@ -218,13 +218,13 @@ export function CommandPalette() { const sessionsQuery = useQuery({ queryKey: ['command-palette', 'sessions'], - queryFn: () => listSessions(200, 1, 'exclude'), + queryFn: () => listAllProfileSessions(200, 1, 'exclude'), enabled: open }) const archivedQuery = useQuery({ queryKey: ['command-palette', 'archived'], - queryFn: () => listSessions(200, 0, 'only'), + queryFn: () => listAllProfileSessions(200, 0, 'only'), enabled: open }) diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index f04ade8f80e..0130eb7c613 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -547,7 +547,9 @@ export function DesktopController() { return } - const storedProfile = $sessions.get().find(session => session.id === storedSessionId)?.profile + const storedProfile = $sessions + .get() + .find(session => session.id === storedSessionId || session._lineage_root_id === storedSessionId)?.profile for (let index = 0; index < Math.max(1, attempts); index += 1) { try { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index 9980c90809d..8a419488740 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react' import { useCallback, useRef } from 'react' import type { NavigateFunction } from 'react-router-dom' -import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes' +import { deleteSession, getSessionMessages, listAllProfileSessions, setSessionArchived } from '@/hermes' import { useI18n } from '@/i18n' import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' import { normalizePersonalityValue } from '@/lib/chat-runtime' @@ -209,6 +209,46 @@ function patchSessionWorkspace(sessionId: string, cwd: string | undefined) { setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session))) } +function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string): boolean { + return session.id === storedSessionId || session._lineage_root_id === storedSessionId +} + +function upsertResolvedSession(session: SessionInfo, storedSessionId: string) { + const lineage = session._lineage_root_id ?? session.id + + setSessions(prev => [ + session, + ...prev.filter(existing => { + if (sessionMatchesStoredId(existing, storedSessionId)) { + return false + } + + return (existing._lineage_root_id ?? existing.id) !== lineage + }) + ]) +} + +async function resolveStoredSession(storedSessionId: string): Promise { + const cached = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) + + if (cached) { + return cached + } + + try { + const result = await listAllProfileSessions(500, 0, 'include', 'recent', 'all') + const resolved = result.sessions.find(session => sessionMatchesStoredId(session, storedSessionId)) + + if (resolved) { + upsertResolvedSession(resolved, storedSessionId) + } + + return resolved + } catch { + return undefined + } +} + type SessionRuntimeStatePatch = Partial< Pick< ClientSessionState, @@ -480,8 +520,13 @@ export function useSessionActions({ // Swap the single live gateway to this session's profile before any // gateway call (no-op when it's already on that profile / single-profile). - const storedForProfile = $sessions.get().find(session => session.id === storedSessionId) + const storedForProfile = await resolveStoredSession(storedSessionId) const sessionProfile = storedForProfile?.profile + + if (resumeRequestRef.current !== requestId) { + return + } + await ensureGatewayProfile(sessionProfile) const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId) @@ -549,7 +594,7 @@ export function useSessionActions({ setSelectedStoredSessionId(storedSessionId) selectedStoredSessionIdRef.current = storedSessionId setSessionStartedAt(Date.now()) - const stored = $sessions.get().find(session => session.id === storedSessionId) + const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) applyStoredSessionPreviewRuntimeInfo(stored) if (stored) { @@ -799,7 +844,7 @@ export function useSessionActions({ async (storedSessionId: string) => { clearNotifications() - const removed = $sessions.get().find(s => s.id === storedSessionId) + const removed = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) const wasSelected = selectedStoredSessionId === storedSessionId const closingRuntimeId = wasSelected ? activeSessionId : null const previousMessages = $messages.get() @@ -808,7 +853,7 @@ export function useSessionActions({ // live tip after compression. Drop both so the pin can't linger. const removedPinId = removed ? sessionPinId(removed) : storedSessionId - setSessions(prev => prev.filter(s => s.id !== storedSessionId)) + setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))) // Keep $sessionsTotal in sync so the sidebar's "Load N more" footer // doesn't keep claiming the removed row is still on the server. setSessionsTotal(prev => Math.max(0, prev - 1)) @@ -843,7 +888,7 @@ export function useSessionActions({ setFreshDraftReady(false) setSelectedStoredSessionId(storedSessionId) selectedStoredSessionIdRef.current = storedSessionId - const stored = $sessions.get().find(session => session.id === storedSessionId) + const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) if (stored) { setCurrentUsage(current => ({ @@ -882,7 +927,7 @@ export function useSessionActions({ async (storedSessionId: string) => { clearNotifications() - const archived = $sessions.get().find(s => s.id === storedSessionId) + const archived = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) const wasSelected = selectedStoredSessionId === storedSessionId const previousPinned = $pinnedSessionIds.get() // Pins are keyed on the durable lineage-root id; the stored id may be the @@ -890,7 +935,7 @@ export function useSessionActions({ const archivedPinId = archived ? sessionPinId(archived) : storedSessionId // Soft-hide: drop from the sidebar immediately, keep the data. - setSessions(prev => prev.filter(s => s.id !== storedSessionId)) + setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))) // Archived sessions are hidden by the listSessions(min_messages=1) query // on the next refresh, so they count as "removed" for the load-more // footer math. @@ -907,12 +952,12 @@ export function useSessionActions({ // in flight and briefly reinsert the still-unarchived backend row. Win // that race after the mutation succeeds so right-click → Archive does // not appear to do nothing until the next full refresh. - setSessions(prev => prev.filter(s => s.id !== storedSessionId)) + setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))) $pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId)) notify({ durationMs: 2_000, kind: 'success', message: copy.archived }) } catch (err) { if (archived) { - setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)]) + setSessions(prev => [archived, ...prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))]) setSessionsTotal(prev => prev + 1) } diff --git a/apps/desktop/src/app/settings/sessions-settings.tsx b/apps/desktop/src/app/settings/sessions-settings.tsx index 2e043ff0ef3..f644ded929c 100644 --- a/apps/desktop/src/app/settings/sessions-settings.tsx +++ b/apps/desktop/src/app/settings/sessions-settings.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { Tip } from '@/components/ui/tooltip' -import { deleteSession, listSessions, setSessionArchived } from '@/hermes' +import { deleteSession, listAllProfileSessions, setSessionArchived } from '@/hermes' import { useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' import { triggerHaptic } from '@/lib/haptics' @@ -43,14 +43,14 @@ export function SessionsSettings() { setLoading(true) try { - const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only') + const result = await listAllProfileSessions(ARCHIVED_FETCH_LIMIT, 0, 'only') setLocalSessions(result.sessions) } catch (err) { notifyError(err, s.failedLoad) } finally { setLoading(false) } - }, []) + }, [s.failedLoad]) useEffect(() => { void load() diff --git a/apps/desktop/src/components/session-picker.tsx b/apps/desktop/src/components/session-picker.tsx index 048fa32a208..67012d9a3f0 100644 --- a/apps/desktop/src/components/session-picker.tsx +++ b/apps/desktop/src/components/session-picker.tsx @@ -3,7 +3,7 @@ import { Dialog as DialogPrimitive } from 'radix-ui' import { useEffect, useMemo, useState } from 'react' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' -import { listSessions } from '@/hermes' +import { listAllProfileSessions } from '@/hermes' import { useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' import { Check, MessageCircle } from '@/lib/icons' @@ -35,7 +35,7 @@ export function SessionPickerDialog({ const sessionsQuery = useQuery({ enabled: open, - queryFn: () => listSessions(200, 1, 'exclude'), + queryFn: () => listAllProfileSessions(200, 1, 'exclude'), queryKey: ['session-picker', 'sessions'] }) diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts index 0dcf58b3640..290f6aac96d 100644 --- a/apps/desktop/src/hermes.test.ts +++ b/apps/desktop/src/hermes.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { listAllProfileSessions, listSessions } from './hermes' +import { getSessionMessages, listAllProfileSessions, listSessions } from './hermes' const emptySessionsResponse = { limit: 0, @@ -46,4 +46,15 @@ describe('Hermes REST session helpers', () => { }) ) }) + + it('tags cross-profile message reads for Electron routing and backend lookup', async () => { + api.mockResolvedValue({ messages: [], session_id: 'session-1' }) + + await getSessionMessages('session-1', 'xiaoxuxu') + + expect(api).toHaveBeenCalledWith({ + path: '/api/sessions/session-1/messages?profile=xiaoxuxu', + profile: 'xiaoxuxu' + }) + }) }) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index da3247a36a9..147c296cd6b 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -54,10 +54,10 @@ export type { AnalyticsSkillEntry, AnalyticsSkillsSummary, AnalyticsTotals, - BackendUpdateCheckResponse, AudioSpeakResponse, AudioTranscriptionResponse, AuxiliaryModelsResponse, + BackendUpdateCheckResponse, ConfigFieldSchema, ConfigSchemaResponse, CronJob, @@ -218,6 +218,7 @@ export function getSessionMessages(id: string, profile?: string | null): Promise const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : '' return window.hermesDesktop.api({ + ...(profile ? { profile } : {}), path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}` }) } diff --git a/apps/desktop/src/lib/session-export.ts b/apps/desktop/src/lib/session-export.ts index b32a705b7eb..8aa31c695cb 100644 --- a/apps/desktop/src/lib/session-export.ts +++ b/apps/desktop/src/lib/session-export.ts @@ -5,6 +5,7 @@ import { notify, notifyError } from '@/store/notifications' interface ExportSessionParams { sessionId: string + profile?: string | null title?: string | null session?: SessionInfo } @@ -31,7 +32,8 @@ export async function exportSession(sessionId: string, params: Omit Date: Thu, 11 Jun 2026 18:18:22 -0500 Subject: [PATCH 3/6] fix(desktop): refuse a foreign backend's session token after readiness The served-token fallback adopts whatever token the dashboard HTML injects. That is correct when our own child regenerated the token (env pin lost across a shell-wrapped spawn), but wrong when the readiness probe answered from a process we did not spawn: /api/status is public, so an orphaned dashboard squatting the port passes waitForHermes while our child dies on the bind conflict. Silently adopting that process's token would authenticate the renderer against a foreign backend, possibly on the wrong profile. Discriminate on child liveness: the desktop pins HERMES_DASHBOARD_SESSION_TOKEN on every spawn, so a live child always serves our token. Served-token mismatch + dead child = foreign backend; fail the boot loudly instead of connecting. Mismatch + live child keeps the adopt-served-token salvage from #43720. --- apps/desktop/electron/dashboard-token.cjs | 22 +++++++++++++ .../desktop/electron/dashboard-token.test.cjs | 21 +++++++++++++ apps/desktop/electron/main.cjs | 31 ++++++++++++++++++- 3 files changed, 73 insertions(+), 1 deletion(-) 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', From 9ff0ba082739a74c54fc9bb26062be168a167391 Mon Sep 17 00:00:00 2001 From: "Mani Saint-Victor, MD" Date: Tue, 9 Jun 2026 17:03:55 -0400 Subject: [PATCH 4/6] fix(desktop): prevent backend port-squat boot loop and pickPort self-collision Two fixes to the Electron desktop launch path, with the port-reservation logic extracted into a unit-tested module: 1. hermes:bootstrap:reset ("Reload and retry") only cleared connectionPromise, leaving the live backend alive; the orphan kept binding PORT_FLOOR (9120) so the next startHermes() hit EADDRINUSE / "Object has been destroyed" and the window looped. Await teardownPrimaryBackendAndWait() so the reset stops the old backend before restarting. 2. pickPort() probes-then-closes a socket before the real bind happens in a separate Python child, so two concurrent spawns (primary + pool backend) could both be handed PORT_FLOOR and one died with EADDRINUSE. The reservation bookkeeping is extracted into electron/port-pool.cjs (PortPool): pickPort() reserves the chosen port until the child exits and releases it on every exit/error/throw-before-spawn path, closing the TOCTOU window. PortPool is dependency-injected (probe passed in) and socket-free, unit-tested in electron/port-pool.test.cjs (8 cases) and wired into the test:desktop:platforms script. (cherry picked from commit d4133945b91e1d25b2e3a506553a8f0e7a598a5a) --- apps/desktop/electron/main.cjs | 47 ++++++++++++--- apps/desktop/electron/port-pool.cjs | 73 ++++++++++++++++++++++ apps/desktop/electron/port-pool.test.cjs | 77 ++++++++++++++++++++++++ apps/desktop/package.json | 2 +- 4 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/electron/port-pool.cjs create mode 100644 apps/desktop/electron/port-pool.test.cjs diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 4ab82a10e41..8975cec79a2 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -30,6 +30,7 @@ const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./sessio const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') const { isForeignBackendToken, resolveServedDashboardToken } = require('./dashboard-token.cjs') +const { PortPool } = require('./port-pool.cjs') const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') const { readDirForIpc } = require('./fs-read-dir.cjs') @@ -108,6 +109,10 @@ if (USER_DATA_OVERRIDE) { const PORT_FLOOR = 9120 const PORT_CEILING = 9199 +// In-process port reservations that close the pickPort() TOCTOU window where +// two concurrent backend spawns could be handed the same port. See +// port-pool.cjs for the full rationale. +const portPool = new PortPool(PORT_FLOOR, PORT_CEILING) const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER const IS_PACKAGED = app.isPackaged const IS_MAC = process.platform === 'darwin' @@ -2453,10 +2458,11 @@ function isPortAvailable(port) { } async function pickPort() { - for (let port = PORT_FLOOR; port <= PORT_CEILING; port += 1) { - if (await isPortAvailable(port)) return port + const port = await portPool.reserve(isPortAvailable) + if (port === null) { + throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`) } - throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`) + return port } function fetchJson(url, token, options = {}) { @@ -4540,9 +4546,20 @@ async function spawnPoolBackend(profile, entry) { // --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. const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)] - const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs)) - const hermesCwd = resolveHermesCwd() - const webDist = resolveWebDist() + let backend + let hermesCwd + let webDist + try { + backend = await ensureRuntime(resolveHermesBackend(dashboardArgs)) + hermesCwd = resolveHermesCwd() + webDist = resolveWebDist() + } catch (error) { + // These run before the child exists / its exit handler is attached, so a + // throw here would otherwise leak the reservation and slowly exhaust the + // 9120-9199 range across switch cycles in one app session. + portPool.release(port) + throw error + } rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`) @@ -4580,11 +4597,13 @@ async function spawnPoolBackend(profile, entry) { child.once('error', error => { rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`) backendPool.delete(profile) + portPool.release(port) rejectStart?.(error) }) child.once('exit', (code, signal) => { rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`) backendPool.delete(profile) + portPool.release(port) if (!ready) { rejectStart?.( new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`) @@ -4633,6 +4652,7 @@ function stopPoolBackend(profile) { const entry = backendPool.get(profile) if (!entry) return backendPool.delete(profile) + if (entry.port) portPool.release(entry.port) if (entry.process && !entry.process.killed) { try { entry.process.kill('SIGTERM') @@ -4718,6 +4738,11 @@ async function startHermes() { } if (connectionPromise) return connectionPromise + // Hoisted so the outer .catch can release a port reserved by pickPort() when + // a throw (e.g. ensureRuntime failing) happens before the child's exit + // handler is attached. Stays null on the remote path (no port picked). + let reservedPort = null + connectionPromise = (async () => { await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8) // Resolve for the desktop's primary profile so a per-profile remote @@ -4747,6 +4772,7 @@ async function startHermes() { await advanceBootProgress('backend.port', 'Finding an open local port', 16) const port = await pickPort() + reservedPort = port const token = crypto.randomBytes(32).toString('base64url') const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)] // Pin the desktop's chosen profile via the global --profile flag. This is @@ -4811,6 +4837,7 @@ async function startHermes() { ) hermesProcess = null connectionPromise = null + portPool.release(port) sendBackendExit({ code: null, signal: null, error: error.message }) rejectBackendStart?.(error) }) @@ -4818,6 +4845,7 @@ async function startHermes() { rememberLog(`Hermes backend exited (${signal || code})`) hermesProcess = null connectionPromise = null + portPool.release(port) sendBackendExit({ code, signal }) if (!backendReady) { const message = `Hermes backend exited before it became ready (${signal || code}).` @@ -4846,11 +4874,13 @@ async function startHermes() { rememberLog(`[boot] could not read served dashboard token: ${error.message}`) return token }) + // The exit/error handlers null hermesProcess when the child dies, so a + // null here already means "child dead". if ( isForeignBackendToken({ servedToken: authToken, spawnToken: token, - childAlive: hermesProcess.exitCode === null && !hermesProcess.killed + childAlive: hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed }) ) { // Our child is dead and the port answers with someone else's token: @@ -4890,6 +4920,7 @@ async function startHermes() { { allowDecrease: true } ) connectionPromise = null + portPool.release(reservedPort) throw error }) @@ -5164,8 +5195,8 @@ ipcMain.handle('hermes:bootstrap:reset', async () => { // reset connection state so the next startHermes() call restarts the // full backend flow (including a fresh runBootstrap pass). rememberLog('[bootstrap] reset requested by renderer; clearing latched failure') + await teardownPrimaryBackendAndWait() bootstrapFailure = null - connectionPromise = null bootstrapState = { active: false, manifest: null, diff --git a/apps/desktop/electron/port-pool.cjs b/apps/desktop/electron/port-pool.cjs new file mode 100644 index 00000000000..35131090814 --- /dev/null +++ b/apps/desktop/electron/port-pool.cjs @@ -0,0 +1,73 @@ +'use strict' + +/** + * In-process port reservation pool for the desktop backend launcher. + * + * pickPort() probes a localhost port with a throwaway server and closes it + * before the real bind happens in a separate Python child. Between that probe + * and the child's bind there is a TOCTOU window: a second concurrent spawn + * (the primary backend racing a pool backend) can be handed the SAME port, and + * one then dies with EADDRINUSE ("address already in use" -> "Object has been + * destroyed" boot loop). Reserving the chosen port in THIS process until the + * child exits closes that window. + * + * The OS bind remains the source of truth; this only deconflicts racers inside + * this process — it can't stop a foreign squatter, which the probe + the + * EADDRINUSE self-heal still cover. + * + * The pool is dependency-injected (the availability probe is passed in) and + * free of Electron/Node socket I/O, so it is unit-tested without real sockets + * (see port-pool.test.cjs). + */ +class PortPool { + /** + * @param {number} floor inclusive lowest port to hand out + * @param {number} ceiling inclusive highest port to hand out + */ + constructor(floor, ceiling) { + this.floor = floor + this.ceiling = ceiling + this._reserved = new Set() + } + + /** @returns {boolean} whether `port` is currently reserved in-process. */ + has(port) { + return this._reserved.has(port) + } + + /** Release a previously reserved port. No-op if it was not reserved. */ + release(port) { + this._reserved.delete(port) + } + + /** Drop all reservations. */ + clear() { + this._reserved.clear() + } + + /** @returns {number} count of currently reserved ports. */ + get size() { + return this._reserved.size + } + + /** + * Reserve and return the lowest port in [floor, ceiling] that is neither + * already reserved in-process nor rejected by `isAvailable(port)`, or null + * if every port is taken. `isAvailable` may be sync (boolean) or async + * (Promise); it is awaited either way. + * + * @param {(port: number) => boolean | Promise} isAvailable + * @returns {Promise} + */ + async reserve(isAvailable) { + for (let port = this.floor; port <= this.ceiling; port += 1) { + if (this._reserved.has(port)) continue + if (!(await isAvailable(port))) continue + this._reserved.add(port) + return port + } + return null + } +} + +module.exports = { PortPool } diff --git a/apps/desktop/electron/port-pool.test.cjs b/apps/desktop/electron/port-pool.test.cjs new file mode 100644 index 00000000000..f2600ce7d5f --- /dev/null +++ b/apps/desktop/electron/port-pool.test.cjs @@ -0,0 +1,77 @@ +/** + * Tests for electron/port-pool.cjs. + * + * Run with: node --test electron/port-pool.test.cjs + * + * PortPool is the in-process reservation that closes the pickPort() TOCTOU + * window. These cover selection order, skipping reserved/unavailable ports, + * release/reuse, exhaustion, and async probes — without real sockets. + */ + +const test = require('node:test') +const assert = require('node:assert/strict') + +const { PortPool } = require('./port-pool.cjs') + +const allFree = () => true + +test('reserve returns the lowest free port and reserves it', async () => { + const pool = new PortPool(9120, 9199) + const port = await pool.reserve(allFree) + assert.equal(port, 9120) + assert.ok(pool.has(9120)) + assert.equal(pool.size, 1) +}) + +test('reserve skips ports already reserved in-process', async () => { + const pool = new PortPool(9120, 9199) + const first = await pool.reserve(allFree) + const second = await pool.reserve(allFree) + assert.equal(first, 9120) + assert.equal(second, 9121) +}) + +test('reserve skips ports the probe rejects', async () => { + const pool = new PortPool(9120, 9199) + const busy = new Set([9120, 9121]) + const port = await pool.reserve(p => !busy.has(p)) + assert.equal(port, 9122) +}) + +test('reserve returns null when every port is taken', async () => { + const pool = new PortPool(9120, 9121) + await pool.reserve(allFree) + await pool.reserve(allFree) + assert.equal(await pool.reserve(allFree), null) +}) + +test('release frees a reserved port for reuse', async () => { + const pool = new PortPool(9120, 9120) + assert.equal(await pool.reserve(allFree), 9120) + assert.equal(await pool.reserve(allFree), null) // exhausted + pool.release(9120) + assert.ok(!pool.has(9120)) + assert.equal(await pool.reserve(allFree), 9120) // reusable +}) + +test('release is a no-op for an unreserved port', () => { + const pool = new PortPool(9120, 9199) + pool.release(9120) + assert.equal(pool.size, 0) +}) + +test('reserve awaits an async probe', async () => { + const pool = new PortPool(9120, 9199) + const busy = new Set([9120]) + const port = await pool.reserve(p => Promise.resolve(!busy.has(p))) + assert.equal(port, 9121) +}) + +test('clear drops all reservations', async () => { + const pool = new PortPool(9120, 9199) + await pool.reserve(allFree) + await pool.reserve(allFree) + assert.equal(pool.size, 2) + pool.clear() + assert.equal(pool.size, 0) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6d2e1b0ce1c..a552f950f20 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/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", + "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/port-pool.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", From cc726aad687af5847dbde3e39787ae866a0828b3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 11 Jun 2026 18:33:05 -0500 Subject: [PATCH 5/6] refactor(desktop): fold served-token adoption + foreign-backend refusal into one helper Both spawn paths (startHermes, spawnPoolBackend) duplicated the same resolve -> log-fallback -> foreign-check -> throw dance. Collapse it into adoptServedDashboardToken(baseUrl, spawnToken, {childAlive, label}) in dashboard-token.cjs; childAlive is a thunk so liveness is sampled after the fetch. Drop the redundant backendPool.delete in the pool's throw path (the child exit/error handlers already own pool eviction). Validated end-to-end against a real web_server.py backend, not just units: token-injection regex vs the actual served index.html, foreign refusal (dead child + live squatter), benign drift adoption, and the 401-vs-200 token auth split on /api/sessions. --- apps/desktop/electron/dashboard-token.cjs | 41 +++++++++----- .../desktop/electron/dashboard-token.test.cjs | 56 +++++++++++++++---- apps/desktop/electron/main.cjs | 47 +++------------- 3 files changed, 79 insertions(+), 65 deletions(-) diff --git a/apps/desktop/electron/dashboard-token.cjs b/apps/desktop/electron/dashboard-token.cjs index ed55f9dd52b..639091a1f79 100644 --- a/apps/desktop/electron/dashboard-token.cjs +++ b/apps/desktop/electron/dashboard-token.cjs @@ -79,28 +79,39 @@ async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) } /** - * 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). + * 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, diff --git a/apps/desktop/electron/dashboard-token.test.cjs b/apps/desktop/electron/dashboard-token.test.cjs index 54e69f3997b..d598ffc2bc1 100644 --- a/apps/desktop/electron/dashboard-token.test.cjs +++ b/apps/desktop/electron/dashboard-token.test.cjs @@ -9,6 +9,7 @@ const test = require('node:test') const assert = require('node:assert/strict') const { + adoptServedDashboardToken, dashboardIndexUrl, extractInjectedDashboardToken, fetchPublicText, @@ -89,22 +90,53 @@ 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 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('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('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('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('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('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) +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 8975cec79a2..911e26e1106 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 { isForeignBackendToken, resolveServedDashboardToken } = require('./dashboard-token.cjs') +const { adoptServedDashboardToken } = require('./dashboard-token.cjs') const { PortPool } = require('./port-pool.cjs') const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') @@ -4614,25 +4614,11 @@ 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 + const authToken = await adoptServedDashboardToken(baseUrl, token, { + childAlive: () => child.exitCode === null && !child.killed, + label: `Hermes backend for profile "${profile}"`, + rememberLog }) - 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 { @@ -4870,26 +4856,11 @@ 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 + 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 }) - // The exit/error handlers null hermesProcess when the child dies, so a - // null here already means "child dead". - if ( - isForeignBackendToken({ - servedToken: authToken, - spawnToken: token, - childAlive: hermesProcess !== null && 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', From b097d7b03352bcbddea53571ac65c048e4c16d45 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 11 Jun 2026 18:41:16 -0500 Subject: [PATCH 6/6] refactor(desktop): use native fetch in dashboard-token Node >=18 / Electron 40 ship fetch; the hand-rolled http/https.request plumbing buys nothing. AbortSignal.timeout replaces the socket timeout, protocol guard and >=400 rejection semantics preserved. 13/13 unit tests and the live web_server.py repro both green over the new transport. --- apps/desktop/electron/dashboard-token.cjs | 53 +++++++---------------- 1 file changed, 16 insertions(+), 37 deletions(-) diff --git a/apps/desktop/electron/dashboard-token.cjs b/apps/desktop/electron/dashboard-token.cjs index 639091a1f79..1a9ca50ad9c 100644 --- a/apps/desktop/electron/dashboard-token.cjs +++ b/apps/desktop/electron/dashboard-token.cjs @@ -7,47 +7,26 @@ * 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 +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`) } - - 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() + throw error }) + const text = await res.text() + + if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`) + + return text } function extractInjectedDashboardToken(html) {