Skip to content
Merged
12 changes: 10 additions & 2 deletions docs/decisions/ci-utility.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,16 @@ speedup claim or an inferred narrower harness.

`bun run check:pr --base origin/main` runs local lint/leakage, metadata,
contribution changeset and affected fast invariants, including uncommitted work.
It does not require Docker, Chrome or namespaces and does not duplicate complete
packaging, strict types or full browser/runtime proof. Agents still supply
It does not require Docker or Chrome and does not duplicate complete packaging,
strict types or full browser/runtime proof. Under the revised #2370 scope, Linux
preflight requires permitted user/network namespaces and times three real
sandbox setups before checks run; the fastest must take less than 250 ms.
A probe timeout retains its 5 s budget and refuses with an elapsed lower bound
and actionable host diagnostics, separately from an ordinary failed setup.
The timing probe belongs to the local CLI entry point. Hosted categories use
their own runtime prerequisites: container-backed browser/editorial proof does
not require bubblewrap on the host, while native proof retains its namespace
prerequisite. This leaves product and test deadlines unchanged. Agents still supply
focused failing-first and restored mutation receipts, debug failures and review
visual results. A test failure is not excused by moving its execution.

Expand Down
13 changes: 13 additions & 0 deletions docs/process/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,19 @@ in the [CI and merge reference](../../.agents/skills/work-issue/references/ci-an
For an upper stack layer, substitute its verified immediate parent ref;
see [stack rules](github.md#stacked-pull-requests). Each included PR needs
its own selected proof and actual base/head/checkout evidence.
On Linux, every executing local preflight mode times three real sandbox
setup probes and requires the fastest to take less than 250 ms. Slow setup
can consume the workers' fixed runtime deadlines. Refusal reports the fastest
time, bubblewrap version, mount count and dominant mount-point parent prefix.
A probe that reaches its 5 s budget refuses with an elapsed lower bound and
the same host diagnostics; failed setup remains a separate prerequisite error.
Use bubblewrap 0.13 or newer to avoid the older per-mount remount cost, or
restart Docker Desktop (on WSL2, `wsl --shutdown`) to clear leaked bind mounts.
Mount count alone does not decide whether setup is affordable. The guard
never unmounts host paths or requires root. This local CLI prerequisite is
separate from hosted category checks; container-backed proof uses its pinned
runtime and does not acquire a host bubblewrap prerequisite. Linux focused checks now require
`bwrap` and permitted user/network namespaces; `--plan` stays inspection only.
This is local preflight and affected fast feedback, including working-tree
inputs. Default preflight also runs `bun run test --changed=<merge-base-sha>`
through the offline wrapper as import-graph affected feedback. Bun includes
Expand Down
48 changes: 46 additions & 2 deletions scripts/check-pr.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** Focused local preflight; repeatable full proof is authoritative in hosted CI. */
import { resolve } from "node:path";
import { accessSync, constants, statSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { accessSync, constants, readFileSync, statSync } from "node:fs";
import { changedFiles, planChecks, workspaces, type CheckPlan } from "./ci-plan";
import { runCommands, type Command } from "./ci-runner";
import { checkPackages } from "./check-packages";
Expand Down Expand Up @@ -49,6 +49,49 @@ export function localChecks(plan: CheckPlan, full = false, mergeBase?: string):
return checks;
}

class SandboxSetupTimeout extends Error {}

function probeSandboxSetup(): number {
const start = performance.now();
const namespace = Bun.spawnSync(["bwrap", "--unshare-net", "--ro-bind", "/", "/", "--proc", "/proc", "--dev", "/dev", "/usr/bin/true"], { timeout: 5000 });
const elapsed = performance.now() - start;
if (namespace.exitedDueToTimeout) throw new SandboxSetupTimeout();
if (namespace.exitCode !== 0) throw new Error("Local checks require bwrap and permitted user/network namespaces; sandbox setup probe failed");
return elapsed;
}

export function requireLocalSandbox(readMountinfo = () => readFileSync("/proc/self/mountinfo", "utf8"), probeTimer = probeSandboxSetup): void {
if (process.platform === "linux") {
const refuse = (measurement: string): never => {
const entries = readMountinfo().split("\n").filter(line => line.trim().length > 0);
// Group mount points by their immediate parent, so individually named
// leaked bind mounts report their containing directory rather than `/`.
const prefixes = new Map<string, number>();
for (const entry of entries) {
const mountpoint = entry.split(" ")[4];
if (!mountpoint) throw new Error("Cannot read mount point from /proc/self/mountinfo");
const decoded = mountpoint.replace(/\\([0-7]{3})/g, (_, octal: string) => String.fromCharCode(parseInt(octal, 8)));
const prefix = dirname(decoded);
prefixes.set(prefix, (prefixes.get(prefix) ?? 0) + 1);
}
const [prefix, count] = [...prefixes].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0] ?? ["(none)", 0];
const version = Bun.spawnSync(["bwrap", "--version"], { timeout: 5000 });
const versionText = version.exitCode === 0 ? new TextDecoder().decode(version.stdout).trim() : "bubblewrap version unavailable";
throw new Error(`Local checks refuse slow sandbox setup: ${measurement}; ${versionText}; ${entries.length} mount entries; dominant mount-point prefix: ${prefix} (${count} entries). Use bubblewrap 0.13 or newer. Restart Docker Desktop, or on WSL2 run wsl --shutdown; then retry local preflight.`);
};
let fastest: number;
try {
fastest = Math.min(...Array.from({ length: 3 }, () => probeTimer()));
} catch (error) {
if (!(error instanceof SandboxSetupTimeout)) throw error;
// The killed probe has no completed sample. Report only the budget's
// elapsed lower bound, never a precise fastest-of-three measurement.
return refuse("probe timed out; setup elapsed at least 5000 ms (5000 ms probe budget; requires less than 250 ms)");
}
if (fastest >= 250) refuse(`fastest of three ${fastest.toFixed(1)} ms (requires less than 250 ms)`);
}
}

export function requireRuntimeTools(plan: CheckPlan): string | undefined {
if (!Bun.semver.satisfies(Bun.version, ">=1.4.0")) throw new Error("Local checks require Bun >=1.4.0 (CI pins 1.4.2)");
if (!Bun.which("flock")) throw new Error("Local checks require flock for workspace output coordination");
Expand Down Expand Up @@ -93,6 +136,7 @@ if (import.meta.main) {
const commands = localChecks(plan, full, mergeBase);
console.log(JSON.stringify({ ...plan, commands: commands.map(command => command.name), packedConsumerChecks: full && plan.pack, hostedProofRequired: plan.local }, null, 2));
if (print) process.exit(0);
requireLocalSandbox();
const chrome = full ? requireRuntimeTools(plan) : undefined;
// Setup/metadata are ordered. Full Bun tests and strict types are the only
// independent memory-bounded pair; browser jobs stay sequential to retain
Expand Down
127 changes: 125 additions & 2 deletions tests/local-checks.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { afterEach, expect, spyOn, test } from "bun:test";
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { join, resolve } from "node:path";
import { checkPackages } from "../scripts/check-packages";
import { localChecks, requireRuntimeTools } from "../scripts/check-pr";
import { localChecks, requireLocalSandbox, requireRuntimeTools } from "../scripts/check-pr";
import { testArgv } from "../scripts/test";
import { planChecks, workspaces } from "../scripts/ci-plan";

Expand All @@ -15,6 +15,129 @@ function fixture() {
return dir;
}

function mountTable(count: number): string {
return Array.from({ length: count }, (_, i) => `${i + 1} 0 0:1 / /mnt/fixture/binds/${i} rw - tmpfs tmpfs rw`).join("\n") + "\n";
}

test("sandbox prerequisite permits 249 ms despite a large mount table", () => {
expect(requireLocalSandbox( () => mountTable(8000), () => 249)).toBeUndefined();
});

test("sandbox prerequisite refuses 250 ms before downstream checks", () => {
const which = spyOn(Bun, "which").mockReturnValue("/fixture/tool");
const spawn = spyOn(Bun, "spawnSync").mockReturnValue({ exitCode: 0, stdout: new TextEncoder().encode("bubblewrap 0.9.0\n") } as ReturnType<typeof Bun.spawnSync>);
try {
{
expect(() => requireLocalSandbox( () => mountTable(20), () => 250))
.toThrow("fastest of three 250.0 ms (requires less than 250 ms); bubblewrap 0.9.0; 20 mount entries; dominant mount-point prefix: /mnt/fixture/binds (20 entries). Use bubblewrap 0.13 or newer. Restart Docker Desktop, or on WSL2 run wsl --shutdown");
}
expect(which).not.toHaveBeenCalled();
expect(spawn).toHaveBeenCalledTimes(1);
expect(spawn).toHaveBeenCalledWith(["bwrap", "--version"], { timeout: 5000 });
} finally { which.mockRestore(); spawn.mockRestore(); }
});

test("sandbox prerequisite compares the fastest of exactly three probes", () => {
const samples = [900, 249, 300];
const timer = () => samples.shift()!;
expect(requireLocalSandbox( () => mountTable(8000), timer)).toBeUndefined();
expect(samples).toEqual([]);
});

test("sandbox prerequisite timeout reports a lower bound and actionable host diagnostics", () => {
const spawn = spyOn(Bun, "spawnSync").mockImplementation(argv => {
const command = Array.isArray(argv) ? argv : argv.cmd;
return {
exitCode: command[1] === "--version" ? 0 : 143,
exitedDueToTimeout: command[1] !== "--version",
stdout: new TextEncoder().encode(command[1] === "--version" ? "bubblewrap 0.9.0\n" : ""),
} as ReturnType<typeof Bun.spawnSync>;
});
try {
let message = "";
try { requireLocalSandbox(() => mountTable(24000)); }
catch (error) { message = (error as Error).message; }
expect(message, "TIMED_OUT_SANDBOX_MUST_REPORT_ACTIONABLE_LOWER_BOUND")
.toContain("probe timed out; setup elapsed at least 5000 ms (5000 ms probe budget; requires less than 250 ms)");
expect(message).toContain("bubblewrap 0.9.0; 24000 mount entries; dominant mount-point prefix: /mnt/fixture/binds (24000 entries)");
expect(message).toContain("Use bubblewrap 0.13 or newer. Restart Docker Desktop, or on WSL2 run wsl --shutdown");
expect(message).not.toContain("fastest of three");
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn.mock.calls[0]).toEqual([["bwrap", "--unshare-net", "--ro-bind", "/", "/", "--proc", "/proc", "--dev", "/dev", "/usr/bin/true"], { timeout: 5000 }]);
expect(spawn.mock.calls[1]).toEqual([["bwrap", "--version"], { timeout: 5000 }]);
} finally { spawn.mockRestore(); }
});

test("sandbox prerequisite does not classify nonzero, signal or spawn errors as slow timeouts", () => {
const spawn = spyOn(Bun, "spawnSync");
try {
for (const result of [{ exitCode: 1 }, { exitCode: 124 }, { exitCode: 137, signalCode: "SIGKILL", exitedDueToTimeout: false }]) {
spawn.mockReturnValue(result as ReturnType<typeof Bun.spawnSync>);
expect(() => requireLocalSandbox(() => { throw new Error("MOUNT_DIAGNOSTICS_MUST_NOT_RUN_FOR_FAILED_SETUP"); }))
.toThrow("sandbox setup probe failed");
}
const failure = new Error("Odysseus bwrap permission denied");
spawn.mockImplementation(() => { throw failure; });
expect(() => requireLocalSandbox()).toThrow(failure);
expect(spawn).toHaveBeenCalledTimes(4);
} finally { spawn.mockRestore(); }
});

test("sandbox prerequisite reports the largest parent prefix and decodes mountinfo escapes", () => {
const table = mountTable(600).replaceAll("/mnt/fixture/binds", "/mnt/fixture/binds\\040here") +
mountTable(400).replaceAll("/mnt/fixture/binds", "/other");
const spawn = spyOn(Bun, "spawnSync").mockReturnValue({ exitCode: 0, stdout: new TextEncoder().encode("bubblewrap fixture") } as ReturnType<typeof Bun.spawnSync>);
try {
expect(() => requireLocalSandbox( () => table, () => 300))
.toThrow("dominant mount-point prefix: /mnt/fixture/binds here (600 entries)");
} finally { spawn.mockRestore(); }
});

test("sandbox prerequisite runs the real namespace command three times and rejects failed setup", () => {
const spawn = spyOn(Bun, "spawnSync").mockReturnValue({ exitCode: 0 } as ReturnType<typeof Bun.spawnSync>);
try {
expect(requireLocalSandbox()).toBeUndefined();
expect(spawn).toHaveBeenCalledTimes(3);
expect(spawn).toHaveBeenCalledWith(["bwrap", "--unshare-net", "--ro-bind", "/", "/", "--proc", "/proc", "--dev", "/dev", "/usr/bin/true"], { timeout: 5000 });
spawn.mockReturnValue({ exitCode: 1 } as ReturnType<typeof Bun.spawnSync>);
expect(() => requireLocalSandbox()).toThrow("sandbox setup probe failed");
} finally { spawn.mockRestore(); }
});

test("focused and full local CLI refuse setup before downstream proof while plan stays inspection-only", async () => {
const dir = fixture(), preload = join(dir, "odysseus-refused-setup.ts");
writeFileSync(preload, `const original=Bun.spawnSync;
Bun.spawnSync=((argv,options)=>{
const command=Array.isArray(argv)?argv:argv.cmd;
return command[0]==="bwrap"?{exitCode:1,stdout:Buffer.alloc(0),stderr:Buffer.alloc(0)}:original(argv,options);});
Bun.spawn=()=>{throw new Error("DOWNSTREAM_PROOF_REACHED_BEFORE_SANDBOX_REFUSAL");};`);
for (const mode of [[], ["--full"], ["--plan"]]) {
const child = Bun.spawn([process.execPath, "--preload", preload, resolve(import.meta.dir, "../scripts/check-pr.ts"), ...mode],
{ cwd: resolve(import.meta.dir, ".."), stdout: "pipe", stderr: "pipe" });
const [code, out, error] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
if (mode[0] === "--plan") {
expect(code, error).toBe(0); expect(out).toContain("hostedProofRequired");
} else {
expect(code, "LOCAL_CLI_MUST_REFUSE_FAILED_SANDBOX_SETUP").not.toBe(0);
expect(error, "LOCAL_CLI_REFUSES_BEFORE_DOWNSTREAM_PROOF").toContain("sandbox setup probe failed");
expect(error).not.toContain("DOWNSTREAM_PROOF_REACHED");
}
}
});

test("container-backed hosted categories do not require host sandbox setup", () => {
const which = spyOn(Bun, "which").mockImplementation(name => name === "docker" || name === "flock" ? `/fixture/${name}` : null);
const spawn = spyOn(Bun, "spawnSync").mockImplementation(() => { throw new Error("HOST_SANDBOX_MUST_NOT_RUN_FOR_CONTAINER_PROOF"); });
try {
for (const category of ["browser", "layout", "captures", "endurance"] as const) {
const plan = planChecks([], []);
plan.local = { ...plan.local, fullTests: false, [category]: true };
expect(() => requireRuntimeTools(plan), "HOSTED_CONTAINER_CATEGORY_HAS_NO_HOST_SANDBOX_PREREQUISITE").not.toThrow();
}
expect(spawn).not.toHaveBeenCalled();
} finally { which.mockRestore(); spawn.mockRestore(); }
});

test("runtime tools reject invalid Chrome paths before launching any child", () => {
const dir = fixture();
const file = join(dir, "non-executable"); writeFileSync(file, "fixture"); chmodSync(file, 0o600);
Expand Down
Loading