From 58d23e42b6c82edeb34ee8677d197a87041acb17 Mon Sep 17 00:00:00 2001 From: Luis Taffelli <86675342+LuisTaffelli@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:06:08 -0300 Subject: [PATCH 1/6] fix(verification): align Linux Hopper setup lane with current contracts --- docs/testing.md | 11 ++++++ package.json | 2 +- scripts/verify-linux-setup-e2e.mjs | 51 +++++++++++++++------------- scripts/verify-real-hopper-linux.mjs | 5 ++- 4 files changed, 43 insertions(+), 26 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 1473bbf35..42c5cb24c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -376,6 +376,17 @@ 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. 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 diff --git a/package.json b/package.json index 0cc9979eb..50662290a 100644 --- a/package.json +++ b/package.json @@ -192,7 +192,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", diff --git a/scripts/verify-linux-setup-e2e.mjs b/scripts/verify-linux-setup-e2e.mjs index 8b57fbacc..a2b79dd60 100644 --- a/scripts/verify-linux-setup-e2e.mjs +++ b/scripts/verify-linux-setup-e2e.mjs @@ -23,8 +23,7 @@ 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 +34,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 +68,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 +77,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 +103,23 @@ 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"); +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, diff --git a/scripts/verify-real-hopper-linux.mjs b/scripts/verify-real-hopper-linux.mjs index 854a58ce0..951b71078 100644 --- a/scripts/verify-real-hopper-linux.mjs +++ b/scripts/verify-real-hopper-linux.mjs @@ -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 }); } From c039dd81d52b7eb049bf6445a0bf21597efe264f Mon Sep 17 00:00:00 2001 From: Luis Taffelli <86675342+LuisTaffelli@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:35:44 -0300 Subject: [PATCH 2/6] fix(verification): retain orphan checks on clean Linux runners --- docs/testing.md | 6 +- scripts/lib/clean-linux-hopper-runtime.d.mts | 1 + scripts/lib/clean-linux-hopper-runtime.mjs | 15 ++++ scripts/verify-linux-setup-e2e.mjs | 3 + .../cleanLinuxHopperRuntime.test.ts | 89 +++++++++++++++++++ 5 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 scripts/lib/clean-linux-hopper-runtime.d.mts create mode 100644 scripts/lib/clean-linux-hopper-runtime.mjs create mode 100644 tests/process-global/cleanLinuxHopperRuntime.test.ts 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); + } + } + }, +); From b88cdc248a0589cf6314fc653fc92acae51531b4 Mon Sep 17 00:00:00 2001 From: Luis Taffelli <86675342+LuisTaffelli@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:06:32 -0300 Subject: [PATCH 3/6] fix(verification): detect Hopper through alternate launcher paths --- docs/testing.md | 6 ++- scripts/lib/clean-linux-hopper-runtime.d.mts | 4 +- scripts/lib/clean-linux-hopper-runtime.mjs | 46 +++++++++++++++- .../cleanLinuxHopperRuntime.test.ts | 53 +++++++++++++++---- 4 files changed, 95 insertions(+), 14 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 3f1b2d68b..1685b361d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -385,8 +385,10 @@ 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; -the dedicated runner must have no unrelated Hopper/Xvfb sessions. It requires +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. diff --git a/scripts/lib/clean-linux-hopper-runtime.d.mts b/scripts/lib/clean-linux-hopper-runtime.d.mts index c860c876d..dd1010aa1 100644 --- a/scripts/lib/clean-linux-hopper-runtime.d.mts +++ b/scripts/lib/clean-linux-hopper-runtime.d.mts @@ -1 +1,3 @@ -export function assertCleanLinuxHopperRuntime(): Promise; +export function assertCleanLinuxHopperRuntime( + launcherPaths?: readonly string[], +): Promise; diff --git a/scripts/lib/clean-linux-hopper-runtime.mjs b/scripts/lib/clean-linux-hopper-runtime.mjs index 2fc8fbfad..f7556ec67 100644 --- a/scripts/lib/clean-linux-hopper-runtime.mjs +++ b/scripts/lib/clean-linux-hopper-runtime.mjs @@ -1,5 +1,6 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; +import { readdir, stat } from "node:fs/promises"; const execFileAsync = promisify(execFile); @@ -8,8 +9,51 @@ const execFileAsync = promisify(execFile); * 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 () => { +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. + 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; + } + throw error; + } + if ( + launchers.some( + ({ dev, ino }) => dev === executable.dev && ino === executable.ino, + ) + ) + throw new Error("Clean Linux runner retained a Hopper demo process"); + } }; diff --git a/tests/process-global/cleanLinuxHopperRuntime.test.ts b/tests/process-global/cleanLinuxHopperRuntime.test.ts index af16911fb..bc86aea77 100644 --- a/tests/process-global/cleanLinuxHopperRuntime.test.ts +++ b/tests/process-global/cleanLinuxHopperRuntime.test.ts @@ -1,5 +1,16 @@ import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; +import { + chmod, + copyFile, + mkdtemp, + readFile, + rm, + symlink, + unlink, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { promisify } from "node:util"; import { expect, it } from "vitest"; @@ -10,21 +21,34 @@ 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 () => { +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 mkdtemp(join(tmpdir(), "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(process.execPath, ['-e', ${JSON.stringify(childCode)}], { - detached: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'] + 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('message', () => { + child.once(${JSON.stringify(alternateMode ? "spawn" : "message")}, () => { process.stdout.write(JSON.stringify({ pid: child.pid, parentPid: process.pid })); - child.disconnect(); + if (child.connected) child.disconnect(); child.unref(); }); `; @@ -64,9 +88,17 @@ it.runIf(process.platform === "linux")( lineage.process_lineage.descendants.some((p) => p.pid === pid), ).toBe(false); - await expect(assertCleanLinuxHopperRuntime()).rejects.toThrow( - "Clean Linux runner retained a Hopper demo process", - ); + 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", @@ -84,6 +116,7 @@ it.runIf(process.platform === "linux")( }); expect(cleanup.cleaned, JSON.stringify(cleanup)).toBe(true); } + await rm(directory, { recursive: true, force: true }); } }, ); From b9d9a474cf53615ba60651f429068cf3c69fbbb9 Mon Sep 17 00:00:00 2001 From: Luis Taffelli <86675342+LuisTaffelli@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:28:41 -0300 Subject: [PATCH 4/6] fix(verification): report identified Hopper leaks before proc errors --- scripts/lib/clean-linux-hopper-runtime.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/lib/clean-linux-hopper-runtime.mjs b/scripts/lib/clean-linux-hopper-runtime.mjs index f7556ec67..27a306fce 100644 --- a/scripts/lib/clean-linux-hopper-runtime.mjs +++ b/scripts/lib/clean-linux-hopper-runtime.mjs @@ -31,6 +31,7 @@ export const assertCleanLinuxHopperRuntime = async ( 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; @@ -47,7 +48,10 @@ export const assertCleanLinuxHopperRuntime = async ( }); if (owner === undefined || owner.uid !== process.getuid()) continue; } - throw error; + // 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( @@ -56,4 +60,5 @@ export const assertCleanLinuxHopperRuntime = async ( ) throw new Error("Clean Linux runner retained a Hopper demo process"); } + if (inspectionError !== undefined) throw inspectionError; }; From 8ea0432ffc11d47d2f43fbd4964f48116b4bd4a5 Mon Sep 17 00:00:00 2001 From: Luis Taffelli <86675342+LuisTaffelli@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:29:19 -0300 Subject: [PATCH 5/6] fix(verification): distinguish Hopper string recognition from byte fidelity --- docs/testing.md | 12 ++- scripts/lib/real-hopper-search.mjs | 120 ++++++++++++++++++++++++----- tests/conformance/c/fixture.c | 19 ++++- 3 files changed, 126 insertions(+), 25 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 1685b361d..76fa648b2 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -369,7 +369,17 @@ 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 +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 and reports absent +recognition with decoded-object coverage explicitly unverified. Recognized +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 diff --git a/scripts/lib/real-hopper-search.mjs b/scripts/lib/real-hopper-search.mjs index 0d796f3d2..5e37564e0 100644 --- a/scripts/lib/real-hopper-search.mjs +++ b/scripts/lib/real-hopper-search.mjs @@ -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,9 +106,23 @@ 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"); assert.ok(long.startsWith(longItem.value)); @@ -158,7 +175,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,31 +193,94 @@ async function verifySearchModes(call, address) { }), expected, ); - await verifyHopperStringObjects(call, [ - { value: "REA_UTF8_é_😀", encoding: "utf-8" }, + 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" }, - { value: "REA_LATIN1_ÿ", encoding: "latin-1" }, + { + 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, + }, ]); + return { + fullLiteralSourceBytes: fullBytes.length, + fragmentedLiteralSourceBytes: Buffer.byteLength(long), + fullLiteralRecognition: strings.some((item) => + item.value.startsWith("REA_LONG_LITERAL_"), + ) + ? "observed" + : "not_observed", + ...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"); + const recognition = []; + for (const { + value, + encoding, + symbol, + recognitionOptional = false, + } of expectations) { const expectedBytes = Buffer.from( value + "\0", encoding.replaceAll("-", ""), ); + let sourceAddress; + if (symbol !== undefined) { + const raw = await call("read_bytes", { + address: symbol, + length: expectedBytes.length, + }); + sourceAddress = raw.address; + assert.equal(raw.complete, true); + assert.equal(raw.bytes_hex, expectedBytes.toString("hex")); + } + const item = strings.find((candidate) => + sourceAddress === undefined + ? candidate.value === value + : candidate.address === sourceAddress, + ); + if (item === undefined && recognitionOptional) { + assert.ok( + symbol, + "optional recognition requires independent source-byte coverage", + ); + recognition.push({ + value, + 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, 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 = await call("read_bytes", { address: item.address, @@ -225,7 +305,7 @@ export async function verifyHopperStringObjects(call, expectations) { ); return { sourceBytesVerified: true, - inferredEncodingReported: true, + recognition, displayEscapesExcludedFromMatching: true, }; } diff --git a/tests/conformance/c/fixture.c b/tests/conformance/c/fixture.c index 6708ac9dd..e1dff9a9c 100644 --- a/tests/conformance/c/fixture.c +++ b/tests/conformance/c/fixture.c @@ -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) { From 97b76aa20afbe2c654d2309175a708f774373234 Mon Sep 17 00:00:00 2001 From: Luis Taffelli <86675342+LuisTaffelli@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:46:16 -0300 Subject: [PATCH 6/6] fix(verification): report full literal decoding coverage explicitly --- docs/testing.md | 12 +++++++----- scripts/lib/real-hopper-search.mjs | 15 ++++++++++----- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 76fa648b2..4327d1476 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -372,15 +372,17 @@ cover active and queued expiry, late replies and timer cleanup. 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 and reports absent -recognition with decoded-object coverage explicitly unverified. Recognized -objects must still match their source bytes, encoding, filters and searches. +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 +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 diff --git a/scripts/lib/real-hopper-search.mjs b/scripts/lib/real-hopper-search.mjs index 5e37564e0..e7011b66c 100644 --- a/scripts/lib/real-hopper-search.mjs +++ b/scripts/lib/real-hopper-search.mjs @@ -125,6 +125,7 @@ async function verifySearchModes(call, address) { (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"); @@ -218,11 +219,15 @@ async function verifySearchModes(call, address) { return { fullLiteralSourceBytes: fullBytes.length, fragmentedLiteralSourceBytes: Buffer.byteLength(long), - fullLiteralRecognition: strings.some((item) => - item.value.startsWith("REA_LONG_LITERAL_"), - ) - ? "observed" - : "not_observed", + 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, }; }