diff --git a/docs/client-verification.md b/docs/client-verification.md index 38d0bf399..575cc61d2 100644 --- a/docs/client-verification.md +++ b/docs/client-verification.md @@ -377,6 +377,11 @@ Such a stall leaves the initial page unloaded with empty or placeholder CDP URLs it does not establish permission to capture that page. See [the keyring diagnosis](https://github.com/morluto/rea/issues/1384#issuecomment-6093781686). +On Windows, browser verifier lanes that launch an owned fixture require a +standard, non-elevated caller token. They inspect the verifier caller before +allocating fixture resources and refuse elevated or unobservable tokens; they do +not change caller authority or claim to inspect the browser process token. + `verify:browser` also captures a source-owned noise canvas as a real PNG above 8 MiB through the CLI and stdio MCP, with complete byte/digest parity and real PNG decoding. Its SDK client explicitly permits the larger inline JSON response; diff --git a/scripts/fixtures/browser-verifier-site.d.mts b/scripts/fixtures/browser-verifier-site.d.mts new file mode 100644 index 000000000..75827f3d3 --- /dev/null +++ b/scripts/fixtures/browser-verifier-site.d.mts @@ -0,0 +1,7 @@ +export interface BrowserVerifierSite { + readonly origin: string; + triggerSessionNavigation(): void; + close(): Promise; +} + +export function startBrowserVerifierSite(): Promise; diff --git a/scripts/fixtures/browser-verifier-site.mjs b/scripts/fixtures/browser-verifier-site.mjs index 059a63599..40281bc64 100644 --- a/scripts/fixtures/browser-verifier-site.mjs +++ b/scripts/fixtures/browser-verifier-site.mjs @@ -184,11 +184,13 @@ export async function startBrowserVerifierSite() { async close() { for (const client of webSockets.clients) client.terminate(); await new Promise((resolve) => webSockets.close(resolve)); - await new Promise((resolve, reject) => + const serverClosed = new Promise((resolve, reject) => server.close((error) => error === undefined ? resolve() : reject(error), ), ); + server.closeAllConnections(); + await serverClosed; }, }; } diff --git a/scripts/lib/browser-runtime-e2e.mjs b/scripts/lib/browser-runtime-e2e.mjs index b35cde09a..78d9d4615 100644 --- a/scripts/lib/browser-runtime-e2e.mjs +++ b/scripts/lib/browser-runtime-e2e.mjs @@ -14,6 +14,7 @@ import { parseEvidence } from "../../dist/domain/evidence.js"; import { webExecutionSchema } from "../../dist/domain/webExecution.js"; import { webEventListenersSchema } from "../../dist/domain/webEventListeners.js"; import { startBrowserRuntimeSite } from "../fixtures/browser-runtime-site.mjs"; +import { requireOrdinaryWindowsVerifierCaller } from "./windows-ordinary-user-verifier.mjs"; import { mcpTextValue } from "./mcp-verifier-results.mjs"; import { startRuntimeFixtureBrowser, @@ -37,6 +38,7 @@ export async function verifyBrowserRuntime( executable, entrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url)), ) { + await requireOrdinaryWindowsVerifierCaller(); await access(executable); const site = await startBrowserRuntimeSite(); let fixture; diff --git a/scripts/lib/browser-runtime-fixture-lifecycle.mjs b/scripts/lib/browser-runtime-fixture-lifecycle.mjs index 88a76e573..78709646d 100644 --- a/scripts/lib/browser-runtime-fixture-lifecycle.mjs +++ b/scripts/lib/browser-runtime-fixture-lifecycle.mjs @@ -12,9 +12,11 @@ import { BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS, waitForBrowserDevtoolsPort, } from "../../dist/browser/BrowserProcessStartup.js"; +import { requireOrdinaryWindowsVerifierCaller } from "./windows-ordinary-user-verifier.mjs"; /** Launch the synthetic fixture browser and await CDP in its own verified process group. */ export async function startRuntimeFixtureBrowser(executable, origin) { + await requireOrdinaryWindowsVerifierCaller(); const profile = await mkdtemp(join(tmpdir(), "rea-real-web-runtime-")); let owned; try { diff --git a/scripts/lib/real-hopper-owner-exit.mjs b/scripts/lib/real-hopper-owner-exit.mjs index 300b35ada..ff59b9586 100644 --- a/scripts/lib/real-hopper-owner-exit.mjs +++ b/scripts/lib/real-hopper-owner-exit.mjs @@ -10,6 +10,7 @@ import { verifyNoTokenOwnedProcesses } from "../../dist/process/ProcessOwnership import { observeProcessStartIdentity, signalProcessWithStartIdentity, + systemProcessOwnershipHost, } from "../../dist/process/ProcessOwnershipObservation.js"; import { startUnrelatedHopperSentinel } from "./unrelated-hopper-sentinel.mjs"; import { @@ -31,6 +32,11 @@ export async function verifyLinuxHopperOwnerExit({ const outcomes = []; try { for (const mode of ["graceful", "SIGKILL"]) { + const baseline = await systemProcessOwnershipHost.captureBaseline?.(); + assert.ok( + baseline, + "Could not capture pre-launch process ownership baseline", + ); const transport = new StdioClientTransport({ command: serverCommand, args: serverArgs, @@ -47,6 +53,18 @@ export async function verifyLinuxHopperOwnerExit({ assert.ok(ownerPid !== null); const owner = await observeProcessStartIdentity(ownerPid); assert.equal(owner?.state, "readable"); + const ownerProcess = ( + await systemProcessOwnershipHost.listProcesses() + ).find(({ pid }) => pid === ownerPid); + assert.ok( + ownerProcess, + "Could not observe the launched MCP process group", + ); + assert.equal(typeof ownerProcess.processGroupId, "number"); + const relation = { + leaderPid: ownerPid, + processGroupId: ownerProcess.processGroupId, + }; requireMcpLifecycleResult( await client.callTool( { @@ -75,7 +93,17 @@ export async function verifyLinuxHopperOwnerExit({ const runId = status.analysis_run?.run_id; assert.equal(typeof runId, "string"); assert.equal(status.analysis_provider_binding?.provider.id, "hopper"); - assert.equal((await verifyNoTokenOwnedProcesses(runId)).cleaned, false); + assert.equal( + ( + await verifyNoTokenOwnedProcesses( + runId, + systemProcessOwnershipHost, + baseline, + relation, + ) + ).cleaned, + false, + ); if (mode === "graceful") await client.close(); else { assert.equal( @@ -90,7 +118,12 @@ export async function verifyLinuxHopperOwnerExit({ const deadline = Date.now() + 10_000; let cleanup; do { - cleanup = await verifyNoTokenOwnedProcesses(runId); + cleanup = await verifyNoTokenOwnedProcesses( + runId, + systemProcessOwnershipHost, + baseline, + relation, + ); if (cleanup.cleaned) break; await delay(100); } while (Date.now() < deadline); diff --git a/scripts/lib/verifier-run.d.mts b/scripts/lib/verifier-run.d.mts index 1e9d41324..9b8a71668 100644 --- a/scripts/lib/verifier-run.d.mts +++ b/scripts/lib/verifier-run.d.mts @@ -39,3 +39,8 @@ export function createVerifierRun(): VerifierRunStart; export function completeVerifierRun( run: VerifierRunStart, ): Promise; + +/** Require positive process-lineage evidence before claiming cleanup complete. */ +export function assertVerifierCleanupComplete( + processLineage: VerifierProcessLineage, +): void; diff --git a/scripts/lib/verifier-run.mjs b/scripts/lib/verifier-run.mjs index 0b8f08919..23d8edbbf 100644 --- a/scripts/lib/verifier-run.mjs +++ b/scripts/lib/verifier-run.mjs @@ -33,6 +33,16 @@ export const completeVerifierRun = async (run) => { }; }; +/** Require positive process-lineage evidence before claiming cleanup complete. */ +export const assertVerifierCleanupComplete = (processLineage) => { + if (processLineage.status !== "verified") + throw new Error( + `Verifier cleanup could not be verified: ${processLineage.reason}`, + ); + if (processLineage.descendants.length !== 0) + throw new Error("Owned descendants survived verifier cleanup"); +}; + const observeVerifierLineage = async (run, observedAt) => { if (process.platform === "win32") return unavailableLineage( diff --git a/scripts/lib/windows-ordinary-user-verifier.d.mts b/scripts/lib/windows-ordinary-user-verifier.d.mts new file mode 100644 index 000000000..2fde877f1 --- /dev/null +++ b/scripts/lib/windows-ordinary-user-verifier.d.mts @@ -0,0 +1,4 @@ +export declare const assertOrdinaryWindowsVerifierCaller: ( + token: unknown, +) => void; +export declare const requireOrdinaryWindowsVerifierCaller: () => Promise; diff --git a/scripts/lib/windows-ordinary-user-verifier.mjs b/scripts/lib/windows-ordinary-user-verifier.mjs new file mode 100644 index 000000000..496a429da --- /dev/null +++ b/scripts/lib/windows-ordinary-user-verifier.mjs @@ -0,0 +1,40 @@ +/** Require fixture verifiers on Windows to use the caller's ordinary-user token. */ +export const assertOrdinaryWindowsVerifierCaller = (token) => { + if ( + typeof token !== "object" || + token === null || + !("administrator" in token) || + !("elevated" in token) || + token.administrator !== false || + token.elevated !== false + ) { + const integrity = + typeof token === "object" && + token !== null && + "integrityLevel" in token && + Number.isSafeInteger(token.integrityLevel) + ? ` (integrity level ${String(token.integrityLevel)})` + : ""; + throw new Error( + "Windows browser verifier requires an ordinary, non-elevated user token" + + `${integrity}. Rerun it from a standard-user Windows session; the verifier will not change caller authority.`, + ); + } +}; + +/** Observe the native caller token before a Windows browser fixture allocates resources. */ +export const requireOrdinaryWindowsVerifierCaller = async () => { + if (process.platform !== "win32") return; + let token; + try { + const { requireWindowsNativeAuthority } = + await import("../../dist/windows/WindowsNativeLoader.js"); + token = requireWindowsNativeAuthority().call("process_caller_token", []); + } catch (cause) { + throw new Error( + "Windows browser verifier could not observe its caller token; use the verified Windows x64 native artifact and rerun from a standard-user session.", + { cause }, + ); + } + assertOrdinaryWindowsVerifierCaller(token); +}; diff --git a/scripts/verify-browser-source-maps.mjs b/scripts/verify-browser-source-maps.mjs index a8b53e504..a4e7c2a26 100644 --- a/scripts/verify-browser-source-maps.mjs +++ b/scripts/verify-browser-source-maps.mjs @@ -4,6 +4,9 @@ import { isAbsolute } from "node:path"; import { verifyBrowserSourceMap } from "./lib/browser-source-map-e2e.mjs"; import { verifyBrowserNestedSourceMaps } from "./lib/browser-nested-source-map-e2e.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { requireOrdinaryWindowsVerifierCaller } from "./lib/windows-ordinary-user-verifier.mjs"; + +await requireOrdinaryWindowsVerifierCaller(); const executable = process.env.REA_BROWSER_EXECUTABLE; if (!executable || !isAbsolute(executable)) throw new Error( diff --git a/scripts/verify-package-setup.d.mts b/scripts/verify-package-setup.d.mts index 2c7244291..d6f0ce53e 100644 --- a/scripts/verify-package-setup.d.mts +++ b/scripts/verify-package-setup.d.mts @@ -4,3 +4,6 @@ export function assertClientConfig( configPath: string, original: Uint8Array, ): Promise; + +/** Return true only when stty output positively reports canonical input and echo. */ +export function terminalModesRestored(termios: unknown): boolean; diff --git a/scripts/verify-package-setup.mjs b/scripts/verify-package-setup.mjs index 44f28c142..ad23548cb 100644 --- a/scripts/verify-package-setup.mjs +++ b/scripts/verify-package-setup.mjs @@ -22,6 +22,12 @@ import { import { parse as parseJsonc } from "jsonc-parser"; import { skillReferenceIssues } from "./lib/docs-facts.mjs"; +export const terminalModesRestored = (termios) => { + if (typeof termios !== "string") return false; + const modes = new Set(termios.trim().split(/\s+/u)); + return modes.has("icanon") && modes.has("echo"); +}; + const OPENCODE_ORIGINAL = `{ // OpenCode preferences must survive setup. "model": "provider/model", @@ -131,8 +137,7 @@ const verifyInteractiveSetup = async ({ childResult?.status !== 0 || !output.includes("\u001b[?25h") || (process.platform !== "win32" && - (!childResult.termios?.includes("icanon") || - !childResult.termios?.includes("echo"))) || + !terminalModesRestored(childResult.termios)) || !output.includes("No changes were made.") ) { finish( diff --git a/scripts/verify-real-browser.mjs b/scripts/verify-real-browser.mjs index 3f793d82c..28c53067a 100644 --- a/scripts/verify-real-browser.mjs +++ b/scripts/verify-real-browser.mjs @@ -39,6 +39,8 @@ import { verifyScenarioFailureEvidence, } from "./lib/browser-scenario-verifier.mjs"; import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs"; +import { closeRuntimeFixtureResources } from "./lib/browser-runtime-fixture-lifecycle.mjs"; +import { requireOrdinaryWindowsVerifierCaller } from "./lib/windows-ordinary-user-verifier.mjs"; import { verifyLargeScreenshotE2e } from "./lib/browser-screenshot-e2e.mjs"; import { verifyPopupEventCoverage } from "./lib/browser-popup-e2e.mjs"; import { verifyBrowserNetworkEvidence } from "./lib/browser-network-e2e.mjs"; @@ -58,13 +60,17 @@ process.env.REA_BROWSER_VERIFIER_SECRET = SCENARIO_SECRET_VALUE; process.env.REA_BROWSER_VERIFIER_URL = SCENARIO_URL_SECRET_VALUE; const verifierRun = createVerifierRun(); +await requireOrdinaryWindowsVerifierCaller(); const executable = await browserExecutable(); -const profile = await mkdtemp(join(tmpdir(), "rea-real-browser-")); -const site = await startBrowserVerifierSite(); +let profile; +let site; let browser; let pageProxy; let report; +let verificationError; try { + profile = await mkdtemp(join(tmpdir(), "rea-real-browser-")); + site = await startBrowserVerifierSite(); browser = spawn( executable, [ @@ -409,19 +415,39 @@ try { scenarioServiceWorkers, verified: true, }; +} catch (cause) { + verificationError = cause; + throw cause; } finally { - process.stderr.write("Browser verifier cleanup: closing page proxy\n"); - if (pageProxy !== undefined) await pageProxy.close(); - process.stderr.write("Browser verifier cleanup: stopping owned Chrome\n"); - if (browser !== undefined) await stopProcess(browser); - process.stderr.write("Browser verifier cleanup: closing fixture site\n"); - await site.close(); - await rm(profile, { - recursive: true, - force: true, - maxRetries: 10, - retryDelay: 100, - }); + const cleanup = []; + if (pageProxy !== undefined) { + cleanup.push(async () => { + process.stderr.write("Browser verifier cleanup: closing page proxy\n"); + await pageProxy.close(); + }); + } + if (browser !== undefined) { + cleanup.push(async () => { + process.stderr.write("Browser verifier cleanup: stopping owned Chrome\n"); + await stopProcess(browser); + }); + } + if (site !== undefined) { + cleanup.push(async () => { + process.stderr.write("Browser verifier cleanup: closing fixture site\n"); + await site.close(); + }); + } + if (profile !== undefined) + cleanup.push(() => + rm(profile, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 100, + }), + ); + await closeRuntimeFixtureResources(cleanup, verificationError); } process.stdout.write( `${JSON.stringify({ diff --git a/scripts/verify-real-ghidra-dos.mjs b/scripts/verify-real-ghidra-dos.mjs index 50a014d0a..b212a018d 100644 --- a/scripts/verify-real-ghidra-dos.mjs +++ b/scripts/verify-real-ghidra-dos.mjs @@ -24,7 +24,11 @@ import { } from "../dist/domain/hopperValues.js"; import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js"; import { buildDosMzFixture } from "../tests/conformance/ghidra/dos-mz-fixture.mjs"; -import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { + assertVerifierCleanupComplete, + createVerifierRun, + completeVerifierRun, +} from "./lib/verifier-run.mjs"; import { verifyGhidraAddressBoundaries } from "./lib/real-ghidra-address-boundaries.mjs"; // This lane intentionally has no game paths, binary seeds or compiler dependency. @@ -479,12 +483,7 @@ try { } assert.ok(report, "No DOS proof report was produced"); const verifierRun = await completeVerifierRun(run); -if (verifierRun.process_lineage.status === "verified") - assert.deepEqual( - verifierRun.process_lineage.descendants, - [], - "Owned descendants survived verifier cleanup", - ); +assertVerifierCleanupComplete(verifierRun.process_lineage); process.stdout.write( `${JSON.stringify({ verifier_run: verifierRun, ...report, cleanup: "complete" })}\n`, ); diff --git a/scripts/verify-wasm-artifact.mjs b/scripts/verify-wasm-artifact.mjs index 5123d3400..c05952118 100644 --- a/scripts/verify-wasm-artifact.mjs +++ b/scripts/verify-wasm-artifact.mjs @@ -2,8 +2,16 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdir, readFile, writeFile, readdir } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; import { isAbsolute, join } from "node:path"; +import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/client"; @@ -51,11 +59,32 @@ const tool = (name, args, cwd) => const sha256 = (value) => createHash("sha256").update(value).digest("hex"); const run = createVerifierRun(); const root = await PrivateRuntimeRoot.create({ prefix: "rea-wabt-verifier-" }); +// Keep provider scratch outside the leased private input root. On Windows the +// root's retained DELETE handle blocks child temp creation beneath that root. +let childTempRoot; +try { + childTempRoot = await mkdtemp(join(tmpdir(), "rea-wabt-child-temp-")); +} catch (cause) { + try { + await root.close(); + } catch (cleanupCause) { + throw new AggregateError( + [cause, cleanupCause], + "Could not allocate verifier temp space or release its private root.", + ); + } + throw cause; +} const client = new Client({ name: "wabt-artifact-verifier", version: "1" }); const transport = new StdioClientTransport({ command: process.execPath, args: [entrypoint, "mcp"], - env: { ...environment, TMPDIR: root.path, TMP: root.path, TEMP: root.path }, + env: { + ...environment, + TMPDIR: childTempRoot, + TMP: childTempRoot, + TEMP: childTempRoot, + }, stderr: "pipe", }); let cases = 0; @@ -95,9 +124,9 @@ try { { env: { ...environment, - TMPDIR: root.path, - TMP: root.path, - TEMP: root.path, + TMPDIR: childTempRoot, + TMP: childTempRoot, + TEMP: childTempRoot, }, timeout: 120_000, maxBuffer: 64 * 1024 * 1024, @@ -388,13 +417,26 @@ try { } try { assert.ok( - !(await readdir(root.path)).some((name) => name.startsWith("rea-wabt-")), + !(await readdir(childTempRoot)).some((name) => + name.startsWith("rea-wabt-"), + ), "Provider workspace leaked", ); +} catch (cause) { + failures.push(cause); +} +// Each directory is an independent owner. Attempt every cleanup even if a +// residue assertion or another owner's close fails, retaining every error. +try { await root.close(); } catch (cause) { failures.push(cause); } +try { + await rm(childTempRoot, { recursive: true, force: true }); +} catch (cause) { + failures.push(cause); +} if (failures.length > 0) throw new AggregateError(failures, "WABT real-tool verification failed."); console.log( diff --git a/tests/boundary/browser/browserProcessStartup.test.ts b/tests/boundary/browser/browserProcessStartup.test.ts index b1cfaeafe..3bf88d102 100644 --- a/tests/boundary/browser/browserProcessStartup.test.ts +++ b/tests/boundary/browser/browserProcessStartup.test.ts @@ -14,8 +14,31 @@ import { BrowserStartupError, waitForBrowserDevtoolsPort, } from "../../../src/browser/BrowserProcessStartup.js"; +import { assertOrdinaryWindowsVerifierCaller } from "../../../scripts/lib/windows-ordinary-user-verifier.mjs"; describe("browser process startup", () => { + it("requires a positively observed ordinary Windows verifier caller", () => { + expect(() => + assertOrdinaryWindowsVerifierCaller({ + administrator: false, + elevated: false, + integrityLevel: 8192, + }), + ).not.toThrow(); + + for (const token of [ + { administrator: true, elevated: false }, + { administrator: false, elevated: true }, + { administrator: false }, + { administrator: false, elevated: null }, + null, + ]) { + expect(() => assertOrdinaryWindowsVerifierCaller(token)).toThrow( + /ordinary, non-elevated user token/u, + ); + } + }); + it("returns a delayed valid DevToolsActivePort", async ({ processes }) => { const root = await temporaryRoot(); const portPath = join(root, "DevToolsActivePort"); diff --git a/tests/boundary/browser/browserVerifierFixture.test.ts b/tests/boundary/browser/browserVerifierFixture.test.ts new file mode 100644 index 000000000..e171d14f8 --- /dev/null +++ b/tests/boundary/browser/browserVerifierFixture.test.ts @@ -0,0 +1,30 @@ +import { createConnection } from "node:net"; +import { it, expect } from "vitest"; +import { startBrowserVerifierSite } from "../../../scripts/fixtures/browser-verifier-site.mjs"; + +it("closes an accepted HTTP socket that has not sent request headers", async () => { + const site = await startBrowserVerifierSite(); + const { port } = new URL(site.origin); + const socket = createConnection(Number(port), "127.0.0.1"); + let close: Promise | undefined; + let timeout: ReturnType | undefined; + try { + await new Promise((resolve, reject) => { + socket.once("connect", resolve); + socket.once("error", reject); + }); + const closing = site.close(); + close = closing; + const closedBeforeDeadline = await Promise.race([ + closing.then(() => true), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), 1_000); + }), + ]); + expect(closedBeforeDeadline).toBe(true); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + socket.destroy(); + await (close ?? site.close()); + } +}); diff --git a/tests/boundary/filesystem/packageClientBackup.test.ts b/tests/boundary/filesystem/packageClientBackup.test.ts index cd57fda10..8294c76c8 100644 --- a/tests/boundary/filesystem/packageClientBackup.test.ts +++ b/tests/boundary/filesystem/packageClientBackup.test.ts @@ -2,9 +2,27 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; -import { assertClientConfig } from "../../../scripts/verify-package-setup.mjs"; +import { + assertClientConfig, + terminalModesRestored, +} from "../../../scripts/verify-package-setup.mjs"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +it("requires positive canonical-input and echo terminal mode tokens", () => { + expect(terminalModesRestored("speed 38400 baud; icanon echo isig")).toBe( + true, + ); + expect(terminalModesRestored("speed 38400 baud; -icanon -echo isig")).toBe( + false, + ); + expect(terminalModesRestored("speed 38400 baud; -icanon echo isig")).toBe( + false, + ); + expect(terminalModesRestored("speed 38400 baud; icanon -echo isig")).toBe( + false, + ); +}); + it("rejects a backup replaced with the post-setup client configuration", async () => { const directory = await createTestTempDirectory("rea-client-backup-"); const path = join(directory, "client.json"); diff --git a/tests/boundary/process/verifierRun.test.ts b/tests/boundary/process/verifierRun.test.ts new file mode 100644 index 000000000..af8f5447e --- /dev/null +++ b/tests/boundary/process/verifierRun.test.ts @@ -0,0 +1,38 @@ +import { expect, it } from "vitest"; + +import { assertVerifierCleanupComplete } from "../../../scripts/lib/verifier-run.mjs"; + +it("requires verified empty lineage before claiming verifier cleanup", () => { + expect(() => + assertVerifierCleanupComplete({ + status: "unavailable", + observed_at: "2026-10-14T00:00:00.000Z", + launcher_pid: 100, + launcher_parent_pid: 1, + process_group_id: null, + reason: "snapshot unavailable", + }), + ).toThrow("Verifier cleanup could not be verified: snapshot unavailable"); + + expect(() => + assertVerifierCleanupComplete({ + status: "verified", + observed_at: "2026-10-14T00:00:00.000Z", + launcher_pid: 100, + launcher_parent_pid: 1, + process_group_id: 100, + descendants: [{ pid: 101, parent_pid: 100, process_group_id: 100 }], + }), + ).toThrow("Owned descendants survived verifier cleanup"); + + expect(() => + assertVerifierCleanupComplete({ + status: "verified", + observed_at: "2026-10-14T00:00:00.000Z", + launcher_pid: 100, + launcher_parent_pid: 1, + process_group_id: 100, + descendants: [], + }), + ).not.toThrow(); +});