test(hopper): verify Linux demo setup and source-bound string coverage

Integrate the verification corrections from #1942 on current main, retaining their original commit history. Reuse the unchanged document inventory and verified source bytes, retain recognition addresses, and reject incomplete or mismatched string evidence.
This commit is contained in:
N0zoM1z0 2026-10-13 06:21:07 +00:00
commit 11c188523a
11 changed files with 520 additions and 60 deletions

View File

@ -298,13 +298,42 @@ subsequent wire recovery and clean shutdown. Caller timeout does not interrupt
Hopper's synchronous native operation. Socket boundary tests deterministically
cover active and queued expiry, late replies and timer cleanup.
The Linux demo lane remains a separate `verify:hopper:linux` command. Its
lifecycle checks attempt competing CLI launches for the active target and a
The Linux demo lane remains a separate `verify:hopper:linux` command. Hopper
6.4.2's ELF loader did not recognize the original 4,119-byte literal or three
non-ASCII byte strings in the measured fixtures. The lane retains complete raw
byte checks for those literals through source-owned symbols. Recognition of the
original full-length literal is bound to its exact source address; its decoded
object coverage remains explicitly unverified on Linux even when recognized.
Absent non-ASCII recognition also reports decoded-object coverage as unverified.
Recognized non-ASCII and control-text objects must still match their source
bytes, encoding, filters and searches.
A separate 548-byte ASCII literal requires native fragmentation, shortened
provider display, complete decoded fragments and dossier provenance on Linux;
the macOS lane retains the original full-length recognition requirement. These
are observed fixture/build capabilities, not universal Hopper length limits.
Its lifecycle checks attempt competing CLI launches for the active target and a
different target, requiring rejection before either can change the owning
MCP session's documents or procedures. Lease boundary tests also preserve
unresponsive or malformed live endpoints while allowing confirmed stale
sockets to recover.
`verify:setup:linux` exercises installation on a clean supported Linux runner as
root. Use a disposable VM: this lane installs Hopper and its Linux runtime
dependencies through the system package manager. Set `REA_VERIFY_SERVER_COMMAND`
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. 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.
Hopper detection compares executable file identity through `/proc`, covering
alternate or configured launcher paths even after an invoked symlink is removed.
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.
`verify:hopper:fat` is a separate macOS lane requiring installed Hopper and the
existing Xcode clang/lipo toolchain. It compiles arm64/x86-64 thin executables and
one- and two-slice FAT32 containers, verifies exact/interior address mappings

View File

@ -207,7 +207,7 @@
"fixtures:harmony": "node scripts/fixtures/harmony/fetch.mjs",
"verify:agent": "npm run build:cached && node scripts/verify-agent-experience.mjs",
"verify:hopper:linux": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper-linux.mjs",
"verify:setup:linux": "npm run build:cached && node scripts/verify-linux-setup-e2e.mjs",
"verify:setup:linux": "npm run verify:fixtures && npm run build:cached && node scripts/verify-linux-setup-e2e.mjs",
"build:fixtures": "node scripts/build-conformance-fixtures.mjs",
"build:ghidra-windows-fixture": "node scripts/create-ghidra-windows-fixture.mjs",
"verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs",

View File

@ -0,0 +1,3 @@
export function assertCleanLinuxHopperRuntime(
launcherPaths?: readonly string[],
): Promise<void>;

View File

@ -0,0 +1,64 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { readdir, stat } from "node:fs/promises";
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 (
launcherPaths = [
"/opt/hopper/bin/Hopper",
...(process.env.HOPPER_LAUNCHER_PATH === undefined
? []
: [process.env.HOPPER_LAUNCHER_PATH]),
],
) => {
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");
const launchers = [];
for (const path of launcherPaths) {
try {
launchers.push(await stat(path, { bigint: true }));
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
if (launchers.length === 0) return;
// /proc exposes the executed file even when argv[0] names a removed symlink.
// Compare file identity, not a display path or a process name.
let inspectionError;
for (const entry of await readdir("/proc", { withFileTypes: true })) {
if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue;
let executable;
try {
executable = await stat(`/proc/${entry.name}/exe`, { bigint: true });
} catch (error) {
if (error?.code === "ENOENT" || error?.code === "ESRCH") continue;
// Other users' executables may be unreadable. The setup lane launches
// providers as the caller; unreadable caller-owned processes fail closed.
if (error?.code === "EACCES") {
const owner = await stat(`/proc/${entry.name}`).catch((cause) => {
if (cause?.code === "ENOENT") return undefined;
throw cause;
});
if (owner === undefined || owner.uid !== process.getuid()) continue;
}
// Finish the inventory so an unrelated restricted process cannot mask a
// positively identified leak. Unknown caller processes still fail closed.
inspectionError ??= error;
continue;
}
if (
launchers.some(
({ dev, ino }) => dev === executable.dev && ino === executable.ino,
)
)
throw new Error("Clean Linux runner retained a Hopper demo process");
}
if (inspectionError !== undefined) throw inspectionError;
};

View File

@ -0,0 +1,30 @@
type VerifierCall = (
name: string,
arguments_?: Record<string, unknown>,
) => Promise<unknown>;
interface StringExpectation {
readonly value: string;
readonly encoding: string;
readonly symbol?: string;
readonly recognitionOptional?: boolean;
}
/** Verify source bytes, observed decoding, address filters and literal searches. */
export function verifyHopperStringObjects(
call: VerifierCall,
expectations: readonly StringExpectation[],
inventory?: readonly z.output<typeof analysisStringSchema>[] | null,
): Promise<{
readonly sourceBytesVerified: true;
readonly recognition: readonly {
readonly value: string;
readonly address: string;
readonly state: "observed" | "not_observed";
readonly decodedObject: "verified" | "unverified";
}[];
readonly displayEscapesExcludedFromMatching: true;
}>;
import type { z } from "zod";
import type { analysisStringSchema } from "../../src/domain/hopperValues.js";

View File

@ -1,13 +1,16 @@
import assert from "node:assert/strict";
import { mcpTextValue } from "./mcp-verifier-results.mjs";
const fixtureSymbolPrefix = process.platform === "darwin" ? "_" : "";
/** Exercise literal/regex search, Unicode identities and cache invalidation in Hopper. */
export async function verifyHopperSearch(call, invalid, procedure) {
const address = procedure.address;
const original = await call("address_name", { address });
let stringCoverage;
try {
await verifySymbolNames(call, invalid, address);
await verifySearchModes(call, address);
stringCoverage = await verifySearchModes(call, address);
} finally {
await call("set_address_name", { address, name: original });
}
@ -26,7 +29,7 @@ export async function verifyHopperSearch(call, invalid, procedure) {
symbolUtf16LimitValidatedBeforeMutation: true,
regexAndCaseModes: true,
annotationCacheInvalidation: true,
escapedUtf8AndByteStrings: true,
stringCoverage,
};
}
@ -103,11 +106,26 @@ async function verifySearchModes(call, address) {
[{ address, value: "REA_Search_Upper" }],
);
const strings = await call("list_strings");
const long = "REA_LONG_LITERAL_" + "x".repeat(4096) + "needle";
const longItem = strings.find((item) =>
item.value.startsWith("REA_LONG_LITERAL_"),
const linux = process.platform === "linux";
const fullLiteral = "REA_LONG_LITERAL_" + "x".repeat(4096) + "needle";
// Recognition is a provider/loader capability, not an adapter guarantee.
// Always retain the original size in raw-byte coverage; Linux additionally
// requires an extent observed to produce native fragmented string objects.
const fullBytes = Buffer.from(fullLiteral + "\0");
const raw = await call("read_bytes", {
address: `${fixtureSymbolPrefix}rea_long_literal`,
length: fullBytes.length,
});
assert.equal(raw.complete, true);
assert.equal(raw.bytes_hex, fullBytes.toString("hex"));
const long = linux
? "REA_FRAGMENTED_LITERAL_" + "x".repeat(512) + "fragment_tail"
: fullLiteral;
const longItem = strings.find(
(item) => long.startsWith(item.value) && item.value.startsWith("REA_"),
);
assert.ok(longItem, "fixture omitted its real long literal");
if (!linux) assert.equal(longItem.address, raw.address);
assert.ok(long.startsWith(longItem.value));
assert.ok(longItem.provider_value.endsWith("…"));
assert.equal(longItem.string.termination, "missing");
@ -158,7 +176,7 @@ async function verifySearchModes(call, address) {
}
assert.deepEqual(
await call("search_strings", {
pattern: "needle",
pattern: linux ? "fragment_tail" : "needle",
mode: "literal",
case_sensitive: true,
}),
@ -176,36 +194,122 @@ async function verifySearchModes(call, address) {
}),
expected,
);
await verifyHopperStringObjects(call, [
{ value: "REA_UTF8_é_😀", encoding: "utf-8" },
{ value: 'REA_ESCAPED_"\\line\nend\t\r', encoding: "utf-8" },
{ value: "REA_LITERAL_BACKSLASH_\\n", encoding: "utf-8" },
{ value: "REA_LITERAL_…", encoding: "utf-8" },
{ value: "REA_LATIN1_ÿ", encoding: "latin-1" },
]);
const objects = await verifyHopperStringObjects(
call,
[
{
value: "REA_UTF8_é_😀",
encoding: "utf-8",
symbol: `${fixtureSymbolPrefix}rea_utf8_literal`,
recognitionOptional: linux,
},
{ value: 'REA_ESCAPED_"\\line\nend\t\r', encoding: "utf-8" },
{ value: "REA_LITERAL_BACKSLASH_\\n", encoding: "utf-8" },
{
value: "REA_LITERAL_…",
encoding: "utf-8",
symbol: `${fixtureSymbolPrefix}rea_ellipsis_literal`,
recognitionOptional: linux,
},
{
value: "REA_LATIN1_ÿ",
encoding: "latin-1",
symbol: `${fixtureSymbolPrefix}rea_latin1_literal`,
recognitionOptional: linux,
},
],
strings,
);
return {
fullLiteralSourceBytes: fullBytes.length,
fragmentedLiteralSourceBytes: Buffer.byteLength(long),
fullLiteralRecognition: {
address: raw.address,
state: strings.some((item) => item.address === raw.address)
? "observed"
: "not_observed",
// Linux validates the original through raw bytes; its required decoded
// fragment/dossier checks exercise the separate supported-size literal.
decodedObject: linux ? "unverified" : "verified",
},
...objects,
};
}
/** Compare native string identities and values with the source fixture's bytes. */
export async function verifyHopperStringObjects(call, expectations) {
const strings = await call("list_strings");
for (const { value, encoding } of expectations) {
const item = strings.find((candidate) => candidate.value === value);
assert.ok(
item,
`Hopper omitted the decoded string ${JSON.stringify(value)}`,
);
assert.equal(item.string.encoding, encoding);
assert.equal(item.string.encoding_status, "inferred");
assert.equal(item.string.termination, "present_or_not_required");
export async function verifyHopperStringObjects(
call,
expectations,
inventory = null,
) {
// Reuse only the caller's inventory for this unchanged document, never live
// state from an earlier session. Address-filter and search checks remain real.
const strings = inventory ?? (await call("list_strings"));
const recognition = [];
for (const {
value,
encoding,
symbol,
recognitionOptional = false,
} of expectations) {
const expectedBytes = Buffer.from(
value + "\0",
encoding.replaceAll("-", ""),
);
assert.equal(item.string.byte_length, expectedBytes.length);
const bytes = await call("read_bytes", {
let sourceBytes;
if (symbol !== undefined) {
const raw = await call("read_bytes", {
address: symbol,
length: expectedBytes.length,
});
sourceBytes = raw;
assert.equal(raw.complete, true);
assert.equal(raw.bytes_hex, expectedBytes.toString("hex"));
}
const item = strings.find((candidate) =>
sourceBytes === undefined
? candidate.value === value
: candidate.address === sourceBytes.address,
);
if (item === undefined && recognitionOptional) {
assert.ok(
symbol,
"optional recognition requires independent source-byte coverage",
);
recognition.push({
value,
address: sourceBytes.address,
state: "not_observed",
decodedObject: "unverified",
});
continue;
}
assert.ok(
item,
`Hopper omitted the decoded string ${JSON.stringify(value)}`,
);
assert.equal(
item.value,
value,
"native string decoding changed the source value",
);
recognition.push({
value,
address: item.address,
length: expectedBytes.length,
state: "observed",
decodedObject: "verified",
});
assert.equal(item.string.encoding, encoding);
assert.equal(item.string.encoding_status, "inferred");
assert.equal(item.string.termination, "present_or_not_required");
assert.equal(item.string.byte_length, expectedBytes.length);
const bytes =
sourceBytes ??
(await call("read_bytes", {
address: item.address,
length: expectedBytes.length,
}));
assert.equal(bytes.complete, true);
assert.equal(bytes.bytes_hex, expectedBytes.toString("hex"));
assert.deepEqual(await call("list_strings", { address: item.address }), [
item,
@ -225,7 +329,7 @@ export async function verifyHopperStringObjects(call, expectations) {
);
return {
sourceBytesVerified: true,
inferredEncodingReported: true,
recognition,
displayEscapesExcludedFromMatching: true,
};
}

View File

@ -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,12 +20,12 @@ 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");
} catch (error) {
if (error instanceof Error && error.message.includes("clean runner"))
throw error;
if (error?.code !== "ENOENT") throw error;
}
const runJson = async (args) => {
@ -35,14 +36,25 @@ const runJson = async (args) => {
return JSON.parse(stdout);
};
const plan = await runJson(["setup", "--json"]);
if (plan.status !== "needs_confirmation")
throw new Error("setup did not produce a read-only confirmation plan");
const plan = await runJson([
"setup",
"--install-hopper",
"--dry-run",
"--json",
]);
if (
plan.status !== "planned" ||
!plan.plannedActions.some(({ kind }) => kind === "install_hopper") ||
plan.appliedActions.length !== 0
)
throw new Error("setup did not produce a read-only Hopper installation plan");
await access("/opt/hopper/bin/Hopper").then(
() => {
throw new Error("setup plan mutated the clean runner");
},
() => undefined,
(error) => {
if (error?.code !== "ENOENT") throw error;
},
);
const installed = await runJson([
@ -58,7 +70,7 @@ if (
throw new Error(
`approved setup did not become ready: ${JSON.stringify(installed)}`,
);
const doctor = await runJson(["doctor", "--json"]);
const doctor = await runJson(["doctor", "--provider", "hopper", "--json"]);
if (doctor.healthy !== true)
throw new Error(`doctor is unhealthy: ${JSON.stringify(doctor)}`);
@ -67,8 +79,6 @@ await execFileAsync(command, ["analyze", "/usr/bin/true"], {
timeout: 180_000,
maxBuffer: 10 * 1024 * 1024,
});
process.env.HOPPER_TARGET_PATH = "/usr/bin/true";
process.env.HOPPER_SECOND_TARGET_PATH = "/usr/bin/false";
process.env.REA_VERIFY_SERVER_ARGS_JSON = '["mcp"]';
await import("./verify-real-hopper-linux.mjs");
@ -95,28 +105,24 @@ try {
await rm(modifiedDirectory, { recursive: true, force: true });
}
const repeated = await runJson([
"setup",
"--yes",
"--install-hopper",
"--json",
]);
const repeated = await runJson(["setup", "--yes", "--json"]);
if (
repeated.status !== "ready" ||
repeated.appliedActions.includes("installed_hopper")
repeated.plannedActions.length !== 0 ||
repeated.appliedActions.length !== 0
)
throw new Error("a second setup was not idempotent");
const { stdout: processes } = await execFileAsync("ps", [
"-ax",
"-o",
"command=",
]);
if (/\/opt\/hopper\/bin\/Hopper|\bXvfb\b|hopper-demo-x11\.py/u.test(processes))
throw new Error("Linux setup verification leaked a Hopper demo session");
await assertCleanLinuxHopperRuntime();
const completedVerifierRun = await completeVerifierRun(verifierRun);
if (
completedVerifierRun.process_lineage.status !== "verified" ||
completedVerifierRun.process_lineage.descendants.length !== 0
)
throw new Error("Linux setup verification did not prove owned child cleanup");
console.log(
JSON.stringify({
verifier_run: await completeVerifierRun(verifierRun),
verifier_run: completedVerifierRun,
status: "verified",
setup: true,
cli: true,

View File

@ -11,7 +11,8 @@ createVerifierRun();
if (process.platform !== "linux")
throw new Error("Linux real-Hopper verification must run on Linux");
const launcher = process.env.HOPPER_LAUNCHER_PATH ?? "/opt/hopper/bin/Hopper";
const configuredLauncher = process.env.HOPPER_LAUNCHER_PATH;
const launcher = configuredLauncher ?? "/opt/hopper/bin/Hopper";
await access(launcher);
await access("/usr/bin/Xvfb");
await access("/usr/bin/xauth");
@ -28,5 +29,7 @@ try {
process.env.HOPPER_LAUNCHER_PATH = alternateLauncher;
await import("./verify-real-hopper.mjs");
} finally {
if (configuredLauncher === undefined) delete process.env.HOPPER_LAUNCHER_PATH;
else process.env.HOPPER_LAUNCHER_PATH = configuredLauncher;
await rm(alternateDirectory, { recursive: true, force: true });
}

View File

@ -5,13 +5,24 @@ volatile int rea_c_global = 7;
#define REA_LONG_CHUNK "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
#define REA_LONG_256 REA_LONG_CHUNK REA_LONG_CHUNK REA_LONG_CHUNK REA_LONG_CHUNK
#define REA_LONG_1024 REA_LONG_256 REA_LONG_256 REA_LONG_256 REA_LONG_256
/* Keep the full-size literal even when a loader does not type it as a string. */
__attribute__((used)) const char rea_long_literal[] =
"REA_LONG_LITERAL_" REA_LONG_1024 REA_LONG_1024 REA_LONG_1024 REA_LONG_1024 "needle";
/* Linux Hopper 6.4.2 recognizes this extent and splits it into native objects. */
__attribute__((used)) const char rea_fragmented_literal[] =
"REA_FRAGMENTED_LITERAL_" REA_LONG_256 REA_LONG_256 "fragment_tail";
__attribute__((used)) const char rea_utf8_literal[] = "REA_UTF8_é_😀";
__attribute__((used)) const char rea_ellipsis_literal[] = "REA_LITERAL_…";
__attribute__((used)) const char rea_latin1_literal[] = "REA_LATIN1_\xff";
__attribute__((noinline, used)) void rea_long_literal_procedure(void) {
puts("REA_LONG_LITERAL_" REA_LONG_1024 REA_LONG_1024 REA_LONG_1024 REA_LONG_1024 "needle");
puts("REA_UTF8_é_😀");
puts(rea_long_literal);
puts(rea_fragmented_literal);
puts(rea_utf8_literal);
puts("REA_ESCAPED_\"\\line\nend\t\r");
puts("REA_LITERAL_BACKSLASH_\\n");
puts("REA_LITERAL_…");
puts("REA_LATIN1_\xff");
puts(rea_ellipsis_literal);
puts(rea_latin1_literal);
}
__attribute__((noinline, used)) int rea_leaf(int value) {

View File

@ -0,0 +1,97 @@
import { expect, it } from "vitest";
import { verifyHopperStringObjects } from "../../../../scripts/lib/real-hopper-search.mjs";
const value = "REA_UTF8_é_😀";
const address = "0x402020";
const bytes = Buffer.from(`${value}\0`);
const item = {
address,
value,
string: {
encoding: "utf-8",
encoding_status: "inferred" as const,
termination: "present_or_not_required" as const,
byte_length: bytes.length,
},
};
const expectation = {
value,
encoding: "utf-8",
symbol: "rea_utf8_literal",
recognitionOptional: true,
};
it("keeps source identity and unknown decoding when the same text exists elsewhere", async () => {
const report = await verifyHopperStringObjects(
fixtureCall(),
[expectation],
[{ ...item, address: "0x403000" }],
);
expect(report.recognition).toEqual([
{ value, address, state: "not_observed", decodedObject: "unverified" },
]);
});
it("reuses source bytes and inventory while checking real address filters and searches", async () => {
const calls: string[] = [];
const report = await verifyHopperStringObjects(
fixtureCall(calls),
[expectation],
[item],
);
expect(report.recognition).toEqual([
{ value, address, state: "observed", decodedObject: "verified" },
]);
expect(calls).toEqual([
"read_bytes:rea_utf8_literal",
`list_strings:${address}`,
`search_strings:${value}`,
"search_strings:\\xC3\\xA9",
]);
});
it.each([
["changed source bytes", { bytes_hex: "00" }, item],
["incomplete source read", { complete: false }, item],
["wrong decoded value", {}, { ...item, value: "wrong" }],
[
"wrong decoded extent",
{},
{ ...item, string: { ...item.string, byte_length: 1 } },
],
])(
"rejects %s even when native recognition is optional",
async (_label, override, decoded) => {
await expect(
verifyHopperStringObjects(
fixtureCall([], override),
[expectation],
[decoded],
),
).rejects.toThrow();
},
);
function fixtureCall(
calls: string[] = [],
override: Record<string, unknown> = {},
) {
return async (name: string, arguments_: Record<string, unknown> = {}) => {
calls.push(
`${name}:${String(arguments_.address ?? arguments_.pattern ?? "")}`,
);
if (name === "read_bytes")
return {
address,
bytes_hex: bytes.toString("hex"),
complete: true,
...override,
};
if (name === "list_strings" && arguments_.address === address)
return [item];
if (name === "search_strings")
return arguments_.pattern === value ? [item] : [];
throw new Error(`Unmodeled verifier call: ${name}`);
};
}

View File

@ -0,0 +1,113 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { chmod, copyFile, readFile, symlink, unlink } from "node:fs/promises";
import { join } from "node:path";
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";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
const execFileAsync = promisify(execFile);
it
.runIf(process.platform === "linux")
.each(["demo helper", "alternate launcher"])(
"rejects a reparented %s without signaling it",
async (mode) => {
const runId = randomUUID();
const childCode =
"process.title = 'Xvfb'; setInterval(() => {}, 1000); process.send('ready');";
const directory = await createTestTempDirectory("rea-clean-runtime-test-");
const launcher = join(directory, "Hopper");
const alternate = join(directory, "Hopper-alternate");
const alternateMode = mode === "alternate launcher";
if (alternateMode) {
await copyFile("/usr/bin/sleep", launcher);
await chmod(launcher, 0o700);
await symlink(launcher, alternate);
}
const command = alternateMode ? alternate : process.execPath;
const arguments_ = alternateMode ? ["30"] : ["-e", childCode];
const parentCode = `
const { spawn } = require('node:child_process');
const child = spawn(${JSON.stringify(command)}, ${JSON.stringify(arguments_)}, {
detached: true, stdio: ${JSON.stringify(alternateMode ? "ignore" : ["ignore", "ignore", "ignore", "ipc"])}
});
child.once('error', () => process.exit(1));
child.once(${JSON.stringify(alternateMode ? "spawn" : "message")}, () => {
process.stdout.write(JSON.stringify({ pid: child.pid, parentPid: process.pid }));
if (child.connected) 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);
if (alternateMode) {
const commandLine = await readFile(
`/proc/${String(pid)}/cmdline`,
"utf8",
);
expect(commandLine.split("\0")[0]).toBe(alternate);
await unlink(alternate);
}
await expect(
assertCleanLinuxHopperRuntime(alternateMode ? [launcher] : []),
).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);
}
}
},
);