diff --git a/apps/desktop/electron/dashboard-token.cjs b/apps/desktop/electron/dashboard-token.cjs new file mode 100644 index 00000000000..1a9ca50ad9c --- /dev/null +++ b/apps/desktop/electron/dashboard-token.cjs @@ -0,0 +1,99 @@ +/** + * 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 new file mode 100644 index 00000000000..d598ffc2bc1 --- /dev/null +++ b/apps/desktop/electron/dashboard-token.test.cjs @@ -0,0 +1,142 @@ +/** + * 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 bfa5e178d2f..911e26e1106 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -29,6 +29,8 @@ 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 { 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') const { readDirForIpc } = require('./fs-read-dir.cjs') @@ -107,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' @@ -2452,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 = {}) { @@ -4539,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}`) @@ -4579,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}).`) @@ -4594,15 +4614,21 @@ 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 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', 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() } @@ -4612,6 +4638,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') @@ -4697,6 +4724,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 @@ -4726,6 +4758,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 @@ -4790,6 +4823,7 @@ async function startHermes() { ) hermesProcess = null connectionPromise = null + portPool.release(port) sendBackendExit({ code: null, signal: null, error: error.message }) rejectBackendStart?.(error) }) @@ -4797,6 +4831,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}).` @@ -4821,6 +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 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', @@ -4834,8 +4874,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() } @@ -4851,6 +4891,7 @@ async function startHermes() { { allowDecrease: true } ) connectionPromise = null + portPool.release(reservedPort) throw error }) @@ -5125,8 +5166,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/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..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/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", 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 c21fb0a106b..b765390f019 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