Skip to content

Commit ef88bb5

Browse files
authored
Merge pull request #44529 from NousResearch/bb/desktop-profile-fallout
fix(desktop): close out the multi-profile desktop fallout — WS auth + cross-profile session reads
2 parents a84b8ea + 2257554 commit ef88bb5

17 files changed

Lines changed: 533 additions & 40 deletions
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* Helpers for local dashboard session-token discovery.
3+
*
4+
* The desktop main process can pass HERMES_DASHBOARD_SESSION_TOKEN when it
5+
* spawns the local dashboard, but the dashboard is the source of truth for the
6+
* token it actually serves to the renderer. If those drift, HTTP readiness
7+
* probes still pass while /api/ws rejects the renderer's token.
8+
*/
9+
10+
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
11+
12+
async function fetchPublicText(url, options = {}) {
13+
const { protocol } = new URL(url)
14+
if (protocol !== 'http:' && protocol !== 'https:') {
15+
throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
16+
}
17+
18+
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
19+
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
20+
if (error.name === 'TimeoutError') {
21+
throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
22+
}
23+
throw error
24+
})
25+
const text = await res.text()
26+
27+
if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`)
28+
29+
return text
30+
}
31+
32+
function extractInjectedDashboardToken(html) {
33+
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
34+
if (!match) return null
35+
try {
36+
return JSON.parse(match[1])
37+
} catch {
38+
return null
39+
}
40+
}
41+
42+
function dashboardIndexUrl(baseUrl) {
43+
return `${String(baseUrl || '').replace(/\/+$/, '')}/`
44+
}
45+
46+
async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
47+
const fetchText = options.fetchText || fetchPublicText
48+
const html = await fetchText(dashboardIndexUrl(baseUrl), {
49+
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
50+
})
51+
const servedToken = extractInjectedDashboardToken(html)
52+
53+
if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
54+
options.rememberLog('[boot] dashboard served a different session token; using served token for WebSocket auth')
55+
}
56+
57+
return servedToken || fallbackToken
58+
}
59+
60+
/**
61+
* A served token that differs from our spawn token while our child is DEAD
62+
* came from a process we did not spawn (orphan/port squatter that satisfied
63+
* the public /api/status readiness probe). With a live child the mismatch is
64+
* benign: our own backend regenerated the token because the env pin did not
65+
* survive the spawn.
66+
*/
67+
function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
68+
return Boolean(servedToken) && servedToken !== spawnToken && !childAlive
69+
}
70+
71+
/**
72+
* Resolve the token the backend actually serves, adopting benign drift and
73+
* failing loudly on a foreign backend. `childAlive` is a thunk so liveness is
74+
* sampled after the fetch, not before.
75+
*/
76+
async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
77+
const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
78+
options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
79+
return spawnToken
80+
})
81+
82+
if (isForeignBackendToken({ servedToken, spawnToken, childAlive: childAlive() })) {
83+
throw new Error(
84+
`${label} exited and ${dashboardIndexUrl(baseUrl)} is served by a process we did not spawn; refusing its session token.`
85+
)
86+
}
87+
88+
return servedToken
89+
}
90+
91+
module.exports = {
92+
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
93+
adoptServedDashboardToken,
94+
dashboardIndexUrl,
95+
extractInjectedDashboardToken,
96+
fetchPublicText,
97+
isForeignBackendToken,
98+
resolveServedDashboardToken
99+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* Tests for electron/dashboard-token.cjs.
3+
*
4+
* Run with: node --test electron/dashboard-token.test.cjs
5+
* (Wired into npm test:desktop:platforms in package.json.)
6+
*/
7+
8+
const test = require('node:test')
9+
const assert = require('node:assert/strict')
10+
11+
const {
12+
adoptServedDashboardToken,
13+
dashboardIndexUrl,
14+
extractInjectedDashboardToken,
15+
fetchPublicText,
16+
isForeignBackendToken,
17+
resolveServedDashboardToken
18+
} = require('./dashboard-token.cjs')
19+
20+
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
21+
const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>'
22+
assert.equal(extractInjectedDashboardToken(html), 'served-token')
23+
})
24+
25+
test('extractInjectedDashboardToken handles escaped token strings', () => {
26+
const html = '<script>window.__HERMES_SESSION_TOKEN__="served\\\\token\\"quoted";</script>'
27+
assert.equal(extractInjectedDashboardToken(html), 'served\\token"quoted')
28+
})
29+
30+
test('extractInjectedDashboardToken returns null for missing or malformed values', () => {
31+
assert.equal(extractInjectedDashboardToken('<html></html>'), null)
32+
assert.equal(extractInjectedDashboardToken('<script>window.__HERMES_SESSION_TOKEN__={bad}</script>'), null)
33+
})
34+
35+
test('dashboardIndexUrl preserves dashboard path prefixes', () => {
36+
assert.equal(dashboardIndexUrl('http://127.0.0.1:9120'), 'http://127.0.0.1:9120/')
37+
assert.equal(dashboardIndexUrl('https://host.example/hermes/'), 'https://host.example/hermes/')
38+
})
39+
40+
test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
41+
const logs = []
42+
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
43+
fetchText: async url => {
44+
assert.equal(url, 'http://127.0.0.1:9120/')
45+
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
46+
},
47+
rememberLog: line => logs.push(line)
48+
})
49+
50+
assert.equal(token, 'served-token')
51+
assert.equal(logs.length, 1)
52+
assert.match(logs[0], /served a different session token/)
53+
})
54+
55+
test('resolveServedDashboardToken falls back when the served HTML has no token', async () => {
56+
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
57+
fetchText: async () => '<html></html>',
58+
rememberLog: () => {
59+
throw new Error('should not log when no served token is present')
60+
}
61+
})
62+
63+
assert.equal(token, 'spawn-token')
64+
})
65+
66+
test('resolveServedDashboardToken does not log when served token matches fallback', async () => {
67+
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'same-token', {
68+
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="same-token";</script>',
69+
rememberLog: () => {
70+
throw new Error('should not log when token already matches')
71+
}
72+
})
73+
74+
assert.equal(token, 'same-token')
75+
})
76+
77+
test('resolveServedDashboardToken propagates fetch errors so callers can fall back explicitly', async () => {
78+
await assert.rejects(
79+
() =>
80+
resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
81+
fetchText: async () => {
82+
throw new Error('boom')
83+
}
84+
}),
85+
/boom/
86+
)
87+
})
88+
89+
test('fetchPublicText rejects unsupported protocols', async () => {
90+
await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/)
91+
})
92+
93+
test('isForeignBackendToken only flags a mismatched token from a dead child', () => {
94+
const cases = [
95+
[{ servedToken: 'other', spawnToken: 'mine', childAlive: false }, true],
96+
// Live child + drift = our backend regenerated the token (env pin lost).
97+
[{ servedToken: 'other', spawnToken: 'mine', childAlive: true }, false],
98+
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: false }, false],
99+
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: true }, false],
100+
[{ servedToken: null, spawnToken: 'mine', childAlive: false }, false],
101+
[{ servedToken: '', spawnToken: 'mine', childAlive: false }, false]
102+
]
103+
for (const [input, expected] of cases) {
104+
assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input))
105+
}
106+
})
107+
108+
test('adoptServedDashboardToken adopts drift from a live child', async () => {
109+
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
110+
childAlive: () => true,
111+
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
112+
})
113+
114+
assert.equal(token, 'served-token')
115+
})
116+
117+
test('adoptServedDashboardToken refuses a foreign token when our child is dead', async () => {
118+
await assert.rejects(
119+
() =>
120+
adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
121+
childAlive: () => false,
122+
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="squatter-token";</script>',
123+
label: 'Hermes backend for profile "work"'
124+
}),
125+
/profile "work".*process we did not spawn/
126+
)
127+
})
128+
129+
test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => {
130+
const logs = []
131+
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
132+
childAlive: () => true,
133+
fetchText: async () => {
134+
throw new Error('boom')
135+
},
136+
rememberLog: line => logs.push(line)
137+
})
138+
139+
assert.equal(token, 'spawn-token')
140+
assert.equal(logs.length, 1)
141+
assert.match(logs[0], /could not read served dashboard token \(Hermes backend\): boom/)
142+
})

0 commit comments

Comments
 (0)