mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
fcace146e9
commit
9a6140877b
3 files changed
+121
-7
No files matched your search
@@ -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`)
|
||||
|
||||
@@ -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-'))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user