From e07835f1c78595576dc987881a71d0ae1859ac3c Mon Sep 17 00:00:00 2001 From: "Andrex Ibiza, MBA" Date: Fri, 21 Aug 2026 12:37:32 -0500 Subject: [PATCH] fix(desktop): bind teardown to retained process authority (#89614) Consolidate the verified Windows Job Object and POSIX retained nested-owner implementation onto current main as one exact commit. Preserve explicit transfer, typed stop outcomes, MCP/LSP subtree isolation, generation fencing, and the full topology proof surface while removing obsolete CI-only intermediate history. Refs #89614. Supersedes #89689. --- apps/desktop/electron/backend-child.ts | 270 ++++++++---- apps/desktop/electron/backend-env.test.ts | 5 +- apps/desktop/electron/backend-env.ts | 61 ++- .../backend-process-authority-env.test.ts | 88 ++++ .../electron/backend-stale-pid.test.ts | 140 ++++++ apps/desktop/electron/main.ts | 125 +----- .../electron/windows-child-options.test.ts | 155 +++---- hermes_cli/_posix_process_authority_state.py | 159 +++++++ hermes_cli/_posix_process_guard.py | 198 +++++++++ hermes_cli/_posix_process_nested.py | 232 ++++++++++ hermes_cli/_posix_process_transfer.py | 369 ++++++++++++++++ .../desktop_bootstrap/hermes_cli/__init__.py | 44 ++ .../desktop_bootstrap/hermes_cli/main.py | 63 +++ hermes_cli/desktop_bootstrap/sitecustomize.py | 32 ++ hermes_cli/posix_nested_owner.py | 286 +++++++++++++ hermes_cli/posix_process_authority.py | 257 +++++++++++ hermes_cli/posix_transfer_owner.py | 334 +++++++++++++++ hermes_cli/windows_process_authority.py | 373 ++++++++++++++++ ...est_desktop_process_authority_bootstrap.py | 138 ++++++ .../test_posix_nested_process_authority.py | 252 +++++++++++ .../test_posix_process_authority.py | 401 ++++++++++++++++++ .../test_windows_process_authority.py | 211 +++++++++ tools/mcp_stdio_watchdog.py | 48 ++- 23 files changed, 3929 insertions(+), 312 deletions(-) create mode 100644 apps/desktop/electron/backend-process-authority-env.test.ts create mode 100644 apps/desktop/electron/backend-stale-pid.test.ts create mode 100644 hermes_cli/_posix_process_authority_state.py create mode 100644 hermes_cli/_posix_process_guard.py create mode 100644 hermes_cli/_posix_process_nested.py create mode 100644 hermes_cli/_posix_process_transfer.py create mode 100644 hermes_cli/desktop_bootstrap/hermes_cli/__init__.py create mode 100644 hermes_cli/desktop_bootstrap/hermes_cli/main.py create mode 100644 hermes_cli/desktop_bootstrap/sitecustomize.py create mode 100644 hermes_cli/posix_nested_owner.py create mode 100644 hermes_cli/posix_process_authority.py create mode 100644 hermes_cli/posix_transfer_owner.py create mode 100644 hermes_cli/windows_process_authority.py create mode 100644 tests/hermes_cli/test_desktop_process_authority_bootstrap.py create mode 100644 tests/hermes_cli/test_posix_nested_process_authority.py create mode 100644 tests/hermes_cli/test_posix_process_authority.py create mode 100644 tests/hermes_cli/test_windows_process_authority.py diff --git a/apps/desktop/electron/backend-child.ts b/apps/desktop/electron/backend-child.ts index df720be5916f5..b14b887b67b75 100644 --- a/apps/desktop/electron/backend-child.ts +++ b/apps/desktop/electron/backend-child.ts @@ -1,103 +1,225 @@ /** - * backend-child.ts + * Fail-closed lifecycle control for Desktop-owned backend execution scopes. * - * Windows-aware teardown for the desktop's managed backend child process. - * - * Node's `child.kill()` only signals the direct child. On Windows a backend - * that spawned its own grandchildren (a `hermes` REPL, a pty terminal - * session, the gateway) survives a plain SIGTERM and keeps files (e.g. the - * venv shim) locked. So on Windows we tree-kill via `forceKillProcessTree`. - * - * On POSIX the backend IS spawned into its own session/process-group - * (start_new_session=True), so `child.kill('SIGTERM')` would only reach the - * backend and orphan its MCP grandchildren (the leak in #serve-orphans). We - * signal the whole group via `process.kill(-pid, ...)` instead, falling back - * to the direct child if the group send fails. - * - * Extracted into its own dependency-free module (no electron import) so the - * tree-kill / group-kill branching can be asserted directly with a fake child - * object and spy kill functions, instead of grepping main.ts source text for - * the function body. + * A numeric PID is observation, never destructive authority. Electron signals + * only the retained ChildProcess object. Platform authority installed before + * `hermes_cli.main` expands that retained-root signal to the complete owned + * scope: a Windows Job Object or a POSIX session supervisor. */ -export interface StopBackendChildDeps { - /** Defaults to the real platform check; injectable for tests. */ - isWindows?: boolean - /** Windows tree-kill implementation (real: taskkill /T /F via execFileSync). */ - forceKillProcessTree: (pid: number) => void - /** - * POSIX group-signal implementation. Real: process.kill(-pgid, signal). - * Injectable so the negative-pid group send is asserted in tests without a - * live process group. Defaults to process.kill. - */ - killGroup?: (pgid: number, signal: string) => void +export const STOP_REQUESTED = 'StopRequested' as const +export const STOP_EXITED = 'Exited' as const +export const STOP_ALREADY_EXITED = 'AlreadyExited' as const +export const STOP_NO_AUTHORITY = 'NoAuthority' as const +export const STOP_PERMISSION_DENIED = 'PermissionDenied' as const +export const STOP_TIMED_OUT = 'TimedOut' as const + +export type BackendStopKind = + | typeof STOP_REQUESTED + | typeof STOP_EXITED + | typeof STOP_ALREADY_EXITED + | typeof STOP_NO_AUTHORITY + | typeof STOP_PERMISSION_DENIED + | typeof STOP_TIMED_OUT + +export interface BackendStopResult { + readonly kind: BackendStopKind + readonly pid?: number | null + readonly exitCode?: number | null + readonly signalCode?: string | null + readonly detail?: string } -export interface StopBackendTreesForUpdateDeps { - /** Synchronous Windows taskkill /T /F implementation. */ - forceKillProcessTree: (pid: number) => void - /** Clears and stops the desktop's pooled backends. */ - stopAllPoolBackends: () => void +export class BackendStopError extends Error { + readonly result: BackendStopResult + + constructor(operation: string, result: BackendStopResult) { + super(`${operation} failed with ${result.kind}${result.detail ? `: ${result.detail}` : ''}`) + this.name = 'BackendStopError' + this.result = result + } } export interface BackendProcessRoot { pid?: number | null + exitCode?: null | number + signalCode?: null | string } export interface KillableChild extends BackendProcessRoot { killed?: boolean - kill: (signal: string) => void + kill: (signal?: NodeJS.Signals | number | null) => unknown + once?: (event: 'exit', listener: (code: number | null, signal: string | null) => void) => unknown + removeListener?: (event: 'exit', listener: (...args: any[]) => void) => unknown } -/** - * Stop a managed child process, choosing the right strategy for the platform. - * No-ops silently if `child` is falsy, already killed, or the kill attempt - * throws (the process may already be gone) -- mirrors the original inline - * best-effort semantics in main.ts. - */ -export function stopBackendChild(child: KillableChild | null | undefined, deps: StopBackendChildDeps) { - if (!child || child.killed) { - return - } +export interface StopBackendTreesForUpdateDeps { + /** Stops pooled backends through their retained ChildProcess owners. */ + stopAllPoolBackends: () => Promise | void +} - const isWindows = deps.isWindows ?? process.platform === 'win32' - const killGroup = deps.killGroup ?? ((pgid: number, signal: string) => process.kill(pgid, signal)) +function snapshot(child: KillableChild | null | undefined): Omit { + return child + ? { + pid: child.pid, + exitCode: child.exitCode, + signalCode: child.signalCode + } + : {} +} + +/** Missing lifecycle fields mean no authority, never a legacy fallback. */ +export function isLiveProcessRoot(root: BackendProcessRoot | null | undefined): boolean { + return Boolean( + root && + Number.isInteger(root.pid) && + (root.pid as number) > 0 && + root.exitCode === null && + root.signalCode === null + ) +} + +function signalRetainedChild( + child: KillableChild | null | undefined, + signal: NodeJS.Signals +): BackendStopResult { + if (!child) { + return { kind: STOP_ALREADY_EXITED } + } + if (typeof child.kill !== 'function') { + return { kind: STOP_NO_AUTHORITY, ...snapshot(child) } + } + if (child.exitCode != null || child.signalCode != null) { + return { kind: STOP_ALREADY_EXITED, ...snapshot(child) } + } + if (!isLiveProcessRoot(child)) { + return { kind: STOP_NO_AUTHORITY, ...snapshot(child) } + } try { - if (isWindows && Number.isInteger(child.pid)) { - deps.forceKillProcessTree(child.pid as number) - } else if (Number.isInteger(child.pid)) { - // POSIX: pgid == pid (start_new_session). Signal the whole group so MCP - // grandchildren die too; fall back to the direct child on failure. - try { - killGroup(-(child.pid as number), 'SIGTERM') - } catch { - child.kill('SIGTERM') + if (child.kill(signal) === false) { + return { + kind: STOP_PERMISSION_DENIED, + detail: `retained ChildProcess refused ${signal}`, + ...snapshot(child) } - } else { - child.kill('SIGTERM') } - } catch { - // Already gone. + // Signal submission is not exit observation. The retained owner remains + // live until its exit event or populated exit fields prove otherwise. + return { + kind: STOP_REQUESTED, + detail: `submitted ${signal} to retained owner`, + ...snapshot(child) + } + } catch (error) { + return { + kind: STOP_PERMISSION_DENIED, + detail: error instanceof Error ? error.message : String(error), + ...snapshot(child) + } + } +} + +function requireStopSubmission(operation: string, result: BackendStopResult): BackendStopResult { + if (result.kind === STOP_NO_AUTHORITY || result.kind === STOP_PERMISSION_DENIED) { + // Existing lifecycle call sites may ignore a compatibility return value, + // but they can no longer silently discard a hard authority failure. + throw new BackendStopError(operation, result) } + return result +} + +/** Graceful stop through the retained owner only. */ +export function requestBackendGracefulStop( + child: KillableChild | null | undefined +): BackendStopResult { + return signalRetainedChild(child, 'SIGTERM') } /** - * Stop every backend tree owned by a Windows Desktop update hand-off. - * - * Tree-kill the primary root while its PID is still live, then delegate pool - * teardown to the existing routine that tree-kills each pooled root exactly - * once before mutating its registry. In particular, do not signal the primary - * first: if that root exits before taskkill /T runs, Windows can no longer - * enumerate its MCP grandchildren and they survive with the venv locked. + * Forced stop through the same retained owner. POSIX uses SIGUSR2 as the + * supervisor's non-PID force command; Windows uses SIGKILL, whose root exit + * closes the generation-bound Job and reaps descendants. */ -export function stopBackendTreesForUpdate( - primary: BackendProcessRoot | null | undefined, - deps: StopBackendTreesForUpdateDeps -): void { - if (primary && Number.isInteger(primary.pid)) { - deps.forceKillProcessTree(primary.pid as number) +export function requestBackendForceStop( + child: KillableChild | null | undefined, + platform = process.platform +): BackendStopResult { + const signal: NodeJS.Signals = platform === 'win32' ? 'SIGKILL' : 'SIGUSR2' + return signalRetainedChild(child, signal) +} + +/** Compatibility entry point that preserves typed outcomes and fails loudly. */ +export function stopBackendChild( + child: KillableChild | null | undefined +): BackendStopResult { + return requireStopSubmission('graceful backend stop', requestBackendGracefulStop(child)) +} + +/** Compatibility entry point that preserves typed outcomes and fails loudly. */ +export function forceStopBackendChild( + child: KillableChild | null | undefined, + platform = process.platform +): BackendStopResult { + return requireStopSubmission('forced backend stop', requestBackendForceStop(child, platform)) +} + +function waitForExit( + child: KillableChild | null | undefined, + timeoutMs: number +): Promise { + if (!child) { + return Promise.resolve({ kind: STOP_ALREADY_EXITED }) + } + if (typeof child.once !== 'function') { + return Promise.resolve({ kind: STOP_NO_AUTHORITY, ...snapshot(child) }) + } + if (!isLiveProcessRoot(child)) { + return Promise.resolve({ kind: STOP_ALREADY_EXITED, ...snapshot(child) }) + } + + return new Promise(resolve => { + const onExit = (code: number | null, signal: string | null) => { + clearTimeout(timer) + child.exitCode = code + child.signalCode = signal + resolve({ kind: STOP_EXITED, ...snapshot(child) }) + } + const timer = setTimeout(() => { + child.removeListener?.('exit', onExit) + resolve({ kind: STOP_TIMED_OUT, ...snapshot(child) }) + }, Math.max(0, Math.min(Math.trunc(timeoutMs), 120_000))) + child.once?.('exit', onExit) + }) +} + +/** Graceful -> bounded wait -> force -> terminal confirmation. */ +export async function stopBackendChildAndWait( + child: KillableChild | null | undefined, + options: { gracefulTimeoutMs?: number; forceTimeoutMs?: number; platform?: NodeJS.Platform } = {} +): Promise { + const graceful = requestBackendGracefulStop(child) + if (graceful.kind !== STOP_REQUESTED) { + return graceful } - deps.stopAllPoolBackends() + const gracefulExit = await waitForExit(child, options.gracefulTimeoutMs ?? 5_000) + if (gracefulExit.kind !== STOP_TIMED_OUT) { + return gracefulExit + } + + const forced = requestBackendForceStop(child, options.platform ?? process.platform) + if (forced.kind !== STOP_REQUESTED) { + return forced + } + return waitForExit(child, options.forceTimeoutMs ?? 2_000) +} + +export async function stopBackendTreesForUpdate( + primary: KillableChild | null | undefined, + deps: StopBackendTreesForUpdateDeps +): Promise { + const primaryResult = stopBackendChild(primary) + await deps.stopAllPoolBackends() + return primaryResult } diff --git a/apps/desktop/electron/backend-env.test.ts b/apps/desktop/electron/backend-env.test.ts index 986f164f9e1de..ee91116a12af4 100644 --- a/apps/desktop/electron/backend-env.test.ts +++ b/apps/desktop/electron/backend-env.test.ts @@ -118,7 +118,10 @@ test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () = pathModule: path.posix }) - assert.equal(env.PYTHONPATH, '/repo/hermes-agent:/existing/pythonpath') + assert.equal( + env.PYTHONPATH, + '/repo/hermes-agent/hermes_cli/desktop_bootstrap:/repo/hermes-agent:/existing/pythonpath' + ) assert.ok( env.PATH.startsWith( '/Users/test/.hermes/node/bin:/Users/test/.hermes/node:/Users/test/.hermes/hermes-agent/venv/bin:' diff --git a/apps/desktop/electron/backend-env.ts b/apps/desktop/electron/backend-env.ts index 24d6928bad099..5a2e47a2553b5 100644 --- a/apps/desktop/electron/backend-env.ts +++ b/apps/desktop/electron/backend-env.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto' import path from 'node:path' // Match the POSIX fallback surface used by the Python terminal environment. @@ -14,6 +15,18 @@ const POSIX_SANE_PATH_ENTRIES = Object.freeze([ '/bin' ]) +export const WINDOWS_PROCESS_AUTHORITY_MODE = 'windows-job-v1' +export const POSIX_PROCESS_AUTHORITY_MODE = 'posix-session-v1' +export const DESKTOP_PROCESS_AUTHORITY_ENV = 'HERMES_DESKTOP_PROCESS_AUTHORITY' +export const DESKTOP_PROCESS_GENERATION_ENV = 'HERMES_DESKTOP_PROCESS_GENERATION' +export const DESKTOP_PARENT_PID_ENV = 'HERMES_DESKTOP_PARENT_PID' +export const DESKTOP_PARENT_STARTED_AT_ENV = 'HERMES_DESKTOP_PARENT_STARTED_AT_MS' + +// Capture this once for the Electron generation. Re-deriving Date.now() - +// process.uptime() at every backend spawn makes an NTP jump look like a parent +// generation change and fail-closes every subsequent launch until app restart. +const DESKTOP_PARENT_STARTED_AT_MS = Math.round(Date.now() - process.uptime() * 1000) + function delimiterForPlatform(platform = process.platform) { return platform === 'win32' ? ';' : ':' } @@ -64,15 +77,10 @@ function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) { * Hermes-managed Node.js directories, in preferred lookup order. * * There are two on-disk layouts. `scripts/install.ps1` unpacks portable Node - * straight into `%LOCALAPPDATA%\hermes\node` (node.exe at the root, no `bin\`); + * straight into `%LOCALAPPDATA%\\hermes\\node` (node.exe at the root, no `bin\\`); * `scripts/install.sh` and the node-bootstrap helper use the POSIX * `$HERMES_HOME/node/bin`. Emit BOTH on every platform so mixed and migrated * installs resolve, leading with the layout native to the current platform. - * - * This is the single source of truth for the ordering rule on the Node side — - * `main.ts` imports it rather than keeping its own copy. Mirrors - * `iter_hermes_node_dirs()` in hermes_constants.py, which the Electron main - * process cannot import. */ function hermesManagedNodePathEntries( hermesHome, @@ -118,26 +126,51 @@ function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatfor return resolved } +/** + * Python imports `sitecustomize` before `hermes_cli.main`. Desktop prepends + * this scoped directory on every native platform so the process authority is + * armed before Hermes imports any code capable of spawning descendants. + */ +function desktopProcessAuthorityBootstrapDirs( + pythonPathEntries, + { pathModule = pathModuleForPlatform(process.platform) }: any = {} +) { + return pythonPathEntries + .filter(Boolean) + .map(root => pathModule.join(root, 'hermes_cli', 'desktop_bootstrap')) +} + function buildDesktopBackendEnv({ hermesHome, pythonPathEntries = [], venvRoot, currentEnv = process.env, platform = process.platform, - pathModule = pathModuleForPlatform(platform) + pathModule = pathModuleForPlatform(platform), + authorityGeneration = randomUUID(), + parentPid = process.pid, + parentStartedAtMs = DESKTOP_PARENT_STARTED_AT_MS }: any = {}) { const delimiter = delimiterForPlatform(platform) const currentPythonPath = currentEnv?.PYTHONPATH || '' const key = pathEnvKey(currentEnv, platform) + const authorityBootstrapDirs = desktopProcessAuthorityBootstrapDirs(pythonPathEntries, { + pathModule + }) + const authorityMode = + platform === 'win32' ? WINDOWS_PROCESS_AUTHORITY_MODE : POSIX_PROCESS_AUTHORITY_MODE return { - PYTHONPATH: appendUniquePathEntries([...pythonPathEntries, currentPythonPath], { delimiter }), + [DESKTOP_PARENT_PID_ENV]: String(parentPid), + [DESKTOP_PARENT_STARTED_AT_ENV]: String(parentStartedAtMs), + [DESKTOP_PROCESS_AUTHORITY_ENV]: authorityMode, + [DESKTOP_PROCESS_GENERATION_ENV]: String(authorityGeneration), + PYTHONPATH: appendUniquePathEntries( + [authorityBootstrapDirs, pythonPathEntries, currentPythonPath], + { delimiter } + ), // Force PEP 540 UTF-8 mode in the spawned Python backend so its stdio and - // subprocess defaults are UTF-8 even on non-UTF-8 Windows locales (GBK, - // cp1252, ...). hermes_bootstrap sets this inside the child too, but only - // after import — anything emitted earlier (interpreter startup errors, - // pre-bootstrap tracebacks) still decodes with the locale default without - // this. User's explicit setting wins. Re-port of PR #56499 (echoriver89). + // subprocess defaults are UTF-8 even on non-UTF-8 Windows locales. PYTHONUTF8: currentEnv?.PYTHONUTF8 ?? '1', [key]: buildDesktopBackendPath({ hermesHome, @@ -154,6 +187,8 @@ export { buildDesktopBackendEnv, buildDesktopBackendPath, delimiterForPlatform, + DESKTOP_PARENT_STARTED_AT_MS, + desktopProcessAuthorityBootstrapDirs, hermesManagedNodePathEntries, normalizeHermesHomeRoot, pathEnvKey, diff --git a/apps/desktop/electron/backend-process-authority-env.test.ts b/apps/desktop/electron/backend-process-authority-env.test.ts new file mode 100644 index 0000000000000..8746b5a3b58d1 --- /dev/null +++ b/apps/desktop/electron/backend-process-authority-env.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict' +import path from 'node:path' + +import { test } from 'vitest' + +import { + buildDesktopBackendEnv, + DESKTOP_PARENT_PID_ENV, + DESKTOP_PARENT_STARTED_AT_ENV, + DESKTOP_PROCESS_AUTHORITY_ENV, + DESKTOP_PROCESS_GENERATION_ENV, + desktopProcessAuthorityBootstrapDirs, + POSIX_PROCESS_AUTHORITY_MODE, + WINDOWS_PROCESS_AUTHORITY_MODE +} from './backend-env' + +const GENERATION = 'e2f531e4-14b1-47ff-9f87-bc278cfa816d' + +test('Windows backend launch carries a generation-bound Job authority envelope', () => { + const root = 'C:\\repo\\hermes-agent' + const env = buildDesktopBackendEnv({ + authorityGeneration: GENERATION, + currentEnv: { Path: 'C:\\Windows\\System32' }, + hermesHome: 'C:\\Users\\test\\AppData\\Local\\hermes', + parentPid: 4242, + parentStartedAtMs: 1_700_000_000_123, + pathModule: path.win32, + platform: 'win32', + pythonPathEntries: [root], + venvRoot: 'C:\\Users\\test\\AppData\\Local\\hermes\\hermes-agent\\venv' + }) + + assert.equal(env[DESKTOP_PROCESS_AUTHORITY_ENV], WINDOWS_PROCESS_AUTHORITY_MODE) + assert.equal(env[DESKTOP_PROCESS_GENERATION_ENV], GENERATION) + assert.equal(env[DESKTOP_PARENT_PID_ENV], '4242') + assert.equal(env[DESKTOP_PARENT_STARTED_AT_ENV], '1700000000123') + assert.equal( + env.PYTHONPATH.split(';')[0], + 'C:\\repo\\hermes-agent\\hermes_cli\\desktop_bootstrap' + ) +}) + +test('POSIX backend launch arms the scoped session authority before imports', () => { + const root = '/repo/hermes-agent' + const env = buildDesktopBackendEnv({ + authorityGeneration: GENERATION, + currentEnv: { PATH: '/usr/bin' }, + hermesHome: '/Users/test/.hermes', + parentPid: 4242, + parentStartedAtMs: 1_700_000_000_123, + pathModule: path.posix, + platform: 'darwin', + pythonPathEntries: [root], + venvRoot: '/Users/test/.hermes/hermes-agent/venv' + }) + + assert.equal(env[DESKTOP_PROCESS_AUTHORITY_ENV], POSIX_PROCESS_AUTHORITY_MODE) + assert.equal(env[DESKTOP_PROCESS_GENERATION_ENV], GENERATION) + assert.equal(env[DESKTOP_PARENT_PID_ENV], '4242') + assert.equal(env[DESKTOP_PARENT_STARTED_AT_ENV], '1700000000123') + assert.deepEqual(desktopProcessAuthorityBootstrapDirs([root], { pathModule: path.posix }), [ + '/repo/hermes-agent/hermes_cli/desktop_bootstrap' + ]) + assert.equal(env.PYTHONPATH.split(':')[0], '/repo/hermes-agent/hermes_cli/desktop_bootstrap') + assert.equal(env.PYTHONPATH.split(':')[1], root) +}) + +test('default Electron parent marker is stable across backend generations', () => { + const first = buildDesktopBackendEnv({ + currentEnv: { PATH: '/usr/bin' }, + hermesHome: '/tmp/hermes', + pathModule: path.posix, + platform: 'linux', + pythonPathEntries: ['/repo'], + venvRoot: '/tmp/hermes/venv' + }) + const second = buildDesktopBackendEnv({ + currentEnv: { PATH: '/usr/bin' }, + hermesHome: '/tmp/hermes', + pathModule: path.posix, + platform: 'linux', + pythonPathEntries: ['/repo'], + venvRoot: '/tmp/hermes/venv' + }) + + assert.notEqual(first[DESKTOP_PROCESS_GENERATION_ENV], second[DESKTOP_PROCESS_GENERATION_ENV]) + assert.equal(first[DESKTOP_PARENT_STARTED_AT_ENV], second[DESKTOP_PARENT_STARTED_AT_ENV]) +}) diff --git a/apps/desktop/electron/backend-stale-pid.test.ts b/apps/desktop/electron/backend-stale-pid.test.ts new file mode 100644 index 0000000000000..f95eb2b02e4c0 --- /dev/null +++ b/apps/desktop/electron/backend-stale-pid.test.ts @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { + BackendStopError, + forceStopBackendChild, + isLiveProcessRoot, + requestBackendForceStop, + requestBackendGracefulStop, + STOP_ALREADY_EXITED, + STOP_NO_AUTHORITY, + STOP_PERMISSION_DENIED, + STOP_REQUESTED, + stopBackendChild, + stopBackendTreesForUpdate +} from './backend-child' + +const live = (pid: number, kill: (signal: NodeJS.Signals) => unknown = () => true) => ({ + exitCode: null, + kill, + killed: false, + pid, + signalCode: null +}) + +const exited = (pid: number, kill: (signal: NodeJS.Signals) => unknown = () => true, code = 0) => ({ + exitCode: code, + kill, + killed: false, + pid, + signalCode: null +}) + +const signalled = ( + pid: number, + kill: (signal: NodeJS.Signals) => unknown = () => true, + signalCode = 'SIGTERM' +) => ({ + exitCode: null, + kill, + killed: true, + pid, + signalCode +}) + +test('only an explicit live retained owner is actionable', () => { + assert.equal(isLiveProcessRoot(live(4242)), true) + assert.equal(isLiveProcessRoot(exited(4242)), false) + assert.equal(isLiveProcessRoot(signalled(4242)), false) + assert.equal(isLiveProcessRoot({ pid: 4242 }), false) + assert.equal(isLiveProcessRoot({ exitCode: null, pid: 0, signalCode: null }), false) + assert.equal(isLiveProcessRoot({ exitCode: null, pid: -991, signalCode: null }), false) +}) + +test('a reaped owner with a populated PID is never signalled', () => { + const signals: string[] = [] + const child = exited(4242, signal => signals.push(signal)) + + assert.equal(stopBackendChild(child).kind, STOP_ALREADY_EXITED) + assert.equal(requestBackendGracefulStop(child).kind, STOP_ALREADY_EXITED) + assert.deepEqual(signals, []) +}) + +test('PID-only residue raises instead of silently discarding no authority', () => { + const pidOnly = { exitCode: null, pid: 9999, signalCode: null } + + assert.throws( + () => stopBackendChild(pidOnly as any), + error => error instanceof BackendStopError && error.result.kind === STOP_NO_AUTHORITY + ) + assert.equal(requestBackendGracefulStop(pidOnly as any).kind, STOP_NO_AUTHORITY) +}) + +test('signal submission is StopRequested, never a fabricated exit observation', () => { + const signals: string[] = [] + const child = live(1234, signal => signals.push(signal)) + + assert.equal(stopBackendChild(child).kind, STOP_REQUESTED) + assert.equal(requestBackendGracefulStop(live(1235)).kind, STOP_REQUESTED) + assert.equal(child.exitCode, null) + assert.equal(child.signalCode, null) + assert.deepEqual(signals, ['SIGTERM']) +}) + +test('child.killed records signal submission, not exit, so force escalation remains actionable', () => { + const signals: string[] = [] + const child = { ...live(1236, signal => signals.push(signal)), killed: true } + + assert.equal(requestBackendForceStop(child, 'linux').kind, STOP_REQUESTED) + assert.deepEqual(signals, ['SIGUSR2']) +}) + +test('a missing retained child is an already-complete no-op', () => { + assert.equal(stopBackendChild(null).kind, STOP_ALREADY_EXITED) +}) + +test('forced stop uses the retained platform authority command', () => { + const windowsSignals: string[] = [] + const posixSignals: string[] = [] + + assert.equal( + forceStopBackendChild(live(1, signal => windowsSignals.push(signal)), 'win32').kind, + STOP_REQUESTED + ) + assert.equal( + forceStopBackendChild(live(2, signal => posixSignals.push(signal)), 'linux').kind, + STOP_REQUESTED + ) + assert.equal(requestBackendForceStop(live(3), 'darwin').kind, STOP_REQUESTED) + assert.deepEqual(windowsSignals, ['SIGKILL']) + assert.deepEqual(posixSignals, ['SIGUSR2']) +}) + +test('child.kill failures remain typed and compatibility calls fail loudly', () => { + const child = live(9, () => { + throw new Error('EPERM') + }) + + assert.throws( + () => stopBackendChild(child), + error => error instanceof BackendStopError && error.result.kind === STOP_PERMISSION_DENIED + ) + assert.equal(requestBackendGracefulStop(child).kind, STOP_PERMISSION_DENIED) + assert.match(requestBackendGracefulStop(child).detail || '', /EPERM/) +}) + +test('update teardown submits the retained primary stop before stopping the pool', async () => { + const calls: string[] = [] + const primaryChild = live(2002, signal => calls.push(`primary:${signal}`)) + + const result = await stopBackendTreesForUpdate(primaryChild, { + stopAllPoolBackends: () => { + calls.push('pool-stopped') + } + }) + + assert.equal(result.kind, STOP_REQUESTED) + assert.deepEqual(calls, ['primary:SIGTERM', 'pool-stopped']) +}) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index adf7338ef5141..48a8e387f0fbb 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -31,7 +31,11 @@ import { } from 'electron' import { classifyActiveRuntime } from './active-runtime-state' -import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } from './backend-child' +import { + forceStopBackendChild as forceStopBackendChildImpl, + stopBackendChild as stopBackendChildImpl, + stopBackendTreesForUpdate +} from './backend-child' import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command' import { createBackendConnectionState } from './backend-connection-state' import { buildDesktopBackendEnv, hermesManagedNodePathEntries, normalizeHermesHomeRoot } from './backend-env' @@ -3070,31 +3074,6 @@ function isShimLocked(shimPath) { } } -// Force-kill the entire process TREE rooted at each PID. Node's child.kill() -// only signals the direct child, so on Windows a backend `hermes.exe` that -// spawned its own grandchildren (a `hermes` REPL, a pty terminal session, the -// gateway) would survive and keep the venv shim locked. taskkill /T /F reaps -// the whole tree synchronously. Windows-only: this is called solely from the -// Windows shim-unlock path, and the backend is NOT spawned detached (so it's -// not a process-group leader — a POSIX negative-pgid kill would be meaningless -// here anyway). POSIX teardown stays with the existing before-quit SIGTERM. -function forceKillProcessTree(pid) { - if (!IS_WINDOWS) { - return - } - - if (!Number.isInteger(pid) || pid <= 0) { - return - } - - try { - execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], hiddenWindowsChildOptions({ stdio: 'ignore' })) - } catch { - // Already gone, or no permission — best effort; the unlock wait below is - // the real gate. - } -} - function writeBackendOwnership(contents) { fs.mkdirSync(path.dirname(DESKTOP_BACKEND_OWNERSHIP_PATH), { recursive: true }) const tempPath = `${DESKTOP_BACKEND_OWNERSHIP_PATH}.${process.pid}.tmp` @@ -3236,46 +3215,14 @@ async function stopOwnedBackend(identity) { return } - if (IS_WINDOWS) { - forceKillProcessTree(identity.pid) - } else { - try { - process.kill(-identity.pid, 'SIGTERM') - } catch { - try { - process.kill(identity.pid, 'SIGTERM') - } catch { - return - } - } - - const deadline = Date.now() + 1500 - - while (Date.now() < deadline) { - if ((await processIdentityMatches(identity)) !== true) { - return - } - - await new Promise(resolve => setTimeout(resolve, 50)) - } - - // Revalidate immediately before escalation so PID reuse cannot target a - // replacement process. - if ((await processIdentityMatches(identity)) === true) { - try { - process.kill(-identity.pid, 'SIGKILL') - } catch { - process.kill(identity.pid, 'SIGKILL') - } - } - } - - await new Promise(resolve => setTimeout(resolve, 50)) - const remaining = await processIdentityMatches(identity) - - if (remaining !== false) { - throw new Error(`Backend PID ${identity.pid} did not stop cleanly.`) - } + // Persisted identity is evidence that the process still looks like Hermes; + // it is not the retained capability that created/adopted that process. Keep + // the ownership record so a later authority-aware recovery can report it, + // but never turn the PID back into kill authority (#89614). + throw Object.assign( + new Error(`Refusing to stop backend PID ${identity.pid}: no retained process authority.`), + { code: 'NO_PROCESS_AUTHORITY' } + ) } const backendOwnership = createBackendOwnership({ @@ -3412,10 +3359,8 @@ async function releaseBackendLock(updateRoot, tag) { const hermesProcess = backendConnectionState.getProcess() - stopBackendTreesForUpdate(hermesProcess, { - forceKillProcessTree, - stopAllPoolBackends - }) + await stopBackendTreesForUpdate(hermesProcess, { stopAllPoolBackends }) + await waitForBackendExit(hermesProcess) const shim = venvHermesShimPath(updateRoot) const deadlineMs = Date.now() + 15000 @@ -3427,27 +3372,9 @@ async function releaseBackendLock(updateRoot, tag) { return { unlocked: true } } - // A supervised backend can respawn between kill and check (grandchildren, - // pool entries registered mid-teardown). Re-collect and re-kill each pass - // instead of trusting the initial sweep. - const stragglers = [] - - const currentHermesProcess = backendConnectionState.getProcess() - - if (currentHermesProcess && Number.isInteger(currentHermesProcess.pid)) { - stragglers.push(currentHermesProcess.pid) - } - - for (const entry of backendPool.values()) { - if (entry.process && Number.isInteger(entry.process.pid)) { - stragglers.push(entry.process.pid) - } - } - - for (const pid of stragglers) { - forceKillProcessTree(pid) - } - + // The lock is observation only. Descendants or foreign processes may + // remain after retained-child shutdown; never rediscover authority from + // their PIDs. The timeout below aborts before replacement (#89614). await new Promise(r => setTimeout(r, 300)) } @@ -9734,7 +9661,7 @@ function resetBootProgressForReconnect() { } function stopBackendChild(child) { - stopBackendChildImpl(child, { forceKillProcessTree, isWindows: IS_WINDOWS }) + stopBackendChildImpl(child) } // Soft gateway-mode apply: tear down the primary without resetting boot UI or @@ -9833,19 +9760,7 @@ async function waitForBackendExit(child, timeoutMs = 5000) { return } - try { - if (IS_WINDOWS && Number.isInteger(child.pid)) { - forceKillProcessTree(child.pid) - } else if (Number.isInteger(child.pid)) { - try { - process.kill(-child.pid, 'SIGKILL') - } catch { - child.kill('SIGKILL') - } - } else { - child.kill('SIGKILL') - } - } catch { + if (!forceStopBackendChildImpl(child)) { return } diff --git a/apps/desktop/electron/windows-child-options.test.ts b/apps/desktop/electron/windows-child-options.test.ts index a68b9dfc5e79b..813c12a0e95b6 100644 --- a/apps/desktop/electron/windows-child-options.test.ts +++ b/apps/desktop/electron/windows-child-options.test.ts @@ -2,7 +2,18 @@ import assert from 'node:assert/strict' import { test } from 'vitest' -import { stopBackendChild, stopBackendTreesForUpdate } from './backend-child' +import { + BackendStopError, + forceStopBackendChild, + type KillableChild, + requestBackendForceStop, + requestBackendGracefulStop, + STOP_ALREADY_EXITED, + STOP_NO_AUTHORITY, + STOP_REQUESTED, + stopBackendChild, + stopBackendTreesForUpdate +} from './backend-child' import { hiddenWindowsChildOptions } from './windows-child-options' test('hiddenWindowsChildOptions adds windowsHide:true on Windows when unset', () => { @@ -39,130 +50,64 @@ test('hiddenWindowsChildOptions defaults isWindows from process.platform when om function makeChild(overrides: Partial<{ pid: number | null; killed: boolean }> = {}) { const calls: string[] = [] - - return { - calls, - child: { - kill: (signal: string) => { - calls.push(signal) - }, - killed: overrides.killed ?? false, - pid: 'pid' in overrides ? overrides.pid : 1234 - } + const child: KillableChild = { + exitCode: null, + kill: signal => { + calls.push(String(signal)) + }, + killed: overrides.killed ?? false, + pid: 'pid' in overrides ? overrides.pid : 1234, + signalCode: null } -} - -test('stopBackendChild tree-kills on Windows when the child has a pid', () => { - const { child, calls } = makeChild({ pid: 4242 }) - const treeKillCalls: number[] = [] - - stopBackendChild(child, { - forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), - isWindows: true - }) - assert.deepEqual(treeKillCalls, [4242]) - assert.deepEqual(calls, [], 'SIGTERM must not be sent when the Windows tree-kill path is taken') -}) + return { calls, child } +} -test('stopBackendChild group-SIGTERMs on POSIX (negative pgid) when the child has a pid', () => { +test('graceful stop reports accepted submission, not fabricated exit', () => { const { child, calls } = makeChild({ pid: 4242 }) - const treeKillCalls: number[] = [] - const groupKills: Array<[number, string]> = [] - stopBackendChild(child, { - forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), - isWindows: false, - killGroup: (pgid, signal) => groupKills.push([pgid, signal]) - }) - - assert.deepEqual(groupKills, [[-4242, 'SIGTERM']], 'must signal the whole process group') - assert.deepEqual(calls, [], 'direct child.kill must not run when the group send succeeds') - assert.deepEqual(treeKillCalls, [], 'tree-kill must not run off Windows') + assert.equal(stopBackendChild(child).kind, STOP_REQUESTED) + assert.equal(requestBackendGracefulStop(makeChild().child).kind, STOP_REQUESTED) + assert.deepEqual(calls, ['SIGTERM']) }) -test('stopBackendChild falls back to direct SIGTERM on POSIX when the group send throws', () => { - const { child, calls } = makeChild({ pid: 4242 }) +test('force stop selects the retained Windows Job or POSIX supervisor command', () => { + const windows = makeChild({ pid: 100 }) + const posix = makeChild({ pid: 200 }) - stopBackendChild(child, { - forceKillProcessTree: () => {}, - isWindows: false, - killGroup: () => { - throw new Error('ESRCH: no such process group') - } - }) - - assert.deepEqual(calls, ['SIGTERM'], 'must fall back to signalling the direct child') + assert.equal(forceStopBackendChild(windows.child, 'win32').kind, STOP_REQUESTED) + assert.equal(forceStopBackendChild(posix.child, 'linux').kind, STOP_REQUESTED) + assert.equal(requestBackendForceStop(makeChild().child, 'win32').kind, STOP_REQUESTED) + assert.deepEqual(windows.calls, ['SIGKILL']) + assert.deepEqual(posix.calls, ['SIGUSR2']) }) -test('stopBackendChild falls back to SIGTERM on Windows when the pid is not an integer', () => { - const { child, calls } = makeChild({ pid: null }) - const treeKillCalls: number[] = [] - - stopBackendChild(child, { - forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), - isWindows: true - }) +test('PID-only records fail loudly while already-terminal children are truthful', () => { + const pidOnly = { pid: 99 } as KillableChild + assert.equal(requestBackendGracefulStop(pidOnly).kind, STOP_NO_AUTHORITY) + assert.throws( + () => stopBackendChild(pidOnly), + error => error instanceof BackendStopError && error.result.kind === STOP_NO_AUTHORITY + ) - assert.deepEqual(calls, ['SIGTERM']) - assert.deepEqual(treeKillCalls, []) -}) - -test('stopBackendChild is a no-op for an already-killed child', () => { const { child, calls } = makeChild({ killed: true }) - const treeKillCalls: number[] = [] - - stopBackendChild(child, { - forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), - isWindows: true - }) - + child.signalCode = 'SIGTERM' + assert.equal(stopBackendChild(child).kind, STOP_ALREADY_EXITED) + assert.equal(requestBackendGracefulStop(child).kind, STOP_ALREADY_EXITED) assert.deepEqual(calls, []) - assert.deepEqual(treeKillCalls, []) -}) - -test('stopBackendChild is a no-op for a null/undefined child', () => { - const treeKillCalls: number[] = [] - - assert.doesNotThrow(() => { - stopBackendChild(null, { forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), isWindows: true }) - stopBackendChild(undefined, { forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), isWindows: true }) - }) - assert.deepEqual(treeKillCalls, []) }) -test('stopBackendChild swallows errors thrown by the kill strategy', () => { - const child = { - kill: () => { - throw new Error('ESRCH: no such process') - }, - killed: false, - pid: 99 - } - - assert.doesNotThrow(() => { - stopBackendChild(child, { - forceKillProcessTree: () => {}, - isWindows: false - }) - }) -}) - -test('Windows update tree-kills captured roots without pre-signalling the primary backend', () => { - const primary = makeChild({ pid: 101 }) - const pooled = makeChild({ pid: 202 }) +test('update teardown preserves typed submission and has no bare-PID tree-kill path', async () => { const events: string[] = [] + const primary = makeChild({ pid: 101 }) - stopBackendTreesForUpdate(primary.child, { - forceKillProcessTree: pid => events.push(`tree:${pid}`), + const result = await stopBackendTreesForUpdate(primary.child, { stopAllPoolBackends: () => { events.push('pool-stop') - // Production stopAllPoolBackends() already tree-kills every pool root. - events.push(`tree:${pooled.child.pid}`) } }) - assert.deepEqual(events, ['tree:101', 'pool-stop', 'tree:202']) - assert.deepEqual(primary.calls, [], 'the primary root must not be signalled before taskkill /T sees it') - assert.deepEqual(pooled.calls, []) + assert.equal(result.kind, STOP_REQUESTED) + assert.deepEqual(events, ['pool-stop']) + assert.deepEqual(primary.calls, ['SIGTERM']) }) diff --git a/hermes_cli/_posix_process_authority_state.py b/hermes_cli/_posix_process_authority_state.py new file mode 100644 index 0000000000000..c22c47fe771aa --- /dev/null +++ b/hermes_cli/_posix_process_authority_state.py @@ -0,0 +1,159 @@ +"""Shared state and contracts for retained POSIX process authority.""" + +from __future__ import annotations + +import os +import re +import signal +import subprocess +import threading +import time +from dataclasses import dataclass +from typing import Any, Mapping, MutableMapping + +AUTHORITY_MODE = "posix-session-v1" +AUTHORITY_MODE_ENV = "HERMES_DESKTOP_PROCESS_AUTHORITY" +GENERATION_ENV = "HERMES_DESKTOP_PROCESS_GENERATION" +PARENT_PID_ENV = "HERMES_DESKTOP_PARENT_PID" +PARENT_STARTED_AT_ENV = "HERMES_DESKTOP_PARENT_STARTED_AT_MS" +ROLE_ENV = "HERMES_DESKTOP_PROCESS_ROLE" +LIFETIME_ENV = "HERMES_DESKTOP_PROCESS_LIFETIME" +TRANSFER_RECEIPT_ENV = "HERMES_DESKTOP_PROCESS_TRANSFER_RECEIPT" +TRANSFER_TOKEN_ENV = "_HERMES_DESKTOP_PROCESS_TRANSFER_TOKEN" +TRANSFER_RECEIVER_ENV = "_HERMES_DESKTOP_PROCESS_TRANSFER_RECEIVER" +DESCENDANT_GUARD_ENV = "_HERMES_DESKTOP_POSIX_DESCENDANT_GUARD" + +LIFETIME_CONTAINED = "contained" +LIFETIME_TRANSFERRED = "transferred" +LIFETIME_FOREIGN = "foreign" +ALLOWED_LIFETIMES = { + LIFETIME_CONTAINED, + LIFETIME_TRANSFERRED, + LIFETIME_FOREIGN, +} +GENERATION_RE = re.compile(r"^[A-Za-z0-9._-]{16,128}$") +RECEIVER_RE = re.compile(r"^[A-Za-z0-9._:/-]{3,128}$") +TRANSFER_PROTOCOL = "desktop-posix-transfer-v1" +TRANSFER_TTL_SECONDS = 30.0 +TRANSFER_ACK_SECONDS = 3.0 +DEFAULT_GRACE_SECONDS = 5.0 +DEFAULT_FORCE_SECONDS = 2.0 +SIGKILL = getattr(signal, "SIGKILL", signal.SIGTERM) +SIGHUP = getattr(signal, "SIGHUP", None) +SIGUSR2 = getattr(signal, "SIGUSR2", None) + + +class ProcessAuthorityError(RuntimeError): + """The requested process operation could not preserve authority.""" + + +@dataclass(frozen=True) +class AuthoritySpec: + generation: str + parent_pid: int + parent_started_at_ms: int + + +@dataclass(frozen=True) +class InstalledPosixAuthority: + spec: AuthoritySpec + role: str + scope_id: int + + +@dataclass(frozen=True) +class ProcessTransferGrant: + """One-use permission to hand a child to a named receiving owner.""" + + token: str + receiver: str + + +@dataclass(frozen=True) +class PendingTransfer: + receiver: str + expires_at: float + + +@dataclass(frozen=True) +class TransferStartNewSession: + """In-process marker carried by the existing detach-helper kwargs.""" + + grant: ProcessTransferGrant + + def __bool__(self) -> bool: + return True + + +install_lock = threading.RLock() +transfer_lock = threading.Lock() +installed: InstalledPosixAuthority | None = None +guard_installed = False +pending_transfers: dict[str, PendingTransfer] = {} +original_popen_init = subprocess.Popen.__init__ +original_setsid = getattr(os, "setsid", None) +original_setpgid = getattr(os, "setpgid", None) +original_setpgrp = getattr(os, "setpgrp", None) +original_posix_spawn = getattr(os, "posix_spawn", None) +original_posix_spawnp = getattr(os, "posix_spawnp", None) +original_killpg = getattr(os, "killpg", None) +original_detach_helper: Any | None = None + + +def read_spec(environ: Mapping[str, str]) -> AuthoritySpec | None: + mode = (environ.get(AUTHORITY_MODE_ENV) or "").strip() + if not mode: + return None + if mode != AUTHORITY_MODE: + raise ProcessAuthorityError(f"unsupported POSIX process authority mode: {mode!r}") + + generation = (environ.get(GENERATION_ENV) or "").strip() + if not GENERATION_RE.fullmatch(generation): + raise ProcessAuthorityError("desktop process generation is missing or malformed") + try: + parent_pid = int((environ.get(PARENT_PID_ENV) or "").strip()) + parent_started_at_ms = int((environ.get(PARENT_STARTED_AT_ENV) or "").strip()) + except ValueError as exc: + raise ProcessAuthorityError("desktop parent identity is malformed") from exc + if parent_pid <= 0 or parent_started_at_ms <= 0: + raise ProcessAuthorityError("desktop parent identity must be positive") + return AuthoritySpec(generation, parent_pid, parent_started_at_ms) + + +def authority_keys() -> tuple[str, ...]: + return ( + AUTHORITY_MODE_ENV, + GENERATION_ENV, + PARENT_PID_ENV, + PARENT_STARTED_AT_ENV, + ROLE_ENV, + ) + + +def transfer_keys() -> tuple[str, ...]: + return ( + TRANSFER_TOKEN_ENV, + TRANSFER_RECEIVER_ENV, + TRANSFER_RECEIPT_ENV, + ) + + +def strip_authority_envelope(env: MutableMapping[str, str]) -> None: + for key in authority_keys(): + env.pop(key, None) + + +def strip_transfer_envelope(env: MutableMapping[str, str]) -> None: + for key in transfer_keys(): + env.pop(key, None) + + +def prune_transfers(now: float | None = None) -> None: + cutoff = time.monotonic() if now is None else now + expired = [ + token + for token, pending in pending_transfers.items() + if pending.expires_at <= cutoff + ] + for token in expired: + pending_transfers.pop(token, None) diff --git a/hermes_cli/_posix_process_guard.py b/hermes_cli/_posix_process_guard.py new file mode 100644 index 0000000000000..e07d0b6697ad3 --- /dev/null +++ b/hermes_cli/_posix_process_guard.py @@ -0,0 +1,198 @@ +"""Descendant containment guard for retained POSIX process authority.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from typing import Any, Mapping + +from hermes_cli import _posix_process_authority_state as S +from hermes_cli import _posix_process_nested as N +from hermes_cli import _posix_process_transfer as T + + +def _install_detach_helper_adapter() -> None: + from hermes_cli import _subprocess_compat + + current = _subprocess_compat.windows_detach_popen_kwargs + if getattr(current, "__hermes_posix_authority_adapter__", False): + return + S.original_detach_helper = current + + def authority_detach_popen_kwargs() -> dict[str, object]: + result = dict(current()) + if not result.get("start_new_session"): + raise S.ProcessAuthorityError( + "POSIX detach helper did not request a new session" + ) + grant = T.begin_process_transfer("hermes-intentional-detached-child") + result["start_new_session"] = S.TransferStartNewSession(grant) + return result + + setattr( + authority_detach_popen_kwargs, + "__hermes_posix_authority_adapter__", + True, + ) + _subprocess_compat.windows_detach_popen_kwargs = authority_detach_popen_kwargs + + +def install_descendant_guard() -> None: + if S.guard_installed: + return + + with S.install_lock: + if S.guard_installed: + return + + def guarded_popen_init(self, *args: Any, **kwargs: Any) -> None: + detach_request = kwargs.get("start_new_session") + if isinstance(detach_request, S.TransferStartNewSession): + env = T.desktop_child_env( + lifetime=S.LIFETIME_TRANSFERRED, + transfer=detach_request.grant, + base=kwargs.get("env"), + ) + kwargs["start_new_session"] = True + lifetime = S.LIFETIME_TRANSFERRED + else: + env, lifetime = T.normalize_child_env(kwargs.get("env")) + + if lifetime != S.LIFETIME_CONTAINED: + T.launch_transferred_popen(self, args, kwargs, env) + return + + # A raw private-session request is not an ownership escape. It is + # a narrower child-control scope that remains owned by the Desktop + # generation. Keep a retained owner in the caller's group and put + # only the real target into the private session. + if kwargs.get("start_new_session"): + N.launch_nested_owned_popen(self, args, kwargs, env) + return + + if kwargs.get("process_group") is not None: + kwargs["process_group"] = None + preexec_fn = kwargs.get("preexec_fn") + if preexec_fn in {S.original_setsid, S.original_setpgrp}: + raise S.ProcessAuthorityError( + "contained preexec session creation requires " + "start_new_session=True so nested authority can be retained" + ) + if preexec_fn is not None: + raise S.ProcessAuthorityError( + "contained child preexec_fn is opaque and cannot prove " + "process-group containment" + ) + + kwargs["env"] = env + S.original_popen_init(self, *args, **kwargs) + + subprocess.Popen.__init__ = guarded_popen_init # type: ignore[assignment] + + if S.original_setsid is not None: + + def guarded_setsid() -> int: + raise S.ProcessAuthorityError( + "direct setsid() escapes retained Desktop authority; " + "use a retained Popen start_new_session scope or " + "begin_process_transfer()" + ) + + setattr(os, "setsid", guarded_setsid) + if S.original_setpgid is not None: + + def guarded_setpgid(_pid: int, _pgid: int) -> None: + raise S.ProcessAuthorityError( + "direct setpgid() escapes retained Desktop authority; " + "use retained Popen authority" + ) + + setattr(os, "setpgid", guarded_setpgid) + if S.original_setpgrp is not None: + + def guarded_setpgrp() -> None: + raise S.ProcessAuthorityError( + "direct setpgrp() escapes retained Desktop authority; " + "use retained Popen authority" + ) + + setattr(os, "setpgrp", guarded_setpgrp) + + def wrap_posix_spawn(original): + if original is None: + return None + + def guarded(path, argv, env, *args: Any, **kwargs: Any): + child_env, lifetime = T.normalize_child_env(env) + if lifetime != S.LIFETIME_CONTAINED: + token = (child_env.get(S.TRANSFER_TOKEN_ENV) or "").strip() + T.revoke_transfer(token) + raise S.ProcessAuthorityError( + "receipted process transfer requires " + "subprocess.Popen acknowledgement" + ) + # posix_spawn has no retained owner object to anchor either a + # transferred or nested private scope. Keep it in the current + # contained group; callers needing private mutation scope must + # use Popen so the authority can be retained and receipted. + if kwargs.get("setsid"): + kwargs["setsid"] = False + if "setpgroup" in kwargs: + kwargs.pop("setpgroup") + return original(path, argv, child_env, *args, **kwargs) + + return guarded + + if S.original_posix_spawn is not None: + setattr(os, "posix_spawn", wrap_posix_spawn(S.original_posix_spawn)) + if S.original_posix_spawnp is not None: + setattr(os, "posix_spawnp", wrap_posix_spawn(S.original_posix_spawnp)) + + S.guard_installed = True + _install_detach_helper_adapter() + + +def install_posix_descendant_guard( + *, + environ: Mapping[str, str] | None = None, + platform: str | None = None, +) -> bool: + """Install guard-only authority in a contained Python descendant.""" + + actual_platform = sys.platform if platform is None else platform + source = os.environ if environ is None else environ + marker = (source.get(S.DESCENDANT_GUARD_ENV) or "").strip() + if not marker: + return False + if marker != S.AUTHORITY_MODE: + raise S.ProcessAuthorityError( + f"unsupported descendant guard mode: {marker!r}" + ) + if actual_platform == "win32": + raise S.ProcessAuthorityError("POSIX descendant guard requested on Windows") + install_descendant_guard() + return True + + +def reset_guard_for_tests() -> None: + with S.install_lock: + if not S.guard_installed: + return + subprocess.Popen.__init__ = S.original_popen_init # type: ignore[assignment] + if S.original_setsid is not None: + setattr(os, "setsid", S.original_setsid) + if S.original_setpgid is not None: + setattr(os, "setpgid", S.original_setpgid) + if S.original_setpgrp is not None: + setattr(os, "setpgrp", S.original_setpgrp) + if S.original_posix_spawn is not None: + setattr(os, "posix_spawn", S.original_posix_spawn) + if S.original_posix_spawnp is not None: + setattr(os, "posix_spawnp", S.original_posix_spawnp) + if S.original_detach_helper is not None: + from hermes_cli import _subprocess_compat + + _subprocess_compat.windows_detach_popen_kwargs = S.original_detach_helper + S.original_detach_helper = None + S.guard_installed = False diff --git a/hermes_cli/_posix_process_nested.py b/hermes_cli/_posix_process_nested.py new file mode 100644 index 0000000000000..5a15a2fa5d86b --- /dev/null +++ b/hermes_cli/_posix_process_nested.py @@ -0,0 +1,232 @@ +"""Retained nested POSIX process authority for contained child controllers. + +A nested scope stays owned by the Desktop generation while giving an immediate +owner a narrower process group for subtree-local control. The returned Popen +remains bound to a small owner process in the caller's retained group; the real +target runs in a fresh session and all signals route through that retained +owner. This preserves local mutation scope without releasing outer ownership. +""" + +from __future__ import annotations + +import base64 +import json +import os +import signal +import subprocess +import sys +import types +from typing import Any + +from hermes_cli import _posix_process_authority_state as S + +_PROTOCOL = "desktop-posix-nested-v1" +_ACK_SECONDS = 3.0 +_MAX_ACK_BYTES = 4096 + + +def _coerce_exec_argv(raw: Any) -> list[str]: + if isinstance(raw, (str, bytes, os.PathLike)): + return [os.fsdecode(raw)] + try: + values = list(raw) + except TypeError as exc: + raise S.ProcessAuthorityError( + "nested-owned Popen args must be a path or argv sequence" + ) from exc + if not values: + raise S.ProcessAuthorityError("nested-owned Popen args cannot be empty") + return [os.fsdecode(value) for value in values] + + +def _target_spec( + args: tuple[Any, ...], + kwargs: dict[str, Any], +) -> tuple[tuple[Any, ...], dict[str, Any], Any, list[str], str]: + if args: + raw_args = args[0] + rewritten_args = list(args) + elif "args" in kwargs: + raw_args = kwargs["args"] + rewritten_args = [] + else: + raise S.ProcessAuthorityError("nested-owned Popen launch is missing args") + + target_argv = _coerce_exec_argv(raw_args) + requested_executable = kwargs.pop("executable", None) + if kwargs.get("shell"): + shell_executable = os.fsdecode(requested_executable or "/bin/sh") + target_argv = [shell_executable, "-c", *target_argv] + target_executable = shell_executable + kwargs["shell"] = False + else: + target_executable = os.fsdecode(requested_executable or target_argv[0]) + return tuple(rewritten_args), kwargs, raw_args, target_argv, target_executable + + +def _read_ack( + fd: int, + owner_pid: int, + expected_owner_pgid: int, +) -> int: + import select + + ready, _, _ = select.select([fd], [], [], _ACK_SECONDS) + if not ready: + raise S.ProcessAuthorityError( + "nested process owner did not acknowledge retained child scope" + ) + payload = os.read(fd, _MAX_ACK_BYTES + 1) + if not payload or len(payload) > _MAX_ACK_BYTES: + raise S.ProcessAuthorityError( + "nested process owner acknowledgement is empty or oversized" + ) + try: + decoded = json.loads(payload.decode("utf-8").strip()) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise S.ProcessAuthorityError( + "nested process owner acknowledgement is malformed" + ) from exc + + if decoded.get("protocol") != _PROTOCOL or decoded.get("owner_pid") != owner_pid: + raise S.ProcessAuthorityError( + "nested process owner acknowledgement did not match retained owner" + ) + if decoded.get("owner_pgid") != expected_owner_pgid: + raise S.ProcessAuthorityError( + "nested process owner escaped the caller's retained scope" + ) + error = decoded.get("error") + if error: + detail = error.get("detail") if isinstance(error, dict) else str(error) + raise S.ProcessAuthorityError( + f"nested process owner could not exec target: {detail}" + ) + scope_id = decoded.get("scope_id") + if not isinstance(scope_id, int) or scope_id <= 0 or scope_id == owner_pid: + raise S.ProcessAuthorityError( + "nested process owner acknowledgement carried an invalid child scope" + ) + if decoded.get("target_sid") != scope_id or decoded.get("target_pgid") != scope_id: + raise S.ProcessAuthorityError( + "nested process owner did not prove the target's exact private scope" + ) + return scope_id + + +def _abort_owner(child: subprocess.Popen[Any]) -> None: + try: + child.terminate() + except (ProcessLookupError, PermissionError, OSError): + pass + try: + child.wait(timeout=S.DEFAULT_FORCE_SECONDS) + return + except (subprocess.TimeoutExpired, ChildProcessError): + pass + try: + child.kill() + except (ProcessLookupError, PermissionError, OSError): + pass + try: + child.wait(timeout=S.DEFAULT_FORCE_SECONDS) + except (subprocess.TimeoutExpired, ChildProcessError): + pass + + +def launch_nested_owned_popen( + child: subprocess.Popen[Any], + args: tuple[Any, ...], + kwargs: dict[str, Any], + env: dict[str, str], +) -> None: + """Launch a contained private scope without releasing Desktop ownership.""" + + if not kwargs.get("start_new_session"): + raise S.ProcessAuthorityError( + "nested-owned child must request start_new_session=True" + ) + if kwargs.get("process_group") is not None: + raise S.ProcessAuthorityError( + "nested-owned child cannot combine a process_group override" + ) + if kwargs.get("preexec_fn") is not None: + raise S.ProcessAuthorityError( + "nested-owned child cannot use opaque preexec_fn code" + ) + + rewritten_args, kwargs, original_args, target_argv, target_executable = ( + _target_spec(args, kwargs) + ) + read_fd, write_fd = os.pipe() + prior_pass_fds = tuple(kwargs.get("pass_fds") or ()) + kwargs["pass_fds"] = tuple(dict.fromkeys((*prior_pass_fds, write_fd))) + kwargs["close_fds"] = True + + # The owner itself must stay in the caller's retained scope. It is the + # anchor that lets outer teardown reach and drain this nested scope. + kwargs["start_new_session"] = False + kwargs.pop("process_group", None) + kwargs["env"] = env + + wrapper_spec = { + "protocol": _PROTOCOL, + "ack_fd": write_fd, + "parent_pid": os.getpid(), + "argv": target_argv, + "executable": target_executable, + "target_pass_fds": list(prior_pass_fds), + } + encoded_spec = base64.urlsafe_b64encode( + json.dumps(wrapper_spec, separators=(",", ":")).encode("utf-8") + ).decode("ascii") + wrapper_argv = [ + sys.executable, + "-m", + "hermes_cli.posix_nested_owner", + encoded_spec, + ] + if rewritten_args: + rewritten_args = (wrapper_argv, *rewritten_args[1:]) + else: + kwargs["args"] = wrapper_argv + + expected_owner_pgid = os.getpgrp() + try: + S.original_popen_init(child, *rewritten_args, **kwargs) + except BaseException: + os.close(read_fd) + os.close(write_fd) + raise + + original_send_signal = child.send_signal + + def send_authority_signal(self: subprocess.Popen[Any], sig: int) -> None: + force_control = S.SIGUSR2 or S.SIGHUP + if sig == S.SIGKILL: + if force_control is None: + raise S.ProcessAuthorityError( + "nested process owner has no force-control signal" + ) + original_send_signal(force_control) + return + allowed = {signal.SIGTERM, signal.SIGINT} + allowed.update(value for value in (S.SIGHUP, S.SIGUSR2) if value is not None) + if sig not in allowed: + raise S.ProcessAuthorityError( + f"signal {sig!r} bypasses nested process authority" + ) + original_send_signal(sig) + + child.send_signal = types.MethodType(send_authority_signal, child) + os.close(write_fd) + try: + scope_id = _read_ack(read_fd, int(child.pid), expected_owner_pgid) + child.args = original_args + setattr(child, "__hermes_nested_owned__", True) + setattr(child, "__hermes_nested_scope_id__", scope_id) + except BaseException: + _abort_owner(child) + raise + finally: + os.close(read_fd) diff --git a/hermes_cli/_posix_process_transfer.py b/hermes_cli/_posix_process_transfer.py new file mode 100644 index 0000000000000..98eb9cc187223 --- /dev/null +++ b/hermes_cli/_posix_process_transfer.py @@ -0,0 +1,369 @@ +"""One-shot child-side handoff for POSIX process authority.""" + +from __future__ import annotations + +import base64 +import json +import os +import secrets +import select +import signal +import subprocess +import sys +import time +import types +from typing import Any, Literal, Mapping + +from hermes_cli import _posix_process_authority_state as S + + +def begin_process_transfer(receiver: str) -> S.ProcessTransferGrant: + """Mint a one-shot handoff grant inside an installed descendant guard.""" + + receiver = receiver.strip() + if not S.RECEIVER_RE.fullmatch(receiver): + raise ValueError("transfer receiver must be 3-128 stable identifier characters") + if not S.guard_installed: + raise S.ProcessAuthorityError( + "process transfer requires an installed POSIX descendant guard" + ) + + now = time.monotonic() + with S.transfer_lock: + S.prune_transfers(now) + token = secrets.token_urlsafe(32) + while token in S.pending_transfers: + token = secrets.token_urlsafe(32) + S.pending_transfers[token] = S.PendingTransfer( + receiver, + now + S.TRANSFER_TTL_SECONDS, + ) + return S.ProcessTransferGrant(token, receiver) + + +def _pending_for(grant: S.ProcessTransferGrant) -> S.PendingTransfer: + now = time.monotonic() + with S.transfer_lock: + S.prune_transfers(now) + pending = S.pending_transfers.get(grant.token) + if pending is None or pending.receiver != grant.receiver: + raise S.ProcessAuthorityError( + "process transfer grant is unknown, expired, or already consumed" + ) + return pending + + +def _claim_transfer(token: str, receiver: str) -> S.ProcessTransferGrant: + now = time.monotonic() + with S.transfer_lock: + S.prune_transfers(now) + pending = S.pending_transfers.pop(token, None) + if pending is None or pending.receiver != receiver: + raise S.ProcessAuthorityError( + "process transfer grant is unknown, expired, or already consumed" + ) + return S.ProcessTransferGrant(token, receiver) + + +def revoke_transfer(token: str) -> None: + with S.transfer_lock: + S.pending_transfers.pop(token, None) + + +def desktop_child_env( + *, + lifetime: Literal["contained", "transferred", "foreign"] = S.LIFETIME_CONTAINED, + transfer: S.ProcessTransferGrant | None = None, + transfer_receipt: str | None = None, + base: Mapping[str, str] | None = None, +) -> dict[str, str]: + """Build a child environment with an explicit lifetime classification.""" + + if lifetime not in S.ALLOWED_LIFETIMES: + raise ValueError(f"unsupported process lifetime: {lifetime!r}") + if transfer_receipt is not None: + raise S.ProcessAuthorityError( + "caller-supplied transfer receipts are not authority; " + "use begin_process_transfer()" + ) + if lifetime == S.LIFETIME_CONTAINED and transfer is not None: + raise S.ProcessAuthorityError( + "contained process lifetime cannot carry a transfer grant" + ) + if lifetime != S.LIFETIME_CONTAINED: + if transfer is None: + raise S.ProcessAuthorityError( + f"{lifetime} process lifetime requires a transfer grant" + ) + _pending_for(transfer) + + env = dict(os.environ if base is None else base) + S.strip_authority_envelope(env) + S.strip_transfer_envelope(env) + env.pop(S.DESCENDANT_GUARD_ENV, None) + env[S.LIFETIME_ENV] = lifetime + if lifetime == S.LIFETIME_CONTAINED: + env[S.DESCENDANT_GUARD_ENV] = S.AUTHORITY_MODE + else: + assert transfer is not None + env[S.TRANSFER_TOKEN_ENV] = transfer.token + env[S.TRANSFER_RECEIVER_ENV] = transfer.receiver + return env + + +def normalize_child_env( + raw: Mapping[str, str] | None, +) -> tuple[dict[str, str], str]: + env = dict(os.environ if raw is None else raw) + lifetime = (env.get(S.LIFETIME_ENV) or S.LIFETIME_CONTAINED).strip() + if lifetime not in S.ALLOWED_LIFETIMES: + raise S.ProcessAuthorityError( + f"unsupported descendant process lifetime: {lifetime!r}" + ) + + token = (env.get(S.TRANSFER_TOKEN_ENV) or "").strip() + receiver = (env.get(S.TRANSFER_RECEIVER_ENV) or "").strip() + S.strip_authority_envelope(env) + env[S.LIFETIME_ENV] = lifetime + if lifetime == S.LIFETIME_CONTAINED: + if token or receiver: + raise S.ProcessAuthorityError( + "contained descendant carried a transfer capability" + ) + S.strip_transfer_envelope(env) + env[S.DESCENDANT_GUARD_ENV] = S.AUTHORITY_MODE + return env, lifetime + + env.pop(S.DESCENDANT_GUARD_ENV, None) + if not token or not receiver: + raise S.ProcessAuthorityError( + f"{lifetime} descendant requires a one-shot transfer grant" + ) + _pending_for(S.ProcessTransferGrant(token, receiver)) + return env, lifetime + + +def _read_transfer_ack( + fd: int, + child_pid: int, + grant: S.ProcessTransferGrant, +) -> int: + ready, _, _ = select.select([fd], [], [], S.TRANSFER_ACK_SECONDS) + if not ready: + raise S.ProcessAuthorityError( + f"receiving owner {grant.receiver!r} did not acknowledge process transfer" + ) + payload = os.read(fd, 4097) + if not payload or len(payload) > 4096: + raise S.ProcessAuthorityError( + "process transfer acknowledgement is empty or oversized" + ) + try: + decoded = json.loads(payload.decode("utf-8").strip()) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise S.ProcessAuthorityError( + "process transfer acknowledgement is malformed" + ) from exc + + expected = { + "protocol": S.TRANSFER_PROTOCOL, + "token": grant.token, + "receiver": grant.receiver, + "owner_pid": child_pid, + "owner_sid": child_pid, + "owner_pgid": child_pid, + } + for key, value in expected.items(): + if decoded.get(key) != value: + raise S.ProcessAuthorityError( + "process transfer acknowledgement did not match the retained " + "receiving owner" + ) + error = decoded.get("error") + if error: + detail = error.get("detail") if isinstance(error, dict) else str(error) + raise S.ProcessAuthorityError( + f"receiving owner could not exec transferred target: {detail}" + ) + scope_id = decoded.get("scope_id") + if not isinstance(scope_id, int) or scope_id <= 0 or scope_id == child_pid: + raise S.ProcessAuthorityError( + "process transfer acknowledgement carried an invalid owned scope" + ) + if decoded.get("target_sid") != child_pid or decoded.get("target_pgid") != scope_id: + raise S.ProcessAuthorityError( + "process transfer acknowledgement did not prove the target's exact " + "receiving scope" + ) + return scope_id + + +def _abort_unaccepted_transfer(child: subprocess.Popen[Any]) -> None: + try: + child.terminate() + except (ProcessLookupError, PermissionError, OSError): + pass + try: + child.wait(timeout=S.DEFAULT_FORCE_SECONDS) + return + except (subprocess.TimeoutExpired, ChildProcessError): + pass + try: + child.kill() + except (ProcessLookupError, PermissionError, OSError): + pass + try: + child.wait(timeout=S.DEFAULT_FORCE_SECONDS) + except (subprocess.TimeoutExpired, ChildProcessError): + pass + + +def _coerce_exec_argv(raw: Any) -> list[str]: + if isinstance(raw, (str, bytes, os.PathLike)): + return [os.fsdecode(raw)] + try: + values = list(raw) + except TypeError as exc: + raise S.ProcessAuthorityError( + "transferred Popen args must be a path or argv sequence" + ) from exc + if not values: + raise S.ProcessAuthorityError("transferred Popen args cannot be empty") + return [os.fsdecode(value) for value in values] + + +def _transfer_target_spec( + args: tuple[Any, ...], + kwargs: dict[str, Any], +) -> tuple[tuple[Any, ...], dict[str, Any], Any, list[str], str]: + if args: + raw_args = args[0] + rewritten_args = list(args) + elif "args" in kwargs: + raw_args = kwargs["args"] + rewritten_args = [] + else: + raise S.ProcessAuthorityError("transferred Popen launch is missing args") + + target_argv = _coerce_exec_argv(raw_args) + requested_executable = kwargs.pop("executable", None) + if kwargs.get("shell"): + shell_executable = os.fsdecode(requested_executable or "/bin/sh") + target_argv = [shell_executable, "-c", *target_argv] + target_executable = shell_executable + kwargs["shell"] = False + else: + target_executable = os.fsdecode(requested_executable or target_argv[0]) + + return ( + tuple(rewritten_args), + kwargs, + raw_args, + target_argv, + target_executable, + ) + + +def launch_transferred_popen( + child: subprocess.Popen[Any], + args: tuple[Any, ...], + kwargs: dict[str, Any], + env: dict[str, str], +) -> None: + token = (env.get(S.TRANSFER_TOKEN_ENV) or "").strip() + receiver = (env.get(S.TRANSFER_RECEIVER_ENV) or "").strip() + if not kwargs.get("start_new_session"): + revoke_transfer(token) + raise S.ProcessAuthorityError( + "transferred child must request start_new_session=True" + ) + if kwargs.get("process_group") is not None: + revoke_transfer(token) + raise S.ProcessAuthorityError( + "transferred child cannot combine a process_group override" + ) + if kwargs.get("preexec_fn") is not None: + revoke_transfer(token) + raise S.ProcessAuthorityError( + "transferred child cannot use opaque preexec_fn code" + ) + + grant = _claim_transfer(token, receiver) + rewritten_args, kwargs, original_args, target_argv, target_executable = ( + _transfer_target_spec(args, kwargs) + ) + read_fd, write_fd = os.pipe() + prior_pass_fds = tuple(kwargs.get("pass_fds") or ()) + kwargs["pass_fds"] = tuple(dict.fromkeys((*prior_pass_fds, write_fd))) + kwargs["close_fds"] = True + + S.strip_transfer_envelope(env) + env.pop(S.DESCENDANT_GUARD_ENV, None) + env.pop(S.LIFETIME_ENV, None) + env[S.TRANSFER_RECEIPT_ENV] = f"{S.TRANSFER_PROTOCOL}:{receiver}" + kwargs["env"] = env + + wrapper_spec = { + "protocol": S.TRANSFER_PROTOCOL, + "token": grant.token, + "receiver": grant.receiver, + "ack_fd": write_fd, + "sender_pid": os.getpid(), + "argv": target_argv, + "executable": target_executable, + "target_pass_fds": list(prior_pass_fds), + } + encoded_spec = base64.urlsafe_b64encode( + json.dumps(wrapper_spec, separators=(",", ":")).encode("utf-8") + ).decode("ascii") + wrapper_argv = [ + sys.executable, + "-m", + "hermes_cli.posix_transfer_owner", + encoded_spec, + "--", + *target_argv, + ] + if rewritten_args: + rewritten_args = (wrapper_argv, *rewritten_args[1:]) + else: + kwargs["args"] = wrapper_argv + + try: + S.original_popen_init(child, *rewritten_args, **kwargs) + except BaseException: + os.close(read_fd) + os.close(write_fd) + raise + + original_send_signal = child.send_signal + + def send_authority_signal(self: subprocess.Popen[Any], sig: int) -> None: + force_control = S.SIGUSR2 or S.SIGHUP + if sig == S.SIGKILL: + if force_control is None: + raise S.ProcessAuthorityError( + "receiving owner has no force-control signal on this POSIX platform" + ) + original_send_signal(force_control) + return + allowed = {signal.SIGTERM, signal.SIGINT} + allowed.update( + value for value in (S.SIGHUP, S.SIGUSR2) if value is not None + ) + if sig not in allowed: + raise S.ProcessAuthorityError( + f"signal {sig!r} bypasses transferred process authority" + ) + original_send_signal(sig) + + child.send_signal = types.MethodType(send_authority_signal, child) + os.close(write_fd) + try: + _read_transfer_ack(read_fd, int(child.pid), grant) + child.args = original_args + except BaseException: + _abort_unaccepted_transfer(child) + raise + finally: + os.close(read_fd) diff --git a/hermes_cli/desktop_bootstrap/hermes_cli/__init__.py b/hermes_cli/desktop_bootstrap/hermes_cli/__init__.py new file mode 100644 index 0000000000000..bb5e9b409b7aa --- /dev/null +++ b/hermes_cli/desktop_bootstrap/hermes_cli/__init__.py @@ -0,0 +1,44 @@ +"""Scoped shim that intercepts only ``python -m hermes_cli.main``. + +The Desktop prepends this package directory to ``PYTHONPATH``. The shim locates +but deliberately does not execute the real Hermes package until ``main.py`` has +installed the platform process authority. No other Python invocation is +changed because the path is supplied only to Desktop backend launches. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +_SHIM_PACKAGE = Path(__file__).resolve().parent +_BOOTSTRAP_ROOT = _SHIM_PACKAGE.parent +_REAL_PACKAGE: Path | None = None + +for raw_entry in sys.path: + if not raw_entry: + continue + try: + candidate = (Path(raw_entry).resolve() / "hermes_cli").resolve() + except (OSError, RuntimeError): + continue + if candidate == _SHIM_PACKAGE: + continue + if (candidate / "__init__.py").is_file() and (candidate / "main.py").is_file(): + _REAL_PACKAGE = candidate + break + +if _REAL_PACKAGE is None: + raise ImportError("Desktop authority bootstrap could not locate the real hermes_cli package") + +# Let the scoped main shim import the authority implementation from the real +# package without running the real package initializer first. +__path__ = [str(_SHIM_PACKAGE), str(_REAL_PACKAGE)] +if __spec__ is not None: + __spec__.submodule_search_locations = list(__path__) + +DESKTOP_BOOTSTRAP_ROOT = str(_BOOTSTRAP_ROOT) +DESKTOP_REAL_PACKAGE = str(_REAL_PACKAGE) +DESKTOP_REAL_INIT = str(_REAL_PACKAGE / "__init__.py") +DESKTOP_REAL_MAIN = str(_REAL_PACKAGE / "main.py") diff --git a/hermes_cli/desktop_bootstrap/hermes_cli/main.py b/hermes_cli/desktop_bootstrap/hermes_cli/main.py new file mode 100644 index 0000000000000..a7e0329fcb849 --- /dev/null +++ b/hermes_cli/desktop_bootstrap/hermes_cli/main.py @@ -0,0 +1,63 @@ +"""Authority-first wrapper for ``python -m hermes_cli.main`` Desktop launches.""" + +from __future__ import annotations + +import importlib.util +import os +import runpy +import sys +from pathlib import Path +from types import ModuleType + +import hermes_cli as package + + +def _load_authority_module(name: str, path: Path) -> ModuleType: + qualified = f"hermes_cli.{name}" + existing = sys.modules.get(qualified) + if existing is not None: + return existing + spec = importlib.util.spec_from_file_location(qualified, path) + if spec is None or spec.loader is None: + raise ImportError(f"could not load Desktop authority module {path}") + module = importlib.util.module_from_spec(spec) + sys.modules[qualified] = module + spec.loader.exec_module(module) + return module + + +real_package = Path(package.DESKTOP_REAL_PACKAGE) +mode = (os.environ.get("HERMES_DESKTOP_PROCESS_AUTHORITY") or "").strip() + +if mode == "windows-job-v1": + authority = _load_authority_module( + "windows_process_authority", + real_package / "windows_process_authority.py", + ) + authority.install_windows_process_authority() +elif mode == "posix-session-v1": + authority = _load_authority_module( + "posix_process_authority", + real_package / "posix_process_authority.py", + ) + authority.install_posix_process_authority() +elif mode: + raise RuntimeError(f"unsupported Desktop process authority mode: {mode!r}") + +# Only the authority-owning backend child reaches this point on POSIX; the +# supervisor remains the Electron ChildProcess and never imports application +# code. Restore the real package before loading the real CLI entrypoint. +bootstrap_root = Path(package.DESKTOP_BOOTSTRAP_ROOT).resolve() +sys.path[:] = [ + entry + for entry in sys.path + if not entry or Path(entry).resolve() != bootstrap_root +] +package.__file__ = package.DESKTOP_REAL_INIT +package.__path__ = [str(real_package)] +if package.__spec__ is not None: + package.__spec__.submodule_search_locations = list(package.__path__) + +real_init = Path(package.DESKTOP_REAL_INIT) +exec(compile(real_init.read_bytes(), str(real_init), "exec"), package.__dict__) +runpy.run_module("hermes_cli.main", run_name="__main__", alter_sys=True) diff --git a/hermes_cli/desktop_bootstrap/sitecustomize.py b/hermes_cli/desktop_bootstrap/sitecustomize.py new file mode 100644 index 0000000000000..d11ed8d6efa41 --- /dev/null +++ b/hermes_cli/desktop_bootstrap/sitecustomize.py @@ -0,0 +1,32 @@ +"""Earliest process-authority bootstrap for Desktop-owned backends. + +Electron prepends this directory to ``PYTHONPATH`` only for marked Desktop +backend launches. Python imports ``sitecustomize`` before ``hermes_cli.main``, +so platform authority owns the complete execution scope before Hermes imports +spawn-capable code. Marked setup failures deliberately abort startup. +""" + +import os +import sys + +_AUTHORITY_ENV = "HERMES_DESKTOP_PROCESS_AUTHORITY" +_DESCENDANT_GUARD_ENV = "_HERMES_DESKTOP_POSIX_DESCENDANT_GUARD" +_POSIX_AUTHORITY_MODE = "posix-session-v1" + +mode = (os.environ.get(_AUTHORITY_ENV) or "").strip() +descendant_mode = (os.environ.get(_DESCENDANT_GUARD_ENV) or "").strip() + +if mode == "windows-job-v1": + from hermes_cli.windows_process_authority import install_windows_process_authority + + install_windows_process_authority() +elif mode == _POSIX_AUTHORITY_MODE: + from hermes_cli.posix_process_authority import install_posix_process_authority + + install_posix_process_authority() +elif mode: + raise RuntimeError(f"unsupported Desktop process authority mode: {mode!r} on {sys.platform}") +elif descendant_mode: + from hermes_cli.posix_process_authority import install_posix_descendant_guard + + install_posix_descendant_guard() diff --git a/hermes_cli/posix_nested_owner.py b/hermes_cli/posix_nested_owner.py new file mode 100644 index 0000000000000..63166cc625113 --- /dev/null +++ b/hermes_cli/posix_nested_owner.py @@ -0,0 +1,286 @@ +"""Retained owner for a nested POSIX child-control scope.""" + +from __future__ import annotations + +import base64 +import json +import os +import select +import signal +import sys +import time +from typing import Any + +from hermes_cli import _posix_process_authority_state as S + +_PROTOCOL = "desktop-posix-nested-v1" +_EXEC_ACK_SECONDS = 3.0 +_GRACE_SECONDS = 2.0 +_FORCE_SECONDS = 1.0 +_MAX_ACK_BYTES = 4096 +_FORK = getattr(os, "fork", None) + + +class NestedOwnerError(RuntimeError): + """A nested owner could not retain or prove its exact child scope.""" + + +def _decode_spec(raw: str) -> dict[str, Any]: + try: + spec = json.loads(base64.urlsafe_b64decode(raw.encode("ascii")).decode("utf-8")) + except (ValueError, UnicodeError, json.JSONDecodeError) as exc: + raise NestedOwnerError("malformed nested process specification") from exc + if not isinstance(spec, dict) or spec.get("protocol") != _PROTOCOL: + raise NestedOwnerError("unsupported nested process protocol") + if not isinstance(spec.get("ack_fd"), int) or spec["ack_fd"] < 0: + raise NestedOwnerError("nested process acknowledgement descriptor is invalid") + if not isinstance(spec.get("parent_pid"), int) or spec["parent_pid"] <= 0: + raise NestedOwnerError("nested process parent identity is invalid") + if not isinstance(spec.get("executable"), str) or not spec["executable"]: + raise NestedOwnerError("nested process executable is missing") + argv = spec.get("argv") + if not isinstance(argv, list) or not argv or not all(isinstance(v, str) for v in argv): + raise NestedOwnerError("nested process argv is malformed") + pass_fds = spec.get("target_pass_fds", []) + if not isinstance(pass_fds, list) or not all(isinstance(fd, int) and fd >= 0 for fd in pass_fds): + raise NestedOwnerError("nested process descriptor list is malformed") + return spec + + +def _close(fd: int) -> None: + try: + os.close(fd) + except OSError: + pass + + +def _write_ack(fd: int, payload: dict[str, Any]) -> bool: + encoded = (json.dumps(payload, separators=(",", ":")) + "\n").encode("utf-8") + if len(encoded) > _MAX_ACK_BYTES: + return False + try: + os.write(fd, encoded) + return True + except (BrokenPipeError, OSError): + return False + + +def _error(kind: str, detail: str) -> dict[str, Any]: + return {"type": kind, "detail": detail} + + +def _kill_scope(scope_id: int, sig: int) -> None: + if S.original_killpg is None: + raise NestedOwnerError("nested owner requires process-group signalling") + try: + S.original_killpg(scope_id, sig) + except (ProcessLookupError, PermissionError): + pass + + +def _wait_child(pid: int, timeout: float) -> int | None: + deadline = time.monotonic() + max(timeout, 0.0) + while True: + try: + waited, status = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + return 0 + if waited == pid: + return status + if time.monotonic() >= deadline: + return None + time.sleep(0.05) + + +def _stop_scope(child_pid: int, *, force: bool) -> int | None: + if force: + _kill_scope(child_pid, S.SIGKILL) + return _wait_child(child_pid, _FORCE_SECONDS) + _kill_scope(child_pid, signal.SIGTERM) + status = _wait_child(child_pid, _GRACE_SECONDS) + if status is None: + _kill_scope(child_pid, S.SIGKILL) + status = _wait_child(child_pid, _FORCE_SECONDS) + return status + + +def _exit_from_status(status: int | None) -> None: + if status is None: + os._exit(1) + if os.WIFEXITED(status): + os._exit(os.WEXITSTATUS(status)) + if os.WIFSIGNALED(status): + os._exit(128 + os.WTERMSIG(status)) + os._exit(1) + + +def _install_handlers(requested: list[int]) -> tuple[int, ...]: + handled: list[int] = [] + + def request(sig: int, _frame: Any) -> None: + requested[:] = [sig] + + for candidate in (signal.SIGTERM, signal.SIGINT, S.SIGHUP, S.SIGUSR2): + if candidate is None or candidate in handled: + continue + signal.signal(candidate, request) + handled.append(candidate) + return tuple(handled) + + +def _reset_handlers(handled: tuple[int, ...]) -> None: + for sig in handled: + signal.signal(sig, signal.SIG_DFL) + + +def _run_target( + spec: dict[str, Any], + status_read: int, + status_write: int, + handled: tuple[int, ...], +) -> None: + _close(status_read) + _reset_handlers(handled) + try: + if S.original_setsid is None: + raise NestedOwnerError("nested owner requires setsid()") + S.original_setsid() + for fd in spec.get("target_pass_fds", []): + os.set_inheritable(fd, True) + os.execvpe(spec["executable"], list(spec["argv"]), dict(os.environ)) + except BaseException as exc: + payload = _error(type(exc).__name__, str(exc)) + try: + os.write( + status_write, + json.dumps(payload, separators=(",", ":")).encode("utf-8")[:_MAX_ACK_BYTES], + ) + except OSError: + pass + finally: + os._exit(127) + + +def _read_exec_status( + fd: int, + *, + parent_pid: int, + requested: list[int], +) -> dict[str, Any] | None: + deadline = time.monotonic() + _EXEC_ACK_SECONDS + while True: + if requested: + return _error("NestedOwnerCancelled", "owner stopped before acknowledgement") + if os.getppid() != parent_pid: + return _error("ParentExited", "nested parent exited before acknowledgement") + remaining = deadline - time.monotonic() + if remaining <= 0: + return _error("ExecTimeout", "nested target did not cross exec before timeout") + ready, _, _ = select.select([fd], [], [], min(remaining, 0.05)) + if not ready: + continue + payload = os.read(fd, _MAX_ACK_BYTES + 1) + if not payload: + return None + if len(payload) > _MAX_ACK_BYTES: + return _error("ExecError", "nested target exec failure was oversized") + try: + decoded = json.loads(payload.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return _error("ExecError", "nested target exec failure was malformed") + return decoded if isinstance(decoded, dict) else _error( + "ExecError", "nested target exec failure was malformed" + ) + + +def _target_identity(child_pid: int) -> tuple[int, int]: + try: + sid = int(os.getsid(child_pid)) + pgid = int(os.getpgid(child_pid)) + except (ProcessLookupError, PermissionError) as exc: + raise NestedOwnerError("nested target exited before acknowledgement") from exc + if sid != child_pid or pgid != child_pid: + raise NestedOwnerError("nested target did not enter its exact private scope") + return sid, pgid + + +def _supervise(child_pid: int, parent_pid: int, requested: list[int]) -> None: + force_signals = {sig for sig in (S.SIGHUP, S.SIGUSR2) if sig is not None} + while True: + try: + waited, status = os.waitpid(child_pid, os.WNOHANG) + except ChildProcessError: + waited, status = child_pid, 0 + if waited == child_pid: + _kill_scope(child_pid, S.SIGKILL) + _exit_from_status(status) + if os.getppid() != parent_pid: + status = _stop_scope(child_pid, force=True) + _kill_scope(child_pid, S.SIGKILL) + _exit_from_status(status) + if requested: + status = _stop_scope(child_pid, force=requested[-1] in force_signals) + _kill_scope(child_pid, S.SIGKILL) + _exit_from_status(status) + time.sleep(0.05) + + +def main() -> None: + if len(sys.argv) < 2: + raise NestedOwnerError("nested process specification is missing") + spec = _decode_spec(sys.argv[1]) + if _FORK is None or S.original_killpg is None: + raise NestedOwnerError("nested owner requires POSIX process primitives") + + ack_fd = int(spec["ack_fd"]) + os.set_inheritable(ack_fd, False) + requested: list[int] = [] + handled = _install_handlers(requested) + + status_read, status_write = os.pipe() + os.set_inheritable(status_write, False) + child_pid = _FORK() + if child_pid == 0: + _run_target(spec, status_read, status_write, handled) + raise AssertionError("nested target returned from exec") + + _close(status_write) + for fd in spec.get("target_pass_fds", []): + if fd > 2 and fd != ack_fd: + _close(fd) + + exec_error = _read_exec_status( + status_read, + parent_pid=int(spec["parent_pid"]), + requested=requested, + ) + _close(status_read) + + ack: dict[str, Any] = { + "protocol": _PROTOCOL, + "owner_pid": os.getpid(), + "owner_sid": os.getsid(0), + "owner_pgid": os.getpgrp(), + "scope_id": child_pid, + } + if exec_error is None: + try: + sid, pgid = _target_identity(child_pid) + ack["target_sid"] = sid + ack["target_pgid"] = pgid + except NestedOwnerError as exc: + exec_error = _error(type(exc).__name__, str(exc)) + if exec_error is not None: + ack["error"] = exec_error + + acknowledged = _write_ack(ack_fd, ack) + _close(ack_fd) + if exec_error is not None or not acknowledged: + _stop_scope(child_pid, force=True) + os._exit(127 if exec_error is not None else 126) + + _supervise(child_pid, int(spec["parent_pid"]), requested) + + +if __name__ == "__main__": + main() diff --git a/hermes_cli/posix_process_authority.py b/hermes_cli/posix_process_authority.py new file mode 100644 index 0000000000000..5eb3ee3613eb1 --- /dev/null +++ b/hermes_cli/posix_process_authority.py @@ -0,0 +1,257 @@ +"""Retained POSIX execution-scope authority for Desktop-owned backends. + +Electron keeps a supervisor as its retained ``ChildProcess``. The real backend +runs as a fresh session leader, descendants default to that contained scope, +nested child controllers retain narrower owned scopes, and deliberate escapes +require a completed child-side handoff to a receiving owner. Persisted PIDs and +caller-authored receipt strings never become kill authority. +""" + +from __future__ import annotations + +import os +import signal +import sys +import time +from typing import Mapping + +from hermes_cli import _posix_process_authority_state as S +from hermes_cli._posix_process_authority_state import ( + AuthoritySpec, + InstalledPosixAuthority, + ProcessAuthorityError, + ProcessTransferGrant, +) +from hermes_cli._posix_process_guard import ( + install_descendant_guard as _install_descendant_guard, + install_posix_descendant_guard, + reset_guard_for_tests as _reset_guard_for_tests, +) +from hermes_cli._posix_process_transfer import ( + begin_process_transfer, + desktop_child_env, +) + +AUTHORITY_MODE = S.AUTHORITY_MODE +AUTHORITY_MODE_ENV = S.AUTHORITY_MODE_ENV +GENERATION_ENV = S.GENERATION_ENV +PARENT_PID_ENV = S.PARENT_PID_ENV +PARENT_STARTED_AT_ENV = S.PARENT_STARTED_AT_ENV +ROLE_ENV = S.ROLE_ENV +LIFETIME_ENV = S.LIFETIME_ENV +TRANSFER_RECEIPT_ENV = S.TRANSFER_RECEIPT_ENV +TRANSFER_TOKEN_ENV = S.TRANSFER_TOKEN_ENV +TRANSFER_RECEIVER_ENV = S.TRANSFER_RECEIVER_ENV +DESCENDANT_GUARD_ENV = S.DESCENDANT_GUARD_ENV +LIFETIME_CONTAINED = S.LIFETIME_CONTAINED +LIFETIME_TRANSFERRED = S.LIFETIME_TRANSFERRED +LIFETIME_FOREIGN = S.LIFETIME_FOREIGN + + +_SIGNAL_SELF = getattr(os, "kill", None) + + +def _signal_group(pgid: int, sig: int) -> None: + if S.original_killpg is None: + raise ProcessAuthorityError("POSIX process authority requires killpg()") + try: + S.original_killpg(pgid, sig) + except ProcessLookupError: + return + except PermissionError as exc: + raise ProcessAuthorityError( + f"permission denied signalling owned process group: {exc}" + ) from exc + + +def _scope_exists(pgid: int) -> bool: + if S.original_killpg is None: + return False + try: + S.original_killpg(pgid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + + +def _wait_child(pid: int, timeout: float) -> int | None: + deadline = time.monotonic() + max(timeout, 0.0) + while True: + try: + waited, status = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + return 0 + if waited == pid: + return status + if time.monotonic() >= deadline: + return None + time.sleep(0.05) + + +def _wait_scope_empty(pgid: int, timeout: float) -> bool: + deadline = time.monotonic() + max(timeout, 0.0) + while _scope_exists(pgid): + if time.monotonic() >= deadline: + return False + time.sleep(0.05) + return True + + +def _exit_from_status(status: int) -> None: + if os.WIFEXITED(status): + os._exit(os.WEXITSTATUS(status)) + if os.WIFSIGNALED(status): + sig = os.WTERMSIG(status) + try: + signal.signal(sig, signal.SIG_DFL) + if _SIGNAL_SELF is None: + raise OSError("POSIX self-signal is unavailable") + _SIGNAL_SELF(os.getpid(), sig) + except (OSError, ValueError): + os._exit(128 + sig) + os._exit(1) + + +def _drain_residue(pgid: int) -> None: + """Let retained nested owners clean their subgroups before final force.""" + + if not _scope_exists(pgid): + return + _signal_group(pgid, signal.SIGTERM) + if _wait_scope_empty(pgid, S.DEFAULT_GRACE_SECONDS): + return + _signal_group(pgid, S.SIGKILL) + _wait_scope_empty(pgid, S.DEFAULT_FORCE_SECONDS) + + +def _stop_scope(child_pid: int, *, force: bool) -> int: + if force: + _signal_group(child_pid, S.SIGKILL) + status = _wait_child(child_pid, S.DEFAULT_FORCE_SECONDS) + _wait_scope_empty(child_pid, S.DEFAULT_FORCE_SECONDS) + else: + _signal_group(child_pid, signal.SIGTERM) + status = _wait_child(child_pid, S.DEFAULT_GRACE_SECONDS) + if status is not None: + # The backend may have exited while nested retained owners are + # still draining their exact child groups. Do not kill those + # owners immediately; give the root group the rest of its grace. + if _wait_scope_empty(child_pid, S.DEFAULT_GRACE_SECONDS): + return status + _signal_group(child_pid, S.SIGKILL) + if status is None: + status = _wait_child(child_pid, S.DEFAULT_FORCE_SECONDS) + _wait_scope_empty(child_pid, S.DEFAULT_FORCE_SECONDS) + + if status is None: + status = _wait_child(child_pid, S.DEFAULT_FORCE_SECONDS) + return 1 if status is None else status + + +def _run_supervisor(spec: AuthoritySpec, child_pid: int) -> None: + requested: list[str] = [] + + def request_graceful(_sig, _frame) -> None: + requested[:] = ["graceful"] + + def request_force(_sig, _frame) -> None: + requested[:] = ["force"] + + signal.signal(signal.SIGTERM, request_graceful) + signal.signal(signal.SIGINT, request_graceful) + for force_signal in (S.SIGHUP, S.SIGUSR2): + if force_signal is not None: + signal.signal(force_signal, request_force) + + while True: + try: + waited, status = os.waitpid(child_pid, os.WNOHANG) + except ChildProcessError: + waited, status = child_pid, 0 + + if waited == child_pid: + # Natural backend exit does not imply nested owners are gone. + # Drain the retained root group first; those owners then terminate + # their private subgroups before this supervisor relinquishes it. + _drain_residue(child_pid) + _exit_from_status(status) + + if os.getppid() != spec.parent_pid: + status = _stop_scope(child_pid, force=True) + _exit_from_status(status) + + if requested: + status = _stop_scope(child_pid, force=requested[-1] == "force") + _exit_from_status(status) + + time.sleep(0.05) + + +def install_posix_process_authority( + *, + environ: Mapping[str, str] | None = None, + platform: str | None = None, +) -> InstalledPosixAuthority | None: + """Install marked authority; leave unmarked processes untouched.""" + + actual_platform = sys.platform if platform is None else platform + source = os.environ if environ is None else environ + spec = S.read_spec(source) + if spec is None: + return None + if actual_platform == "win32": + raise ProcessAuthorityError("POSIX process authority requested on Windows") + + with S.install_lock: + if S.installed is not None: + if S.installed.spec == spec: + return S.installed + raise ProcessAuthorityError( + "a different Desktop generation already owns this process" + ) + + if os.getppid() != spec.parent_pid: + raise ProcessAuthorityError( + "Desktop parent mismatch before POSIX authority installation: " + f"expected {spec.parent_pid}, observed {os.getppid()}" + ) + + fork = getattr(os, "fork", None) + if fork is None: + raise ProcessAuthorityError("POSIX process authority requires fork()") + child_pid = fork() + if child_pid == 0: + try: + if S.original_setsid is None: + raise ProcessAuthorityError( + "POSIX process authority requires setsid()" + ) + pgid = S.original_setsid() + if pgid is None: + pgid = os.getpgrp() + except Exception: + os._exit(126) + os.environ[ROLE_ENV] = "posix-backend" + installed = InstalledPosixAuthority( + spec, + "posix-backend", + int(pgid), + ) + with S.install_lock: + S.installed = installed + _install_descendant_guard() + return installed + + os.environ[ROLE_ENV] = "posix-supervisor" + _run_supervisor(spec, child_pid) + raise AssertionError("POSIX process supervisor returned unexpectedly") + + +def _reset_process_authority_for_tests() -> None: + with S.install_lock: + S.installed = None + _reset_guard_for_tests() + with S.transfer_lock: + S.pending_transfers.clear() diff --git a/hermes_cli/posix_transfer_owner.py b/hermes_cli/posix_transfer_owner.py new file mode 100644 index 0000000000000..d031d5d3f5daf --- /dev/null +++ b/hermes_cli/posix_transfer_owner.py @@ -0,0 +1,334 @@ +"""Receiving owner for a completed POSIX process-authority transfer. + +The old Desktop-owned backend launches this module as a fresh session leader. +This owner starts the transferred target in a dedicated process group, proves +that the target successfully crossed ``exec``, acknowledges the exact handoff, +and then remains alive to forward lifecycle commands and reap residue. +""" + +from __future__ import annotations + +import base64 +import json +import os +import select +import signal +import sys +import time +from typing import Any + +from tools.environments.local import build_subprocess_env + +_PROTOCOL = "desktop-posix-transfer-v1" +_EXEC_ACK_SECONDS = 3.0 +_GRACE_SECONDS = 5.0 +_FORCE_SECONDS = 2.0 +_MAX_ACK_BYTES = 4096 +_DESCENDANT_GUARD_ENV = "_HERMES_DESKTOP_POSIX_DESCENDANT_GUARD" +_AUTHORITY_MODE = "posix-session-v1" +_LIFETIME_ENV = "HERMES_DESKTOP_PROCESS_LIFETIME" + +_FORK = getattr(os, "fork", None) +_SET_PGID = getattr(os, "setpgid", None) +_KILL_GROUP = getattr(os, "killpg", None) +_GET_SID = getattr(os, "getsid", None) +_GET_PGID = getattr(os, "getpgid", None) +_SIGKILL = getattr(signal, "SIGKILL", signal.SIGTERM) +_SIGHUP = getattr(signal, "SIGHUP", None) +_SIGUSR2 = getattr(signal, "SIGUSR2", None) + + +class TransferOwnerError(RuntimeError): + """The receiving owner could not establish or retain its exact scope.""" + + +def _decode_spec(raw: str) -> dict[str, Any]: + try: + decoded = base64.urlsafe_b64decode(raw.encode("ascii")) + spec = json.loads(decoded.decode("utf-8")) + except (ValueError, UnicodeError, json.JSONDecodeError) as exc: + raise TransferOwnerError("malformed process-transfer specification") from exc + + if not isinstance(spec, dict) or spec.get("protocol") != _PROTOCOL: + raise TransferOwnerError("unsupported process-transfer protocol") + if not isinstance(spec.get("token"), str) or not spec["token"]: + raise TransferOwnerError("process-transfer token is missing") + if not isinstance(spec.get("receiver"), str) or not spec["receiver"]: + raise TransferOwnerError("process-transfer receiver is missing") + if not isinstance(spec.get("ack_fd"), int) or spec["ack_fd"] < 0: + raise TransferOwnerError("process-transfer acknowledgement descriptor is invalid") + if not isinstance(spec.get("sender_pid"), int) or spec["sender_pid"] <= 0: + raise TransferOwnerError("process-transfer sender identity is invalid") + if not isinstance(spec.get("executable"), str) or not spec["executable"]: + raise TransferOwnerError("transferred executable is missing") + argv = spec.get("argv") + if not isinstance(argv, list) or not argv or not all(isinstance(value, str) for value in argv): + raise TransferOwnerError("transferred argv is malformed") + pass_fds = spec.get("target_pass_fds", []) + if not isinstance(pass_fds, list) or not all(isinstance(fd, int) and fd >= 0 for fd in pass_fds): + raise TransferOwnerError("transferred descriptor list is malformed") + return spec + + +def _close(fd: int) -> None: + try: + os.close(fd) + except OSError: + pass + + +def _write_ack(fd: int, payload: dict[str, Any]) -> bool: + encoded = (json.dumps(payload, separators=(",", ":")) + "\n").encode("utf-8") + if len(encoded) > _MAX_ACK_BYTES: + return False + try: + os.write(fd, encoded) + return True + except (BrokenPipeError, OSError): + return False + + +def _error(kind: str, detail: str, *, errno: int | None = None) -> dict[str, Any]: + payload: dict[str, Any] = {"type": kind, "detail": detail} + if errno is not None: + payload["errno"] = errno + return payload + + +def _kill_scope(scope_id: int, sig: int) -> None: + if _KILL_GROUP is None: + raise TransferOwnerError("receiving owner requires process-group signalling") + try: + _KILL_GROUP(scope_id, sig) + except (ProcessLookupError, PermissionError): + pass + + +def _wait_child(child_pid: int, timeout: float) -> int | None: + deadline = time.monotonic() + max(timeout, 0.0) + while True: + try: + waited, status = os.waitpid(child_pid, os.WNOHANG) + except ChildProcessError: + return 0 + if waited == child_pid: + return status + if time.monotonic() >= deadline: + return None + time.sleep(0.05) + + +def _stop_scope(child_pid: int, *, force: bool) -> int | None: + if force: + _kill_scope(child_pid, _SIGKILL) + status = _wait_child(child_pid, _FORCE_SECONDS) + else: + _kill_scope(child_pid, signal.SIGTERM) + status = _wait_child(child_pid, _GRACE_SECONDS) + if status is None: + _kill_scope(child_pid, _SIGKILL) + status = _wait_child(child_pid, _FORCE_SECONDS) + if status is None: + _kill_scope(child_pid, _SIGKILL) + status = _wait_child(child_pid, _FORCE_SECONDS) + return status + + +def _exit_from_status(status: int | None) -> None: + if status is None: + os._exit(1) + if os.WIFEXITED(status): + os._exit(os.WEXITSTATUS(status)) + if os.WIFSIGNALED(status): + os._exit(128 + os.WTERMSIG(status)) + os._exit(1) + + +def _install_handlers(requested: list[int]) -> tuple[int, ...]: + handled: list[int] = [] + + def request(sig: int, _frame: Any) -> None: + requested[:] = [sig] + + for candidate in (signal.SIGTERM, signal.SIGINT, _SIGHUP, _SIGUSR2): + if candidate is None or candidate in handled: + continue + signal.signal(candidate, request) + handled.append(candidate) + return tuple(handled) + + +def _reset_handlers(handled: tuple[int, ...]) -> None: + for sig in handled: + signal.signal(sig, signal.SIG_DFL) + + +def _read_exec_status( + fd: int, + *, + sender_pid: int, + requested: list[int], +) -> dict[str, Any] | None: + deadline = time.monotonic() + _EXEC_ACK_SECONDS + while True: + if requested: + return _error("TransferCancelled", "receiving owner was stopped before acknowledgement") + if os.getppid() != sender_pid: + return _error("SenderExited", "transferring owner exited before acknowledgement") + + remaining = deadline - time.monotonic() + if remaining <= 0: + return _error("ExecTimeout", "transferred target did not cross exec before timeout") + ready, _, _ = select.select([fd], [], [], min(remaining, 0.05)) + if not ready: + continue + + payload = os.read(fd, _MAX_ACK_BYTES + 1) + if not payload: + return None + if len(payload) > _MAX_ACK_BYTES: + return _error("ExecError", "transferred target exec failure was oversized") + try: + decoded = json.loads(payload.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return _error("ExecError", "transferred target exec failure was malformed") + if not isinstance(decoded, dict): + return _error("ExecError", "transferred target exec failure was malformed") + return decoded + + +def _target_identity(child_pid: int, owner_pid: int) -> tuple[int, int]: + if _GET_SID is None or _GET_PGID is None: + raise TransferOwnerError("receiving owner requires session and process-group identity") + try: + target_sid = int(_GET_SID(child_pid)) + target_pgid = int(_GET_PGID(child_pid)) + except (ProcessLookupError, PermissionError) as exc: + raise TransferOwnerError("transferred target exited before acknowledgement") from exc + if target_sid != owner_pid or target_pgid != child_pid: + raise TransferOwnerError("transferred target did not enter the receiving owner's exact scope") + return target_sid, target_pgid + + +def _run_target(spec: dict[str, Any], status_read: int, status_write: int, handled: tuple[int, ...]) -> None: + _close(status_read) + _reset_handlers(handled) + try: + if _SET_PGID is None: + raise TransferOwnerError("receiving owner requires process-group creation") + _SET_PGID(0, 0) + target_env = build_subprocess_env( + scrub_secrets=False, + inherit_profile_home=False, + ) + target_env[_DESCENDANT_GUARD_ENV] = _AUTHORITY_MODE + target_env[_LIFETIME_ENV] = "contained" + os.execvpe(spec["executable"], list(spec["argv"]), target_env) + except BaseException as exc: + payload = _error( + type(exc).__name__, + str(exc), + errno=getattr(exc, "errno", None), + ) + try: + os.write( + status_write, + json.dumps(payload, separators=(",", ":")).encode("utf-8")[:_MAX_ACK_BYTES], + ) + except OSError: + pass + finally: + os._exit(127) + + +def _supervise(child_pid: int, requested: list[int]) -> None: + force_signals = {sig for sig in (_SIGHUP, _SIGUSR2) if sig is not None} + while True: + try: + waited, status = os.waitpid(child_pid, os.WNOHANG) + except ChildProcessError: + waited, status = child_pid, 0 + + if waited == child_pid: + _kill_scope(child_pid, _SIGKILL) + _exit_from_status(status) + + if requested: + status = _stop_scope(child_pid, force=requested[-1] in force_signals) + _kill_scope(child_pid, _SIGKILL) + _exit_from_status(status) + + time.sleep(0.05) + + +def main() -> None: + if len(sys.argv) < 2: + raise TransferOwnerError("process-transfer specification is missing") + spec = _decode_spec(sys.argv[1]) + if _FORK is None or _SET_PGID is None or _KILL_GROUP is None: + raise TransferOwnerError("receiving owner requires POSIX process primitives") + + ack_fd = int(spec["ack_fd"]) + os.set_inheritable(ack_fd, False) + requested: list[int] = [] + handled = _install_handlers(requested) + + status_read, status_write = os.pipe() + os.set_inheritable(status_write, False) + child_pid = _FORK() + if child_pid == 0: + _run_target(spec, status_read, status_write, handled) + raise AssertionError("transferred target returned from exec") + + _close(status_write) + try: + _SET_PGID(child_pid, child_pid) + except (PermissionError, ProcessLookupError): + pass + + for fd in spec.get("target_pass_fds", []): + if fd > 2 and fd != ack_fd: + _close(fd) + + owner_pid = os.getpid() + if os.getppid() != int(spec["sender_pid"]): + exec_error = _error("SenderExited", "transferring owner exited before receiver startup") + else: + exec_error = _read_exec_status( + status_read, + sender_pid=int(spec["sender_pid"]), + requested=requested, + ) + _close(status_read) + + ack: dict[str, Any] = { + "protocol": spec["protocol"], + "token": spec["token"], + "receiver": spec["receiver"], + "owner_pid": owner_pid, + "owner_sid": int(_GET_SID(0)) if _GET_SID is not None else -1, + "owner_pgid": os.getpgrp(), + "scope_id": child_pid, + } + + if exec_error is None: + try: + target_sid, target_pgid = _target_identity(child_pid, owner_pid) + ack["target_sid"] = target_sid + ack["target_pgid"] = target_pgid + except TransferOwnerError as exc: + exec_error = _error(type(exc).__name__, str(exc)) + if exec_error is not None: + ack["error"] = exec_error + + acknowledged = _write_ack(ack_fd, ack) + _close(ack_fd) + if exec_error is not None or not acknowledged: + _stop_scope(child_pid, force=True) + os._exit(127 if exec_error is not None else 126) + + _supervise(child_pid, requested) + + +if __name__ == "__main__": + main() diff --git a/hermes_cli/windows_process_authority.py b/hermes_cli/windows_process_authority.py new file mode 100644 index 0000000000000..aef38548e0510 --- /dev/null +++ b/hermes_cli/windows_process_authority.py @@ -0,0 +1,373 @@ +"""Generation-bound Windows process authority for Desktop-owned backends. + +A PID is observation, never destructive authority. The Electron Desktop marks +each backend launch with a fresh generation plus the parent process creation +time. At Python bootstrap, before Hermes can spawn any children, this module: + +* creates an unnamed Job Object with ``KILL_ON_JOB_CLOSE``; +* verifies the parent PID against its creation time and retains that process + HANDLE for the lifetime of the backend; +* assigns the current process to the Job Object; and +* closes the Job Object when the retained parent HANDLE signals. + +Normal root shutdown and Desktop crashes both close the last Job Object HANDLE, +so Windows terminates the whole backend tree without a later PID lookup. Any +setup/identity failure aborts the Desktop-owned backend instead of falling back +to ``taskkill`` or a reconstructed PID (#89614). +""" + +from __future__ import annotations + +import ctypes +import os +import re +import sys +import threading +from ctypes import wintypes +from dataclasses import dataclass +from typing import Any, Callable, Mapping, Protocol + +AUTHORITY_MODE_ENV = "HERMES_DESKTOP_PROCESS_AUTHORITY" +AUTHORITY_MODE = "windows-job-v1" +GENERATION_ENV = "HERMES_DESKTOP_PROCESS_GENERATION" +PARENT_PID_ENV = "HERMES_DESKTOP_PARENT_PID" +PARENT_STARTED_AT_ENV = "HERMES_DESKTOP_PARENT_STARTED_AT_MS" + +_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9 +_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 +_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 +_SYNCHRONIZE = 0x00100000 +_INFINITE = 0xFFFFFFFF +_WAIT_OBJECT_0 = 0x00000000 +_WINDOWS_EPOCH_100NS = 116444736000000000 +_PARENT_START_TOLERANCE_MS = 5_000 +_GENERATION_RE = re.compile(r"^[A-Za-z0-9._-]{16,128}$") + + +class ProcessAuthorityError(RuntimeError): + """Desktop process authority could not be established safely.""" + + +@dataclass(frozen=True) +class AuthoritySpec: + generation: str + parent_pid: int + parent_started_at_ms: int + + +@dataclass +class InstalledAuthority: + spec: AuthoritySpec + api: "WindowsAuthorityApi" + job_handle: Any + parent_handle: Any + watcher: Any + + +class WindowsAuthorityApi(Protocol): + def create_job(self) -> Any: ... + + def enable_kill_on_close(self, job_handle: Any) -> None: ... + + def current_process_handle(self) -> Any: ... + + def assign_current_process(self, job_handle: Any, process_handle: Any) -> None: ... + + def open_parent(self, pid: int) -> Any: ... + + def process_started_at_ms(self, process_handle: Any) -> int: ... + + def wait_for_process_exit(self, process_handle: Any) -> None: ... + + def close_handle(self, handle: Any) -> None: ... + + +class _IoCounters(ctypes.Structure): + _fields_ = [ + ("ReadOperationCount", ctypes.c_ulonglong), + ("WriteOperationCount", ctypes.c_ulonglong), + ("OtherOperationCount", ctypes.c_ulonglong), + ("ReadTransferCount", ctypes.c_ulonglong), + ("WriteTransferCount", ctypes.c_ulonglong), + ("OtherTransferCount", ctypes.c_ulonglong), + ] + + +class _BasicLimitInformation(ctypes.Structure): + _fields_ = [ + ("PerProcessUserTimeLimit", ctypes.c_longlong), + ("PerJobUserTimeLimit", ctypes.c_longlong), + ("LimitFlags", wintypes.DWORD), + ("MinimumWorkingSetSize", ctypes.c_size_t), + ("MaximumWorkingSetSize", ctypes.c_size_t), + ("ActiveProcessLimit", wintypes.DWORD), + ("Affinity", ctypes.c_size_t), + ("PriorityClass", wintypes.DWORD), + ("SchedulingClass", wintypes.DWORD), + ] + + +class _ExtendedLimitInformation(ctypes.Structure): + _fields_ = [ + ("BasicLimitInformation", _BasicLimitInformation), + ("IoInfo", _IoCounters), + ("ProcessMemoryLimit", ctypes.c_size_t), + ("JobMemoryLimit", ctypes.c_size_t), + ("PeakProcessMemoryUsed", ctypes.c_size_t), + ("PeakJobMemoryUsed", ctypes.c_size_t), + ] + + +class _Kernel32AuthorityApi: + def __init__(self) -> None: + if sys.platform != "win32": + raise ProcessAuthorityError("Windows process authority requested off Windows") + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + self._kernel32 = kernel32 + + kernel32.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR] + kernel32.CreateJobObjectW.restype = wintypes.HANDLE + kernel32.SetInformationJobObject.argtypes = [ + wintypes.HANDLE, + ctypes.c_int, + ctypes.c_void_p, + wintypes.DWORD, + ] + kernel32.SetInformationJobObject.restype = wintypes.BOOL + kernel32.AssignProcessToJobObject.argtypes = [ + wintypes.HANDLE, + wintypes.HANDLE, + ] + kernel32.AssignProcessToJobObject.restype = wintypes.BOOL + kernel32.GetCurrentProcess.argtypes = [] + kernel32.GetCurrentProcess.restype = wintypes.HANDLE + kernel32.OpenProcess.argtypes = [ + wintypes.DWORD, + wintypes.BOOL, + wintypes.DWORD, + ] + kernel32.OpenProcess.restype = wintypes.HANDLE + kernel32.GetProcessTimes.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(wintypes.FILETIME), + ctypes.POINTER(wintypes.FILETIME), + ctypes.POINTER(wintypes.FILETIME), + ctypes.POINTER(wintypes.FILETIME), + ] + kernel32.GetProcessTimes.restype = wintypes.BOOL + kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] + kernel32.WaitForSingleObject.restype = wintypes.DWORD + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + + @staticmethod + def _raise_last_error(operation: str) -> None: + code = ctypes.get_last_error() + raise ProcessAuthorityError(f"{operation} failed: {ctypes.WinError(code)}") + + def create_job(self) -> Any: + handle = self._kernel32.CreateJobObjectW(None, None) + if not handle: + self._raise_last_error("CreateJobObjectW") + return handle + + def enable_kill_on_close(self, job_handle: Any) -> None: + info = _ExtendedLimitInformation() + info.BasicLimitInformation.LimitFlags = _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + ok = self._kernel32.SetInformationJobObject( + job_handle, + _JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, + ctypes.byref(info), + ctypes.sizeof(info), + ) + if not ok: + self._raise_last_error("SetInformationJobObject") + + def current_process_handle(self) -> Any: + return self._kernel32.GetCurrentProcess() + + def assign_current_process(self, job_handle: Any, process_handle: Any) -> None: + if not self._kernel32.AssignProcessToJobObject(job_handle, process_handle): + self._raise_last_error("AssignProcessToJobObject") + + def open_parent(self, pid: int) -> Any: + handle = self._kernel32.OpenProcess( + _SYNCHRONIZE | _PROCESS_QUERY_LIMITED_INFORMATION, + False, + pid, + ) + if not handle: + self._raise_last_error("OpenProcess(parent)") + return handle + + def process_started_at_ms(self, process_handle: Any) -> int: + created = wintypes.FILETIME() + exited = wintypes.FILETIME() + kernel = wintypes.FILETIME() + user = wintypes.FILETIME() + if not self._kernel32.GetProcessTimes( + process_handle, + ctypes.byref(created), + ctypes.byref(exited), + ctypes.byref(kernel), + ctypes.byref(user), + ): + self._raise_last_error("GetProcessTimes(parent)") + ticks = (created.dwHighDateTime << 32) | created.dwLowDateTime + return int((ticks - _WINDOWS_EPOCH_100NS) / 10_000) + + def wait_for_process_exit(self, process_handle: Any) -> None: + result = self._kernel32.WaitForSingleObject(process_handle, _INFINITE) + if result != _WAIT_OBJECT_0: + raise ProcessAuthorityError( + "WaitForSingleObject(parent) returned unexpected status " + f"0x{result:08x}" + ) + + def close_handle(self, handle: Any) -> None: + if handle: + self._kernel32.CloseHandle(handle) + + +_install_lock = threading.Lock() +_installed: InstalledAuthority | None = None + + +def _read_spec(environ: Mapping[str, str]) -> AuthoritySpec | None: + mode = (environ.get(AUTHORITY_MODE_ENV) or "").strip() + if not mode: + return None + if mode != AUTHORITY_MODE: + raise ProcessAuthorityError( + f"unsupported desktop process authority mode: {mode!r}" + ) + + generation = (environ.get(GENERATION_ENV) or "").strip() + if not _GENERATION_RE.fullmatch(generation): + raise ProcessAuthorityError("desktop process generation is missing or malformed") + + try: + parent_pid = int((environ.get(PARENT_PID_ENV) or "").strip()) + parent_started_at_ms = int( + (environ.get(PARENT_STARTED_AT_ENV) or "").strip() + ) + except ValueError as exc: + raise ProcessAuthorityError("desktop parent identity is malformed") from exc + + if parent_pid <= 0 or parent_started_at_ms <= 0: + raise ProcessAuthorityError("desktop parent identity must be positive") + + return AuthoritySpec( + generation=generation, + parent_pid=parent_pid, + parent_started_at_ms=parent_started_at_ms, + ) + + +def install_windows_process_authority( + *, + environ: Mapping[str, str] | None = None, + platform: str | None = None, + api: WindowsAuthorityApi | None = None, + thread_factory: Callable[..., Any] = threading.Thread, + parent_start_tolerance_ms: int = _PARENT_START_TOLERANCE_MS, +) -> InstalledAuthority | None: + """Install and retain the Desktop-owned Windows Job Object authority. + + Returns ``None`` when the process was not launched under the Desktop + authority envelope or when running off Windows. Once the envelope is + present on Windows, every validation/setup failure raises + :class:`ProcessAuthorityError`; there is deliberately no PID fallback. + """ + + global _installed + + actual_platform = sys.platform if platform is None else platform + source = os.environ if environ is None else environ + spec = _read_spec(source) + if spec is None or actual_platform != "win32": + return None + + if parent_start_tolerance_ms < 0: + raise ValueError("parent_start_tolerance_ms must be non-negative") + + with _install_lock: + if _installed is not None: + if _installed.spec == spec: + return _installed + raise ProcessAuthorityError( + "a different desktop process generation already owns this process" + ) + + winapi = api or _Kernel32AuthorityApi() + job_handle = None + parent_handle = None + try: + job_handle = winapi.create_job() + winapi.enable_kill_on_close(job_handle) + + # Bind the parent PID to the creation marker passed at spawn time + # before granting it lifecycle authority. A recycled PID cannot + # satisfy both values. + parent_handle = winapi.open_parent(spec.parent_pid) + observed_start = winapi.process_started_at_ms(parent_handle) + if ( + abs(observed_start - spec.parent_started_at_ms) + > parent_start_tolerance_ms + ): + raise ProcessAuthorityError( + "desktop parent process generation mismatch: " + f"expected {spec.parent_started_at_ms}, " + f"observed {observed_start}" + ) + + # The current-process pseudo-handle is already generation-bound; + # no PID lookup occurs here. + winapi.assign_current_process( + job_handle, + winapi.current_process_handle(), + ) + + def _watch_parent() -> None: + try: + winapi.wait_for_process_exit(parent_handle) + finally: + # Closing the last job handle is the tree-wide teardown + # primitive. It terminates this root and every descendant + # still in the job without resolving any PID. + winapi.close_handle(parent_handle) + winapi.close_handle(job_handle) + + watcher = thread_factory( + target=_watch_parent, + name="hermes-desktop-parent-authority", + daemon=True, + ) + installed = InstalledAuthority( + spec=spec, + api=winapi, + job_handle=job_handle, + parent_handle=parent_handle, + watcher=watcher, + ) + _installed = installed + try: + watcher.start() + except Exception: + _installed = None + raise + return installed + except Exception: + if parent_handle is not None: + winapi.close_handle(parent_handle) + if job_handle is not None: + winapi.close_handle(job_handle) + raise + + +def _reset_process_authority_for_tests() -> None: + """Clear the process-global installation latch for isolated unit tests.""" + + global _installed + with _install_lock: + _installed = None diff --git a/tests/hermes_cli/test_desktop_process_authority_bootstrap.py b/tests/hermes_cli/test_desktop_process_authority_bootstrap.py new file mode 100644 index 0000000000000..efea17dddff62 --- /dev/null +++ b/tests/hermes_cli/test_desktop_process_authority_bootstrap.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import os +import runpy +import sys +import types +from pathlib import Path +from unittest.mock import Mock + +import pytest + +BOOTSTRAP = ( + Path(__file__).resolve().parents[2] + / "hermes_cli" + / "desktop_bootstrap" + / "sitecustomize.py" +) +AUTHORITY_ENV = "HERMES_DESKTOP_PROCESS_AUTHORITY" +DESCENDANT_GUARD_ENV = "_HERMES_DESKTOP_POSIX_DESCENDANT_GUARD" + + +def run_bootstrap(monkeypatch, mode: str | None): + monkeypatch.delenv(DESCENDANT_GUARD_ENV, raising=False) + if mode is None: + monkeypatch.delenv(AUTHORITY_ENV, raising=False) + else: + monkeypatch.setenv(AUTHORITY_ENV, mode) + return runpy.run_path( + str(BOOTSTRAP), + run_name="desktop_authority_sitecustomize_test", + ) + + +def test_unmarked_interpreter_is_untouched(monkeypatch): + run_bootstrap(monkeypatch, None) + + +def test_windows_mode_installs_windows_authority(monkeypatch): + install = Mock(return_value=None) + module = types.ModuleType("hermes_cli.windows_process_authority") + module.install_windows_process_authority = install + monkeypatch.setitem(sys.modules, module.__name__, module) + + run_bootstrap(monkeypatch, "windows-job-v1") + + install.assert_called_once_with() + + +def test_posix_mode_installs_posix_authority(monkeypatch): + install = Mock(return_value=None) + module = types.ModuleType("hermes_cli.posix_process_authority") + module.install_posix_process_authority = install + monkeypatch.setitem(sys.modules, module.__name__, module) + + run_bootstrap(monkeypatch, "posix-session-v1") + + install.assert_called_once_with() + + +def test_unknown_marked_mode_fails_closed(monkeypatch): + with pytest.raises(RuntimeError, match="unsupported Desktop process authority mode"): + run_bootstrap(monkeypatch, "pid-v1") + + +def test_bootstrap_does_not_mutate_mode(monkeypatch): + run_bootstrap(monkeypatch, None) + assert os.environ.get(AUTHORITY_ENV) is None + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX bootstrap topology") +def test_scoped_main_shim_arms_authority_before_real_package_init( + monkeypatch, + tmp_path, +): + import json + import shutil + import subprocess + + project_root = Path(__file__).resolve().parents[2] + bootstrap_root = project_root / "hermes_cli" / "desktop_bootstrap" + real_package = tmp_path / "real" / "hermes_cli" + real_package.mkdir(parents=True) + isolated_cwd = tmp_path / "cwd" + isolated_cwd.mkdir() + marker = tmp_path / "real-init-pids.txt" + (real_package / "__init__.py").write_text( + "import os\n" + "from pathlib import Path\n" + "Path(os.environ['BOOTSTRAP_INIT_MARKER']).open('a').write(str(os.getpid()) + '\\n')\n", + encoding="utf-8", + ) + (real_package / "main.py").write_text( + "import json, os\n" + "print(json.dumps({'backend': os.getpid(), 'pgid': os.getpgrp()}), flush=True)\n", + encoding="utf-8", + ) + for name in ( + "posix_process_authority.py", + "_posix_process_authority_state.py", + "_posix_process_transfer.py", + "_posix_process_guard.py", + "_subprocess_compat.py", + ): + shutil.copy2(project_root / "hermes_cli" / name, real_package / name) + + env = os.environ.copy() + env.update( + { + AUTHORITY_ENV: "posix-session-v1", + "HERMES_DESKTOP_PROCESS_GENERATION": "generation-bootstrap-shim-01", + "HERMES_DESKTOP_PARENT_PID": str(os.getpid()), + "HERMES_DESKTOP_PARENT_STARTED_AT_MS": "1700000000000", + "BOOTSTRAP_INIT_MARKER": str(marker), + "PYTHONPATH": os.pathsep.join( + [bootstrap_root.as_posix(), real_package.parent.as_posix()] + ), + } + ) + proc = subprocess.Popen( + [sys.executable, "-m", "hermes_cli.main"], + cwd=isolated_cwd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + stdout, stderr = proc.communicate(timeout=10) + + assert proc.returncode == 0, stderr + payload = json.loads(stdout.strip()) + assert payload["backend"] != proc.pid + initialized_pids = [ + int(value) + for value in marker.read_text(encoding="utf-8").splitlines() + ] + assert initialized_pids == [payload["backend"]], ( + "the retained supervisor must not import the real Hermes package" + ) diff --git a/tests/hermes_cli/test_posix_nested_process_authority.py b/tests/hermes_cli/test_posix_nested_process_authority.py new file mode 100644 index 0000000000000..9bd28ba4179ac --- /dev/null +++ b/tests/hermes_cli/test_posix_nested_process_authority.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +import json +import os +import select +import subprocess +import sys +import textwrap +import time +from pathlib import Path + +import psutil +import pytest + +from hermes_cli import posix_process_authority as authority + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +BOOTSTRAP_FILE = PROJECT_ROOT / "hermes_cli" / "desktop_bootstrap" / "sitecustomize.py" + + +def authority_env(generation: str) -> dict[str, str]: + env = os.environ.copy() + env.update( + { + authority.AUTHORITY_MODE_ENV: authority.AUTHORITY_MODE, + authority.GENERATION_ENV: generation, + authority.PARENT_PID_ENV: str(os.getpid()), + authority.PARENT_STARTED_AT_ENV: "1700000000000", + "PYTHONPATH": os.pathsep.join( + [str(PROJECT_ROOT), env.get("PYTHONPATH", "")] + ).rstrip(os.pathsep), + } + ) + return env + + +def bootstrapped(code: str) -> str: + return f"import runpy\nrunpy.run_path({str(BOOTSTRAP_FILE)!r})\n" + textwrap.dedent(code) + + +def spawn_authority(script: str, generation: str) -> subprocess.Popen[str]: + return subprocess.Popen( + [sys.executable, "-c", bootstrapped(script)], + env=authority_env(generation), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + +def read_json_line(proc: subprocess.Popen[str], timeout: float = 10.0) -> dict[str, object]: + assert proc.stdout is not None + ready, _, _ = select.select([proc.stdout], [], [], timeout) + if not ready: + stderr = proc.stderr.read() if proc.stderr and proc.poll() is not None else "" + raise AssertionError(f"authority process produced no output: {stderr}") + line = proc.stdout.readline() + assert line, proc.stderr.read() if proc.stderr else "authority process produced no output" + return json.loads(line) + + +def process_is_live(pid: int) -> bool: + try: + process = psutil.Process(pid) + return process.is_running() and process.status() != psutil.STATUS_ZOMBIE + except psutil.NoSuchProcess: + return False + + +def wait_not_live(pid: int, timeout: float = 8.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not process_is_live(pid): + return True + time.sleep(0.05) + return not process_is_live(pid) + + +def stop_supervisor(proc: subprocess.Popen[str]) -> None: + if proc.poll() is None: + proc.terminate() + proc.wait(timeout=12) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX authority") +def test_nested_owned_popen_preserves_local_scope_and_outer_reap(): + target_code = textwrap.dedent( + """ + import json, os, time + print(json.dumps({ + "target": os.getpid(), + "target_sid": os.getsid(0), + "target_pgid": os.getpgrp(), + }), flush=True) + time.sleep(60) + """ + ) + proc = spawn_authority( + f""" + import json, os, subprocess, sys, time + + control = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(60)"], + ) + nested = subprocess.Popen( + [sys.executable, "-c", {target_code!r}], + stdout=subprocess.PIPE, + text=True, + start_new_session=True, + ) + target = json.loads(nested.stdout.readline()) + print(json.dumps({{ + "backend": os.getpid(), + "backend_pgid": os.getpgrp(), + "control": control.pid, + "owner": nested.pid, + "owner_pgid": os.getpgid(nested.pid), + "nested_owned": bool(getattr(nested, "__hermes_nested_owned__", False)), + **target, + }}), flush=True) + + nested.terminate() + nested.wait(timeout=8) + print(json.dumps({{ + "control_alive": control.poll() is None, + "backend_alive": True, + }}), flush=True) + time.sleep(60) + """, + "generation-posix-nested-owned-01", + ) + topology = read_json_line(proc) + local_cleanup = read_json_line(proc) + try: + backend_pgid = int(topology["backend_pgid"]) + target = int(topology["target"]) + assert topology["nested_owned"] is True + assert int(topology["owner_pgid"]) == backend_pgid + assert int(topology["target_sid"]) == target + assert int(topology["target_pgid"]) == target + assert target != backend_pgid + assert local_cleanup == {"control_alive": True, "backend_alive": True} + assert wait_not_live(target) + assert process_is_live(int(topology["control"])) + finally: + stop_supervisor(proc) + assert wait_not_live(int(topology["control"])) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX authority") +def test_mcp_watchdog_cleanup_cannot_widen_to_desktop_root(): + target_code = textwrap.dedent( + """ + import json, os, time + print(json.dumps({ + "target": os.getpid(), + "target_pgid": os.getpgrp(), + }), flush=True) + time.sleep(60) + """ + ) + proc = spawn_authority( + f""" + import json, os, subprocess, sys, time + from tools.mcp_stdio_watchdog import _terminate_process_group + + control = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(60)"], + ) + child = subprocess.Popen( + [sys.executable, "-c", {target_code!r}], + stdout=subprocess.PIPE, + text=True, + start_new_session=True, + ) + target = json.loads(child.stdout.readline()) + _terminate_process_group(child) + print(json.dumps({{ + "backend": os.getpid(), + "backend_pgid": os.getpgrp(), + "control": control.pid, + "control_alive": control.poll() is None, + "nested_owned": bool(getattr(child, "__hermes_nested_owned__", False)), + **target, + }}), flush=True) + time.sleep(60) + """, + "generation-posix-mcp-nested-01", + ) + topology = read_json_line(proc, timeout=12) + try: + assert topology["nested_owned"] is True + assert topology["control_alive"] is True + assert int(topology["target_pgid"]) == int(topology["target"]) + assert int(topology["target_pgid"]) != int(topology["backend_pgid"]) + assert wait_not_live(int(topology["target"])) + assert process_is_live(int(topology["backend"])) + assert process_is_live(int(topology["control"])) + finally: + stop_supervisor(proc) + assert wait_not_live(int(topology["control"])) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX authority") +def test_asyncio_lsp_shape_retains_private_scope_under_guard(): + target_code = textwrap.dedent( + """ + import json, os, time + print(json.dumps({ + "target": os.getpid(), + "target_sid": os.getsid(0), + "target_pgid": os.getpgrp(), + }), flush=True) + time.sleep(60) + """ + ) + proc = spawn_authority( + f""" + import asyncio, json, os, sys + + async def main(): + child = await asyncio.create_subprocess_exec( + sys.executable, + "-c", + {target_code!r}, + stdout=asyncio.subprocess.PIPE, + start_new_session=True, + ) + line = await child.stdout.readline() + target = json.loads(line.decode()) + print(json.dumps({{ + "backend": os.getpid(), + "backend_pgid": os.getpgrp(), + "owner": child.pid, + "owner_pgid": os.getpgid(child.pid), + **target, + }}), flush=True) + child.terminate() + await asyncio.wait_for(child.wait(), timeout=8) + + asyncio.run(main()) + """, + "generation-posix-lsp-shape-01", + ) + topology = read_json_line(proc, timeout=12) + proc.wait(timeout=12) + target = int(topology["target"]) + assert int(topology["owner_pgid"]) == int(topology["backend_pgid"]) + assert int(topology["target_sid"]) == target + assert int(topology["target_pgid"]) == target + assert target != int(topology["backend_pgid"]) + assert wait_not_live(target) diff --git a/tests/hermes_cli/test_posix_process_authority.py b/tests/hermes_cli/test_posix_process_authority.py new file mode 100644 index 0000000000000..0c36de1b227de --- /dev/null +++ b/tests/hermes_cli/test_posix_process_authority.py @@ -0,0 +1,401 @@ +from __future__ import annotations + +import json +import os +import select +import signal +import subprocess +import sys +import textwrap +import time +from pathlib import Path + +import psutil +import pytest + +from hermes_cli import posix_process_authority as authority + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +BOOTSTRAP_FILE = PROJECT_ROOT / "hermes_cli" / "desktop_bootstrap" / "sitecustomize.py" + + +def authority_env(generation: str) -> dict[str, str]: + env = os.environ.copy() + env.update( + { + authority.AUTHORITY_MODE_ENV: authority.AUTHORITY_MODE, + authority.GENERATION_ENV: generation, + authority.PARENT_PID_ENV: str(os.getpid()), + authority.PARENT_STARTED_AT_ENV: "1700000000000", + "PYTHONPATH": os.pathsep.join( + [str(PROJECT_ROOT), env.get("PYTHONPATH", "")] + ).rstrip(os.pathsep), + } + ) + return env + + +def bootstrapped(code: str) -> str: + return f"import runpy\nrunpy.run_path({str(BOOTSTRAP_FILE)!r})\n" + textwrap.dedent(code) + + +def process_is_live(pid: int) -> bool: + try: + process = psutil.Process(pid) + return process.is_running() and process.status() != psutil.STATUS_ZOMBIE + except psutil.NoSuchProcess: + return False + + +def wait_not_live(pid: int, timeout: float = 8.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not process_is_live(pid): + return True + time.sleep(0.05) + return not process_is_live(pid) + + +def read_json_line(proc: subprocess.Popen[str], timeout: float = 10.0) -> dict[str, object]: + assert proc.stdout is not None + ready, _, _ = select.select([proc.stdout], [], [], timeout) + if not ready: + stderr = proc.stderr.read() if proc.stderr and proc.poll() is not None else "" + raise AssertionError(f"authority process produced no output: {stderr}") + line = proc.stdout.readline() + assert line, proc.stderr.read() if proc.stderr else "authority process produced no output" + return json.loads(line) + + +def spawn_authority(script: str, generation: str) -> subprocess.Popen[str]: + return subprocess.Popen( + [sys.executable, "-c", bootstrapped(script)], + env=authority_env(generation), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + +def stop_supervisor(proc: subprocess.Popen[str]) -> None: + if proc.poll() is None: + force_signal = getattr(signal, "SIGUSR2", signal.SIGTERM) + proc.send_signal(force_signal) + proc.wait(timeout=10) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX authority") +def test_nested_containment_propagates_and_root_teardown_reaps_scope(): + control = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(60)"], + start_new_session=True, + ) + child_code = bootstrapped( + """ + import json, os, subprocess, sys, time + grandchild = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(60)"], + start_new_session=True, + ) + print(json.dumps({ + "child": os.getpid(), + "child_pgid": os.getpgrp(), + "grandchild_owner": grandchild.pid, + }), flush=True) + time.sleep(60) + """ + ) + proc = spawn_authority( + f""" + import json, os, subprocess, sys, time + child = subprocess.Popen( + [sys.executable, "-c", {child_code!r}], + stdout=subprocess.PIPE, + text=True, + start_new_session=True, + ) + nested = json.loads(child.stdout.readline()) + print(json.dumps({{ + "backend": os.getpid(), + "backend_pgid": os.getpgrp(), + "child_owner": child.pid, + "child_owner_pgid": os.getpgid(child.pid), + **nested, + }}), flush=True) + time.sleep(60) + """, + "generation-posix-contained-01", + ) + topology = read_json_line(proc) + try: + backend_pgid = int(topology["backend_pgid"]) + child = int(topology["child"]) + child_pgid = int(topology["child_pgid"]) + child_owner = int(topology["child_owner"]) + grandchild_owner = int(topology["grandchild_owner"]) + assert int(topology["backend"]) != proc.pid + assert int(topology["child_owner_pgid"]) == backend_pgid + assert child_pgid == child + assert child_pgid != backend_pgid + assert os.getpgid(grandchild_owner) == child_pgid + + proc.terminate() + proc.wait(timeout=12) + assert wait_not_live(int(topology["backend"])) + assert wait_not_live(child_owner) + assert wait_not_live(child) + assert wait_not_live(grandchild_owner) + assert process_is_live(control.pid), "unrelated process must not be mutated" + finally: + stop_supervisor(proc) + if control.poll() is None: + control.terminate() + control.wait(timeout=8) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX authority") +def test_positive_process_group_and_posix_spawn_setpgroup_cannot_escape(tmp_path: Path): + spawn_result = tmp_path / "spawn-pgid.txt" + proc = spawn_authority( + f""" + import json, os, subprocess, sys, time + popen_child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(60)"], + process_group=987654, + ) + target = {str(spawn_result)!r} + code = ( + "import os; open(" + + repr(target) + + ", 'w', encoding='utf-8').write(str(os.getpgrp()))" + ) + spawned = os.posix_spawn( + sys.executable, + [sys.executable, "-c", code], + os.environ.copy(), + setpgroup=987654, + ) + os.waitpid(spawned, 0) + print(json.dumps({{ + "backend": os.getpid(), + "backend_pgid": os.getpgrp(), + "popen_child": popen_child.pid, + "spawn_pgid": int(open(target, encoding="utf-8").read()), + }}), flush=True) + time.sleep(60) + """, + "generation-posix-positive-pgroup-01", + ) + topology = read_json_line(proc) + try: + backend_pgid = int(topology["backend_pgid"]) + assert os.getpgid(int(topology["popen_child"])) == backend_pgid + assert int(topology["spawn_pgid"]) == backend_pgid + finally: + stop_supervisor(proc) + assert wait_not_live(int(topology["popen_child"])) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX authority") +def test_intentional_detach_helper_completes_child_side_handoff_and_preserves_env(tmp_path: Path): + stop_file = tmp_path / "stop-transferred" + transferred_code = textwrap.dedent( + f""" + import json, os, time + print(json.dumps({{ + "pid": os.getpid(), + "sid": os.getsid(0), + "pgid": os.getpgrp(), + "marker": os.environ.get("HERMES_TRANSFER_TEST_MARKER"), + "receipt": os.environ.get("HERMES_DESKTOP_PROCESS_TRANSFER_RECEIPT"), + }}), flush=True) + stop = {str(stop_file)!r} + while not os.path.exists(stop): + time.sleep(0.05) + """ + ) + proc = spawn_authority( + f""" + import json, os, subprocess, sys, time + from hermes_cli._subprocess_compat import windows_detach_popen_kwargs + + env = os.environ.copy() + env["HERMES_TRANSFER_TEST_MARKER"] = "preserved" + child = subprocess.Popen( + [sys.executable, "-c", {transferred_code!r}], + env=env, + stdout=subprocess.PIPE, + text=True, + **windows_detach_popen_kwargs(), + ) + accepted = json.loads(child.stdout.readline()) + print(json.dumps({{ + "backend": os.getpid(), + "backend_pgid": os.getpgrp(), + "owner": child.pid, + **accepted, + }}), flush=True) + time.sleep(60) + """, + "generation-posix-transfer-01", + ) + topology = read_json_line(proc) + transferred = int(topology["pid"]) + owner = int(topology["owner"]) + try: + assert int(topology["sid"]) == owner + assert int(topology["pgid"]) == transferred + assert transferred != owner + assert owner != int(topology["backend_pgid"]) + assert topology["marker"] == "preserved" + assert str(topology["receipt"]).startswith( + "desktop-posix-transfer-v1:hermes-intentional-detached-child" + ) + + proc.terminate() + proc.wait(timeout=12) + assert wait_not_live(int(topology["backend"])) + assert process_is_live(owner) + assert process_is_live(transferred) + + stop_file.touch() + assert wait_not_live(transferred), "the receiving process must own and finish its own lifecycle" + assert wait_not_live(owner), "the receiving supervisor must reap and follow its target" + finally: + stop_supervisor(proc) + stop_file.touch(exist_ok=True) + assert wait_not_live(transferred) + assert wait_not_live(owner) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX authority") +def test_transferred_popen_kill_routes_through_receiving_owner(): + target_code = textwrap.dedent( + """ + import json, os, time + print(json.dumps({"pid": os.getpid()}), flush=True) + time.sleep(60) + """ + ) + proc = spawn_authority( + f""" + import json, subprocess, sys + from hermes_cli._subprocess_compat import windows_detach_popen_kwargs + + child = subprocess.Popen( + [sys.executable, "-c", {target_code!r}], + stdout=subprocess.PIPE, + text=True, + **windows_detach_popen_kwargs(), + ) + target = json.loads(child.stdout.readline()) + owner = child.pid + child.kill() + status = child.wait(timeout=8) + print(json.dumps({{ + "owner": owner, + "target": target["pid"], + "status": status, + }}), flush=True) + """, + "generation-posix-transfer-kill-01", + ) + topology = read_json_line(proc, timeout=12) + proc.wait(timeout=12) + assert int(topology["status"]) != 0 + assert wait_not_live(int(topology["target"])) + assert wait_not_live(int(topology["owner"])) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX authority") +def test_transfer_without_a_runnable_receiving_target_is_rejected(): + proc = spawn_authority( + """ + import json, subprocess + from hermes_cli.posix_process_authority import ( + ProcessAuthorityError, + begin_process_transfer, + desktop_child_env, + ) + + grant = begin_process_transfer("test-missing-target") + env = desktop_child_env(lifetime="transferred", transfer=grant) + try: + subprocess.Popen( + ["/definitely/missing/hermes-transfer-target"], + env=env, + start_new_session=True, + ) + except ProcessAuthorityError as exc: + print(json.dumps({"error": str(exc)}), flush=True) + else: + raise AssertionError("transfer to a missing target unexpectedly succeeded") + """, + "generation-posix-transfer-fail-01", + ) + result = read_json_line(proc, timeout=8) + proc.wait(timeout=8) + assert "could not exec transferred target" in str(result["error"]) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX authority") +def test_generation_n_teardown_cannot_touch_generation_n_plus_one(): + script = """ + import json, os, subprocess, sys, time + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(60)"], + start_new_session=True, + ) + print(json.dumps({ + "backend": os.getpid(), + "child": child.pid, + "pgid": os.getpgrp(), + }), flush=True) + time.sleep(60) + """ + first = spawn_authority(script, "generation-posix-fence-0001") + second = spawn_authority(script, "generation-posix-fence-0002") + first_topology = read_json_line(first) + second_topology = read_json_line(second) + try: + assert int(first_topology["pgid"]) != int(second_topology["pgid"]) + first.send_signal(getattr(signal, "SIGUSR2", signal.SIGTERM)) + first.wait(timeout=10) + assert wait_not_live(int(first_topology["backend"])) + assert wait_not_live(int(first_topology["child"])) + assert process_is_live(int(second_topology["backend"])) + assert process_is_live(int(second_topology["child"])) + finally: + stop_supervisor(first) + stop_supervisor(second) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX authority") +def test_natural_backend_exit_reaps_contained_residue(): + proc = spawn_authority( + """ + import json, os, subprocess, sys + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(60)"], + start_new_session=True, + ) + print(json.dumps({"backend": os.getpid(), "child": child.pid}), flush=True) + """, + "generation-posix-natural-01", + ) + topology = read_json_line(proc) + proc.wait(timeout=10) + assert wait_not_live(int(topology["backend"])) + assert wait_not_live(int(topology["child"])) + + +def test_caller_supplied_receipt_is_rejected_as_false_authority(): + with pytest.raises(authority.ProcessAuthorityError, match="caller-supplied transfer receipts"): + authority.desktop_child_env( + lifetime="transferred", + transfer_receipt="service-manager:unit-42", + ) + with pytest.raises(authority.ProcessAuthorityError, match="requires a transfer grant"): + authority.desktop_child_env(lifetime="transferred") + with pytest.raises(authority.ProcessAuthorityError, match="requires a transfer grant"): + authority.desktop_child_env(lifetime="foreign") diff --git a/tests/hermes_cli/test_windows_process_authority.py b/tests/hermes_cli/test_windows_process_authority.py new file mode 100644 index 0000000000000..ffca16fbd2b3a --- /dev/null +++ b/tests/hermes_cli/test_windows_process_authority.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import pytest + +from hermes_cli import windows_process_authority as authority + + +class FakeThread: + def __init__(self, *, target, name, daemon): + self.target = target + self.name = name + self.daemon = daemon + self.started = False + + def start(self): + self.started = True + + +class FakeApi: + def __init__(self, *, parent_started_at_ms=1_700_000_000_000): + self.parent_started_at = parent_started_at_ms + self.calls = [] + self.closed = [] + self.job = object() + self.parent = object() + self.current = object() + self.fail_at = None + + def _call(self, name, *args): + self.calls.append((name, *args)) + if self.fail_at == name: + raise authority.ProcessAuthorityError(f"{name} failed") + + def create_job(self): + self._call("create_job") + return self.job + + def enable_kill_on_close(self, job_handle): + self._call("enable_kill_on_close", job_handle) + + def current_process_handle(self): + self._call("current_process_handle") + return self.current + + def assign_current_process(self, job_handle, process_handle): + self._call("assign_current_process", job_handle, process_handle) + + def open_parent(self, pid): + self._call("open_parent", pid) + return self.parent + + def process_started_at_ms(self, process_handle): + self._call("process_started_at_ms", process_handle) + return self.parent_started_at + + def wait_for_process_exit(self, process_handle): + self._call("wait_for_process_exit", process_handle) + + def close_handle(self, handle): + self.calls.append(("close_handle", handle)) + self.closed.append(handle) + + +@pytest.fixture(autouse=True) +def reset_authority(): + authority._reset_process_authority_for_tests() + yield + authority._reset_process_authority_for_tests() + + +def env(**overrides): + values = { + authority.AUTHORITY_MODE_ENV: authority.AUTHORITY_MODE, + authority.GENERATION_ENV: "e2f531e4-14b1-47ff-9f87-bc278cfa816d", + authority.PARENT_PID_ENV: "4242", + authority.PARENT_STARTED_AT_ENV: "1700000000000", + } + values.update(overrides) + return values + + +def test_unmarked_process_is_untouched(): + api = FakeApi() + assert authority.install_windows_process_authority( + environ={}, platform="win32", api=api, thread_factory=FakeThread + ) is None + assert api.calls == [] + + +def test_off_windows_is_noop_even_with_envelope(): + api = FakeApi() + assert authority.install_windows_process_authority( + environ=env(), platform="linux", api=api, thread_factory=FakeThread + ) is None + assert api.calls == [] + + +@pytest.mark.parametrize( + "key,value,match", + [ + (authority.AUTHORITY_MODE_ENV, "pid-v1", "unsupported"), + (authority.GENERATION_ENV, "short", "generation"), + (authority.PARENT_PID_ENV, "not-a-pid", "identity"), + (authority.PARENT_PID_ENV, "0", "positive"), + (authority.PARENT_STARTED_AT_ENV, "-1", "positive"), + ], +) +def test_malformed_authority_envelope_fails_before_os_calls(key, value, match): + api = FakeApi() + with pytest.raises(authority.ProcessAuthorityError, match=match): + authority.install_windows_process_authority( + environ=env(**{key: value}), + platform="win32", + api=api, + thread_factory=FakeThread, + ) + assert api.calls == [] + + +def test_installs_job_and_binds_parent_generation_before_assignment(): + api = FakeApi() + installed = authority.install_windows_process_authority( + environ=env(), platform="win32", api=api, thread_factory=FakeThread + ) + + assert installed is not None + assert installed.spec.parent_pid == 4242 + assert installed.spec.parent_started_at_ms == 1_700_000_000_000 + assert installed.watcher.started is True + assert [call[0] for call in api.calls] == [ + "create_job", + "enable_kill_on_close", + "open_parent", + "process_started_at_ms", + "current_process_handle", + "assign_current_process", + ] + assert api.calls[-1][1:] == (api.job, api.current) + + installed.watcher.target() + assert [call[0] for call in api.calls[-3:]] == [ + "wait_for_process_exit", + "close_handle", + "close_handle", + ] + assert api.closed[-2:] == [api.parent, api.job] + + +def test_recycled_parent_pid_is_rejected_and_handles_are_closed(): + api = FakeApi(parent_started_at_ms=1_700_000_020_000) + with pytest.raises(authority.ProcessAuthorityError, match="generation mismatch"): + authority.install_windows_process_authority( + environ=env(), + platform="win32", + api=api, + thread_factory=FakeThread, + ) + assert "assign_current_process" not in [call[0] for call in api.calls] + assert api.closed == [api.parent, api.job] + + +def test_assignment_failure_closes_parent_and_job_without_fallback(): + api = FakeApi() + api.fail_at = "assign_current_process" + with pytest.raises(authority.ProcessAuthorityError, match="assign_current_process"): + authority.install_windows_process_authority( + environ=env(), platform="win32", api=api, thread_factory=FakeThread + ) + assert api.closed == [api.parent, api.job] + assert all(call[0] != "taskkill" for call in api.calls) + + +def test_same_generation_is_idempotent_but_different_generation_is_rejected(): + api = FakeApi() + first = authority.install_windows_process_authority( + environ=env(), platform="win32", api=api, thread_factory=FakeThread + ) + second = authority.install_windows_process_authority( + environ=env(), platform="win32", api=api, thread_factory=FakeThread + ) + assert second is first + assert [call[0] for call in api.calls].count("create_job") == 1 + + with pytest.raises( + authority.ProcessAuthorityError, + match="different desktop process generation", + ): + authority.install_windows_process_authority( + environ=env( + **{ + authority.GENERATION_ENV: + "3b371df1-dbb7-4b0c-b87c-44f4ec1e2293" + } + ), + platform="win32", + api=api, + thread_factory=FakeThread, + ) + + +def test_negative_tolerance_is_configuration_error_before_os_calls(): + api = FakeApi() + with pytest.raises(ValueError, match="non-negative"): + authority.install_windows_process_authority( + environ=env(), + platform="win32", + api=api, + thread_factory=FakeThread, + parent_start_tolerance_ms=-1, + ) + assert api.calls == [] diff --git a/tools/mcp_stdio_watchdog.py b/tools/mcp_stdio_watchdog.py index a39f36d6febd4..9776faa43a126 100644 --- a/tools/mcp_stdio_watchdog.py +++ b/tools/mcp_stdio_watchdog.py @@ -31,12 +31,19 @@ 4. the instant the original parent is gone, terminates the real child's process group (SIGTERM, grace period, then SIGKILL) and exits. +When this watchdog itself runs inside Desktop retained POSIX authority, the +global descendant guard upgrades the private-session request into a retained +nested-owned scope. In that case the returned ``Popen`` is the nested owner, +not a numeric handle to rediscover with ``getpgid``; teardown therefore routes +through that retained owner instead of widening a child-local kill onto the +Desktop root process group. + This is intentionally a thin, standard-library-only script so it starts fast and can't itself become a resource leak. Usage (see ``tools/mcp_tool.py::_run_stdio``):: - python3 -m tools.mcp_stdio_watchdog \\ + python3 -m tools.mcp_stdio_watchdog \ --ppid -- ... """ @@ -60,13 +67,28 @@ def _is_orphaned(original_ppid: int, getppid=os.getppid) -> bool: def _terminate_process_group(proc: subprocess.Popen) -> None: - """Best-effort SIGTERM-then-SIGKILL of the child's process group. + """Best-effort termination of exactly the child scope owned by ``proc``. - This module only ever runs on POSIX (the wrap site in tools/mcp_tool.py - gates on ``os.name == "posix"``), but guard the POSIX-only primitives - anyway so an accidental Windows import/execute degrades to a plain - child kill instead of AttributeError. + Under Desktop retained authority, a private-session request is represented + by a retained nested owner. Signals must go through that object; deriving a + PGID from its PID would recover the *outer* Desktop group and widen local + authority. Outside that regime, preserve the historical process-group + teardown for the real direct child. """ + if getattr(proc, "__hermes_nested_owned__", False): + try: + proc.terminate() + proc.wait(timeout=_TERM_GRACE_S) + return + except (OSError, subprocess.TimeoutExpired): + pass + try: + proc.kill() + proc.wait(timeout=_TERM_GRACE_S) + except (OSError, subprocess.TimeoutExpired): + pass + return + killpg = getattr(os, "killpg", None) if killpg is None: # windows-footgun: ok — non-POSIX fallback try: @@ -115,9 +137,9 @@ def main(argv: list[str] | None = None) -> int: print("mcp_stdio_watchdog: no command given after '--'", file=sys.stderr) return 2 - # New process group so we can killpg() the whole tree the real command - # may spawn (e.g. mcp-remote's own child `node` process), without - # touching our own group or the (already-gone) original parent's. + # New process group so we can kill the exact real-command subtree without + # touching the watchdog or its parent. Under Desktop authority the global + # guard retains this as a nested-owned scope instead of flattening it. proc = subprocess.Popen( real_argv, stdin=sys.stdin, @@ -127,11 +149,9 @@ def main(argv: list[str] | None = None) -> int: ) # Because the real server lives in its OWN process group (above), the - # parent's graceful-shutdown killpg of *our* group no longer reaches it. - # Forward SIGTERM/SIGINT to the child's group so graceful teardown - # (`_kill_orphaned_mcp_children`, shutdown sweeps) still kills a wedged - # server that ignores stdin EOF — otherwise the watchdog wrap would - # invert the bug it fixes. + # parent's graceful-shutdown signal of *our* group no longer reaches it. + # Forward SIGTERM/SIGINT through the child authority so graceful teardown + # still kills a wedged server that ignores stdin EOF. def _forward_shutdown(signum, frame): # noqa: ARG001 _terminate_process_group(proc) sys.exit(128 + signum)