fix(install-e2e): desktop smoke accepts the canonical gateway Desktop attaches to

Since #106742 Desktop reaches its local backend through `hermes gateway ensure`,
which attaches to (or starts, detached) the canonical `gateway run` daemon. That
listener is reparented away from Electron and has no `serve` spawn-ledger record,
so localBackendProcess found 0 listeners and every Linux +desktop install/update
leg failed after an otherwise healthy boot.

Accept a third listener kind, `host-gateway`: the pid gateway_state.json names in
the home under test or its machine root, running, kind hermes-gateway, same
incarnation (start_time in gateway.status units), and the actual OS listener on
the connected port. The installed-tree origin check is unchanged and gets no
app-report waiver for it; the gateway proves its tree by the install-root cwd
`gateway ensure` starts it in.
This commit is contained in:
Maciej Lisiewski committed 2026-10-21 02:43:42 +02:00
1 parent fcace146e9
commit 9a6140877b
3 files changed
+121 -7

No files matched your search

+55 -5
View File
@@ -23,9 +23,10 @@ export interface NativeProcess {
cwd?: string
}
/** The identified listener, with how it was tied to this app (ancestry or this home's ledger). */
/** The identified listener, with how it was tied to this app (ancestry, this home's ledger,
* or the canonical gateway this home's `gateway_state.json` names). */
export interface BackendProcess extends NativeProcess {
ownership: 'child' | 'host-ledger'
ownership: 'child' | 'host-ledger' | 'host-gateway'
}
/** What the caller already knows about the app that owns this listener. */
@@ -179,6 +180,49 @@ function processStartTime(pid: number): number | null {
}
}
const gatewayStateSchema = z.object({
pid: z.number().int().positive(), kind: z.literal('hermes-gateway'),
gateway_state: z.string(), start_time: z.number().nullable().optional(),
}).passthrough()
/** Whether `pid` is still the incarnation `gateway.status._get_process_start_time` recorded:
* raw `/proc/<pid>/stat` start ticks on Linux, psutil centiseconds elsewhere. An unrecorded
* start time carries only its PID, as in the product's `_start_times_conflict`. */
function gatewayIncarnationMatches(pid: number, recorded: number | null | undefined): boolean {
if (recorded === null || recorded === undefined) {
return true
}
if (process.platform === 'linux') {
try {
return Number(fs.readFileSync(`/proc/${pid}/stat`, 'utf8').split(')').pop()!.trim().split(/\s+/)[19]) === recorded
} catch {
return false
}
}
const started = processStartTime(pid)
return started !== null && Math.abs(started - recorded / 100) < 2
}
/** The running canonical gateway (`hermes gateway run`) recorded for `homes`. Desktop reaches a
* local backend through `hermes gateway ensure`, which attaches to that daemon or starts it
* detached, so it is neither an Electron descendant nor a `serve` spawn-ledger record. */
function canonicalGatewayPids(homes: string[], processes: NativeProcess[]): number[] {
return [...new Set(homes)].flatMap((home: string): number[] => {
try {
const state = gatewayStateSchema.parse(JSON.parse(
fs.readFileSync(path.join(home, 'gateway_state.json'), 'utf8').replace(/^\uFEFF/, '')))
const live = state.gateway_state === 'running' && processes.some(candidate => candidate.pid === state.pid)
&& gatewayIncarnationMatches(state.pid, state.start_time)
return live ? [state.pid] : []
} catch { return [] /* Missing/unreadable state provides no attachment evidence. */ }
})
}
/** Identify the actual listener, not a healthy helper or a command recorded before spawn. */
export function localBackendProcess(port: number, electronPid: number, hermesHome?: string): BackendProcess {
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
@@ -192,12 +236,15 @@ export function localBackendProcess(port: number, electronPid: number, hermesHom
// its own child. Registration is only a candidate: the OS listener and the
// caller's existing installed-tree checks must independently agree.
let registered: number[] = []
let gateways: number[] = []
if (hermesHome) {
// The product writes one machine-root ledger for every profile, and the app
// reads it at its own resolved root (`main.ts` -> `spawnLedgerPath(HERMES_HOME)`,
// where a `<root>/profiles/<name>` home resolves to `<root>`).
const ledgerRoot = resolveDesktopHermesHome({ home: os.homedir(), env: { HERMES_HOME: hermesHome } })
// A named profile is served by the root's multiplexer; a standalone one by its own gateway.
gateways = canonicalGatewayPids([hermesHome, ledgerRoot], processes)
try {
registered = parseSpawnLedger(fs.readFileSync(path.join(ledgerRoot, SPAWN_LEDGER_FILENAME), 'utf8'))
@@ -218,7 +265,9 @@ export function localBackendProcess(port: number, electronPid: number, hermesHom
} catch { /* Missing/unreadable ledger provides no attachment evidence. */ }
}
const candidates = processes.filter(candidate => children.includes(candidate) || registered.includes(candidate.pid))
const candidates = processes.filter(candidate => children.includes(candidate) || registered.includes(candidate.pid)
|| gateways.includes(candidate.pid))
let pids: number[]
if (process.platform === 'win32') {
@@ -234,10 +283,11 @@ export function localBackendProcess(port: number, electronPid: number, hermesHom
const matches = candidates.filter((child: NativeProcess): boolean => pids.includes(child.pid))
if (matches.length !== 1) {
throw new Error(`Expected one Desktop-owned or registered host backend listener on port ${port}; found ${matches.length}`)
throw new Error(`Expected one Desktop-owned, registered host or canonical gateway backend listener on port ${port}; found ${matches.length}`)
}
const backend: BackendProcess = { ...matches[0], ownership: children.includes(matches[0]) ? 'child' : 'host-ledger' }
const ownership = children.includes(matches[0]) ? 'child' : registered.includes(matches[0].pid) ? 'host-ledger' : 'host-gateway'
const backend: BackendProcess = { ...matches[0], ownership }
if (process.platform === 'linux') {
backend.executable = fs.readlinkSync(`/proc/${backend.pid}/exe`)
+62
View File
@@ -218,6 +218,68 @@ test.runIf(process.platform === 'linux' || process.platform === 'darwin')('a hos
}
})
test.runIf(process.platform === 'linux' || process.platform === 'darwin')('the canonical gateway gateway_state.json names is the listener, for its own incarnation only', async (): Promise<void> => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'smoke-host-gateway-'))
// A named profile: the root's multiplexer serves it and records its own state there.
const home = path.join(root, 'profiles', 'work')
fs.mkdirSync(home, { recursive: true })
const spawnedAt = Date.now() / 1000
const child = spawn(process.execPath, ['-e', 'const s=require("node:net").createServer();s.listen(0,"127.0.0.1",()=>console.log(s.address().port))'], { stdio: ['ignore', 'pipe', 'pipe'] })
try {
const port = await new Promise<number>((resolve, reject): void => {
child.once('error', reject)
child.stdout.once('data', (data: Buffer): void => resolve(Number(data.toString().trim())))
})
// gateway.status._get_process_start_time: raw start ticks on Linux, centiseconds elsewhere.
const startTime = process.platform === 'linux'
? Number(fs.readFileSync(`/proc/${child.pid}/stat`, 'utf8').split(')').pop()!.trim().split(/\s+/)[19])
: Math.round(spawnedAt * 100)
const state = { pid: child.pid, kind: 'hermes-gateway', gateway_state: 'running', start_time: startTime }
const statePath = path.join(root, 'gateway_state.json')
// `gateway ensure` leaves the daemon outside the app's tree and out of the spawn ledger.
expect(() => localBackendProcess(port, child.pid!, home)).toThrow('found 0')
for (const invalid of [{ ...state, gateway_state: 'stopped' }, { ...state, kind: 'other' },
{ ...state, pid: process.pid }, { ...state, start_time: startTime + 360_000 }]) {
fs.writeFileSync(statePath, JSON.stringify(invalid))
expect(() => localBackendProcess(port, child.pid!, home)).toThrow('found 0')
}
fs.writeFileSync(statePath, JSON.stringify(state))
const backend = localBackendProcess(port, child.pid!, home)
expect(backend.pid).toBe(child.pid)
expect(backend.ownership).toBe('host-gateway')
// The gateway is not tied to the app by ancestry: its own tree evidence is still required.
expect(() => assertBackendOrigin(backend, root, 'source', { appReportedRoot: root })).toThrow('source tree')
fs.writeFileSync(statePath, 'invalid')
expect(() => localBackendProcess(port, child.pid!, home)).toThrow('found 0')
} finally {
child.kill()
await new Promise<void>((resolve): void => { child.once('exit', (): void => resolve()) })
fs.rmSync(root, { recursive: true, force: true })
}
})
test('a canonical gateway proves its tree by the cwd `gateway ensure` starts it in', (): void => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'smoke-gateway-origin-'))
try {
// spawn_unmanaged_gateway: `sys.executable -m hermes_cli.main --profile default gateway run`, cwd=<install root>.
// The interpreter is the managed one beside the checkout, so argv never names the root.
const executable = path.join(os.tmpdir(), 'hermes-tools', 'python', 'bin', 'python3.14')
const backend = { pid: 2, parentPid: 1, executable, cwd: root, ownership: 'host-gateway' as const,
command: [executable, '-m', 'hermes_cli.main', '--profile', 'default', 'gateway', 'run', '--quiet'].map(arg => JSON.stringify(arg)).join(' ') }
expect(() => assertBackendOrigin(backend, root, 'source', { appReportedRoot: root })).not.toThrow()
// The app's own report never vouches for a listener that is not its descendant.
expect(() => assertBackendOrigin({ ...backend, cwd: os.tmpdir() }, root, 'source', { appReportedRoot: root })).toThrow('different source tree')
} finally { fs.rmSync(root, { recursive: true, force: true }) }
})
test('a backend bound to the tree by environment needs no root in argv', (): void => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'smoke-env-origin-'))
+4 -2
View File
@@ -337,8 +337,10 @@ export async function runInstalledDesktopSmoke(options: SmokeOptions, launchApp:
const identity = await readChatIdentity(page)
assertResolvedIdentity(identity, options, predictedHome)
// The ordinary host-attach path may reuse the updater's relaunched app's
// backend. Observe the ledger the app reads for this home (its machine root),
// not an arbitrary listener, and retain the OS listener + installed-tree proof below.
// backend, and `hermes gateway ensure` attaches to (or starts, detached) the
// canonical gateway. Observe the ledger and gateway_state.json the app's home
// resolves to (its machine root), not an arbitrary listener, and retain the
// OS listener + installed-tree proof below.
fs.writeFileSync(path.join(out, `desktop-backend-${options.phase}.log`), connection.logs.map(redact).join('\n'))
const backend = localBackendProcess(Number(base.port), running.pid, predictedHome ?? options.home)
// `identity.hermesRoot` was asserted against options.root above. For a listener that