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:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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', {})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user