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