fix(verification): retain orphan checks on clean Linux runners
This commit is contained in:
parent
58d23e42b6
commit
c039dd81d5
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export function assertCleanLinuxHopperRuntime(): Promise<void>;
|
||||
|
|
@ -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");
|
||||
};
|
||||
|
|
@ -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" ||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
Loading…
Reference in New Issue