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.
This commit is contained in:
parent
9ff0ba0827
commit
cc726aad68
@ -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,
|
||||
|
||||
@ -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 () => '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
|
||||
})
|
||||
|
||||
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 () => '<script>window.__HERMES_SESSION_TOKEN__="squatter-token";</script>',
|
||||
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/)
|
||||
})
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user