diff --git a/docs/testing.md b/docs/testing.md index 42c5cb24c..3f1b2d68b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -383,7 +383,11 @@ to the CLI entrypoint being verified. The command builds and checks source-owned ELF fixtures before testing the read-only Hopper setup plan, approved installation, scoped doctor, real CLI/MCP analysis, rejection of a modified launcher, idempotent setup without requesting a reinstall, and owned child -cleanup. It requires the repository's pinned development toolchain and an +cleanup. Before installation and after teardown, a clean-runner process scan +also rejects surviving Hopper demo helpers or Xvfb groups, including orphans +whose launcher exited. This scan observes processes without signaling them; +the dedicated runner must have no unrelated Hopper/Xvfb sessions. It requires +the repository's pinned development toolchain and an existing host C compiler, `file`, `nm`, and `strings`. Use `verify:hopper:linux` instead when Hopper is already installed. diff --git a/scripts/lib/clean-linux-hopper-runtime.d.mts b/scripts/lib/clean-linux-hopper-runtime.d.mts new file mode 100644 index 000000000..c860c876d --- /dev/null +++ b/scripts/lib/clean-linux-hopper-runtime.d.mts @@ -0,0 +1 @@ +export function assertCleanLinuxHopperRuntime(): Promise; diff --git a/scripts/lib/clean-linux-hopper-runtime.mjs b/scripts/lib/clean-linux-hopper-runtime.mjs new file mode 100644 index 000000000..2fc8fbfad --- /dev/null +++ b/scripts/lib/clean-linux-hopper-runtime.mjs @@ -0,0 +1,15 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +/** + * Observe leftover demo processes on a dedicated clean Linux runner, including + * groups reparented after their launcher exits. This is not ownership proof and + * never authorizes signaling; unrelated runtime processes also block this lane. + */ +export const assertCleanLinuxHopperRuntime = async () => { + const { stdout } = await execFileAsync("ps", ["-ax", "-o", "command="]); + if (/\/opt\/hopper\/bin\/Hopper|\bXvfb\b|hopper-demo-x11\.py/u.test(stdout)) + throw new Error("Clean Linux runner retained a Hopper demo process"); +}; diff --git a/scripts/verify-linux-setup-e2e.mjs b/scripts/verify-linux-setup-e2e.mjs index a2b79dd60..e08e8b160 100644 --- a/scripts/verify-linux-setup-e2e.mjs +++ b/scripts/verify-linux-setup-e2e.mjs @@ -12,6 +12,7 @@ import { tmpdir } from "node:os"; import { promisify } from "node:util"; import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs"; +import { assertCleanLinuxHopperRuntime } from "./lib/clean-linux-hopper-runtime.mjs"; const execFileAsync = promisify(execFile); const verifierRun = createVerifierRun(); @@ -19,6 +20,7 @@ const command = process.env.REA_VERIFY_SERVER_COMMAND; if (process.platform !== "linux" || process.getuid?.() !== 0) throw new Error("Linux setup verification requires a root Linux runner"); if (!command) throw new Error("REA_VERIFY_SERVER_COMMAND is required"); +await assertCleanLinuxHopperRuntime(); try { await access("/opt/hopper/bin/Hopper"); throw new Error("Linux setup verification requires a clean runner"); @@ -110,6 +112,7 @@ if ( repeated.appliedActions.length !== 0 ) throw new Error("a second setup was not idempotent"); +await assertCleanLinuxHopperRuntime(); const completedVerifierRun = await completeVerifierRun(verifierRun); if ( completedVerifierRun.process_lineage.status !== "verified" || diff --git a/tests/process-global/cleanLinuxHopperRuntime.test.ts b/tests/process-global/cleanLinuxHopperRuntime.test.ts new file mode 100644 index 000000000..af16911fb --- /dev/null +++ b/tests/process-global/cleanLinuxHopperRuntime.test.ts @@ -0,0 +1,89 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { promisify } from "node:util"; + +import { expect, it } from "vitest"; + +import { assertCleanLinuxHopperRuntime } from "../../scripts/lib/clean-linux-hopper-runtime.mjs"; +import { completeVerifierRun } from "../../scripts/lib/verifier-run.mjs"; +import { cleanupOwnedProcessGroup } from "../../src/process/ProcessOwnership.js"; + +const execFileAsync = promisify(execFile); + +it.runIf(process.platform === "linux")( + "rejects a reparented demo process without signaling it", + async () => { + const runId = randomUUID(); + const childCode = + "process.title = 'Xvfb'; setInterval(() => {}, 1000); process.send('ready');"; + const parentCode = ` + const { spawn } = require('node:child_process'); + const child = spawn(process.execPath, ['-e', ${JSON.stringify(childCode)}], { + detached: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'] + }); + child.once('error', () => process.exit(1)); + child.once('message', () => { + process.stdout.write(JSON.stringify({ pid: child.pid, parentPid: process.pid })); + child.disconnect(); + child.unref(); + }); + `; + const parent = execFileAsync(process.execPath, ["-e", parentCode], { + env: { ...process.env, REA_PROCESS_RUN_ID: runId }, + timeout: 10_000, + }); + const parentPid = parent.child.pid; + if (parentPid === undefined) + throw new Error("Fixture parent did not spawn"); + let orphanPid: number | undefined; + try { + const { stdout } = await parent; + const { pid } = JSON.parse(stdout) as { pid: number }; + orphanPid = pid; + const { stdout: coordinates } = await execFileAsync("ps", [ + "-p", + String(pid), + "-o", + "ppid=,pgid=", + ]); + const [currentParent, group] = coordinates + .trim() + .split(/\s+/u) + .map(Number); + expect(currentParent).not.toBe(parentPid); + expect(group).toBe(pid); + + const lineage = await completeVerifierRun({ + run_id: runId, + verifier_pid: process.pid, + parent_pid: process.ppid, + }); + expect(lineage.process_lineage.status).toBe("verified"); + if (lineage.process_lineage.status === "verified") + expect( + lineage.process_lineage.descendants.some((p) => p.pid === pid), + ).toBe(false); + + await expect(assertCleanLinuxHopperRuntime()).rejects.toThrow( + "Clean Linux runner retained a Hopper demo process", + ); + // Observation must not terminate the fixture, even though it resembles Xvfb. + const { stdout: alive } = await execFileAsync("ps", [ + "-p", + String(pid), + "-o", + "stat=", + ]); + expect(alive.trim()).not.toMatch(/^Z/u); + } finally { + if (orphanPid !== undefined) { + const cleanup = await cleanupOwnedProcessGroup({ + runId, + leaderPid: orphanPid, + processGroupId: orphanPid, + }); + expect(cleanup.cleaned, JSON.stringify(cleanup)).toBe(true); + } + } + }, +);