diff --git a/docs/decisions/ci-utility.md b/docs/decisions/ci-utility.md index d7ca188a0..459c3c019 100644 --- a/docs/decisions/ci-utility.md +++ b/docs/decisions/ci-utility.md @@ -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. diff --git a/docs/process/testing.md b/docs/process/testing.md index f92bc17b9..319c01646 100644 --- a/docs/process/testing.md +++ b/docs/process/testing.md @@ -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=` through the offline wrapper as import-graph affected feedback. Bun includes diff --git a/scripts/check-pr.ts b/scripts/check-pr.ts index 14fb2610f..7aacf8454 100644 --- a/scripts/check-pr.ts +++ b/scripts/check-pr.ts @@ -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"; @@ -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(); + 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"); @@ -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 diff --git a/tests/local-checks.test.ts b/tests/local-checks.test.ts index d765a7213..fd2cfe193 100644 --- a/tests/local-checks.test.ts +++ b/tests/local-checks.test.ts @@ -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"; @@ -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); + 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; + }); + 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); + 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); + 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); + 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); + 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);