fix(desktop): adopt a published session token on the post-update relaunch

The post-update relaunch refused a backend that had published a session token
and spawned another. Adopt the host rendezvous token when GET / withholds it.

The stale app.asar half is dropped: main now judges desktop freshness from the
compiler receipt written inside the packaged output (26c4e8b160), so a skipped
or failed rebuild no longer looks current.
This commit is contained in:
Hermes Agent
2026-09-24 20:06:38 -05:00
committed by brooklyn!
parent f26825eb2d
commit f1247d2e01
5 changed files with 445 additions and 1 deletions
@@ -95,3 +95,74 @@ test('a record whose backend rejects the session token does not attach', async (
assert.equal(attached, null)
})
/**
* Post-update relaunch: the previous backend published a session token, but
* GET / withholds it. Refusing that record and spawning another is the
* "did not publish a session token" hand-off failure.
*/
test('a relaunch adopts a backend that published a session token instead of spawning another', async () => {
const logs: string[] = []
const token = 'published-session-token'
let spawns = 0
const setup = await runPrimaryBackendStartup({
assertCurrentAttempt: () => {},
attachHostBackend: () =>
attachToHostBackend(
{ isolated: false, ledgerPath: '/ledger.json' },
{
...attachDeps(LEDGER),
log: message => {
logs.push(message)
},
probeWebSocket: async wsUrl => {
assert.match(wsUrl, /token=published-session-token/)
return { ok: true }
},
publishedTokenFor: () => token,
resolveServedToken: async () => null
} as Parameters<typeof attachToHostBackend>[1]
),
connectRemote: async () => ({ mode: 'remote' }),
ensureLocalRuntime: async backend => backend,
prepareLocalBackend: () => {
spawns += 1
return { label: 'spawned' }
},
resolveRemote: async () => null,
waitForDecision: async () => 'continue-local' as const,
waitForLocalStart: async () => undefined
})
assert.equal(setup.kind, 'attached')
assert.equal(spawns, 0, 'a published session token must be adopted, not replaced by a second backend')
assert.equal(
logs.some(line => line.includes('did not publish a session token')),
false
)
assert.equal(setup.kind === 'attached' ? setup.attached.token : null, token)
})
test('a published token the websocket rejects is not adopted', async () => {
let probed = 0
const attached = await attachToHostBackend(
{ isolated: false, ledgerPath: '/ledger.json' },
{
...attachDeps(LEDGER),
probeWebSocket: async () => {
probed += 1
return { ok: false, reason: 'unauthorized' }
},
publishedTokenFor: () => 'published-session-token',
resolveServedToken: async () => null
} as Parameters<typeof attachToHostBackend>[1]
)
assert.equal(attached, null)
assert.equal(probed, 1)
})
+22 -1
View File
@@ -33,6 +33,11 @@ export interface HostBackendAttachDeps {
readLedger: (path: string) => string | null
/** Resolve the token the backend actually serves at `GET /`. */
resolveServedToken: (baseUrl: string) => Promise<string | null>
/**
* Session token the backend published for this record when `GET /` withholds
* it. Absent readers keep the dashboard-HTML-only handshake.
*/
publishedTokenFor?: (record: HostBackendRecord) => string | null
/** Reject unless the backend answers its readiness probe. */
waitForReady: (baseUrl: string, token: string) => Promise<unknown>
/** Reject unless `/api/ws` accepts the token — the leg the renderer uses. */
@@ -48,6 +53,12 @@ function wsUrlFor(baseUrl: string, token: string): string {
return `${baseUrl.replace(/^http/, 'ws')}/api/ws?token=${encodeURIComponent(token)}`
}
function nonemptyToken(value: string | null | undefined): string | null {
const token = String(value ?? '').trim()
return token || null
}
/**
* Validate one candidate all the way to a usable connection, or return null.
*
@@ -57,8 +68,18 @@ function wsUrlFor(baseUrl: string, token: string): string {
*/
async function validate(record: HostBackendRecord, deps: HostBackendAttachDeps): Promise<AttachedBackend | null> {
const baseUrl = recordBaseUrl(record)
const servedToken = nonemptyToken(await deps.resolveServedToken(baseUrl).catch(() => null))
let publishedToken: string | null = null
const token = await deps.resolveServedToken(baseUrl).catch(() => null)
if (!servedToken && deps.publishedTokenFor) {
try {
publishedToken = nonemptyToken(deps.publishedTokenFor(record))
} catch {
publishedToken = null
}
}
const token = servedToken || publishedToken
if (!token) {
deps.log(`[attach] ${baseUrl} (pid ${record.pid}) did not publish a session token; not attaching`)
@@ -0,0 +1,155 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import { attachToHostBackend } from './host-backend-attach'
import { lookupPublishedSessionToken, publishedTokenForRecord } from './host-published-token'
const RECORD = {
createTime: 1_000,
host: '127.0.0.1',
pid: 4711,
port: 65_238,
profile: 'ops',
purpose: 'serve',
registeredAt: 2_000
}
function fingerprint(token: string): string {
return createHash('sha256').update(token, 'utf8').digest('hex').slice(0, 16)
}
function publication(role: string, token: string, overrides: Record<string, unknown> = {}) {
return {
recordText: JSON.stringify({
createTime: 1_000,
host: '127.0.0.1',
pid: 4711,
port: 65_238,
protocolVersion: 1,
role,
tokenFingerprint: fingerprint(token),
...overrides
}),
role,
token
}
}
test('a desktop-serve publication matches the ledger record by pid, port, and fingerprint', () => {
const token = 'published-session-token'
assert.equal(
publishedTokenForRecord(RECORD, [publication('desktop-serve', token)]),
token
)
assert.equal(
publishedTokenForRecord(RECORD, [publication('desktop-serve', token, { tokenFingerprint: '0'.repeat(16) })]),
null
)
assert.equal(publishedTokenForRecord(RECORD, [publication('desktop-serve', token, { pid: 99 })]), null)
assert.equal(publishedTokenForRecord(RECORD, [publication('gateway', token)]), null)
})
test('lookup adopts an owner-only desktop-serve token and ignores a world-readable one', () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-host-token-'))
const token = 'published-session-token'
const record = {
createTime: 1_000,
host: '127.0.0.1',
pid: 4711,
port: 65_238,
protocolVersion: 1,
role: 'desktop-serve',
tokenFingerprint: fingerprint(token)
}
fs.chmodSync(directory, 0o700)
fs.writeFileSync(path.join(directory, 'host-desktop-serve.json'), JSON.stringify(record), { mode: 0o600 })
fs.writeFileSync(path.join(directory, 'host-desktop-serve.token'), `${token}\n`, { mode: 0o600 })
const env = { home: os.homedir(), lockDir: directory, platform: process.platform }
const io = {
lstat: (target: string) => fs.lstatSync(target),
readFile: (target: string) => fs.readFileSync(target, 'utf8'),
uid: typeof process.getuid === 'function' ? process.getuid() : null
}
assert.equal(lookupPublishedSessionToken(RECORD, env, io), token)
fs.chmodSync(path.join(directory, 'host-desktop-serve.token'), 0o644)
assert.equal(process.platform === 'win32' ? token : null, lookupPublishedSessionToken(RECORD, env, io))
fs.rmSync(directory, { recursive: true, force: true })
})
test('a withheld dashboard token adopts the on-disk published token instead of spawning', async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-host-token-'))
const token = 'published-session-token'
fs.chmodSync(directory, 0o700)
fs.writeFileSync(
path.join(directory, 'host-desktop-serve.json'),
JSON.stringify({
host: '127.0.0.1',
pid: 4711,
port: 65_238,
protocolVersion: 1,
role: 'desktop-serve',
tokenFingerprint: fingerprint(token)
}),
{ mode: 0o600 }
)
fs.writeFileSync(path.join(directory, 'host-desktop-serve.token'), token, { mode: 0o600 })
const logs: string[] = []
const attached = await attachToHostBackend(
{ isolated: false, ledgerPath: '/ledger.json' },
{
log: message => {
logs.push(message)
},
probeWebSocket: async wsUrl => {
assert.match(wsUrl, /token=published-session-token/)
return { ok: true }
},
publishedTokenFor: record =>
lookupPublishedSessionToken(
record,
{ home: os.homedir(), lockDir: directory, platform: process.platform },
{
lstat: target => fs.lstatSync(target),
readFile: target => fs.readFileSync(target, 'utf8'),
uid: typeof process.getuid === 'function' ? process.getuid() : null
}
),
readLedger: () =>
JSON.stringify([
{
host: '127.0.0.1',
pid: 4711,
port: 65_238,
profile: 'ops',
purpose: 'dashboard',
registered_at: 2_000
}
]),
resolveServedToken: async () => null,
waitForReady: async () => undefined
}
)
assert.equal(attached?.token, token)
assert.equal(
logs.some(line => line.includes('did not publish a session token')),
false
)
fs.rmSync(directory, { recursive: true, force: true })
})
@@ -0,0 +1,180 @@
// Session token published by a Hermes backend for same-user attach.
//
// `GET /` withholds `window.__HERMES_SESSION_TOKEN__` when the dashboard is
// auth-gated. The backend still writes the live token next to its host
// rendezvous record (`gateway/host_rendezvous.py`): `host-serve.token` for the
// machine owner, `host-desktop-serve.token` for a Desktop-spawned child. The
// post-update relaunch has to adopt that token instead of logging "did not
// publish a session token" and spawning a second backend.
import { createHash } from 'node:crypto'
import path from 'node:path'
import type { HostBackendRecord } from './backend-discovery'
const HOST_PROTOCOL_VERSION = 1
const PUBLISHED_ROLES = ['serve', 'desktop-serve'] as const
export interface PublishedHostToken {
recordText: string
role: string
token: string
}
export interface HostTokenStat {
isDirectory: () => boolean
isFile: () => boolean
isSymbolicLink: () => boolean
mode: number
uid: number
}
export interface HostTokenIo {
lstat: (target: string) => HostTokenStat
readFile: (target: string) => string
uid: number | null
}
export interface HostRendezvousEnv {
home: string
lockDir?: string
platform: string
stateHome?: string
}
export function hostRendezvousDirectory(env: HostRendezvousEnv): string {
const override = String(env.lockDir || '').trim()
if (override) {
return path.resolve(override)
}
const stateHomeEnv = String(env.stateHome || '').trim()
const stateHome = stateHomeEnv && path.isAbsolute(stateHomeEnv) ? stateHomeEnv : path.join(env.home, '.local', 'state')
return path.join(stateHome, 'hermes', 'gateway-locks')
}
function tokenFingerprint(token: string): string {
return createHash('sha256').update(token, 'utf8').digest('hex').slice(0, 16)
}
function isPrivateEntry(stat: HostTokenStat, platform: string, uid: number | null): boolean {
if (stat.isSymbolicLink()) {
return false
}
if (platform === 'win32') {
return true
}
if ((stat.mode & 0o077) !== 0) {
return false
}
return uid === null || stat.uid === uid
}
function tokenForPublication(record: HostBackendRecord, publication: PublishedHostToken): string | null {
if (!PUBLISHED_ROLES.includes(publication.role as (typeof PUBLISHED_ROLES)[number])) {
return null
}
let parsed: unknown
try {
parsed = JSON.parse(publication.recordText)
} catch {
return null
}
if (!parsed || typeof parsed !== 'object') {
return null
}
const payload = parsed as Record<string, unknown>
const token = publication.token.trim()
const fingerprint = String(payload.tokenFingerprint ?? '')
if (
payload.role !== publication.role ||
payload.protocolVersion !== HOST_PROTOCOL_VERSION ||
payload.pid !== record.pid ||
payload.port !== record.port ||
!token ||
!/^[0-9a-f]{16}$/.test(fingerprint) ||
tokenFingerprint(token) !== fingerprint
) {
return null
}
return token
}
/** The published token that belongs to this ledger record, or null. */
export function publishedTokenForRecord(
record: HostBackendRecord,
publications: PublishedHostToken[]
): string | null {
for (const publication of publications) {
const token = tokenForPublication(record, publication)
if (token) {
return token
}
}
return null
}
/** Read owner-only host-serve and host-desktop-serve token pairs. Never throws. */
export function readPublishedHostTokens(directory: string, io: HostTokenIo, platform: string): PublishedHostToken[] {
try {
const directoryStat = io.lstat(directory)
if (!directoryStat.isDirectory() || !isPrivateEntry(directoryStat, platform, io.uid)) {
return []
}
} catch {
return []
}
const publications: PublishedHostToken[] = []
for (const role of PUBLISHED_ROLES) {
const recordPath = path.join(directory, `host-${role}.json`)
const tokenPath = path.join(directory, `host-${role}.token`)
try {
const recordStat = io.lstat(recordPath)
const tokenStat = io.lstat(tokenPath)
if (
!recordStat.isFile() ||
!tokenStat.isFile() ||
!isPrivateEntry(recordStat, platform, io.uid) ||
!isPrivateEntry(tokenStat, platform, io.uid)
) {
continue
}
publications.push({
recordText: io.readFile(recordPath),
role,
token: io.readFile(tokenPath)
})
} catch {
continue
}
}
return publications
}
export function lookupPublishedSessionToken(
record: HostBackendRecord,
env: HostRendezvousEnv,
io: HostTokenIo
): string | null {
return publishedTokenForRecord(record, readPublishedHostTokens(hostRendezvousDirectory(env), io, env.platform))
}
+17
View File
@@ -65,6 +65,7 @@ import {
import { dashboardFallbackArgs } from './backend-command'
import { createBackendConnectionState } from './backend-connection-state'
import { BackendDialClaims } from './backend-dial-claim'
import type { HostBackendRecord } from './backend-discovery'
import { buildDesktopBackendEnv, profileBackendParentEnv } from './backend-env'
import { createBackendExitRecoveryLatch } from './backend-exit-recovery'
import { isReauthRequiredError, waitForHermesReady } from './backend-health'
@@ -278,6 +279,7 @@ import {
type SpawnReservation
} from './host-backend-attach'
import { assertNoSecondLocalBackend, assertNotPassiveSpawn } from './host-backend-singleton'
import { lookupPublishedSessionToken } from './host-published-token'
import { requestHudClose } from './hud-close'
import { cursorPointInWindow } from './hud-cursor'
import { startHudGameOverlayWatch } from './hud-game-overlay'
@@ -12133,6 +12135,21 @@ function hostBackendAttachDeps() {
}
},
probeWebSocket: (wsUrl: string) => probeGatewayWebSocket(wsUrl, { WebSocketImpl: globalThis.WebSocket }),
publishedTokenFor: (record: HostBackendRecord) =>
lookupPublishedSessionToken(
record,
{
home: os.homedir(),
lockDir: process.env.HERMES_GATEWAY_LOCK_DIR,
platform: process.platform,
stateHome: process.env.XDG_STATE_HOME
},
{
lstat: target => fs.lstatSync(target),
readFile: target => fs.readFileSync(target, 'utf8'),
uid: typeof process.getuid === 'function' ? process.getuid() : null
}
),
resolveServedToken: (baseUrl: string) => resolveServedDashboardToken(baseUrl, ''),
waitForReady: (baseUrl: string, token: string) => waitForHermes(baseUrl, token, undefined, 'token', {})
}