diff --git a/scripts/gate-lane-candidate-binding.test.mjs b/scripts/gate-lane-candidate-binding.test.mjs new file mode 100644 index 000000000..ed3250d14 --- /dev/null +++ b/scripts/gate-lane-candidate-binding.test.mjs @@ -0,0 +1,194 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync, spawn, spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import net from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const GATE_SCRIPT = fileURLToPath(new URL("./gate-lane.mjs", import.meta.url)); + +function write(path, text) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, text); +} + +function git(root, ...args) { + return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); +} + +function makeRepo() { + const root = mkdtempSync(join(tmpdir(), "gate-candidate-")); + write(join(root, ".gitignore"), "node_modules/\npackages/cli/daemon/\n"); + write(join(root, "tracked.txt"), "one\n"); + write(join(root, "packages/cli/index.js"), "export {};\n"); + const packages = { + "": { workspaces: ["packages/cli"] }, + "node_modules/@openrig/cli": { resolved: "packages/cli", link: true }, + "packages/cli": { name: "@openrig/cli" }, + }; + write(join(root, "package-lock.json"), `${JSON.stringify({ name: "fixture", lockfileVersion: 3, packages }, null, 2)}\n`); + mkdirSync(join(root, "node_modules/@openrig"), { recursive: true }); + symlinkSync("../../packages/cli", join(root, "node_modules/@openrig/cli")); + git(root, "init", "-q"); + git(root, "config", "user.email", "gate@example.invalid"); + git(root, "config", "user.name", "Gate Test"); + git(root, "add", "."); + git(root, "commit", "-qm", "fixture"); + return root; +} + +async function freePort() { + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address(); + await new Promise((resolve) => server.close(resolve)); + return port; +} + +async function runGate(root, extraEnv = {}) { + const port = await freePort(); + return spawnSync(process.execPath, [GATE_SCRIPT], { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + OPENRIG_GATE_LANE_PORT: String(port), + OPENRIG_GATE_LANE_SMOKE: "1", + OPENRIG_GATE_VERDICT: join(root, "gate-lane-verdict.json"), + ...extraEnv, + }, + }); +} + +function assertCandidateRefusal(result, pattern) { + assert.notEqual(result.status, 0, `gate must refuse\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.match(`${result.stdout}\n${result.stderr}`, pattern); +} + +test("candidate binding: tracked dirt refuses before the first cleanup mutation", async () => { + const root = makeRepo(); + const sentinel = join(root, "packages/cli/daemon/sentinel.txt"); + write(sentinel, "must survive refusal\n"); + write(join(root, "tracked.txt"), "dirty\n"); + const result = await runGate(root); + assertCandidateRefusal(result, /candidate.*dirty|tracked.*change/i); + assert.equal(existsSync(sentinel), true, "pre-mutation refusal preserves the stale-bundle sentinel"); +}); + +test("candidate binding: a Git-visible untracked file refuses", async () => { + const root = makeRepo(); + write(join(root, "untracked.test.mjs"), "// visible\n"); + assertCandidateRefusal(await runGate(root), /candidate.*dirty|untracked/i); +}); + +test("candidate binding: same verdict basename at a different path is not excluded", async () => { + const root = makeRepo(); + write(join(root, "sub/dir/gate-lane-verdict.json"), "{}\n"); + assertCandidateRefusal(await runGate(root), /sub\/dir\/gate-lane-verdict\.json|candidate.*dirty/i); +}); + +test("candidate binding: only the canonical verdict path may be present and the verdict binds HEAD", async () => { + const root = makeRepo(); + const verdictPath = join(root, "receipts/current-verdict.json"); + write(verdictPath, "{}\n"); + const head = git(root, "rev-parse", "HEAD"); + const result = await runGate(root, { OPENRIG_GATE_VERDICT: verdictPath }); + assert.equal(result.status, 0, result.stderr); + const verdict = JSON.parse(readFileSync(verdictPath, "utf8")); + assert.equal(verdict.candidateSha, head); + assert.equal(verdict.gate, "pass"); +}); + +test("candidate binding: HEAD drift during the gate refuses before writing a verdict", async () => { + const root = makeRepo(); + const verdictPath = join(root, "gate-lane-verdict.json"); + const fakeBin = mkdtempSync(join(tmpdir(), "gate-fake-npm-")); + const marker = join(tmpdir(), `gate-drift-marker-${process.pid}-${Date.now()}`); + const fakeNpm = join(fakeBin, "npm"); + write(fakeNpm, `#!/bin/sh\n: > "$OPENRIG_TEST_MARKER"\nsleep 1\nexit 0\n`); + chmodSync(fakeNpm, 0o755); + const port = await freePort(); + + const child = spawn(process.execPath, [GATE_SCRIPT], { + cwd: root, + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH}`, + OPENRIG_TEST_MARKER: marker, + OPENRIG_GATE_LANE_PORT: String(port), + OPENRIG_GATE_VERDICT: verdictPath, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + const closed = new Promise((resolve) => child.on("close", resolve)); + + const deadline = Date.now() + 5000; + while (!existsSync(marker) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.equal(existsSync(marker), true, "the real gate reached its first injected leg"); + write(join(root, "tracked.txt"), "two\n"); + git(root, "add", "tracked.txt"); + git(root, "commit", "-qm", "move head during gate"); + + const status = await closed; + assertCandidateRefusal({ status, stdout, stderr }, /HEAD.*(drift|changed)|candidateSha/i); + assert.equal(existsSync(verdictPath), false, "a drifted run writes no verdict"); +}); + +test("candidate binding: Git-visible input added during the gate refuses before writing a verdict", async () => { + const root = makeRepo(); + const verdictPath = join(root, "gate-lane-verdict.json"); + const fakeBin = mkdtempSync(join(tmpdir(), "gate-fake-npm-")); + const marker = join(tmpdir(), `gate-dirty-marker-${process.pid}-${Date.now()}`); + const release = join(tmpdir(), `gate-dirty-release-${process.pid}-${Date.now()}`); + const fakeNpm = join(fakeBin, "npm"); + write(fakeNpm, `#!/bin/sh\n: > "$OPENRIG_TEST_MARKER"\nwhile [ ! -e "$OPENRIG_TEST_RELEASE" ]; do sleep 0.02; done\nexit 0\n`); + chmodSync(fakeNpm, 0o755); + const port = await freePort(); + + const child = spawn(process.execPath, [GATE_SCRIPT], { + cwd: root, + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH}`, + OPENRIG_TEST_MARKER: marker, + OPENRIG_TEST_RELEASE: release, + OPENRIG_GATE_LANE_PORT: String(port), + OPENRIG_GATE_VERDICT: verdictPath, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + const closed = new Promise((resolve) => child.on("close", resolve)); + + const deadline = Date.now() + 5000; + while (!existsSync(marker) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + if (!existsSync(marker)) write(release, "abort\n"); + assert.equal(existsSync(marker), true, "the real gate reached its first injected leg"); + write(join(root, "late-input.test.mjs"), "// introduced while the gate was running\n"); + write(release, "continue\n"); + + const status = await closed; + assertCandidateRefusal({ status, stdout, stderr }, /candidate.*dirty|late-input\.test\.mjs/i); + assert.equal(existsSync(verdictPath), false, "a late-dirty run writes no verdict"); +}); diff --git a/scripts/gate-lane-consume.mjs b/scripts/gate-lane-consume.mjs new file mode 100644 index 000000000..29b8d255e --- /dev/null +++ b/scripts/gate-lane-consume.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +function refuse(message) { + throw new Error(`[gate-consume] REFUSED: ${message}`); +} + +/** Honor one exact real-gate verdict, then remove it so it cannot be reused. */ +export function consumeGateVerdict({ verdictPath, headSha, log = console.log }) { + if (!existsSync(verdictPath)) refuse(`verdict is missing: ${verdictPath}`); + let verdict; + try { + verdict = JSON.parse(readFileSync(verdictPath, "utf8")); + } catch (error) { + refuse(`verdict is not valid JSON: ${error?.message ?? error}`); + } + if (typeof verdict?.candidateSha !== "string" || verdict.candidateSha.length === 0) { + refuse("candidateSha is required"); + } + if (verdict.candidateSha !== headSha) { + refuse(`candidateSha does not match current HEAD (verdict=${verdict.candidateSha}, HEAD=${headSha})`); + } + if (verdict.gate !== "pass") refuse(`gate must be "pass" (received ${JSON.stringify(verdict.gate)})`); + if (verdict.smoke !== false) refuse("smoke must be strictly false; only a real gate may be consumed"); + + unlinkSync(verdictPath); + if (existsSync(verdictPath)) refuse(`consumed verdict still exists after unlink: ${verdictPath}`); + log(`[gate-consume] honored and consumed candidate ${headSha}; removed ${verdictPath}`); + return verdict; +} + +function readHead(root) { + const result = spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }); + if (result.status !== 0) refuse(`cannot derive current HEAD: ${result.stderr.trim()}`); + return result.stdout.trim(); +} + +function main() { + const root = process.cwd(); + const verdictPath = resolve(process.argv[2] ?? process.env.OPENRIG_GATE_VERDICT ?? join(root, "gate-lane-verdict.json")); + consumeGateVerdict({ verdictPath, headSha: readHead(root) }); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + main(); + } catch (error) { + console.error(error?.message ?? error); + process.exitCode = 1; + } +} diff --git a/scripts/gate-lane-consume.test.mjs b/scripts/gate-lane-consume.test.mjs new file mode 100644 index 000000000..f05f6a676 --- /dev/null +++ b/scripts/gate-lane-consume.test.mjs @@ -0,0 +1,93 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const CONSUMER = fileURLToPath(new URL("./gate-lane-consume.mjs", import.meta.url)); + +function write(path, text) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, text); +} + +function git(root, ...args) { + return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); +} + +function makeRepo() { + const root = mkdtempSync(join(tmpdir(), "gate-consume-")); + write(join(root, "tracked.txt"), "one\n"); + git(root, "init", "-q"); + git(root, "config", "user.email", "gate@example.invalid"); + git(root, "config", "user.name", "Gate Test"); + git(root, "add", "."); + git(root, "commit", "-qm", "fixture"); + return { root, head: git(root, "rev-parse", "HEAD"), verdictPath: join(root, "gate-lane-verdict.json") }; +} + +function runConsumer(root, verdictPath) { + return spawnSync(process.execPath, [CONSUMER, verdictPath], { cwd: root, encoding: "utf8" }); +} + +function assertRefused(result, verdictPath, pattern) { + assert.notEqual(result.status, 0, `consumer must refuse\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.match(`${result.stdout}\n${result.stderr}`, pattern); + if (verdictPath) assert.equal(existsSync(verdictPath), true, "a refused verdict is retained"); +} + +function put(path, value) { + write(path, `${JSON.stringify(value)}\n`); +} + +test("consumer refuses loudly when no verdict exists", () => { + const { root, verdictPath } = makeRepo(); + const result = runConsumer(root, verdictPath); + assert.notEqual(result.status, 0); + assert.match(`${result.stdout}\n${result.stderr}`, /verdict.*(missing|does not exist)/i); +}); + +test("consumer refuses and retains a verdict with no candidateSha", () => { + const { root, verdictPath } = makeRepo(); + put(verdictPath, { gate: "pass", smoke: false }); + assertRefused(runConsumer(root, verdictPath), verdictPath, /candidateSha.*required|missing.*candidateSha/i); +}); + +test("consumer derives current HEAD and refuses a stale candidateSha", () => { + const { root, head, verdictPath } = makeRepo(); + put(verdictPath, { candidateSha: head, gate: "pass", smoke: false }); + write(join(root, "tracked.txt"), "two\n"); + git(root, "add", "tracked.txt"); + git(root, "commit", "-qm", "move head"); + assertRefused(runConsumer(root, verdictPath), verdictPath, /candidateSha.*(mismatch|does not match)|current HEAD/i); +}); + +test("consumer refuses and retains a matching-SHA failed verdict", () => { + const { root, head, verdictPath } = makeRepo(); + put(verdictPath, { candidateSha: head, gate: "fail", smoke: false }); + assertRefused(runConsumer(root, verdictPath), verdictPath, /gate.*pass/i); +}); + +test("consumer refuses and retains a smoke:true verdict", () => { + const { root, head, verdictPath } = makeRepo(); + put(verdictPath, { candidateSha: head, gate: "pass", smoke: true }); + assertRefused(runConsumer(root, verdictPath), verdictPath, /smoke.*false|real gate/i); +}); + +test("consumer refuses and retains a verdict whose smoke field is absent", () => { + const { root, head, verdictPath } = makeRepo(); + put(verdictPath, { candidateSha: head, gate: "pass" }); + assertRefused(runConsumer(root, verdictPath), verdictPath, /smoke.*false|missing.*smoke/i); +}); + +test("consumer honors only a matching real PASS and removes the consumed artifact", () => { + const { root, head, verdictPath } = makeRepo(); + put(verdictPath, { candidateSha: head, gate: "pass", smoke: false }); + const result = runConsumer(root, verdictPath); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /consum|honor/i); + assert.match(result.stdout, new RegExp(head)); + assert.equal(existsSync(verdictPath), false, "successful consume removes the verdict"); +}); diff --git a/scripts/gate-lane-hermeticity.mjs b/scripts/gate-lane-hermeticity.mjs new file mode 100644 index 000000000..4a7eb51ba --- /dev/null +++ b/scripts/gate-lane-hermeticity.mjs @@ -0,0 +1,88 @@ +import { lstatSync, readFileSync, realpathSync } from "node:fs"; +import { isAbsolute, join, relative } from "node:path"; + +function fail(message) { + throw new Error(`[gate-hermeticity] REFUSED: ${message}`); +} + +function inside(parent, child) { + const rel = relative(parent, child); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +/** + * Verify that this worktree owns the dependency root and every lock-derived + * @openrig workspace link. The check reports only; it never repairs. + */ +export function checkDependencyRoot(repoRoot, { log = console.log } = {}) { + const root = realpathSync(repoRoot); + const nodeModules = join(root, "node_modules"); + let nodeModulesStat; + try { + nodeModulesStat = lstatSync(nodeModules); + } catch { + fail(`node_modules is absent at ${nodeModules}`); + } + if (nodeModulesStat.isSymbolicLink()) { + let actual = ""; + try { actual = realpathSync(nodeModules); } catch { /* reported below */ } + log(`[gate-hermeticity] node_modules actual=${actual} expected=${nodeModules}`); + fail(`node_modules is a symlink: ${nodeModules}`); + } + if (!nodeModulesStat.isDirectory()) fail(`node_modules is not a directory: ${nodeModules}`); + + let lock; + try { + lock = JSON.parse(readFileSync(join(root, "package-lock.json"), "utf8")); + } catch (error) { + fail(`cannot read package-lock.json: ${error?.message ?? error}`); + } + const entries = Object.entries(lock?.packages ?? {}) + .filter(([path]) => path.startsWith("node_modules/@openrig/")) + .sort(([a], [b]) => a.localeCompare(b)); + if (entries.length === 0) fail("zero scoped @openrig links were derived from package-lock.json"); + + const packagesRoot = realpathSync(join(root, "packages")); + const pairs = []; + const failures = []; + for (const [linkPath, metadata] of entries) { + const name = linkPath.slice("node_modules/".length); + if (metadata?.link !== true || typeof metadata.resolved !== "string" || metadata.resolved.trim() === "") { + log(`[gate-hermeticity] ${name} actual= expected=`); + failures.push(`${name} has no lock-derived workspace target`); + continue; + } + + const expectedPath = join(root, metadata.resolved); + let expected; + try { + expected = realpathSync(expectedPath); + } catch (error) { + log(`[gate-hermeticity] ${name} actual= expected=`); + failures.push(`${name} expected target cannot be resolved: ${error?.message ?? error}`); + continue; + } + if (!inside(packagesRoot, expected)) { + log(`[gate-hermeticity] ${name} actual= expected=${expected}`); + failures.push(`${name} lock-derived target escapes ${packagesRoot}`); + continue; + } + + const actualPath = join(root, linkPath); + let actual; + try { + actual = realpathSync(actualPath); + } catch (error) { + log(`[gate-hermeticity] ${name} actual= expected=${expected}`); + failures.push(`${name} actual target cannot be resolved: ${error?.message ?? error}`); + continue; + } + const line = `[gate-hermeticity] ${name} actual=${actual} expected=${expected}`; + log(line); + pairs.push({ name, actual, expected }); + if (actual !== expected) failures.push(`${name} actual target does not equal its lock-derived expected target`); + } + + if (failures.length > 0) fail(failures.join("; ")); + return { checked: pairs.length, pairs }; +} diff --git a/scripts/gate-lane-hermeticity.test.mjs b/scripts/gate-lane-hermeticity.test.mjs new file mode 100644 index 000000000..093fc44e0 --- /dev/null +++ b/scripts/gate-lane-hermeticity.test.mjs @@ -0,0 +1,191 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync, spawn, spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import net from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const GATE_SCRIPT = fileURLToPath(new URL("./gate-lane.mjs", import.meta.url)); +const CONSUMER_SCRIPT = fileURLToPath(new URL("./gate-lane-consume.mjs", import.meta.url)); + +function write(path, text) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, text); +} + +function git(root, ...args) { + return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); +} + +function makeRepo({ lockEntries = true, dependencyRoot = "exact" } = {}) { + const root = mkdtempSync(join(tmpdir(), "gate-hermeticity-")); + write(join(root, ".gitignore"), "node_modules/\npackages/cli/daemon/\n"); + write(join(root, "package.json"), '{"private":true}\n'); + write(join(root, "packages/cli/index.js"), "export {};\n"); + write(join(root, "packages/other/index.js"), "export {};\n"); + const packages = lockEntries + ? { + "": { workspaces: ["packages/cli"] }, + "node_modules/@openrig/cli": { resolved: "packages/cli", link: true }, + "packages/cli": { name: "@openrig/cli" }, + } + : { "": { workspaces: [] } }; + write(join(root, "package-lock.json"), `${JSON.stringify({ name: "fixture", lockfileVersion: 3, packages }, null, 2)}\n`); + + if (dependencyRoot === "symlink") { + const foreign = mkdtempSync(join(tmpdir(), "gate-hermeticity-root-")); + mkdirSync(join(foreign, "node_modules"), { recursive: true }); + symlinkSync(join(foreign, "node_modules"), join(root, "node_modules")); + } else if (dependencyRoot !== "absent") { + mkdirSync(join(root, "node_modules/@openrig"), { recursive: true }); + const target = dependencyRoot === "swapped" + ? "../../packages/other" + : dependencyRoot === "cross-tree" + ? join(mkdtempSync(join(tmpdir(), "gate-hermeticity-cross-")), "packages/cli") + : "../../packages/cli"; + if (dependencyRoot === "cross-tree") mkdirSync(target, { recursive: true }); + symlinkSync(target, join(root, "node_modules/@openrig/cli")); + } + + git(root, "init", "-q"); + git(root, "config", "user.email", "gate@example.invalid"); + git(root, "config", "user.name", "Gate Test"); + git(root, "add", "."); + git(root, "commit", "-qm", "fixture"); + return root; +} + +async function freePort() { + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address(); + await new Promise((resolve) => server.close(resolve)); + return port; +} + +async function runGate(root) { + const port = await freePort(); + return spawnSync(process.execPath, [GATE_SCRIPT], { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + OPENRIG_GATE_LANE_PORT: String(port), + OPENRIG_GATE_LANE_SMOKE: "1", + OPENRIG_GATE_VERDICT: join(root, "gate-lane-verdict.json"), + }, + }); +} + +function assertHermeticRefusal(result, pattern) { + assert.notEqual(result.status, 0, `gate must refuse\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.match(`${result.stdout}\n${result.stderr}`, pattern); +} + +test("dependency root: absent node_modules refuses before the gate mutates", async () => { + const result = await runGate(makeRepo({ dependencyRoot: "absent" })); + assertHermeticRefusal(result, /node_modules.*absent|missing dependency root/i); +}); + +test("dependency root: a symlinked node_modules root refuses", async () => { + const root = makeRepo({ dependencyRoot: "symlink" }); + const sentinel = join(root, "packages/cli/daemon/sentinel.txt"); + write(sentinel, "must survive refusal\n"); + const result = await runGate(root); + assertHermeticRefusal(result, /node_modules.*symlink/i); + assert.equal(existsSync(sentinel), true, "the hermeticity refusal runs before cleanup mutation"); +}); + +test("dependency root: a scoped link escaping to another tree refuses and prints both operands", async () => { + const result = await runGate(makeRepo({ dependencyRoot: "cross-tree" })); + assertHermeticRefusal(result, /@openrig\/cli[\s\S]*actual[\s\S]*expected/i); +}); + +test("dependency root: a scoped link swapped to another in-tree package refuses", async () => { + const result = await runGate(makeRepo({ dependencyRoot: "swapped" })); + assertHermeticRefusal(result, /@openrig\/cli[\s\S]*packages\/other[\s\S]*packages\/cli/i); +}); + +test("dependency root: a lock deriving zero scoped links refuses instead of vacuously passing", async () => { + const result = await runGate(makeRepo({ lockEntries: false })); + assertHermeticRefusal(result, /zero|no scoped @openrig/i); +}); + +test("dependency root: exact lock-derived target passes and prints the actual/expected pair", async () => { + const result = await runGate(makeRepo()); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /@openrig\/cli[\s\S]*actual[\s\S]*expected/i); + assert.match(result.stdout, /packages\/cli/i); +}); + +test("dependency root: a later refusal invalidates a same-HEAD prior PASS", async () => { + const root = makeRepo(); + const verdictPath = join(root, "gate-lane-verdict.json"); + const first = await runGate(root); + assert.equal(first.status, 0, first.stderr); + assert.equal(existsSync(verdictPath), true, "the first gate writes a PASS receipt"); + + rmSync(join(root, "node_modules/@openrig/cli"), { force: true }); + symlinkSync("../../packages/other", join(root, "node_modules/@openrig/cli")); + + const second = await runGate(root); + assertHermeticRefusal(second, /@openrig\/cli[\s\S]*packages\/other[\s\S]*packages\/cli/i); + assert.equal(existsSync(verdictPath), false, "the refused retry invalidates the prior PASS"); + const consume = spawnSync(process.execPath, [CONSUMER_SCRIPT, verdictPath], { cwd: root, encoding: "utf8" }); + assert.notEqual(consume.status, 0, "the prior PASS is no longer consumable"); + assert.match(`${consume.stdout}\n${consume.stderr}`, /verdict.*missing/i); +}); + +test("dependency root: drift during the gate refuses before writing a verdict", async () => { + const root = makeRepo(); + const verdictPath = join(root, "gate-lane-verdict.json"); + const fakeBin = mkdtempSync(join(tmpdir(), "gate-hermeticity-fake-npm-")); + const marker = join(tmpdir(), `gate-root-marker-${process.pid}-${Date.now()}`); + const release = join(tmpdir(), `gate-root-release-${process.pid}-${Date.now()}`); + const fakeNpm = join(fakeBin, "npm"); + write(fakeNpm, `#!/bin/sh\n: > "$OPENRIG_TEST_MARKER"\nwhile [ ! -e "$OPENRIG_TEST_RELEASE" ]; do sleep 0.02; done\nexit 0\n`); + chmodSync(fakeNpm, 0o755); + const port = await freePort(); + + const child = spawn(process.execPath, [GATE_SCRIPT], { + cwd: root, + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH}`, + OPENRIG_TEST_MARKER: marker, + OPENRIG_TEST_RELEASE: release, + OPENRIG_GATE_LANE_PORT: String(port), + OPENRIG_GATE_VERDICT: verdictPath, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + const closed = new Promise((resolve) => child.on("close", resolve)); + + const deadline = Date.now() + 5000; + while (!existsSync(marker) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + if (!existsSync(marker)) write(release, "abort\n"); + assert.equal(existsSync(marker), true, "the real gate reached its first injected leg"); + rmSync(join(root, "node_modules/@openrig/cli"), { force: true }); + symlinkSync("../../packages/other", join(root, "node_modules/@openrig/cli")); + write(release, "continue\n"); + + const status = await closed; + assertHermeticRefusal({ status, stdout, stderr }, /@openrig\/cli[\s\S]*packages\/other[\s\S]*packages\/cli|gate-hermeticity/i); + assert.equal(existsSync(verdictPath), false, "a late dependency-root drift writes no verdict"); +}); diff --git a/scripts/gate-lane.mjs b/scripts/gate-lane.mjs index 652304cc8..894f25bce 100644 --- a/scripts/gate-lane.mjs +++ b/scripts/gate-lane.mjs @@ -5,12 +5,13 @@ // (advisory, recorded) → run BOTH legs (typecheck AND vitest AND vitest:ui — the two honesty gaps) → // write the C2 verdict (green carries the load it ran under) → release (kernel also frees on death). import { spawnSync, execSync } from "node:child_process"; -import { writeFileSync, readFileSync, mkdirSync } from "node:fs"; +import { writeFileSync, readFileSync, mkdirSync, realpathSync, unlinkSync } from "node:fs"; import { tmpdir, loadavg } from "node:os"; -import { join, dirname } from "node:path"; +import { join, dirname, basename, isAbsolute, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { acquireGateLane, GATE_LANE_PORT } from "./gate-lane-lock.mjs"; import { renderRefusal, runGate, observeForeignLoad, cleanStaleVendoredBundle } from "./gate-lane-run.mjs"; +import { checkDependencyRoot } from "./gate-lane-hermeticity.mjs"; const RUNTIME_DIR = (() => { const d = join(tmpdir(), "openrig-gate"); mkdirSync(d, { recursive: true }); return d; })(); const HOLDER_INFO = join(RUNTIME_DIR, "gate-lane.holder.json"); @@ -37,6 +38,53 @@ function snapshotProcesses() { } catch { return []; } } +function runGit(args, cwd) { + const result = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr.trim()}`); + return result.stdout; +} + +function readCandidateSha(repoRoot) { + const sha = runGit(["rev-parse", "HEAD"], repoRoot).trim(); + if (!sha) throw new Error("candidate HEAD is empty"); + return sha; +} + +function canonicalVerdictRelativePath(repoRoot) { + const verdictPath = resolve(VERDICT_PATH); + let canonicalPath = verdictPath; + try { + canonicalPath = realpathSync(verdictPath); + } catch { + try { canonicalPath = join(realpathSync(dirname(verdictPath)), basename(verdictPath)); } catch { /* absent parent: no status entry can match */ } + } + const rel = relative(realpathSync(repoRoot), canonicalPath); + if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return null; + return rel.split(sep).join("/"); +} + +function assertCandidateClean(repoRoot) { + const canonicalVerdict = canonicalVerdictRelativePath(repoRoot); + const records = runGit(["status", "--porcelain=v1", "-z", "--untracked-files=all"], repoRoot) + .split("\0") + .filter(Boolean); + const unexpected = records.filter((record) => { + if (record.length < 4 || record[2] !== " ") return true; + return record.slice(3).split(sep).join("/") !== canonicalVerdict; + }); + if (unexpected.length > 0) { + throw new Error(`candidate worktree is dirty: ${unexpected.join(" | ")}`); + } +} + +function invalidatePriorVerdict() { + try { + unlinkSync(VERDICT_PATH); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } +} + async function main() { const startedAt = new Date().toISOString(); const lane = await acquireGateLane({ port: GATE_LANE_PORT, holderInfoPath: HOLDER_INFO }); @@ -46,8 +94,15 @@ async function main() { } let exitCode = 3; try { + // This acquired run supersedes every earlier receipt, including for the same HEAD. + invalidatePriorVerdict(); const foreignLoad = observeForeignLoad({ loadavg: loadavg(), processes: snapshotProcesses() }); if (foreignLoad.advisory.length) console.warn(`⚠ advisory (exit UNCHANGED, recorded in verdict): ${foreignLoad.advisory.join("; ")}`); + // Bind the run to one clean, worktree-local candidate BEFORE the first repository mutation. + const repoRoot = process.cwd(); + const candidateSha = readCandidateSha(repoRoot); + assertCandidateClean(repoRoot); + checkDependencyRoot(repoRoot); // SOURCE-TRUTH: drop any stale vendored daemon bundle (a gitignored build:package leftover) before // the legs run, so test:repo's freshness guard is never poisoned by a desk leftover. Real package-time // assembly is still guarded; a fresh checkout is a no-op. @@ -61,6 +116,13 @@ async function main() { // picks the executor AND flows into the verdict, so the two can never disagree. The gate's own // negative-control test calls THIS SAME runGate — it guards this shipped path, not a lookalike. const verdict = await runGate({ smoke: SMOKE, realExec, foreignLoad, startedAt, ledger, cutCeiling }); + const endingSha = readCandidateSha(repoRoot); + if (endingSha !== candidateSha) { + throw new Error(`candidate HEAD changed during gate (started ${candidateSha}, ended ${endingSha})`); + } + assertCandidateClean(repoRoot); + checkDependencyRoot(repoRoot); + verdict.candidateSha = candidateSha; mkdirSync(dirname(VERDICT_PATH), { recursive: true }); // ensure the verdict's home exists writeFileSync(VERDICT_PATH, JSON.stringify(verdict, null, 2)); console.log(verdict.ledgerState); // in-band LOUD: name every exclusion (or "0 exclusions")