From f1247d2e0146bbd8edd4e510b9e67e0d259509a4 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 24 Sep 2026 19:25:16 -0500 Subject: [PATCH] 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. --- .../electron/backend-discovery.test.ts | 71 +++++++ apps/desktop/electron/host-backend-attach.ts | 23 ++- .../electron/host-published-token.test.ts | 155 +++++++++++++++ apps/desktop/electron/host-published-token.ts | 180 ++++++++++++++++++ apps/desktop/electron/main.ts | 17 ++ 5 files changed, 445 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/electron/host-published-token.test.ts create mode 100644 apps/desktop/electron/host-published-token.ts diff --git a/apps/desktop/electron/backend-discovery.test.ts b/apps/desktop/electron/backend-discovery.test.ts index be5e56e8e2..090d48d2a7 100644 --- a/apps/desktop/electron/backend-discovery.test.ts +++ b/apps/desktop/electron/backend-discovery.test.ts @@ -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[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[1] + ) + + assert.equal(attached, null) + assert.equal(probed, 1) +}) diff --git a/apps/desktop/electron/host-backend-attach.ts b/apps/desktop/electron/host-backend-attach.ts index 180abcbc54..b7f095f597 100644 --- a/apps/desktop/electron/host-backend-attach.ts +++ b/apps/desktop/electron/host-backend-attach.ts @@ -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 + /** + * 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 /** 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 { 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`) diff --git a/apps/desktop/electron/host-published-token.test.ts b/apps/desktop/electron/host-published-token.test.ts new file mode 100644 index 0000000000..4b4fccb3f8 --- /dev/null +++ b/apps/desktop/electron/host-published-token.test.ts @@ -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 = {}) { + 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 }) +}) diff --git a/apps/desktop/electron/host-published-token.ts b/apps/desktop/electron/host-published-token.ts new file mode 100644 index 0000000000..f3f417f9f7 --- /dev/null +++ b/apps/desktop/electron/host-published-token.ts @@ -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 + 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)) +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 840ac59242..79d7135fef 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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', {}) }