Files
hermes-agent/apps/desktop/electron/parent-process-identity.ts
T
Teknium 95fa814269 feat(process): positive process identity — spawn tags, machine spawn ledger, Windows job-object self-attach
Every long-lived Hermes process is now positively identifiable so reapers
never have to guess lineage from PPID archaeology or cmdline shape:

- hermes_cli/process_identity.py (new): HERMES_SPAWN tag build/parse,
  spawn-ledger.json self-registration keyed on (pid, create_time) — PID
  reuse cannot forge the pair — with #89298-style corrupt-file quarantine,
  and a kill-on-close job-object self-attach (BREAKAWAY_OK preserved for
  the existing CREATE_BREAKAWAY_FROM_JOB escape hatches).
- serve/dashboard (web_server.py) and the gateway entry point register
  themselves at startup and attach to the job; Desktop legacy
  HERMES_PARENT_PID/winms marker reused as spawner identity so lineage
  works with every Desktop version.
- Desktop stamps HERMES_SPAWN on backend spawns (parent-process-identity.ts).
- hermes update gets a positive-identity rung ahead of the heuristic ones:
  _ledger_reapable_backend_pids reaps holders the ledger PROVES are orphaned
  backends (purpose reapable + recorded spawner provably dead) in ANY update
  context. Ledger-unknown holders fall through to the existing rungs.

22 new tests, sabotage-verified.
2026-08-20 04:47:38 -07:00

109 lines
3.3 KiB
TypeScript

export type ParentWatchdogEnv = {
HERMES_PARENT_PID: string
HERMES_PARENT_START_MARKER?: string
HERMES_PARENT_NONCE?: string
/** Spawn tag consumed by hermes_cli.process_identity (`v1:<install>:<purpose>:<spawner_pid>:<spawner_create_s>`).
* Install is `-` (unknown) from the Desktop — the Python side scopes by
* venv membership, so the tag only needs lineage, not install identity. */
HERMES_SPAWN?: string
}
export interface ParentStartMarkerResolverOptions {
load: () => Promise<string>
onError?: (error: unknown) => void
}
/**
* Build the cross-runtime marker for Electron's own process without spawning
* an OS helper. Electron reports milliseconds since the Unix epoch; the Python
* watchdog converts its exact Windows FILETIME to the same representation.
*/
export function electronProcessStartMarker(pid: number, ownPid: number, creationTime: unknown): string | null {
if (pid !== ownPid || typeof creationTime !== 'number' || !Number.isFinite(creationTime)) {
return null
}
const milliseconds = Math.trunc(creationTime)
if (!Number.isSafeInteger(milliseconds) || milliseconds <= 0) {
return null
}
return `winms:${milliseconds}`
}
/** Cache a successful parent marker while allowing a transient failure to retry. */
export function createParentStartMarkerResolver(options: ParentStartMarkerResolverOptions) {
let cached: Promise<string> | null = null
return async (): Promise<string | null> => {
const attempt = cached ?? Promise.resolve().then(options.load)
cached = attempt
try {
return await attempt
} catch (error) {
let shouldReport = false
if (cached === attempt) {
cached = null
shouldReport = true
}
if (shouldReport) {
try {
options.onError?.(error)
} catch {
// Diagnostics must not turn an optional identity probe into a boot gate.
}
}
return null
}
}
}
/**
* Keep the watchdog's marker and nonce atomic. A failed marker probe degrades
* to the legacy PID-only watchdog instead of preventing the backend spawn.
*/
export function parentWatchdogEnv(pid: number, startMarker: string | null, nonce: string): ParentWatchdogEnv {
if (!Number.isInteger(pid) || pid <= 0) {
throw new Error('Parent watchdog requires a positive process ID.')
}
const env: ParentWatchdogEnv = { HERMES_PARENT_PID: String(pid) }
env.HERMES_SPAWN = spawnTag(pid, startMarker)
if (startMarker === null) {
return env
}
if (!startMarker || !nonce) {
throw new Error('Parent watchdog marker and nonce must be non-empty.')
}
env.HERMES_PARENT_START_MARKER = startMarker
env.HERMES_PARENT_NONCE = nonce
return env
}
/** Build the `HERMES_SPAWN` tag mirrored by hermes_cli/process_identity.py.
* The Desktop only ever spawns backend servers, so purpose is `serve`; the
* spawner create-time is derived from the same `winms:` marker the parent
* watchdog uses (seconds, 3 decimals), `-` when the marker probe failed. */
export function spawnTag(pid: number, startMarker: string | null): string {
let createPart = '-'
if (startMarker?.startsWith('winms:')) {
const ms = Number(startMarker.slice('winms:'.length))
if (Number.isFinite(ms) && ms > 0) {
createPart = (ms / 1000).toFixed(3)
}
}
return `v1:-:serve:${pid}:${createPart}`
}