From 4b7733264686ba6154333b141df209f0597c4acd Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sat, 17 Oct 2026 18:05:54 +0200 Subject: [PATCH 01/15] fix(ci): rebuild when effective inputs are unqualified --- CONTRIBUTING.md | 14 ++- scripts/build-receipt.ts | 4 +- scripts/build-stamp.ts | 96 -------------- scripts/build.ts | 17 +-- scripts/captures/provenance.ts | 2 +- scripts/generated-build-inputs.ts | 8 +- tests/build-concurrency.test.ts | 4 +- tests/build-freshness.test.ts | 106 ++++++++++++++++ tests/build-stamp.test.ts | 181 --------------------------- tests/generated-build-inputs.test.ts | 2 +- tests/generated-theme-inputs.test.ts | 2 +- tests/inclusion-inputs.test.ts | 2 +- tests/workspace-lease.test.ts | 2 +- 13 files changed, 128 insertions(+), 312 deletions(-) delete mode 100644 scripts/build-stamp.ts create mode 100644 tests/build-freshness.test.ts delete mode 100644 tests/build-stamp.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4beac75699..5ffcbf8d66 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,12 +48,14 @@ issue when you are not sure. repository preload, and the repository browser wrapper wait automatically for other commands using the same checkout's build output. Runtime tests hold exclusive access because their fixtures rebuild the -packages. A build reports a package `Up to date` instead of recompiling it only -when every build input (all package sources, root config, lockfile, build -scripts and Bun version) and that package's `dist` match its last successful -build; any source edit rebuilds everything. The stamps live in -`node_modules/.cache/brain-build/`, and `bun scripts/build.ts --force` always -rebuilds. Browser ownership is acquired inside the pinned container against the +packages. Every selected package is rebuilt. Persistent build-output reuse is +disabled +until its effective configuration, installed toolchain, runtime and stylesheet +inputs can be qualified; package versions and symlink target strings are not +complete input identities. `bun scripts/build.ts --force` remains accepted and +follows the same rebuild path. Old files under `node_modules/.cache/brain-build/` +are inert and are not read. Browser ownership is acquired inside the pinned +container against the same bind-mounted lock file. Nested builds reuse an inherited descriptor, or verify its live owning ancestor on Linux when Bun's internal shell closes it; replaced child environments and existing extra stdio channels keep that ownership. diff --git a/scripts/build-receipt.ts b/scripts/build-receipt.ts index 3bc1007882..eeff4d4104 100644 --- a/scripts/build-receipt.ts +++ b/scripts/build-receipt.ts @@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto"; type Compiler = "tsc" | "css"; type Child = { compiler: Compiler; pid: number; startedAt: number; elapsedMs?: number; code?: number }; -type Package = { name: string; state: "pending" | "running" | "complete" | "failed" | "up-to-date"; startedAt?: number; elapsedMs?: number; children: Child[] }; +type Package = { name: string; state: "pending" | "running" | "complete" | "failed"; startedAt?: number; elapsedMs?: number; children: Child[] }; export function buildReceipt(root: string, options: { jobs: number; subset: boolean; packages: readonly string[] }) { let available = true; @@ -47,8 +47,6 @@ export function buildReceipt(root: string, options: { jobs: number; subset: bool mkdirSync(directory, { recursive: true }); }); return { - /** Skipped by the build stamp (#2319): no compiler ran for this package. */ - packageUpToDate(name: string) { observe(() => { const value = pkg(name); value.state = "up-to-date"; value.startedAt = elapsed(); value.elapsedMs = 0; }); }, packageStart(name: string) { observe(() => { const value = pkg(name); value.state = "running"; value.startedAt = elapsed(); }); }, childStart(name: string, compiler: Compiler, pid: number) { observe(() => { pkg(name).children.push({ compiler, pid, startedAt: elapsed() }); }); diff --git a/scripts/build-stamp.ts b/scripts/build-stamp.ts deleted file mode 100644 index 5e30cccf15..0000000000 --- a/scripts/build-stamp.ts +++ /dev/null @@ -1,96 +0,0 @@ -// Skip an identical workspace rebuild without trusting a stale dist (#2319). -// -// Runtime test hooks rebuild the workspace so a source mutation can never hide -// behind an old dist. A package is skipped only when two things both hold: -// every input that can affect any package's build is byte-identical to the -// last successful build, and that package's dist is exactly what the build -// produced. Cross-package imports resolve to each sibling's src through the -// root customConditions (see scripts/build.ts), so the input hash is shared by -// every package: any source change anywhere rebuilds everything, as before. -// -// The stamp lives under the root node_modules/.cache, never in dist: every -// package ships dist, and a stamp there would reach the npm tarball. -import { createHash } from "node:crypto"; -import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs"; -import { join, relative, resolve } from "node:path"; - -const STAMP_VERSION = 1; -/** Root files the build reads or runs, beside every package's own files. */ -export const ROOT_BUILD_INPUTS = ["package.json", "bun.lock", "scripts/build.ts", "scripts/build-stamp.ts", - "scripts/bin-modes.ts", "scripts/publishable-packages.ts", "scripts/workspace-lease.mjs"]; - -function fileEntry(root: string, path: string): string { - const full = join(root, path); - let stat; - try { stat = lstatSync(full); } catch { return `${path}\0missing`; } - if (stat.isSymbolicLink()) return `${path}\0link\0${readlinkSync(full)}`; - if (!stat.isFile()) return `${path}\0other\0${stat.mode}`; - return `${path}\0${stat.mode & 0o777}\0${createHash("sha256").update(readFileSync(full)).digest("hex")}`; -} - -/** Every package file Git does not ignore (tracked or new), outside dist and node_modules. */ -function packageFiles(root: string): string[] | undefined { - const listed = Bun.spawnSync(["git", "-C", root, "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", "packages"], - { stdout: "pipe", stderr: "pipe" }); - if (listed.exitCode !== 0) return undefined; - return [...new Set(new TextDecoder().decode(listed.stdout).split("\0").filter(Boolean))] - .filter(path => !/^packages\/[^/]+\/(?:dist|node_modules)\//.test(path)).sort(); -} - -/** - * The shared input hash, or undefined when it cannot be established (no Git - * checkout, for example). Undefined never skips: the caller always rebuilds. - */ -export function buildInputs(root: string, runtime = Bun.version): string | undefined { - const files = packageFiles(root); - if (!files) return undefined; - const rootConfigs = readdirSync(root).filter(name => /^tsconfig.*\.json$/.test(name)).sort(); - const typescript = join(root, "node_modules/typescript/package.json"); - const hash = createHash("sha256").update(`brain-build-inputs\0${STAMP_VERSION}\0bun\0${runtime}\0`); - hash.update(`typescript\0${existsSync(typescript) ? JSON.parse(readFileSync(typescript, "utf8")).version : "missing"}\0`); - for (const path of [...ROOT_BUILD_INPUTS, ...rootConfigs, ...files]) hash.update(fileEntry(root, path) + "\n"); - return hash.digest("hex"); -} - -/** Every path, mode and content under dist, or undefined when dist is absent. */ -export function distHash(packageDir: string): string | undefined { - const dist = join(packageDir, "dist"); - if (!existsSync(dist)) return undefined; - const entries: string[] = []; - const visit = (dir: string) => { - for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) { - const path = join(dir, entry.name); - if (entry.isDirectory()) { entries.push(`${relative(dist, path)}/\0dir`); visit(path); } - else entries.push(fileEntry(dist, relative(dist, path))); - } - }; - visit(dist); - return createHash("sha256").update(entries.join("\n")).digest("hex"); -} - -export function stampPath(root: string, packageName: string): string { - return resolve(root, "node_modules/.cache/brain-build", `${packageName}.json`); -} - -/** "up-to-date" only for an unforced build whose inputs and dist both match the stamp. */ -export function buildDecision(root: string, packageName: string, input: string | undefined, force: boolean): "up-to-date" | "build" { - if (force || !input) return "build"; - let stamp: { version?: unknown; input?: unknown; output?: unknown }; - try { stamp = JSON.parse(readFileSync(stampPath(root, packageName), "utf8")); } catch { return "build"; } - if (stamp.version !== STAMP_VERSION || stamp.input !== input || typeof stamp.output !== "string") return "build"; - return distHash(resolve(root, "packages", packageName)) === stamp.output ? "up-to-date" : "build"; -} - -/** Remove a stamp before rebuilding, so an interrupted build can never look current. */ -export function clearStamp(root: string, packageName: string): void { - rmSync(stampPath(root, packageName), { force: true }); -} - -/** Record a completed build. Without an input hash there is nothing to record. */ -export function recordBuild(root: string, packageName: string, input: string | undefined): void { - const output = distHash(resolve(root, "packages", packageName)); - if (!input || !output) return; - const path = stampPath(root, packageName); - mkdirSync(resolve(path, ".."), { recursive: true }); - writeFileSync(path, JSON.stringify({ version: STAMP_VERSION, input, output }) + "\n"); -} diff --git a/scripts/build.ts b/scripts/build.ts index e8d5f26fd4..02ef391c00 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -2,7 +2,6 @@ import { cpSync, existsSync, rmSync } from "fs"; import { resolve } from "path"; import { buildReceipt } from "./build-receipt.ts"; import { markBinsExecutable } from "./bin-modes.ts"; -import { buildDecision, buildInputs, clearStamp, recordBuild } from "./build-stamp.ts"; import { selectBuildPackages } from "./publishable-packages.ts"; import { ensureWorkspaceLease } from "./workspace-lease.mjs"; @@ -22,9 +21,9 @@ import { ensureWorkspaceLease } from "./workspace-lease.mjs"; // --jobs=2 opts a single CI workspace into bounded dependency-level builds. // The default stays serial, including two concurrent private unit workspaces. // -// A package whose complete build inputs and dist are unchanged since its last -// successful build is reported up to date instead of being recompiled -// (scripts/build-stamp.ts, #2319). --force always rebuilds every package. +// Rebuild every selected package. Persistent reuse is unavailable until effective +// configuration, installed producers and runtime inputs are qualified (#2319). +// --force remains accepted; ordinary builds already follow the same safe path. const args = process.argv.slice(2); let dryRun = false, force = false, dryRoot: string | undefined, directories: string[] | undefined; let jobs = 1, jobsSeen = false; @@ -68,9 +67,6 @@ if (!bunx) { } const compilerLauncher = bunx; -// Read under the write lease, before any package is touched. -const input = buildInputs(root); - class PackageBuildFailure extends Error { constructor(readonly packageName: string, readonly code: number, detail: string) { super(`Build failed: packages/${packageName}: ${detail}`); @@ -80,7 +76,6 @@ class PackageBuildFailure extends Error { async function buildPackageBody({ dir: packageName, manifest }: (typeof packages)[number]): Promise { const packageDir = resolve(root, "packages", packageName); const distDir = resolve(packageDir, "dist"); - clearStamp(root, packageName); rmSync(distDir, { recursive: true, force: true }); console.log(`Building packages/${packageName}...`); @@ -155,15 +150,9 @@ async function buildPackageBody({ dir: packageName, manifest }: (typeof packages async function buildPackage(pkg: (typeof packages)[number]): Promise { - if (buildDecision(root, pkg.dir, input, force) === "up-to-date") { - console.log(`Up to date packages/${pkg.dir}`); - receipt.packageUpToDate(pkg.dir); - return; - } receipt.packageStart(pkg.dir); try { await buildPackageBody(pkg); - recordBuild(root, pkg.dir, input); receipt.packageEnd(pkg.dir, true); } catch (error) { receipt.packageEnd(pkg.dir, false); diff --git a/scripts/captures/provenance.ts b/scripts/captures/provenance.ts index d85aaf5420..c3864dffaf 100644 --- a/scripts/captures/provenance.ts +++ b/scripts/captures/provenance.ts @@ -28,7 +28,7 @@ export async function sourceProvenance(root: string): Promise<{ const git = (args: string[]) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); const files = git(["ls-files", "--cached", "--others", "--exclude-standard", "-z"]).split("\u0000").filter(Boolean); const relevant = files.filter((path) => path === "bun.lock" || path === "package.json" || - path === "docs/process/feature-captures.md" || path === "scripts/capture.ts" || path === "scripts/build.ts" || path === "scripts/build-stamp.ts" || path === "scripts/visual.mjs" || path === "scripts/workspace-lease.mjs" || path.startsWith("scripts/captures/") || + path === "docs/process/feature-captures.md" || path === "scripts/capture.ts" || path === "scripts/build.ts" || path === "scripts/visual.mjs" || path === "scripts/workspace-lease.mjs" || path.startsWith("scripts/captures/") || /^packages\/(?:core|ui-kit|ui-react|ui-sdk|ui-server|render-template)\//.test(path)).sort(); const records = await Promise.all(relevant.map(async (path) => { try { return { path, sha256: sha256(await readFile(resolve(root, path))) }; } diff --git a/scripts/generated-build-inputs.ts b/scripts/generated-build-inputs.ts index 037b69e023..7a6964bb68 100644 --- a/scripts/generated-build-inputs.ts +++ b/scripts/generated-build-inputs.ts @@ -2,14 +2,12 @@ import { createHash } from "node:crypto"; import { existsSync, readFileSync, realpathSync } from "node:fs"; import { isAbsolute, relative, resolve } from "node:path"; -// These sources establish: tracked source entry, a cleaned package dist (or one -// verified byte-identical to a build from identical inputs, #2319), locked +// These sources establish: tracked source entry, a cleaned package dist, locked // Tailwind CLI, build-before-browser order, checked exits and post-success receipt. // This deliberately supports one audited producer, not arbitrary build tools. const auditedProducer: Record = { - "scripts/build.ts": "132f4d8a530db23bfe3724424186e4edd6de94dff11853026b9b5617a55320f4", - "scripts/build-stamp.ts": "5c7c7b72d0d7ba383600dbc16c0d6d1cc544104b480823b90955be6d8566d220", - "scripts/build-receipt.ts": "a2505dd297ae214befb455a0e86ba698ab6312c22d5d8ce0f97e102c3f665a0b", + "scripts/build.ts": "95c40d174c0dfa8d2f8f317a3c2236fecbba600faaaf3ebecb7a96dbd94c392d", + "scripts/build-receipt.ts": "567c3d13b5e3e1295f597e4f57a9d0d9912685f2669cf3872daa26c2931bd1e6", "scripts/bin-modes.ts": "5e412f4743b388558a00d1068881ff6d6a0916e852e9ed2e973bc0a97a83c8ce", "scripts/publishable-packages.ts": "c43a3fbea72217fc42c3514313dcb16d1b337242fc251b61d7f1a3c83ca25740", "scripts/workspace-lease.mjs": "dc6dc975e6abdb3fb193b368b79544bc98c60c624f0fd9c9985358284bb07df0", diff --git a/tests/build-concurrency.test.ts b/tests/build-concurrency.test.ts index 008c31007c..e59d860062 100644 --- a/tests/build-concurrency.test.ts +++ b/tests/build-concurrency.test.ts @@ -36,7 +36,7 @@ async function compilerDrain(storageFailure?: StorageFailure) { const release = join(root, "release"); try { mkdirSync(join(root, "scripts")); - for (const file of ["build.ts", "build-stamp.ts", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { + for (const file of ["build.ts", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { copyFileSync(join(repo, "scripts", file), join(root, "scripts", file)); } mkdirSync(join(root, "node_modules/.bin"), { recursive: true }); @@ -127,7 +127,7 @@ async function successfulBuild(storageFailure?: StorageFailure) { const root = mkdtempSync(join(tmpdir(), "odysseus-compiler-levels-")); try { mkdirSync(join(root, "scripts")); - for (const file of ["build.ts", "build-stamp.ts", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { + for (const file of ["build.ts", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { copyFileSync(join(repo, "scripts", file), join(root, "scripts", file)); } mkdirSync(join(root, "node_modules/.bin"), { recursive: true }); diff --git a/tests/build-freshness.test.ts b/tests/build-freshness.test.ts new file mode 100644 index 0000000000..d74bcf50a6 --- /dev/null +++ b/tests/build-freshness.test.ts @@ -0,0 +1,106 @@ +import { afterEach, expect, test } from "bun:test"; +import { chmodSync, copyFileSync, cpSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +const repo = join(import.meta.dir, ".."); +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +function write(root: string, path: string, text: string) { + mkdirSync(dirname(join(root, path)), { recursive: true }); writeFileSync(join(root, path), text); +} +function fixture() { + const outer = mkdtempSync(join(tmpdir(), "odysseus-build-freshness-")); roots.push(outer); + const root = join(outer, "checkout"); mkdirSync(root); + expect(Bun.spawnSync(["git", "init", "-q", root]).exitCode).toBe(0); + write(root, ".gitignore", "node_modules/\npackages/*/dist/\ntmp/\n"); + write(root, "package.json", JSON.stringify({ name: "odysseus-workspace", private: true })); + write(root, "bun.lock", "{}\n"); + write(root, "tsconfig.json", JSON.stringify({ compilerOptions: { target: "ES2019", module: "ESNext", moduleResolution: "Bundler", types: [], skipLibCheck: true } })); + mkdirSync(join(root, "scripts")); + for (const file of ["build.ts", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { + copyFileSync(join(repo, "scripts", file), join(root, "scripts", file)); + } + const compiler = join(root, "node_modules/typescript"); + cpSync(join(repo, "node_modules/typescript"), compiler, { recursive: true }); + expect(lstatSync(join(compiler, "lib/_tsc.js")).ino).not.toBe(lstatSync(join(repo, "node_modules/typescript/lib/_tsc.js")).ino); + write(root, "node_modules/.bin/tsc", `#!${process.execPath}\nconst child=Bun.spawn([process.execPath,${JSON.stringify(join(compiler, "bin/tsc"))},...process.argv.slice(2)],{stdout:"inherit",stderr:"inherit"});process.exitCode=await child.exited;\n`); + chmodSync(join(root, "node_modules/.bin/tsc"), 0o755); + for (const name of ["ithaca", "troy"]) { + write(root, `packages/${name}/package.json`, JSON.stringify({ name: `@odysseus/${name}`, version: "1.0.0", type: "module" })); + write(root, `packages/${name}/src/index.ts`, 'export const cargo = 1; export const harbour = (value?: { name?: string }) => value?.name ?? "Ithaca";\n'); + write(root, `packages/${name}/tsconfig.build.json`, JSON.stringify({ extends: "../../tsconfig.json", compilerOptions: { outDir: "dist", rootDir: "src", declaration: true }, include: ["src"] })); + } + return { root, outer, compiler }; +} +async function build(root: string, ...args: string[]) { + const child = Bun.spawn([process.execPath, "scripts/build.ts", ...args], { cwd: root, stdin: "ignore", stdout: "pipe", stderr: "pipe" }); + const [code, out, err] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect(code, `${out}\n${err}`).toBe(0); + const names = readdirSync(join(root, "tmp")).filter(name => /^build-.*-probe\.json$/.test(name)); + expect(names).toHaveLength(1); + const receipt = JSON.parse(readFileSync(join(root, "tmp", names[0]!), "utf8")); + for (const name of names) rmSync(join(root, "tmp", name)); + return { out, receipt }; +} +function compiled(result: Awaited>, names = ["ithaca", "troy"]) { + expect(result.receipt.success).toBe(true); + expect(result.receipt.packages.map((pkg: { name: string; state: string; children: { compiler: string; code: number }[] }) => ({ + name: pkg.name, state: pkg.state, children: pkg.children.map(child => `${child.compiler}:${child.code}`), + })), "UNQUALIFIED_INPUTS_RUN_ACTUAL_COMPILERS").toEqual(names.map(name => ({ name, state: "complete", children: ["tsc:0"] }))); +} +function output(root: string) { return readFileSync(join(root, "packages/ithaca/dist/index.js"), "utf8"); } +function consume(root: string): number { + const child = Bun.spawnSync(["node", "--input-type=module", "-e", `import {cargo} from ${JSON.stringify(join(root, "packages/ithaca/dist/index.js"))}; console.log(cargo);`], { stdout: "pipe", stderr: "pipe" }); + expect(child.exitCode, new TextDecoder().decode(child.stderr)).toBe(0); + return Number(new TextDecoder().decode(child.stdout).trim()); +} + +test("effective aliased compiler configuration is rebuilt before consuming output", async () => { + const { root, outer } = fixture(); const config = join(outer, "compiler.json"); + writeFileSync(config, readFileSync(join(root, "tsconfig.json"))); rmSync(join(root, "tsconfig.json")); symlinkSync("../compiler.json", join(root, "tsconfig.json")); + compiled(await build(root)); const warm = output(root); expect(warm).not.toContain("?."); + const original = readFileSync(config, "utf8"); + try { + writeFileSync(config, original.replace("ES2019", "ES2022")); + const changed = await build(root); + expect(output(root), "EFFECTIVE_CONFIG_ALIAS_CHANGE_REACHES_EMITTED_OUTPUT").toContain("?."); compiled(changed); expect(consume(root)).toBe(1); + } finally { writeFileSync(config, original); } + compiled(await build(root)); expect(output(root), "RESTORED_CONFIG_RESTORES_OUTPUT").toBe(warm); +}, 60_000); + +test("effective installed compiler changes reach the ordinary built consumer", async () => { + const { root, compiler } = fixture(); compiled(await build(root)); expect(consume(root)).toBe(1); const warm = output(root); + const entry = join(compiler, "lib/_tsc.js"), original = readFileSync(entry, "utf8"); + const anchor = "function emitNumericOrBigIntLiteral(node) {"; expect(original.split(anchor)).toHaveLength(2); + const version = readFileSync(join(compiler, "package.json"), "utf8"); + try { + writeFileSync(entry, original.replace(anchor, anchor + '\n if (node.text === "1") { writeStringLiteral("2"); return; }')); + const changed = await build(root); + expect(consume(root), "INSTALLED_COMPILER_CHANGE_REACHES_REAL_CONSUMER").toBe(2); compiled(changed); + expect(readFileSync(join(compiler, "package.json"), "utf8")).toBe(version); + } finally { writeFileSync(entry, original); } + compiled(await build(root)); expect(consume(root)).toBe(1); expect(output(root), "RESTORED_COMPILER_RESTORES_OUTPUT").toBe(warm); +}, 60_000); + +test("unqualified repeats, damaged output, source and producer changes keep ordinary compilation", async () => { + const { root } = fixture(); compiled(await build(root, "--jobs=2")); const warm = output(root); + compiled(await build(root, "--jobs=2")); expect(output(root)).toBe(warm); + for (const damage of [() => rmSync(join(root, "packages/ithaca/dist/index.js")), () => write(root, "packages/ithaca/dist/index.js", "stale"), () => write(root, "packages/ithaca/dist/extra.js", "stale")]) { + damage(); compiled(await build(root)); expect(output(root)).toBe(warm); expect(Bun.file(join(root, "packages/ithaca/dist/extra.js")).size).toBe(0); + } + write(root, "packages/troy/src/index.ts", 'export const cargo = 3;\n'); compiled(await build(root)); + expect(readFileSync(join(root, "packages/troy/dist/index.js"), "utf8")).toContain("cargo = 3"); + write(root, "packages/troy/src/new.ts", 'export const voyage = "Ithaca";\n'); compiled(await build(root)); expect(Bun.file(join(root, "packages/troy/dist/new.js")).size).toBeGreaterThan(0); + write(root, "bun.lock", '{"lockfileVersion":2}\n'); compiled(await build(root)); + write(root, "scripts/build-receipt.ts", readFileSync(join(root, "scripts/build-receipt.ts"), "utf8") + "\n// Odysseus observed the next voyage.\n"); compiled(await build(root)); + compiled(await build(root, "--force")); compiled(await build(root, "--for", "ithaca"), ["ithaca"]); + // Old/foreign admission records are inert; no stamp is read or written. + write(root, "node_modules/.cache/brain-build/ithaca.json", JSON.stringify({ version: 1, input: "old", output: "old" })); + compiled(await build(root)); + // Missing output in another checkout and absent Git metadata also rebuild. + const copied = fixture(); + cpSync(join(root, "node_modules/.cache"), join(copied.root, "node_modules/.cache"), { recursive: true }); + compiled(await build(copied.root)); + rmSync(join(root, ".git"), { recursive: true }); compiled(await build(root)); +}, 60_000); diff --git a/tests/build-stamp.test.ts b/tests/build-stamp.test.ts deleted file mode 100644 index 890e577c80..0000000000 --- a/tests/build-stamp.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { chmodSync, copyFileSync, cpSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { buildDecision, buildInputs, recordBuild, stampPath } from "../scripts/build-stamp.ts"; - -const repo = join(import.meta.dir, ".."); -const roots: string[] = []; -afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); - -function write(root: string, path: string, text: string) { - mkdirSync(dirname(join(root, path)), { recursive: true }); - writeFileSync(join(root, path), text); -} - -/** A two-package Odysseus workspace whose dist each "build" writes by hand. */ -function fixture(git = true): string { - const root = mkdtempSync(join(tmpdir(), "odysseus-build-stamp-")); - roots.push(root); - if (git) expect(Bun.spawnSync(["git", "init", "-q", root]).exitCode).toBe(0); - write(root, ".gitignore", "dist/\nnode_modules/\npackages/*/dist/\n"); - write(root, "package.json", JSON.stringify({ name: "odysseus-workspace", private: true })); - write(root, "bun.lock", "{}\n"); - write(root, "tsconfig.json", JSON.stringify({ compilerOptions: { customConditions: ["bun"] } })); - for (const name of ["ithaca", "troy"]) { - write(root, `packages/${name}/package.json`, JSON.stringify({ name: `@odysseus/${name}`, version: "1.0.0" })); - write(root, `packages/${name}/src/index.ts`, `export const harbour = "${name}";\n`); - } - return root; -} - -function build(root: string, name: string, input: string | undefined, text = `export const harbour = "${name}";\n`) { - rmSync(join(root, "packages", name, "dist"), { recursive: true, force: true }); - write(root, `packages/${name}/dist/index.js`, text); - recordBuild(root, name, input); -} - -describe("workspace build stamp", () => { - test("an unchanged second build is up to date and leaves dist untouched", () => { - const root = fixture(); - const input = buildInputs(root); - expect(input).toMatch(/^[0-9a-f]{64}$/); - for (const name of ["ithaca", "troy"]) build(root, name, input); - expect(buildInputs(root)).toBe(input!); - for (const name of ["ithaca", "troy"]) expect(buildDecision(root, name, input, false)).toBe("up-to-date"); - }); - - test("any package source edit, new file or root build input rebuilds every package", () => { - const root = fixture(); - const input = buildInputs(root); - for (const name of ["ithaca", "troy"]) build(root, name, input); - const changes: [string, () => void][] = [ - ["a sibling's source", () => write(root, "packages/troy/src/index.ts", 'export const harbour = "wooden horse";\n')], - ["a new untracked source file", () => write(root, "packages/troy/src/gift.ts", "export const gift = 1;\n")], - ["the root tsconfig", () => write(root, "tsconfig.json", "{}\n")], - ["the lockfile", () => write(root, "bun.lock", '{"lockfileVersion":2}\n')], - ]; - let previous = input; - for (const [label, change] of changes) { - change(); - const next = buildInputs(root); - expect(next, label).not.toBe(previous!); - for (const name of ["ithaca", "troy"]) expect(buildDecision(root, name, next, false), `${label} → ${name}`).toBe("build"); - previous = next; - } - }); - - test("a different runtime version is a different input", () => { - const root = fixture(); - expect(buildInputs(root, "1.4.2")).not.toBe(buildInputs(root, "1.4.3")!); - }); - - test("an ignored file does not change the inputs", () => { - const root = fixture(); - const input = buildInputs(root); - write(root, "packages/ithaca/dist/notes.txt", "Penelope's loom\n"); - write(root, "packages/ithaca/node_modules/x/index.js", "1\n"); - expect(buildInputs(root)).toBe(input!); - }); - - test("a deleted, edited or extra dist file rebuilds only that package", () => { - const root = fixture(); - const input = buildInputs(root); - const damage: [string, () => void][] = [ - ["deleted", () => rmSync(join(root, "packages/ithaca/dist/index.js"))], - ["edited", () => write(root, "packages/ithaca/dist/index.js", "export const harbour = 'Ogygia';\n")], - ["extra", () => write(root, "packages/ithaca/dist/stale.js", "export {};\n")], - ]; - for (const [label, change] of damage) { - for (const name of ["ithaca", "troy"]) build(root, name, input); - expect(buildDecision(root, "ithaca", input, false), `${label} before`).toBe("up-to-date"); - change(); - expect(buildDecision(root, "ithaca", input, false), `${label} dist`).toBe("build"); - expect(buildDecision(root, "troy", input, false), `${label} sibling`).toBe("up-to-date"); - } - }); - - test("--force always rebuilds", () => { - const root = fixture(); - const input = buildInputs(root); - build(root, "ithaca", input); - expect(buildDecision(root, "ithaca", input, true)).toBe("build"); - }); - - test("a stamp copied into another checkout without dist does not skip", () => { - const root = fixture(); - const input = buildInputs(root); - build(root, "ithaca", input); - const copy = fixture(); - cpSync(join(root, "node_modules"), join(copy, "node_modules"), { recursive: true }); - expect(Bun.file(stampPath(copy, "ithaca")).size).toBeGreaterThan(0); - expect(buildInputs(copy)).toBe(input!); - expect(buildDecision(copy, "ithaca", buildInputs(copy), false)).toBe("build"); - }); - - test("without a Git checkout the inputs are unknown and nothing skips", () => { - const root = fixture(false); - const input = buildInputs(root); - expect(input).toBeUndefined(); - build(root, "ithaca", input); - expect(Bun.file(stampPath(root, "ithaca")).size).toBe(0); - expect(buildDecision(root, "ithaca", input, false)).toBe("build"); - }); - - test("a malformed or foreign stamp rebuilds", () => { - const root = fixture(); - const input = buildInputs(root); - build(root, "ithaca", input); - for (const text of ["not json", JSON.stringify({ version: 0, input, output: "x" }), JSON.stringify({ version: 1, input: "other", output: "x" })]) { - writeFileSync(stampPath(root, "ithaca"), text); - expect(buildDecision(root, "ithaca", input, false)).toBe("build"); - } - }); -}); - -/** The real build script, in a Git fixture whose tsc wrapper runs the installed compiler. */ -async function realBuild(root: string, ...args: string[]) { - const child = Bun.spawn([process.execPath, "scripts/build.ts", ...args], { cwd: root, stdin: "ignore", stdout: "pipe", stderr: "pipe" }); - const [code, out, err] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); - expect(code, `${out}\n${err}`).toBe(0); - const names = readdirSync(join(root, "tmp")).filter(name => /^build-.*-probe\.json$/.test(name)); - const receipts = names.map(name => JSON.parse(readFileSync(join(root, "tmp", name), "utf8"))); - for (const name of names) rmSync(join(root, "tmp", name)); - expect(receipts).toHaveLength(1); - return { out, receipt: receipts[0] as { success: boolean; packages: { name: string; state: string; children: unknown[] }[] } }; -} - -test("the real build reports an unchanged second run up to date and rebuilds after --force or a source edit", async () => { - const root = fixture(); - mkdirSync(join(root, "scripts"), { recursive: true }); - for (const file of ["build.ts", "build-stamp.ts", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { - copyFileSync(join(repo, "scripts", file), join(root, "scripts", file)); - } - mkdirSync(join(root, "node_modules/.bin"), { recursive: true }); - const wrapper = join(root, "node_modules/.bin/tsc"); - writeFileSync(wrapper, `#!${process.execPath} -const compiler=Bun.spawn([process.execPath,${JSON.stringify(join(repo, "node_modules/typescript/bin/tsc"))},...process.argv.slice(2)],{stdout:"inherit",stderr:"inherit"}); -process.exitCode=await compiler.exited; -`); - chmodSync(wrapper, 0o755); - for (const name of ["ithaca", "troy"]) write(root, `packages/${name}/tsconfig.build.json`, JSON.stringify({ compilerOptions: { - rootDir: "src", outDir: "dist", target: "ESNext", module: "ESNext", moduleResolution: "Bundler", types: [], declaration: true, skipLibCheck: true, - }, include: ["src"] })); - const states = (receipt: { packages: { state: string; children: unknown[] }[] }) => receipt.packages.map(pkg => `${pkg.state}:${pkg.children.length}`); - - const first = await realBuild(root, "--jobs=2"); - expect(states(first.receipt), "FIRST_BUILD_COMPILES_EVERY_PACKAGE").toEqual(["complete:1", "complete:1"]); - const built = readFileSync(join(root, "packages/troy/dist/index.js"), "utf8"); - - const second = await realBuild(root, "--jobs=2"); - expect(second.out.trim().split("\n").sort(), "SECOND_BUILD_SKIPS_EVERY_PACKAGE").toEqual(["Up to date packages/ithaca", "Up to date packages/troy"]); - expect(states(second.receipt), "SKIPPED_PACKAGES_RUN_NO_COMPILER").toEqual(["up-to-date:0", "up-to-date:0"]); - expect(second.receipt.success).toBe(true); - expect(readFileSync(join(root, "packages/troy/dist/index.js"), "utf8")).toBe(built); - - expect(states((await realBuild(root, "--force")).receipt), "FORCE_REBUILDS").toEqual(["complete:1", "complete:1"]); - - write(root, "packages/ithaca/src/index.ts", 'export const harbour = "Scheria";\n'); - expect(states((await realBuild(root)).receipt), "ANY_SOURCE_EDIT_REBUILDS_EVERY_PACKAGE").toEqual(["complete:1", "complete:1"]); - expect(readFileSync(join(root, "packages/ithaca/dist/index.js"), "utf8")).toContain("Scheria"); -}, 60_000); diff --git a/tests/generated-build-inputs.test.ts b/tests/generated-build-inputs.test.ts index dd56731d54..a2b58e5c28 100644 --- a/tests/generated-build-inputs.test.ts +++ b/tests/generated-build-inputs.test.ts @@ -6,7 +6,7 @@ import { generatedCssInputs, generatedCssReadInputs } from "../scripts/generated import { discoverInputClosure } from "../scripts/browser-input-closure"; const repo=resolve(import.meta.dir,".."),roots:string[]=[]; afterEach(()=>{for(const root of roots.splice(0))rmSync(root,{recursive:true,force:true});}); -const producer=["scripts/build.ts","scripts/build-stamp.ts","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json"]; +const producer=["scripts/build.ts","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json"]; function fixture(){ const root=mkdtempSync(join(tmpdir(),"generated-css-"));roots.push(root); const write=(file:string,bytes:string)=>{mkdirSync(dirname(join(root,file)),{recursive:true});writeFileSync(join(root,file),bytes);}; diff --git a/tests/generated-theme-inputs.test.ts b/tests/generated-theme-inputs.test.ts index 2f2f3d66d4..f4e252086c 100644 --- a/tests/generated-theme-inputs.test.ts +++ b/tests/generated-theme-inputs.test.ts @@ -5,7 +5,7 @@ import {dirname,join,resolve} from "node:path"; import {browserImporters} from "../scripts/bound-browser-runtime-inputs"; import {generatedThemeInputs} from "../scripts/generated-theme-inputs"; const repo=resolve(import.meta.dir,".."),roots:string[]=[],owner="packages/ui-kit/tests/visual/dictation-motion.ts",caller="packages/ui-kit/vitest.config.ts",entry="packages/ui-kit/tests/visual/dictation-theme.css"; -const producers=["scripts/build.ts","scripts/build-stamp.ts","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json","bun.lock","packages/ui-react/src/theme.css","packages/ui-react/package.json"]; +const producers=["scripts/build.ts","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json","bun.lock","packages/ui-react/src/theme.css","packages/ui-react/package.json"]; afterEach(()=>{for(const root of roots.splice(0))rmSync(root,{recursive:true,force:true});}); function fixture(){const root=mkdtempSync(join(tmpdir(),"generated-theme-"));roots.push(root);const write=(file:string,text:string)=>{mkdirSync(dirname(join(root,file)),{recursive:true});writeFileSync(join(root,file),text);};for(const file of [...producers,owner,caller,entry])write(file,readFileSync(join(repo,file),"utf8"));symlinkSync(join(repo,"node_modules"),join(root,"node_modules"),"dir");return{root,write};} test("temporary theme output binds the audited command, copied theme and installed compiler",()=>{ diff --git a/tests/inclusion-inputs.test.ts b/tests/inclusion-inputs.test.ts index 437e8673d3..208e400a23 100644 --- a/tests/inclusion-inputs.test.ts +++ b/tests/inclusion-inputs.test.ts @@ -81,7 +81,7 @@ test("validated installed asset bytes and mode join immutable source fingerprint const installed=resolve(import.meta.dir,"../node_modules"); for(const entry of readdirSync(installed)) if(entry!=="axe-core") symlinkSync(join(installed,entry),join(root,"node_modules",entry),"dir"); const owner="packages/ui-react/tests/browser/offline/cold-capture/commands.ts"; - for(const file of [owner,"scripts/build.ts","scripts/build-stamp.ts","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json","packages/ui-react/package.json","packages/ui-kit/package.json"]) + for(const file of [owner,"scripts/build.ts","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json","packages/ui-react/package.json","packages/ui-kit/package.json"]) write(root,file,readFileSync(resolve(import.meta.dir,"..",file),"utf8")); write(root,spec,`import "../../../ui-react/tests/browser/offline/cold-capture/commands.ts";`); write(root,"node_modules/axe-core/package.json",JSON.stringify({name:"axe-core",version:"4.13.0"}));write(root,"node_modules/axe-core/axe.min.js","owned asset before");commit(root); diff --git a/tests/workspace-lease.test.ts b/tests/workspace-lease.test.ts index 11e335f9b7..7f499ef974 100644 --- a/tests/workspace-lease.test.ts +++ b/tests/workspace-lease.test.ts @@ -115,7 +115,7 @@ test("shared verification overlaps, blocks a writer and rejects a nested build b mkdirSync(join(root, "scripts")); mkdirSync(join(root, "bin")); mkdirSync(join(root, "packages/geo/dist"), { recursive: true }); writeFileSync(join(root, "packages/geo/dist/retained"), "retained\n"); - for (const name of ["build.ts", "build-stamp.ts", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) copyFileSync(join(project, "scripts", name), join(root, "scripts", name)); + for (const name of ["build.ts", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) copyFileSync(join(project, "scripts", name), join(root, "scripts", name)); writeFileSync(join(root, "bin/bunx"), "#!/bin/sh\nexit 77\n", { mode: 0o755 }); const first = start(root, "read", "hold", "first"), second = start(root, "read", "hold", "second"); await Promise.all([entered(root, "first"), entered(root, "second")]); From 8af7d93ba140c81e2d19cadc0cd5a11301fa2c67 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sat, 17 Oct 2026 18:18:07 +0200 Subject: [PATCH 02/15] test: bound callout scenarios and drain their complete work --- .../browser/diagrams-callouts.layout.tsx | 59 ++++++++++++++++--- scripts/bound-browser-documents.ts | 2 +- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/packages/ui-react/tests/browser/diagrams-callouts.layout.tsx b/packages/ui-react/tests/browser/diagrams-callouts.layout.tsx index 19a9b181b2..c57b8dced7 100644 --- a/packages/ui-react/tests/browser/diagrams-callouts.layout.tsx +++ b/packages/ui-react/tests/browser/diagrams-callouts.layout.tsx @@ -1,4 +1,4 @@ -import { afterAll, afterEach, beforeAll, expect, test } from "vitest"; +import { afterAll, afterEach, beforeAll, expect, test as browserTest } from "vitest"; import { commands } from "vitest/browser"; import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; @@ -16,8 +16,38 @@ import { buildDiagramLayout } from "../../../module-diagrams/src/layout.js"; let host: HTMLDivElement, render: Root, root: BrainUiRoot, styles: HTMLStyleElement; const originalTheme = document.documentElement.dataset.theme; beforeAll(async () => { styles = document.createElement("style"); styles.dataset.diagramCaptureConsumer = ""; styles.textContent = await commands.formConsumerStyles(); document.head.append(styles); }); -afterAll(() => styles.remove()); -afterEach(() => { if (render) flushSync(() => render.unmount()); root?.dispose(); host?.remove(); document.documentElement.dataset.theme = originalTheme; }); +// A Vitest deadline rejects its wrapper without cancelling the async body. +// Retain the whole body, including captures it may schedule after that deadline. +let ownedCase: Promise | undefined, ownedTeardown: Promise | undefined; +function test(name: string, run: () => Promise) { + browserTest(name, () => { + if (ownedCase || ownedTeardown) throw Error("PREVIOUS_CALLOUT_CASE_NOT_DRAINED"); + ownedCase = Promise.resolve().then(run); + return ownedCase; + }); +} +async function teardownCase() { + const work = ownedCase; + const teardown = ownedTeardown ??= (async () => { + // The test still receives its original rejected promise. Teardown observes + // settlement so a failure cannot leave later work using another case's DOM. + if (work) await Promise.allSettled([work]); + const failures: unknown[] = []; + for (const cleanup of [() => { if (render) flushSync(() => render.unmount()); }, + () => root?.dispose(), () => host?.remove(), () => { document.documentElement.dataset.theme = originalTheme; }]) { + try { cleanup(); } catch (error) { failures.push(error); } + } + if (failures.length) throw new AggregateError(failures, "CALLOUT_CASE_TEARDOWN_FAILED"); + if (ownedCase !== work) throw Error("CALLOUT_CASE_OWNERSHIP_CHANGED"); + ownedCase = undefined; + })(); + // A failed or still-pending teardown retains ownership. Another case refuses + // before its body runs, and styles remain while unknown work could use them. + await teardown; + if (ownedTeardown === teardown) ownedTeardown = undefined; +} +afterAll(async () => { if (ownedCase || ownedTeardown) await teardownCase(); styles?.remove(); }); +afterEach(teardownCase); function mount(source: string, width: number, theme: "light" | "dark" | "print" = "light") { if (!host?.isConnected) { host = document.createElement("div"); document.body.append(host); render = createRoot(host); root = createBrainUiRoot({ storage: null, request: async () => Response.json({ renderers: [], enabled: [] }) }); } document.documentElement.dataset.theme = theme; host.style.width = `${width}px`; @@ -104,17 +134,32 @@ async function assertParity(svg: SVGSVGElement, source: string, theme: "light" | const xml = new DOMParser().parseFromString(await toStatic(source, { theme }), "image/svg+xml"); expect(annotation(svg), "SHARED_CLIENT_STATIC_ALL_ANNOTATION_GEOMETRY").toEqual(annotation(xml.documentElement)); } -for (const theme of ["light", "dark", "print"] as const) test(`literal and grouped full notes client/static ${theme}`, async () => { - let source = JSON.stringify(calloutsSpec); mount(source, 800, theme); let svg = await svgReady(); assertAnnotations(svg, source, 1); await assertParity(svg, source, theme); +for (const theme of ["light", "dark", "print"] as const) { + test(`literal full notes client/static capture ${theme}`, async () => { + const source = JSON.stringify(calloutsSpec); mount(source, 800, theme); const svg = await svgReady(); assertAnnotations(svg, source, 1); await assertParity(svg, source, theme); await commands.diagramCapture('[data-content-renderer="diagram"]', `callouts-literal-${theme}`); + }); + test(`literal to grouped full notes live client/static ${theme}`, async () => { + let source = JSON.stringify(calloutsSpec); mount(source, 800, theme); let svg = await svgReady(); assertAnnotations(svg, source, 1); await assertParity(svg, source, theme); + const previousHost = host; const spec = { ...calloutsSpec, callouts: Array.from({ length: 1024 }, (_, i) => ({ target: "odysseus", text: `Prepare provisions ${i + 1}` })) }; - source = JSON.stringify(spec); mount(source, 800, theme); svg = await svgReady(); assertAnnotations(svg, source, 1024); await assertParity(svg, source, theme); + source = JSON.stringify(spec); mount(source, 800, theme); svg = await svgReady(); expect(host, "GROUPED_UPDATE_RETAINS_CLIENT_ROOT").toBe(previousHost); assertAnnotations(svg, source, 1024); await assertParity(svg, source, theme); expect(svg.querySelectorAll("[data-diagram-callout-key]")).toHaveLength(1); expect(svg.querySelector("[data-diagram-callout-key]")!.textContent).toBe("1"); expect(svg.querySelector("[data-diagram-notes-band]")!.getAttribute("data-columns")).toBe("2"); const rows = [...svg.querySelectorAll("[data-diagram-callout]")]; for (const column of [0, 1]) { const selected = rows.filter(row => row.dataset.column === String(column)); expect(selected).toHaveLength(512); expect(new Set(selected.map(row => row.querySelector("[data-callout-index]")!.getAttribute("x"))).size).toBe(1); } + }); + test(`literal to grouped full notes capture ${theme}`, async () => { + mount(JSON.stringify(calloutsSpec), 800, theme); const literal = await svgReady(); expect(literal.querySelectorAll("[data-diagram-callout]")).toHaveLength(1); + const previousHost = host; + const source = JSON.stringify({ ...calloutsSpec, callouts: Array.from({ length: 1024 }, (_, i) => ({ target: "odysseus", text: `Prepare provisions ${i + 1}` })) }); + mount(source, 800, theme); const svg = await svgReady(); expect(host, "CAPTURE_UPDATE_RETAINS_CLIENT_ROOT").toBe(previousHost); + expect(svg.querySelectorAll("[data-diagram-callout]"), "CAPTURE_ALL_SOURCE_NOTES").toHaveLength(1024); + expect([...svg.querySelectorAll("[data-callout-index]")].map(index => index.textContent), "CAPTURE_ALL_SOURCE_INDICES").toEqual(Array.from({ length: 1024 }, (_, i) => String(i + 1))); await commands.diagramCapture('[data-content-renderer="diagram"]', `callouts-1024-${theme}`); -}); + }); +} + for (const theme of ["light", "dark", "print"] as const) test(`authored narrow grouped notes ${theme}`, async () => { const spec = { ...narrowCallouts(), callouts: Array.from({ length: 1024 }, (_, i) => ({ target: "odysseus", text: `Prepare provisions ${i + 1}` })) }, source = JSON.stringify(spec); mount(source, 320, theme); const svg = await svgReady(); assertAnnotations(svg, source, 1024); diff --git a/scripts/bound-browser-documents.ts b/scripts/bound-browser-documents.ts index 225a40c8e7..160b32b502 100644 --- a/scripts/bound-browser-documents.ts +++ b/scripts/bound-browser-documents.ts @@ -53,7 +53,7 @@ const declarations:DocumentDeclaration[]=[{ "calls": 2 }, "packages/ui-react/tests/browser/diagrams-callouts.layout.tsx": { - "hash": "4a791c76a7ebd916ed7e47b91c37641a2f4b204aefd8ea470a46a3214f6c8cb2", + "hash": "64e5d794fe1a9f54386d712bb445d75d8e9fd50ac0e271b791de6ca4a0948b44", "calls": 8 } }, From cb66d1a92280d4e7f99eaec1dbb267f5210f034a Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sat, 17 Oct 2026 18:58:34 +0200 Subject: [PATCH 03/15] test: drain React work before per-file DOM unregister --- packages/ui-react/tests/expired-scratch-links.test.tsx | 2 +- .../ui-react/tests/file-viewer-binary-fallback.test.tsx | 3 ++- packages/ui-react/tests/pdf-preview.test.tsx | 1 + packages/ui-react/tests/registration-on-mount.test.tsx | 3 ++- packages/ui-react/tests/render/activity-report-dom.ts | 7 +++++-- packages/ui-react/tests/render/activity-report.test.tsx | 3 +-- packages/ui-react/tests/render/answer-delivery-dom.ts | 7 +++++-- packages/ui-react/tests/render/answer-suggestions-dom.ts | 7 +++++-- packages/ui-react/tests/render/approval-effect-dom.ts | 7 +++++-- packages/ui-react/tests/render/approval-effect.test.tsx | 3 +-- packages/ui-react/tests/render/ask-user-dom.ts | 7 +++++-- packages/ui-react/tests/render/ask-user-form-dom.ts | 7 +++++-- packages/ui-react/tests/render/ask-user-list-dom.ts | 7 +++++-- packages/ui-react/tests/render/ask-user-rank-dom.ts | 7 +++++-- packages/ui-react/tests/render/classified-blocks-dom.ts | 7 +++++-- packages/ui-react/tests/render/composer-dom.ts | 6 +++++- packages/ui-react/tests/render/copy-control-dom.ts | 7 +++++-- packages/ui-react/tests/render/desktop-panes-sixth-dom.ts | 7 +++++-- .../ui-react/tests/render/desktop-panes-sixth.test.tsx | 3 +-- .../ui-react/tests/render/dictation-provider-end-dom.ts | 7 +++++-- packages/ui-react/tests/render/dictation-teardown-dom.ts | 7 +++++-- packages/ui-react/tests/render/durable-actions-dom.ts | 6 +++++- packages/ui-react/tests/render/error-card-dom.ts | 7 +++++-- packages/ui-react/tests/render/handoff-dom.ts | 7 +++++-- packages/ui-react/tests/render/handoff-entry-dom.ts | 7 +++++-- packages/ui-react/tests/render/message-source-dom.ts | 7 +++++-- packages/ui-react/tests/render/restored-approval-dom.ts | 7 +++++-- packages/ui-react/tests/render/slide-panel-dom.ts | 7 +++++-- packages/ui-react/tests/render/slide-panel.test.tsx | 3 +-- packages/ui-react/tests/render/staged-tracks-unload-dom.ts | 4 ++-- packages/ui-react/tests/render/stats-answer-dom.ts | 7 +++++-- packages/ui-react/tests/render/stats-session-dom.ts | 7 +++++-- packages/ui-react/tests/render/turn-failure-dom.ts | 7 +++++-- packages/ui-react/tests/render/update-holds-dom.ts | 7 +++++-- packages/ui-react/tests/render/worker-host-notice-dom.ts | 7 +++++-- 35 files changed, 142 insertions(+), 63 deletions(-) diff --git a/packages/ui-react/tests/expired-scratch-links.test.tsx b/packages/ui-react/tests/expired-scratch-links.test.tsx index 166ffd13cd..abab6fc8b4 100644 --- a/packages/ui-react/tests/expired-scratch-links.test.tsx +++ b/packages/ui-react/tests/expired-scratch-links.test.tsx @@ -175,7 +175,7 @@ if (!process.env[CHILD_MARKER]) { cleanup(); for (const root of roots.splice(0)) root.dispose(); }); - afterAll(async () => { await GlobalRegistrator.unregister(); }); + afterAll(async () => { await React.act(async () => {}); await GlobalRegistrator.unregister(); }); describe("the chat", () => { test("a cleared scratch link keeps its words, is inert and is named expired", async () => { diff --git a/packages/ui-react/tests/file-viewer-binary-fallback.test.tsx b/packages/ui-react/tests/file-viewer-binary-fallback.test.tsx index 35e696fc44..e25ac08af0 100644 --- a/packages/ui-react/tests/file-viewer-binary-fallback.test.tsx +++ b/packages/ui-react/tests/file-viewer-binary-fallback.test.tsx @@ -28,7 +28,7 @@ if (!process.env[CHILD_MARKER]) { GlobalRegistrator.register(); (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - const { cleanup, fireEvent, render } = await import("@testing-library/react"); + const { act, cleanup, fireEvent, render } = await import("@testing-library/react"); const { BrainUiProvider } = await import("../src/root-context.js"); const { createBrainUiRoot } = await import("../src/root.js"); const { FileViewerBinary } = await import("../src/components/files/file-viewer-binary.js"); @@ -66,6 +66,7 @@ if (!process.env[CHILD_MARKER]) { afterAll(async () => { cleanup(); root.dispose(); + await act(async () => {}); await GlobalRegistrator.unregister(); }); } diff --git a/packages/ui-react/tests/pdf-preview.test.tsx b/packages/ui-react/tests/pdf-preview.test.tsx index 9f410eee33..56d51a2b3e 100644 --- a/packages/ui-react/tests/pdf-preview.test.tsx +++ b/packages/ui-react/tests/pdf-preview.test.tsx @@ -420,6 +420,7 @@ if (!process.env[CHILD_MARKER]) { afterAll(async () => { cleanup(); root.dispose(); + await act(async () => {}); await GlobalRegistrator.unregister(); }); } diff --git a/packages/ui-react/tests/registration-on-mount.test.tsx b/packages/ui-react/tests/registration-on-mount.test.tsx index e938192a57..f4853e6c75 100644 --- a/packages/ui-react/tests/registration-on-mount.test.tsx +++ b/packages/ui-react/tests/registration-on-mount.test.tsx @@ -161,7 +161,7 @@ if (!childMode) { sdk.defaultAsrClientRegistry.register = registerAsrClient; const React = await import("react"); - const { cleanup, render, renderHook } = await import("@testing-library/react"); + const { act, cleanup, render, renderHook } = await import("@testing-library/react"); const harness = await import("./registration-on-mount-harness.js"); test("StrictMode renders and explicit repeats register each built-in once", () => { @@ -216,6 +216,7 @@ if (!childMode) { afterAll(async () => { cleanup(); + await act(async () => {}); await GlobalRegistrator.unregister(); }); } diff --git a/packages/ui-react/tests/render/activity-report-dom.ts b/packages/ui-react/tests/render/activity-report-dom.ts index 9f1af6dbac..51d8d7a0b1 100644 --- a/packages/ui-react/tests/render/activity-report-dom.ts +++ b/packages/ui-react/tests/render/activity-report-dom.ts @@ -3,14 +3,17 @@ // once per process, so each render test file registers its own DOM and // unregisters it in `afterAll`. Import it FIRST, before any component module. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterActivityReportDom(): void { +export async function unregisterActivityReportDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/activity-report.test.tsx b/packages/ui-react/tests/render/activity-report.test.tsx index 98f0a414da..895d5ad283 100644 --- a/packages/ui-react/tests/render/activity-report.test.tsx +++ b/packages/ui-react/tests/render/activity-report.test.tsx @@ -31,9 +31,8 @@ const render = (node: ReactNode) => renderDom({node afterEach(cleanup); afterAll(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); restoreGeometry(); - unregisterActivityReportDom(); + await unregisterActivityReportDom(); }); const SECRET = "sk-odyssey-0000000000000000"; diff --git a/packages/ui-react/tests/render/answer-delivery-dom.ts b/packages/ui-react/tests/render/answer-delivery-dom.ts index e557f33366..96745dc39c 100644 --- a/packages/ui-react/tests/render/answer-delivery-dom.ts +++ b/packages/ui-react/tests/render/answer-delivery-dom.ts @@ -3,14 +3,17 @@ // once per process, so each render test file registers its own DOM and // unregisters it in `afterAll`. Import it FIRST, before any component module. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterAnswerDeliveryDom(): void { +export async function unregisterAnswerDeliveryDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/answer-suggestions-dom.ts b/packages/ui-react/tests/render/answer-suggestions-dom.ts index e6af5e3dae..b0672857cd 100644 --- a/packages/ui-react/tests/render/answer-suggestions-dom.ts +++ b/packages/ui-react/tests/render/answer-suggestions-dom.ts @@ -3,14 +3,17 @@ // once per process, so each render test file registers its own DOM and // unregisters it in `afterAll`. Import it FIRST, before any component module. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterAnswerSuggestionsDom(): void { +export async function unregisterAnswerSuggestionsDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/approval-effect-dom.ts b/packages/ui-react/tests/render/approval-effect-dom.ts index f826d3c8a2..4816ca5785 100644 --- a/packages/ui-react/tests/render/approval-effect-dom.ts +++ b/packages/ui-react/tests/render/approval-effect-dom.ts @@ -3,14 +3,17 @@ // once per process, so a second test file importing it would get no DOM. // Import it FIRST, before any component module. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterApprovalEffectDom(): void { +export async function unregisterApprovalEffectDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/approval-effect.test.tsx b/packages/ui-react/tests/render/approval-effect.test.tsx index e046295f59..580a712efe 100644 --- a/packages/ui-react/tests/render/approval-effect.test.tsx +++ b/packages/ui-react/tests/render/approval-effect.test.tsx @@ -14,8 +14,7 @@ import type { ToolCall } from "../../src/stores/chat-store.js"; afterEach(cleanup); afterAll(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - unregisterDom(); + await unregisterDom(); }); const EFFECT = "delete a directory and everything inside it"; diff --git a/packages/ui-react/tests/render/ask-user-dom.ts b/packages/ui-react/tests/render/ask-user-dom.ts index 2c9d8d3d45..0bbaf57606 100644 --- a/packages/ui-react/tests/render/ask-user-dom.ts +++ b/packages/ui-react/tests/render/ask-user-dom.ts @@ -6,14 +6,17 @@ // before the next test file — the same containment contract, kept per file. // Import it FIRST, before any component module. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterAskUserDom(): void { +export async function unregisterAskUserDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/ask-user-form-dom.ts b/packages/ui-react/tests/render/ask-user-form-dom.ts index 978dee156b..865147119f 100644 --- a/packages/ui-react/tests/render/ask-user-form-dom.ts +++ b/packages/ui-react/tests/render/ask-user-form-dom.ts @@ -3,6 +3,7 @@ // once per process, so each render test file registers its own DOM and // unregisters it in `afterAll`. Import it FIRST, before any component module. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); @@ -11,8 +12,10 @@ if (!GlobalRegistrator.isRegistered) { globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterAskUserFormDom(): void { +export async function unregisterAskUserFormDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/ask-user-list-dom.ts b/packages/ui-react/tests/render/ask-user-list-dom.ts index b117dabc0d..f16f1f5f1d 100644 --- a/packages/ui-react/tests/render/ask-user-list-dom.ts +++ b/packages/ui-react/tests/render/ask-user-list-dom.ts @@ -3,14 +3,17 @@ // once per process, so each render test file registers its own DOM and // unregisters it in `afterAll`. Import it FIRST, before any component module. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterAskUserListDom(): void { +export async function unregisterAskUserListDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/ask-user-rank-dom.ts b/packages/ui-react/tests/render/ask-user-rank-dom.ts index 59034839d0..5d27039148 100644 --- a/packages/ui-react/tests/render/ask-user-rank-dom.ts +++ b/packages/ui-react/tests/render/ask-user-rank-dom.ts @@ -3,14 +3,17 @@ // once per process, so each render test file registers its own DOM and // unregisters it in `afterAll`. Import it FIRST, before any component module. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterAskUserRankDom(): void { +export async function unregisterAskUserRankDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/classified-blocks-dom.ts b/packages/ui-react/tests/render/classified-blocks-dom.ts index 5be504a5eb..a34ba4da9d 100644 --- a/packages/ui-react/tests/render/classified-blocks-dom.ts +++ b/packages/ui-react/tests/render/classified-blocks-dom.ts @@ -3,14 +3,17 @@ // once per process, so each render test file registers its own DOM and // unregisters it in `afterAll`. Import it FIRST, before any component module. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterClassifiedBlocksDom(): void { +export async function unregisterClassifiedBlocksDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/composer-dom.ts b/packages/ui-react/tests/render/composer-dom.ts index 1d700c55cd..b8110e9e29 100644 --- a/packages/ui-react/tests/render/composer-dom.ts +++ b/packages/ui-react/tests/render/composer-dom.ts @@ -1,9 +1,13 @@ // Register before component imports; contain globals like the other render suites. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register(); (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; export async function unregisterComposerDom(): Promise { - if (GlobalRegistrator.isRegistered) await GlobalRegistrator.unregister(); + if (GlobalRegistrator.isRegistered) { + await act(async () => {}); + await GlobalRegistrator.unregister(); + } } diff --git a/packages/ui-react/tests/render/copy-control-dom.ts b/packages/ui-react/tests/render/copy-control-dom.ts index b0bf14caa6..a2c4e83ff4 100644 --- a/packages/ui-react/tests/render/copy-control-dom.ts +++ b/packages/ui-react/tests/render/copy-control-dom.ts @@ -3,14 +3,17 @@ // once per process, so each render test file registers its own DOM and // unregisters it in `afterAll`. Import it FIRST, before any component module. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterCopyControlDom(): void { +export async function unregisterCopyControlDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/desktop-panes-sixth-dom.ts b/packages/ui-react/tests/render/desktop-panes-sixth-dom.ts index 7e85c2fdd7..9009225dcd 100644 --- a/packages/ui-react/tests/render/desktop-panes-sixth-dom.ts +++ b/packages/ui-react/tests/render/desktop-panes-sixth-dom.ts @@ -6,14 +6,17 @@ // before the next test file — the same containment contract, kept per file. // Import it FIRST, before any component module. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterDesktopPanesDom(): void { +export async function unregisterDesktopPanesDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/desktop-panes-sixth.test.tsx b/packages/ui-react/tests/render/desktop-panes-sixth.test.tsx index 3230804237..4a4c077ce9 100644 --- a/packages/ui-react/tests/render/desktop-panes-sixth.test.tsx +++ b/packages/ui-react/tests/render/desktop-panes-sixth.test.tsx @@ -47,8 +47,7 @@ mock.module("../../src/components/graph/graph-canvas.js", () => ({ afterEach(cleanup); afterAll(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - unregisterDom(); + await unregisterDom(); }); async function flushPromises(): Promise { diff --git a/packages/ui-react/tests/render/dictation-provider-end-dom.ts b/packages/ui-react/tests/render/dictation-provider-end-dom.ts index 669ad18bf8..eaddf8991e 100644 --- a/packages/ui-react/tests/render/dictation-provider-end-dom.ts +++ b/packages/ui-react/tests/render/dictation-provider-end-dom.ts @@ -3,14 +3,17 @@ // once per process, so each render test file registers its own DOM and // unregisters it in `afterAll`. Import it FIRST, before any component module. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterDictationProviderEndDom(): void { +export async function unregisterDictationProviderEndDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/dictation-teardown-dom.ts b/packages/ui-react/tests/render/dictation-teardown-dom.ts index 81a34ab85d..f1ededef31 100644 --- a/packages/ui-react/tests/render/dictation-teardown-dom.ts +++ b/packages/ui-react/tests/render/dictation-teardown-dom.ts @@ -3,14 +3,17 @@ // once per process, so each render test file registers its own DOM and // unregisters it in `afterAll`. Import it FIRST, before any component module. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterDictationTeardownDom(): void { +export async function unregisterDictationTeardownDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/durable-actions-dom.ts b/packages/ui-react/tests/render/durable-actions-dom.ts index 678245337c..78e0582b7b 100644 --- a/packages/ui-react/tests/render/durable-actions-dom.ts +++ b/packages/ui-react/tests/render/durable-actions-dom.ts @@ -1,9 +1,13 @@ // Register before component imports; contain globals like the other render suites. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register(); (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; export async function unregisterDurableActionsDom(): Promise { - if (GlobalRegistrator.isRegistered) await GlobalRegistrator.unregister(); + if (GlobalRegistrator.isRegistered) { + await act(async () => {}); + await GlobalRegistrator.unregister(); + } } diff --git a/packages/ui-react/tests/render/error-card-dom.ts b/packages/ui-react/tests/render/error-card-dom.ts index 1621c2f570..3edcbe02f2 100644 --- a/packages/ui-react/tests/render/error-card-dom.ts +++ b/packages/ui-react/tests/render/error-card-dom.ts @@ -3,14 +3,17 @@ // once per process, so each render test file registers its own DOM and // unregisters it in `afterAll`. Import it FIRST, before any component module. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterErrorCardDom(): void { +export async function unregisterErrorCardDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/handoff-dom.ts b/packages/ui-react/tests/render/handoff-dom.ts index 7ef53dbaa6..905169df08 100644 --- a/packages/ui-react/tests/render/handoff-dom.ts +++ b/packages/ui-react/tests/render/handoff-dom.ts @@ -1,14 +1,17 @@ // happy-dom registration for the handoff render tests; a sibling of `dom.ts` // for the reason `ask-user-dom.ts` states. Import it FIRST. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterHandoffDom(): void { +export async function unregisterHandoffDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/handoff-entry-dom.ts b/packages/ui-react/tests/render/handoff-entry-dom.ts index c3e4ebf9d3..48ff389afc 100644 --- a/packages/ui-react/tests/render/handoff-entry-dom.ts +++ b/packages/ui-react/tests/render/handoff-entry-dom.ts @@ -1,14 +1,17 @@ // happy-dom registration for the handoff entry render tests; a sibling of // `dom.ts` for the reason `ask-user-dom.ts` states. Import it FIRST. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterHandoffEntryDom(): void { +export async function unregisterHandoffEntryDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/message-source-dom.ts b/packages/ui-react/tests/render/message-source-dom.ts index ad62701df7..6c588648dc 100644 --- a/packages/ui-react/tests/render/message-source-dom.ts +++ b/packages/ui-react/tests/render/message-source-dom.ts @@ -3,14 +3,17 @@ // once per process, so each render test file registers its own DOM and // unregisters it in `afterAll`. Import it FIRST, before any component module. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterMessageSourceDom(): void { +export async function unregisterMessageSourceDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/restored-approval-dom.ts b/packages/ui-react/tests/render/restored-approval-dom.ts index d31b0f093c..228416fa8b 100644 --- a/packages/ui-react/tests/render/restored-approval-dom.ts +++ b/packages/ui-react/tests/render/restored-approval-dom.ts @@ -3,14 +3,17 @@ // once per process, so each render test file registers its own DOM and // unregisters it in `afterAll`. Import it FIRST, before any component module. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterRestoredApprovalDom(): void { +export async function unregisterRestoredApprovalDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/slide-panel-dom.ts b/packages/ui-react/tests/render/slide-panel-dom.ts index 8b815b6735..5be04cd922 100644 --- a/packages/ui-react/tests/render/slide-panel-dom.ts +++ b/packages/ui-react/tests/render/slide-panel-dom.ts @@ -6,14 +6,17 @@ // unregisters again before the next test file — the same containment // contract, kept per file. Import it FIRST, before any component module. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterSlidePanelDom(): void { +export async function unregisterSlidePanelDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/slide-panel.test.tsx b/packages/ui-react/tests/render/slide-panel.test.tsx index 7f95a0f0f7..cb22ea8d2b 100644 --- a/packages/ui-react/tests/render/slide-panel.test.tsx +++ b/packages/ui-react/tests/render/slide-panel.test.tsx @@ -25,8 +25,7 @@ import { useUIStore } from "../../src/stores/ui-store.js"; afterEach(cleanup); afterAll(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - unregisterDom(); + await unregisterDom(); }); const realFetch = globalThis.fetch; diff --git a/packages/ui-react/tests/render/staged-tracks-unload-dom.ts b/packages/ui-react/tests/render/staged-tracks-unload-dom.ts index 73eca57142..57227bb583 100644 --- a/packages/ui-react/tests/render/staged-tracks-unload-dom.ts +++ b/packages/ui-react/tests/render/staged-tracks-unload-dom.ts @@ -9,8 +9,8 @@ if (!GlobalRegistrator.isRegistered) { } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterStagedTracksUnloadDom(): void { +export async function unregisterStagedTracksUnloadDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/stats-answer-dom.ts b/packages/ui-react/tests/render/stats-answer-dom.ts index cd60c5449c..224520ea85 100644 --- a/packages/ui-react/tests/render/stats-answer-dom.ts +++ b/packages/ui-react/tests/render/stats-answer-dom.ts @@ -3,14 +3,17 @@ // once per process, so each render test file registers its own DOM and // unregisters it in `afterAll`. Import it FIRST, before any component module. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterStatsAnswerDom(): void { +export async function unregisterStatsAnswerDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/stats-session-dom.ts b/packages/ui-react/tests/render/stats-session-dom.ts index 194eb326b0..49c2c027bf 100644 --- a/packages/ui-react/tests/render/stats-session-dom.ts +++ b/packages/ui-react/tests/render/stats-session-dom.ts @@ -3,14 +3,17 @@ // once per process, so each render test file registers its own DOM and // unregisters it in `afterAll`. Import it FIRST, before any component module. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterStatsSessionDom(): void { +export async function unregisterStatsSessionDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/turn-failure-dom.ts b/packages/ui-react/tests/render/turn-failure-dom.ts index 648380e598..c5eaa173e8 100644 --- a/packages/ui-react/tests/render/turn-failure-dom.ts +++ b/packages/ui-react/tests/render/turn-failure-dom.ts @@ -3,14 +3,17 @@ // once per process, so each render test file registers its own DOM and // unregisters it in `afterAll`. Import it FIRST, before any component module. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterTurnFailureDom(): void { +export async function unregisterTurnFailureDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/update-holds-dom.ts b/packages/ui-react/tests/render/update-holds-dom.ts index 736b3bb8de..9d81708e08 100644 --- a/packages/ui-react/tests/render/update-holds-dom.ts +++ b/packages/ui-react/tests/render/update-holds-dom.ts @@ -3,14 +3,17 @@ // once per process, so each render test file registers its own DOM and // unregisters it in `afterAll`. Import it FIRST, before any component module. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterUpdateHoldsDom(): void { +export async function unregisterUpdateHoldsDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } diff --git a/packages/ui-react/tests/render/worker-host-notice-dom.ts b/packages/ui-react/tests/render/worker-host-notice-dom.ts index a1d617b4c8..1e05d76749 100644 --- a/packages/ui-react/tests/render/worker-host-notice-dom.ts +++ b/packages/ui-react/tests/render/worker-host-notice-dom.ts @@ -1,14 +1,17 @@ // happy-dom registration for the worker host notice render tests; a sibling of // `dom.ts` for the reason `ask-user-dom.ts` states. Import it FIRST. import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act } from "react"; if (!GlobalRegistrator.isRegistered) { GlobalRegistrator.register(); } (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -export function unregisterWorkerHostNoticeDom(): void { +export async function unregisterWorkerHostNoticeDom(): Promise { if (GlobalRegistrator.isRegistered) { - void GlobalRegistrator.unregister(); + // Drain React callbacks before removing the globals they still read. + await act(async () => {}); + await GlobalRegistrator.unregister(); } } From 1977a75ea82e23d5a9d03b40c058341e8df3e3a1 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sat, 17 Oct 2026 19:24:48 +0200 Subject: [PATCH 04/15] test: keep auth snapshot abort active through queued autosaves --- .../tests/auth-expiry-runtime.test.ts | 35 ++++++++++++++----- .../tests/fixtures/auth-expiry-client.ts | 2 ++ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/ui-react/tests/auth-expiry-runtime.test.ts b/packages/ui-react/tests/auth-expiry-runtime.test.ts index ef899b9bf3..dcde882585 100644 --- a/packages/ui-react/tests/auth-expiry-runtime.test.ts +++ b/packages/ui-react/tests/auth-expiry-runtime.test.ts @@ -145,6 +145,8 @@ afterAll(() => hook.phase("cleanup", async () => { type Outcome = { ok: true; value: T } | { ok: false; error: string }; type Fixture = { phase(): string; + snapshotFailed(): boolean; + hasSnapshot(): boolean; recording(): Promise>; tracks(): unknown[]; allImages(): unknown[]; @@ -222,21 +224,25 @@ const field = (page: Page) => page.locator("textarea[data-composer]"); /** * While armed, holds every readwrite transaction over `records` open after * its own requests have succeeded: a request keeps going on each until the - * test releases them all, or aborts them all. So whichever write a - * snapshot waits on, its own transaction is among the held. Counts the - * writes that succeeded meanwhile. + * test releases them all, or aborts them all. Abort remains armed for + * later queued writes until explicitly restored: a held autosave must not + * consume the auth snapshot's injected failure. Counts successful puts. */ async function armHold(page: Page, outcome: "release" | "abort") { await page.evaluate((outcome) => { - const w = window as unknown as { __hold: { held: number; puts: number; finish: () => void } }; + const w = window as unknown as { __hold: { held: number; puts: number; finish: () => void; restore: () => void } }; const put = IDBObjectStore.prototype.put; const held: IDBTransaction[] = []; let done = false; - w.__hold = { held: 0, puts: 0, finish: () => {} }; + w.__hold = { held: 0, puts: 0, finish: () => {}, restore: () => { IDBObjectStore.prototype.put = put; } }; IDBObjectStore.prototype.put = function (this: IDBObjectStore, ...a: Parameters) { const req = put.apply(this, a); const key = a[1]; if (this.name === "records" && Array.isArray(key) && String(key[1]).startsWith("root:")) { + if (done && outcome === "abort") { + this.transaction.abort(); + return req; + } if (!held.includes(this.transaction)) { held.push(this.transaction); w.__hold.held = held.length; @@ -254,8 +260,8 @@ const field = (page: Page) => page.locator("textarea[data-composer]"); }; w.__hold.finish = () => { done = true; - IDBObjectStore.prototype.put = put; - if (outcome === "abort") for (const tx of held) tx.abort(); + if (outcome === "release") w.__hold.restore(); + else for (const tx of held) tx.abort(); }; }, outcome); } @@ -375,16 +381,24 @@ for (const kind of ["401", "1008"] as const) describe.skipIf(!executablePath)(`w }); describe.skipIf(!executablePath)("auth boundary failures and revocation (#1018)",()=>{ - test("failed snapshot is reported; revocation uses signed-out copy",async()=>{ + for (const priorAutosave of [false, true]) test(`failed snapshot is reported; revocation uses signed-out copy${priorAutosave ? "; prior autosave is held" : ""}`,async()=>{ const context=await browser!.newContext({viewport:{width:900,height:700}}); const page=await context.newPage(); try { await boot(page); await field(page).fill(DRAFT); await committed(page); await armHold(page,"abort"); await field(page).evaluate((el:HTMLTextAreaElement)=>el.setSelectionRange(2,5)); + if (priorAutosave) { + // Explicit ownership control: this native write predates auth revocation. + await page.waitForFunction(()=>(window as unknown as {__hold:{puts:number}}).__hold.puts>0); + expect(await fixture(page,f=>f.phase()),"held autosave precedes auth snapshot").toBe("active"); + } await expire(page,"revoke"); await page.waitForFunction(()=>(window as unknown as {__hold:{puts:number}}).__hold.puts>0); expect(await field(page).count(),"still mounted before abort").toBe(1); await page.evaluate(()=>(window as unknown as {__hold:{finish():void}}).__hold.finish()); await until(page,"f.phase() === 'locked' && f.accountKey() === null"); + await page.evaluate(()=>(window as unknown as {__hold:{restore():void}}).__hold.restore()); + expect(await fixture(page,f=>f.snapshotFailed()),"injected failure belongs to auth snapshot").toBe(true); + expect(await fixture(page,f=>f.hasSnapshot()),"failed auth snapshot is not restorable").toBe(false); expect(await page.getByText("Your draft couldn't be saved on this device.",{exact:true}).count(),"failed snapshot is reported on neutral reauth screen").toBe(1); expect(await field(page).count()).toBe(0); expect(await page.getByText("This device was signed out",{exact:true}).count()).toBe(1); @@ -396,7 +410,10 @@ describe.skipIf(!executablePath)("auth boundary failures and revocation (#1018)" expect(await navigation,"failed snapshot reloads even when account confirmation fails").toBe(true); expect(navigations,"same-account sign-in reloads after a failed snapshot").toBeGreaterThan(0); await page.unroute("**/api/vpn-check"); - } finally { await context.close(); } + } finally { + try { await page.evaluate(()=>(window as unknown as {__hold?:{restore():void}}).__hold?.restore()); } + finally { await context.close(); } + } },120_000); test("different account navigates and cannot read or restore prior drafts or audio",async()=>{ const context=await browser!.newContext({viewport:{width:900,height:700}}); const page=await context.newPage(); diff --git a/packages/ui-react/tests/fixtures/auth-expiry-client.ts b/packages/ui-react/tests/fixtures/auth-expiry-client.ts index a8ba30b6e5..ece2a2bc4d 100644 --- a/packages/ui-react/tests/fixtures/auth-expiry-client.ts +++ b/packages/ui-react/tests/fixtures/auth-expiry-client.ts @@ -32,6 +32,8 @@ const outcome = (p: Promise) => p.then((value) => ({ ok: true as const, Object.assign(window, { __local: { phase:()=>root.authLock.state.getState().phase, + snapshotFailed:()=>root.authLock.state.getState().snapshotFailed, + hasSnapshot:()=>root.authLock.hasSnapshot(), held:()=>updateHeld(root), recording:()=>root.recordings!.list(root.stores.connection.getState().accountKey ? `account:${root.stores.connection.getState().accountKey}` : "unassigned"), ended:()=>microphone.streams.flatMap((s)=>s.getTracks().map((t)=>t.readyState === "ended")), From 43a4b9da701185c22d4d82d8ddb5090b975d7700 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sat, 17 Oct 2026 20:17:20 +0200 Subject: [PATCH 05/15] feat(ci): prepare a fixed isolated build operation without activation --- docs/decisions/README.md | 1 + docs/decisions/ci-utility.md | 10 + docs/decisions/isolated-ci-build.md | 59 ++++++ scripts/build-execution.ts | 144 +++++++++++++ scripts/build.ts | 143 +------------ scripts/ci-build-boundary.ts | 171 ++++++++++++++++ scripts/ci-build-entry.ts | 58 ++++++ scripts/ci-build-operation.ts | 296 +++++++++++++++++++++++++++ scripts/ci-build-qualification.json | 13 ++ scripts/generated-build-inputs.ts | 7 +- tests/build-concurrency.test.ts | 4 +- tests/build-freshness.test.ts | 2 +- tests/ci-build-boundary.test.ts | 91 ++++++++ tests/generated-build-inputs.test.ts | 2 +- tests/generated-theme-inputs.test.ts | 2 +- tests/inclusion-inputs.test.ts | 2 +- tests/workspace-lease.test.ts | 2 +- 17 files changed, 864 insertions(+), 143 deletions(-) create mode 100644 docs/decisions/isolated-ci-build.md create mode 100644 scripts/build-execution.ts create mode 100644 scripts/ci-build-boundary.ts create mode 100644 scripts/ci-build-entry.ts create mode 100644 scripts/ci-build-operation.ts create mode 100644 scripts/ci-build-qualification.json create mode 100644 tests/ci-build-boundary.test.ts diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 805edc09b7..2745983873 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -13,6 +13,7 @@ somebody already learn the hard way?* | --- | --- | | [github-actions-ci.md](github-actions-ci.md) | GitHub Actions as the sole CI provider, with preserved automatic/local guarantees and safe fork execution. | | [ci-utility.md](ci-utility.md) | Complete affected hosted proof, cheap local feedback and concurrent ready-PR scheduling without removing tests or blocking unrelated merges. | +| [isolated-ci-build.md](isolated-ci-build.md) | One qualified fixed CI build operation, conservative fallback and actual input/output authority before unchanged-build reuse. | | [tag-aliases.md](tag-aliases.md) | No-go for the tested semantic alias pipeline: Jev recognizes the aliases, but candidate retrieval misses one found by the shipped audit skill. | | [example-corpus.md](example-corpus.md) | Odysseus as the sole example world across core, CLI, docs and presentation; separate technical representations and historical evidence. | | [design-kit.md](design-kit.md) | The design kit and the chat surface: D1–D53, dated. Component API, tokens, the light theme, the accessibility gate, the in-chat tool contracts, the classification pass, how the bridge tools reach the model, what a model-authored link may show, the follow-ups a model may offer, recoverable turn failures, the navigation, session-tracker and per-session draft design, ghost-text loading, and offline continuity with committed recording boundaries and measured browser limits. | diff --git a/docs/decisions/ci-utility.md b/docs/decisions/ci-utility.md index 96833f6a3f..05ae5a3fdd 100644 --- a/docs/decisions/ci-utility.md +++ b/docs/decisions/ci-utility.md @@ -672,3 +672,13 @@ The exact unit inventory is validated against default Bun discovery. It includes owning fast tests once, and uses at most the existing four partitions, reduced only when fewer files exist; no empty partition or duplicate execution substitutes for coverage. Selected categories remain required by the unchanged aggregate. + + +## Fixed CI build output reuse + +The [isolated build decision](isolated-ci-build.md) records the maintainer's +#2319 ruling: develop and qualify one fixed first-party operation, preserving +ordinary rebuilding for local and unsupported contexts. CI intent does not +authorize reuse. Complete effective inputs, drained output ownership, actual +consumer compatibility and comparable hosted cost must be qualified before +activation. This does not cache selected test results or relax their proof. diff --git a/docs/decisions/isolated-ci-build.md b/docs/decisions/isolated-ci-build.md new file mode 100644 index 0000000000..edc6d9e04e --- /dev/null +++ b/docs/decisions/isolated-ci-build.md @@ -0,0 +1,59 @@ +# Isolated CI build output reuse + +The [maintainer ruling on #2319](https://github.com/schlessera/brain-kit/issues/2319#issuecomment-6237827676) +selects one fixed first-party CI build operation. Ordinary local builds and +unsupported contexts rebuild without requiring Docker. This is build output +reuse, not reuse of test results or authority to omit selected proof. + +The previous stamp could authorize a skip after an effective compiler config +target or installed emitter changed. Package versions, link strings and a +larger code manifest do not establish everything an active producer reads. +The [completed #1837 investigation](https://github.com/schlessera/brain-kit/issues/1837#issuecomment-6236303704) +measured conditional duplicate work; its 606.090 seconds are executed builder +work, not measured saved elapsed time or cache eligibility. + +## Authority and fallback + +CI intent only requests the fixed operation. Positive reuse requires an exact +reviewed producer/configuration/installation/runtime qualification, a complete +captured input identity, a previous successful drained execution of that same +operation, and a matching current output tree including modes and bytes. +Changing source invalidates the input identity. Unsupported effective aliases, +changed executable configuration or installed producers, unavailable context +and ambiguous state require ordinary compilation. A real producer failure +propagates; it is not retried as an ordinary build to manufacture success. + +The fixed operation consumes captured bytes through read-only mounts, a fixed +environment and local-only executable resolution, without network acquisition. +Source and dependency snapshots, fresh writable state and outputs have separate +ownership. The original checkout, host scratch, credentials and Docker socket +are not producer mounts. The image identity covers its immutable filesystem; +it does not by itself qualify kernel interfaces, devices, clocks or random +sources. Output-sensitive ambient reads must be bounded by actual operation +qualification. Unknowns cannot become supported inputs through a hash or a +successful output sample. + +Workspace leases continue coordinating cooperating writers. They do not +restrict compiler reads. A private container operation has its own explicit +input boundary; the diagram export operation grants it no inherited authority. +Normal and isolated builds share the concrete compiler/copy/bin scheduler, +without an extension or plugin interface. The isolated entry receives only +validated manifest-selected pending packages. No public CLI or environment +bypass changes ordinary package selection. + +## Publication and evidence + +Only checked bytes from a completed, drained operation may replace workspace +outputs. Stamps live outside shipped package paths and follow verified +publication. Missing, modified, extra, aliased or mode-changed outputs refuse a +skip. Interrupted or failed execution cannot issue a successful receipt. +Records distinguish unsupported fallback, unqualified evidence, compilation and +reuse; an intent flag or synthetic fixture is never production qualification. + +Activation requires causal offline boundary and output controls, actual +unchanged child omission, config/installed-compiler/stylesheet invalidation, +source-marker/default-export freshness, all nineteen packed consumer inventories +and comparable hosted setup/critical-path measurements including identity and +publication overhead. The operation remains unqualified until those concrete +identities and evidence are reviewed. This requirement also binds later +producer, runtime, installed-input and configuration changes. diff --git a/scripts/build-execution.ts b/scripts/build-execution.ts new file mode 100644 index 0000000000..cea217ace1 --- /dev/null +++ b/scripts/build-execution.ts @@ -0,0 +1,144 @@ +/** Shared execution body of the ordinary builder and fixed CI operation. */ +import { cpSync, existsSync, rmSync } from "node:fs"; +import { resolve } from "node:path"; +import { buildReceipt } from "./build-receipt"; +import { markBinsExecutable } from "./bin-modes"; +import type { PublishablePackage } from "./publishable-packages"; + +export async function executeBuild(root: string, packages: PublishablePackage[], jobs: 1 | 2, subset = true): Promise { + const receipt = buildReceipt(root, { jobs, subset, packages: packages.map(pkg => pkg.dir) }); + const bunx = Bun.which("bunx"); + if (!bunx) { + console.error( + "Build failed: could not find `bunx` on PATH. Install Bun >= 1.3 from https://bun.sh." + ); + receipt.finish(1); + return 1; + } + + const compilerLauncher = bunx; + class PackageBuildFailure extends Error { + constructor(readonly packageName: string, readonly code: number, detail: string) { + super(`Build failed: packages/${packageName}: ${detail}`); + } + } + + async function buildPackageBody({ dir: packageName, manifest }: PublishablePackage): Promise { + const packageDir = resolve(root, "packages", packageName); + const distDir = resolve(packageDir, "dist"); + rmSync(distDir, { recursive: true, force: true }); + + console.log(`Building packages/${packageName}...`); + let subprocess: ReturnType; + try { + subprocess = Bun.spawn([compilerLauncher, "tsc", "-p", resolve(packageDir, "tsconfig.build.json")], { + cwd: root, + stdout: "inherit", + stderr: "inherit", + }); + } catch (error) { + console.error( + `Build failed: could not spawn \`bunx tsc\` for packages/${packageName}: ${(error as Error).message}` + ); + throw new PackageBuildFailure(packageName, 1, "could not spawn compiler"); + } + receipt.childStart(packageName, "tsc", subprocess.pid); + const exitCode = await subprocess.exited; + receipt.childExit(packageName, "tsc", exitCode); + if (exitCode !== 0) throw new PackageBuildFailure(packageName, exitCode, `compiler exited ${exitCode}`); + + if (packageName === "ui-react" || packageName === "ui-kit") { + // Precompiled stylesheet for consumers without a Tailwind build. The + // entry pins its scan root to the package src via @source, so the output + // is identical no matter where the CLI runs from. + const cssEntry = resolve(packageDir, "src", "styles.css"); + const cssOut = resolve(distDir, "styles.css"); + const css = Bun.spawn( + [compilerLauncher, "-p", "@tailwindcss/cli", "tailwindcss", "-i", cssEntry, "-o", cssOut, "--minify"], + { cwd: packageDir, stdout: "inherit", stderr: "inherit" } + ); + receipt.childStart(packageName, "css", css.pid); + const cssExit = await css.exited; + receipt.childExit(packageName, "css", cssExit); + if (cssExit !== 0) throw new PackageBuildFailure(packageName, cssExit, `stylesheet compiler exited ${cssExit}`); + + // The Tailwind-consumer theme entry ships from dist like every other + // export target, so a dist-only tarball keeps both CSS exports working. + // It is copied verbatim: consumers' Tailwind builds process it themselves. + cpSync(resolve(packageDir, "src", "theme.css"), resolve(distDir, "theme.css"), { + preserveTimestamps: true, + }); + // The kit's theme.css imports its tokens.css; both ship, and tokens.css is + // also the entry a consumer with its own Tailwind scale imports directly. + if (packageName === "ui-kit") { + cpSync(resolve(packageDir, "src", "tokens.css"), resolve(distDir, "tokens.css"), { + preserveTimestamps: true, + }); + } + } + + if (packageName === "core") { + const hooksSource = resolve(packageDir, "src", "hooks"); + if (!existsSync(hooksSource)) { + console.error(`Build failed: core hooks source directory is missing: ${hooksSource}`); + throw new PackageBuildFailure(packageName, 1, "hooks source directory is missing"); + } + cpSync(hooksSource, resolve(distDir, "hooks"), { + recursive: true, + preserveTimestamps: true, + }); + } + + // tsc writes the bin targets 0644; node_modules/.bin links straight to them. + try { + markBinsExecutable(packageDir, manifest); + } catch (error) { + console.error(`Build failed: packages/${packageName} declares a bin the build did not produce: ${(error as Error).message}`); + throw new PackageBuildFailure(packageName, 1, "declared bin output is missing"); + } + } + + + async function buildPackage(pkg: PublishablePackage): Promise { + receipt.packageStart(pkg.dir); + try { + await buildPackageBody(pkg); + receipt.packageEnd(pkg.dir, true); + } catch (error) { + receipt.packageEnd(pkg.dir, false); + throw error; + } + } + + let code = 0; + try { + if (jobs === 1) { + for (const pkg of packages) await buildPackage(pkg); + } else { + const pending = new Set(packages.map(pkg => pkg.name)); + const selected = new Set(pending); + while (pending.size) { + const level = packages.filter(pkg => pending.has(pkg.name) && + Object.keys({ ...pkg.manifest.dependencies, ...pkg.manifest.optionalDependencies }) + .every(name => name === pkg.name || !selected.has(name) || !pending.has(name))); + if (!level.length) throw new Error("Cannot schedule cyclic build dependencies"); + for (let index = 0; index < level.length; index += jobs) { + const batch = level.slice(index, index + jobs); + // Never exit/reject while a started peer still owns output/compiler work. + const results = await Promise.allSettled(batch.map(buildPackage)); + const errors = results.filter((result): result is PromiseRejectedResult => result.status === "rejected"); + if (errors.length) { + for (const error of errors.slice(1)) console.error(String(error.reason)); + throw errors[0]!.reason; + } + for (const pkg of batch) pending.delete(pkg.name); + } + } + } + } catch (error) { + console.error(String(error)); + code = error instanceof PackageBuildFailure ? error.code : 1; + } + receipt.finish(code); + return code; +} diff --git a/scripts/build.ts b/scripts/build.ts index 02ef391c00..13c966292c 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,7 +1,5 @@ -import { cpSync, existsSync, rmSync } from "fs"; import { resolve } from "path"; -import { buildReceipt } from "./build-receipt.ts"; -import { markBinsExecutable } from "./bin-modes.ts"; +import { executeBuild } from "./build-execution"; import { selectBuildPackages } from "./publishable-packages.ts"; import { ensureWorkspaceLease } from "./workspace-lease.mjs"; @@ -56,136 +54,11 @@ if (dryRun) { const coordinated = await ensureWorkspaceLease(root, "write"); if (coordinated !== undefined) process.exit(coordinated); -const receipt = buildReceipt(root, { jobs, subset: directories !== undefined, packages: packages.map(pkg => pkg.dir) }); -const bunx = Bun.which("bunx"); -if (!bunx) { - console.error( - "Build failed: could not find `bunx` on PATH. Install Bun >= 1.3 from https://bun.sh." - ); - receipt.finish(1); - process.exit(1); -} - -const compilerLauncher = bunx; -class PackageBuildFailure extends Error { - constructor(readonly packageName: string, readonly code: number, detail: string) { - super(`Build failed: packages/${packageName}: ${detail}`); - } -} - -async function buildPackageBody({ dir: packageName, manifest }: (typeof packages)[number]): Promise { - const packageDir = resolve(root, "packages", packageName); - const distDir = resolve(packageDir, "dist"); - rmSync(distDir, { recursive: true, force: true }); - - console.log(`Building packages/${packageName}...`); - let subprocess: ReturnType; - try { - subprocess = Bun.spawn([compilerLauncher, "tsc", "-p", resolve(packageDir, "tsconfig.build.json")], { - cwd: root, - stdout: "inherit", - stderr: "inherit", - }); - } catch (error) { - console.error( - `Build failed: could not spawn \`bunx tsc\` for packages/${packageName}: ${(error as Error).message}` - ); - throw new PackageBuildFailure(packageName, 1, "could not spawn compiler"); - } - receipt.childStart(packageName, "tsc", subprocess.pid); - const exitCode = await subprocess.exited; - receipt.childExit(packageName, "tsc", exitCode); - if (exitCode !== 0) throw new PackageBuildFailure(packageName, exitCode, `compiler exited ${exitCode}`); - - if (packageName === "ui-react" || packageName === "ui-kit") { - // Precompiled stylesheet for consumers without a Tailwind build. The - // entry pins its scan root to the package src via @source, so the output - // is identical no matter where the CLI runs from. - const cssEntry = resolve(packageDir, "src", "styles.css"); - const cssOut = resolve(distDir, "styles.css"); - const css = Bun.spawn( - [compilerLauncher, "-p", "@tailwindcss/cli", "tailwindcss", "-i", cssEntry, "-o", cssOut, "--minify"], - { cwd: packageDir, stdout: "inherit", stderr: "inherit" } - ); - receipt.childStart(packageName, "css", css.pid); - const cssExit = await css.exited; - receipt.childExit(packageName, "css", cssExit); - if (cssExit !== 0) throw new PackageBuildFailure(packageName, cssExit, `stylesheet compiler exited ${cssExit}`); - - // The Tailwind-consumer theme entry ships from dist like every other - // export target, so a dist-only tarball keeps both CSS exports working. - // It is copied verbatim: consumers' Tailwind builds process it themselves. - cpSync(resolve(packageDir, "src", "theme.css"), resolve(distDir, "theme.css"), { - preserveTimestamps: true, - }); - // The kit's theme.css imports its tokens.css; both ship, and tokens.css is - // also the entry a consumer with its own Tailwind scale imports directly. - if (packageName === "ui-kit") { - cpSync(resolve(packageDir, "src", "tokens.css"), resolve(distDir, "tokens.css"), { - preserveTimestamps: true, - }); - } - } - - if (packageName === "core") { - const hooksSource = resolve(packageDir, "src", "hooks"); - if (!existsSync(hooksSource)) { - console.error(`Build failed: core hooks source directory is missing: ${hooksSource}`); - throw new PackageBuildFailure(packageName, 1, "hooks source directory is missing"); - } - cpSync(hooksSource, resolve(distDir, "hooks"), { - recursive: true, - preserveTimestamps: true, - }); - } - - // tsc writes the bin targets 0644; node_modules/.bin links straight to them. - try { - markBinsExecutable(packageDir, manifest); - } catch (error) { - console.error(`Build failed: packages/${packageName} declares a bin the build did not produce: ${(error as Error).message}`); - throw new PackageBuildFailure(packageName, 1, "declared bin output is missing"); - } -} - - -async function buildPackage(pkg: (typeof packages)[number]): Promise { - receipt.packageStart(pkg.dir); - try { - await buildPackageBody(pkg); - receipt.packageEnd(pkg.dir, true); - } catch (error) { - receipt.packageEnd(pkg.dir, false); - throw error; - } -} - -try { - if (jobs === 1) { - for (const pkg of packages) await buildPackage(pkg); - } else { - const pending = new Set(packages.map(pkg => pkg.name)); - const selected = new Set(pending); - while (pending.size) { - const level = packages.filter(pkg => pending.has(pkg.name) && - Object.keys({ ...pkg.manifest.dependencies, ...pkg.manifest.optionalDependencies }) - .every(name => name === pkg.name || !selected.has(name) || !pending.has(name))); - if (!level.length) throw new Error("Cannot schedule cyclic build dependencies"); - for (let index = 0; index < level.length; index += jobs) { - const batch = level.slice(index, index + jobs); - // Never exit/reject while a started peer still owns output/compiler work. - const results = await Promise.allSettled(batch.map(buildPackage)); - const errors = results.filter((result): result is PromiseRejectedResult => result.status === "rejected"); - if (errors.length) { - for (const error of errors.slice(1)) console.error(String(error.reason)); - throw errors[0]!.reason; - } - for (const pkg of batch) pending.delete(pkg.name); - } - } - } -} catch (error) { - console.error(String(error)); - process.exitCode = error instanceof PackageBuildFailure ? error.code : 1; +// Intent requests this one operation; its independent qualification and actual +// captured input/output checks supply authority. Unqualified contexts rebuild. +if (process.env.BRAIN_CI_BUILD_OPERATION === "isolated-v1") { + const { attemptCiBuild } = await import("./ci-build-operation"); + const code = await attemptCiBuild(root, { directories, jobs: jobs as 1 | 2, force }); + if (code !== undefined) process.exit(code); } -receipt.finish(Number(process.exitCode ?? 0)); +process.exitCode = await executeBuild(root, packages, jobs as 1 | 2, directories !== undefined); diff --git a/scripts/ci-build-boundary.ts b/scripts/ci-build-boundary.ts new file mode 100644 index 0000000000..6825d5bc28 --- /dev/null +++ b/scripts/ci-build-boundary.ts @@ -0,0 +1,171 @@ +/** Captured files for the single isolated workspace-build operation. */ +import { createHash } from "node:crypto"; +import { chmodSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, symlinkSync, writeFileSync } from "node:fs"; +import { join, posix, resolve } from "node:path"; +import { listPublishablePackages } from "./publishable-packages"; + +export type BuildFile = { path: string; mode: number; kind: "directory" | "file" | "link"; sha256?: string; target?: string }; +export const digest = (bytes: string | Uint8Array) => createHash("sha256").update(bytes).digest("hex"); +export const identity = (value: unknown) => digest(JSON.stringify(value)); +export function safePath(path: string): string { + if (!path || path.includes("\\") || path.includes("\0") || posix.isAbsolute(path) || posix.normalize(path) !== path || path === ".." || path.startsWith("../")) throw new Error("Unsupported build path"); + return path; +} +const ignored = (path: string) => path === ".git" || path.startsWith(".git/") || path === "tmp" || path.startsWith("tmp/") || + /^packages\/[^/]+\/dist(?:\/|$)/.test(path) || path === "node_modules/.cache/brain-ci-build" || path.startsWith("node_modules/.cache/brain-ci-build/"); + +/** The bytes identified here, rather than a subsequent path read, are consumed. */ +export function captureBuildInputs(root: string, destination: string) { + const files = new Map(); + const source = Bun.spawnSync(["git", "--no-lazy-fetch", "--no-replace-objects", "-c", "core.fsmonitor=false", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { + cwd: root, env: { PATH: process.env.PATH ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_OPTIONAL_LOCKS: "0" }, stdout: "pipe", stderr: "pipe", + }); + if (source.exitCode !== 0) throw new Error("Build source inventory unavailable"); + mkdirSync(destination, { recursive: true, mode: 0o700 }); + function directory(path: string) { + if (path === "." || files.has(path)) return; + safePath(path); directory(posix.dirname(path)); + const stat = lstatSync(join(root, path)); + if (!stat.isDirectory()) throw new Error("Build input ancestor is not a directory"); + files.set(path, { path, kind: "directory", mode: stat.mode & 0o7777 }); + mkdirSync(join(destination, path), { mode: 0o700 }); + } + function capture(path: string, recurse: boolean) { + safePath(path); + if (ignored(path) || files.has(path)) return; + directory(posix.dirname(path)); + const from = join(root, path), to = join(destination, path), stat = lstatSync(from); + const mode = stat.mode & 0o7777; + if (stat.isDirectory()) { + directory(path); + if (recurse) for (const name of readdirSync(from).sort()) capture(`${path}/${name}`, true); + } else if (stat.isFile()) { + const bytes = readFileSync(from); + files.set(path, { path, mode, kind: "file", sha256: digest(bytes) }); + writeFileSync(to, bytes, { mode, flag: "wx" }); chmodSync(to, mode); + } else if (stat.isSymbolicLink()) { + const target = readlinkSync(from); + if (posix.isAbsolute(target) || target.includes("\\")) throw new Error("External build input link"); + files.set(path, { path, mode, kind: "link", target }); symlinkSync(target, to); + } else throw new Error("Unsupported build input type"); + } + for (const path of new TextDecoder().decode(source.stdout).split("\0").filter(Boolean).sort()) { + if (!/(^|\/)node_modules(?:\/|$)/.test(path)) capture(path, false); + } + const packages = listPublishablePackages(root); + const dependencies = ["node_modules", ...packages.map(pkg => `packages/${pkg.dir}/node_modules`)]; + for (const path of dependencies) { + const stat = lstatSync(join(root, path), { throwIfNoEntry: false }); + if (stat) { + if (!stat.isDirectory()) throw new Error("Installed dependency root must be a private directory"); + capture(path, true); + } + } + if (!files.has("node_modules")) throw new Error("Missing captured dependency installation"); + const outputAliases = new Set(packages.flatMap(pkg => Object.values(pkg.manifest.bin ?? {}).map(target => safePath(posix.normalize(`packages/${pkg.dir}/${target}`))))); + function effective(path: string): string { + const seen = new Set(); + for (let count = 0; count < 128; count++) { + safePath(path); + if (seen.has(path)) throw new Error("Cyclic build input link"); + seen.add(path); + const parts = path.split("/"); let changed = false; + for (let index = 1; index <= parts.length; index++) { + const prefix = parts.slice(0, index).join("/"), entry = files.get(prefix); + if (entry?.kind !== "link") continue; + path = posix.normalize(posix.join(posix.dirname(prefix), entry.target!, ...parts.slice(index))); + changed = true; break; + } + if (!changed) return path; + } + throw new Error("Unbounded build input link"); + } + for (const entry of files.values()) if (entry.kind === "link") { + const target = effective(entry.path); + if (!files.has(target) && !outputAliases.has(target)) throw new Error("Uncaptured build input link target"); + } + // Parents are initially writable only to materialize their children. They + // become read-only producer mounts, with their original modes recorded. + for (const entry of [...files.values()].reverse()) if (entry.kind === "directory") chmodSync(join(destination, entry.path), entry.mode); + const records = [...files.values()].sort((a, b) => a.path.localeCompare(b.path)); + const installation = records.filter(entry => /(^|\/)node_modules(?:\/|$)/.test(entry.path)); + return { records, installation: identity(installation), sha256: identity(records), packages: packages.map(pkg => pkg.dir) }; +} + +export function outputFiles(root: string): BuildFile[] { + const records: BuildFile[] = []; + function walk(path: string) { + const stat = lstatSync(join(root, path)); + if (stat.isDirectory()) { + records.push({ path, kind: "directory", mode: stat.mode & 0o7777 }); + for (const name of readdirSync(join(root, path)).sort()) walk(path ? `${path}/${name}` : name); + } else if (stat.isFile()) records.push({ path, kind: "file", mode: stat.mode & 0o7777, sha256: digest(readFileSync(join(root, path))) }); + else throw new Error("Unsupported build output type"); + } + walk(""); return records; +} +/** One necessary reuse predicate, never sufficient authority on its own. */ +export function sameOutputIdentity(root: string, expected: string): boolean { + try { return identity(outputFiles(root)) === expected; } catch { return false; } +} + +/** Publish checked bytes; no validation-then-copy reread of a mutable pathname. */ +export function captureOutputs(root: string, destination: string): BuildFile[] { + const records: BuildFile[] = []; + function walk(path: string) { + const from = join(root, path), to = join(destination, path), stat = lstatSync(from); + if (stat.isDirectory()) { + mkdirSync(to, { recursive: true, mode: 0o700 }); + records.push({ path, kind: "directory", mode: stat.mode & 0o7777 }); + for (const name of readdirSync(from).sort()) walk(path ? `${path}/${name}` : name); + chmodSync(to, stat.mode & 0o7777); + } else if (stat.isFile()) { + const bytes = readFileSync(from), mode = stat.mode & 0o7777; + writeFileSync(to, bytes, { mode, flag: "wx" }); chmodSync(to, mode); + records.push({ path, kind: "file", mode, sha256: digest(bytes) }); + } else throw new Error("Unsupported build output type"); + } + walk(""); return records; +} +/** Recheck the captured intermediate, not the earlier mutable host pathname. */ +export function captureDerivedOutput(root: string, destination: string, expected: string): BuildFile[] { + const records = captureOutputs(root, destination); + if (identity(records) !== expected) throw new Error("Derived build output changed during capture"); + return records; +} + +export type BuildMount = { source: string; target: string; readonly: boolean }; +export function buildMounts(input: string, state: string, packages: readonly string[], derived: Readonly> = {}) { + const mounts: BuildMount[] = []; + if (Object.keys(derived).some(pkg => !packages.includes(pkg))) throw new Error("Unknown derived build package"); + function mount(source: string, target: string, readonly: boolean) { + if (source.includes(",") || target.includes(",")) throw new Error("Unsupported Docker mount path"); + mounts.push({ source: resolve(source), target, readonly }); + } + for (const name of readdirSync(input).sort()) { + if (name === "packages") continue; + const source = join(input, name); + if (lstatSync(source).isSymbolicLink()) throw new Error("Top-level input alias needs separate qualification"); + mount(source, `/repo/${name}`, true); + } + const topology: Record = {}; + for (const pkg of packages) { + const parent = join(state, "parents", pkg); mkdirSync(parent, { recursive: true, mode: 0o700 }); + mount(parent, `/repo/packages/${pkg}`, false); + const names = readdirSync(join(input, "packages", pkg)).sort(); topology[pkg] = names; + for (const name of names) { + const source = join(input, "packages", pkg, name), stat = lstatSync(source); + if (stat.isSymbolicLink()) throw new Error("Package-root alias needs separate qualification"); + if (stat.isDirectory()) mkdirSync(join(parent, name)); else writeFileSync(join(parent, name), "", { flag: "wx" }); + mount(source, `/repo/packages/${pkg}/${name}`, true); + } + if (derived[pkg]) { + mkdirSync(join(parent, "dist")); + mount(derived[pkg]!, `/repo/packages/${pkg}/dist`, true); + topology[pkg] = [...names, "dist"].sort(); + } + } + const diagnostics = join(state, "diagnostics"); mkdirSync(diagnostics, { mode: 0o700 }); mount(diagnostics, "/repo/tmp", false); + return { mounts, topology, rootNames: [...readdirSync(input).filter(name => name !== "packages"), "packages", "tmp"].sort() }; +} + diff --git a/scripts/ci-build-entry.ts b/scripts/ci-build-entry.ts new file mode 100644 index 0000000000..5d708af3fd --- /dev/null +++ b/scripts/ci-build-entry.ts @@ -0,0 +1,58 @@ +/** The only program run by the fixed CI build container. Not a plugin host. */ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { digest } from "./ci-build-boundary"; +import { executeBuild } from "./build-execution"; +import { listPublishablePackages } from "./publishable-packages"; +import { ensureWorkspaceLease } from "./workspace-lease.mjs"; + +type Entry = { + version: 1; originalRoot: string; canary: string; selected: string[]; jobs: 1 | 2; + topology: Record; rootNames: string[]; node: string; + readonlyTargets: string[]; writableTargets: string[]; +}; +if (import.meta.main) { + const coordinated = await ensureWorkspaceLease("/repo", "write"); + if (coordinated !== undefined) process.exit(coordinated); + process.umask(0o022); + const entry = JSON.parse(readFileSync("/operation/entry.json", "utf8")) as Entry; + assert.equal(entry.version, 1); + assert(!existsSync(entry.originalRoot), "CI_BUILD_ORIGINAL_CHECKOUT_UNMOUNTED"); + assert(!existsSync(entry.canary), "CI_BUILD_HOST_DATA_UNMOUNTED"); + const mounts = readFileSync("/proc/self/mountinfo", "utf8").trim().split("\n").map(line => { + const parts = line.split(" "), separator = parts.indexOf("-"); + assert(separator > 5, "CI_BUILD_MOUNTINFO_FORMAT"); + const target = parts[4]!.replace(/\\([0-7]{3})/g, (_, value: string) => String.fromCharCode(parseInt(value, 8))); + return { target, options: parts[5]!, type: parts[separator + 1]!, rootIdentity: digest(parts[3]!) }; + }); + for (const target of entry.readonlyTargets) assert(mounts.some(mount => mount.target === target && mount.options.split(",").includes("ro")), "CI_BUILD_SOURCE_MOUNT_READONLY"); + for (const target of entry.writableTargets) assert(mounts.some(mount => mount.target === target && mount.options.split(",").includes("rw")), "CI_BUILD_ONLY_PRIVATE_WRITABLE_MOUNT"); + assert(mounts.some(mount => mount.target === "/" && mount.options.split(",").includes("ro")), "CI_BUILD_IMAGE_ROOT_READONLY"); + assert.equal(Bun.which("node"), entry.node, "CI_BUILD_NODE_LOOKUP_FIXED"); + assert.equal(Bun.which("bunx"), "/tool/bunx", "CI_BUILD_LAUNCHER_LOOKUP_FIXED"); + assert.equal(realpathSync("/repo/node_modules/.bin/tsc"), "/repo/node_modules/typescript/bin/tsc", "CI_BUILD_LOCAL_TYPESCRIPT_OWNER"); + assert.equal(realpathSync("/repo/node_modules/.bin/tailwindcss"), "/repo/node_modules/@tailwindcss/cli/dist/index.mjs", "CI_BUILD_LOCAL_CSS_OWNER"); + assert.deepEqual(readdirSync("/repo").sort(), entry.rootNames, "CI_BUILD_ROOT_TOPOLOGY"); + assert.deepEqual(readdirSync("/repo/packages").sort(), Object.keys(entry.topology).sort(), "CI_BUILD_PACKAGE_TOPOLOGY"); + for (const [pkg, names] of Object.entries(entry.topology)) { + assert.deepEqual(readdirSync(`/repo/packages/${pkg}`).sort(), names, "CI_BUILD_INITIAL_PACKAGE_TOPOLOGY"); + } + const runtime = { bun: digest(readFileSync("/tool/bun")), node: { path: entry.node, sha256: digest(readFileSync(entry.node)) } }; + mkdirSync("/tmp/home", { mode: 0o700 }); mkdirSync("/tmp/cache", { mode: 0o700 }); + process.env.TMPDIR = "/tmp"; process.env.XDG_CACHE_HOME = "/tmp/cache"; + const packages = listPublishablePackages("/repo").filter(pkg => entry.selected.includes(pkg.dir)); + assert.equal(packages.length, entry.selected.length, "CI_BUILD_ONLY_VALIDATED_PENDING_PACKAGES"); + const code = await executeBuild("/repo", packages, entry.jobs); + assert.deepEqual(readdirSync("/repo").sort(), entry.rootNames, "CI_BUILD_NO_UNKNOWN_ROOT_OUTPUT"); + assert.deepEqual(readdirSync("/repo/packages").sort(), Object.keys(entry.topology).sort(), "CI_BUILD_NO_UNKNOWN_PACKAGE"); + for (const [pkg, names] of Object.entries(entry.topology)) { + const expected = [...names, ...(entry.selected.includes(pkg) ? ["dist"] : [])].sort(); + assert.deepEqual(readdirSync(`/repo/packages/${pkg}`).sort(), expected, "CI_BUILD_NO_UNKNOWN_PACKAGE_OUTPUT"); + assert.equal(statSync(`/repo/packages/${pkg}`).mode & 0o777, 0o700, "CI_BUILD_PRIVATE_PARENT_MODE"); + } + const receipts = readdirSync("/repo/tmp").filter(name => /^build-\d+-[0-9a-f-]+-probe\.json$/.test(name)).sort() + .map(name => ({ name, value: JSON.parse(readFileSync(join("/repo/tmp", name), "utf8")) })); + writeFileSync("/repo/tmp/isolated-producer.json", JSON.stringify({ version: 1, code, runtime, receipts, mounts, topology: "checked-after-child-exit" })); + process.exitCode = code; +} diff --git a/scripts/ci-build-operation.ts b/scripts/ci-build-operation.ts new file mode 100644 index 0000000000..5e38c36663 --- /dev/null +++ b/scripts/ci-build-operation.ts @@ -0,0 +1,296 @@ +/** One dormant, fixed isolated build operation. CI intent is not authority. */ +import { randomUUID } from "node:crypto"; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; +import { buildMounts, captureBuildInputs, captureDerivedOutput, captureOutputs, digest, identity, outputFiles, sameOutputIdentity, type BuildFile } from "./ci-build-boundary"; +import { selectBuildPackages } from "./publishable-packages"; +import { ensureWorkspaceLease } from "./workspace-lease.mjs"; + +type Options = { directories?: string[]; jobs: 1 | 2; force: boolean }; +type Qualification = { + version: 1; activation: "unqualified" | "qualified"; image: string; + producer: Record; configuration: Record; + installation: string | null; bun: string | null; node: { path: string; sha256: string | null }; + ambientEvidence: string | null; compatibilityEvidence: string | null; hostedCostEvidence: string | null; +}; +const policyPath = join(import.meta.dir, "ci-build-qualification.json"); +const fixedImage = "mcr.microsoft.com/playwright@sha256:eff16c30e6f3f4af0a03fa4b706120d5e9b0891c344a27d64559aff5900a4a27"; +const producerPaths = ["scripts/build.ts", "scripts/build-execution.ts", "scripts/build-receipt.ts", "scripts/bin-modes.ts", "scripts/publishable-packages.ts", "scripts/workspace-lease.mjs", + "scripts/ci-build-boundary.ts", "scripts/ci-build-operation.ts", "scripts/ci-build-entry.ts"]; +const configuration = (entry: BuildFile) => !/(^|\/)node_modules(?:\/|$)/.test(entry.path) && + (/(^|\/)package\.json$/.test(entry.path) || /(^|\/)tsconfig[^/]*\.json$/.test(entry.path) || /(^|\/)bunfig\.toml$/.test(entry.path) || entry.path.endsWith(".css")); +const launcher = '#!/bin/sh\nexec /tool/bun x --no-install "$@"\n'; +function readPolicy(): Qualification { + const policy = JSON.parse(readFileSync(policyPath, "utf8")) as Qualification; + if (policy.image !== fixedImage || policy.node.path !== "/usr/bin/node") throw new Error("Unknown fixed build operation"); + return policy; +} +function complete(policy: Qualification): boolean { + return policy.version === 1 && policy.activation === "qualified" && + /^mcr\.microsoft\.com\/playwright@sha256:[0-9a-f]{64}$/.test(policy.image) && + [policy.installation, policy.bun, policy.node.sha256, policy.ambientEvidence, policy.compatibilityEvidence, policy.hostedCostEvidence].every(value => typeof value === "string" && /^[0-9a-f]{64}$/.test(value)) && + policy.node.path === "/usr/bin/node" && Object.keys(policy.producer).sort().join("\0") === [...producerPaths].sort().join("\0") && Object.keys(policy.configuration).length > 0; +} +function policyMatches(policy: Qualification, captured: ReturnType, bun: string): boolean { + if (!complete(policy) || policy.installation !== captured.installation || policy.bun !== bun) return false; + const byPath = new Map(captured.records.map(entry => [entry.path, entry])); + if (!producerPaths.every(path => byPath.get(path)?.kind === "file" && policy.producer[path] === identity(byPath.get(path)))) return false; + const actual = Object.fromEntries(captured.records.filter(configuration).map(entry => [entry.path, identity(entry)])); + return identity(actual) === identity(Object.fromEntries(Object.entries(policy.configuration).sort(([a], [b]) => a.localeCompare(b)))); +} + +function docker(config: string, ...argv: string[]) { + // The daemon selection/configuration is also fixed: no remote context, + // inherited registry credentials, CLI plugins or user Docker configuration. + const result = Bun.spawnSync(["/usr/bin/docker", "--host", "unix:///var/run/docker.sock", "--config", config, ...argv], { stdout: "pipe", stderr: "pipe", env: { PATH: "/usr/bin:/bin" } }); + if (result.exitCode !== 0) throw new Error(`Fixed build Docker ${argv[0]} failed`); + return new TextDecoder().decode(result.stdout).trim(); +} +function imagePresent(image: string, config: string) { + const records = JSON.parse(docker(config, "image", "inspect", image)) as Array<{ RepoDigests?: string[] }>; + if (records.length !== 1 || !records[0]!.RepoDigests?.includes(image)) throw new Error("Fixed build image manifest is unavailable"); +} +function workspaceScope(root: string) { + if (process.platform !== "linux" || process.arch !== "x64") throw new Error("Unsupported fixed build platform"); + const directory = lstatSync(root), socket = lstatSync("/var/run/docker.sock"); + if (!directory.isDirectory() || !socket.isSocket()) throw new Error("Unsupported fixed build ownership"); + return { root: realpathSync(root), directory: { dev: directory.dev, ino: directory.ino }, uid: process.getuid!(), gid: process.getgid!(), + boot: digest(readFileSync("/proc/sys/kernel/random/boot_id")), kernel: digest(readFileSync("/proc/sys/kernel/osrelease")), + dockerSocket: { dev: socket.dev, ino: socket.ino }, platform: process.platform, arch: process.arch }; +} +function operationReceipt(root: string, value: unknown) { + // Observations cannot turn a safe ordinary fallback into a build failure. + // Successful reuse authority is persisted separately and is mandatory. + try { + const path = join(root, "tmp", `ci-build-operation-${randomUUID()}-probe.json`); mkdirSync(join(root, "tmp"), { recursive: true }); + writeFileSync(`${path}.writing`, JSON.stringify(value)); renameSync(`${path}.writing`, path); + return path; + } catch { console.error("Isolated CI build observation unavailable."); } +} +function ownedDirectory(root: string, path: string, privateMode = false) { + const suffix = relative(root, path); + if (!suffix || suffix.startsWith("..")) throw new Error("Invalid owned build state"); + let current = root; + for (const name of suffix.split("/")) { + current = join(current, name); + const before = lstatSync(current, { throwIfNoEntry: false }); + if (!before) mkdirSync(current, { mode: current === path && privateMode ? 0o700 : 0o755 }); + const stat = lstatSync(current); + if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid!()) throw new Error("Build state is not privately owned"); + } + if (privateMode && (lstatSync(path).mode & 0o777) !== 0o700) throw new Error("Build state permissions are not private"); +} +function ownedJSON(path: string) { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== process.getuid!() || stat.size > 16 * 1024 * 1024) throw new Error("Invalid owned build receipt"); + const bytes = readFileSync(path); + return { sha256: digest(bytes), value: JSON.parse(bytes.toString("utf8")) }; +} +function successfulProducer(producer: any, selected: string[], policy: Qualification, bun: string, qualificationOnly: boolean): boolean { + if (producer?.version !== 1 || producer.code !== 0 || producer.runtime?.bun !== bun || producer.runtime?.node?.path !== policy.node.path || + (!qualificationOnly && producer.runtime?.node?.sha256 !== policy.node.sha256) || producer.topology !== "checked-after-child-exit" || producer.receipts?.length !== 1) return false; + const receipt = producer.receipts[0]?.value; + return receipt?.kind === "workspace-build" && receipt.state === "complete" && receipt.success === true && receipt.code === 0 && + Array.isArray(receipt.packages) && identity(receipt.packages.map((pkg: any) => pkg.name).sort()) === identity([...selected].sort()) && + receipt.packages.every((pkg: any) => pkg.state === "complete" && Array.isArray(pkg.children) && + pkg.children.length === (["ui-kit", "ui-react"].includes(pkg.name) ? 2 : 1) && + pkg.children.filter((child: any) => child.compiler === "tsc" && child.code === 0).length === 1 && + pkg.children.filter((child: any) => child.compiler === "css" && child.code === 0).length === (["ui-kit", "ui-react"].includes(pkg.name) ? 1 : 0)); +} +function writableCleanup(path: string) { + if (!existsSync(path)) return; + const stat = lstatSync(path); + if (stat.isDirectory()) { + chmodSync(path, (stat.mode & 0o777) | 0o700); + for (const name of readdirSync(path)) writableCleanup(join(path, name)); + } + rmSync(path, { recursive: true }); +} + +/** Invoked only after the normal host workspace write lease is acquired. */ +export async function attemptCiBuild(root: string, options: Options): Promise { + if (process.env.BRAIN_CI_BUILD_OPERATION !== "isolated-v1") return; + if (["NODE_OPTIONS", "NODE_PATH", "NAPI_RS_NATIVE_LIBRARY_PATH", "LD_PRELOAD", "LD_LIBRARY_PATH", "BUN_OPTIONS", "BUN_INSTALL_CACHE_DIR"].some(name => process.env[name] !== undefined)) { + operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: "unsupported", action: "ordinary-rebuild", reason: "producer-runtime-override" }); + return; + } + let policy: Qualification; + try { policy = readPolicy(); } + catch { + operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: "unsupported", action: "ordinary-rebuild", reason: "qualification-unavailable" }); + return; + } + if (!complete(policy)) { + console.log("Isolated CI build is unqualified; rebuilding normally."); + operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: "unqualified", action: "ordinary-rebuild", reason: "qualification-unavailable" }); + return; + } + return await execute(root, options, policy, false); +} + +async function execute(root: string, options: Options, policy: Qualification, qualificationOnly: boolean): Promise { + const started = performance.now(), session = randomUUID(), name = `brain-ci-build-${session}`; + const selected = selectBuildPackages(root, options.directories).map(pkg => pkg.dir); + let state: string, dockerConfig: string; + try { + const directory = join(root, "tmp", "isolated-ci-build"); ownedDirectory(root, directory, true); + state = mkdtempSync(join(directory, "operation-")); + dockerConfig = join(state, "docker-config"); mkdirSync(dockerConfig, { mode: 0o700 }); + } catch (error) { + if (qualificationOnly) throw error; + operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: "unsupported", action: "ordinary-rebuild", reason: "private-state-unavailable" }); + return; + } + let preserve = qualificationOnly, containerKnown = false, drained = false; + const phases: Record = {}; + try { + const captureStart = performance.now(), input = join(state, "input"); + const captured = captureBuildInputs(root, input); + const bun = readFileSync(realpathSync(process.execPath)), bunHash = digest(bun); + phases.captureMs = performance.now() - captureStart; + if (!qualificationOnly && !policyMatches(policy, captured, bunHash)) { + operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: "unsupported", action: "ordinary-rebuild", reason: "captured-policy-mismatch", phases }); + return; + } + imagePresent(policy.image, dockerConfig); + const scope = workspaceScope(root); + const key = identity({ version: 1, inputs: captured.sha256, image: policy.image, bun: bunHash, bunVersion: Bun.version, policy: identity(policy), jobs: options.jobs, scope }); + const stampDirectory = join(root, "tmp", "ci-build-cache"); + if (!qualificationOnly) ownedDirectory(root, stampDirectory, true); + const reused: string[] = [], pending: string[] = [], previousOutputs: Record = {}; + for (const pkg of selected) { + let matches = false; + if (!qualificationOnly && !options.force) try { + const stamp = ownedJSON(join(stampDirectory, `${pkg}.json`)).value; + if (!/^[0-9a-f-]{36}$/.test(stamp.producerSession)) throw new Error("Missing producer ownership"); + const producerReceipt = ownedJSON(join(stampDirectory, `${stamp.producerSession}.json`)); + const prior = producerReceipt.value; + matches = stamp.version === 1 && stamp.key === key && stamp.operation === "fixed-isolated-ci-v1" && + producerReceipt.sha256 === stamp.receipt && prior.version === 1 && prior.operation === stamp.operation && + prior.session === stamp.producerSession && prior.key === key && prior.image === policy.image && prior.drained === true && + Array.isArray(prior.compiled) && prior.compiled.includes(pkg) && prior.outputs?.[pkg] === stamp.output && + successfulProducer(prior.producer, prior.compiled, policy, bunHash, false) && + sameOutputIdentity(join(root, "packages", pkg, "dist"), stamp.output); + if (matches) previousOutputs[pkg] = stamp.output; + } catch { /* Missing, partial or changed output is a miss. */ } + (matches ? reused : pending).push(pkg); + } + phases.lookupMs = performance.now() - captureStart - phases.captureMs!; + for (const pkg of reused) console.log(`Up to date packages/${pkg} (qualified isolated operation)`); + if (!pending.length) { + operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: "reused", key, selected, reused, compiled: [], phases, elapsedMs: performance.now() - started }); + return 0; + } + // CSS package exports can consume another selected package's dist. These + // are checked, same-key derived intermediates, never arbitrary host output. + const derived: Record = {}, derivedIdentities: Record = {}; + for (const pkg of reused) { + const destination = join(state, "derived", pkg); + const records = captureDerivedOutput(join(root, "packages", pkg, "dist"), destination, previousOutputs[pkg]!); + derived[pkg] = destination; derivedIdentities[pkg] = identity(records); + } + const { mounts, topology, rootNames } = buildMounts(input, state, captured.packages, derived); + const tool = join(state, "tool"); mkdirSync(tool, { mode: 0o700 }); + writeFileSync(join(tool, "bun"), bun, { mode: 0o755, flag: "wx" }); chmodSync(join(tool, "bun"), 0o755); + writeFileSync(join(tool, "bunx"), launcher, { mode: 0o755, flag: "wx" }); chmodSync(join(tool, "bunx"), 0o755); + const hostFiles = join(state, "host-files"); mkdirSync(hostFiles, { mode: 0o700 }); + for (const [file, bytes] of Object.entries({ hosts: "127.0.0.1 localhost brain-build\n::1 localhost\n", hostname: "brain-build\n", "resolv.conf": "" })) writeFileSync(join(hostFiles, file), bytes, { flag: "wx" }); + const entryPath = join(state, "entry.json"), canary = join(state, "ambient-canary"); writeFileSync(canary, "Odysseus stays outside the build"); + const uid = process.getuid!(), gid = process.getgid!(); + const argv = ["run", "--name", name, "--label", `brain-ci-build-session=${session}`, "--pull=never", "--read-only", "--network=none", "--cap-drop=ALL", "--security-opt=no-new-privileges", "--user", `${uid}:${gid}`, "--hostname", "brain-build", "--cpus=2", "--memory=4g", "--pids-limit=256", "--shm-size=64m", + "--tmpfs", `/repo:rw,nosuid,nodev,mode=0700,uid=${uid},gid=${gid},size=64m`, "--tmpfs", "/tmp:rw,exec,nosuid,nodev,mode=1777,size=256m"]; + const allMounts = [...mounts, { source: tool, target: "/tool", readonly: true }, { source: entryPath, target: "/operation/entry.json", readonly: true }, + ...["hosts", "hostname", "resolv.conf"].map(file => ({ source: join(hostFiles, file), target: `/etc/${file}`, readonly: true }))]; + writeFileSync(entryPath, JSON.stringify({ version: 1, originalRoot: root, canary, selected: pending, jobs: options.jobs, topology, rootNames, node: policy.node.path, + readonlyTargets: allMounts.filter(mount => mount.readonly).map(mount => mount.target), writableTargets: allMounts.filter(mount => !mount.readonly).map(mount => mount.target) })); + for (const mount of allMounts) { + if (/[\r\n,]/.test(mount.source + mount.target)) throw new Error("Unsupported fixed build mount path"); + argv.push("--mount", `type=bind,src=${mount.source},dst=${mount.target}${mount.readonly ? ",readonly" : ""}`); + } + argv.push("--workdir", "/repo", policy.image, "/usr/bin/env", "-i", "PATH=/tool:/usr/bin:/bin", "HOME=/tmp/home", "LANG=C.UTF-8", "LC_ALL=C.UTF-8", "TZ=UTC", "/tool/bun", "/repo/scripts/ci-build-entry.ts"); + writeFileSync(join(state, "invocation.json"), JSON.stringify({ version: 1, session, key, argv, mounts: allMounts, captured, derived: derivedIdentities, runtime: { image: policy.image, bun: bunHash }, scope, qualificationOnly }, null, 2)); + const compileStart = performance.now(); + const child = Bun.spawn(["/usr/bin/docker", "--host", "unix:///var/run/docker.sock", "--config", dockerConfig, ...argv], { stdout: "inherit", stderr: "inherit", env: { PATH: "/usr/bin:/bin" } }); + containerKnown = true; + let interrupted = false; + const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const; + const interrupt = () => { interrupted = true; try { docker(dockerConfig, "stop", "--time", "5", name); } catch { /* Finally retains state unless drain is verified. */ } }; + for (const signal of signals) process.on(signal, interrupt); + let code: number; + try { code = await child.exited; } finally { for (const signal of signals) process.off(signal, interrupt); } + phases.producerMs = performance.now() - compileStart; + const status = JSON.parse(docker(dockerConfig, "inspect", name)) as Array<{ State: { Running: boolean }; Config: { Image: string; Labels: Record }; + HostConfig: { ReadonlyRootfs: boolean; NetworkMode: string; Privileged: boolean; CapDrop: string[]; SecurityOpt: string[]; Tmpfs: Record }; + Mounts: Array<{ Type: string; Source: string; Destination: string; RW: boolean }> }>; + if (status.length !== 1 || status[0]!.Config.Labels["brain-ci-build-session"] !== session || status[0]!.State.Running) throw new Error("Build container did not drain"); + drained = true; + const actual = status[0]!, host = actual.HostConfig; + if (actual.Config.Image !== policy.image || host.ReadonlyRootfs !== true || host.NetworkMode !== "none" || host.Privileged !== false || + !host.CapDrop?.includes("ALL") || !host.SecurityOpt?.some(value => value === "no-new-privileges" || value === "no-new-privileges=true") || + identity(Object.keys(host.Tmpfs ?? {}).sort()) !== identity(["/repo", "/tmp"])) throw new Error("Actual build container boundary changed"); + const actualBinds = actual.Mounts.filter(mount => mount.Type === "bind").map(mount => ({ source: mount.Source, target: mount.Destination, readonly: !mount.RW })).sort((a, b) => a.target.localeCompare(b.target)); + if (actual.Mounts.some(mount => !["bind", "tmpfs"].includes(mount.Type)) || identity(actualBinds) !== identity([...allMounts].sort((a, b) => a.target.localeCompare(b.target)))) throw new Error("Actual build mounts differ from captured inputs"); + if (interrupted || code !== 0) throw new Error(`Isolated compiler failed (${code})`); + const producer = JSON.parse(readFileSync(join(state, "diagnostics/isolated-producer.json"), "utf8")); + if (!successfulProducer(producer, pending, policy, bunHash, qualificationOnly)) throw new Error("Consumed runtime or compiler receipt mismatch"); + const outputs: Record = {}, publishStart = performance.now(); + for (const pkg of pending) { + const staged = join(state, "validated", pkg); + outputs[pkg] = identity(captureOutputs(join(state, "parents", pkg, "dist"), staged)); + } + if (!qualificationOnly) { + for (const pkg of pending) { + const staged = join(state, "validated", pkg); + const destination = join(root, "packages", pkg, "dist"); + rmSync(destination, { recursive: true, force: true }); renameSync(staged, destination); + if (identity(outputFiles(destination)) !== outputs[pkg]) throw new Error("Published build output changed"); + } + // No new package stamp is written until every selected publication and + // its verification have completed after the real container drained. + const record = JSON.stringify({ version: 1, operation: "fixed-isolated-ci-v1", session, key, image: policy.image, drained: true, compiled: pending, derived: derivedIdentities, outputs, producer }); + writeFileSync(join(stampDirectory, `${session}.json`), record, { flag: "wx", mode: 0o600 }); + for (const pkg of pending) { + const stamp = join(stampDirectory, `${pkg}.json`); + writeFileSync(`${stamp}.writing`, JSON.stringify({ version: 1, operation: "fixed-isolated-ci-v1", key, output: outputs[pkg], producerSession: session, receipt: digest(record) }), { mode: 0o600 }); renameSync(`${stamp}.writing`, stamp); + } + } + phases.publishMs = performance.now() - publishStart; + operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: qualificationOnly ? "unqualified" : "compiled", action: qualificationOnly ? "qualification-only" : "publish", session, key, selected, reused, compiled: pending, outputs, producer, phases, elapsedMs: performance.now() - started }); + if (qualificationOnly) console.log(`Unqualified build artifacts retained at ${state}; no reuse stamp or workspace output published.`); + return 0; + } catch (error) { + operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: containerKnown ? "failed" : "unsupported", action: containerKnown ? "fail" : "ordinary-rebuild", reason: containerKnown ? "producer-incomplete" : "boundary-unavailable", phases }); + if (!containerKnown && !qualificationOnly) return; + throw error; + } finally { + if (containerKnown) { + try { + const status = JSON.parse(docker(dockerConfig, "inspect", name)) as Array<{ State: { Running: boolean }; Config: { Labels: Record } }>; + if (status.length !== 1 || status[0]!.Config.Labels["brain-ci-build-session"] !== session) throw new Error("Unknown container ownership"); + if (status[0]!.State.Running) docker(dockerConfig, "stop", "--time", "5", name); + docker(dockerConfig, "wait", name); drained = true; + docker(dockerConfig, "rm", name); + } catch { preserve = true; } + } + if (containerKnown && !drained) preserve = true; + if (!preserve) writableCleanup(state); + } +} + +if (import.meta.main) { + const args = process.argv.slice(2); + if (args.shift() !== "--qualify") throw new Error("Only the fixed --qualify operation is available"); + const options: Options = { jobs: 1, force: true }; + for (let index = 0; index < args.length; index++) { + const arg = args[index]!; + if (arg === "--for" && options.directories === undefined && args[index + 1] && !args[index + 1]!.startsWith("--")) options.directories = args[++index]!.split(","); + else if (/^--jobs=[12]$/.test(arg)) options.jobs = Number(arg.slice(7)) as 1 | 2; + else throw new Error("Unsupported fixed qualification argument"); + } + const root = resolve(import.meta.dir, ".."); + const coordinated = await ensureWorkspaceLease(root, "write"); + if (coordinated !== undefined) process.exit(coordinated); + // This path emits evidence only. It cannot authorize reuse, write stamps, + // change qualification, or replace the ordinary workspace's dist outputs. + process.exitCode = await execute(root, options, readPolicy(), true) ?? 1; +} diff --git a/scripts/ci-build-qualification.json b/scripts/ci-build-qualification.json new file mode 100644 index 0000000000..4b8c8eb1b7 --- /dev/null +++ b/scripts/ci-build-qualification.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "activation": "unqualified", + "image": "mcr.microsoft.com/playwright@sha256:eff16c30e6f3f4af0a03fa4b706120d5e9b0891c344a27d64559aff5900a4a27", + "producer": {}, + "configuration": {}, + "installation": null, + "bun": null, + "node": { "path": "/usr/bin/node", "sha256": null }, + "ambientEvidence": null, + "compatibilityEvidence": null, + "hostedCostEvidence": null +} diff --git a/scripts/generated-build-inputs.ts b/scripts/generated-build-inputs.ts index 7a6964bb68..6a5c3d5135 100644 --- a/scripts/generated-build-inputs.ts +++ b/scripts/generated-build-inputs.ts @@ -6,7 +6,12 @@ import { isAbsolute, relative, resolve } from "node:path"; // Tailwind CLI, build-before-browser order, checked exits and post-success receipt. // This deliberately supports one audited producer, not arbitrary build tools. const auditedProducer: Record = { - "scripts/build.ts": "95c40d174c0dfa8d2f8f317a3c2236fecbba600faaaf3ebecb7a96dbd94c392d", + "scripts/ci-build-qualification.json": "31044cc0055d43353ae235daf56f88b4d24f40fef9ea5aac96f69421f5c20fc6", + "scripts/ci-build-entry.ts": "1c7ab967983e79caba48ffb342a9ef062f67dbea63dda15046c3baab4458c3bd", + "scripts/ci-build-operation.ts": "2cb7a8109551dadd81bcb9f9958ba26bdf987cee1c3f6a4a4ca65890da9facf2", + "scripts/ci-build-boundary.ts": "2ef58d24f20a410150a48144382bc5b053d482fc378de2bd6abece28833ae54f", + "scripts/build-execution.ts": "17ee42de1b37aa8034c6b0719b32ec432ac8fde321fb9c2c3a426799ea996a5e", + "scripts/build.ts": "30c1ecc7272921efe4e437b23e3d088e03d38869a9f5b2365fc08592e248d8d8", "scripts/build-receipt.ts": "567c3d13b5e3e1295f597e4f57a9d0d9912685f2669cf3872daa26c2931bd1e6", "scripts/bin-modes.ts": "5e412f4743b388558a00d1068881ff6d6a0916e852e9ed2e973bc0a97a83c8ce", "scripts/publishable-packages.ts": "c43a3fbea72217fc42c3514313dcb16d1b337242fc251b61d7f1a3c83ca25740", diff --git a/tests/build-concurrency.test.ts b/tests/build-concurrency.test.ts index e59d860062..5daf0ebd11 100644 --- a/tests/build-concurrency.test.ts +++ b/tests/build-concurrency.test.ts @@ -36,7 +36,7 @@ async function compilerDrain(storageFailure?: StorageFailure) { const release = join(root, "release"); try { mkdirSync(join(root, "scripts")); - for (const file of ["build.ts", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { + for (const file of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { copyFileSync(join(repo, "scripts", file), join(root, "scripts", file)); } mkdirSync(join(root, "node_modules/.bin"), { recursive: true }); @@ -127,7 +127,7 @@ async function successfulBuild(storageFailure?: StorageFailure) { const root = mkdtempSync(join(tmpdir(), "odysseus-compiler-levels-")); try { mkdirSync(join(root, "scripts")); - for (const file of ["build.ts", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { + for (const file of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { copyFileSync(join(repo, "scripts", file), join(root, "scripts", file)); } mkdirSync(join(root, "node_modules/.bin"), { recursive: true }); diff --git a/tests/build-freshness.test.ts b/tests/build-freshness.test.ts index d74bcf50a6..b57ab84d24 100644 --- a/tests/build-freshness.test.ts +++ b/tests/build-freshness.test.ts @@ -18,7 +18,7 @@ function fixture() { write(root, "bun.lock", "{}\n"); write(root, "tsconfig.json", JSON.stringify({ compilerOptions: { target: "ES2019", module: "ESNext", moduleResolution: "Bundler", types: [], skipLibCheck: true } })); mkdirSync(join(root, "scripts")); - for (const file of ["build.ts", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { + for (const file of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { copyFileSync(join(repo, "scripts", file), join(root, "scripts", file)); } const compiler = join(root, "node_modules/typescript"); diff --git a/tests/ci-build-boundary.test.ts b/tests/ci-build-boundary.test.ts new file mode 100644 index 0000000000..ef89142eab --- /dev/null +++ b/tests/ci-build-boundary.test.ts @@ -0,0 +1,91 @@ +import { afterEach, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildMounts, captureBuildInputs, captureDerivedOutput, captureOutputs, identity, outputFiles, sameOutputIdentity } from "../scripts/ci-build-boundary"; +import { attemptCiBuild } from "../scripts/ci-build-operation"; + +const owned: string[] = []; +afterEach(() => { for (const root of owned.splice(0)) rmSync(root, { recursive: true, force: true }); }); +function fixture() { + const root = mkdtempSync(join(tmpdir(), "odysseus-ci-build-")); owned.push(root); + for (const path of ["packages/common/src", "node_modules/compiler/dist", "node_modules/.bin"]) mkdirSync(join(root, path), { recursive: true }); + writeFileSync(join(root, "packages/common/package.json"), JSON.stringify({ name: "@schlessera/brain-common", version: "0.44.0", bin: { "odysseus-fixture": "dist/cli.js" } })); + writeFileSync(join(root, "packages/common/src/index.ts"), "export const harbour = 'Ithaca';\n"); + writeFileSync(join(root, "node_modules/compiler/dist/emitter.js"), "export const emitter = 'Odysseus';\n"); + symlinkSync("../../packages/common/dist/cli.js", join(root, "node_modules/.bin/odysseus-fixture")); + const git = Bun.spawnSync(["git", "init", "--quiet", root], { stdout: "pipe", stderr: "pipe" }); expect(git.exitCode).toBe(0); + return root; +} +test("captured input bytes survive an original source change and include installed dist", () => { + const root = fixture(), state = mkdtempSync(join(tmpdir(), "odysseus-ci-input-")); owned.push(state); + chmodSync(join(root, "packages/common/src/index.ts"), 0o666); + const mask = process.umask(0o077); + let captured: ReturnType; + try { captured = captureBuildInputs(root, state); } finally { process.umask(mask); } + writeFileSync(join(root, "packages/common/src/index.ts"), "export const harbour = 'Pylos';\n"); + expect(readFileSync(join(state, "packages/common/src/index.ts"), "utf8"), "CI_BUILD_CONSUMES_CAPTURED_BYTES").toContain("Ithaca"); + expect(statSync(join(state, "packages/common/src/index.ts")).mode & 0o7777, "CI_BUILD_CAPTURE_MODE_IGNORES_HOST_UMASK").toBe(0o666); + expect(captured.records.some(entry => entry.path === "node_modules/compiler/dist/emitter.js"), "CI_BUILD_TRANSITIVE_INSTALLED_DIST_INCLUDED").toBe(true); + expect(captured.records.some(entry => entry.path === "node_modules/.bin/odysseus-fixture"), "declared output alias remains an alias, not invented input bytes").toBe(true); +}); +test("an effective config alias escaping the captured view refuses eligibility", () => { + const root = fixture(), outside = mkdtempSync(join(tmpdir(), "odysseus-ci-config-")); owned.push(outside); + writeFileSync(join(outside, "config.json"), "{}\n"); symlinkSync(join(outside, "config.json"), join(root, "tsconfig.json")); + const captured = join(outside, "captured"); + expect(() => captureBuildInputs(root, captured), "CI_BUILD_EXTERNAL_CONFIG_REFUSES_REUSE").toThrow("External build input link"); +}); +test("an undeclared dangling installed alias refuses eligibility", () => { + const root = fixture(), state = mkdtempSync(join(tmpdir(), "odysseus-ci-input-")); owned.push(state); + symlinkSync("../compiler/missing.js", join(root, "node_modules/.bin/missing")); + expect(() => captureBuildInputs(root, state), "CI_BUILD_UNKNOWN_DANGLING_LINK_REFUSES_REUSE").toThrow("Uncaptured build input link target"); +}); +test("output identity detects deletion, editing, extra files and mode drift", () => { + const root = mkdtempSync(join(tmpdir(), "odysseus-ci-output-")); owned.push(root); + const path = join(root, "cli.js"), bytes = "console.log('Ithaca');\n"; writeFileSync(path, bytes, { mode: 0o755 }); + const original = identity(outputFiles(root)); + for (const change of [() => rmSync(path), () => writeFileSync(path, "console.log('Pylos');\n"), () => writeFileSync(join(root, "extra.js"), bytes), () => chmodSync(path, 0o644)]) { + expect(sameOutputIdentity(root, original)).toBe(true); + change(); expect(sameOutputIdentity(root, original), "CI_BUILD_OUTPUT_DRIFT_REFUSES_REUSE").toBe(false); + rmSync(root, { recursive: true }); mkdirSync(root, { mode: 0o700 }); writeFileSync(path, bytes, { mode: 0o755 }); + } +}); +test("validated output capture preserves its checked bytes and modes", () => { + const root = mkdtempSync(join(tmpdir(), "odysseus-ci-output-")); owned.push(root); + const output = join(root, "dist"), captured = join(root, "captured"); mkdirSync(output); + writeFileSync(join(output, "cli.js"), "console.log('Ithaca');\n", { mode: 0o755 }); + const records = captureOutputs(output, captured); + writeFileSync(join(output, "cli.js"), "console.log('Pylos');\n"); + expect(readFileSync(join(captured, "cli.js"), "utf8"), "CI_BUILD_PUBLISHES_VALIDATED_BYTES").toContain("Ithaca"); + expect(identity(outputFiles(captured))).toBe(identity(records)); +}); +test("derived output data and all source leaves are read-only mounts", () => { + const root = fixture(), state = mkdtempSync(join(tmpdir(), "odysseus-ci-mounts-")); owned.push(state); + const input = join(state, "input"); captureBuildInputs(root, input); + const derived = join(state, "derived-common"); mkdirSync(derived); writeFileSync(join(derived, "tokens.css"), ":root{--harbour:Ithaca}"); + const { mounts, topology } = buildMounts(input, state, ["common"], { common: derived }); + expect(mounts.find(mount => mount.target === "/repo/packages/common/dist")?.readonly, "CI_BUILD_DERIVED_OUTPUT_IS_READONLY").toBe(true); + expect(mounts.filter(mount => mount.target.startsWith("/repo/packages/common/")).every(mount => mount.readonly), "CI_BUILD_SOURCE_LEAVES_ARE_READONLY").toBe(true); + expect(topology.common).toEqual(["dist", "package.json", "src"]); + expect(() => buildMounts(input, join(state, "unknown"), ["common"], { missing: derived }), "CI_BUILD_UNDECLARED_DERIVED_INPUT_REFUSED").toThrow("Unknown derived build package"); +}); +test("derived output changed between identity check and capture refuses consumption", () => { + const root = mkdtempSync(join(tmpdir(), "odysseus-ci-derived-")); owned.push(root); + const source = join(root, "dist"); mkdirSync(source); + const path = join(source, "tokens.css"), original = ":root{--harbour:Ithaca}"; writeFileSync(path, original); + const expected = identity(outputFiles(source)); + writeFileSync(path, ":root{--harbour:Pylos}"); + expect(() => captureDerivedOutput(source, join(root, "raced"), expected), "CI_BUILD_DERIVED_CAPTURE_RACE_REFUSES_REUSE").toThrow("Derived build output changed during capture"); + writeFileSync(path, original); + expect(identity(captureDerivedOutput(source, join(root, "restored"), expected))).toBe(expected); +}); +test("CI intent cannot authorize unqualified reuse", async () => { + const root = mkdtempSync(join(tmpdir(), "odysseus-ci-intent-")); owned.push(root); + const before = process.env.BRAIN_CI_BUILD_OPERATION; + try { + process.env.BRAIN_CI_BUILD_OPERATION = "isolated-v1"; + expect(await attemptCiBuild(root, { jobs: 1, force: false }), "CI_BUILD_INTENT_IS_NOT_AUTHORITY").toBeUndefined(); + } finally { + if (before === undefined) delete process.env.BRAIN_CI_BUILD_OPERATION; else process.env.BRAIN_CI_BUILD_OPERATION = before; + } +}); diff --git a/tests/generated-build-inputs.test.ts b/tests/generated-build-inputs.test.ts index a2b58e5c28..19cb49574e 100644 --- a/tests/generated-build-inputs.test.ts +++ b/tests/generated-build-inputs.test.ts @@ -6,7 +6,7 @@ import { generatedCssInputs, generatedCssReadInputs } from "../scripts/generated import { discoverInputClosure } from "../scripts/browser-input-closure"; const repo=resolve(import.meta.dir,".."),roots:string[]=[]; afterEach(()=>{for(const root of roots.splice(0))rmSync(root,{recursive:true,force:true});}); -const producer=["scripts/build.ts","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json"]; +const producer=["scripts/build.ts","scripts/build-execution.ts","scripts/ci-build-boundary.ts","scripts/ci-build-operation.ts","scripts/ci-build-entry.ts","scripts/ci-build-qualification.json","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json"]; function fixture(){ const root=mkdtempSync(join(tmpdir(),"generated-css-"));roots.push(root); const write=(file:string,bytes:string)=>{mkdirSync(dirname(join(root,file)),{recursive:true});writeFileSync(join(root,file),bytes);}; diff --git a/tests/generated-theme-inputs.test.ts b/tests/generated-theme-inputs.test.ts index f4e252086c..82104a0204 100644 --- a/tests/generated-theme-inputs.test.ts +++ b/tests/generated-theme-inputs.test.ts @@ -5,7 +5,7 @@ import {dirname,join,resolve} from "node:path"; import {browserImporters} from "../scripts/bound-browser-runtime-inputs"; import {generatedThemeInputs} from "../scripts/generated-theme-inputs"; const repo=resolve(import.meta.dir,".."),roots:string[]=[],owner="packages/ui-kit/tests/visual/dictation-motion.ts",caller="packages/ui-kit/vitest.config.ts",entry="packages/ui-kit/tests/visual/dictation-theme.css"; -const producers=["scripts/build.ts","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json","bun.lock","packages/ui-react/src/theme.css","packages/ui-react/package.json"]; +const producers=["scripts/build.ts","scripts/build-execution.ts","scripts/ci-build-boundary.ts","scripts/ci-build-operation.ts","scripts/ci-build-entry.ts","scripts/ci-build-qualification.json","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json","bun.lock","packages/ui-react/src/theme.css","packages/ui-react/package.json"]; afterEach(()=>{for(const root of roots.splice(0))rmSync(root,{recursive:true,force:true});}); function fixture(){const root=mkdtempSync(join(tmpdir(),"generated-theme-"));roots.push(root);const write=(file:string,text:string)=>{mkdirSync(dirname(join(root,file)),{recursive:true});writeFileSync(join(root,file),text);};for(const file of [...producers,owner,caller,entry])write(file,readFileSync(join(repo,file),"utf8"));symlinkSync(join(repo,"node_modules"),join(root,"node_modules"),"dir");return{root,write};} test("temporary theme output binds the audited command, copied theme and installed compiler",()=>{ diff --git a/tests/inclusion-inputs.test.ts b/tests/inclusion-inputs.test.ts index 208e400a23..2035400fac 100644 --- a/tests/inclusion-inputs.test.ts +++ b/tests/inclusion-inputs.test.ts @@ -81,7 +81,7 @@ test("validated installed asset bytes and mode join immutable source fingerprint const installed=resolve(import.meta.dir,"../node_modules"); for(const entry of readdirSync(installed)) if(entry!=="axe-core") symlinkSync(join(installed,entry),join(root,"node_modules",entry),"dir"); const owner="packages/ui-react/tests/browser/offline/cold-capture/commands.ts"; - for(const file of [owner,"scripts/build.ts","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json","packages/ui-react/package.json","packages/ui-kit/package.json"]) + for(const file of [owner,"scripts/build.ts","scripts/build-execution.ts","scripts/ci-build-boundary.ts","scripts/ci-build-operation.ts","scripts/ci-build-entry.ts","scripts/ci-build-qualification.json","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json","packages/ui-react/package.json","packages/ui-kit/package.json"]) write(root,file,readFileSync(resolve(import.meta.dir,"..",file),"utf8")); write(root,spec,`import "../../../ui-react/tests/browser/offline/cold-capture/commands.ts";`); write(root,"node_modules/axe-core/package.json",JSON.stringify({name:"axe-core",version:"4.13.0"}));write(root,"node_modules/axe-core/axe.min.js","owned asset before");commit(root); diff --git a/tests/workspace-lease.test.ts b/tests/workspace-lease.test.ts index 7f499ef974..5230db36b5 100644 --- a/tests/workspace-lease.test.ts +++ b/tests/workspace-lease.test.ts @@ -115,7 +115,7 @@ test("shared verification overlaps, blocks a writer and rejects a nested build b mkdirSync(join(root, "scripts")); mkdirSync(join(root, "bin")); mkdirSync(join(root, "packages/geo/dist"), { recursive: true }); writeFileSync(join(root, "packages/geo/dist/retained"), "retained\n"); - for (const name of ["build.ts", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) copyFileSync(join(project, "scripts", name), join(root, "scripts", name)); + for (const name of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) copyFileSync(join(project, "scripts", name), join(root, "scripts", name)); writeFileSync(join(root, "bin/bunx"), "#!/bin/sh\nexit 77\n", { mode: 0o755 }); const first = start(root, "read", "hold", "first"), second = start(root, "read", "hold", "second"); await Promise.all([entered(root, "first"), entered(root, "second")]); From a5471d8422a2986495a4b3863f31100ab0854ea1 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sat, 17 Oct 2026 20:30:25 +0200 Subject: [PATCH 06/15] fix(ci): anchor build captures and bound owned producer cleanup --- docs/decisions/isolated-ci-build.md | 16 ++ scripts/ci-build-boundary.ts | 37 ++-- scripts/ci-build-entry.ts | 7 +- scripts/ci-build-files.ts | 62 ++++++ scripts/ci-build-lifetime.ts | 19 ++ scripts/ci-build-operation.ts | 307 +++++++++++++++------------ scripts/generated-build-inputs.ts | 8 +- tests/build-concurrency.test.ts | 4 +- tests/build-freshness.test.ts | 2 +- tests/ci-build-boundary.test.ts | 6 +- tests/ci-build-files.test.ts | 36 ++++ tests/ci-build-lifetime.test.ts | 24 +++ tests/generated-build-inputs.test.ts | 2 +- tests/generated-theme-inputs.test.ts | 2 +- tests/inclusion-inputs.test.ts | 2 +- tests/workspace-lease.test.ts | 2 +- 16 files changed, 365 insertions(+), 171 deletions(-) create mode 100644 scripts/ci-build-files.ts create mode 100644 scripts/ci-build-lifetime.ts create mode 100644 tests/ci-build-files.test.ts create mode 100644 tests/ci-build-lifetime.test.ts diff --git a/docs/decisions/isolated-ci-build.md b/docs/decisions/isolated-ci-build.md index edc6d9e04e..adaef6f238 100644 --- a/docs/decisions/isolated-ci-build.md +++ b/docs/decisions/isolated-ci-build.md @@ -57,3 +57,19 @@ and comparable hosted setup/critical-path measurements including identity and publication overhead. The operation remains unqualified until those concrete identities and evidence are reviewed. This requirement also binds later producer, runtime, installed-input and configuration changes. + +File capture traverses Linux directory descriptors without following aliases, +opens regular files with no-follow and nonblocking flags, and checks descriptor +and ancestor bindings before and after bounded reads. Captured bytes, rather +than a later pathname read, become the operation input. Mutable receipt and +policy files use the same capture rule; kernel process-status files have fixed +kernel-owned origins and are not repository inputs. + +The fixed operation requires non-root UID and GID. Each local daemon control +has a ten-second limit; the image-owned entry has a 300-second TERM deadline +with five-second KILL grace, and its host client has a 330-second limit. These +operation bounds require real qualification on all nineteen packages before +activation. They do not alter ordinary builds or test deadlines. Session-owned +container removal and confirmed absence precede output publication and reuse +stamps. Unconfirmed cleanup fails, records the incomplete cleanup and retains +owned state; it never grants successful reuse authority. diff --git a/scripts/ci-build-boundary.ts b/scripts/ci-build-boundary.ts index 6825d5bc28..d1df9b7429 100644 --- a/scripts/ci-build-boundary.ts +++ b/scripts/ci-build-boundary.ts @@ -1,7 +1,8 @@ /** Captured files for the single isolated workspace-build operation. */ import { createHash } from "node:crypto"; -import { chmodSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, lstatSync, mkdirSync, readdirSync, symlinkSync, writeFileSync } from "node:fs"; import { join, posix, resolve } from "node:path"; +import { buildStat, buildRead, buildNames, buildLink } from "./ci-build-files"; import { listPublishablePackages } from "./publishable-packages"; export type BuildFile = { path: string; mode: number; kind: "directory" | "file" | "link"; sha256?: string; target?: string }; @@ -25,26 +26,26 @@ export function captureBuildInputs(root: string, destination: string) { function directory(path: string) { if (path === "." || files.has(path)) return; safePath(path); directory(posix.dirname(path)); - const stat = lstatSync(join(root, path)); + const stat = buildStat(join(root, path)); if (!stat.isDirectory()) throw new Error("Build input ancestor is not a directory"); - files.set(path, { path, kind: "directory", mode: stat.mode & 0o7777 }); + files.set(path, { path, kind: "directory", mode: Number(stat.mode & 0o7777n) }); mkdirSync(join(destination, path), { mode: 0o700 }); } function capture(path: string, recurse: boolean) { safePath(path); if (ignored(path) || files.has(path)) return; directory(posix.dirname(path)); - const from = join(root, path), to = join(destination, path), stat = lstatSync(from); - const mode = stat.mode & 0o7777; + const from = join(root, path), to = join(destination, path), stat = buildStat(from); + const mode = Number(stat.mode & 0o7777n); if (stat.isDirectory()) { directory(path); - if (recurse) for (const name of readdirSync(from).sort()) capture(`${path}/${name}`, true); + if (recurse) for (const name of buildNames(from, stat)) capture(`${path}/${name}`, true); } else if (stat.isFile()) { - const bytes = readFileSync(from); + const bytes = buildRead(from, stat).bytes; files.set(path, { path, mode, kind: "file", sha256: digest(bytes) }); writeFileSync(to, bytes, { mode, flag: "wx" }); chmodSync(to, mode); } else if (stat.isSymbolicLink()) { - const target = readlinkSync(from); + const target = buildLink(from, stat); if (posix.isAbsolute(target) || target.includes("\\")) throw new Error("External build input link"); files.set(path, { path, mode, kind: "link", target }); symlinkSync(target, to); } else throw new Error("Unsupported build input type"); @@ -52,7 +53,7 @@ export function captureBuildInputs(root: string, destination: string) { for (const path of new TextDecoder().decode(source.stdout).split("\0").filter(Boolean).sort()) { if (!/(^|\/)node_modules(?:\/|$)/.test(path)) capture(path, false); } - const packages = listPublishablePackages(root); + const packages = listPublishablePackages(destination); const dependencies = ["node_modules", ...packages.map(pkg => `packages/${pkg.dir}/node_modules`)]; for (const path of dependencies) { const stat = lstatSync(join(root, path), { throwIfNoEntry: false }); @@ -95,11 +96,11 @@ export function captureBuildInputs(root: string, destination: string) { export function outputFiles(root: string): BuildFile[] { const records: BuildFile[] = []; function walk(path: string) { - const stat = lstatSync(join(root, path)); + const stat = buildStat(join(root, path)); if (stat.isDirectory()) { - records.push({ path, kind: "directory", mode: stat.mode & 0o7777 }); - for (const name of readdirSync(join(root, path)).sort()) walk(path ? `${path}/${name}` : name); - } else if (stat.isFile()) records.push({ path, kind: "file", mode: stat.mode & 0o7777, sha256: digest(readFileSync(join(root, path))) }); + records.push({ path, kind: "directory", mode: Number(stat.mode & 0o7777n) }); + for (const name of buildNames(join(root, path), stat)) walk(path ? `${path}/${name}` : name); + } else if (stat.isFile()) records.push({ path, kind: "file", mode: Number(stat.mode & 0o7777n), sha256: digest(buildRead(join(root, path), stat).bytes) }); else throw new Error("Unsupported build output type"); } walk(""); return records; @@ -113,14 +114,14 @@ export function sameOutputIdentity(root: string, expected: string): boolean { export function captureOutputs(root: string, destination: string): BuildFile[] { const records: BuildFile[] = []; function walk(path: string) { - const from = join(root, path), to = join(destination, path), stat = lstatSync(from); + const from = join(root, path), to = join(destination, path), stat = buildStat(from); if (stat.isDirectory()) { mkdirSync(to, { recursive: true, mode: 0o700 }); - records.push({ path, kind: "directory", mode: stat.mode & 0o7777 }); - for (const name of readdirSync(from).sort()) walk(path ? `${path}/${name}` : name); - chmodSync(to, stat.mode & 0o7777); + records.push({ path, kind: "directory", mode: Number(stat.mode & 0o7777n) }); + for (const name of buildNames(from, stat)) walk(path ? `${path}/${name}` : name); + chmodSync(to, Number(stat.mode & 0o7777n)); } else if (stat.isFile()) { - const bytes = readFileSync(from), mode = stat.mode & 0o7777; + const bytes = buildRead(from, stat).bytes, mode = Number(stat.mode & 0o7777n); writeFileSync(to, bytes, { mode, flag: "wx" }); chmodSync(to, mode); records.push({ path, kind: "file", mode, sha256: digest(bytes) }); } else throw new Error("Unsupported build output type"); diff --git a/scripts/ci-build-entry.ts b/scripts/ci-build-entry.ts index 5d708af3fd..748369a4a5 100644 --- a/scripts/ci-build-entry.ts +++ b/scripts/ci-build-entry.ts @@ -1,4 +1,5 @@ /** The only program run by the fixed CI build container. Not a plugin host. */ +import { buildRead } from "./ci-build-files"; import assert from "node:assert/strict"; import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -16,7 +17,7 @@ if (import.meta.main) { const coordinated = await ensureWorkspaceLease("/repo", "write"); if (coordinated !== undefined) process.exit(coordinated); process.umask(0o022); - const entry = JSON.parse(readFileSync("/operation/entry.json", "utf8")) as Entry; + const entry = JSON.parse(buildRead("/operation/entry.json", undefined, 1024 * 1024).bytes.toString("utf8")) as Entry; assert.equal(entry.version, 1); assert(!existsSync(entry.originalRoot), "CI_BUILD_ORIGINAL_CHECKOUT_UNMOUNTED"); assert(!existsSync(entry.canary), "CI_BUILD_HOST_DATA_UNMOUNTED"); @@ -38,7 +39,7 @@ if (import.meta.main) { for (const [pkg, names] of Object.entries(entry.topology)) { assert.deepEqual(readdirSync(`/repo/packages/${pkg}`).sort(), names, "CI_BUILD_INITIAL_PACKAGE_TOPOLOGY"); } - const runtime = { bun: digest(readFileSync("/tool/bun")), node: { path: entry.node, sha256: digest(readFileSync(entry.node)) } }; + const runtime = { bun: digest(buildRead("/tool/bun").bytes), node: { path: entry.node, sha256: digest(buildRead(entry.node).bytes) } }; mkdirSync("/tmp/home", { mode: 0o700 }); mkdirSync("/tmp/cache", { mode: 0o700 }); process.env.TMPDIR = "/tmp"; process.env.XDG_CACHE_HOME = "/tmp/cache"; const packages = listPublishablePackages("/repo").filter(pkg => entry.selected.includes(pkg.dir)); @@ -52,7 +53,7 @@ if (import.meta.main) { assert.equal(statSync(`/repo/packages/${pkg}`).mode & 0o777, 0o700, "CI_BUILD_PRIVATE_PARENT_MODE"); } const receipts = readdirSync("/repo/tmp").filter(name => /^build-\d+-[0-9a-f-]+-probe\.json$/.test(name)).sort() - .map(name => ({ name, value: JSON.parse(readFileSync(join("/repo/tmp", name), "utf8")) })); + .map(name => ({ name, value: JSON.parse(buildRead(join("/repo/tmp", name), undefined, 16 * 1024 * 1024).bytes.toString("utf8")) })); writeFileSync("/repo/tmp/isolated-producer.json", JSON.stringify({ version: 1, code, runtime, receipts, mounts, topology: "checked-after-child-exit" })); process.exitCode = code; } diff --git a/scripts/ci-build-files.ts b/scripts/ci-build-files.ts new file mode 100644 index 0000000000..fbed3007c2 --- /dev/null +++ b/scripts/ci-build-files.ts @@ -0,0 +1,62 @@ +/** Descriptor-anchored reads for the fixed CI build operation; Linux only. */ +import { constants, openSync, closeSync, fstatSync, lstatSync, readSync, readdirSync, readlinkSync, type BigIntStats } from "node:fs"; +import { resolve } from "node:path"; +const at = (fd: number, name: string) => `/proc/self/fd/${fd}/${name}`; +const version = (s: BigIntStats) => `${s.dev}:${s.ino}:${s.mode}:${s.uid}:${s.gid}:${s.nlink}:${s.size}:${s.mtimeNs}:${s.ctimeNs}`; +const object = (s: BigIntStats) => `${s.dev}:${s.ino}:${s.mode}:${s.uid}:${s.gid}`; +const LIMIT = 512 * 1024 * 1024; +/** Keep every ancestor descriptor until the final post-read binding check. */ +function anchored(path: string, action: (parent: number, name: string) => T): T { + if (process.platform !== "linux") throw new Error("Unsupported fixed build file platform"); + const parts = resolve(path).split("/").filter(Boolean), name = parts.pop() ?? "."; + const fds: number[] = [], links: { parent: number; name: string; fd: number; stat: BigIntStats }[] = []; + try { + let parent = openSync("/", constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW | constants.O_NONBLOCK); fds.push(parent); + for (const part of parts) { + const fd = openSync(at(parent, part), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + fds.push(fd); const stat = fstatSync(fd, { bigint: true }); + if (object(lstatSync(at(parent, part), { bigint: true })) !== object(stat)) throw new Error("Build ancestor changed"); + links.push({ parent, name: part, fd, stat }); parent = fd; + } + const result = action(parent, name); + for (const link of links) if (object(fstatSync(link.fd, { bigint: true })) !== object(link.stat) || object(lstatSync(at(link.parent, link.name), { bigint: true })) !== object(link.stat)) throw new Error("Build ancestor changed"); + return result; + } finally { for (const fd of fds.reverse()) closeSync(fd); } +} +export function buildStat(path: string): BigIntStats { return anchored(path, (parent, name) => lstatSync(at(parent, name), { bigint: true })); } +export function buildRead(path: string, expected?: BigIntStats, limit = LIMIT): { bytes: Buffer; stat: BigIntStats } { + return anchored(path, (parent, name) => { + const before = lstatSync(at(parent, name), { bigint: true }); + if (!before.isFile() || (expected && version(before) !== version(expected)) || before.size > BigInt(limit)) throw new Error("Unsupported or changed build file"); + const fd = openSync(at(parent, name), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const opened = fstatSync(fd, { bigint: true }); + if (!opened.isFile() || version(opened) !== version(before)) throw new Error("Build file changed before capture"); + const bytes = Buffer.alloc(Number(opened.size)); let offset = 0; + while (offset < bytes.length) { const n = readSync(fd, bytes, offset, bytes.length - offset, null); if (!n) throw new Error("Build file shortened"); offset += n; } + if (readSync(fd, Buffer.alloc(1), 0, 1, null) !== 0 || version(fstatSync(fd, { bigint: true })) !== version(opened) || version(lstatSync(at(parent, name), { bigint: true })) !== version(opened)) throw new Error("Build file changed during capture"); + return { bytes, stat: opened }; + } finally { closeSync(fd); } + }); +} +export function buildNames(path: string, expected?: BigIntStats): string[] { + return anchored(path, (parent, name) => { + const before = lstatSync(at(parent, name), { bigint: true }); + if (!before.isDirectory() || (expected && version(before) !== version(expected))) throw new Error("Build directory changed"); + const fd = openSync(at(parent, name), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + if (version(fstatSync(fd, { bigint: true })) !== version(before)) throw new Error("Build directory changed"); + const names = readdirSync(`/proc/self/fd/${fd}`).sort(); + if (version(fstatSync(fd, { bigint: true })) !== version(before) || version(lstatSync(at(parent, name), { bigint: true })) !== version(before)) throw new Error("Build directory changed"); + return names; + } finally { closeSync(fd); } + }); +} +export function buildLink(path: string, expected: BigIntStats): string { + return anchored(path, (parent, name) => { + if (!expected.isSymbolicLink() || version(lstatSync(at(parent, name), { bigint: true })) !== version(expected)) throw new Error("Build link changed"); + const target = readlinkSync(at(parent, name)); + if (version(lstatSync(at(parent, name), { bigint: true })) !== version(expected)) throw new Error("Build link changed"); + return target; + }); +} diff --git a/scripts/ci-build-lifetime.ts b/scripts/ci-build-lifetime.ts new file mode 100644 index 0000000000..490cde101a --- /dev/null +++ b/scripts/ci-build-lifetime.ts @@ -0,0 +1,19 @@ +/** Local daemon controls for the one fixed CI build operation. */ +import { spawnSync } from "node:child_process"; +export function dockerControl(config: string, ...argv: string[]): string { + const result = spawnSync("/usr/bin/docker", ["--host", "unix:///var/run/docker.sock", "--config", config, ...argv], { + timeout: 10_000, killSignal: "SIGKILL", maxBuffer: 4 * 1024 * 1024, env: { PATH: "/usr/bin:/bin" }, + }); + if (result.error || result.signal || result.status !== 0) throw new Error(`Fixed build Docker ${argv[0]} failed`); + return result.stdout.toString("utf8").trim(); +} +/** Return only after this session's container is removed and absence verified. */ +export function removeOwnedBuildContainer(config: string, name: string, session: string): void { + if (!/^[0-9a-f-]{36}$/.test(session) || name !== `brain-ci-build-${session}`) throw new Error("Invalid build container ownership"); + const status = JSON.parse(dockerControl(config, "inspect", name)) as Array<{ State: { Running: boolean }; Config: { Labels: Record } }>; + if (status.length !== 1 || status[0]!.Config.Labels["brain-ci-build-session"] !== session) throw new Error("Unknown container ownership"); + if (status[0]!.State.Running) dockerControl(config, "stop", "--time", "5", name); + dockerControl(config, "wait", name); + dockerControl(config, "rm", name); + if (dockerControl(config, "container", "ls", "--all", "--filter", `name=^/${name}$`, "--format", "{{.Names}}") !== "") throw new Error("Owned build container removal unconfirmed"); +} diff --git a/scripts/ci-build-operation.ts b/scripts/ci-build-operation.ts index 5e38c36663..d05090806c 100644 --- a/scripts/ci-build-operation.ts +++ b/scripts/ci-build-operation.ts @@ -1,6 +1,8 @@ /** One dormant, fixed isolated build operation. CI intent is not authority. */ +import { dockerControl as docker, removeOwnedBuildContainer } from "./ci-build-lifetime"; +import { buildRead } from "./ci-build-files"; import { randomUUID } from "node:crypto"; -import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; import { join, relative, resolve } from "node:path"; import { buildMounts, captureBuildInputs, captureDerivedOutput, captureOutputs, digest, identity, outputFiles, sameOutputIdentity, type BuildFile } from "./ci-build-boundary"; import { selectBuildPackages } from "./publishable-packages"; @@ -16,12 +18,12 @@ type Qualification = { const policyPath = join(import.meta.dir, "ci-build-qualification.json"); const fixedImage = "mcr.microsoft.com/playwright@sha256:eff16c30e6f3f4af0a03fa4b706120d5e9b0891c344a27d64559aff5900a4a27"; const producerPaths = ["scripts/build.ts", "scripts/build-execution.ts", "scripts/build-receipt.ts", "scripts/bin-modes.ts", "scripts/publishable-packages.ts", "scripts/workspace-lease.mjs", - "scripts/ci-build-boundary.ts", "scripts/ci-build-operation.ts", "scripts/ci-build-entry.ts"]; + "scripts/ci-build-files.ts", "scripts/ci-build-lifetime.ts", "scripts/ci-build-boundary.ts", "scripts/ci-build-operation.ts", "scripts/ci-build-entry.ts"]; const configuration = (entry: BuildFile) => !/(^|\/)node_modules(?:\/|$)/.test(entry.path) && (/(^|\/)package\.json$/.test(entry.path) || /(^|\/)tsconfig[^/]*\.json$/.test(entry.path) || /(^|\/)bunfig\.toml$/.test(entry.path) || entry.path.endsWith(".css")); const launcher = '#!/bin/sh\nexec /tool/bun x --no-install "$@"\n'; function readPolicy(): Qualification { - const policy = JSON.parse(readFileSync(policyPath, "utf8")) as Qualification; + const policy = JSON.parse(buildRead(policyPath, undefined, 1024 * 1024).bytes.toString("utf8")) as Qualification; if (policy.image !== fixedImage || policy.node.path !== "/usr/bin/node") throw new Error("Unknown fixed build operation"); return policy; } @@ -39,19 +41,13 @@ function policyMatches(policy: Qualification, captured: ReturnType a.localeCompare(b)))); } -function docker(config: string, ...argv: string[]) { - // The daemon selection/configuration is also fixed: no remote context, - // inherited registry credentials, CLI plugins or user Docker configuration. - const result = Bun.spawnSync(["/usr/bin/docker", "--host", "unix:///var/run/docker.sock", "--config", config, ...argv], { stdout: "pipe", stderr: "pipe", env: { PATH: "/usr/bin:/bin" } }); - if (result.exitCode !== 0) throw new Error(`Fixed build Docker ${argv[0]} failed`); - return new TextDecoder().decode(result.stdout).trim(); -} function imagePresent(image: string, config: string) { const records = JSON.parse(docker(config, "image", "inspect", image)) as Array<{ RepoDigests?: string[] }>; if (records.length !== 1 || !records[0]!.RepoDigests?.includes(image)) throw new Error("Fixed build image manifest is unavailable"); } function workspaceScope(root: string) { if (process.platform !== "linux" || process.arch !== "x64") throw new Error("Unsupported fixed build platform"); + if (process.getuid!() === 0 || process.getgid!() === 0) throw new Error("Fixed build requires non-root UID and GID"); const directory = lstatSync(root), socket = lstatSync("/var/run/docker.sock"); if (!directory.isDirectory() || !socket.isSocket()) throw new Error("Unsupported fixed build ownership"); return { root: realpathSync(root), directory: { dev: directory.dev, ino: directory.ino }, uid: process.getuid!(), gid: process.getgid!(), @@ -81,9 +77,8 @@ function ownedDirectory(root: string, path: string, privateMode = false) { if (privateMode && (lstatSync(path).mode & 0o777) !== 0o700) throw new Error("Build state permissions are not private"); } function ownedJSON(path: string) { - const stat = lstatSync(path); - if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== process.getuid!() || stat.size > 16 * 1024 * 1024) throw new Error("Invalid owned build receipt"); - const bytes = readFileSync(path); + const { bytes, stat } = buildRead(path, undefined, 16 * 1024 * 1024); + if (stat.uid !== BigInt(process.getuid!())) throw new Error("Invalid owned build receipt"); return { sha256: digest(bytes), value: JSON.parse(bytes.toString("utf8")) }; } function successfulProducer(producer: any, selected: string[], policy: Qualification, bun: string, qualificationOnly: boolean): boolean { @@ -129,6 +124,11 @@ export async function attemptCiBuild(root: string, options: Options): Promise { + if (process.getuid!() === 0 || process.getgid!() === 0) { + if (qualificationOnly) throw new Error("Fixed build requires non-root UID and GID"); + operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: "unsupported", action: "ordinary-rebuild", reason: "non-root-context-required" }); + return; + } const started = performance.now(), session = randomUUID(), name = `brain-ci-build-${session}`; const selected = selectBuildPackages(root, options.directories).map(pkg => pkg.dir); let state: string, dockerConfig: string; @@ -141,140 +141,169 @@ async function execute(root: string, options: Options, policy: Qualification, qu operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: "unsupported", action: "ordinary-rebuild", reason: "private-state-unavailable" }); return; } - let preserve = qualificationOnly, containerKnown = false, drained = false; - const phases: Record = {}; - try { - const captureStart = performance.now(), input = join(state, "input"); - const captured = captureBuildInputs(root, input); - const bun = readFileSync(realpathSync(process.execPath)), bunHash = digest(bun); - phases.captureMs = performance.now() - captureStart; - if (!qualificationOnly && !policyMatches(policy, captured, bunHash)) { - operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: "unsupported", action: "ordinary-rebuild", reason: "captured-policy-mismatch", phases }); - return; - } - imagePresent(policy.image, dockerConfig); - const scope = workspaceScope(root); - const key = identity({ version: 1, inputs: captured.sha256, image: policy.image, bun: bunHash, bunVersion: Bun.version, policy: identity(policy), jobs: options.jobs, scope }); - const stampDirectory = join(root, "tmp", "ci-build-cache"); - if (!qualificationOnly) ownedDirectory(root, stampDirectory, true); - const reused: string[] = [], pending: string[] = [], previousOutputs: Record = {}; - for (const pkg of selected) { - let matches = false; - if (!qualificationOnly && !options.force) try { - const stamp = ownedJSON(join(stampDirectory, `${pkg}.json`)).value; - if (!/^[0-9a-f-]{36}$/.test(stamp.producerSession)) throw new Error("Missing producer ownership"); - const producerReceipt = ownedJSON(join(stampDirectory, `${stamp.producerSession}.json`)); - const prior = producerReceipt.value; - matches = stamp.version === 1 && stamp.key === key && stamp.operation === "fixed-isolated-ci-v1" && - producerReceipt.sha256 === stamp.receipt && prior.version === 1 && prior.operation === stamp.operation && - prior.session === stamp.producerSession && prior.key === key && prior.image === policy.image && prior.drained === true && - Array.isArray(prior.compiled) && prior.compiled.includes(pkg) && prior.outputs?.[pkg] === stamp.output && - successfulProducer(prior.producer, prior.compiled, policy, bunHash, false) && - sameOutputIdentity(join(root, "packages", pkg, "dist"), stamp.output); - if (matches) previousOutputs[pkg] = stamp.output; - } catch { /* Missing, partial or changed output is a miss. */ } - (matches ? reused : pending).push(pkg); - } - phases.lookupMs = performance.now() - captureStart - phases.captureMs!; - for (const pkg of reused) console.log(`Up to date packages/${pkg} (qualified isolated operation)`); - if (!pending.length) { - operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: "reused", key, selected, reused, compiled: [], phases, elapsedMs: performance.now() - started }); - return 0; - } - // CSS package exports can consume another selected package's dist. These - // are checked, same-key derived intermediates, never arbitrary host output. - const derived: Record = {}, derivedIdentities: Record = {}; - for (const pkg of reused) { - const destination = join(state, "derived", pkg); - const records = captureDerivedOutput(join(root, "packages", pkg, "dist"), destination, previousOutputs[pkg]!); - derived[pkg] = destination; derivedIdentities[pkg] = identity(records); - } - const { mounts, topology, rootNames } = buildMounts(input, state, captured.packages, derived); - const tool = join(state, "tool"); mkdirSync(tool, { mode: 0o700 }); - writeFileSync(join(tool, "bun"), bun, { mode: 0o755, flag: "wx" }); chmodSync(join(tool, "bun"), 0o755); - writeFileSync(join(tool, "bunx"), launcher, { mode: 0o755, flag: "wx" }); chmodSync(join(tool, "bunx"), 0o755); - const hostFiles = join(state, "host-files"); mkdirSync(hostFiles, { mode: 0o700 }); - for (const [file, bytes] of Object.entries({ hosts: "127.0.0.1 localhost brain-build\n::1 localhost\n", hostname: "brain-build\n", "resolv.conf": "" })) writeFileSync(join(hostFiles, file), bytes, { flag: "wx" }); - const entryPath = join(state, "entry.json"), canary = join(state, "ambient-canary"); writeFileSync(canary, "Odysseus stays outside the build"); - const uid = process.getuid!(), gid = process.getgid!(); - const argv = ["run", "--name", name, "--label", `brain-ci-build-session=${session}`, "--pull=never", "--read-only", "--network=none", "--cap-drop=ALL", "--security-opt=no-new-privileges", "--user", `${uid}:${gid}`, "--hostname", "brain-build", "--cpus=2", "--memory=4g", "--pids-limit=256", "--shm-size=64m", - "--tmpfs", `/repo:rw,nosuid,nodev,mode=0700,uid=${uid},gid=${gid},size=64m`, "--tmpfs", "/tmp:rw,exec,nosuid,nodev,mode=1777,size=256m"]; - const allMounts = [...mounts, { source: tool, target: "/tool", readonly: true }, { source: entryPath, target: "/operation/entry.json", readonly: true }, - ...["hosts", "hostname", "resolv.conf"].map(file => ({ source: join(hostFiles, file), target: `/etc/${file}`, readonly: true }))]; - writeFileSync(entryPath, JSON.stringify({ version: 1, originalRoot: root, canary, selected: pending, jobs: options.jobs, topology, rootNames, node: policy.node.path, - readonlyTargets: allMounts.filter(mount => mount.readonly).map(mount => mount.target), writableTargets: allMounts.filter(mount => !mount.readonly).map(mount => mount.target) })); - for (const mount of allMounts) { - if (/[\r\n,]/.test(mount.source + mount.target)) throw new Error("Unsupported fixed build mount path"); - argv.push("--mount", `type=bind,src=${mount.source},dst=${mount.target}${mount.readonly ? ",readonly" : ""}`); - } - argv.push("--workdir", "/repo", policy.image, "/usr/bin/env", "-i", "PATH=/tool:/usr/bin:/bin", "HOME=/tmp/home", "LANG=C.UTF-8", "LC_ALL=C.UTF-8", "TZ=UTC", "/tool/bun", "/repo/scripts/ci-build-entry.ts"); - writeFileSync(join(state, "invocation.json"), JSON.stringify({ version: 1, session, key, argv, mounts: allMounts, captured, derived: derivedIdentities, runtime: { image: policy.image, bun: bunHash }, scope, qualificationOnly }, null, 2)); - const compileStart = performance.now(); - const child = Bun.spawn(["/usr/bin/docker", "--host", "unix:///var/run/docker.sock", "--config", dockerConfig, ...argv], { stdout: "inherit", stderr: "inherit", env: { PATH: "/usr/bin:/bin" } }); - containerKnown = true; - let interrupted = false; - const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const; - const interrupt = () => { interrupted = true; try { docker(dockerConfig, "stop", "--time", "5", name); } catch { /* Finally retains state unless drain is verified. */ } }; - for (const signal of signals) process.on(signal, interrupt); - let code: number; - try { code = await child.exited; } finally { for (const signal of signals) process.off(signal, interrupt); } - phases.producerMs = performance.now() - compileStart; - const status = JSON.parse(docker(dockerConfig, "inspect", name)) as Array<{ State: { Running: boolean }; Config: { Image: string; Labels: Record }; - HostConfig: { ReadonlyRootfs: boolean; NetworkMode: string; Privileged: boolean; CapDrop: string[]; SecurityOpt: string[]; Tmpfs: Record }; - Mounts: Array<{ Type: string; Source: string; Destination: string; RW: boolean }> }>; - if (status.length !== 1 || status[0]!.Config.Labels["brain-ci-build-session"] !== session || status[0]!.State.Running) throw new Error("Build container did not drain"); - drained = true; - const actual = status[0]!, host = actual.HostConfig; - if (actual.Config.Image !== policy.image || host.ReadonlyRootfs !== true || host.NetworkMode !== "none" || host.Privileged !== false || - !host.CapDrop?.includes("ALL") || !host.SecurityOpt?.some(value => value === "no-new-privileges" || value === "no-new-privileges=true") || - identity(Object.keys(host.Tmpfs ?? {}).sort()) !== identity(["/repo", "/tmp"])) throw new Error("Actual build container boundary changed"); - const actualBinds = actual.Mounts.filter(mount => mount.Type === "bind").map(mount => ({ source: mount.Source, target: mount.Destination, readonly: !mount.RW })).sort((a, b) => a.target.localeCompare(b.target)); - if (actual.Mounts.some(mount => !["bind", "tmpfs"].includes(mount.Type)) || identity(actualBinds) !== identity([...allMounts].sort((a, b) => a.target.localeCompare(b.target)))) throw new Error("Actual build mounts differ from captured inputs"); - if (interrupted || code !== 0) throw new Error(`Isolated compiler failed (${code})`); - const producer = JSON.parse(readFileSync(join(state, "diagnostics/isolated-producer.json"), "utf8")); - if (!successfulProducer(producer, pending, policy, bunHash, qualificationOnly)) throw new Error("Consumed runtime or compiler receipt mismatch"); - const outputs: Record = {}, publishStart = performance.now(); - for (const pkg of pending) { - const staged = join(state, "validated", pkg); - outputs[pkg] = identity(captureOutputs(join(state, "parents", pkg, "dist"), staged)); + let preserve = qualificationOnly, containerKnown = false, drained = false, removed = false, interrupted = false; + let child: ReturnType | undefined; + const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const; + const interrupt = () => { + interrupted = true; + try { if (containerKnown && !removed) docker(dockerConfig, "stop", "--time", "5", name); } + catch { preserve = true; } + finally { try { child?.kill("SIGTERM"); } catch { preserve = true; } } + }; + // Keep ownership handlers installed through cleanup and publication. + for (const signal of signals) process.on(signal, interrupt); + const cleanup = () => { + try { + removeOwnedBuildContainer(dockerConfig, name, session); + drained = true; removed = true; + } catch (error) { + preserve = true; + operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: "failed", action: "fail", session, reason: "owned-container-cleanup-incomplete", drained, removed: false }); + throw error; } - if (!qualificationOnly) { + }; + const phases: Record = {}; + const perform = async (): Promise => { + try { + const captureStart = performance.now(), input = join(state, "input"); + const captured = captureBuildInputs(root, input); + const executable = buildRead(realpathSync(process.execPath)), loaded = statSync("/proc/self/exe", { bigint: true }); + if (executable.stat.dev !== loaded.dev || executable.stat.ino !== loaded.ino) throw new Error("Captured Bun differs from running executable"); + const bun = executable.bytes, bunHash = digest(bun); + phases.captureMs = performance.now() - captureStart; + if (!qualificationOnly && !policyMatches(policy, captured, bunHash)) { + operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: "unsupported", action: "ordinary-rebuild", reason: "captured-policy-mismatch", phases }); + return; + } + imagePresent(policy.image, dockerConfig); + const scope = workspaceScope(root); + const key = identity({ version: 1, inputs: captured.sha256, image: policy.image, bun: bunHash, bunVersion: Bun.version, policy: identity(policy), jobs: options.jobs, scope }); + const stampDirectory = join(root, "tmp", "ci-build-cache"); + if (!qualificationOnly) ownedDirectory(root, stampDirectory, true); + const reused: string[] = [], pending: string[] = [], previousOutputs: Record = {}; + for (const pkg of selected) { + let matches = false; + if (!qualificationOnly && !options.force) try { + const stamp = ownedJSON(join(stampDirectory, `${pkg}.json`)).value; + if (!/^[0-9a-f-]{36}$/.test(stamp.producerSession)) throw new Error("Missing producer ownership"); + const producerReceipt = ownedJSON(join(stampDirectory, `${stamp.producerSession}.json`)); + const prior = producerReceipt.value; + matches = stamp.version === 1 && stamp.key === key && stamp.operation === "fixed-isolated-ci-v1" && + producerReceipt.sha256 === stamp.receipt && prior.version === 1 && prior.operation === stamp.operation && + prior.session === stamp.producerSession && prior.key === key && prior.image === policy.image && prior.drained === true && prior.removed === true && + Array.isArray(prior.compiled) && prior.compiled.includes(pkg) && prior.outputs?.[pkg] === stamp.output && + successfulProducer(prior.producer, prior.compiled, policy, bunHash, false) && + sameOutputIdentity(join(root, "packages", pkg, "dist"), stamp.output); + if (matches) previousOutputs[pkg] = stamp.output; + } catch { /* Missing, partial or changed output is a miss. */ } + (matches ? reused : pending).push(pkg); + } + phases.lookupMs = performance.now() - captureStart - phases.captureMs!; + for (const pkg of reused) console.log(`Up to date packages/${pkg} (qualified isolated operation)`); + if (!pending.length) { + operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: "reused", key, selected, reused, compiled: [], phases, elapsedMs: performance.now() - started }); + return 0; + } + // CSS package exports can consume another selected package's dist. These + // are checked, same-key derived intermediates, never arbitrary host output. + const derived: Record = {}, derivedIdentities: Record = {}; + for (const pkg of reused) { + const destination = join(state, "derived", pkg); + const records = captureDerivedOutput(join(root, "packages", pkg, "dist"), destination, previousOutputs[pkg]!); + derived[pkg] = destination; derivedIdentities[pkg] = identity(records); + } + const { mounts, topology, rootNames } = buildMounts(input, state, captured.packages, derived); + const tool = join(state, "tool"); mkdirSync(tool, { mode: 0o700 }); + writeFileSync(join(tool, "bun"), bun, { mode: 0o755, flag: "wx" }); chmodSync(join(tool, "bun"), 0o755); + writeFileSync(join(tool, "bunx"), launcher, { mode: 0o755, flag: "wx" }); chmodSync(join(tool, "bunx"), 0o755); + const hostFiles = join(state, "host-files"); mkdirSync(hostFiles, { mode: 0o700 }); + for (const [file, bytes] of Object.entries({ hosts: "127.0.0.1 localhost brain-build\n::1 localhost\n", hostname: "brain-build\n", "resolv.conf": "" })) writeFileSync(join(hostFiles, file), bytes, { flag: "wx" }); + const entryPath = join(state, "entry.json"), canary = join(state, "ambient-canary"); writeFileSync(canary, "Odysseus stays outside the build"); + const uid = process.getuid!(), gid = process.getgid!(); + const argv = ["run", "--name", name, "--label", `brain-ci-build-session=${session}`, "--pull=never", "--read-only", "--network=none", "--cap-drop=ALL", "--security-opt=no-new-privileges", "--user", `${uid}:${gid}`, "--hostname", "brain-build", "--cpus=2", "--memory=4g", "--pids-limit=256", "--shm-size=64m", + "--tmpfs", `/repo:rw,nosuid,nodev,mode=0700,uid=${uid},gid=${gid},size=64m`, "--tmpfs", "/tmp:rw,exec,nosuid,nodev,mode=1777,size=256m"]; + const allMounts = [...mounts, { source: tool, target: "/tool", readonly: true }, { source: entryPath, target: "/operation/entry.json", readonly: true }, + ...["hosts", "hostname", "resolv.conf"].map(file => ({ source: join(hostFiles, file), target: `/etc/${file}`, readonly: true }))]; + writeFileSync(entryPath, JSON.stringify({ version: 1, originalRoot: root, canary, selected: pending, jobs: options.jobs, topology, rootNames, node: policy.node.path, + readonlyTargets: allMounts.filter(mount => mount.readonly).map(mount => mount.target), writableTargets: allMounts.filter(mount => !mount.readonly).map(mount => mount.target) })); + for (const mount of allMounts) { + if (/[\r\n,]/.test(mount.source + mount.target)) throw new Error("Unsupported fixed build mount path"); + argv.push("--mount", `type=bind,src=${mount.source},dst=${mount.target}${mount.readonly ? ",readonly" : ""}`); + } + argv.push("--workdir", "/repo", policy.image, "/usr/bin/env", "-i", "PATH=/tool:/usr/bin:/bin", "HOME=/tmp/home", "LANG=C.UTF-8", "LC_ALL=C.UTF-8", "TZ=UTC", "/usr/bin/timeout", "--signal=TERM", "--kill-after=5s", "300s", "/tool/bun", "/repo/scripts/ci-build-entry.ts"); + writeFileSync(join(state, "invocation.json"), JSON.stringify({ version: 1, session, key, argv, mounts: allMounts, captured, derived: derivedIdentities, runtime: { image: policy.image, bun: bunHash }, lifetime: { producerSeconds: 300, killGraceSeconds: 5, clientSeconds: 330, dockerControlMilliseconds: 10_000 }, scope, qualificationOnly }, null, 2)); + const compileStart = performance.now(); + child = Bun.spawn(["/usr/bin/timeout", "--signal=TERM", "--kill-after=5s", "330s", "/usr/bin/docker", "--host", "unix:///var/run/docker.sock", "--config", dockerConfig, ...argv], { stdout: "inherit", stderr: "inherit", env: { PATH: "/usr/bin:/bin" } }); + containerKnown = true; + const code = await child.exited; + phases.producerMs = performance.now() - compileStart; + const status = JSON.parse(docker(dockerConfig, "inspect", name)) as Array<{ State: { Running: boolean }; Config: { Image: string; Labels: Record }; + HostConfig: { ReadonlyRootfs: boolean; NetworkMode: string; Privileged: boolean; CapDrop: string[]; SecurityOpt: string[]; Tmpfs: Record }; + Mounts: Array<{ Type: string; Source: string; Destination: string; RW: boolean }> }>; + if (status.length !== 1 || status[0]!.Config.Labels["brain-ci-build-session"] !== session || status[0]!.State.Running) throw new Error("Build container did not drain"); + drained = true; + const actual = status[0]!, host = actual.HostConfig; + if (actual.Config.Image !== policy.image || host.ReadonlyRootfs !== true || host.NetworkMode !== "none" || host.Privileged !== false || + !host.CapDrop?.includes("ALL") || !host.SecurityOpt?.some(value => value === "no-new-privileges" || value === "no-new-privileges=true") || + identity(Object.keys(host.Tmpfs ?? {}).sort()) !== identity(["/repo", "/tmp"])) throw new Error("Actual build container boundary changed"); + const actualBinds = actual.Mounts.filter(mount => mount.Type === "bind").map(mount => ({ source: mount.Source, target: mount.Destination, readonly: !mount.RW })).sort((a, b) => a.target.localeCompare(b.target)); + if (actual.Mounts.some(mount => !["bind", "tmpfs"].includes(mount.Type)) || identity(actualBinds) !== identity([...allMounts].sort((a, b) => a.target.localeCompare(b.target)))) throw new Error("Actual build mounts differ from captured inputs"); + if (interrupted || code !== 0) throw new Error(`Isolated compiler failed (${code})`); + const producer = ownedJSON(join(state, "diagnostics/isolated-producer.json")).value; + if (!successfulProducer(producer, pending, policy, bunHash, qualificationOnly)) throw new Error("Consumed runtime or compiler receipt mismatch"); + // Removal is part of success, not best-effort work after publication. + cleanup(); + if (interrupted) throw new Error("Isolated compiler interrupted before publication"); + const outputs: Record = {}, publishStart = performance.now(); for (const pkg of pending) { const staged = join(state, "validated", pkg); - const destination = join(root, "packages", pkg, "dist"); - rmSync(destination, { recursive: true, force: true }); renameSync(staged, destination); - if (identity(outputFiles(destination)) !== outputs[pkg]) throw new Error("Published build output changed"); + outputs[pkg] = identity(captureOutputs(join(state, "parents", pkg, "dist"), staged)); } - // No new package stamp is written until every selected publication and - // its verification have completed after the real container drained. - const record = JSON.stringify({ version: 1, operation: "fixed-isolated-ci-v1", session, key, image: policy.image, drained: true, compiled: pending, derived: derivedIdentities, outputs, producer }); - writeFileSync(join(stampDirectory, `${session}.json`), record, { flag: "wx", mode: 0o600 }); - for (const pkg of pending) { - const stamp = join(stampDirectory, `${pkg}.json`); - writeFileSync(`${stamp}.writing`, JSON.stringify({ version: 1, operation: "fixed-isolated-ci-v1", key, output: outputs[pkg], producerSession: session, receipt: digest(record) }), { mode: 0o600 }); renameSync(`${stamp}.writing`, stamp); + if (!qualificationOnly) { + for (const pkg of pending) { + const staged = join(state, "validated", pkg); + const destination = join(root, "packages", pkg, "dist"); + rmSync(destination, { recursive: true, force: true }); renameSync(staged, destination); + if (identity(outputFiles(destination)) !== outputs[pkg]) throw new Error("Published build output changed"); + } + // No new package stamp is written until every selected publication and + // its verification have completed after the real container drained. + const record = JSON.stringify({ version: 1, operation: "fixed-isolated-ci-v1", session, key, image: policy.image, drained: true, removed: true, compiled: pending, derived: derivedIdentities, outputs, producer }); + writeFileSync(join(stampDirectory, `${session}.json`), record, { flag: "wx", mode: 0o600 }); + for (const pkg of pending) { + const stamp = join(stampDirectory, `${pkg}.json`); + writeFileSync(`${stamp}.writing`, JSON.stringify({ version: 1, operation: "fixed-isolated-ci-v1", key, output: outputs[pkg], producerSession: session, receipt: digest(record) }), { mode: 0o600 }); renameSync(`${stamp}.writing`, stamp); + } } + phases.publishMs = performance.now() - publishStart; + operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: qualificationOnly ? "unqualified" : "compiled", action: qualificationOnly ? "qualification-only" : "publish", session, key, selected, reused, compiled: pending, outputs, producer, drained, removed, phases, elapsedMs: performance.now() - started }); + if (qualificationOnly) console.log(`Unqualified build artifacts retained at ${state}; no reuse stamp or workspace output published.`); + return 0; + } catch (error) { + if (containerKnown) preserve = true; + operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: containerKnown ? "failed" : "unsupported", action: containerKnown ? "fail" : "ordinary-rebuild", reason: containerKnown ? "producer-incomplete" : "boundary-unavailable", phases }); + if (!containerKnown && !qualificationOnly) return; + throw error; } - phases.publishMs = performance.now() - publishStart; - operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: qualificationOnly ? "unqualified" : "compiled", action: qualificationOnly ? "qualification-only" : "publish", session, key, selected, reused, compiled: pending, outputs, producer, phases, elapsedMs: performance.now() - started }); - if (qualificationOnly) console.log(`Unqualified build artifacts retained at ${state}; no reuse stamp or workspace output published.`); - return 0; - } catch (error) { - operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: containerKnown ? "failed" : "unsupported", action: containerKnown ? "fail" : "ordinary-rebuild", reason: containerKnown ? "producer-incomplete" : "boundary-unavailable", phases }); - if (!containerKnown && !qualificationOnly) return; - throw error; - } finally { - if (containerKnown) { - try { - const status = JSON.parse(docker(dockerConfig, "inspect", name)) as Array<{ State: { Running: boolean }; Config: { Labels: Record } }>; - if (status.length !== 1 || status[0]!.Config.Labels["brain-ci-build-session"] !== session) throw new Error("Unknown container ownership"); - if (status[0]!.State.Running) docker(dockerConfig, "stop", "--time", "5", name); - docker(dockerConfig, "wait", name); drained = true; - docker(dockerConfig, "rm", name); - } catch { preserve = true; } + }; + let result: number | undefined, failure: unknown, failed = false; + try { result = await perform(); } catch (error) { failure = error; failed = true; } + let cleanupError: unknown; + try { + if (containerKnown && !removed) { + try { cleanup(); } + catch (error) { + preserve = true; cleanupError = error; + operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: "failed", action: "fail", session, reason: "owned-container-cleanup-incomplete", drained, removed: false, phases }); + } } - if (containerKnown && !drained) preserve = true; + if (containerKnown && (!drained || !removed)) preserve = true; if (!preserve) writableCleanup(state); - } + } catch (error) { cleanupError = error; } + finally { for (const signal of signals) process.off(signal, interrupt); } + if (cleanupError) throw cleanupError; + if (failed) throw failure; + return result; } if (import.meta.main) { diff --git a/scripts/generated-build-inputs.ts b/scripts/generated-build-inputs.ts index 6a5c3d5135..06f44eba01 100644 --- a/scripts/generated-build-inputs.ts +++ b/scripts/generated-build-inputs.ts @@ -6,10 +6,12 @@ import { isAbsolute, relative, resolve } from "node:path"; // Tailwind CLI, build-before-browser order, checked exits and post-success receipt. // This deliberately supports one audited producer, not arbitrary build tools. const auditedProducer: Record = { + "scripts/ci-build-files.ts": "29c2ca0fa5640ae98ab7860a8289d9b48e533ccbf150333f3555fa9593a088ac", + "scripts/ci-build-lifetime.ts": "ff717d82e99cbeb52472bfb37e5b628ef11d470610dd583be371207f164878d2", "scripts/ci-build-qualification.json": "31044cc0055d43353ae235daf56f88b4d24f40fef9ea5aac96f69421f5c20fc6", - "scripts/ci-build-entry.ts": "1c7ab967983e79caba48ffb342a9ef062f67dbea63dda15046c3baab4458c3bd", - "scripts/ci-build-operation.ts": "2cb7a8109551dadd81bcb9f9958ba26bdf987cee1c3f6a4a4ca65890da9facf2", - "scripts/ci-build-boundary.ts": "2ef58d24f20a410150a48144382bc5b053d482fc378de2bd6abece28833ae54f", + "scripts/ci-build-entry.ts": "e7d2891c6b0737066f0f6818ed2f774b55d59549e81610ac2db264cb71dd6809", + "scripts/ci-build-operation.ts": "ce844becca2991fed45cd9feb69d3bd2394689364cdb2ba778c73e1da7ccc949", + "scripts/ci-build-boundary.ts": "4f8c6fc11d838adff3dc91f4df75042f6d22bf76819c3519579c82b22d0e9e5e", "scripts/build-execution.ts": "17ee42de1b37aa8034c6b0719b32ec432ac8fde321fb9c2c3a426799ea996a5e", "scripts/build.ts": "30c1ecc7272921efe4e437b23e3d088e03d38869a9f5b2365fc08592e248d8d8", "scripts/build-receipt.ts": "567c3d13b5e3e1295f597e4f57a9d0d9912685f2669cf3872daa26c2931bd1e6", diff --git a/tests/build-concurrency.test.ts b/tests/build-concurrency.test.ts index 5daf0ebd11..3ef71824ed 100644 --- a/tests/build-concurrency.test.ts +++ b/tests/build-concurrency.test.ts @@ -36,7 +36,7 @@ async function compilerDrain(storageFailure?: StorageFailure) { const release = join(root, "release"); try { mkdirSync(join(root, "scripts")); - for (const file of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { + for (const file of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-files.ts", "ci-build-lifetime.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { copyFileSync(join(repo, "scripts", file), join(root, "scripts", file)); } mkdirSync(join(root, "node_modules/.bin"), { recursive: true }); @@ -127,7 +127,7 @@ async function successfulBuild(storageFailure?: StorageFailure) { const root = mkdtempSync(join(tmpdir(), "odysseus-compiler-levels-")); try { mkdirSync(join(root, "scripts")); - for (const file of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { + for (const file of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-files.ts", "ci-build-lifetime.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { copyFileSync(join(repo, "scripts", file), join(root, "scripts", file)); } mkdirSync(join(root, "node_modules/.bin"), { recursive: true }); diff --git a/tests/build-freshness.test.ts b/tests/build-freshness.test.ts index b57ab84d24..d7f2594ec0 100644 --- a/tests/build-freshness.test.ts +++ b/tests/build-freshness.test.ts @@ -18,7 +18,7 @@ function fixture() { write(root, "bun.lock", "{}\n"); write(root, "tsconfig.json", JSON.stringify({ compilerOptions: { target: "ES2019", module: "ESNext", moduleResolution: "Bundler", types: [], skipLibCheck: true } })); mkdirSync(join(root, "scripts")); - for (const file of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { + for (const file of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-files.ts", "ci-build-lifetime.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { copyFileSync(join(repo, "scripts", file), join(root, "scripts", file)); } const compiler = join(root, "node_modules/typescript"); diff --git a/tests/ci-build-boundary.test.ts b/tests/ci-build-boundary.test.ts index ef89142eab..c4f2e27c3f 100644 --- a/tests/ci-build-boundary.test.ts +++ b/tests/ci-build-boundary.test.ts @@ -1,5 +1,6 @@ -import { afterEach, expect, test } from "bun:test"; +import { afterEach, expect, spyOn, test } from "bun:test"; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import * as childProcess from "node:child_process"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildMounts, captureBuildInputs, captureDerivedOutput, captureOutputs, identity, outputFiles, sameOutputIdentity } from "../scripts/ci-build-boundary"; @@ -82,10 +83,13 @@ test("derived output changed between identity check and capture refuses consumpt test("CI intent cannot authorize unqualified reuse", async () => { const root = mkdtempSync(join(tmpdir(), "odysseus-ci-intent-")); owned.push(root); const before = process.env.BRAIN_CI_BUILD_OPERATION; + const child = spyOn(childProcess, "spawnSync").mockImplementation(() => { throw new Error("Unexpected daemon control"); }); try { process.env.BRAIN_CI_BUILD_OPERATION = "isolated-v1"; expect(await attemptCiBuild(root, { jobs: 1, force: false }), "CI_BUILD_INTENT_IS_NOT_AUTHORITY").toBeUndefined(); + expect(child, "CI_BUILD_UNQUALIFIED_LOOKUP_HAS_ZERO_DOCKER").not.toHaveBeenCalled(); } finally { + child.mockRestore(); if (before === undefined) delete process.env.BRAIN_CI_BUILD_OPERATION; else process.env.BRAIN_CI_BUILD_OPERATION = before; } }); diff --git a/tests/ci-build-files.test.ts b/tests/ci-build-files.test.ts new file mode 100644 index 0000000000..8471ac800c --- /dev/null +++ b/tests/ci-build-files.test.ts @@ -0,0 +1,36 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { buildRead } from "../scripts/ci-build-files"; +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); }); +function fixture() { + const root = fs.mkdtempSync(join(tmpdir(), "odysseus-build-fd-")); roots.push(root); + fs.mkdirSync(join(root, "source")); fs.mkdirSync(join(root, "outside")); + fs.writeFileSync(join(root, "source/file"), "Ithaca"); fs.writeFileSync(join(root, "outside/file"), "Pylos"); + return root; +} +for (const kind of ["alias", "fifo", "ancestor"] as const) test(`anchored build capture refuses an actual ${kind} swap`, () => { + const root = fixture(), file = join(root, "source/file"); + const original = fs.openSync; let swapped = false; + const open = spyOn(fs, "openSync").mockImplementation(((path: fs.PathLike, ...args: any[]) => { + if (!swapped && String(path).startsWith("/proc/self/fd/") && String(path).endsWith("/file")) { + swapped = true; + if (kind === "ancestor") { fs.renameSync(join(root, "source"), join(root, "prior")); fs.symlinkSync("outside", join(root, "source")); } + else { + fs.unlinkSync(file); + if (kind === "alias") fs.symlinkSync("../outside/file", file); + else { const result = Bun.spawnSync(["/usr/bin/mkfifo", file]); expect(result.exitCode).toBe(0); } + } + } + return (original as any)(path, ...args); + }) as typeof fs.openSync); + try { expect(() => buildRead(file), `CI_BUILD_${kind.toUpperCase()}_SWAP_REFUSED`).toThrow(); expect(swapped).toBe(true); } + finally { open.mockRestore(); } +}); +test("anchored capture bounds regular-file bytes and retains mode", () => { + const root = fixture(), file = join(root, "source/file"); fs.chmodSync(file, 0o640); + expect(() => buildRead(file, undefined, 5), "CI_BUILD_FILE_BUDGET_REFUSAL").toThrow("Unsupported"); + const captured = buildRead(file); expect(captured.bytes.toString()).toBe("Ithaca"); expect(Number(captured.stat.mode & 0o777n)).toBe(0o640); +}); diff --git a/tests/ci-build-lifetime.test.ts b/tests/ci-build-lifetime.test.ts new file mode 100644 index 0000000000..1ae64dfe3a --- /dev/null +++ b/tests/ci-build-lifetime.test.ts @@ -0,0 +1,24 @@ +import { expect, spyOn, test } from "bun:test"; +import * as child from "node:child_process"; +import { dockerControl, removeOwnedBuildContainer } from "../scripts/ci-build-lifetime"; +const session = "00000000-0000-4000-8000-000000000001", name = `brain-ci-build-${session}`; +for (const failure of ["none", "rm", "still-present", "foreign"] as const) test(`owned fixed-build cleanup ${failure}`, () => { + const calls: string[][] = []; + const spawn = spyOn(child, "spawnSync").mockImplementation(((exe: string, args: string[], options: any) => { + expect(exe).toBe("/usr/bin/docker"); expect(options.timeout, "CI_BUILD_DOCKER_CONTROL_BOUNDED").toBe(10_000); expect(options.killSignal).toBe("SIGKILL"); + expect(args.slice(0, 4)).toEqual(["--host", "unix:///var/run/docker.sock", "--config", "/owned/config"]); + const argv = args.slice(4); calls.push(argv); + const out = argv[0] === "inspect" ? JSON.stringify([{ State: { Running: true }, Config: { Labels: { "brain-ci-build-session": failure === "foreign" ? "foreign" : session } } }]) : argv[0] === "container" && failure === "still-present" ? name : ""; + return { status: argv[0] === "rm" && failure === "rm" ? 1 : 0, signal: null, stdout: Buffer.from(out), stderr: Buffer.alloc(0) }; + }) as typeof child.spawnSync); + try { + if (failure === "none") { removeOwnedBuildContainer("/owned/config", name, session); expect(calls.map(argv => argv[0])).toEqual(["inspect", "stop", "wait", "rm", "container"]); } + else expect(() => removeOwnedBuildContainer("/owned/config", name, session), "CI_BUILD_CLEANUP_FAILURE_REFUSES_SUCCESS").toThrow(); + if (failure === "foreign") expect(calls).toHaveLength(1); + } finally { spawn.mockRestore(); } +}); +test("timed-out local Docker control fails closed", () => { + const spawn = spyOn(child, "spawnSync").mockReturnValue({ status: null, signal: "SIGKILL", error: Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }) } as any); + try { expect(() => dockerControl("/owned/config", "inspect", name), "CI_BUILD_CONTROL_TIMEOUT_REFUSES_SUCCESS").toThrow("failed"); } + finally { spawn.mockRestore(); } +}); diff --git a/tests/generated-build-inputs.test.ts b/tests/generated-build-inputs.test.ts index 19cb49574e..610bb05b42 100644 --- a/tests/generated-build-inputs.test.ts +++ b/tests/generated-build-inputs.test.ts @@ -6,7 +6,7 @@ import { generatedCssInputs, generatedCssReadInputs } from "../scripts/generated import { discoverInputClosure } from "../scripts/browser-input-closure"; const repo=resolve(import.meta.dir,".."),roots:string[]=[]; afterEach(()=>{for(const root of roots.splice(0))rmSync(root,{recursive:true,force:true});}); -const producer=["scripts/build.ts","scripts/build-execution.ts","scripts/ci-build-boundary.ts","scripts/ci-build-operation.ts","scripts/ci-build-entry.ts","scripts/ci-build-qualification.json","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json"]; +const producer=["scripts/build.ts","scripts/build-execution.ts","scripts/ci-build-boundary.ts","scripts/ci-build-files.ts","scripts/ci-build-lifetime.ts","scripts/ci-build-operation.ts","scripts/ci-build-entry.ts","scripts/ci-build-qualification.json","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json"]; function fixture(){ const root=mkdtempSync(join(tmpdir(),"generated-css-"));roots.push(root); const write=(file:string,bytes:string)=>{mkdirSync(dirname(join(root,file)),{recursive:true});writeFileSync(join(root,file),bytes);}; diff --git a/tests/generated-theme-inputs.test.ts b/tests/generated-theme-inputs.test.ts index 82104a0204..c68421883e 100644 --- a/tests/generated-theme-inputs.test.ts +++ b/tests/generated-theme-inputs.test.ts @@ -5,7 +5,7 @@ import {dirname,join,resolve} from "node:path"; import {browserImporters} from "../scripts/bound-browser-runtime-inputs"; import {generatedThemeInputs} from "../scripts/generated-theme-inputs"; const repo=resolve(import.meta.dir,".."),roots:string[]=[],owner="packages/ui-kit/tests/visual/dictation-motion.ts",caller="packages/ui-kit/vitest.config.ts",entry="packages/ui-kit/tests/visual/dictation-theme.css"; -const producers=["scripts/build.ts","scripts/build-execution.ts","scripts/ci-build-boundary.ts","scripts/ci-build-operation.ts","scripts/ci-build-entry.ts","scripts/ci-build-qualification.json","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json","bun.lock","packages/ui-react/src/theme.css","packages/ui-react/package.json"]; +const producers=["scripts/build.ts","scripts/build-execution.ts","scripts/ci-build-boundary.ts","scripts/ci-build-files.ts","scripts/ci-build-lifetime.ts","scripts/ci-build-operation.ts","scripts/ci-build-entry.ts","scripts/ci-build-qualification.json","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json","bun.lock","packages/ui-react/src/theme.css","packages/ui-react/package.json"]; afterEach(()=>{for(const root of roots.splice(0))rmSync(root,{recursive:true,force:true});}); function fixture(){const root=mkdtempSync(join(tmpdir(),"generated-theme-"));roots.push(root);const write=(file:string,text:string)=>{mkdirSync(dirname(join(root,file)),{recursive:true});writeFileSync(join(root,file),text);};for(const file of [...producers,owner,caller,entry])write(file,readFileSync(join(repo,file),"utf8"));symlinkSync(join(repo,"node_modules"),join(root,"node_modules"),"dir");return{root,write};} test("temporary theme output binds the audited command, copied theme and installed compiler",()=>{ diff --git a/tests/inclusion-inputs.test.ts b/tests/inclusion-inputs.test.ts index 2035400fac..67a05c2c6c 100644 --- a/tests/inclusion-inputs.test.ts +++ b/tests/inclusion-inputs.test.ts @@ -81,7 +81,7 @@ test("validated installed asset bytes and mode join immutable source fingerprint const installed=resolve(import.meta.dir,"../node_modules"); for(const entry of readdirSync(installed)) if(entry!=="axe-core") symlinkSync(join(installed,entry),join(root,"node_modules",entry),"dir"); const owner="packages/ui-react/tests/browser/offline/cold-capture/commands.ts"; - for(const file of [owner,"scripts/build.ts","scripts/build-execution.ts","scripts/ci-build-boundary.ts","scripts/ci-build-operation.ts","scripts/ci-build-entry.ts","scripts/ci-build-qualification.json","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json","packages/ui-react/package.json","packages/ui-kit/package.json"]) + for(const file of [owner,"scripts/build.ts","scripts/build-execution.ts","scripts/ci-build-boundary.ts","scripts/ci-build-files.ts","scripts/ci-build-lifetime.ts","scripts/ci-build-operation.ts","scripts/ci-build-entry.ts","scripts/ci-build-qualification.json","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json","packages/ui-react/package.json","packages/ui-kit/package.json"]) write(root,file,readFileSync(resolve(import.meta.dir,"..",file),"utf8")); write(root,spec,`import "../../../ui-react/tests/browser/offline/cold-capture/commands.ts";`); write(root,"node_modules/axe-core/package.json",JSON.stringify({name:"axe-core",version:"4.13.0"}));write(root,"node_modules/axe-core/axe.min.js","owned asset before");commit(root); diff --git a/tests/workspace-lease.test.ts b/tests/workspace-lease.test.ts index 5230db36b5..cfab4afdbb 100644 --- a/tests/workspace-lease.test.ts +++ b/tests/workspace-lease.test.ts @@ -115,7 +115,7 @@ test("shared verification overlaps, blocks a writer and rejects a nested build b mkdirSync(join(root, "scripts")); mkdirSync(join(root, "bin")); mkdirSync(join(root, "packages/geo/dist"), { recursive: true }); writeFileSync(join(root, "packages/geo/dist/retained"), "retained\n"); - for (const name of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) copyFileSync(join(project, "scripts", name), join(root, "scripts", name)); + for (const name of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-files.ts", "ci-build-lifetime.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) copyFileSync(join(project, "scripts", name), join(root, "scripts", name)); writeFileSync(join(root, "bin/bunx"), "#!/bin/sh\nexit 77\n", { mode: 0o755 }); const first = start(root, "read", "hold", "first"), second = start(root, "read", "hold", "second"); await Promise.all([entered(root, "first"), entered(root, "second")]); From a8b116a118d9813c4b48ca864317edc222ee088c Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sat, 17 Oct 2026 20:32:12 +0200 Subject: [PATCH 07/15] test(ci): keep receipt faults owned by workspace builds --- tests/build-concurrency.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/build-concurrency.test.ts b/tests/build-concurrency.test.ts index 3ef71824ed..1faa097a29 100644 --- a/tests/build-concurrency.test.ts +++ b/tests/build-concurrency.test.ts @@ -20,7 +20,7 @@ import {mock} from "bun:test"; let launched=false, blocked=false; const spawn=Bun.spawn;Bun.spawn=(...args)=>{const child=spawn(...args);if(Array.isArray(args[0]) && args[0].includes("tsc"))launched=true;return child}; const write=fs.writeFileSync;mock.module("node:fs",()=>({...fs,writeFileSync:(path,...args)=>{ - if(!blocked && String(path).endsWith("-probe.json.writing") && (${JSON.stringify(point)}==="startup" || launched)) { + if(!blocked && /^build-[0-9]+-[0-9a-f-]{36}-probe[.]json[.]writing$/.test(String(path).split("/").at(-1) ?? "") && (${JSON.stringify(point)}==="startup" || launched)) { blocked=true;fs.mkdirSync(path); // Actual EISDIR on the original filesystem write. } return write(path,...args); From 40e53bad96f140618e8f06a446724598a98aadf0 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sat, 17 Oct 2026 20:34:11 +0200 Subject: [PATCH 08/15] fix(ci): address only verified immutable build container IDs --- docs/decisions/isolated-ci-build.md | 6 ++++-- scripts/ci-build-lifetime.ts | 29 ++++++++++++++++++++--------- scripts/ci-build-operation.ts | 21 ++++++++++++--------- scripts/generated-build-inputs.ts | 4 ++-- tests/ci-build-lifetime.test.ts | 21 +++++++++++++++++---- 5 files changed, 55 insertions(+), 26 deletions(-) diff --git a/docs/decisions/isolated-ci-build.md b/docs/decisions/isolated-ci-build.md index adaef6f238..12b9d46b9f 100644 --- a/docs/decisions/isolated-ci-build.md +++ b/docs/decisions/isolated-ci-build.md @@ -69,7 +69,9 @@ The fixed operation requires non-root UID and GID. Each local daemon control has a ten-second limit; the image-owned entry has a 300-second TERM deadline with five-second KILL grace, and its host client has a 330-second limit. These operation bounds require real qualification on all nineteen packages before -activation. They do not alter ordinary builds or test deadlines. Session-owned -container removal and confirmed absence precede output publication and reuse +activation. They do not alter ordinary builds or test deadlines. A session label and immutable container ID are verified before any stop, wait +or removal, including interruption handling. Subsequent mutations and the +absence check address that ID, so replacement of a name grants no authority. +Session-owned container removal and confirmed absence precede output publication and reuse stamps. Unconfirmed cleanup fails, records the incomplete cleanup and retains owned state; it never grants successful reuse authority. diff --git a/scripts/ci-build-lifetime.ts b/scripts/ci-build-lifetime.ts index 490cde101a..b4db84fc00 100644 --- a/scripts/ci-build-lifetime.ts +++ b/scripts/ci-build-lifetime.ts @@ -7,13 +7,24 @@ export function dockerControl(config: string, ...argv: string[]): string { if (result.error || result.signal || result.status !== 0) throw new Error(`Fixed build Docker ${argv[0]} failed`); return result.stdout.toString("utf8").trim(); } -/** Return only after this session's container is removed and absence verified. */ -export function removeOwnedBuildContainer(config: string, name: string, session: string): void { - if (!/^[0-9a-f-]{36}$/.test(session) || name !== `brain-ci-build-${session}`) throw new Error("Invalid build container ownership"); - const status = JSON.parse(dockerControl(config, "inspect", name)) as Array<{ State: { Running: boolean }; Config: { Labels: Record } }>; - if (status.length !== 1 || status[0]!.Config.Labels["brain-ci-build-session"] !== session) throw new Error("Unknown container ownership"); - if (status[0]!.State.Running) dockerControl(config, "stop", "--time", "5", name); - dockerControl(config, "wait", name); - dockerControl(config, "rm", name); - if (dockerControl(config, "container", "ls", "--all", "--filter", `name=^/${name}$`, "--format", "{{.Names}}") !== "") throw new Error("Owned build container removal unconfirmed"); +type Container = { + Id: string; State: { Running: boolean }; Config: { Image: string; Labels: Record }; + HostConfig: { ReadonlyRootfs: boolean; NetworkMode: string; Privileged: boolean; CapDrop: string[]; SecurityOpt: string[]; Tmpfs: Record }; + Mounts: Array<{ Type: string; Source: string; Destination: string; RW: boolean }>; +}; +/** Names select an initial inspection only; all mutations use the verified ID. */ +export function inspectOwnedBuildContainer(config: string, reference: string, session: string) { + if (!/^[0-9a-f-]{36}$/.test(session) || (reference !== `brain-ci-build-${session}` && !/^[0-9a-f]{64}$/.test(reference))) throw new Error("Invalid build container ownership"); + const records = JSON.parse(dockerControl(config, "inspect", reference)) as Container[]; + const record = records[0]; + if (records.length !== 1 || !record || !/^[0-9a-f]{64}$/.test(record.Id) || record.Config?.Labels?.["brain-ci-build-session"] !== session || typeof record.State?.Running !== "boolean" || (/^[0-9a-f]{64}$/.test(reference) && record.Id !== reference)) throw new Error("Unknown container ownership"); + return { id: record.Id, running: record.State.Running, session, record }; +} +/** Return only after this session's immutable container ID is absent. */ +export function removeOwnedBuildContainer(config: string, reference: string, session: string): void { + const owned = inspectOwnedBuildContainer(config, reference, session); + if (owned.running) dockerControl(config, "stop", "--time", "5", owned.id); + dockerControl(config, "wait", owned.id); + dockerControl(config, "rm", owned.id); + if (dockerControl(config, "container", "ls", "--all", "--no-trunc", "--filter", `id=${owned.id}`, "--format", "{{.ID}}") !== "") throw new Error("Owned build container removal unconfirmed"); } diff --git a/scripts/ci-build-operation.ts b/scripts/ci-build-operation.ts index d05090806c..0a760eb978 100644 --- a/scripts/ci-build-operation.ts +++ b/scripts/ci-build-operation.ts @@ -1,5 +1,5 @@ /** One dormant, fixed isolated build operation. CI intent is not authority. */ -import { dockerControl as docker, removeOwnedBuildContainer } from "./ci-build-lifetime"; +import { dockerControl as docker, inspectOwnedBuildContainer, removeOwnedBuildContainer } from "./ci-build-lifetime"; import { buildRead } from "./ci-build-files"; import { randomUUID } from "node:crypto"; import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; @@ -142,11 +142,16 @@ async function execute(root: string, options: Options, policy: Qualification, qu return; } let preserve = qualificationOnly, containerKnown = false, drained = false, removed = false, interrupted = false; - let child: ReturnType | undefined; + let child: ReturnType | undefined, containerID: string | undefined; const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const; const interrupt = () => { interrupted = true; - try { if (containerKnown && !removed) docker(dockerConfig, "stop", "--time", "5", name); } + try { + if (containerKnown && !removed) { + const owned = inspectOwnedBuildContainer(dockerConfig, containerID ?? name, session); containerID = owned.id; + docker(dockerConfig, "stop", "--time", "5", owned.id); + } + } catch { preserve = true; } finally { try { child?.kill("SIGTERM"); } catch { preserve = true; } } }; @@ -154,7 +159,7 @@ async function execute(root: string, options: Options, policy: Qualification, qu for (const signal of signals) process.on(signal, interrupt); const cleanup = () => { try { - removeOwnedBuildContainer(dockerConfig, name, session); + removeOwnedBuildContainer(dockerConfig, containerID ?? name, session); drained = true; removed = true; } catch (error) { preserve = true; @@ -237,12 +242,10 @@ async function execute(root: string, options: Options, policy: Qualification, qu containerKnown = true; const code = await child.exited; phases.producerMs = performance.now() - compileStart; - const status = JSON.parse(docker(dockerConfig, "inspect", name)) as Array<{ State: { Running: boolean }; Config: { Image: string; Labels: Record }; - HostConfig: { ReadonlyRootfs: boolean; NetworkMode: string; Privileged: boolean; CapDrop: string[]; SecurityOpt: string[]; Tmpfs: Record }; - Mounts: Array<{ Type: string; Source: string; Destination: string; RW: boolean }> }>; - if (status.length !== 1 || status[0]!.Config.Labels["brain-ci-build-session"] !== session || status[0]!.State.Running) throw new Error("Build container did not drain"); + const owned = inspectOwnedBuildContainer(dockerConfig, containerID ?? name, session); containerID = owned.id; + if (owned.running) throw new Error("Build container did not drain"); drained = true; - const actual = status[0]!, host = actual.HostConfig; + const actual = owned.record, host = actual.HostConfig; if (actual.Config.Image !== policy.image || host.ReadonlyRootfs !== true || host.NetworkMode !== "none" || host.Privileged !== false || !host.CapDrop?.includes("ALL") || !host.SecurityOpt?.some(value => value === "no-new-privileges" || value === "no-new-privileges=true") || identity(Object.keys(host.Tmpfs ?? {}).sort()) !== identity(["/repo", "/tmp"])) throw new Error("Actual build container boundary changed"); diff --git a/scripts/generated-build-inputs.ts b/scripts/generated-build-inputs.ts index 06f44eba01..7b7c81af26 100644 --- a/scripts/generated-build-inputs.ts +++ b/scripts/generated-build-inputs.ts @@ -7,10 +7,10 @@ import { isAbsolute, relative, resolve } from "node:path"; // This deliberately supports one audited producer, not arbitrary build tools. const auditedProducer: Record = { "scripts/ci-build-files.ts": "29c2ca0fa5640ae98ab7860a8289d9b48e533ccbf150333f3555fa9593a088ac", - "scripts/ci-build-lifetime.ts": "ff717d82e99cbeb52472bfb37e5b628ef11d470610dd583be371207f164878d2", + "scripts/ci-build-lifetime.ts": "54acc253aba0f432bf4a204d34c6da902aaffc508131d02dd046927c6925af5d", "scripts/ci-build-qualification.json": "31044cc0055d43353ae235daf56f88b4d24f40fef9ea5aac96f69421f5c20fc6", "scripts/ci-build-entry.ts": "e7d2891c6b0737066f0f6818ed2f774b55d59549e81610ac2db264cb71dd6809", - "scripts/ci-build-operation.ts": "ce844becca2991fed45cd9feb69d3bd2394689364cdb2ba778c73e1da7ccc949", + "scripts/ci-build-operation.ts": "8fb5c0c8424be481c2476bf178365237ba67580e9b9e8d8b7b4e63ea223be143", "scripts/ci-build-boundary.ts": "4f8c6fc11d838adff3dc91f4df75042f6d22bf76819c3519579c82b22d0e9e5e", "scripts/build-execution.ts": "17ee42de1b37aa8034c6b0719b32ec432ac8fde321fb9c2c3a426799ea996a5e", "scripts/build.ts": "30c1ecc7272921efe4e437b23e3d088e03d38869a9f5b2365fc08592e248d8d8", diff --git a/tests/ci-build-lifetime.test.ts b/tests/ci-build-lifetime.test.ts index 1ae64dfe3a..83a940aaa2 100644 --- a/tests/ci-build-lifetime.test.ts +++ b/tests/ci-build-lifetime.test.ts @@ -1,20 +1,33 @@ import { expect, spyOn, test } from "bun:test"; import * as child from "node:child_process"; import { dockerControl, removeOwnedBuildContainer } from "../scripts/ci-build-lifetime"; +const id = "a".repeat(64), foreignID = "b".repeat(64); const session = "00000000-0000-4000-8000-000000000001", name = `brain-ci-build-${session}`; -for (const failure of ["none", "rm", "still-present", "foreign"] as const) test(`owned fixed-build cleanup ${failure}`, () => { - const calls: string[][] = []; +for (const failure of ["none", "rm", "still-present", "foreign", "name-replaced"] as const) test(`owned fixed-build cleanup ${failure}`, () => { + const calls: string[][] = [], alive = new Set([id, foreignID]); + let currentNameID = id; const spawn = spyOn(child, "spawnSync").mockImplementation(((exe: string, args: string[], options: any) => { expect(exe).toBe("/usr/bin/docker"); expect(options.timeout, "CI_BUILD_DOCKER_CONTROL_BOUNDED").toBe(10_000); expect(options.killSignal).toBe("SIGKILL"); expect(args.slice(0, 4)).toEqual(["--host", "unix:///var/run/docker.sock", "--config", "/owned/config"]); const argv = args.slice(4); calls.push(argv); - const out = argv[0] === "inspect" ? JSON.stringify([{ State: { Running: true }, Config: { Labels: { "brain-ci-build-session": failure === "foreign" ? "foreign" : session } } }]) : argv[0] === "container" && failure === "still-present" ? name : ""; + // A foreign container may now occupy the original name. Commands must + // continue addressing the immutable inspected ID, never resolve that name. + if (["stop", "wait", "rm"].includes(argv[0]!)) { + const target = argv.at(-1) === name ? currentNameID : argv.at(-1); + expect(target, "CI_BUILD_MUTATES_ONLY_INSPECTED_ID").toBe(id); + if (argv[0] === "rm" && failure !== "rm") alive.delete(target!); + } + if (argv[0] === "container") expect(argv).toContain(`id=${id}`); + const inspected = argv.at(-1) === name ? currentNameID : argv.at(-1); + const out = argv[0] === "inspect" ? JSON.stringify([{ Id: inspected, State: { Running: true }, Config: { Labels: { "brain-ci-build-session": failure === "foreign" ? "foreign" : session } } }]) : argv[0] === "container" && (failure === "still-present" || alive.has(id)) ? id : ""; + if (argv[0] === "inspect" && failure === "name-replaced") currentNameID = foreignID; return { status: argv[0] === "rm" && failure === "rm" ? 1 : 0, signal: null, stdout: Buffer.from(out), stderr: Buffer.alloc(0) }; }) as typeof child.spawnSync); try { - if (failure === "none") { removeOwnedBuildContainer("/owned/config", name, session); expect(calls.map(argv => argv[0])).toEqual(["inspect", "stop", "wait", "rm", "container"]); } + if (["none", "name-replaced"].includes(failure)) { removeOwnedBuildContainer("/owned/config", name, session); expect(calls.map(argv => argv[0])).toEqual(["inspect", "stop", "wait", "rm", "container"]); } else expect(() => removeOwnedBuildContainer("/owned/config", name, session), "CI_BUILD_CLEANUP_FAILURE_REFUSES_SUCCESS").toThrow(); if (failure === "foreign") expect(calls).toHaveLength(1); + if (failure === "name-replaced") { expect(currentNameID).toBe(foreignID); expect(alive.has(foreignID), "CI_BUILD_REPLACEMENT_CONTAINER_UNTOUCHED").toBe(true); expect(alive.has(id)).toBe(false); } } finally { spawn.mockRestore(); } }); test("timed-out local Docker control fails closed", () => { From dc827002a0834dd1666fe35b647ec405b0265120 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sat, 17 Oct 2026 20:39:34 +0200 Subject: [PATCH 09/15] fix(ci): initialize private HOME before leased Bun execution --- scripts/ci-build-boundary.ts | 1 - scripts/ci-build-entry.ts | 11 +++++++---- scripts/ci-build-files.ts | 6 ++++++ scripts/ci-build-lifetime.ts | 2 +- scripts/ci-build-operation.ts | 16 +++++++++++----- scripts/generated-build-inputs.ts | 10 +++++----- tests/ci-build-files.test.ts | 11 ++++++++++- 7 files changed, 40 insertions(+), 17 deletions(-) diff --git a/scripts/ci-build-boundary.ts b/scripts/ci-build-boundary.ts index d1df9b7429..10c3c408f7 100644 --- a/scripts/ci-build-boundary.ts +++ b/scripts/ci-build-boundary.ts @@ -169,4 +169,3 @@ export function buildMounts(input: string, state: string, packages: readonly str const diagnostics = join(state, "diagnostics"); mkdirSync(diagnostics, { mode: 0o700 }); mount(diagnostics, "/repo/tmp", false); return { mounts, topology, rootNames: [...readdirSync(input).filter(name => name !== "packages"), "packages", "tmp"].sort() }; } - diff --git a/scripts/ci-build-entry.ts b/scripts/ci-build-entry.ts index 748369a4a5..9aadef9a2c 100644 --- a/scripts/ci-build-entry.ts +++ b/scripts/ci-build-entry.ts @@ -1,7 +1,7 @@ /** The only program run by the fixed CI build container. Not a plugin host. */ -import { buildRead } from "./ci-build-files"; +import { buildRead, checkBuildPrivateDirectory } from "./ci-build-files"; import assert from "node:assert/strict"; -import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { digest } from "./ci-build-boundary"; import { executeBuild } from "./build-execution"; @@ -9,7 +9,7 @@ import { listPublishablePackages } from "./publishable-packages"; import { ensureWorkspaceLease } from "./workspace-lease.mjs"; type Entry = { - version: 1; originalRoot: string; canary: string; selected: string[]; jobs: 1 | 2; + version: 1; uid: number; gid: number; originalRoot: string; canary: string; selected: string[]; jobs: 1 | 2; topology: Record; rootNames: string[]; node: string; readonlyTargets: string[]; writableTargets: string[]; }; @@ -19,6 +19,8 @@ if (import.meta.main) { process.umask(0o022); const entry = JSON.parse(buildRead("/operation/entry.json", undefined, 1024 * 1024).bytes.toString("utf8")) as Entry; assert.equal(entry.version, 1); + assert(entry.uid > 0 && entry.gid > 0, "CI_BUILD_NONROOT_IDENTITY"); + assert.equal(process.getuid!(), entry.uid, "CI_BUILD_EXPECTED_UID"); assert.equal(process.getgid!(), entry.gid, "CI_BUILD_EXPECTED_GID"); assert(!existsSync(entry.originalRoot), "CI_BUILD_ORIGINAL_CHECKOUT_UNMOUNTED"); assert(!existsSync(entry.canary), "CI_BUILD_HOST_DATA_UNMOUNTED"); const mounts = readFileSync("/proc/self/mountinfo", "utf8").trim().split("\n").map(line => { @@ -40,7 +42,8 @@ if (import.meta.main) { assert.deepEqual(readdirSync(`/repo/packages/${pkg}`).sort(), names, "CI_BUILD_INITIAL_PACKAGE_TOPOLOGY"); } const runtime = { bun: digest(buildRead("/tool/bun").bytes), node: { path: entry.node, sha256: digest(buildRead(entry.node).bytes) } }; - mkdirSync("/tmp/home", { mode: 0o700 }); mkdirSync("/tmp/cache", { mode: 0o700 }); + assert(mounts.some(mount => mount.target === "/tmp" && mount.type === "tmpfs" && mount.options.split(",").includes("rw")), "CI_BUILD_FRESH_PRIVATE_TMPFS"); + checkBuildPrivateDirectory("/tmp/home"); checkBuildPrivateDirectory("/tmp/cache"); process.env.TMPDIR = "/tmp"; process.env.XDG_CACHE_HOME = "/tmp/cache"; const packages = listPublishablePackages("/repo").filter(pkg => entry.selected.includes(pkg.dir)); assert.equal(packages.length, entry.selected.length, "CI_BUILD_ONLY_VALIDATED_PENDING_PACKAGES"); diff --git a/scripts/ci-build-files.ts b/scripts/ci-build-files.ts index fbed3007c2..a301f51492 100644 --- a/scripts/ci-build-files.ts +++ b/scripts/ci-build-files.ts @@ -60,3 +60,9 @@ export function buildLink(path: string, expected: BigIntStats): string { return target; }); } + +/** The fixed image bootstrap creates these inside its fresh operation tmpfs. */ +export function checkBuildPrivateDirectory(path: string): void { + const stat = buildStat(path); + if (!stat.isDirectory() || stat.uid !== BigInt(process.getuid!()) || stat.gid !== BigInt(process.getgid!()) || (stat.mode & 0o7777n) !== 0o700n) throw new Error("Invalid private build directory"); +} diff --git a/scripts/ci-build-lifetime.ts b/scripts/ci-build-lifetime.ts index b4db84fc00..1312bd26e1 100644 --- a/scripts/ci-build-lifetime.ts +++ b/scripts/ci-build-lifetime.ts @@ -8,7 +8,7 @@ export function dockerControl(config: string, ...argv: string[]): string { return result.stdout.toString("utf8").trim(); } type Container = { - Id: string; State: { Running: boolean }; Config: { Image: string; Labels: Record }; + Id: string; State: { Running: boolean }; Config: { Image: string; User: string; Labels: Record }; HostConfig: { ReadonlyRootfs: boolean; NetworkMode: string; Privileged: boolean; CapDrop: string[]; SecurityOpt: string[]; Tmpfs: Record }; Mounts: Array<{ Type: string; Source: string; Destination: string; RW: boolean }>; }; diff --git a/scripts/ci-build-operation.ts b/scripts/ci-build-operation.ts index 0a760eb978..8b7bcc2144 100644 --- a/scripts/ci-build-operation.ts +++ b/scripts/ci-build-operation.ts @@ -22,6 +22,8 @@ const producerPaths = ["scripts/build.ts", "scripts/build-execution.ts", "script const configuration = (entry: BuildFile) => !/(^|\/)node_modules(?:\/|$)/.test(entry.path) && (/(^|\/)package\.json$/.test(entry.path) || /(^|\/)tsconfig[^/]*\.json$/.test(entry.path) || /(^|\/)bunfig\.toml$/.test(entry.path) || entry.path.endsWith(".css")); const launcher = '#!/bin/sh\nexec /tool/bun x --no-install "$@"\n'; +// Run once before Bun and its lease coordinator can initialize HOME. +const entryLauncher = '#!/bin/sh\nset -eu\numask 077\n/usr/bin/mkdir -m 0700 /tmp/home /tmp/cache\nexec /tool/bun /repo/scripts/ci-build-entry.ts\n'; function readPolicy(): Qualification { const policy = JSON.parse(buildRead(policyPath, undefined, 1024 * 1024).bytes.toString("utf8")) as Qualification; if (policy.image !== fixedImage || policy.node.path !== "/usr/bin/node") throw new Error("Unknown fixed build operation"); @@ -159,11 +161,13 @@ async function execute(root: string, options: Options, policy: Qualification, qu for (const signal of signals) process.on(signal, interrupt); const cleanup = () => { try { - removeOwnedBuildContainer(dockerConfig, containerID ?? name, session); + const owned = inspectOwnedBuildContainer(dockerConfig, containerID ?? name, session); containerID = owned.id; + removeOwnedBuildContainer(dockerConfig, owned.id, session); drained = true; removed = true; + writeFileSync(join(state, "cleanup.json"), JSON.stringify({ version: 1, session, containerID, drained, removed }), { flag: "wx", mode: 0o600 }); } catch (error) { preserve = true; - operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: "failed", action: "fail", session, reason: "owned-container-cleanup-incomplete", drained, removed: false }); + operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: "failed", action: "fail", session, reason: removed ? "cleanup-audit-unavailable" : "owned-container-cleanup-incomplete", drained, removed }); throw error; } }; @@ -221,6 +225,7 @@ async function execute(root: string, options: Options, policy: Qualification, qu const tool = join(state, "tool"); mkdirSync(tool, { mode: 0o700 }); writeFileSync(join(tool, "bun"), bun, { mode: 0o755, flag: "wx" }); chmodSync(join(tool, "bun"), 0o755); writeFileSync(join(tool, "bunx"), launcher, { mode: 0o755, flag: "wx" }); chmodSync(join(tool, "bunx"), 0o755); + writeFileSync(join(tool, "entry.sh"), entryLauncher, { mode: 0o700, flag: "wx" }); chmodSync(join(tool, "entry.sh"), 0o700); const hostFiles = join(state, "host-files"); mkdirSync(hostFiles, { mode: 0o700 }); for (const [file, bytes] of Object.entries({ hosts: "127.0.0.1 localhost brain-build\n::1 localhost\n", hostname: "brain-build\n", "resolv.conf": "" })) writeFileSync(join(hostFiles, file), bytes, { flag: "wx" }); const entryPath = join(state, "entry.json"), canary = join(state, "ambient-canary"); writeFileSync(canary, "Odysseus stays outside the build"); @@ -229,13 +234,13 @@ async function execute(root: string, options: Options, policy: Qualification, qu "--tmpfs", `/repo:rw,nosuid,nodev,mode=0700,uid=${uid},gid=${gid},size=64m`, "--tmpfs", "/tmp:rw,exec,nosuid,nodev,mode=1777,size=256m"]; const allMounts = [...mounts, { source: tool, target: "/tool", readonly: true }, { source: entryPath, target: "/operation/entry.json", readonly: true }, ...["hosts", "hostname", "resolv.conf"].map(file => ({ source: join(hostFiles, file), target: `/etc/${file}`, readonly: true }))]; - writeFileSync(entryPath, JSON.stringify({ version: 1, originalRoot: root, canary, selected: pending, jobs: options.jobs, topology, rootNames, node: policy.node.path, + writeFileSync(entryPath, JSON.stringify({ version: 1, uid, gid, originalRoot: root, canary, selected: pending, jobs: options.jobs, topology, rootNames, node: policy.node.path, readonlyTargets: allMounts.filter(mount => mount.readonly).map(mount => mount.target), writableTargets: allMounts.filter(mount => !mount.readonly).map(mount => mount.target) })); for (const mount of allMounts) { if (/[\r\n,]/.test(mount.source + mount.target)) throw new Error("Unsupported fixed build mount path"); argv.push("--mount", `type=bind,src=${mount.source},dst=${mount.target}${mount.readonly ? ",readonly" : ""}`); } - argv.push("--workdir", "/repo", policy.image, "/usr/bin/env", "-i", "PATH=/tool:/usr/bin:/bin", "HOME=/tmp/home", "LANG=C.UTF-8", "LC_ALL=C.UTF-8", "TZ=UTC", "/usr/bin/timeout", "--signal=TERM", "--kill-after=5s", "300s", "/tool/bun", "/repo/scripts/ci-build-entry.ts"); + argv.push("--workdir", "/repo", policy.image, "/usr/bin/env", "-i", "PATH=/tool:/usr/bin:/bin", "HOME=/tmp/home", "LANG=C.UTF-8", "LC_ALL=C.UTF-8", "TZ=UTC", "/usr/bin/timeout", "--signal=TERM", "--kill-after=5s", "300s", "/bin/sh", "/tool/entry.sh"); writeFileSync(join(state, "invocation.json"), JSON.stringify({ version: 1, session, key, argv, mounts: allMounts, captured, derived: derivedIdentities, runtime: { image: policy.image, bun: bunHash }, lifetime: { producerSeconds: 300, killGraceSeconds: 5, clientSeconds: 330, dockerControlMilliseconds: 10_000 }, scope, qualificationOnly }, null, 2)); const compileStart = performance.now(); child = Bun.spawn(["/usr/bin/timeout", "--signal=TERM", "--kill-after=5s", "330s", "/usr/bin/docker", "--host", "unix:///var/run/docker.sock", "--config", dockerConfig, ...argv], { stdout: "inherit", stderr: "inherit", env: { PATH: "/usr/bin:/bin" } }); @@ -246,7 +251,8 @@ async function execute(root: string, options: Options, policy: Qualification, qu if (owned.running) throw new Error("Build container did not drain"); drained = true; const actual = owned.record, host = actual.HostConfig; - if (actual.Config.Image !== policy.image || host.ReadonlyRootfs !== true || host.NetworkMode !== "none" || host.Privileged !== false || + writeFileSync(join(state, "container-boundary.json"), JSON.stringify({ version: 1, session, containerID, actual }, null, 2), { flag: "wx", mode: 0o600 }); + if (actual.Config.Image !== policy.image || actual.Config.User !== `${uid}:${gid}` || host.ReadonlyRootfs !== true || host.NetworkMode !== "none" || host.Privileged !== false || !host.CapDrop?.includes("ALL") || !host.SecurityOpt?.some(value => value === "no-new-privileges" || value === "no-new-privileges=true") || identity(Object.keys(host.Tmpfs ?? {}).sort()) !== identity(["/repo", "/tmp"])) throw new Error("Actual build container boundary changed"); const actualBinds = actual.Mounts.filter(mount => mount.Type === "bind").map(mount => ({ source: mount.Source, target: mount.Destination, readonly: !mount.RW })).sort((a, b) => a.target.localeCompare(b.target)); diff --git a/scripts/generated-build-inputs.ts b/scripts/generated-build-inputs.ts index 7b7c81af26..3a779cfbcc 100644 --- a/scripts/generated-build-inputs.ts +++ b/scripts/generated-build-inputs.ts @@ -6,12 +6,12 @@ import { isAbsolute, relative, resolve } from "node:path"; // Tailwind CLI, build-before-browser order, checked exits and post-success receipt. // This deliberately supports one audited producer, not arbitrary build tools. const auditedProducer: Record = { - "scripts/ci-build-files.ts": "29c2ca0fa5640ae98ab7860a8289d9b48e533ccbf150333f3555fa9593a088ac", - "scripts/ci-build-lifetime.ts": "54acc253aba0f432bf4a204d34c6da902aaffc508131d02dd046927c6925af5d", + "scripts/ci-build-files.ts": "afb8020ce5976f2d848554c970b3f9330167baeaf13cfd89fcd015f7fdce03a3", + "scripts/ci-build-lifetime.ts": "97ac87d386df8010c90de814b472bddc7166a2d3844809ceb73719e9c204a7e9", "scripts/ci-build-qualification.json": "31044cc0055d43353ae235daf56f88b4d24f40fef9ea5aac96f69421f5c20fc6", - "scripts/ci-build-entry.ts": "e7d2891c6b0737066f0f6818ed2f774b55d59549e81610ac2db264cb71dd6809", - "scripts/ci-build-operation.ts": "8fb5c0c8424be481c2476bf178365237ba67580e9b9e8d8b7b4e63ea223be143", - "scripts/ci-build-boundary.ts": "4f8c6fc11d838adff3dc91f4df75042f6d22bf76819c3519579c82b22d0e9e5e", + "scripts/ci-build-entry.ts": "f8519a004586c7ec6d7017853b3bc0c5b1f26b4d44682455553ce48f8152e4e9", + "scripts/ci-build-operation.ts": "28eb2de8d48d1e73a5557dc1031f9c44d03c7968ae726287c0dd44869f2d54db", + "scripts/ci-build-boundary.ts": "34926ef4e78f53112c401f3ad5df26d028b98fec3d6d428ea5fc18990ae02ed8", "scripts/build-execution.ts": "17ee42de1b37aa8034c6b0719b32ec432ac8fde321fb9c2c3a426799ea996a5e", "scripts/build.ts": "30c1ecc7272921efe4e437b23e3d088e03d38869a9f5b2365fc08592e248d8d8", "scripts/build-receipt.ts": "567c3d13b5e3e1295f597e4f57a9d0d9912685f2669cf3872daa26c2931bd1e6", diff --git a/tests/ci-build-files.test.ts b/tests/ci-build-files.test.ts index 8471ac800c..dafdaf8f7b 100644 --- a/tests/ci-build-files.test.ts +++ b/tests/ci-build-files.test.ts @@ -2,7 +2,7 @@ import { afterEach, expect, spyOn, test } from "bun:test"; import * as fs from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { buildRead } from "../scripts/ci-build-files"; +import { buildRead, checkBuildPrivateDirectory } from "../scripts/ci-build-files"; const roots: string[] = []; afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); }); function fixture() { @@ -34,3 +34,12 @@ test("anchored capture bounds regular-file bytes and retains mode", () => { expect(() => buildRead(file, undefined, 5), "CI_BUILD_FILE_BUDGET_REFUSAL").toThrow("Unsupported"); const captured = buildRead(file); expect(captured.bytes.toString()).toBe("Ithaca"); expect(Number(captured.stat.mode & 0o777n)).toBe(0o640); }); + +test("fixed build HOME and cache require private owned nonalias directories", () => { + const root = fixture(), home = join(root, "home"); fs.mkdirSync(home, { mode: 0o700 }); + checkBuildPrivateDirectory(home); + fs.chmodSync(home, 0o755); + expect(() => checkBuildPrivateDirectory(home), "CI_BUILD_HOME_MODE_REFUSAL").toThrow("Invalid private"); + fs.rmSync(home, { recursive: true }); fs.symlinkSync("source", home); + expect(() => checkBuildPrivateDirectory(home), "CI_BUILD_HOME_ALIAS_REFUSAL").toThrow("Invalid private"); +}); From a692495084cbb5863f755e3106f27ac4ad516588 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sat, 17 Oct 2026 21:25:40 +0200 Subject: [PATCH 10/15] test: add purpose-bound private build qualification controller --- docs/decisions/isolated-ci-build.md | 38 +++++ scripts/ci-build-consumers.ts | 136 +++++++++++++++ scripts/ci-build-data.ts | 23 +++ scripts/ci-build-entry.ts | 13 +- scripts/ci-build-evidence.ts | 51 ++++++ scripts/ci-build-operation.ts | 240 +++++++++++++++++++++------ scripts/ci-build-state.ts | 41 +++++ scripts/ci-build-trace.ts | 83 +++++++++ scripts/generated-build-inputs.ts | 9 +- tests/build-concurrency.test.ts | 4 +- tests/build-freshness.test.ts | 2 +- tests/ci-build-evidence.test.ts | 32 ++++ tests/ci-build-qualification.test.ts | 42 +++++ tests/ci-build-state.test.ts | 38 +++++ tests/generated-build-inputs.test.ts | 2 +- tests/generated-theme-inputs.test.ts | 2 +- tests/inclusion-inputs.test.ts | 2 +- tests/workspace-lease.test.ts | 2 +- 18 files changed, 697 insertions(+), 63 deletions(-) create mode 100644 scripts/ci-build-consumers.ts create mode 100644 scripts/ci-build-data.ts create mode 100644 scripts/ci-build-evidence.ts create mode 100644 scripts/ci-build-state.ts create mode 100644 scripts/ci-build-trace.ts create mode 100644 tests/ci-build-evidence.test.ts create mode 100644 tests/ci-build-qualification.test.ts create mode 100644 tests/ci-build-state.test.ts diff --git a/docs/decisions/isolated-ci-build.md b/docs/decisions/isolated-ci-build.md index 12b9d46b9f..b08bb92675 100644 --- a/docs/decisions/isolated-ci-build.md +++ b/docs/decisions/isolated-ci-build.md @@ -75,3 +75,41 @@ absence check address that ID, so replacement of a name grants no authority. Session-owned container removal and confirmed absence precede output publication and reuse stamps. Unconfirmed cleanup fails, records the incomplete cleanup and retains owned state; it never grants successful reuse authority. + +## Private qualification operation + +The fixed `--qualify-private` controller creates one fresh private workspace +and exercises the same capture, necessary receipt predicates, derived-output +capture, compiler scheduler, cleanup and publication implementation. Its +operation name, receipt purpose and random workspace domain differ from +production. Copying a qualification receipt into the production cache cannot +make it eligible. Only this fixed controller creates that private context; +there is no caller-selected executor or output-root option. + +Qualification runs never update the production policy. The controller checks +real unchanged child omission and output identities, per-package recovery, +source/config/installed-compiler/nested-stylesheet changes and the existing +nineteen-package packed consumer assertions. Each observation retains actual +phase costs. Producer failures remain failures; unsupported inputs use the +ordinary private-workspace builder. Real interruption, browser consumers and +comparable hosted costs remain separate required evidence before activation. + +The trace arm adds a captured diagnostic executable and its libraries through +read-only mounts, without network, extra capabilities or seccomp relaxation. +It is a different observed arm: exact output identity is compared to the +unobserved operation, and its receipts cannot seed ordinary repeat reuse. +Syscall observation cannot establish vDSO or hardware independence. Kernel, +device, time, randomness, unresolved paths and incomplete traces remain +unqualified until a finite source-specific audit and causal controls explain +the actual output dependency. An image hash or a sample output match cannot +remove those unknowns. + +Packed consumers use the existing `ciPack` assertions. Dependency acquisition +happens only as separate preparation before the closed operation: a fixed +private catalog records exact registry tarball integrity and transitive +packages, with reviewed integrity from the workspace lock or explicit consumer +records. The operation itself uses Bun's native offline mode, a fresh private +cache and no install scripts. Before consumer imports, installed payload bytes +must equal verified archived bytes. Missing cache artifacts, integrity or +unsupported install shapes fail; they do not fall back to an online installer. +Preparation does not change project dependencies or the workspace lock. diff --git a/scripts/ci-build-consumers.ts b/scripts/ci-build-consumers.ts new file mode 100644 index 0000000000..316ac5f0ec --- /dev/null +++ b/scripts/ci-build-consumers.ts @@ -0,0 +1,136 @@ +/** Fixed offline transport for the existing nineteen-package consumer probes. */ +import { spawnSync } from "node:child_process"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { cpSync, existsSync, mkdirSync, lstatSync, readdirSync, realpathSync, writeFileSync } from "node:fs"; +import { join, relative, posix } from "node:path"; +import ts from "typescript"; +import { buildRead } from "./ci-build-files"; +import { captureOutputs, identity } from "./ci-build-boundary"; +// Extra React-18 records require actual reviewed offline tarball provenance. +// An empty map refuses them; no registry request fills this prerequisite. +const extraConsumerIntegrities: Record = {}; +type Catalog = { version: 1; purpose: "offline-packed-consumer-cache"; packages: {name: string; version: string; integrity: string; tarball: string}[] }; +export function offlinePackInstallArgs(argv: readonly string[], bun: string, cwd: string, packages: readonly string[]): string[] | undefined { + if (argv[0] !== bun || argv[1] !== "add") return; + const main = cwd === "/repo/tmp/pack-work/brainkit-consumer", react18 = cwd === "/repo/tmp/pack-work/brainkit-react18-consumer"; + if (!main && !react18) throw new Error("Unexpected packed installer location"); + const specs = argv.slice(2), tarballs = specs.filter(value => value.endsWith(".tgz")); + for (const path of tarballs) { + assert(/^\/repo\/tmp\/pack-work\/brainkit-tarballs\/[a-z0-9-]+\.tgz$/.test(path), "CI_BUILD_PACK_TARBALL_LOCATION"); + assert(packages.includes(path.split("/").at(-1)!.slice(0, -4)), "CI_BUILD_PACK_TARBALL_PACKAGE"); + } + const other = specs.filter(value => !value.endsWith(".tgz")); + const allowed = main ? [["react", "react-dom"], ["-d", "@types/bun", "@types/react", "@types/react-dom"]] : [["react@^18", "react-dom@^18"], ["-d", "@types/react@^18", "@types/react-dom@^18"]]; + assert(allowed.some(expected => identity(expected) === identity(other)), "CI_BUILD_ONLY_KNOWN_PACK_INSTALLERS"); + if (other[0] === "-d") assert.equal(tarballs.length, 0); else assert(tarballs.length > 0); + return [bun, "add", "--offline", "--ignore-scripts", "--backend=copyfile", "--cache-dir=/tmp/consumer-cache", ...specs]; +} +export function prepareBuildConsumers(root: string, directory: string) { + const supplied = join(root, "tmp/ci-build-consumer-cache"); + if (!existsSync(join(supplied, "catalog.json"))) throw new Error("Missing verified offline packed-consumer cache; no acquisition is permitted"); + const catalog = JSON.parse(buildRead(join(supplied, "catalog.json"), undefined, 4 * 1024 * 1024).bytes.toString("utf8")) as Catalog; + assert.equal(catalog.version, 1); assert.equal(catalog.purpose, "offline-packed-consumer-cache"); assert(Array.isArray(catalog.packages) && catalog.packages.length > 0); + const lock = ts.parseConfigFileTextToJson("bun.lock", buildRead(join(root, "bun.lock")).bytes.toString("utf8")); assert(!lock.error); + const locked = new Map(); + for (const entry of Object.values(lock.config.packages ?? {}) as any[]) if (Array.isArray(entry) && typeof entry[0] === "string") { + const integrity = entry.findLast(value => typeof value === "string" && value.startsWith("sha512-")); if (integrity) locked.set(entry[0], integrity); + } + const destination = join(directory, "consumer-input"); mkdirSync(destination, { mode: 0o700 }); mkdirSync(join(destination, "tarballs"), { mode: 0o700 }); + for (const pkg of catalog.packages) { + assert(/^(?:@[a-z0-9_.-]+\/)?[a-z0-9_.-]+$/.test(pkg.name) && /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][a-z0-9.-]+)?$/i.test(pkg.version)); + const expected = locked.get(`${pkg.name}@${pkg.version}`) ?? extraConsumerIntegrities[`${pkg.name}@${pkg.version}`]; + assert(expected && expected === pkg.integrity, "CI_BUILD_CONSUMER_TARBALL_HAS_REVIEWED_INTEGRITY"); + assert(/^tarballs\/[0-9a-f]{64}\.tgz$/.test(pkg.tarball)); + const bytes = buildRead(join(supplied, pkg.tarball)).bytes; + assert.equal(`sha512-${createHash("sha512").update(bytes).digest("base64")}`, expected); + writeFileSync(join(destination, pkg.tarball), bytes, { flag: "wx", mode: 0o600 }); + } + const records = captureOutputs(join(supplied, "cache"), join(destination, "cache")); + writeFileSync(join(destination, "catalog.json"), JSON.stringify(catalog), { flag: "wx", mode: 0o600 }); + return { source: destination, identity: identity({ records, catalog }), catalog }; +} +/** Called only inside the fixed image, before any installed consumer imports. */ +export async function runBuildConsumers(packages: string[]) { + const { ciPack } = await import("./ci-pack"); + const catalog = JSON.parse(buildRead("/consumer-input/catalog.json", undefined, 4 * 1024 * 1024).bytes.toString("utf8")) as Catalog; + cpSync("/consumer-input/cache", "/tmp/consumer-cache", { recursive: true, dereference: false }); + const work = "/repo/tmp/pack-work"; mkdirSync(work, { mode: 0o700 }); + const transport = Bun as unknown as { spawn: (...args: any[]) => any }, original = transport.spawn; + const installers: {original: string[]; actual: string[]; cwd: string; code?: number}[] = []; + const verifiedArchives = new Map>(); + const { listPublishablePackages } = await import("./publishable-packages"); + const firstParty = listPublishablePackages("/repo"); + const archiveFiles = (archive: string) => { + if (verifiedArchives.has(archive)) return verifiedArchives.get(archive)!; + const run = (args: string[]) => { const result = spawnSync("/usr/bin/tar", args, { timeout: 30_000, maxBuffer: 16 * 1024 * 1024, killSignal: "SIGKILL" }); assert(!result.error && result.status === 0, "CI_BUILD_PACK_ARCHIVE_READ"); return result.stdout.toString(); }; + const names = run(["-tzf", archive]).trim().split("\n"); + assert(names.every(name => (name === "package" || name.startsWith("package/")) && !name.includes("\\") && posix.normalize(name).replace(/\/$/, "") === name.replace(/\/$/, "") && !name.split("/").includes("..")), "CI_BUILD_PACK_ARCHIVE_PATHS"); + assert(run(["-tvzf", archive]).trim().split("\n").every(line => /^[d-]/.test(line)), "CI_BUILD_PACK_ARCHIVE_REGULAR_ONLY"); + const extracted = `/tmp/verified-archives/${identity(archive)}`; mkdirSync(extracted, { recursive: true, mode: 0o700 }); + run(["-xzf", archive, "-C", extracted, "--no-same-owner", "--no-same-permissions"]); + const files = new Map(); + const walk = (directory: string, path = "") => { + for (const name of readdirSync(directory).sort()) { + if (name === "node_modules") continue; + const file = join(directory, name), relative = path ? `${path}/${name}` : name, stat = lstatSync(file); + if (stat.isDirectory()) walk(file, relative); + else { assert(stat.isFile(), "CI_BUILD_PACK_PAYLOAD_REGULAR_ONLY"); files.set(relative, createHash("sha256").update(buildRead(file).bytes).digest("hex")); } + } + }; + walk(join(extracted, "package")); verifiedArchives.set(archive, files); return files; + }; + const actualPayloads: {name: string; version: string; identity: string}[] = []; + const verifyInstalled = async (cwd: string) => { + // Cache payloads are checked against the reviewed archived packages before + // ciPack can import them. No lifecycle script runs during installation. + const visited = new Set(); + const scan = async (modules: string) => { + if (!existsSync(modules)) return; + for (const name of readdirSync(modules)) { + if (name.startsWith(".")) continue; + const names = name.startsWith("@") ? readdirSync(join(modules, name)).map(child => `${name}/${child}`) : [name]; + for (const child of names) { + const directory = realpathSync(join(modules, child)); + assert(!relative(cwd, directory).startsWith(".."), "CI_BUILD_PACK_INSTALL_STAYS_PRIVATE"); + if (visited.has(directory)) continue; visited.add(directory); + const manifest = JSON.parse(buildRead(join(directory, "package.json")).bytes.toString("utf8")); + const own = firstParty.find(pkg => pkg.manifest.name === manifest.name && pkg.manifest.version === manifest.version); + const dependency = catalog.packages.find(pkg => pkg.name === manifest.name && pkg.version === manifest.version); + assert(own || dependency, "CI_BUILD_PACK_DEPENDENCY_IS_REVIEWED"); + const archive = own ? join(work, "brainkit-tarballs", `${own.dir}.tgz`) : `/consumer-input/${dependency!.tarball}`; + const expected = archiveFiles(archive), actual = new Map(); + const walk = (base: string, path = "") => { + for (const name of readdirSync(base).sort()) { + if (name === "node_modules") continue; + const file = join(base, name), key = path ? `${path}/${name}` : name, stat = lstatSync(file); + if (stat.isDirectory()) walk(file, key); + else { assert(stat.isFile(), "CI_BUILD_PACK_INSTALLED_PAYLOAD_REGULAR"); actual.set(key, createHash("sha256").update(buildRead(file).bytes).digest("hex")); } + } + }; + walk(directory); assert.deepEqual([...actual], [...expected], "CI_BUILD_PACK_CACHE_PAYLOAD_MATCHES_VERIFIED_TARBALL"); + actualPayloads.push({ name: manifest.name, version: manifest.version, identity: identity([...actual]) }); + await scan(join(directory, "node_modules")); + } + } + }; + await scan(join(cwd, "node_modules")); + return visited.size; + }; + transport.spawn = (...args: any[]) => { + if (!Array.isArray(args[0])) return original(...args); + const actual = offlinePackInstallArgs(args[0], process.execPath, args[1]?.cwd ?? "", packages); + if (!actual) return original(...args); + const observation = { original: [...args[0]], actual, cwd: args[1].cwd, code: undefined as number | undefined }; installers.push(observation); + const child = original(actual, args[1]); + const exited = child.exited.then(async (code: number) => { observation.code = code; if (code === 0) await verifyInstalled(args[1].cwd); return code; }); + return new Proxy(child, { get(target, key) { if (key === "exited") return exited; const value = Reflect.get(target, key, target); return typeof value === "function" ? value.bind(target) : value; } }); + }; + try { + const node = original(["/usr/bin/node", "-p", "process.versions.node"], { stdout: "pipe", stderr: "pipe" }); + const [version, code] = await Promise.all([new Response(node.stdout).text(), node.exited]); assert.equal(code, 0); assert(Bun.semver.satisfies(version.trim(), ">=24.0.0"), "CI_BUILD_PACK_NODE_MATCHES_CI"); + await ciPack("/repo", work); + assert.equal(installers.length, 4, "CI_BUILD_ALL_PACK_INSTALLERS_OFFLINE"); assert(installers.every(child => child.code === 0)); + return { version: 1, purpose: "qualification-consumers", authority: "none", completed: true, packages, node: version.trim(), installers, actualPayloads, registryAcquisition: "disabled-by-offline-native-installer-and-network-namespace" }; + } finally { transport.spawn = original; } +} diff --git a/scripts/ci-build-data.ts b/scripts/ci-build-data.ts new file mode 100644 index 0000000000..c17ae82e45 --- /dev/null +++ b/scripts/ci-build-data.ts @@ -0,0 +1,23 @@ +/** Finite source-data reference policy for the reviewed TypeScript producer. */ +import ts from "typescript"; +import { posix, join } from "node:path"; +import { buildRead } from "./ci-build-files"; +import { type BuildFile } from "./ci-build-boundary"; +export function buildDataRefusals(input: string, records: readonly BuildFile[]): string[] { + const refused: string[] = []; + for (const entry of records) { + if (entry.kind !== "file" || !/^packages\/[^/]+\/src\/.*\.[cm]?[jt]sx?$/.test(entry.path)) continue; + const text = buildRead(join(input, entry.path)).bytes.toString("utf8"); + const parsed = ts.preProcessFile(text, true, true); + for (const ref of [...parsed.importedFiles, ...parsed.referencedFiles, ...parsed.typeReferenceDirectives]) { + const name = ref.fileName; + if (name.startsWith("node:") || name.startsWith("bun:")) continue; + if (name.includes("\\") || posix.isAbsolute(name) || /^[a-z][a-z0-9+.-]*:/i.test(name)) { refused.push(`${entry.path}:external-reference`); continue; } + if (name.startsWith(".")) { + const target = posix.normalize(posix.join(posix.dirname(entry.path), name)); + if (target === ".." || target.startsWith("../")) refused.push(`${entry.path}:external-reference`); + } + } + } + return [...new Set(refused)].sort(); +} diff --git a/scripts/ci-build-entry.ts b/scripts/ci-build-entry.ts index 9aadef9a2c..99bba43129 100644 --- a/scripts/ci-build-entry.ts +++ b/scripts/ci-build-entry.ts @@ -9,7 +9,7 @@ import { listPublishablePackages } from "./publishable-packages"; import { ensureWorkspaceLease } from "./workspace-lease.mjs"; type Entry = { - version: 1; uid: number; gid: number; originalRoot: string; canary: string; selected: string[]; jobs: 1 | 2; + version: 1; mode: "build" | "consumers"; uid: number; gid: number; originalRoot: string; canary: string; selected: string[]; jobs: 1 | 2; topology: Record; rootNames: string[]; node: string; readonlyTargets: string[]; writableTargets: string[]; }; @@ -47,7 +47,14 @@ if (import.meta.main) { process.env.TMPDIR = "/tmp"; process.env.XDG_CACHE_HOME = "/tmp/cache"; const packages = listPublishablePackages("/repo").filter(pkg => entry.selected.includes(pkg.dir)); assert.equal(packages.length, entry.selected.length, "CI_BUILD_ONLY_VALIDATED_PENDING_PACKAGES"); - const code = await executeBuild("/repo", packages, entry.jobs); + assert(entry.mode === "build" || entry.mode === "consumers", "CI_BUILD_FIXED_ENTRY_MODE"); + let consumers: unknown; + const code = entry.mode === "consumers" ? 0 : await executeBuild("/repo", packages, entry.jobs); + if (entry.mode === "consumers") { + assert.equal(entry.selected.length, 0, "CI_BUILD_CONSUMERS_ONLY_CHECKED_DERIVED_OUTPUTS"); + const { runBuildConsumers } = await import("./ci-build-consumers"); + consumers = await runBuildConsumers(Object.keys(entry.topology)); + } assert.deepEqual(readdirSync("/repo").sort(), entry.rootNames, "CI_BUILD_NO_UNKNOWN_ROOT_OUTPUT"); assert.deepEqual(readdirSync("/repo/packages").sort(), Object.keys(entry.topology).sort(), "CI_BUILD_NO_UNKNOWN_PACKAGE"); for (const [pkg, names] of Object.entries(entry.topology)) { @@ -57,6 +64,6 @@ if (import.meta.main) { } const receipts = readdirSync("/repo/tmp").filter(name => /^build-\d+-[0-9a-f-]+-probe\.json$/.test(name)).sort() .map(name => ({ name, value: JSON.parse(buildRead(join("/repo/tmp", name), undefined, 16 * 1024 * 1024).bytes.toString("utf8")) })); - writeFileSync("/repo/tmp/isolated-producer.json", JSON.stringify({ version: 1, code, runtime, receipts, mounts, topology: "checked-after-child-exit" })); + writeFileSync("/repo/tmp/isolated-producer.json", JSON.stringify({ version: 1, kind: entry.mode === "consumers" ? "packed-consumers" : "workspace-build", code, runtime, receipts, consumers, mounts, topology: "checked-after-child-exit" })); process.exitCode = code; } diff --git a/scripts/ci-build-evidence.ts b/scripts/ci-build-evidence.ts new file mode 100644 index 0000000000..fd0457e9cc --- /dev/null +++ b/scripts/ci-build-evidence.ts @@ -0,0 +1,51 @@ +/** Compacts only this controller's successfully drained, removed capture states. */ +import assert from "node:assert/strict"; +import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { buildNames, buildRead, checkBuildPrivateDirectory } from "./ci-build-files"; +import { captureOutputs, digest, identity, outputFiles } from "./ci-build-boundary"; +export function compactBuildEvidence(workspace: string, controller: string, result: any) { + assert.equal(workspace, join(controller, "workspace")); checkBuildPrivateDirectory(controller); + assert.equal(result.authority, "none"); assert(/^qualification(?:-|$)/.test(result.purpose)); + assert(result.drained === true && result.removed === true, "CI_BUILD_ONLY_REMOVED_SUCCESS_CAN_COMPACT"); + assert(/^[0-9a-f-]{36}$/.test(result.session)); + const state = result.artifactDirectory, suffix = relative(join(workspace, "tmp/isolated-ci-build"), state); + assert(/^operation-[a-zA-Z0-9]+$/.test(suffix), "CI_BUILD_EVIDENCE_STATE_IS_CONTROLLER_OWNED"); checkBuildPrivateDirectory(state); + const cleanup = JSON.parse(buildRead(join(state, "cleanup.json")).bytes.toString()); + assert(cleanup.session === result.session && cleanup.drained === true && cleanup.removed === true && /^[0-9a-f]{64}$/.test(cleanup.containerID), "CI_BUILD_COMPACTION_REQUIRES_CLEANUP_RECEIPT"); + const evidence = join(controller, "evidence", result.session); mkdirSync(evidence, { recursive: true, mode: 0o700 }); + const files: {path: string; sha256: string; mode: number}[] = []; + const retain = (path: string) => { + const file = buildRead(join(state, path), undefined, 256 * 1024 * 1024), mode = Number(file.stat.mode & 0o7777n); + mkdirSync(join(evidence, path, ".."), { recursive: true, mode: 0o700 }); + writeFileSync(join(evidence, path), file.bytes, { flag: "wx", mode }); chmodSync(join(evidence, path), mode); + files.push({ path, sha256: digest(file.bytes), mode }); + }; + for (const path of ["invocation.json", "entry.json", "container-boundary.json", "cleanup.json", "diagnostics/isolated-producer.json"]) retain(path); + for (const path of buildNames(join(state, "diagnostics"))) if (/^build-[0-9]+-[0-9a-f-]+-probe\.json$/.test(path) || path === "qualification-syscalls.log") retain(`diagnostics/${path}`); + const bun = buildRead(join(state, "tool/bun")); assert.equal(digest(bun.bytes), result.producer.runtime.bun); + const runtime = join(controller, "runtime"); mkdirSync(runtime, { recursive: true, mode: 0o700 }); + const executable = join(runtime, `bun-${digest(bun.bytes)}`); + if (!existsSync(executable)) writeFileSync(executable, bun.bytes, { flag: "wx", mode: 0o600 }); + assert.equal(digest(buildRead(executable).bytes), digest(bun.bytes)); + const outputs: Record = {}; + for (const [pkg, expected] of Object.entries(result.outputs ?? {})) { + assert(/^[a-z0-9-]+$/.test(pkg) && typeof expected === "string" && /^[0-9a-f]{64}$/.test(expected)); + const destination = join(controller, "outputs", pkg, expected); + if (!existsSync(destination)) { mkdirSync(join(destination, ".."), { recursive: true, mode: 0o700 }); assert.equal(identity(captureOutputs(join(state, "parents", pkg, "dist"), destination)), expected); } + assert.equal(identity(outputFiles(destination)), expected, "CI_BUILD_COMPACTED_OUTPUT_BYTES_MATCH"); + outputs[pkg] = { identity: expected, path: relative(controller, destination) }; + } + if (result.purpose === "qualification-consumers") for (const pkg of result.producer.consumers.packages) { + assert(/^[a-z0-9-]+$/.test(pkg)); retain(`diagnostics/pack-work/brainkit-tarballs/${pkg}.tgz`); + } + const manifest = { version: 1, purpose: "qualification-evidence", authority: "none", session: result.session, key: result.key, originalCapture: state, files, outputs, bun: digest(bun.bytes), removedCapture: true }; + writeFileSync(join(evidence, "retained.writing.json"), JSON.stringify({ ...manifest, removedCapture: false }, null, 2), { flag: "wx", mode: 0o600 }); + // All consumers and publication have completed before this point. Failures + // above retain the original state; only this exact owned capture is removed. + const remove = (path: string) => { const stat = lstatSync(path); if (stat.isDirectory()) { chmodSync(path, (stat.mode & 0o777) | 0o700); for (const name of readdirSync(path)) remove(join(path, name)); } rmSync(path, { recursive: true }); }; + remove(state); + writeFileSync(join(evidence, "retained.json"), JSON.stringify(manifest, null, 2), { flag: "wx", mode: 0o600 }); + rmSync(join(evidence, "retained.writing.json")); + return { artifactDirectory: evidence, originalArtifactDirectory: state, capturedStateRemoved: true, retainedIdentity: identity(manifest) }; +} diff --git a/scripts/ci-build-operation.ts b/scripts/ci-build-operation.ts index 8b7bcc2144..e30d918405 100644 --- a/scripts/ci-build-operation.ts +++ b/scripts/ci-build-operation.ts @@ -1,10 +1,13 @@ /** One dormant, fixed isolated build operation. CI intent is not authority. */ import { dockerControl as docker, inspectOwnedBuildContainer, removeOwnedBuildContainer } from "./ci-build-lifetime"; import { buildRead } from "./ci-build-files"; +import { buildMaterial, buildProducerPaths, matchingBuildReceipt, operationFor, sameBuildMaterial, successfulBuildProducer, type BuildMaterial } from "./ci-build-state"; +import { spawnSync } from "node:child_process"; +import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { join, relative, resolve } from "node:path"; -import { buildMounts, captureBuildInputs, captureDerivedOutput, captureOutputs, digest, identity, outputFiles, sameOutputIdentity, type BuildFile } from "./ci-build-boundary"; +import { chmodSync, closeSync, existsSync, fstatSync, openSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { buildMounts, captureBuildInputs, captureDerivedOutput, captureOutputs, digest, identity, outputFiles, sameOutputIdentity } from "./ci-build-boundary"; import { selectBuildPackages } from "./publishable-packages"; import { ensureWorkspaceLease } from "./workspace-lease.mjs"; @@ -17,10 +20,7 @@ type Qualification = { }; const policyPath = join(import.meta.dir, "ci-build-qualification.json"); const fixedImage = "mcr.microsoft.com/playwright@sha256:eff16c30e6f3f4af0a03fa4b706120d5e9b0891c344a27d64559aff5900a4a27"; -const producerPaths = ["scripts/build.ts", "scripts/build-execution.ts", "scripts/build-receipt.ts", "scripts/bin-modes.ts", "scripts/publishable-packages.ts", "scripts/workspace-lease.mjs", - "scripts/ci-build-files.ts", "scripts/ci-build-lifetime.ts", "scripts/ci-build-boundary.ts", "scripts/ci-build-operation.ts", "scripts/ci-build-entry.ts"]; -const configuration = (entry: BuildFile) => !/(^|\/)node_modules(?:\/|$)/.test(entry.path) && - (/(^|\/)package\.json$/.test(entry.path) || /(^|\/)tsconfig[^/]*\.json$/.test(entry.path) || /(^|\/)bunfig\.toml$/.test(entry.path) || entry.path.endsWith(".css")); +const producerPaths = buildProducerPaths; const launcher = '#!/bin/sh\nexec /tool/bun x --no-install "$@"\n'; // Run once before Bun and its lease coordinator can initialize HOME. const entryLauncher = '#!/bin/sh\nset -eu\numask 077\n/usr/bin/mkdir -m 0700 /tmp/home /tmp/cache\nexec /tool/bun /repo/scripts/ci-build-entry.ts\n'; @@ -36,11 +36,7 @@ function complete(policy: Qualification): boolean { policy.node.path === "/usr/bin/node" && Object.keys(policy.producer).sort().join("\0") === [...producerPaths].sort().join("\0") && Object.keys(policy.configuration).length > 0; } function policyMatches(policy: Qualification, captured: ReturnType, bun: string): boolean { - if (!complete(policy) || policy.installation !== captured.installation || policy.bun !== bun) return false; - const byPath = new Map(captured.records.map(entry => [entry.path, entry])); - if (!producerPaths.every(path => byPath.get(path)?.kind === "file" && policy.producer[path] === identity(byPath.get(path)))) return false; - const actual = Object.fromEntries(captured.records.filter(configuration).map(entry => [entry.path, identity(entry)])); - return identity(actual) === identity(Object.fromEntries(Object.entries(policy.configuration).sort(([a], [b]) => a.localeCompare(b)))); + return complete(policy) && sameBuildMaterial(buildMaterial(captured.records, captured.installation, bun), policy as BuildMaterial); } function imagePresent(image: string, config: string) { @@ -83,17 +79,6 @@ function ownedJSON(path: string) { if (stat.uid !== BigInt(process.getuid!())) throw new Error("Invalid owned build receipt"); return { sha256: digest(bytes), value: JSON.parse(bytes.toString("utf8")) }; } -function successfulProducer(producer: any, selected: string[], policy: Qualification, bun: string, qualificationOnly: boolean): boolean { - if (producer?.version !== 1 || producer.code !== 0 || producer.runtime?.bun !== bun || producer.runtime?.node?.path !== policy.node.path || - (!qualificationOnly && producer.runtime?.node?.sha256 !== policy.node.sha256) || producer.topology !== "checked-after-child-exit" || producer.receipts?.length !== 1) return false; - const receipt = producer.receipts[0]?.value; - return receipt?.kind === "workspace-build" && receipt.state === "complete" && receipt.success === true && receipt.code === 0 && - Array.isArray(receipt.packages) && identity(receipt.packages.map((pkg: any) => pkg.name).sort()) === identity([...selected].sort()) && - receipt.packages.every((pkg: any) => pkg.state === "complete" && Array.isArray(pkg.children) && - pkg.children.length === (["ui-kit", "ui-react"].includes(pkg.name) ? 2 : 1) && - pkg.children.filter((child: any) => child.compiler === "tsc" && child.code === 0).length === 1 && - pkg.children.filter((child: any) => child.compiler === "css" && child.code === 0).length === (["ui-kit", "ui-react"].includes(pkg.name) ? 1 : 0)); -} function writableCleanup(path: string) { if (!existsSync(path)) return; const stat = lstatSync(path); @@ -125,12 +110,19 @@ export async function attemptCiBuild(root: string, options: Options): Promise { +type PrivateProof = { workspace: string; domain: string; candidate?: BuildMaterial; node?: string; results: any[]; + trace?: Awaited>; + consumers?: Awaited> }; +async function execute(root: string, options: Options, policy: Qualification, qualificationOnly: boolean, proof?: PrivateProof): Promise { if (process.getuid!() === 0 || process.getgid!() === 0) { if (qualificationOnly) throw new Error("Fixed build requires non-root UID and GID"); operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: "unsupported", action: "ordinary-rebuild", reason: "non-root-context-required" }); return; } + if (proof && (!qualificationOnly || proof.workspace !== root || !/^[0-9a-f-]{36}$/.test(proof.domain))) throw new Error("Invalid private qualification workspace"); + const purpose = qualificationOnly ? "qualification" : "production", operation = operationFor(purpose), domain = proof?.domain ?? "workspace"; + const diagnostic = proof?.trace ? "trace" : proof?.consumers ? "consumers" : undefined; + const publish = (!qualificationOnly || proof !== undefined) && !diagnostic; const started = performance.now(), session = randomUUID(), name = `brain-ci-build-${session}`; const selected = selectBuildPackages(root, options.directories).map(pkg => pkg.dir); let state: string, dockerConfig: string; @@ -180,28 +172,27 @@ async function execute(root: string, options: Options, policy: Qualification, qu if (executable.stat.dev !== loaded.dev || executable.stat.ino !== loaded.ino) throw new Error("Captured Bun differs from running executable"); const bun = executable.bytes, bunHash = digest(bun); phases.captureMs = performance.now() - captureStart; - if (!qualificationOnly && !policyMatches(policy, captured, bunHash)) { + const material = buildMaterial(captured.records, captured.installation, bunHash); + const { buildDataRefusals } = await import("./ci-build-data"); + const dataRefusals = buildDataRefusals(input, captured.records); + if (dataRefusals.length || (!qualificationOnly && !policyMatches(policy, captured, bunHash)) || (proof?.candidate && !sameBuildMaterial(material, proof.candidate))) { operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: "unsupported", action: "ordinary-rebuild", reason: "captured-policy-mismatch", phases }); return; } imagePresent(policy.image, dockerConfig); const scope = workspaceScope(root); - const key = identity({ version: 1, inputs: captured.sha256, image: policy.image, bun: bunHash, bunVersion: Bun.version, policy: identity(policy), jobs: options.jobs, scope }); - const stampDirectory = join(root, "tmp", "ci-build-cache"); - if (!qualificationOnly) ownedDirectory(root, stampDirectory, true); + const key = identity({ version: 1, purpose, domain, inputs: captured.sha256, image: policy.image, bun: bunHash, bunVersion: Bun.version, policy: identity(policy), jobs: options.jobs, scope }); + const stampDirectory = join(root, "tmp", proof ? "ci-build-qualification-cache" : "ci-build-cache"); + if (publish) ownedDirectory(root, stampDirectory, true); const reused: string[] = [], pending: string[] = [], previousOutputs: Record = {}; for (const pkg of selected) { let matches = false; - if (!qualificationOnly && !options.force) try { + if ((publish || proof?.consumers) && !options.force) try { const stamp = ownedJSON(join(stampDirectory, `${pkg}.json`)).value; if (!/^[0-9a-f-]{36}$/.test(stamp.producerSession)) throw new Error("Missing producer ownership"); const producerReceipt = ownedJSON(join(stampDirectory, `${stamp.producerSession}.json`)); const prior = producerReceipt.value; - matches = stamp.version === 1 && stamp.key === key && stamp.operation === "fixed-isolated-ci-v1" && - producerReceipt.sha256 === stamp.receipt && prior.version === 1 && prior.operation === stamp.operation && - prior.session === stamp.producerSession && prior.key === key && prior.image === policy.image && prior.drained === true && prior.removed === true && - Array.isArray(prior.compiled) && prior.compiled.includes(pkg) && prior.outputs?.[pkg] === stamp.output && - successfulProducer(prior.producer, prior.compiled, policy, bunHash, false) && + matches = matchingBuildReceipt(stamp, prior, producerReceipt.sha256, { purpose, domain, key, image: policy.image, pkg, node: { path: policy.node.path, sha256: proof?.node ?? policy.node.sha256 }, bun: bunHash }) && sameOutputIdentity(join(root, "packages", pkg, "dist"), stamp.output); if (matches) previousOutputs[pkg] = stamp.output; } catch { /* Missing, partial or changed output is a miss. */ } @@ -209,8 +200,11 @@ async function execute(root: string, options: Options, policy: Qualification, qu } phases.lookupMs = performance.now() - captureStart - phases.captureMs!; for (const pkg of reused) console.log(`Up to date packages/${pkg} (qualified isolated operation)`); - if (!pending.length) { - operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: "reused", key, selected, reused, compiled: [], phases, elapsedMs: performance.now() - started }); + if (proof?.consumers && pending.length) throw new Error("Consumer proof requires every current output's successful same-operation receipt"); + if (!pending.length && !proof?.consumers) { + const value = { version: 1, kind: "isolated-ci-build", purpose, domain, authority: qualificationOnly ? "none" : "qualified-production", state: qualificationOnly ? "unqualified-private-repeat" : "reused", key, selected, reused, compiled: [], phases, elapsedMs: performance.now() - started }; + proof?.results.push(value); + operationReceipt(root, value); return 0; } // CSS package exports can consume another selected package's dist. These @@ -232,16 +226,17 @@ async function execute(root: string, options: Options, policy: Qualification, qu const uid = process.getuid!(), gid = process.getgid!(); const argv = ["run", "--name", name, "--label", `brain-ci-build-session=${session}`, "--pull=never", "--read-only", "--network=none", "--cap-drop=ALL", "--security-opt=no-new-privileges", "--user", `${uid}:${gid}`, "--hostname", "brain-build", "--cpus=2", "--memory=4g", "--pids-limit=256", "--shm-size=64m", "--tmpfs", `/repo:rw,nosuid,nodev,mode=0700,uid=${uid},gid=${gid},size=64m`, "--tmpfs", "/tmp:rw,exec,nosuid,nodev,mode=1777,size=256m"]; - const allMounts = [...mounts, { source: tool, target: "/tool", readonly: true }, { source: entryPath, target: "/operation/entry.json", readonly: true }, + const diagnosticMounts = proof?.trace ? [{ source: proof.trace.source, target: "/trace", readonly: true }] : proof?.consumers ? [{ source: proof.consumers.source, target: "/consumer-input", readonly: true }] : []; + const allMounts = [...mounts, ...diagnosticMounts, { source: tool, target: "/tool", readonly: true }, { source: entryPath, target: "/operation/entry.json", readonly: true }, ...["hosts", "hostname", "resolv.conf"].map(file => ({ source: join(hostFiles, file), target: `/etc/${file}`, readonly: true }))]; - writeFileSync(entryPath, JSON.stringify({ version: 1, uid, gid, originalRoot: root, canary, selected: pending, jobs: options.jobs, topology, rootNames, node: policy.node.path, + writeFileSync(entryPath, JSON.stringify({ version: 1, mode: proof?.consumers ? "consumers" : "build", uid, gid, originalRoot: root, canary, selected: pending, jobs: options.jobs, topology, rootNames, node: policy.node.path, readonlyTargets: allMounts.filter(mount => mount.readonly).map(mount => mount.target), writableTargets: allMounts.filter(mount => !mount.readonly).map(mount => mount.target) })); for (const mount of allMounts) { if (/[\r\n,]/.test(mount.source + mount.target)) throw new Error("Unsupported fixed build mount path"); argv.push("--mount", `type=bind,src=${mount.source},dst=${mount.target}${mount.readonly ? ",readonly" : ""}`); } - argv.push("--workdir", "/repo", policy.image, "/usr/bin/env", "-i", "PATH=/tool:/usr/bin:/bin", "HOME=/tmp/home", "LANG=C.UTF-8", "LC_ALL=C.UTF-8", "TZ=UTC", "/usr/bin/timeout", "--signal=TERM", "--kill-after=5s", "300s", "/bin/sh", "/tool/entry.sh"); - writeFileSync(join(state, "invocation.json"), JSON.stringify({ version: 1, session, key, argv, mounts: allMounts, captured, derived: derivedIdentities, runtime: { image: policy.image, bun: bunHash }, lifetime: { producerSeconds: 300, killGraceSeconds: 5, clientSeconds: 330, dockerControlMilliseconds: 10_000 }, scope, qualificationOnly }, null, 2)); + argv.push("--workdir", "/repo", policy.image, "/usr/bin/env", "-i", "PATH=/tool:/usr/bin:/bin", "HOME=/tmp/home", "LANG=C.UTF-8", "LC_ALL=C.UTF-8", "TZ=UTC", "/usr/bin/timeout", "--signal=TERM", "--kill-after=5s", "300s", ...(proof?.trace?.command ?? ["/bin/sh", "/tool/entry.sh"])); + writeFileSync(join(state, "invocation.json"), JSON.stringify({ version: 1, session, key, argv, mounts: allMounts, captured, derived: derivedIdentities, runtime: { image: policy.image, bun: bunHash }, diagnostic: diagnostic ? { kind: diagnostic, identity: proof?.trace?.identity ?? proof?.consumers?.identity } : undefined, lifetime: { producerSeconds: 300, killGraceSeconds: 5, clientSeconds: 330, dockerControlMilliseconds: 10_000 }, scope, qualificationOnly }, null, 2)); const compileStart = performance.now(); child = Bun.spawn(["/usr/bin/timeout", "--signal=TERM", "--kill-after=5s", "330s", "/usr/bin/docker", "--host", "unix:///var/run/docker.sock", "--config", dockerConfig, ...argv], { stdout: "inherit", stderr: "inherit", env: { PATH: "/usr/bin:/bin" } }); containerKnown = true; @@ -259,7 +254,11 @@ async function execute(root: string, options: Options, policy: Qualification, qu if (actual.Mounts.some(mount => !["bind", "tmpfs"].includes(mount.Type)) || identity(actualBinds) !== identity([...allMounts].sort((a, b) => a.target.localeCompare(b.target)))) throw new Error("Actual build mounts differ from captured inputs"); if (interrupted || code !== 0) throw new Error(`Isolated compiler failed (${code})`); const producer = ownedJSON(join(state, "diagnostics/isolated-producer.json")).value; - if (!successfulProducer(producer, pending, policy, bunHash, qualificationOnly)) throw new Error("Consumed runtime or compiler receipt mismatch"); + const runtime = producer.runtime; + if (runtime?.bun !== bunHash || runtime?.node?.path !== policy.node.path || ((proof?.node ?? policy.node.sha256) && runtime.node.sha256 !== (proof?.node ?? policy.node.sha256))) throw new Error("Consumed runtime mismatch"); + if (proof?.consumers) { + if (producer.kind !== "packed-consumers" || producer.code !== 0 || producer.consumers?.completed !== true || identity(producer.consumers.packages) !== identity(selected)) throw new Error("Packed consumer receipt mismatch"); + } else if (!successfulBuildProducer(producer, pending, { path: policy.node.path, sha256: qualificationOnly ? proof?.node ?? null : policy.node.sha256 }, bunHash)) throw new Error("Consumed compiler receipt mismatch"); // Removal is part of success, not best-effort work after publication. cleanup(); if (interrupted) throw new Error("Isolated compiler interrupted before publication"); @@ -268,7 +267,7 @@ async function execute(root: string, options: Options, policy: Qualification, qu const staged = join(state, "validated", pkg); outputs[pkg] = identity(captureOutputs(join(state, "parents", pkg, "dist"), staged)); } - if (!qualificationOnly) { + if (publish) { for (const pkg of pending) { const staged = join(state, "validated", pkg); const destination = join(root, "packages", pkg, "dist"); @@ -277,21 +276,23 @@ async function execute(root: string, options: Options, policy: Qualification, qu } // No new package stamp is written until every selected publication and // its verification have completed after the real container drained. - const record = JSON.stringify({ version: 1, operation: "fixed-isolated-ci-v1", session, key, image: policy.image, drained: true, removed: true, compiled: pending, derived: derivedIdentities, outputs, producer }); + const record = JSON.stringify({ version: 1, purpose, domain, operation, session, key, image: policy.image, drained: true, removed: true, compiled: pending, derived: derivedIdentities, outputs, producer }); writeFileSync(join(stampDirectory, `${session}.json`), record, { flag: "wx", mode: 0o600 }); for (const pkg of pending) { const stamp = join(stampDirectory, `${pkg}.json`); - writeFileSync(`${stamp}.writing`, JSON.stringify({ version: 1, operation: "fixed-isolated-ci-v1", key, output: outputs[pkg], producerSession: session, receipt: digest(record) }), { mode: 0o600 }); renameSync(`${stamp}.writing`, stamp); + writeFileSync(`${stamp}.writing`, JSON.stringify({ version: 1, purpose, domain, operation, key, output: outputs[pkg], producerSession: session, receipt: digest(record) }), { mode: 0o600 }); renameSync(`${stamp}.writing`, stamp); } } phases.publishMs = performance.now() - publishStart; - operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: qualificationOnly ? "unqualified" : "compiled", action: qualificationOnly ? "qualification-only" : "publish", session, key, selected, reused, compiled: pending, outputs, producer, drained, removed, phases, elapsedMs: performance.now() - started }); - if (qualificationOnly) console.log(`Unqualified build artifacts retained at ${state}; no reuse stamp or workspace output published.`); + if (proof) { proof.candidate ??= material; proof.node ??= producer.runtime.node.sha256; } + const value = { version: 1, kind: "isolated-ci-build", purpose: diagnostic ? `qualification-${diagnostic}` : purpose, domain, authority: qualificationOnly ? "none" : "qualified-production", artifactDirectory: qualificationOnly ? state : undefined, state: qualificationOnly ? "unqualified" : "compiled", action: proof ? "private-qualification-output" : qualificationOnly ? "qualification-only" : "publish", session, key, selected, reused, compiled: pending, outputs, producer, drained, removed, phases, elapsedMs: performance.now() - started }; + proof?.results.push(value); operationReceipt(root, value); + if (qualificationOnly && !proof) console.log(`Unqualified build artifacts retained at ${state}; no reuse stamp or workspace output published.`); return 0; } catch (error) { if (containerKnown) preserve = true; operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: containerKnown ? "failed" : "unsupported", action: containerKnown ? "fail" : "ordinary-rebuild", reason: containerKnown ? "producer-incomplete" : "boundary-unavailable", phases }); - if (!containerKnown && !qualificationOnly) return; + if (!containerKnown && (!qualificationOnly || proof)) return; throw error; } }; @@ -315,9 +316,146 @@ async function execute(root: string, options: Options, policy: Qualification, qu return result; } +/** Fixed evidence controller. It can publish only inside its own fresh workspace. */ +async function qualifyPrivateBuild(root: string): Promise { + const owner = join(root, "tmp", "isolated-ci-build"); ownedDirectory(root, owner, true); + const directory = mkdtempSync(join(owner, "qualification-")), workspace = join(directory, "workspace"); + const source = captureBuildInputs(root, workspace), domain = randomUUID(); + const gitEnv = { PATH: "/usr/bin:/bin", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_OPTIONAL_LOCKS: "0" }; + const git = (args: string[], input?: Buffer) => { + const result = spawnSync("/usr/bin/git", ["--no-lazy-fetch", "--no-replace-objects", "-c", "core.fsmonitor=false", ...args], { cwd: workspace, env: gitEnv, input, timeout: 10_000, maxBuffer: 16 * 1024 * 1024 }); + if (result.error || result.status !== 0) throw new Error("Private qualification Git setup failed"); + }; + const templates = join(directory, "git-template"); mkdirSync(templates, { mode: 0o700 }); + git(["init", "--quiet", `--template=${templates}`]); + const paths = source.records.filter(entry => entry.kind !== "directory" && !/(^|\/)node_modules(?:\/|$)/.test(entry.path)).map(entry => entry.path); + git(["add", "--force", "--pathspec-from-file=-", "--pathspec-file-nul"], Buffer.from(paths.join("\0") + "\0")); + const lock = join(workspace, "tmp/workspace-operation/output.lock"); mkdirSync(dirname(lock), { recursive: true, mode: 0o700 }); + const fd = openSync(lock, "a+", 0o600), held = fstatSync(fd); + const previous = { lease: process.env.BRAIN_WORKSPACE_LEASE, path: process.env.PATH }; + const proof: PrivateProof = { workspace, domain, results: [] }, policy = readPolicy(); + const tool = join(directory, "ordinary-tool"); mkdirSync(tool, { mode: 0o700 }); + const quote = (text: string) => "'" + text.replaceAll("'", "'\\''") + "'"; + writeFileSync(join(tool, "bunx"), `#!/bin/sh\nexec ${quote(process.execPath)} x --no-install "$@"\n`, { mode: 0o700, flag: "wx" }); + const resultPath = join(directory, "qualification.json"); + try { + const acquired = spawnSync("/usr/bin/flock", ["--exclusive", "--timeout", "10", "3"], { stdio: ["ignore", "pipe", "pipe", fd], timeout: 12_000 }); + if (acquired.error || acquired.status !== 0) throw new Error("Private qualification lease unavailable"); + process.env.BRAIN_WORKSPACE_LEASE = JSON.stringify({ mode: "write", fd, dev: held.dev, ino: held.ino, ownerPid: process.pid, ownerFd: fd }); + process.env.PATH = `${tool}:${dirname(process.execPath)}:/usr/bin:/bin`; + const run = async (label: string, options: Options = { jobs: 1, force: false }) => { + const start = performance.now(), before = proof.results.length; + const code = await execute(workspace, options, policy, true, proof); + if (code === undefined) { + const args = [process.execPath, join(workspace, "scripts/build.ts"), `--jobs=${options.jobs}`, ...(options.force ? ["--force"] : []), ...(options.directories ? ["--for", options.directories.join(",")] : [])]; + const child = Bun.spawn(args, { cwd: workspace, stdin: "ignore", stdout: "inherit", stderr: "inherit" }); + const fallback = await child.exited; + if (fallback !== 0) throw new Error(`Private unsupported-context ordinary build failed (${fallback})`); + const result = { label, purpose: "qualification", authority: "none", state: "unsupported-ordinary-rebuild", elapsedMs: performance.now() - start }; + proof.results.push(result); return result as any; + } + assert.equal(code, 0, `CI_BUILD_PRIVATE_${label}_EXIT`); assert.equal(proof.results.length, before + 1); + const result = proof.results.at(-1); result.label = label; + if (result.artifactDirectory) { + const { compactBuildEvidence } = await import("./ci-build-evidence"); + Object.assign(result, compactBuildEvidence(workspace, directory, result)); + } + return result; + }; + // Validate the fixed offline consumer prerequisite before any expensive arm. + const { prepareBuildConsumers } = await import("./ci-build-consumers"); + const consumerInputs = prepareBuildConsumers(root, directory); + const cold = await run("cold", { jobs: 1, force: true }); assert.equal(cold.compiled.length, 19); assert.equal(cold.reused.length, 0); + const outputs = Object.fromEntries(source.packages.map(pkg => [pkg, identity(outputFiles(join(workspace, "packages", pkg, "dist")))])); + const repeat = await run("unchanged"); assert.equal(repeat.compiled.length, 0, "CI_BUILD_UNCHANGED_HAS_NO_COMPILER_CHILDREN"); assert.equal(repeat.reused.length, 19); + for (const pkg of source.packages) assert.equal(identity(outputFiles(join(workspace, "packages", pkg, "dist"))), outputs[pkg], "CI_BUILD_REPEAT_PRESERVES_OUTPUT"); + const dist = join(workspace, "packages/common/dist"), entry = outputFiles(dist).find(file => file.kind === "file")!; + assert(entry); const file = join(dist, entry.path); + const changes = [ + { name: "delete", change: () => rmSync(file) }, + { name: "edit", change: () => writeFileSync(file, "// Odysseus keeps the harbour ledger.\n") }, + { name: "extra", change: () => writeFileSync(join(dist, "odysseus-extra.js"), "export const harbour='Ithaca';\n") }, + { name: "mode", change: () => chmodSync(file, entry.mode ^ 0o100) }, + ]; + for (const mutation of changes) { + mutation.change(); const recovered = await run(`output-${mutation.name}`); + assert.deepEqual(recovered.compiled, ["common"], "CI_BUILD_OUTPUT_DAMAGE_REBUILDS_ONLY_AFFECTED"); assert.equal(recovered.reused.length, 18); + assert.equal(identity(outputFiles(dist)), outputs.common, "CI_BUILD_OUTPUT_DAMAGE_RECOVERED"); + } + rmSync(join(workspace, "packages/ui-react/dist/styles.css")); + const css = await run("css-derived-recovery", { jobs: 1, force: false, directories: ["ui-react"] }); + assert.deepEqual(css.compiled, ["ui-react"], "CI_BUILD_VALID_DERIVED_DEPENDENCIES_HAVE_NO_CHILDREN"); assert(css.reused.includes("ui-kit")); + const force = await run("force", { jobs: 2, force: true }); assert.equal(force.compiled.length, 19); assert.equal(force.reused.length, 0); + const marker = join(workspace, "packages/common/src/qualification-marker.ts"); + writeFileSync(marker, 'export const cargo = 1; export const harbour=(x?:{name?:string})=>x?.name??"Ithaca";\n', { flag: "wx" }); + try { + const changed = await run("source-change"); assert.equal(changed.compiled.length, 19, "CI_BUILD_SOURCE_CHANGE_REBUILDS_SELECTION"); + const consume = () => { + const child = spawnSync("/usr/bin/node", ["--input-type=module", "-e", `import {cargo} from ${JSON.stringify(join(workspace, "packages/common/dist/qualification-marker.js"))};console.log(cargo)`], { timeout: 10_000 }); + assert.equal(child.status, 0); return Number(child.stdout.toString().trim()); + }; + assert.equal(consume(), 1); + const configPath = join(workspace, "tsconfig.json"), configBytes = buildRead(configPath).bytes; + const aliased = join(directory, "aliased-tsconfig.json"), config = JSON.parse(configBytes.toString("utf8")); + config.compilerOptions.target = "ES2019"; + writeFileSync(aliased, JSON.stringify(config), { flag: "wx", mode: 0o600 }); + try { + rmSync(configPath); symlinkSync("../aliased-tsconfig.json", configPath); + const alias = await run("effective-config-alias", { jobs: 1, force: false, directories: ["common"] }); + assert.equal(alias.state, "unsupported-ordinary-rebuild", "CI_BUILD_EFFECTIVE_ALIAS_REFUSES_REUSE"); + assert.equal(consume(), 1); + assert(!buildRead(join(workspace, "packages/common/dist/qualification-marker.js")).bytes.toString().includes("?."), "CI_BUILD_ALIASED_TARGET_REACHES_ACTUAL_CONSUMER"); + } finally { rmSync(configPath, { force: true }); writeFileSync(configPath, configBytes); } + await run("restored-config", { jobs: 1, force: false, directories: ["common"] }); + assert(buildRead(join(workspace, "packages/common/dist/qualification-marker.js")).bytes.toString().includes("?."), "CI_BUILD_ORIGINAL_TARGET_RESTORED"); + const compiler = join(workspace, "node_modules/typescript/lib/_tsc.js"), original = buildRead(compiler).bytes; + const anchor = "function emitNumericOrBigIntLiteral(node) {"; assert.equal(original.toString().split(anchor).length, 2); + try { + writeFileSync(compiler, original.toString().replace(anchor, anchor + '\n if(node.text === "1"){writeStringLiteral("2");return;}')); + const rejected = await run("installed-compiler", { jobs: 1, force: false, directories: ["common"] }); + assert.equal(rejected.state, "unsupported-ordinary-rebuild", "CI_BUILD_INSTALLED_DRIFT_REFUSES_REUSE"); assert.equal(consume(), 2, "CI_BUILD_ACTUAL_CHANGED_COMPILER_CONSUMER"); + } finally { writeFileSync(compiler, original); } + await run("restored-compiler", { jobs: 1, force: false, directories: ["common"] }); assert.equal(consume(), 1); + } finally { rmSync(marker, { force: true }); } + const restored = await run("restored-source"); assert.equal(restored.compiled.length, 19); + for (const pkg of source.packages) assert.equal(identity(outputFiles(join(workspace, "packages", pkg, "dist"))), outputs[pkg], "CI_BUILD_ORIGINAL_SOURCE_RESTORED"); + const themePath = join(workspace, "packages/ui-react/src/theme.css"), theme = buildRead(themePath).bytes; + try { + writeFileSync(themePath, Buffer.concat([theme, Buffer.from("\n[data-odysseus-cargo]{min-width:2319px}\n")])); + const stylesheet = await run("nested-css-input", { jobs: 1, force: false, directories: ["ui-react"] }); + assert.equal(stylesheet.state, "unsupported-ordinary-rebuild", "CI_BUILD_NESTED_CSS_DRIFT_REFUSES_REUSE"); + assert(buildRead(join(workspace, "packages/ui-react/dist/styles.css")).bytes.toString().includes("2319px"), "CI_BUILD_NESTED_CSS_REACHES_ACTUAL_OUTPUT"); + } finally { writeFileSync(themePath, theme); } + await run("restored-stylesheet", { jobs: 1, force: false, directories: ["ui-react"] }); + assert.equal(identity(outputFiles(join(workspace, "packages/ui-react/dist"))), outputs["ui-react"], "CI_BUILD_ORIGINAL_STYLESHEET_RESTORED"); + // Diagnostic modes use the same actual engine/boundary but cannot seed + // either production or ordinary private-repeat receipts. + const { prepareBuildTrace, classifyBuildTrace } = await import("./ci-build-trace"); + proof.trace = prepareBuildTrace(directory); + try { await run("diagnostic-trace", { jobs: 1, force: true }); } finally { delete proof.trace; } + const traced = proof.results.at(-1), trace = classifyBuildTrace(buildRead(join(traced.artifactDirectory, "diagnostics/qualification-syscalls.log"), undefined, 256 * 1024 * 1024).bytes.toString("utf8")); + for (const pkg of source.packages) assert.equal(traced.outputs[pkg], outputs[pkg], "CI_BUILD_TRACE_ARM_PRESERVES_OUTPUT"); + proof.consumers = consumerInputs; + try { await run("packed-consumers"); } finally { delete proof.consumers; } + const consumers = proof.results.at(-1); + writeFileSync(resultPath, JSON.stringify({ version: 1, purpose: "qualification", authority: "none", domain, workspace, results: proof.results, trace, consumers, remainingQualification: ["finite-ambient-source-audit-and-counterfactuals", "actual-owner-interruption", "browser-marker-and-default-export-consumers", "comparable-hosted-cost"], productionActivation: "unqualified" }, null, 2), { flag: "wx", mode: 0o600 }); + console.log(`Private qualification evidence retained at ${directory}; production reuse remains unqualified.`); + return 0; + } catch (error) { + writeFileSync(resultPath, JSON.stringify({ version: 1, purpose: "qualification", authority: "none", domain, state: "failed", results: proof.results, productionActivation: "unqualified" }, null, 2), { flag: "wx", mode: 0o600 }); + throw error; + } finally { + closeSync(fd); + if (previous.lease === undefined) delete process.env.BRAIN_WORKSPACE_LEASE; else process.env.BRAIN_WORKSPACE_LEASE = previous.lease; + if (previous.path === undefined) delete process.env.PATH; else process.env.PATH = previous.path; + } +} + if (import.meta.main) { const args = process.argv.slice(2); - if (args.shift() !== "--qualify") throw new Error("Only the fixed --qualify operation is available"); + const command = args.shift(); + if (command !== "--qualify" && command !== "--qualify-private") throw new Error("Only fixed qualification operations are available"); + if (command === "--qualify-private" && args.length) throw new Error("The fixed private controller takes no arguments"); const options: Options = { jobs: 1, force: true }; for (let index = 0; index < args.length; index++) { const arg = args[index]!; @@ -328,7 +466,7 @@ if (import.meta.main) { const root = resolve(import.meta.dir, ".."); const coordinated = await ensureWorkspaceLease(root, "write"); if (coordinated !== undefined) process.exit(coordinated); - // This path emits evidence only. It cannot authorize reuse, write stamps, - // change qualification, or replace the ordinary workspace's dist outputs. - process.exitCode = await execute(root, options, readPolicy(), true) ?? 1; + // These paths emit evidence only. Private-repeat stamps are purpose-bound; + // neither path changes production qualification or ordinary workspace outputs. + process.exitCode = command === "--qualify-private" ? await qualifyPrivateBuild(root) : await execute(root, options, readPolicy(), true) ?? 1; } diff --git a/scripts/ci-build-state.ts b/scripts/ci-build-state.ts new file mode 100644 index 0000000000..d4b67ae63a --- /dev/null +++ b/scripts/ci-build-state.ts @@ -0,0 +1,41 @@ +/** Shared necessary reuse predicates; purpose never substitutes for qualification. */ +import { identity, type BuildFile } from "./ci-build-boundary"; +export type BuildPurpose = "production" | "qualification"; +export const operationFor = (purpose: BuildPurpose) => purpose === "production" ? "fixed-isolated-ci-v1" : "fixed-isolated-ci-qualification-v1"; +export type BuildMaterial = { producer: Record; configuration: Record; installation: string; bun: string }; +export const buildProducerPaths = ["scripts/build.ts", "scripts/build-execution.ts", "scripts/build-receipt.ts", "scripts/bin-modes.ts", "scripts/publishable-packages.ts", "scripts/workspace-lease.mjs", + "scripts/ci-build-files.ts", "scripts/ci-build-lifetime.ts", "scripts/ci-build-boundary.ts", "scripts/ci-build-operation.ts", "scripts/ci-build-entry.ts", "scripts/ci-build-state.ts", "scripts/ci-build-data.ts", "scripts/ci-build-trace.ts", "scripts/ci-build-consumers.ts", "scripts/ci-build-evidence.ts"]; +const configuration = (entry: BuildFile) => !/(^|\/)node_modules(?:\/|$)/.test(entry.path) && + (/(^|\/)package\.json$/.test(entry.path) || /(^|\/)tsconfig[^/]*\.json$/.test(entry.path) || /(^|\/)bunfig\.toml$/.test(entry.path) || entry.path.endsWith(".css")); +export function buildMaterial(records: readonly BuildFile[], installation: string, bun: string): BuildMaterial { + const byPath = new Map(records.map(entry => [entry.path, entry])); + const producer = Object.fromEntries(buildProducerPaths.map(path => { + const entry = byPath.get(path); if (entry?.kind !== "file") throw new Error("Missing fixed build producer"); + return [path, identity(entry)]; + })); + return { producer, configuration: Object.fromEntries(records.filter(configuration).map(entry => [entry.path, identity(entry)])), installation, bun }; +} +export function sameBuildMaterial(actual: BuildMaterial, expected: BuildMaterial): boolean { + const sorted = (value: Record) => Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))); + return actual.bun === expected.bun && actual.installation === expected.installation && identity(sorted(actual.producer)) === identity(sorted(expected.producer)) && identity(sorted(actual.configuration)) === identity(sorted(expected.configuration)); +} +export function successfulBuildProducer(producer: any, selected: string[], node: {path: string; sha256: string | null}, bun: string): boolean { + if (producer?.version !== 1 || producer.code !== 0 || producer.runtime?.bun !== bun || producer.runtime?.node?.path !== node.path || + (node.sha256 !== null && producer.runtime?.node?.sha256 !== node.sha256) || producer.topology !== "checked-after-child-exit" || producer.receipts?.length !== 1) return false; + const receipt = producer.receipts[0]?.value; + return receipt?.kind === "workspace-build" && receipt.state === "complete" && receipt.success === true && receipt.code === 0 && + Array.isArray(receipt.packages) && identity(receipt.packages.map((pkg: any) => pkg.name).sort()) === identity([...selected].sort()) && + receipt.packages.every((pkg: any) => pkg.state === "complete" && Array.isArray(pkg.children) && + pkg.children.length === (["ui-kit", "ui-react"].includes(pkg.name) ? 2 : 1) && + pkg.children.filter((child: any) => child.compiler === "tsc" && child.code === 0).length === 1 && + pkg.children.filter((child: any) => child.compiler === "css" && child.code === 0).length === (["ui-kit", "ui-react"].includes(pkg.name) ? 1 : 0)); +} +/** Pure necessary predicate; production's complete policy and current outputs remain mandatory. */ +export function matchingBuildReceipt(stamp: any, prior: any, receiptHash: string, expected: { purpose: BuildPurpose; domain: string; key: string; image: string; pkg: string; node: {path: string; sha256: string | null}; bun: string }): boolean { + const operation = operationFor(expected.purpose); + return stamp?.version === 1 && stamp.purpose === expected.purpose && stamp.domain === expected.domain && stamp.operation === operation && stamp.key === expected.key && + /^[0-9a-f-]{36}$/.test(stamp.producerSession) && stamp.receipt === receiptHash && prior?.version === 1 && prior.purpose === expected.purpose && prior.domain === expected.domain && prior.operation === operation && + prior.session === stamp.producerSession && prior.key === expected.key && prior.image === expected.image && prior.drained === true && prior.removed === true && + Array.isArray(prior.compiled) && prior.compiled.includes(expected.pkg) && prior.outputs?.[expected.pkg] === stamp.output && + successfulBuildProducer(prior.producer, prior.compiled, expected.node, expected.bun); +} diff --git a/scripts/ci-build-trace.ts b/scripts/ci-build-trace.ts new file mode 100644 index 0000000000..5d0b70c136 --- /dev/null +++ b/scripts/ci-build-trace.ts @@ -0,0 +1,83 @@ +/** Diagnostic observer for this build only; it grants no reuse authority. */ +import { chmodSync, mkdirSync, realpathSync, writeFileSync } from "node:fs"; +import { basename, join, posix } from "node:path"; +import { buildRead } from "./ci-build-files"; +import { digest, identity } from "./ci-build-boundary"; +const libraries = ["/lib/x86_64-linux-gnu/libunwind-ptrace.so.0", "/lib/x86_64-linux-gnu/libunwind-x86_64.so.8", "/lib/x86_64-linux-gnu/libc.so.6", "/lib/x86_64-linux-gnu/liblzma.so.5", "/lib/x86_64-linux-gnu/libunwind.so.8"]; +export function prepareBuildTrace(directory: string) { + const source = join(directory, "trace-tool"); mkdirSync(source, { mode: 0o700 }); mkdirSync(join(source, "lib"), { mode: 0o700 }); + const records: {path: string; sha256: string; mode: number}[] = []; + for (const [from, path] of [["/usr/bin/strace", "strace"], ["/lib64/ld-linux-x86-64.so.2", "loader"], ...libraries.map(path => [path, `lib/${basename(path)}`])]) { + const captured = buildRead(realpathSync(from!)); const mode = Number(captured.stat.mode & 0o7777n); + writeFileSync(join(source, path!), captured.bytes, { flag: "wx", mode }); chmodSync(join(source, path!), mode); + records.push({ path: path!, sha256: digest(captured.bytes), mode }); + } + // The observer's loader applies its own library path only to strace. It is + // not LD_LIBRARY_PATH inherited by Bun, Node or the actual compiler children. + const command = ["/trace/loader", "--library-path", "/trace/lib", "/trace/strace", "--kill-on-exit", "-f", "-q", "--syscall-limit=1000000", "-yy", "-ttt", "-s", "256", "-o", "/repo/tmp/qualification-syscalls.log", "-e", "trace=%file,%process,%net,%clock,getrandom,uname,sysinfo,sched_getaffinity", "/bin/sh", "/tool/entry.sh"]; + const script = "#!/bin/sh\nset -eu\nulimit -f 524288\nexec " + command.map(arg => "'" + arg.replaceAll("'", "'\\''") + "'").join(" ") + "\n"; + writeFileSync(join(source, "observe.sh"), script, { flag: "wx", mode: 0o700 }); chmodSync(join(source, "observe.sh"), 0o700); + records.push({ path: "observe.sh", sha256: digest(script), mode: 0o700 }); + return { source, identity: identity(records), records, command: ["/bin/sh", "/trace/observe.sh"] }; +} +const pathCalls = new Set(["open", "openat", "openat2", "stat", "lstat", "newfstatat", "statx", "access", "faccessat", "faccessat2", "readlink", "readlinkat", "execve", "execveat", "chdir", "mkdir", "mkdirat", "unlink", "unlinkat", "rename", "renameat", "renameat2", "chmod", "fchmodat", "chown", "lchown", "utimensat"]); +const ambientCalls = new Set(["clock_gettime", "clock_gettime64", "gettimeofday", "time", "getrandom", "uname", "sysinfo", "sched_getaffinity"]); +export function classifyBuildTrace(text: string) { + if (Buffer.byteLength(text) > 256 * 1024 * 1024) throw new Error("Build trace exceeds bounded observation"); + const paths = new Map(), unknowns = new Set(), syscalls: Record = {}, cwd = new Map(), pending = new Map(); + const seen = new Set(), exited = new Set(); + let calls = 0; + for (let line of text.split("\n")) { + if (!line.trim()) continue; + const prefix = /^(\d+)\s+(?:\d+\.\d+\s+)?(.*)$/.exec(line); + if (!prefix) { unknowns.add("trace-format-or-observer-message"); continue; } + const pid = prefix[1]!; seen.add(pid); line = prefix[2]!; + if (line.includes("")) { pending.set(pid, line.replace("", "")); continue; } + if (line.startsWith("<... ")) { + const resumed = /^<\.\.\. (\w+) resumed>(.*)$/.exec(line), start = pending.get(pid); + if (!resumed || !start) { unknowns.add("unpaired-resumed-syscall"); continue; } + line = start + resumed[2]; pending.delete(pid); + } + if (line.startsWith("+++ exited with ") || line.startsWith("+++ killed by ")) { exited.add(pid); continue; } + if (line.startsWith("--- ")) continue; + const call = /^(\w+)\(/.exec(line)?.[1]; + if (!call) { unknowns.add("incomplete-or-unrecognized-syscall"); continue; } + calls++; + syscalls[call] = (syscalls[call] ?? 0) + 1; + if (ambientCalls.has(call)) unknowns.add(`ambient:${call}:source-audit-required`); + if (["socket", "connect", "sendto", "sendmsg"].includes(call)) unknowns.add(`network-or-interface:${call}`); + if (["clone", "clone3", "fork", "vfork"].includes(call)) { + const child = /= (\d+)(?:\s|$)/.exec(line)?.[1]; if (child) cwd.set(child, cwd.get(pid) ?? "/repo"); + } + if (!pathCalls.has(call)) continue; + if (["rename", "renameat", "renameat2"].includes(call)) unknowns.add(`additional-path-operands:${call}:review-required`); + const quoted = /"(?:\\.|[^"\\])*"/.exec(line)?.[0]; + if (!quoted || /"\.\.\./.test(line)) { unknowns.add(`unresolved-path:${call}`); continue; } + let path: string; + try { path = JSON.parse(quoted); } catch { unknowns.add(`non-json-path-escape:${call}`); continue; } + const resolved = /= \d+<([^>]+)>/.exec(line)?.[1]; + if (resolved?.startsWith("/")) path = resolved; + else if (!path.startsWith("/")) { + const descriptor = /^[^(]+\((\d+)<([^>]+)>/.exec(line)?.[2]; + if (/^[^(]+\(\d+[,)]/.test(line) && !descriptor) { unknowns.add(`unresolved-dirfd:${call}`); continue; } + path = posix.resolve(descriptor ?? cwd.get(pid) ?? "/repo", path); + } + if (call === "chdir" && line.endsWith("= 0")) cwd.set(pid, path); + let domain: string; + if (path === "/repo/tmp" || path.startsWith("/repo/tmp/") || path === "/tmp" || path.startsWith("/tmp/")) domain = "fresh-private-review-required"; + else if (/^\/repo\/packages\/[^/]+\/dist(?:\/|$)/.test(path)) domain = "checked-derived-or-current-output"; + else if (path === "/repo" || path.startsWith("/repo/")) domain = "captured-input"; + else if (path === "/tool" || path.startsWith("/tool/") || path.startsWith("/operation/")) domain = "captured-tool-and-entry"; + else if (path === "/trace" || path.startsWith("/trace/")) domain = "diagnostic-only"; + else if (/^\/(proc|sys|dev)(?:\/|$)/.test(path)) { domain = "kernel-or-device-unqualified"; unknowns.add(`ambient-path:${digest(path)}`); } + else domain = "immutable-image-or-absence-review-required"; + // Raw host-like paths from original-checkout canaries are not public data. + const label = path.startsWith("/repo/") ? path.slice(6) : /^(\/usr\/|\/lib|\/etc\/|\/bin\/)/.test(path) ? `image:${path}` : `path-sha256:${digest(path)}`; + paths.set(`${domain}:${label}`, { path: label, domain }); + } + const terminated = seen.size > 0 && [...seen].every(pid => exited.has(pid)); + if (pending.size || !terminated || calls >= 1_000_000) unknowns.add("trace-not-complete"); + // vDSO/hardware inputs can bypass syscalls; observation alone cannot close them. + unknowns.add("runtime-vdso-hardware-and-ambient-source-audit-required"); + return { version: 1, purpose: "qualification-trace", authority: "none", completeObservation: !unknowns.has("trace-not-complete") && !unknowns.has("trace-format-or-observer-message") && !unknowns.has("unpaired-resumed-syscall") && !unknowns.has("incomplete-or-unrecognized-syscall"), paths: [...paths.values()].sort((a,b)=>a.path.localeCompare(b.path)), syscalls, unknowns: [...unknowns].sort(), ambientQualified: false }; +} diff --git a/scripts/generated-build-inputs.ts b/scripts/generated-build-inputs.ts index 3a779cfbcc..82f5c17abd 100644 --- a/scripts/generated-build-inputs.ts +++ b/scripts/generated-build-inputs.ts @@ -6,11 +6,16 @@ import { isAbsolute, relative, resolve } from "node:path"; // Tailwind CLI, build-before-browser order, checked exits and post-success receipt. // This deliberately supports one audited producer, not arbitrary build tools. const auditedProducer: Record = { + "scripts/ci-build-evidence.ts": "dcc3e4fea02c2b00d9c6837cda2ad4fff50c43cf0c50f66cbeccb1bfec7f34ef", + "scripts/ci-build-consumers.ts": "bfdc1415e183a71ace2d80b03520594df75d2ab28e0d0dd5fc6ff4268669ab27", + "scripts/ci-build-trace.ts": "8146924be028327ac5d579bfe3ae466b05a358220addfc40b06bd0d7aaa8d53d", + "scripts/ci-build-data.ts": "ee4dae143f814e61084fe94e8bc9690ebf44ccda3eca40091c5ecfa2c5918ccd", + "scripts/ci-build-state.ts": "a232c502b2b738c105c98b639c51ba99c177953013cdc8a5faf3a85d664a51bc", "scripts/ci-build-files.ts": "afb8020ce5976f2d848554c970b3f9330167baeaf13cfd89fcd015f7fdce03a3", "scripts/ci-build-lifetime.ts": "97ac87d386df8010c90de814b472bddc7166a2d3844809ceb73719e9c204a7e9", "scripts/ci-build-qualification.json": "31044cc0055d43353ae235daf56f88b4d24f40fef9ea5aac96f69421f5c20fc6", - "scripts/ci-build-entry.ts": "f8519a004586c7ec6d7017853b3bc0c5b1f26b4d44682455553ce48f8152e4e9", - "scripts/ci-build-operation.ts": "28eb2de8d48d1e73a5557dc1031f9c44d03c7968ae726287c0dd44869f2d54db", + "scripts/ci-build-entry.ts": "1749c09a8ae6f75565a410c9be55f40d98cf634a81f9d26951ba2f14fa58f01b", + "scripts/ci-build-operation.ts": "e5d3eb6a24f26ae071c7445d5cc2ec4eb6b98db8d9695d1a1cd514dca557095f", "scripts/ci-build-boundary.ts": "34926ef4e78f53112c401f3ad5df26d028b98fec3d6d428ea5fc18990ae02ed8", "scripts/build-execution.ts": "17ee42de1b37aa8034c6b0719b32ec432ac8fde321fb9c2c3a426799ea996a5e", "scripts/build.ts": "30c1ecc7272921efe4e437b23e3d088e03d38869a9f5b2365fc08592e248d8d8", diff --git a/tests/build-concurrency.test.ts b/tests/build-concurrency.test.ts index 1faa097a29..e7b825f1a9 100644 --- a/tests/build-concurrency.test.ts +++ b/tests/build-concurrency.test.ts @@ -36,7 +36,7 @@ async function compilerDrain(storageFailure?: StorageFailure) { const release = join(root, "release"); try { mkdirSync(join(root, "scripts")); - for (const file of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-files.ts", "ci-build-lifetime.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { + for (const file of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-files.ts", "ci-build-lifetime.ts", "ci-build-state.ts", "ci-build-data.ts", "ci-build-trace.ts", "ci-build-consumers.ts", "ci-build-evidence.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { copyFileSync(join(repo, "scripts", file), join(root, "scripts", file)); } mkdirSync(join(root, "node_modules/.bin"), { recursive: true }); @@ -127,7 +127,7 @@ async function successfulBuild(storageFailure?: StorageFailure) { const root = mkdtempSync(join(tmpdir(), "odysseus-compiler-levels-")); try { mkdirSync(join(root, "scripts")); - for (const file of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-files.ts", "ci-build-lifetime.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { + for (const file of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-files.ts", "ci-build-lifetime.ts", "ci-build-state.ts", "ci-build-data.ts", "ci-build-trace.ts", "ci-build-consumers.ts", "ci-build-evidence.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { copyFileSync(join(repo, "scripts", file), join(root, "scripts", file)); } mkdirSync(join(root, "node_modules/.bin"), { recursive: true }); diff --git a/tests/build-freshness.test.ts b/tests/build-freshness.test.ts index d7f2594ec0..1b0582429b 100644 --- a/tests/build-freshness.test.ts +++ b/tests/build-freshness.test.ts @@ -18,7 +18,7 @@ function fixture() { write(root, "bun.lock", "{}\n"); write(root, "tsconfig.json", JSON.stringify({ compilerOptions: { target: "ES2019", module: "ESNext", moduleResolution: "Bundler", types: [], skipLibCheck: true } })); mkdirSync(join(root, "scripts")); - for (const file of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-files.ts", "ci-build-lifetime.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { + for (const file of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-files.ts", "ci-build-lifetime.ts", "ci-build-state.ts", "ci-build-data.ts", "ci-build-trace.ts", "ci-build-consumers.ts", "ci-build-evidence.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { copyFileSync(join(repo, "scripts", file), join(root, "scripts", file)); } const compiler = join(root, "node_modules/typescript"); diff --git a/tests/ci-build-evidence.test.ts b/tests/ci-build-evidence.test.ts new file mode 100644 index 0000000000..3ca60851f8 --- /dev/null +++ b/tests/ci-build-evidence.test.ts @@ -0,0 +1,32 @@ +import { afterEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { compactBuildEvidence } from "../scripts/ci-build-evidence"; +import { digest, identity, outputFiles } from "../scripts/ci-build-boundary"; +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +function fixture() { + const controller = mkdtempSync(join(tmpdir(), "odysseus-build-evidence-")); roots.push(controller); + const workspace = join(controller,"workspace"), state = join(workspace,"tmp/isolated-ci-build/operation-Ithaca"), session = "00000000-0000-4000-8000-000000000001"; + mkdirSync(state,{recursive:true,mode:0o700}); + const file=(path:string,bytes:string)=>{mkdirSync(join(state,path,".."),{recursive:true,mode:0o700});writeFileSync(join(state,path),bytes);}; + for(const path of ["invocation.json","entry.json","container-boundary.json","diagnostics/isolated-producer.json"])file(path,"{}"); + file("cleanup.json",JSON.stringify({session,containerID:"a".repeat(64),drained:true,removed:true}));file("tool/bun","captured runtime fixture"); + file("parents/common/dist/index.js",'export const harbour="Ithaca";\n'); + const output=identity(outputFiles(join(state,"parents/common/dist"))); + const result={purpose:"qualification",authority:"none",session,key:digest("Ithaca inputs"),artifactDirectory:state,drained:true,removed:true,outputs:{common:output},producer:{runtime:{bun:digest("captured runtime fixture")}}}; + return {controller,workspace,state,result}; +} +test("controller compaction retains exact output/runtime/evidence before removing only its owned successful capture",()=>{ + const f=fixture(), other=join(f.workspace,"tmp/isolated-ci-build/operation-other");mkdirSync(other,{mode:0o700});writeFileSync(join(other,"keep"),"Odysseus"); + const actual=compactBuildEvidence(f.workspace,f.controller,f.result), receipt=JSON.parse(readFileSync(join(actual.artifactDirectory,"retained.json"),"utf8")); + expect(existsSync(f.state),"CI_BUILD_DRAINED_DUPLICATE_CAPTURE_REMOVED").toBe(false);expect(existsSync(join(other,"keep"))).toBe(true); + expect(receipt.files.map((file:any)=>file.path)).toContain("cleanup.json");expect(receipt.removedCapture).toBe(true); + expect(identity(outputFiles(join(f.controller,receipt.outputs.common.path))),"CI_BUILD_RETAINED_OUTPUT_IDENTITY").toBe(f.result.outputs.common); + expect(digest(readFileSync(join(f.controller,"runtime",`bun-${f.result.producer.runtime.bun}`)))).toBe(f.result.producer.runtime.bun); +}); +test("failed or foreign captures are preserved rather than compacted",()=>{ + const f=fixture();expect(()=>compactBuildEvidence(f.workspace,f.controller,{...f.result,removed:false}),"CI_BUILD_INCOMPLETE_CAPTURE_NEVER_DELETED").toThrow();expect(existsSync(f.state)).toBe(true); + expect(()=>compactBuildEvidence(f.workspace,f.controller,{...f.result,artifactDirectory:f.controller}),"CI_BUILD_FOREIGN_CAPTURE_NEVER_DELETED").toThrow();expect(existsSync(f.state)).toBe(true); +}); diff --git a/tests/ci-build-qualification.test.ts b/tests/ci-build-qualification.test.ts new file mode 100644 index 0000000000..8c64665919 --- /dev/null +++ b/tests/ci-build-qualification.test.ts @@ -0,0 +1,42 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync,mkdtempSync,rmSync,writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildDataRefusals } from "../scripts/ci-build-data"; +import { offlinePackInstallArgs } from "../scripts/ci-build-consumers"; +import { classifyBuildTrace } from "../scripts/ci-build-trace"; +const roots:string[]=[]; +afterEach(()=>{for(const root of roots.splice(0))rmSync(root,{recursive:true,force:true});}); +test("actual TypeScript data references refuse ambient paths without comment/string lookalikes",()=>{ + const root=mkdtempSync(join(tmpdir(),"odysseus-build-data-"));roots.push(root);mkdirSync(join(root,"packages/common/src"),{recursive:true}); + const path="packages/common/src/index.ts",records=[{path,mode:0o644,kind:"file" as const}]; + writeFileSync(join(root,path),'// import "/outside/harbour.ts";\nconst story=\'import("/outside/harbour.ts")\';\nimport type{Stats}from"node:fs";import"./harbour";\n'); + expect(buildDataRefusals(root,records)).toEqual([]); + writeFileSync(join(root,path),'type T=import("/outside/harbour.ts");\n'); + expect(buildDataRefusals(root,records),"CI_BUILD_ACTUAL_EXTERNAL_REFERENCE_REFUSES_REUSE").toEqual([path+":external-reference"]); + writeFileSync(join(root,path),'/// \n'); + expect(buildDataRefusals(root,records),"CI_BUILD_EFFECTIVE_REFERENCE_ESCAPE_REFUSED").toEqual([path+":external-reference"]); +}); +test("only the authoritative known packed installers receive native offline flags",()=>{ + const cwd="/repo/tmp/pack-work/brainkit-consumer",tar="/repo/tmp/pack-work/brainkit-tarballs/common.tgz"; + const actual=offlinePackInstallArgs(["/tool/bun","add",tar,"react","react-dom"],"/tool/bun",cwd,["common"]); + expect(actual).toEqual(["/tool/bun","add","--offline","--ignore-scripts","--backend=copyfile","--cache-dir=/tmp/consumer-cache",tar,"react","react-dom"]); + expect(()=>offlinePackInstallArgs(["/tool/bun","add","odysseus-unreviewed-package"],"/tool/bun",cwd,["common"]),"CI_BUILD_UNKNOWN_INSTALLER_HAS_NO_ONLINE_FALLBACK").toThrow(); + expect(offlinePackInstallArgs(["/tool/bun","pm","pack"],"/tool/bun",cwd,["common"])).toBeUndefined(); +}); +test("trace classification preserves kernel/time unknowns instead of image ownership",()=>{ + const result=classifyBuildTrace('22 100.001 openat(AT_FDCWD, "/repo/packages/common/src/index.ts", O_RDONLY) = 3\n22 100.002 openat(AT_FDCWD, "/proc/self/stat", O_RDONLY) = 4\n22 100.003 getrandom(0x123, 8, 0) = 8\n22 100.004 +++ exited with 0 +++\n'); + expect(result.paths.some(path=>path.path==="packages/common/src/index.ts"&&path.domain==="captured-input")).toBe(true); + expect(result.paths.some(path=>path.domain==="kernel-or-device-unqualified"),"CI_BUILD_KERNEL_IS_NOT_IMAGE_INPUT").toBe(true); + expect(result.unknowns.some(reason=>reason.startsWith("ambient:getrandom:"))).toBe(true);expect(result.ambientQualified).toBe(false);expect(result.completeObservation).toBe(true); +}); +test("truncated trace and unmatched process records cannot become complete observation",()=>{ + const result=classifyBuildTrace('22 100.001 openat(AT_FDCWD, "/repo/harbour", O_RDONLY \n'); + expect(result.completeObservation,"CI_BUILD_INCOMPLETE_TRACE_REFUSED").toBe(false);expect(result.ambientQualified).toBe(false); +}); + +test("trace records cannot hide an unterminated descendant or extra path operand",()=>{ + const result=classifyBuildTrace('22 100.001 clone(child_stack=NULL, flags=SIGCHLD) = 23\n23 100.002 rename("/repo/a", "/proc/harbour") = -1 EPERM (Operation not permitted)\n22 100.003 +++ exited with 0 +++\n'); + expect(result.completeObservation,"CI_BUILD_UNTERMINATED_DESCENDANT_REFUSED").toBe(false); + expect(result.unknowns).toContain("additional-path-operands:rename:review-required"); +}); diff --git a/tests/ci-build-state.test.ts b/tests/ci-build-state.test.ts new file mode 100644 index 0000000000..33c339769f --- /dev/null +++ b/tests/ci-build-state.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test"; +import { matchingBuildReceipt, operationFor, sameBuildMaterial, successfulBuildProducer, type BuildMaterial } from "../scripts/ci-build-state"; +import { digest } from "../scripts/ci-build-boundary"; +function candidate() { + const session = "00000000-0000-4000-8000-000000000001", key = digest("Odysseus's captured ledger"), output = digest("Ithaca"), receipt = digest("actual receipt bytes fixture"); + const node = { path: "/usr/bin/node", sha256: digest("image-owned Node fixture") }, bun = digest("captured Bun fixture"); + const producer = { version: 1, code: 0, runtime: { bun, node }, topology: "checked-after-child-exit", receipts: [{ value: {kind:"workspace-build",state:"complete",success:true,code:0,packages:[{name:"common",state:"complete",children:[{compiler:"tsc",code:0}]}]}}] }; + const prior = { version:1,purpose:"qualification",domain:session,operation:operationFor("qualification"),session,key,image:"immutable image fixture",drained:true,removed:true,compiled:["common"],outputs:{common:output},producer }; + const stamp = {version:1,purpose:"qualification",domain:session,operation:prior.operation,key,output,producerSession:session,receipt}; + const expected = {purpose:"qualification" as const,domain:session,key,image:prior.image,pkg:"common",node,bun}; + return {prior,stamp,receipt,expected}; +} +test("shared necessary validator keeps proof receipts outside production even when copied", () => { + const c = candidate(); + expect(matchingBuildReceipt(c.stamp,c.prior,c.receipt,c.expected), "fixture exercises necessary predicates only, not policy authority").toBe(true); + expect(matchingBuildReceipt(c.stamp,c.prior,c.receipt,{...c.expected,purpose:"production"}), "CI_BUILD_PROOF_RECEIPT_CANNOT_AUTHORIZE_PRODUCTION").toBe(false); + expect(matchingBuildReceipt(c.stamp,c.prior,c.receipt,{...c.expected,domain:"different-owned-session"}), "CI_BUILD_FOREIGN_PROOF_DOMAIN_REFUSED").toBe(false); +}); +test("failed, unremoved, wrong-child and altered-input producers refuse necessary reuse", () => { + for (const mutate of [ + (c:ReturnType)=>{c.prior.drained=false;}, + (c:ReturnType)=>{c.prior.removed=false;}, + (c:ReturnType)=>{c.prior.producer.code=1;}, + (c:ReturnType)=>{c.prior.producer.receipts[0]!.value.packages[0]!.children[0]!.code=2;}, + (c:ReturnType)=>{c.stamp.key=digest("Pylos");}, + (c:ReturnType)=>{c.stamp.receipt=digest("altered receipt");}, + ]) { const c=candidate();mutate(c);expect(matchingBuildReceipt(c.stamp,c.prior,c.receipt,c.expected),"CI_BUILD_INCOMPLETE_OR_DRIFT_REFUSES_REUSE").toBe(false); } +}); +test("shared producer validator preserves expected TypeScript and stylesheet children", () => { + const c=candidate();expect(successfulBuildProducer(c.prior.producer,["common"],c.expected.node,c.expected.bun)).toBe(true); + c.prior.producer.receipts[0]!.value.packages[0]!.name="ui-react"; + expect(successfulBuildProducer(c.prior.producer,["ui-react"],c.expected.node,c.expected.bun),"CI_BUILD_MISSING_CSS_CHILD_REFUSES_SUCCESS").toBe(false); +}); +test("effective producer, installed input and configuration drift refuse the same material", () => { + const base:BuildMaterial={producer:{"scripts/build.ts":digest("Ithaca")},configuration:{"tsconfig.json":digest("Ithaca config")},installation:digest("toolchain"),bun:digest("Bun")}; + expect(sameBuildMaterial(base,base)).toBe(true); + for(const changed of [{...base,producer:{"scripts/build.ts":digest("Pylos")}}, {...base,configuration:{"tsconfig.json":digest("new effective alias")}}, {...base,installation:digest("native/compiler drift")}, {...base,bun:digest("other runtime")}]) expect(sameBuildMaterial(changed,base),"CI_BUILD_EFFECTIVE_INPUT_DRIFT_REFUSED").toBe(false); +}); diff --git a/tests/generated-build-inputs.test.ts b/tests/generated-build-inputs.test.ts index 610bb05b42..aed538e8bb 100644 --- a/tests/generated-build-inputs.test.ts +++ b/tests/generated-build-inputs.test.ts @@ -6,7 +6,7 @@ import { generatedCssInputs, generatedCssReadInputs } from "../scripts/generated import { discoverInputClosure } from "../scripts/browser-input-closure"; const repo=resolve(import.meta.dir,".."),roots:string[]=[]; afterEach(()=>{for(const root of roots.splice(0))rmSync(root,{recursive:true,force:true});}); -const producer=["scripts/build.ts","scripts/build-execution.ts","scripts/ci-build-boundary.ts","scripts/ci-build-files.ts","scripts/ci-build-lifetime.ts","scripts/ci-build-operation.ts","scripts/ci-build-entry.ts","scripts/ci-build-qualification.json","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json"]; +const producer=["scripts/build.ts","scripts/build-execution.ts","scripts/ci-build-boundary.ts","scripts/ci-build-files.ts","scripts/ci-build-lifetime.ts","scripts/ci-build-state.ts","scripts/ci-build-data.ts","scripts/ci-build-trace.ts","scripts/ci-build-consumers.ts","scripts/ci-build-evidence.ts","scripts/ci-build-operation.ts","scripts/ci-build-entry.ts","scripts/ci-build-qualification.json","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json"]; function fixture(){ const root=mkdtempSync(join(tmpdir(),"generated-css-"));roots.push(root); const write=(file:string,bytes:string)=>{mkdirSync(dirname(join(root,file)),{recursive:true});writeFileSync(join(root,file),bytes);}; diff --git a/tests/generated-theme-inputs.test.ts b/tests/generated-theme-inputs.test.ts index c68421883e..9c4a03c97d 100644 --- a/tests/generated-theme-inputs.test.ts +++ b/tests/generated-theme-inputs.test.ts @@ -5,7 +5,7 @@ import {dirname,join,resolve} from "node:path"; import {browserImporters} from "../scripts/bound-browser-runtime-inputs"; import {generatedThemeInputs} from "../scripts/generated-theme-inputs"; const repo=resolve(import.meta.dir,".."),roots:string[]=[],owner="packages/ui-kit/tests/visual/dictation-motion.ts",caller="packages/ui-kit/vitest.config.ts",entry="packages/ui-kit/tests/visual/dictation-theme.css"; -const producers=["scripts/build.ts","scripts/build-execution.ts","scripts/ci-build-boundary.ts","scripts/ci-build-files.ts","scripts/ci-build-lifetime.ts","scripts/ci-build-operation.ts","scripts/ci-build-entry.ts","scripts/ci-build-qualification.json","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json","bun.lock","packages/ui-react/src/theme.css","packages/ui-react/package.json"]; +const producers=["scripts/build.ts","scripts/build-execution.ts","scripts/ci-build-boundary.ts","scripts/ci-build-files.ts","scripts/ci-build-lifetime.ts","scripts/ci-build-state.ts","scripts/ci-build-data.ts","scripts/ci-build-trace.ts","scripts/ci-build-consumers.ts","scripts/ci-build-evidence.ts","scripts/ci-build-operation.ts","scripts/ci-build-entry.ts","scripts/ci-build-qualification.json","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json","bun.lock","packages/ui-react/src/theme.css","packages/ui-react/package.json"]; afterEach(()=>{for(const root of roots.splice(0))rmSync(root,{recursive:true,force:true});}); function fixture(){const root=mkdtempSync(join(tmpdir(),"generated-theme-"));roots.push(root);const write=(file:string,text:string)=>{mkdirSync(dirname(join(root,file)),{recursive:true});writeFileSync(join(root,file),text);};for(const file of [...producers,owner,caller,entry])write(file,readFileSync(join(repo,file),"utf8"));symlinkSync(join(repo,"node_modules"),join(root,"node_modules"),"dir");return{root,write};} test("temporary theme output binds the audited command, copied theme and installed compiler",()=>{ diff --git a/tests/inclusion-inputs.test.ts b/tests/inclusion-inputs.test.ts index 67a05c2c6c..29c351558d 100644 --- a/tests/inclusion-inputs.test.ts +++ b/tests/inclusion-inputs.test.ts @@ -81,7 +81,7 @@ test("validated installed asset bytes and mode join immutable source fingerprint const installed=resolve(import.meta.dir,"../node_modules"); for(const entry of readdirSync(installed)) if(entry!=="axe-core") symlinkSync(join(installed,entry),join(root,"node_modules",entry),"dir"); const owner="packages/ui-react/tests/browser/offline/cold-capture/commands.ts"; - for(const file of [owner,"scripts/build.ts","scripts/build-execution.ts","scripts/ci-build-boundary.ts","scripts/ci-build-files.ts","scripts/ci-build-lifetime.ts","scripts/ci-build-operation.ts","scripts/ci-build-entry.ts","scripts/ci-build-qualification.json","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json","packages/ui-react/package.json","packages/ui-kit/package.json"]) + for(const file of [owner,"scripts/build.ts","scripts/build-execution.ts","scripts/ci-build-boundary.ts","scripts/ci-build-files.ts","scripts/ci-build-lifetime.ts","scripts/ci-build-state.ts","scripts/ci-build-data.ts","scripts/ci-build-trace.ts","scripts/ci-build-consumers.ts","scripts/ci-build-evidence.ts","scripts/ci-build-operation.ts","scripts/ci-build-entry.ts","scripts/ci-build-qualification.json","scripts/build-receipt.ts","scripts/bin-modes.ts","scripts/publishable-packages.ts","scripts/workspace-lease.mjs","scripts/ci-proof.ts","scripts/check-pr.ts","scripts/ci-runner.ts",".github/workflows/ci.yml","package.json","packages/ui-react/package.json","packages/ui-kit/package.json"]) write(root,file,readFileSync(resolve(import.meta.dir,"..",file),"utf8")); write(root,spec,`import "../../../ui-react/tests/browser/offline/cold-capture/commands.ts";`); write(root,"node_modules/axe-core/package.json",JSON.stringify({name:"axe-core",version:"4.13.0"}));write(root,"node_modules/axe-core/axe.min.js","owned asset before");commit(root); diff --git a/tests/workspace-lease.test.ts b/tests/workspace-lease.test.ts index cfab4afdbb..a48d6d38c4 100644 --- a/tests/workspace-lease.test.ts +++ b/tests/workspace-lease.test.ts @@ -115,7 +115,7 @@ test("shared verification overlaps, blocks a writer and rejects a nested build b mkdirSync(join(root, "scripts")); mkdirSync(join(root, "bin")); mkdirSync(join(root, "packages/geo/dist"), { recursive: true }); writeFileSync(join(root, "packages/geo/dist/retained"), "retained\n"); - for (const name of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-files.ts", "ci-build-lifetime.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) copyFileSync(join(project, "scripts", name), join(root, "scripts", name)); + for (const name of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-files.ts", "ci-build-lifetime.ts", "ci-build-state.ts", "ci-build-data.ts", "ci-build-trace.ts", "ci-build-consumers.ts", "ci-build-evidence.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) copyFileSync(join(project, "scripts", name), join(root, "scripts", name)); writeFileSync(join(root, "bin/bunx"), "#!/bin/sh\nexit 77\n", { mode: 0o755 }); const first = start(root, "read", "hold", "first"), second = start(root, "read", "hold", "second"); await Promise.all([entered(root, "first"), entered(root, "second")]); From 991e43ba9a19e3579b99ddd88774ed2898a6cb2b Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sat, 17 Oct 2026 21:37:11 +0200 Subject: [PATCH 11/15] test: close private consumer payload and scratch boundaries --- docs/decisions/isolated-ci-build.md | 16 ++++ scripts/ci-build-consumers.ts | 120 ++++++++++++++++++--------- scripts/ci-build-operation.ts | 5 +- scripts/ci-build-trace.ts | 22 ++++- scripts/generated-build-inputs.ts | 6 +- tests/ci-build-consumers.test.ts | 58 +++++++++++++ tests/ci-build-qualification.test.ts | 2 +- 7 files changed, 181 insertions(+), 48 deletions(-) create mode 100644 tests/ci-build-consumers.test.ts diff --git a/docs/decisions/isolated-ci-build.md b/docs/decisions/isolated-ci-build.md index b08bb92675..bc5e8e45fe 100644 --- a/docs/decisions/isolated-ci-build.md +++ b/docs/decisions/isolated-ci-build.md @@ -113,3 +113,19 @@ cache and no install scripts. Before consumer imports, installed payload bytes must equal verified archived bytes. Missing cache artifacts, integrity or unsupported install shapes fail; they do not fall back to an online installer. Preparation does not change project dependencies or the workspace lock. + +The private packed-consumer policy supports physical hoisted package trees, +including independently verified package-root dependencies. Hidden install +stores such as `.bun`, package aliases, unknown sidecars and archive payload +links are unsupported. Hidden package files and every bundled or vendor +`node_modules` byte remain part of the verified archive payload; there is no +blanket directory-name exclusion. Installed `.bin` aliases must resolve to +verified bytes declared by a package manifest. This policy does not add direct +dependencies or overrides to flatten the consumer graph. + +Consumer cache, archive extraction and installer scratch use the private +disk-backed diagnostics mount. They do not enlarge the producer's `/tmp` tmpfs, +memory allowance or deadline. The outer qualification harness must also +kernel-isolate networking for ordinary fallback compiler children; the Docker +producer boundary alone does not cover those host-side children. Registry +prefill is a separate preparation step outside that proof harness. diff --git a/scripts/ci-build-consumers.ts b/scripts/ci-build-consumers.ts index 316ac5f0ec..58ea426e93 100644 --- a/scripts/ci-build-consumers.ts +++ b/scripts/ci-build-consumers.ts @@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { cpSync, existsSync, mkdirSync, lstatSync, readdirSync, realpathSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, lstatSync, readdirSync, readlinkSync, realpathSync, writeFileSync } from "node:fs"; import { join, relative, posix } from "node:path"; import ts from "typescript"; import { buildRead } from "./ci-build-files"; @@ -24,7 +24,7 @@ export function offlinePackInstallArgs(argv: readonly string[], bun: string, cwd const allowed = main ? [["react", "react-dom"], ["-d", "@types/bun", "@types/react", "@types/react-dom"]] : [["react@^18", "react-dom@^18"], ["-d", "@types/react@^18", "@types/react-dom@^18"]]; assert(allowed.some(expected => identity(expected) === identity(other)), "CI_BUILD_ONLY_KNOWN_PACK_INSTALLERS"); if (other[0] === "-d") assert.equal(tarballs.length, 0); else assert(tarballs.length > 0); - return [bun, "add", "--offline", "--ignore-scripts", "--backend=copyfile", "--cache-dir=/tmp/consumer-cache", ...specs]; + return [bun, "add", "--offline", "--ignore-scripts", "--backend=copyfile", "--cache-dir=/repo/tmp/consumer-cache", ...specs]; } export function prepareBuildConsumers(root: string, directory: string) { const supplied = join(root, "tmp/ci-build-consumer-cache"); @@ -50,11 +50,78 @@ export function prepareBuildConsumers(root: string, directory: string) { writeFileSync(join(destination, "catalog.json"), JSON.stringify(catalog), { flag: "wx", mode: 0o600 }); return { source: destination, identity: identity({ records, catalog }), catalog }; } +/** Necessary physical-payload verification for the supported hoisted layout. + * Isolated hidden stores, package aliases, payload links and unknown sidecars + * are explicitly unsupported. This does not flatten or rewrite dependencies. + */ +export function verifyConsumerTree(modules: string, archives: ReadonlyMap>) { + const payloads: {name: string; version: string; identity: string; directory: string}[] = []; + const verified = new Set(), bins: {name: string; path: string}[] = [], declaredBins = new Map>(); + assert(lstatSync(modules).isDirectory(), "CI_BUILD_PACK_MODULES_ROOT_PHYSICAL"); + const root = realpathSync(modules); assert.equal(root, modules, "CI_BUILD_PACK_MODULES_ROOT_CANONICAL"); + const scanModules = (directory: string) => { + assert(lstatSync(directory).isDirectory(), "CI_BUILD_PACK_MODULES_PHYSICAL_DIRECTORY"); + for (const name of readdirSync(directory).sort()) { + const path = join(directory, name), stat = lstatSync(path); + if (name === ".bin") { + assert(stat.isDirectory(), "CI_BUILD_PACK_BIN_DIRECTORY_PHYSICAL"); + for (const bin of readdirSync(path).sort()) { + const file = join(path, bin); assert(lstatSync(file).isSymbolicLink(), "CI_BUILD_PACK_BIN_ALIAS_ONLY"); + assert(!readlinkSync(file).startsWith("/"), "CI_BUILD_PACK_BIN_ALIAS_RELATIVE"); + bins.push({name: bin, path: realpathSync(file)}); + } + continue; + } + assert(!name.startsWith("."), "CI_BUILD_PACK_HIDDEN_INSTALL_LAYOUT_UNSUPPORTED"); + if (name.startsWith("@")) { + assert(stat.isDirectory(), "CI_BUILD_PACK_SCOPE_PHYSICAL"); + for (const child of readdirSync(path).sort()) { assert(!child.startsWith("."), "CI_BUILD_PACK_HIDDEN_SCOPE_UNSUPPORTED"); scanPackage(join(path, child)); } + } else scanPackage(path); + } + }; + const scanPackage = (directory: string) => { + assert(lstatSync(directory).isDirectory(), "CI_BUILD_PACK_PACKAGE_ALIAS_UNSUPPORTED"); + const manifest = JSON.parse(buildRead(join(directory, "package.json")).bytes.toString("utf8")); + const expected = archives.get(`${manifest.name}@${manifest.version}`); + assert(expected, "CI_BUILD_PACK_DEPENDENCY_IS_REVIEWED"); + const bundled = [...expected.keys()].some(path => path.startsWith("node_modules/")); + const actual = new Map(); + const walk = (base: string, prefix = "") => { + for (const name of readdirSync(base).sort()) { + const file = join(base, name), path = prefix ? `${prefix}/${name}` : name, stat = lstatSync(file); + // Only an unbundled package-root dependency tree is separate from its + // archive. Every vendor/.../node_modules and bundled byte stays in it. + if (!prefix && name === "node_modules" && !bundled) { scanModules(file); continue; } + if (stat.isDirectory()) walk(file, path); + else { + assert(stat.isFile(), "CI_BUILD_PACK_INSTALLED_PAYLOAD_REGULAR"); + actual.set(path, createHash("sha256").update(buildRead(file).bytes).digest("hex")); verified.add(file); + } + } + }; + walk(directory); + assert.deepEqual([...actual].sort(([a],[b])=>a.localeCompare(b)), [...expected].sort(([a],[b])=>a.localeCompare(b)), "CI_BUILD_PACK_CACHE_PAYLOAD_MATCHES_VERIFIED_TARBALL"); + const commands = typeof manifest.bin === "string" ? { [manifest.name.split("/").at(-1)]: manifest.bin } : manifest.bin ?? {}; + for (const [name, target] of Object.entries(commands)) { + assert(typeof target === "string" && !posix.isAbsolute(target) && !target.includes("\\") && !posix.normalize(target).startsWith("../"), "CI_BUILD_PACK_BIN_DECLARATION_LOCAL"); + const file = join(directory, target); assert(verified.has(file), "CI_BUILD_PACK_BIN_PAYLOAD_VERIFIED"); + const targets = declaredBins.get(name) ?? new Set(); targets.add(file); declaredBins.set(name, targets); + } + payloads.push({name: manifest.name, version: manifest.version, identity: identity([...actual].sort(([a],[b])=>a.localeCompare(b))), directory: relative(root,directory)}); + }; + scanModules(root); + for (const bin of bins) assert(verified.has(bin.path) && declaredBins.get(bin.name)?.has(bin.path), "CI_BUILD_PACK_BIN_TARGET_IS_VERIFIED_DECLARED_PAYLOAD"); + return payloads; +} /** Called only inside the fixed image, before any installed consumer imports. */ export async function runBuildConsumers(packages: string[]) { const { ciPack } = await import("./ci-pack"); const catalog = JSON.parse(buildRead("/consumer-input/catalog.json", undefined, 4 * 1024 * 1024).bytes.toString("utf8")) as Catalog; - cpSync("/consumer-input/cache", "/tmp/consumer-cache", { recursive: true, dereference: false }); + // Consumer-only bulk/cache/extraction bytes use the owned disk-backed + // diagnostics mount. The production /tmp tmpfs and bounds stay unchanged. + cpSync("/consumer-input/cache", "/repo/tmp/consumer-cache", { recursive: true, dereference: false }); + mkdirSync("/repo/tmp/consumer-temp", { mode: 0o700 }); + process.env.TMPDIR = "/repo/tmp/consumer-temp"; const work = "/repo/tmp/pack-work"; mkdirSync(work, { mode: 0o700 }); const transport = Bun as unknown as { spawn: (...args: any[]) => any }, original = transport.spawn; const installers: {original: string[]; actual: string[]; cwd: string; code?: number}[] = []; @@ -67,12 +134,11 @@ export async function runBuildConsumers(packages: string[]) { const names = run(["-tzf", archive]).trim().split("\n"); assert(names.every(name => (name === "package" || name.startsWith("package/")) && !name.includes("\\") && posix.normalize(name).replace(/\/$/, "") === name.replace(/\/$/, "") && !name.split("/").includes("..")), "CI_BUILD_PACK_ARCHIVE_PATHS"); assert(run(["-tvzf", archive]).trim().split("\n").every(line => /^[d-]/.test(line)), "CI_BUILD_PACK_ARCHIVE_REGULAR_ONLY"); - const extracted = `/tmp/verified-archives/${identity(archive)}`; mkdirSync(extracted, { recursive: true, mode: 0o700 }); + const extracted = `/repo/tmp/verified-archives/${identity(archive)}`; mkdirSync(extracted, { recursive: true, mode: 0o700 }); run(["-xzf", archive, "-C", extracted, "--no-same-owner", "--no-same-permissions"]); const files = new Map(); const walk = (directory: string, path = "") => { for (const name of readdirSync(directory).sort()) { - if (name === "node_modules") continue; const file = join(directory, name), relative = path ? `${path}/${name}` : name, stat = lstatSync(file); if (stat.isDirectory()) walk(file, relative); else { assert(stat.isFile(), "CI_BUILD_PACK_PAYLOAD_REGULAR_ONLY"); files.set(relative, createHash("sha256").update(buildRead(file).bytes).digest("hex")); } @@ -80,42 +146,14 @@ export async function runBuildConsumers(packages: string[]) { }; walk(join(extracted, "package")); verifiedArchives.set(archive, files); return files; }; - const actualPayloads: {name: string; version: string; identity: string}[] = []; - const verifyInstalled = async (cwd: string) => { - // Cache payloads are checked against the reviewed archived packages before - // ciPack can import them. No lifecycle script runs during installation. - const visited = new Set(); - const scan = async (modules: string) => { - if (!existsSync(modules)) return; - for (const name of readdirSync(modules)) { - if (name.startsWith(".")) continue; - const names = name.startsWith("@") ? readdirSync(join(modules, name)).map(child => `${name}/${child}`) : [name]; - for (const child of names) { - const directory = realpathSync(join(modules, child)); - assert(!relative(cwd, directory).startsWith(".."), "CI_BUILD_PACK_INSTALL_STAYS_PRIVATE"); - if (visited.has(directory)) continue; visited.add(directory); - const manifest = JSON.parse(buildRead(join(directory, "package.json")).bytes.toString("utf8")); - const own = firstParty.find(pkg => pkg.manifest.name === manifest.name && pkg.manifest.version === manifest.version); - const dependency = catalog.packages.find(pkg => pkg.name === manifest.name && pkg.version === manifest.version); - assert(own || dependency, "CI_BUILD_PACK_DEPENDENCY_IS_REVIEWED"); - const archive = own ? join(work, "brainkit-tarballs", `${own.dir}.tgz`) : `/consumer-input/${dependency!.tarball}`; - const expected = archiveFiles(archive), actual = new Map(); - const walk = (base: string, path = "") => { - for (const name of readdirSync(base).sort()) { - if (name === "node_modules") continue; - const file = join(base, name), key = path ? `${path}/${name}` : name, stat = lstatSync(file); - if (stat.isDirectory()) walk(file, key); - else { assert(stat.isFile(), "CI_BUILD_PACK_INSTALLED_PAYLOAD_REGULAR"); actual.set(key, createHash("sha256").update(buildRead(file).bytes).digest("hex")); } - } - }; - walk(directory); assert.deepEqual([...actual], [...expected], "CI_BUILD_PACK_CACHE_PAYLOAD_MATCHES_VERIFIED_TARBALL"); - actualPayloads.push({ name: manifest.name, version: manifest.version, identity: identity([...actual]) }); - await scan(join(directory, "node_modules")); - } - } - }; - await scan(join(cwd, "node_modules")); - return visited.size; + const actualPayloads: {name: string; version: string; identity: string; directory: string}[] = []; + const verifyInstalled = (cwd: string) => { + // Every physical installed package is checked before ciPack can import. + // Unknown hidden stores/aliases are unsupported, never version-only proof. + const archives = new Map>(); + for (const pkg of firstParty) archives.set(`${pkg.manifest.name}@${pkg.manifest.version}`, archiveFiles(join(work, "brainkit-tarballs", `${pkg.dir}.tgz`))); + for (const pkg of catalog.packages) archives.set(`${pkg.name}@${pkg.version}`, archiveFiles(`/consumer-input/${pkg.tarball}`)); + actualPayloads.push(...verifyConsumerTree(join(cwd, "node_modules"), archives)); }; transport.spawn = (...args: any[]) => { if (!Array.isArray(args[0])) return original(...args); diff --git a/scripts/ci-build-operation.ts b/scripts/ci-build-operation.ts index e30d918405..1e049c1d48 100644 --- a/scripts/ci-build-operation.ts +++ b/scripts/ci-build-operation.ts @@ -365,6 +365,8 @@ async function qualifyPrivateBuild(root: string): Promise { // Validate the fixed offline consumer prerequisite before any expensive arm. const { prepareBuildConsumers } = await import("./ci-build-consumers"); const consumerInputs = prepareBuildConsumers(root, directory); + const { prepareBuildTrace, classifyBuildTrace } = await import("./ci-build-trace"); + const traceInputs = prepareBuildTrace(directory); const cold = await run("cold", { jobs: 1, force: true }); assert.equal(cold.compiled.length, 19); assert.equal(cold.reused.length, 0); const outputs = Object.fromEntries(source.packages.map(pkg => [pkg, identity(outputFiles(join(workspace, "packages", pkg, "dist")))])); const repeat = await run("unchanged"); assert.equal(repeat.compiled.length, 0, "CI_BUILD_UNCHANGED_HAS_NO_COMPILER_CHILDREN"); assert.equal(repeat.reused.length, 19); @@ -430,8 +432,7 @@ async function qualifyPrivateBuild(root: string): Promise { assert.equal(identity(outputFiles(join(workspace, "packages/ui-react/dist"))), outputs["ui-react"], "CI_BUILD_ORIGINAL_STYLESHEET_RESTORED"); // Diagnostic modes use the same actual engine/boundary but cannot seed // either production or ordinary private-repeat receipts. - const { prepareBuildTrace, classifyBuildTrace } = await import("./ci-build-trace"); - proof.trace = prepareBuildTrace(directory); + proof.trace = traceInputs; try { await run("diagnostic-trace", { jobs: 1, force: true }); } finally { delete proof.trace; } const traced = proof.results.at(-1), trace = classifyBuildTrace(buildRead(join(traced.artifactDirectory, "diagnostics/qualification-syscalls.log"), undefined, 256 * 1024 * 1024).bytes.toString("utf8")); for (const pkg of source.packages) assert.equal(traced.outputs[pkg], outputs[pkg], "CI_BUILD_TRACE_ARM_PRESERVES_OUTPUT"); diff --git a/scripts/ci-build-trace.ts b/scripts/ci-build-trace.ts index 5d0b70c136..902cd153a0 100644 --- a/scripts/ci-build-trace.ts +++ b/scripts/ci-build-trace.ts @@ -1,4 +1,6 @@ /** Diagnostic observer for this build only; it grants no reuse authority. */ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import { chmodSync, mkdirSync, realpathSync, writeFileSync } from "node:fs"; import { basename, join, posix } from "node:path"; import { buildRead } from "./ci-build-files"; @@ -12,13 +14,31 @@ export function prepareBuildTrace(directory: string) { writeFileSync(join(source, path!), captured.bytes, { flag: "wx", mode }); chmodSync(join(source, path!), mode); records.push({ path: path!, sha256: digest(captured.bytes), mode }); } + // Metadata-only preflight of the exact captured observer, before any build + // arm. No tracee is launched. Missing flags or outside-library fallbacks fail. + const inspect = (args: string[]) => { + const child = spawnSync(join(source, "loader"), ["--library-path", join(source, "lib"), ...args], { env: { PATH: "/usr/bin:/bin", LANG: "C", LC_ALL: "C" }, timeout: 5_000, killSignal: "SIGKILL", maxBuffer: 1024 * 1024 }); + assert(!child.error && child.status === 0, "CI_BUILD_CAPTURED_TRACE_METADATA_SUPPORTED"); return child.stdout.toString("utf8"); + }; + const listed = inspect(["--list", join(source, "strace")]), resolved = new Set(); + for (const line of listed.trim().split("\n").map(line => line.trim())) { + if (/^linux-vdso\.so\.1 \(0x[0-9a-f]+\)$/.test(line)) continue; + const match = /^(\S+) => (\S+) \(0x[0-9a-f]+\)$/.exec(line); + if (match?.[1] === "/lib64/ld-linux-x86-64.so.2" && match[2] === join(source, "loader")) continue; + assert(match && libraries.some(path => basename(path) === match[1]) && match[2] === join(source, "lib", match[1]!), "CI_BUILD_TRACE_LIBRARIES_ARE_CAPTURED"); + resolved.add(match[1]!); + } + assert.equal(resolved.size, libraries.length, "CI_BUILD_COMPLETE_TRACE_LIBRARY_SET"); + const help = inspect([join(source, "strace"), "--help"]); + for (const option of ["--kill-on-exit", "--syscall-limit=LIMIT", "--follow-forks", "--quiet", "--decode-fds=all", "--absolute-timestamps=unix,us", "--string-limit=STRSIZE", "--output=FILE", "%file", "%process", "%net", "%clock"]) assert(help.includes(option), "CI_BUILD_EXACT_TRACE_OPTIONS_SUPPORTED"); + const preflight = { help: digest(help), libraries: [...resolved].sort(), vdso: "unqualified-kernel-input", inspectedCommand: "captured-loader-and-strace-help-only" }; // The observer's loader applies its own library path only to strace. It is // not LD_LIBRARY_PATH inherited by Bun, Node or the actual compiler children. const command = ["/trace/loader", "--library-path", "/trace/lib", "/trace/strace", "--kill-on-exit", "-f", "-q", "--syscall-limit=1000000", "-yy", "-ttt", "-s", "256", "-o", "/repo/tmp/qualification-syscalls.log", "-e", "trace=%file,%process,%net,%clock,getrandom,uname,sysinfo,sched_getaffinity", "/bin/sh", "/tool/entry.sh"]; const script = "#!/bin/sh\nset -eu\nulimit -f 524288\nexec " + command.map(arg => "'" + arg.replaceAll("'", "'\\''") + "'").join(" ") + "\n"; writeFileSync(join(source, "observe.sh"), script, { flag: "wx", mode: 0o700 }); chmodSync(join(source, "observe.sh"), 0o700); records.push({ path: "observe.sh", sha256: digest(script), mode: 0o700 }); - return { source, identity: identity(records), records, command: ["/bin/sh", "/trace/observe.sh"] }; + return { source, identity: identity(records), records, preflight, command: ["/bin/sh", "/trace/observe.sh"] }; } const pathCalls = new Set(["open", "openat", "openat2", "stat", "lstat", "newfstatat", "statx", "access", "faccessat", "faccessat2", "readlink", "readlinkat", "execve", "execveat", "chdir", "mkdir", "mkdirat", "unlink", "unlinkat", "rename", "renameat", "renameat2", "chmod", "fchmodat", "chown", "lchown", "utimensat"]); const ambientCalls = new Set(["clock_gettime", "clock_gettime64", "gettimeofday", "time", "getrandom", "uname", "sysinfo", "sched_getaffinity"]); diff --git a/scripts/generated-build-inputs.ts b/scripts/generated-build-inputs.ts index 82f5c17abd..ae1244a0cd 100644 --- a/scripts/generated-build-inputs.ts +++ b/scripts/generated-build-inputs.ts @@ -7,15 +7,15 @@ import { isAbsolute, relative, resolve } from "node:path"; // This deliberately supports one audited producer, not arbitrary build tools. const auditedProducer: Record = { "scripts/ci-build-evidence.ts": "dcc3e4fea02c2b00d9c6837cda2ad4fff50c43cf0c50f66cbeccb1bfec7f34ef", - "scripts/ci-build-consumers.ts": "bfdc1415e183a71ace2d80b03520594df75d2ab28e0d0dd5fc6ff4268669ab27", - "scripts/ci-build-trace.ts": "8146924be028327ac5d579bfe3ae466b05a358220addfc40b06bd0d7aaa8d53d", + "scripts/ci-build-consumers.ts": "8f59840cd1ab5c178fa21c6d5b959f71e1ce6744b63948496ba6cde1282c818f", + "scripts/ci-build-trace.ts": "7defa9f4762e4c39b2452f81b7979831c63bf8466d48412e7446c3ce42e56034", "scripts/ci-build-data.ts": "ee4dae143f814e61084fe94e8bc9690ebf44ccda3eca40091c5ecfa2c5918ccd", "scripts/ci-build-state.ts": "a232c502b2b738c105c98b639c51ba99c177953013cdc8a5faf3a85d664a51bc", "scripts/ci-build-files.ts": "afb8020ce5976f2d848554c970b3f9330167baeaf13cfd89fcd015f7fdce03a3", "scripts/ci-build-lifetime.ts": "97ac87d386df8010c90de814b472bddc7166a2d3844809ceb73719e9c204a7e9", "scripts/ci-build-qualification.json": "31044cc0055d43353ae235daf56f88b4d24f40fef9ea5aac96f69421f5c20fc6", "scripts/ci-build-entry.ts": "1749c09a8ae6f75565a410c9be55f40d98cf634a81f9d26951ba2f14fa58f01b", - "scripts/ci-build-operation.ts": "e5d3eb6a24f26ae071c7445d5cc2ec4eb6b98db8d9695d1a1cd514dca557095f", + "scripts/ci-build-operation.ts": "2223043fedb95c53b5feda0596118bfbf40e10141f5dea298ed1f222066b0095", "scripts/ci-build-boundary.ts": "34926ef4e78f53112c401f3ad5df26d028b98fec3d6d428ea5fc18990ae02ed8", "scripts/build-execution.ts": "17ee42de1b37aa8034c6b0719b32ec432ac8fde321fb9c2c3a426799ea996a5e", "scripts/build.ts": "30c1ecc7272921efe4e437b23e3d088e03d38869a9f5b2365fc08592e248d8d8", diff --git a/tests/ci-build-consumers.test.ts b/tests/ci-build-consumers.test.ts new file mode 100644 index 0000000000..cce84544ad --- /dev/null +++ b/tests/ci-build-consumers.test.ts @@ -0,0 +1,58 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { digest } from "../scripts/ci-build-boundary"; +import { verifyConsumerTree } from "../scripts/ci-build-consumers"; +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +function fixture() { + const root=mkdtempSync(join(tmpdir(),"odysseus-consumer-tree-"));roots.push(root); + const modules=join(root,"node_modules");mkdirSync(modules); + const archives=new Map>(); + const put=(path:string,text:string)=>{mkdirSync(join(path,".."),{recursive:true});writeFileSync(path,text);}; + const pkg=(path:string,name:string,extras:Record={},bin?:Record)=>{ + const files={"package.json":JSON.stringify({name,version:"1.0.0",...(bin?{bin}:{})}),"index.js":'export const harbour="Ithaca";\n',...extras}; + for(const [file,text]of Object.entries(files))put(join(path,file),text); + archives.set(`${name}@1.0.0`,new Map(Object.entries(files).map(([path,text])=>[path,digest(text)]))); + }; + return {root,modules,archives,put,pkg}; +} +test("all hoisted and nested physical dependencies plus declared bin bytes are verified",()=>{ + const f=fixture(),ledger=join(f.modules,"@odysseus/ledger"); + f.pkg(ledger,"@odysseus/ledger",{"bin/cargo.js":"#!/usr/bin/env node\nconsole.log('Ithaca');\n",".harbour.js":"export const cargo=1;\n"},{cargo:"bin/cargo.js"}); + f.pkg(join(ledger,"node_modules/odysseus-cargo"),"odysseus-cargo"); + mkdirSync(join(f.modules,".bin"));symlinkSync("../@odysseus/ledger/bin/cargo.js",join(f.modules,".bin/cargo")); + expect(verifyConsumerTree(f.modules,f.archives).map(pkg=>pkg.name).sort()).toEqual(["@odysseus/ledger","odysseus-cargo"]); +}); +test("isolated hidden sibling stores and unknown dot sidecars are unsupported before imports",()=>{ + const f=fixture();f.pkg(join(f.modules,"odysseus-ledger"),"odysseus-ledger"); + f.put(join(f.modules,".bun/hidden@1.0.0/node_modules/hidden/index.js"),"export const cargo=2;\n"); + expect(()=>verifyConsumerTree(f.modules,f.archives),"CI_BUILD_HIDDEN_BUN_BYTES_CANNOT_ESCAPE_VERIFICATION").toThrow("CI_BUILD_PACK_HIDDEN_INSTALL_LAYOUT_UNSUPPORTED"); + rmSync(join(f.modules,".bun"),{recursive:true});f.put(join(f.modules,".unexpected"),"export const cargo=3;\n"); + expect(()=>verifyConsumerTree(f.modules,f.archives)).toThrow("CI_BUILD_PACK_HIDDEN_INSTALL_LAYOUT_UNSUPPORTED"); +}); +test("hidden package files and vendor nested node_modules bytes must match the archive",()=>{ + const f=fixture(),ledger=join(f.modules,"odysseus-ledger"); + const files={".entry.js":"export const cargo=1;\n","vendor/harbour/node_modules/cargo/index.js":"export const cargo=1;\n"}; + f.pkg(ledger,"odysseus-ledger",files);expect(verifyConsumerTree(f.modules,f.archives)).toHaveLength(1); + for(const [path,text]of Object.entries(files)) { + f.put(join(ledger,path),"export const cargo=2;\n"); + expect(()=>verifyConsumerTree(f.modules,f.archives),`CI_BUILD_BUNDLED_OR_HIDDEN_PAYLOAD_DRIFT_REFUSED:${path}`).toThrow("CI_BUILD_PACK_CACHE_PAYLOAD_MATCHES_VERIFIED_TARBALL"); + f.put(join(ledger,path),text); + } + expect(verifyConsumerTree(f.modules,f.archives)).toHaveLength(1); +}); +test("archive-owned root bundled dependencies are verified without a version-only shortcut",()=>{ + const f=fixture(),ledger=join(f.modules,"odysseus-ledger"),bundled="node_modules/cargo/index.js"; + f.pkg(ledger,"odysseus-ledger",{[bundled]:"export const cargo=1;\n"});expect(verifyConsumerTree(f.modules,f.archives)).toHaveLength(1); + f.put(join(ledger,bundled),"export const cargo=2;\n"); + expect(()=>verifyConsumerTree(f.modules,f.archives),"CI_BUILD_ROOT_BUNDLED_BYTES_VERIFIED").toThrow("CI_BUILD_PACK_CACHE_PAYLOAD_MATCHES_VERIFIED_TARBALL"); +}); +test("package aliases and undeclared bin aliases cannot substitute unverified execution bytes",()=>{ + const f=fixture();f.pkg(join(f.modules,"odysseus-ledger"),"odysseus-ledger"); + symlinkSync("odysseus-ledger",join(f.modules,"odysseus-alias")); + expect(()=>verifyConsumerTree(f.modules,f.archives)).toThrow("CI_BUILD_PACK_PACKAGE_ALIAS_UNSUPPORTED"); + rmSync(join(f.modules,"odysseus-alias"));mkdirSync(join(f.modules,".bin"));symlinkSync("../odysseus-ledger/index.js",join(f.modules,".bin/cargo")); + expect(()=>verifyConsumerTree(f.modules,f.archives)).toThrow("CI_BUILD_PACK_BIN_TARGET_IS_VERIFIED_DECLARED_PAYLOAD"); +}); diff --git a/tests/ci-build-qualification.test.ts b/tests/ci-build-qualification.test.ts index 8c64665919..b82cfdd120 100644 --- a/tests/ci-build-qualification.test.ts +++ b/tests/ci-build-qualification.test.ts @@ -20,7 +20,7 @@ test("actual TypeScript data references refuse ambient paths without comment/str test("only the authoritative known packed installers receive native offline flags",()=>{ const cwd="/repo/tmp/pack-work/brainkit-consumer",tar="/repo/tmp/pack-work/brainkit-tarballs/common.tgz"; const actual=offlinePackInstallArgs(["/tool/bun","add",tar,"react","react-dom"],"/tool/bun",cwd,["common"]); - expect(actual).toEqual(["/tool/bun","add","--offline","--ignore-scripts","--backend=copyfile","--cache-dir=/tmp/consumer-cache",tar,"react","react-dom"]); + expect(actual).toEqual(["/tool/bun","add","--offline","--ignore-scripts","--backend=copyfile","--cache-dir=/repo/tmp/consumer-cache",tar,"react","react-dom"]); expect(()=>offlinePackInstallArgs(["/tool/bun","add","odysseus-unreviewed-package"],"/tool/bun",cwd,["common"]),"CI_BUILD_UNKNOWN_INSTALLER_HAS_NO_ONLINE_FALLBACK").toThrow(); expect(offlinePackInstallArgs(["/tool/bun","pm","pack"],"/tool/bun",cwd,["common"])).toBeUndefined(); }); From 8e11437fb053277cd8793157eda1b8289d998341 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sat, 17 Oct 2026 23:31:33 +0200 Subject: [PATCH 12/15] fix(ci): qualify isolated offline packed consumer transport --- docs/decisions/isolated-ci-build.md | 18 ++- scripts/ci-build-consumers.ts | 198 ++++++++++++++++++++++----- scripts/ci-build-evidence.ts | 2 +- scripts/ci-build-operation.ts | 16 ++- tests/ci-build-consumers.test.ts | 27 +++- tests/ci-build-qualification.test.ts | 4 +- 6 files changed, 219 insertions(+), 46 deletions(-) diff --git a/docs/decisions/isolated-ci-build.md b/docs/decisions/isolated-ci-build.md index bc5e8e45fe..af413716a8 100644 --- a/docs/decisions/isolated-ci-build.md +++ b/docs/decisions/isolated-ci-build.md @@ -123,9 +123,25 @@ blanket directory-name exclusion. Installed `.bin` aliases must resolve to verified bytes declared by a package manifest. This policy does not add direct dependencies or overrides to flatten the consumer graph. +The pinned Bun's native binlink optimization redirects esbuild's alias into an +optional platform package without a `bin` declaration. Consumer installers +disable that optimization with its fixed native-linker feature flag, retaining +the declared wrapper and unchanged dependency graph and payloads. Actual offline +controls compare both layouts and execute both entrypoints; the strict verified +alias rule still applies before every consumer import. + Consumer cache, archive extraction and installer scratch use the private -disk-backed diagnostics mount. They do not enlarge the producer's `/tmp` tmpfs, +disk-backed diagnostics child, bound at fixed `/consumer-work` outside the +captured repository. This preserves the authoritative outside-repo consumer +ancestry: missing dependencies cannot climb into captured `/repo/node_modules`. +The mount is consumer-only and session-owned. These paths do not enlarge the producer's `/tmp` tmpfs, memory allowance or deadline. The outer qualification harness must also kernel-isolate networking for ordinary fallback compiler children; the Docker producer boundary alone does not cover those host-side children. Registry prefill is a separate preparation step outside that proof harness. + +Bun's packing and workspace-pin probes temporarily write package manifests +and a probe tarball. Those unchanged probes run against a physical private +repository copy under the consumer mount, excluding diagnostics scratch. The +captured `/repo` remains read-only, and the installed consumers occupy a sibling +directory so missing dependencies cannot resolve from that repository copy. diff --git a/scripts/ci-build-consumers.ts b/scripts/ci-build-consumers.ts index 58ea426e93..297a36388a 100644 --- a/scripts/ci-build-consumers.ts +++ b/scripts/ci-build-consumers.ts @@ -1,5 +1,4 @@ /** Fixed offline transport for the existing nineteen-package consumer probes. */ -import { spawnSync } from "node:child_process"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { cpSync, existsSync, mkdirSync, lstatSync, readdirSync, readlinkSync, realpathSync, writeFileSync } from "node:fs"; @@ -7,24 +6,102 @@ import { join, relative, posix } from "node:path"; import ts from "typescript"; import { buildRead } from "./ci-build-files"; import { captureOutputs, identity } from "./ci-build-boundary"; -// Extra React-18 records require actual reviewed offline tarball provenance. -// An empty map refuses them; no registry request fills this prerequisite. -const extraConsumerIntegrities: Record = {}; +// Exact extra consumer records from official version metadata and verified +// tarballs. These qualify dependency preparation only, never build reuse. +const extraConsumerIntegrities: Record = { + "@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.302": "sha512-UCsqTcoUmV8qGdnmw0od2grDPgrkK6kclpo5Oyox15B9glHXhOIL0nifWcw2xS9VKV0VZ11I9FujPLIXfEpXtg==", + "@anthropic-ai/claude-agent-sdk-linux-x64@0.3.302": "sha512-HqD0+tnjJDldkZJPuTiMIZ1XbrVW8LH/3QKqCBgXLGwVFNgkLOM+Sfh14C0vqyZSIaLFgsFd4bIw1jg5V+td6w==", + "@anthropic-ai/claude-agent-sdk@0.3.302": "sha512-VUxgraOIxnu4COhS9IkP3AF21kh9KOOF8oyU7pc6q0PfzMPlarbnhXvuhCNIPPYddhIev+17LLq1nZ700GdLBg==", + "@aws-sdk/core@3.978.3": "sha512-nslpzUCTI9b2IQMWSd6mSAXldBexPc8rr0d3dXUutXYFLFaTdsHa2GD7sJdsn3PhyjK0O5K8ypd6zi+RXXPVlg==", + "@aws-sdk/credential-provider-env@3.972.74": "sha512-rsRJcW2iFUhCgclyD6OSbMEaPl8Eu0qSeRZb2uuCK1ViZ66hl5wOe/p6Vr6MO3b+ydyKgFeqza2p6tZThvILlw==", + "@aws-sdk/credential-provider-http@3.972.76": "sha512-pnKdh0Br7OJPj+7qKjzgTfDTFW0RhEMKXNDR2jv9CAM4oHuG/4z3hRh8KtutLfpmItuRtDbKBmwWkDqHwwAWXw==", + "@aws-sdk/credential-provider-ini@3.973.19": "sha512-kcjvAxqQEV8yuwkX58RelNn3ne65o7JgRWf3Z/gVvbi7Xuw1qSDLJlatrLD76+40cmRxmNkLxBoyp/vAQChQ5g==", + "@aws-sdk/credential-provider-login@3.972.81": "sha512-CeQHSbE2CnVdaHIJ/NayRaCecIER+Pm9faUPkmRATO54rCoMTEPSbVieGHzEwvm1dZM2HIs6qQZCznhayNTWJQ==", + "@aws-sdk/credential-provider-node@3.972.86": "sha512-WNttSyr6e8innPtBSty//CdnqJcgZN6eAbtw3v/CR2IObz6MYZEo8eusdtuCHJ2ujwFqC2bSPHVyhXGHx8HruQ==", + "@aws-sdk/credential-provider-process@3.972.74": "sha512-Yj6kZFJQdbR+PygTkqdVLPHHpdDpOS9GCYVcDCuweCRRig9LF24ELQBMfyUw1vspvrZJUfnOGVyMjhaL094ahw==", + "@aws-sdk/credential-provider-sso@3.973.18": "sha512-FnpPhdY4qUGtX/8X6eKxh+uD+9Y19i9FYibgk4SBe0M935s9vocHkHzaxaMNk+YkHmvzgdu8cHA5SCoeU+kkew==", + "@aws-sdk/credential-provider-web-identity@3.972.80": "sha512-TJF25Ndwy2HxcVj6oxMgp8qVlJAawlExHYBXqhuC6Zt2m+A7WpdkjBD6QglNbiArlteYljZHEY3R7KKmno6ISA==", + "@aws-sdk/eventstream-handler-node@3.972.36": "sha512-HFaKXA6ugPVvpR7W/3UT3hylWIPndqGMwduwNVrmGsRT5+OESHN8/ven4eeofraQF5Kxp8A8QgGVrv7iQsTRKQ==", + "@aws-sdk/middleware-eventstream@3.972.31": "sha512-4yvdTVwPGKCnrwuwlYEmwLqQXKwk0unx3f/FvBi4FA9ruK/WGoPJyqfK7hOYurheIyQL44FR0ovgOHqvkfdpAQ==", + "@aws-sdk/middleware-websocket@3.972.56": "sha512-32d9EnnNM5UMwjVp/qnL0K4XYiZv+YlSbULI3BaoBdJvebAS6cD0bKfPJ7lzovN0qrelvmSh5jo/3tk0KoTuGQ==", + "@aws-sdk/nested-clients@3.997.48": "sha512-B01jdXJtAW/XOiUzGdI2mz67WyEHbGEc9X6Euzw3bjLAa+txTqdZDER4X2FsIEcXzV2ABqudVZ5thali8tCCzQ==", + "@aws-sdk/signature-v4-multi-region@3.996.48": "sha512-cFDXfEbXaGwbnpvR/DyqfxfjxpjXVRVthQLgkwvcfDS5u3+8G9WSj1X1Ag/pTRg/QOeOno1Tq3ycEdrKsCUcOA==", + "@aws-sdk/token-providers@3.1150.0": "sha512-wDU36ofhabZYu+d2KSriyDbwzxNoaOp+4dG0p3qkabk7HeALR5nIWyQbjoyJZLn3iqWkv/xNdyUfbfevDVDzzg==", + "@babel/runtime@7.29.10": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "@hono/node-server@2.1.5": "sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==", + "@modelcontextprotocol/sdk@1.32.1": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", + "@napi-rs/canvas-linux-x64-gnu@1.0.11": "sha512-iDapw7nFSE5tsmQxcH2+KotJhyfVPmHd6vUOQN/Em+8WNbkZJwJ/rDowlJMFnMl0SYSDSfgKCNUGi6Sn5++Smg==", + "@napi-rs/canvas-linux-x64-musl@1.0.11": "sha512-26q26AyITo3CGZX4HWFIMgBMA1lu3YN07gxzBm4tW6Hglq/OVyTP50kDrmWuKybCX6dh3m8Ly+c7YhI+sqCgCQ==", + "@napi-rs/canvas@1.0.11": "sha512-vt5tog1cr+QKadIddGe+3LrcX0EmjEd0izF717Uwf92mBmgYfq2XRmL2r4bZU40spB69exxhrBn9I91HYEr/7w==", + "@peculiar/asn1-android@2.11.0": "sha512-llW/qjavL+tOxt5i9whUv4wsbsLpUwI6k/DrvKsdA/H7A/TB/6CosMFgvei28fcJ8ncD0c5lig1oBBlo4KGMCw==", + "@peculiar/asn1-cms@2.11.0": "sha512-SlAiDoT/Cd8qaNmbTt78gqyaEvu2fQd6ML6KWJkdjXGrgDKnHHrusVkfQUelkfinL2p665IIADb0JBixtT8W1g==", + "@peculiar/asn1-csr@2.11.0": "sha512-M/ED0yJ05qMK+UADPKnIa8vhAeewOJALSxTAZB7RQ/batRHhIJZkwznCcWAbFO5saHJ1yEQ+m4lncEcG9RnQSg==", + "@peculiar/asn1-ecc@2.11.0": "sha512-jj1alGEKev7/un+cI+exyVK9m31lmP7cCBMCZuCb0wkFQH2zmKmJzNiG4wqXFNzlcP8timHLnzf3lV3OxBpb/Q==", + "@peculiar/asn1-pfx@2.11.0": "sha512-ykSAWRu8KkmzU8LRpk9ZQC3zuLmBEtoSMjIv9XHVamD5W7amqreV5wttKqnvtjWOzzSJQxA/HVT8/tHwpEEGSQ==", + "@peculiar/asn1-pkcs8@2.11.0": "sha512-lragjkjQGnQ5luxC+sxoAfAO/jBiQ7hOEGlDKJUk7MC5zY3AzjB2DU0GyPUH1Sswdv10t7DurY0zylOYMLb88w==", + "@peculiar/asn1-pkcs9@2.11.0": "sha512-gDdFjpqV0weJjgNUvueUVf4D8uVbpq+VbG0TsxLAY2QGljAkJyL9qwQ7avol9j0jaYSJ7Ey7v7RZp0M3GTcQSA==", + "@peculiar/asn1-rsa@2.11.0": "sha512-S2sBJmbfI09Kzdt9cEhE6pDNjHpVTkURs+811+PxS8AvsTLxC4woMNRhxHYaIbClkkNfAVt0tZxJdINVfUmzuA==", + "@peculiar/asn1-schema@2.11.0": "sha512-8xh4avo53/vXO6UEv9yaoJ83hY581geaLTygf61NPIXP5AeIZCPjJhpTaYU23IZkG+NpLN/C9FYzWp7hphIT9A==", + "@peculiar/asn1-x509-attr@2.11.0": "sha512-Fgvnd9REmLxEj/4cbDP54dNUNUXkzUksXLqEHqs1mHBuSRjSTCUSPM/WQIZYSvrdG7ND9CX1AQtPoIBekhgQSA==", + "@peculiar/asn1-x509@2.11.0": "sha512-J1sHAwGNlk+8J+FeR1eLVc6Gq0T9f9ITc3HWyPYgJraoW6U8AJuQiWVJ3SCEpltfTdpOY7fHdHT5oKAsbK/jww==", + "@puppeteer/browsers@3.2.5": "sha512-UoCo3Pynbm37aLEEoydSiG+t24ui7sLD40+07CKxzxNy4PpSowJMkvlLIiV67XjP1lMVynhQVnuwlTABqBH8LQ==", + "@smithy/core@3.36.0": "sha512-3hJiwG1F5t1SoXqp/yOCmYXziyx7ZVqF63S5W6YU201e/qwVaHPex+E/3vvyY9a3ORq/b+BydeNZpP2saQ9X2g==", + "@types/bun@1.4.3": "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw==", + "@types/d3-zoom@3.0.9": "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==", + "@types/node@26.6.5": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "@types/prop-types@15.7.15": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "@types/react-dom@18.3.7": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "@types/react@18.3.31": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "asn1js@3.1.0": "sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA==", + "bun-types@1.4.3": "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg==", + "chromium-bidi@157.0.8090-0": "sha512-KJw7IQgqXwxpJgJlEIPg5mr2ZoqA+jkTRtc5EABI7sUJ3bu3cpI7NuEBoj4Gn1AsF78CaT9So5WQWspK3/wYag==", + "content-disposition@2.0.1": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "cytoscape@3.34.4": "sha512-msY99tMY8iWTg+sIPGfSqDcPNOt4xcj+v/K/7qUFaCUxxlqnMkUYLBjRUzn9W5CU/R2hrmtJa5+UtovrEM2ctg==", + "devtools-protocol@0.0.1696802": "sha512-FO5/SENfvc2K8wRnWQflKcwuoTH4dBkXCWTQ9ebIWtuX8JM5+ZvCotaEmkfh21pO7TvIKGTM3hG4xFiq9WTwmg==", + "dompurify@3.4.17": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", + "express-rate-limit@8.7.1": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==", + "express@5.3.0": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "get-east-asian-width@1.7.0": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "hono@4.13.13": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", + "ignore@7.0.12": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "ip-address@10.7.4": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", + "loose-envify@1.4.0": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "marked@18.1.1": "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg==", + "mdast-util-find-and-replace@3.0.3": "sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw==", + "mdast-util-from-markdown@2.1.0": "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==", + "mdast-util-to-markdown@2.2.0": "sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==", + "modern-tar@0.8.6": "sha512-LYSks0tNVqbtez/9OB0YFP6qLRSqkdAxJWOQpVaR67lNTbj2zNLyYpowkRMia5lXLCZKzxwoxkxH2MbbBR2aEg==", + "package-manager-detector@1.9.0": "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==", + "pdfjs-dist@6.4.299": "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==", + "puppeteer-core@25.14.0": "sha512-LQitnes3tBeVbdZ7V/4iwJPLZGgkl9uBVK0W7YYMh68j/3Y9eNuvj4yl+Fdo7zN0SuajOMrhd7aPj3DifK8k6A==", + "qs@6.16.1": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "react-dom@18.3.1": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "react-dom@19.3.1": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "react@18.3.1": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "react@19.3.1": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "scheduler@0.23.2": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "typed-query-selector@2.12.3": "sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg==", + "undici@7.30.1": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", + "uuid@14.0.3": "sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==", + "webdriver-bidi-protocol@1.0.1": "sha512-WXWs/aXF2a6Al1Z2/V/7Gp2oPZOAAmHjJOTmv4KCXRJ122IMj3tSSMc6a4q3K4QOmhQbse/Ahr+oTVJOU2dUiw==", + "yaml@2.9.1": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "zod@4.6.7": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==" +}; type Catalog = { version: 1; purpose: "offline-packed-consumer-cache"; packages: {name: string; version: string; integrity: string; tarball: string}[] }; export function offlinePackInstallArgs(argv: readonly string[], bun: string, cwd: string, packages: readonly string[]): string[] | undefined { if (argv[0] !== bun || argv[1] !== "add") return; - const main = cwd === "/repo/tmp/pack-work/brainkit-consumer", react18 = cwd === "/repo/tmp/pack-work/brainkit-react18-consumer"; + const main = cwd === "/consumer-work/pack-work/brainkit-consumer", react18 = cwd === "/consumer-work/pack-work/brainkit-react18-consumer"; if (!main && !react18) throw new Error("Unexpected packed installer location"); const specs = argv.slice(2), tarballs = specs.filter(value => value.endsWith(".tgz")); for (const path of tarballs) { - assert(/^\/repo\/tmp\/pack-work\/brainkit-tarballs\/[a-z0-9-]+\.tgz$/.test(path), "CI_BUILD_PACK_TARBALL_LOCATION"); + assert(/^\/consumer-work\/pack-work\/brainkit-tarballs\/[a-z0-9-]+\.tgz$/.test(path), "CI_BUILD_PACK_TARBALL_LOCATION"); assert(packages.includes(path.split("/").at(-1)!.slice(0, -4)), "CI_BUILD_PACK_TARBALL_PACKAGE"); } const other = specs.filter(value => !value.endsWith(".tgz")); const allowed = main ? [["react", "react-dom"], ["-d", "@types/bun", "@types/react", "@types/react-dom"]] : [["react@^18", "react-dom@^18"], ["-d", "@types/react@^18", "@types/react-dom@^18"]]; assert(allowed.some(expected => identity(expected) === identity(other)), "CI_BUILD_ONLY_KNOWN_PACK_INSTALLERS"); if (other[0] === "-d") assert.equal(tarballs.length, 0); else assert(tarballs.length > 0); - return [bun, "add", "--offline", "--ignore-scripts", "--backend=copyfile", "--cache-dir=/repo/tmp/consumer-cache", ...specs]; + return [bun, "add", "--offline", "--ignore-scripts", "--backend=copyfile", "--cache-dir=/consumer-work/cache", ...specs]; } export function prepareBuildConsumers(root: string, directory: string) { const supplied = join(root, "tmp/ci-build-consumer-cache"); @@ -50,6 +127,58 @@ export function prepareBuildConsumers(root: string, directory: string) { writeFileSync(join(destination, "catalog.json"), JSON.stringify(catalog), { flag: "wx", mode: 0o600 }); return { source: destination, identity: identity({ records, catalog }), catalog }; } +/** Data-only archive structure; the raw tarball SRI is checked separately. */ +export function consumerArchiveRoot(names: readonly string[]): string { + assert(names.length > 0 && names.every(name => !/[\x00-\x1f\x7f\\]/.test(name) && !posix.isAbsolute(name) && !name.split("/").includes("..")), "CI_BUILD_PACK_ARCHIVE_PATHS"); + // npm's fixed verified artifacts include harmless ./ header components. + // They remain beneath one root; symbolic/hard aliases are refused separately. + const canonical = names.map(name => posix.normalize(name).replace(/\/$/, "")); + const roots = new Set(canonical.map(name => name.split("/")[0])); + assert.equal(roots.size, 1, "CI_BUILD_PACK_SINGLE_ARCHIVE_ROOT"); + const root = [...roots][0]!; assert(root && root !== "." && canonical.includes(`${root}/package.json`), "CI_BUILD_PACK_ROOT_MANIFEST"); + return root; +} +export function checkConsumerArchiveIdentity(text: string, name: string, version: string): void { + const manifest = JSON.parse(text); + assert(manifest.name === name && manifest.version === version, "CI_BUILD_PACK_ARCHIVE_IDENTITY_MATCHES_CATALOG"); +} +/** Fixed consumer archive inspection; no extraction or caller-selected executor. */ +export async function inspectConsumerArchive(bytes: Uint8Array, name: string, version: string): Promise> { + // Measured catalog maxima: 285,794,117 expanded bytes, 285,793,528 entry + // bytes and 4,255 entries. Keep explicit headroom without unbounded payloads. + assert(bytes.byteLength <= 256 * 1024 * 1024, "CI_BUILD_PACK_COMPRESSED_ARCHIVE_BOUND"); + const { createTarDecoder } = await import("modern-tar"); + const stream = new Blob([Uint8Array.from(bytes)]).stream().pipeThrough(new DecompressionStream("gzip")).pipeThrough(createTarDecoder({ strict: true })); + const entries = new Map(), names: string[] = []; + let count = 0, expanded = 0, manifest: string | undefined; + for await (const entry of stream) { + assert(++count <= 8192, "CI_BUILD_PACK_ARCHIVE_ENTRY_COUNT_BOUND"); + const header = entry.header; + assert(header.type === "file" || header.type === "directory", "CI_BUILD_PACK_ARCHIVE_REGULAR_ONLY"); + assert(typeof header.mode === "number" && Number.isInteger(header.mode) && header.mode >= 0 && header.mode <= 0o177777 && ((header.mode & 0o170000) === 0 || (header.mode & 0o170000) === (header.type === "file" ? 0o100000 : 0o040000)), "CI_BUILD_PACK_ARCHIVE_MODE"); + assert(header.size <= 320 * 1024 * 1024 && header.size >= 0 && (header.type !== "directory" || header.size === 0), "CI_BUILD_PACK_ARCHIVE_ENTRY_BYTES_BOUND"); + names.push(header.name); + assert(!/[\x00-\x1f\x7f\\]/.test(header.name) && !posix.isAbsolute(header.name) && !header.name.split("/").includes(".."), "CI_BUILD_PACK_ARCHIVE_PATHS"); + const path = posix.normalize(header.name).replace(/\/$/, ""), rootManifest = path.split("/").length === 2 && path.endsWith("/package.json"); + const hash = createHash("sha256"), chunks: Uint8Array[] = []; let size = 0; + for await (const chunk of entry.body) { + size += chunk.byteLength; expanded += chunk.byteLength; + assert(size <= header.size && expanded <= 384 * 1024 * 1024, "CI_BUILD_PACK_ARCHIVE_EXPANDED_BYTES_BOUND"); + hash.update(chunk); if (rootManifest) { assert(size <= 2 * 1024 * 1024, "CI_BUILD_PACK_ROOT_MANIFEST_BOUND"); chunks.push(chunk); } + } + assert.equal(size, header.size, "CI_BUILD_PACK_ARCHIVE_EXACT_ENTRY_SIZE"); + const actual = {type: header.type, mode: header.mode, size, sha256: hash.digest("hex")}, prior = entries.get(path); + if (prior) assert.deepEqual(actual, prior, "CI_BUILD_PACK_CANONICAL_DUPLICATE_IDENTICAL"); else entries.set(path, actual); + if (rootManifest) manifest = Buffer.concat(chunks).toString("utf8"); + } + const root = consumerArchiveRoot(names); assert(manifest !== undefined, "CI_BUILD_PACK_ROOT_MANIFEST"); checkConsumerArchiveIdentity(manifest, name, version); + const files = new Map(); + for (const [path, entry] of [...entries].sort(([a],[b])=>a.localeCompare(b))) { + assert(path.startsWith(`${root}/`) || path === root && entry.type === "directory", "CI_BUILD_PACK_ENTRY_BENEATH_ROOT"); + if (entry.type === "file") files.set(path.slice(root.length + 1), entry.sha256); + } + return files; +} /** Necessary physical-payload verification for the supported hoisted layout. * Isolated hidden stores, package aliases, payload links and unknown sidecars * are explicitly unsupported. This does not flatten or rewrite dependencies. @@ -118,56 +247,53 @@ export async function runBuildConsumers(packages: string[]) { const { ciPack } = await import("./ci-pack"); const catalog = JSON.parse(buildRead("/consumer-input/catalog.json", undefined, 4 * 1024 * 1024).bytes.toString("utf8")) as Catalog; // Consumer-only bulk/cache/extraction bytes use the owned disk-backed - // diagnostics mount. The production /tmp tmpfs and bounds stay unchanged. - cpSync("/consumer-input/cache", "/repo/tmp/consumer-cache", { recursive: true, dereference: false }); - mkdirSync("/repo/tmp/consumer-temp", { mode: 0o700 }); - process.env.TMPDIR = "/repo/tmp/consumer-temp"; - const work = "/repo/tmp/pack-work"; mkdirSync(work, { mode: 0o700 }); + // diagnostics child bound outside /repo, so imports cannot climb into the + // captured installation. Production tmpfs and bounds stay unchanged. + cpSync("/consumer-input/cache", "/consumer-work/cache", { recursive: true, dereference: false }); + mkdirSync("/consumer-work/temp", { mode: 0o700 }); + process.env.TMPDIR = "/consumer-work/temp"; + const work = "/consumer-work/pack-work"; mkdirSync(work, { mode: 0o700 }); + // Bun's authoritative pack/pin probes temporarily rewrite package manifests + // and node_modules/.pin-probe.tgz. Give those probes a physical private + // copy, while keeping /repo immutable and consumer ancestry separate. + const repository = "/consumer-work/repository"; mkdirSync(repository, { mode: 0o700 }); + for (const name of readdirSync("/repo")) if (name !== "tmp") cpSync(join("/repo", name), join(repository, name), { recursive: true, dereference: false }); const transport = Bun as unknown as { spawn: (...args: any[]) => any }, original = transport.spawn; - const installers: {original: string[]; actual: string[]; cwd: string; code?: number}[] = []; + const installers: {original: string[]; actual: string[]; cwd: string; nativeBinlink: "disabled"; code?: number}[] = []; const verifiedArchives = new Map>(); const { listPublishablePackages } = await import("./publishable-packages"); const firstParty = listPublishablePackages("/repo"); - const archiveFiles = (archive: string) => { - if (verifiedArchives.has(archive)) return verifiedArchives.get(archive)!; - const run = (args: string[]) => { const result = spawnSync("/usr/bin/tar", args, { timeout: 30_000, maxBuffer: 16 * 1024 * 1024, killSignal: "SIGKILL" }); assert(!result.error && result.status === 0, "CI_BUILD_PACK_ARCHIVE_READ"); return result.stdout.toString(); }; - const names = run(["-tzf", archive]).trim().split("\n"); - assert(names.every(name => (name === "package" || name.startsWith("package/")) && !name.includes("\\") && posix.normalize(name).replace(/\/$/, "") === name.replace(/\/$/, "") && !name.split("/").includes("..")), "CI_BUILD_PACK_ARCHIVE_PATHS"); - assert(run(["-tvzf", archive]).trim().split("\n").every(line => /^[d-]/.test(line)), "CI_BUILD_PACK_ARCHIVE_REGULAR_ONLY"); - const extracted = `/repo/tmp/verified-archives/${identity(archive)}`; mkdirSync(extracted, { recursive: true, mode: 0o700 }); - run(["-xzf", archive, "-C", extracted, "--no-same-owner", "--no-same-permissions"]); - const files = new Map(); - const walk = (directory: string, path = "") => { - for (const name of readdirSync(directory).sort()) { - const file = join(directory, name), relative = path ? `${path}/${name}` : name, stat = lstatSync(file); - if (stat.isDirectory()) walk(file, relative); - else { assert(stat.isFile(), "CI_BUILD_PACK_PAYLOAD_REGULAR_ONLY"); files.set(relative, createHash("sha256").update(buildRead(file).bytes).digest("hex")); } - } - }; - walk(join(extracted, "package")); verifiedArchives.set(archive, files); return files; + const archiveFiles = async (archive: string, name: string, version: string) => { + const cacheKey = `${archive}\0${name}@${version}`; + if (verifiedArchives.has(cacheKey)) return verifiedArchives.get(cacheKey)!; + const files = await inspectConsumerArchive(buildRead(archive, undefined, 256 * 1024 * 1024).bytes, name, version); + verifiedArchives.set(cacheKey, files); return files; }; const actualPayloads: {name: string; version: string; identity: string; directory: string}[] = []; - const verifyInstalled = (cwd: string) => { + const verifyInstalled = async (cwd: string) => { // Every physical installed package is checked before ciPack can import. // Unknown hidden stores/aliases are unsupported, never version-only proof. const archives = new Map>(); - for (const pkg of firstParty) archives.set(`${pkg.manifest.name}@${pkg.manifest.version}`, archiveFiles(join(work, "brainkit-tarballs", `${pkg.dir}.tgz`))); - for (const pkg of catalog.packages) archives.set(`${pkg.name}@${pkg.version}`, archiveFiles(`/consumer-input/${pkg.tarball}`)); + for (const pkg of firstParty) archives.set(`${pkg.manifest.name}@${pkg.manifest.version}`, await archiveFiles(join(work, "brainkit-tarballs", `${pkg.dir}.tgz`), pkg.manifest.name, pkg.manifest.version)); + for (const pkg of catalog.packages) archives.set(`${pkg.name}@${pkg.version}`, await archiveFiles(`/consumer-input/${pkg.tarball}`, pkg.name, pkg.version)); actualPayloads.push(...verifyConsumerTree(join(cwd, "node_modules"), archives)); }; transport.spawn = (...args: any[]) => { if (!Array.isArray(args[0])) return original(...args); const actual = offlinePackInstallArgs(args[0], process.execPath, args[1]?.cwd ?? "", packages); if (!actual) return original(...args); - const observation = { original: [...args[0]], actual, cwd: args[1].cwd, code: undefined as number | undefined }; installers.push(observation); - const child = original(actual, args[1]); + const observation = { original: [...args[0]], actual, cwd: args[1].cwd, nativeBinlink: "disabled" as const, code: undefined as number | undefined }; installers.push(observation); + // The exact pinned Bun otherwise redirects esbuild's .bin alias into an + // optional native package without its own bin declaration. Keep the same + // graph/payloads and the declared wrapper so the strict alias guard holds. + const child = original(actual, { ...args[1], env: { ...args[1].env, BUN_FEATURE_FLAG_DISABLE_NATIVE_DEPENDENCY_LINKER: "1" } }); const exited = child.exited.then(async (code: number) => { observation.code = code; if (code === 0) await verifyInstalled(args[1].cwd); return code; }); return new Proxy(child, { get(target, key) { if (key === "exited") return exited; const value = Reflect.get(target, key, target); return typeof value === "function" ? value.bind(target) : value; } }); }; try { const node = original(["/usr/bin/node", "-p", "process.versions.node"], { stdout: "pipe", stderr: "pipe" }); const [version, code] = await Promise.all([new Response(node.stdout).text(), node.exited]); assert.equal(code, 0); assert(Bun.semver.satisfies(version.trim(), ">=24.0.0"), "CI_BUILD_PACK_NODE_MATCHES_CI"); - await ciPack("/repo", work); + await ciPack(repository, work); assert.equal(installers.length, 4, "CI_BUILD_ALL_PACK_INSTALLERS_OFFLINE"); assert(installers.every(child => child.code === 0)); return { version: 1, purpose: "qualification-consumers", authority: "none", completed: true, packages, node: version.trim(), installers, actualPayloads, registryAcquisition: "disabled-by-offline-native-installer-and-network-namespace" }; } finally { transport.spawn = original; } diff --git a/scripts/ci-build-evidence.ts b/scripts/ci-build-evidence.ts index fd0457e9cc..c239e4e30b 100644 --- a/scripts/ci-build-evidence.ts +++ b/scripts/ci-build-evidence.ts @@ -37,7 +37,7 @@ export function compactBuildEvidence(workspace: string, controller: string, resu outputs[pkg] = { identity: expected, path: relative(controller, destination) }; } if (result.purpose === "qualification-consumers") for (const pkg of result.producer.consumers.packages) { - assert(/^[a-z0-9-]+$/.test(pkg)); retain(`diagnostics/pack-work/brainkit-tarballs/${pkg}.tgz`); + assert(/^[a-z0-9-]+$/.test(pkg)); retain(`diagnostics/consumer-work/pack-work/brainkit-tarballs/${pkg}.tgz`); } const manifest = { version: 1, purpose: "qualification-evidence", authority: "none", session: result.session, key: result.key, originalCapture: state, files, outputs, bun: digest(bun.bytes), removedCapture: true }; writeFileSync(join(evidence, "retained.writing.json"), JSON.stringify({ ...manifest, removedCapture: false }, null, 2), { flag: "wx", mode: 0o600 }); diff --git a/scripts/ci-build-operation.ts b/scripts/ci-build-operation.ts index 1e049c1d48..9d5c788f9e 100644 --- a/scripts/ci-build-operation.ts +++ b/scripts/ci-build-operation.ts @@ -226,7 +226,9 @@ async function execute(root: string, options: Options, policy: Qualification, qu const uid = process.getuid!(), gid = process.getgid!(); const argv = ["run", "--name", name, "--label", `brain-ci-build-session=${session}`, "--pull=never", "--read-only", "--network=none", "--cap-drop=ALL", "--security-opt=no-new-privileges", "--user", `${uid}:${gid}`, "--hostname", "brain-build", "--cpus=2", "--memory=4g", "--pids-limit=256", "--shm-size=64m", "--tmpfs", `/repo:rw,nosuid,nodev,mode=0700,uid=${uid},gid=${gid},size=64m`, "--tmpfs", "/tmp:rw,exec,nosuid,nodev,mode=1777,size=256m"]; - const diagnosticMounts = proof?.trace ? [{ source: proof.trace.source, target: "/trace", readonly: true }] : proof?.consumers ? [{ source: proof.consumers.source, target: "/consumer-input", readonly: true }] : []; + const consumerWork = join(state, "diagnostics/consumer-work"); + if (proof?.consumers) mkdirSync(consumerWork, { mode: 0o700 }); + const diagnosticMounts = proof?.trace ? [{ source: proof.trace.source, target: "/trace", readonly: true }] : proof?.consumers ? [{ source: proof.consumers.source, target: "/consumer-input", readonly: true }, { source: consumerWork, target: "/consumer-work", readonly: false }] : []; const allMounts = [...mounts, ...diagnosticMounts, { source: tool, target: "/tool", readonly: true }, { source: entryPath, target: "/operation/entry.json", readonly: true }, ...["hosts", "hostname", "resolv.conf"].map(file => ({ source: join(hostFiles, file), target: `/etc/${file}`, readonly: true }))]; writeFileSync(entryPath, JSON.stringify({ version: 1, mode: proof?.consumers ? "consumers" : "build", uid, gid, originalRoot: root, canary, selected: pending, jobs: options.jobs, topology, rootNames, node: policy.node.path, @@ -348,7 +350,9 @@ async function qualifyPrivateBuild(root: string): Promise { const code = await execute(workspace, options, policy, true, proof); if (code === undefined) { const args = [process.execPath, join(workspace, "scripts/build.ts"), `--jobs=${options.jobs}`, ...(options.force ? ["--force"] : []), ...(options.directories ? ["--for", options.directories.join(",")] : [])]; - const child = Bun.spawn(args, { cwd: workspace, stdin: "ignore", stdout: "inherit", stderr: "inherit" }); + // Bun's default spawn environment retains startup values. Explicitly + // forward the private lease/PATH established by this controller. + const child = Bun.spawn(args, { cwd: workspace, stdin: "ignore", stdout: "inherit", stderr: "inherit", env: { ...process.env } }); const fallback = await child.exited; if (fallback !== 0) throw new Error(`Private unsupported-context ordinary build failed (${fallback})`); const result = { label, purpose: "qualification", authority: "none", state: "unsupported-ordinary-rebuild", elapsedMs: performance.now() - start }; @@ -371,6 +375,11 @@ async function qualifyPrivateBuild(root: string): Promise { const outputs = Object.fromEntries(source.packages.map(pkg => [pkg, identity(outputFiles(join(workspace, "packages", pkg, "dist")))])); const repeat = await run("unchanged"); assert.equal(repeat.compiled.length, 0, "CI_BUILD_UNCHANGED_HAS_NO_COMPILER_CHILDREN"); assert.equal(repeat.reused.length, 19); for (const pkg of source.packages) assert.equal(identity(outputFiles(join(workspace, "packages", pkg, "dist"))), outputs[pkg], "CI_BUILD_REPEAT_PRESERVES_OUTPUT"); + // Check expensive consumer compatibility before the later causal controls, + // so a real packing failure does not repeat every independent build arm. + proof.consumers = consumerInputs; + try { await run("packed-consumers"); } finally { delete proof.consumers; } + const consumers = proof.results.at(-1); const dist = join(workspace, "packages/common/dist"), entry = outputFiles(dist).find(file => file.kind === "file")!; assert(entry); const file = join(dist, entry.path); const changes = [ @@ -436,9 +445,6 @@ async function qualifyPrivateBuild(root: string): Promise { try { await run("diagnostic-trace", { jobs: 1, force: true }); } finally { delete proof.trace; } const traced = proof.results.at(-1), trace = classifyBuildTrace(buildRead(join(traced.artifactDirectory, "diagnostics/qualification-syscalls.log"), undefined, 256 * 1024 * 1024).bytes.toString("utf8")); for (const pkg of source.packages) assert.equal(traced.outputs[pkg], outputs[pkg], "CI_BUILD_TRACE_ARM_PRESERVES_OUTPUT"); - proof.consumers = consumerInputs; - try { await run("packed-consumers"); } finally { delete proof.consumers; } - const consumers = proof.results.at(-1); writeFileSync(resultPath, JSON.stringify({ version: 1, purpose: "qualification", authority: "none", domain, workspace, results: proof.results, trace, consumers, remainingQualification: ["finite-ambient-source-audit-and-counterfactuals", "actual-owner-interruption", "browser-marker-and-default-export-consumers", "comparable-hosted-cost"], productionActivation: "unqualified" }, null, 2), { flag: "wx", mode: 0o600 }); console.log(`Private qualification evidence retained at ${directory}; production reuse remains unqualified.`); return 0; diff --git a/tests/ci-build-consumers.test.ts b/tests/ci-build-consumers.test.ts index cce84544ad..5b2dcf8f12 100644 --- a/tests/ci-build-consumers.test.ts +++ b/tests/ci-build-consumers.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node import { tmpdir } from "node:os"; import { join } from "node:path"; import { digest } from "../scripts/ci-build-boundary"; -import { verifyConsumerTree } from "../scripts/ci-build-consumers"; +import { verifyConsumerTree, consumerArchiveRoot, checkConsumerArchiveIdentity, inspectConsumerArchive } from "../scripts/ci-build-consumers"; const roots: string[] = []; afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); function fixture() { @@ -56,3 +56,28 @@ test("package aliases and undeclared bin aliases cannot substitute unverified ex rmSync(join(f.modules,"odysseus-alias"));mkdirSync(join(f.modules,".bin"));symlinkSync("../odysseus-ledger/index.js",join(f.modules,".bin/cargo")); expect(()=>verifyConsumerTree(f.modules,f.archives)).toThrow("CI_BUILD_PACK_BIN_TARGET_IS_VERIFIED_DECLARED_PAYLOAD"); }); + +test("unreviewed physical dependency refuses verification before any consumer import",()=>{ + const f=fixture();f.pkg(join(f.modules,"odysseus-ledger"),"odysseus-ledger");f.pkg(join(f.modules,"odysseus-unknown"),"odysseus-unknown");f.archives.delete("odysseus-unknown@1.0.0"); + expect(()=>verifyConsumerTree(f.modules,f.archives),"CI_BUILD_UNKNOWN_DEPENDENCY_REFUSED_BEFORE_IMPORT").toThrow("CI_BUILD_PACK_DEPENDENCY_IS_REVIEWED"); +}); + +test("verified type archives retain their single canonical root and exact package identity",()=>{ + const names=["react v18.3/","react v18.3/package.json","react v18.3/.hidden","react v18.3/index.d.ts"]; + expect(consumerArchiveRoot(names)).toBe("react v18.3"); + expect(consumerArchiveRoot([...names,"react v18.3/./index.d.ts"])).toBe("react v18.3"); + expect(()=>checkConsumerArchiveIdentity(JSON.stringify({name:"@types/react",version:"18.3.31"}),"@types/react","18.3.31")).not.toThrow(); + expect(()=>consumerArchiveRoot([...names,"outside/package.json"]),"CI_BUILD_MIXED_ARCHIVE_ROOT_REFUSED").toThrow("CI_BUILD_PACK_SINGLE_ARCHIVE_ROOT"); + for(const path of ["/outside/package.json","react v18.3/../outside.ts"])expect(()=>consumerArchiveRoot([...names,path])).toThrow("CI_BUILD_PACK_ARCHIVE_PATHS"); + expect(()=>checkConsumerArchiveIdentity(JSON.stringify({name:"@types/react",version:"18.3.31"}),"@types/react","18.3.0"),"CI_BUILD_WRONG_ARCHIVE_IDENTITY_REFUSED").toThrow("CI_BUILD_PACK_ARCHIVE_IDENTITY_MATCHES_CATALOG"); +}); + +test("canonical duplicate archive entries require identical bytes and modes before imports",async()=>{ + const {packTar}=await import("modern-tar"); + const metadata=JSON.stringify({name:"odysseus-ledger",version:"1.0.0"}),base=[{header:{name:"package/package.json",type:"file" as const,size:Buffer.byteLength(metadata),mode:0o644},body:metadata},{header:{name:"package/dist/index.js",type:"file" as const,size:1,mode:0o644},body:"1"}]; + const archive=async(body:string,mode:number)=>Bun.gzipSync(await packTar([...base,{header:{name:"package/./dist/index.js",type:"file" as const,size:body.length,mode},body}])); + expect((await inspectConsumerArchive(await archive("1",0o644),"odysseus-ledger","1.0.0")).get("dist/index.js")).toBe(digest("1")); + await expect(inspectConsumerArchive(await archive("1",0o040644),"odysseus-ledger","1.0.0"),"CI_BUILD_TAR_MODE_TYPE_MISMATCH_REFUSED").rejects.toThrow("CI_BUILD_PACK_ARCHIVE_MODE"); + await expect(inspectConsumerArchive(await archive("2",0o644),"odysseus-ledger","1.0.0"),"CI_BUILD_DIFFERING_CANONICAL_BYTES_REFUSED").rejects.toThrow("CI_BUILD_PACK_CANONICAL_DUPLICATE_IDENTICAL"); + await expect(inspectConsumerArchive(await archive("1",0o755),"odysseus-ledger","1.0.0"),"CI_BUILD_DIFFERING_CANONICAL_MODE_REFUSED").rejects.toThrow("CI_BUILD_PACK_CANONICAL_DUPLICATE_IDENTICAL"); +}); diff --git a/tests/ci-build-qualification.test.ts b/tests/ci-build-qualification.test.ts index b82cfdd120..b1118f10fa 100644 --- a/tests/ci-build-qualification.test.ts +++ b/tests/ci-build-qualification.test.ts @@ -18,9 +18,9 @@ test("actual TypeScript data references refuse ambient paths without comment/str expect(buildDataRefusals(root,records),"CI_BUILD_EFFECTIVE_REFERENCE_ESCAPE_REFUSED").toEqual([path+":external-reference"]); }); test("only the authoritative known packed installers receive native offline flags",()=>{ - const cwd="/repo/tmp/pack-work/brainkit-consumer",tar="/repo/tmp/pack-work/brainkit-tarballs/common.tgz"; + const cwd="/consumer-work/pack-work/brainkit-consumer",tar="/consumer-work/pack-work/brainkit-tarballs/common.tgz"; const actual=offlinePackInstallArgs(["/tool/bun","add",tar,"react","react-dom"],"/tool/bun",cwd,["common"]); - expect(actual).toEqual(["/tool/bun","add","--offline","--ignore-scripts","--backend=copyfile","--cache-dir=/repo/tmp/consumer-cache",tar,"react","react-dom"]); + expect(actual).toEqual(["/tool/bun","add","--offline","--ignore-scripts","--backend=copyfile","--cache-dir=/consumer-work/cache",tar,"react","react-dom"]); expect(()=>offlinePackInstallArgs(["/tool/bun","add","odysseus-unreviewed-package"],"/tool/bun",cwd,["common"]),"CI_BUILD_UNKNOWN_INSTALLER_HAS_NO_ONLINE_FALLBACK").toThrow(); expect(offlinePackInstallArgs(["/tool/bun","pm","pack"],"/tool/bun",cwd,["common"])).toBeUndefined(); }); From 2c5907d88e072dcc7750930b3b78407a2325ffa8 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Tue, 20 Oct 2026 00:36:55 +0200 Subject: [PATCH 13/15] fix(ci): bind reuse lookup to the actual producer context --- docs/decisions/isolated-ci-build.md | 10 +++++ scripts/ci-build-consumers.ts | 18 ++++++++ scripts/ci-build-context.ts | 65 +++++++++++++++++++++++++++++ scripts/ci-build-entry.ts | 4 +- scripts/ci-build-evidence.ts | 2 +- scripts/ci-build-lifetime.ts | 2 +- scripts/ci-build-operation.ts | 20 ++++++--- scripts/ci-build-state.ts | 2 +- tests/build-concurrency.test.ts | 4 +- tests/build-freshness.test.ts | 2 +- tests/ci-build-context.test.ts | 37 ++++++++++++++++ tests/ci-build-evidence.test.ts | 3 +- tests/workspace-lease.test.ts | 2 +- 13 files changed, 157 insertions(+), 14 deletions(-) create mode 100644 scripts/ci-build-context.ts create mode 100644 tests/ci-build-context.test.ts diff --git a/docs/decisions/isolated-ci-build.md b/docs/decisions/isolated-ci-build.md index af413716a8..bfd2a62ab4 100644 --- a/docs/decisions/isolated-ci-build.md +++ b/docs/decisions/isolated-ci-build.md @@ -36,6 +36,16 @@ successful output sample. Workspace leases continue coordinating cooperating writers. They do not restrict compiler reads. A private container operation has its own explicit input boundary; the diagram export operation grants it no inherited authority. +Every eligible lookup observes the fixed daemon through a fresh, bounded, +session-owned context container. Its actual boot, kernel release/version, CPU +affinity and Node bytes join the lookup identity with the daemon identity. The +compiling container independently reports its context; a mismatch refuses +publication. A repeat still starts a context reader, even when it starts no +compiler or stylesheet children. Unavailable context refuses reuse; unconfirmed +context-container cleanup is a failure. Caller host boot data and socket inode +equality cannot substitute for this observation. These are necessary context +bindings, not proof of clock, entropy, hardware or other ambient independence. + Normal and isolated builds share the concrete compiler/copy/bin scheduler, without an extension or plugin interface. The isolated entry receives only validated manifest-selected pending packages. No public CLI or environment diff --git a/scripts/ci-build-consumers.ts b/scripts/ci-build-consumers.ts index 297a36388a..4e9c238612 100644 --- a/scripts/ci-build-consumers.ts +++ b/scripts/ci-build-consumers.ts @@ -9,6 +9,24 @@ import { captureOutputs, identity } from "./ci-build-boundary"; // Exact extra consumer records from official version metadata and verified // tarballs. These qualify dependency preparation only, never build reuse. const extraConsumerIntegrities: Record = { + "@anthropic-ai/claude-agent-sdk@0.3.304": "sha512-Ivkt7/UAkATibzvqT31eY71lAp7YdPzfbKDpmbHN9+AQWFh/Ac6tYkL2J1sipJ7tU4IK48w0YxuRW9aI//veFA==", + "@anthropic-ai/claude-agent-sdk-linux-x64@0.3.304": "sha512-72SZA+TYtrDCXM7j8GNaoUpZrtysxw8FiunRv6C59wJRkhpxzlYLGrjser7vX6ivK+LiBkVU/V3wzEyfJqB4sA==", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.304": "sha512-cNZvVaVw5ly/sduYj+c2ekrqnpzqUBLsQapvpXhVFy6hjdwXhj8gCNmr5Wta5tlTrNEo0C2kxvfFm+GjD7Tcvg==", + "@aws-sdk/core@3.978.4": "sha512-JvGp6y9sPX8FSs2K/EmFMZcUfdkHxqGE45+oxwGC75OIh50j2e4cbx7zcR3BwNs2DSGqJdBja5h6Vy89iIDmRQ==", + "@aws-sdk/credential-provider-env@3.972.75": "sha512-nc8Epg98PcYoKNx0VFlwtmZSCP+tuJ1FLWqpslET/gp4h/WYuULSJqtxppM6ywKKDdzNJPvLD7ceRfmxm3Qdxw==", + "@aws-sdk/credential-provider-http@3.972.77": "sha512-KkYFdIQwuw9IOHfEzK78slxmVWuhcm1w6YgE3V3WVxAeRRj9q5J9jlwzpY/oszWxd4SD96FTFkDdRGBdMFF2Mw==", + "@aws-sdk/credential-provider-ini@3.973.20": "sha512-OSl2R/0zLkoeKEIy6oP2tu/bsSdY+DGgro2eOffMD2CcP8POv7rS14FY5Jzc5bjnqB1G9wv/aM1akHC2mdlhZw==", + "@aws-sdk/credential-provider-login@3.972.82": "sha512-8jyDPtf1WPxeQ/5xlNpyxAD6P7YKwkYeNk8B2vFEDwkPWDvb7c3tQJG5/jbNWbF2yTMIH9tDTueBkdfovYIAPw==", + "@aws-sdk/credential-provider-node@3.972.87": "sha512-TKj2C0x4IMPSSOAlmeryX8fCPvvPS2pFJz2ikV9IGqyh6Ehsg4leMalCTVfV2rvMTrlGAe7X7nK0HZ3P0KZR9g==", + "@aws-sdk/credential-provider-process@3.972.75": "sha512-ZNm8T/HJNhpS8MikDVAsym8QyUSUuOUGiCkmI3TTya7G6xhOgfRLd2iK91HhQQyvRM36eo6Xm845hHVyfToA2w==", + "@aws-sdk/credential-provider-sso@3.973.19": "sha512-qHGDAGesyWtRw3BapyFkYSUzVNrC1CWPS6+S2lf/MLMEkWB2gR3Bl6imVHy1nhImXKS+gcuUDzbu39BnsHJc1Q==", + "@aws-sdk/credential-provider-web-identity@3.972.81": "sha512-nHkgkMIC6NHgqfI6UAQq5cCS4bUkNxkT+r9fXCq/X4IKfX/DHGZ8UM3T4b78OXpWK5OlzEzN4kxwyax0W/MUCA==", + "@aws-sdk/eventstream-handler-node@3.972.37": "sha512-vU0v4d6anhLqBCpPq7Q7hD2Av3/5qOR5O30Ze8ALLysgB3iMQTPHQ/P2aTgfrY2ZjnVJUCi7NiTSNqa1mDF+5w==", + "@aws-sdk/middleware-eventstream@3.972.32": "sha512-D3ayeW/K2lTQI8+SyzDvAKfM3N4SFJjfgqeHbS3DOZmmQebZyve6WUyJiqOKqeOxmIcFxTzpSdsijnRF035J3g==", + "@aws-sdk/middleware-websocket@3.972.57": "sha512-mPNUOedufUwZXISOEySSP7l7owyY0Ypp1ajsd1GsB4E5Vpaf8NtfmmNygyPPeGPsRZ1sOcS90aAhQ03yPy/pFg==", + "@aws-sdk/nested-clients@3.997.49": "sha512-zjqIqzTYLRKhT7TPJDUreK9vG9PjhmnfLCaTaqArkRT7j+Ogl0tuXB7MpSd8rlRfRT+xTOeyNFWaOfyNcf51hg==", + "ignore@7.0.13": "sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q==", + "@aws-sdk/token-providers@3.1153.0": "sha512-6bKJ2OfLboxhzeJ3hBDeB8L6emCYf9DfdrjBAharLrY42J1RV/6jA6G8jvfNghzID8Yb40BdSY7jQMtWx9Ot0Q==", "@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.302": "sha512-UCsqTcoUmV8qGdnmw0od2grDPgrkK6kclpo5Oyox15B9glHXhOIL0nifWcw2xS9VKV0VZ11I9FujPLIXfEpXtg==", "@anthropic-ai/claude-agent-sdk-linux-x64@0.3.302": "sha512-HqD0+tnjJDldkZJPuTiMIZ1XbrVW8LH/3QKqCBgXLGwVFNgkLOM+Sfh14C0vqyZSIaLFgsFd4bIw1jg5V+td6w==", "@anthropic-ai/claude-agent-sdk@0.3.302": "sha512-VUxgraOIxnu4COhS9IkP3AF21kh9KOOF8oyU7pc6q0PfzMPlarbnhXvuhCNIPPYddhIev+17LLq1nZ700GdLBg==", diff --git a/scripts/ci-build-context.ts b/scripts/ci-build-context.ts new file mode 100644 index 0000000000..f0921cb7b7 --- /dev/null +++ b/scripts/ci-build-context.ts @@ -0,0 +1,65 @@ +/** Actual fixed-daemon context; necessary identity, never ambient qualification. */ +import { randomUUID } from "node:crypto"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { identity } from "./ci-build-boundary"; +import { dockerControl, inspectOwnedBuildContainer, removeOwnedBuildContainer } from "./ci-build-lifetime"; + +// Self-contained so the fixed image's Node executes the exact same reader used +// inside the compiling container. No caller-selected program or operand. +export function readBuildKernelContext() { + const { readFileSync } = require("node:fs") as typeof import("node:fs"); + const { createHash } = require("node:crypto") as typeof import("node:crypto"); + const hash = (path: string) => createHash("sha256").update(readFileSync(path)).digest("hex"); + const status = readFileSync("/proc/self/status", "utf8"); + const affinity = status.match(/^Cpus_allowed_list:\s*(.+)$/m)?.[1]; + if (!affinity || !/^[0-9,-]+$/.test(affinity)) throw new Error("Unavailable producer CPU affinity"); + return { affinity, boot: hash("/proc/sys/kernel/random/boot_id"), kernel: hash("/proc/sys/kernel/osrelease"), version: hash("/proc/version") }; +} +export function readBuildDaemonContext(config: string) { + const value = JSON.parse(dockerControl(config, "info", "--format", "{{json .}}")); + const fields = ["ID", "ServerVersion", "KernelVersion", "OSType", "Architecture"] as const; + if (fields.some(field => typeof value[field] !== "string" || !value[field]) || value.OSType !== "linux") throw new Error("Unavailable build daemon context"); + return Object.fromEntries(fields.map(field => [field, value[field] as string])); +} + +export class BuildContextCleanupError extends Error {} + +/** Every eligible lookup owns a real, bounded producer-context container. */ +export function observeBuildContext(config: string, image: string, state: string) { + const session = randomUUID(), name = `brain-ci-build-${session}`; + const daemon = readBuildDaemonContext(config), uid = process.getuid!(), gid = process.getgid!(); + if (!uid || !gid) throw new Error("Build context requires non-root identity"); + let attempted = false, id: string | undefined, drained = false, removed = false; + let value: { kernel: ReturnType; node: string } | undefined; + let failure: unknown, cleanupFailure: unknown; + try { + attempted = true; + dockerControl(config, "create", "--name", name, "--label", `brain-ci-build-session=${session}`, "--pull=never", "--read-only", "--network=none", "--cap-drop=ALL", "--security-opt=no-new-privileges", "--user", `${uid}:${gid}`, "--hostname", "brain-build", "--cpus=2", "--memory=4g", "--pids-limit=256", "--shm-size=64m", image, + "/usr/bin/env", "-i", "PATH=/usr/bin:/bin", "LANG=C.UTF-8", "LC_ALL=C.UTF-8", "TZ=UTC", "/usr/bin/timeout", "--signal=TERM", "--kill-after=1s", "5s", "/usr/bin/node", "-e", + `const kernel=(${readBuildKernelContext.toString()})(); const node=require('node:crypto').createHash('sha256').update(require('node:fs').readFileSync('/proc/self/exe')).digest('hex'); process.stdout.write(JSON.stringify({kernel,node}));`); + id = inspectOwnedBuildContainer(config, name, session).id; + const output = dockerControl(config, "start", "--attach", id); + const { record, running } = inspectOwnedBuildContainer(config, id, session); + if (running || record.State.ExitCode !== 0 || record.Config.Image !== image || record.Config.User !== `${uid}:${gid}` || !record.HostConfig.ReadonlyRootfs || record.HostConfig.NetworkMode !== "none" || record.HostConfig.Privileged || !record.HostConfig.CapDrop.includes("ALL") || !record.HostConfig.SecurityOpt.includes("no-new-privileges") || record.Mounts.length) throw new Error("Actual context boundary unavailable"); + value = JSON.parse(output); + if (!value || Object.keys(value).sort().join() !== "kernel,node" || !/^[0-9a-f]{64}$/.test(value.node) || !value.kernel || Object.keys(value.kernel).sort().join() !== "affinity,boot,kernel,version" || !/^[0-9,-]+$/.test(value.kernel.affinity) || [value.kernel.boot, value.kernel.kernel, value.kernel.version].some(hash => !/^[0-9a-f]{64}$/.test(hash))) throw new Error("Incomplete producer context"); + if (identity(readBuildDaemonContext(config)) !== identity(daemon)) throw new Error("Build daemon changed during context lookup"); + } catch (error) { failure = error; } + finally { + if (attempted) { + // Even an ambiguous create timeout must reconcile by this session name. + // A cleanup error propagates: it cannot be downgraded to ordinary fallback. + try { + const owned = inspectOwnedBuildContainer(config, id ?? name, session); id = owned.id; + removeOwnedBuildContainer(config, id, session); drained = true; removed = true; + } catch (error) { cleanupFailure = error; } + } + try { + writeFileSync(join(state, "context.json"), JSON.stringify({ version: 1, session, containerID: id, drained, removed, daemon, value, success: !failure && !cleanupFailure }), { flag: "wx", mode: 0o600 }); + } catch (error) { failure ??= error; } + } + if (cleanupFailure) throw new BuildContextCleanupError("Build context cleanup incomplete", { cause: cleanupFailure }); + if (failure) throw failure; + return { binding: { daemon, ...value! }, session, containerID: id!, drained, removed }; +} diff --git a/scripts/ci-build-entry.ts b/scripts/ci-build-entry.ts index 99bba43129..2fa725e696 100644 --- a/scripts/ci-build-entry.ts +++ b/scripts/ci-build-entry.ts @@ -1,3 +1,4 @@ +import { readBuildKernelContext } from "./ci-build-context"; /** The only program run by the fixed CI build container. Not a plugin host. */ import { buildRead, checkBuildPrivateDirectory } from "./ci-build-files"; import assert from "node:assert/strict"; @@ -41,6 +42,7 @@ if (import.meta.main) { for (const [pkg, names] of Object.entries(entry.topology)) { assert.deepEqual(readdirSync(`/repo/packages/${pkg}`).sort(), names, "CI_BUILD_INITIAL_PACKAGE_TOPOLOGY"); } + const kernel = readBuildKernelContext(); const runtime = { bun: digest(buildRead("/tool/bun").bytes), node: { path: entry.node, sha256: digest(buildRead(entry.node).bytes) } }; assert(mounts.some(mount => mount.target === "/tmp" && mount.type === "tmpfs" && mount.options.split(",").includes("rw")), "CI_BUILD_FRESH_PRIVATE_TMPFS"); checkBuildPrivateDirectory("/tmp/home"); checkBuildPrivateDirectory("/tmp/cache"); @@ -64,6 +66,6 @@ if (import.meta.main) { } const receipts = readdirSync("/repo/tmp").filter(name => /^build-\d+-[0-9a-f-]+-probe\.json$/.test(name)).sort() .map(name => ({ name, value: JSON.parse(buildRead(join("/repo/tmp", name), undefined, 16 * 1024 * 1024).bytes.toString("utf8")) })); - writeFileSync("/repo/tmp/isolated-producer.json", JSON.stringify({ version: 1, kind: entry.mode === "consumers" ? "packed-consumers" : "workspace-build", code, runtime, receipts, consumers, mounts, topology: "checked-after-child-exit" })); + writeFileSync("/repo/tmp/isolated-producer.json", JSON.stringify({ version: 1, kind: entry.mode === "consumers" ? "packed-consumers" : "workspace-build", code, runtime, kernel, receipts, consumers, mounts, topology: "checked-after-child-exit" })); process.exitCode = code; } diff --git a/scripts/ci-build-evidence.ts b/scripts/ci-build-evidence.ts index c239e4e30b..4e92e6522c 100644 --- a/scripts/ci-build-evidence.ts +++ b/scripts/ci-build-evidence.ts @@ -21,7 +21,7 @@ export function compactBuildEvidence(workspace: string, controller: string, resu writeFileSync(join(evidence, path), file.bytes, { flag: "wx", mode }); chmodSync(join(evidence, path), mode); files.push({ path, sha256: digest(file.bytes), mode }); }; - for (const path of ["invocation.json", "entry.json", "container-boundary.json", "cleanup.json", "diagnostics/isolated-producer.json"]) retain(path); + for (const path of ["context.json", "invocation.json", "entry.json", "container-boundary.json", "cleanup.json", "diagnostics/isolated-producer.json"]) retain(path); for (const path of buildNames(join(state, "diagnostics"))) if (/^build-[0-9]+-[0-9a-f-]+-probe\.json$/.test(path) || path === "qualification-syscalls.log") retain(`diagnostics/${path}`); const bun = buildRead(join(state, "tool/bun")); assert.equal(digest(bun.bytes), result.producer.runtime.bun); const runtime = join(controller, "runtime"); mkdirSync(runtime, { recursive: true, mode: 0o700 }); diff --git a/scripts/ci-build-lifetime.ts b/scripts/ci-build-lifetime.ts index 1312bd26e1..e5d80b6d69 100644 --- a/scripts/ci-build-lifetime.ts +++ b/scripts/ci-build-lifetime.ts @@ -8,7 +8,7 @@ export function dockerControl(config: string, ...argv: string[]): string { return result.stdout.toString("utf8").trim(); } type Container = { - Id: string; State: { Running: boolean }; Config: { Image: string; User: string; Labels: Record }; + Id: string; State: { Running: boolean; ExitCode: number }; Config: { Image: string; User: string; Labels: Record }; HostConfig: { ReadonlyRootfs: boolean; NetworkMode: string; Privileged: boolean; CapDrop: string[]; SecurityOpt: string[]; Tmpfs: Record }; Mounts: Array<{ Type: string; Source: string; Destination: string; RW: boolean }>; }; diff --git a/scripts/ci-build-operation.ts b/scripts/ci-build-operation.ts index 9d5c788f9e..e950a63708 100644 --- a/scripts/ci-build-operation.ts +++ b/scripts/ci-build-operation.ts @@ -1,3 +1,4 @@ +import { BuildContextCleanupError, observeBuildContext, readBuildDaemonContext } from "./ci-build-context"; /** One dormant, fixed isolated build operation. CI intent is not authority. */ import { dockerControl as docker, inspectOwnedBuildContainer, removeOwnedBuildContainer } from "./ci-build-lifetime"; import { buildRead } from "./ci-build-files"; @@ -49,7 +50,6 @@ function workspaceScope(root: string) { const directory = lstatSync(root), socket = lstatSync("/var/run/docker.sock"); if (!directory.isDirectory() || !socket.isSocket()) throw new Error("Unsupported fixed build ownership"); return { root: realpathSync(root), directory: { dev: directory.dev, ino: directory.ino }, uid: process.getuid!(), gid: process.getgid!(), - boot: digest(readFileSync("/proc/sys/kernel/random/boot_id")), kernel: digest(readFileSync("/proc/sys/kernel/osrelease")), dockerSocket: { dev: socket.dev, ino: socket.ino }, platform: process.platform, arch: process.arch }; } function operationReceipt(root: string, value: unknown) { @@ -180,7 +180,11 @@ async function execute(root: string, options: Options, policy: Qualification, qu return; } imagePresent(policy.image, dockerConfig); - const scope = workspaceScope(root); + const context = observeBuildContext(dockerConfig, policy.image, state); + await new Promise(resolve => setImmediate(resolve)); + if (interrupted) throw new Error("Build interrupted during context lookup"); + if ((proof?.node ?? policy.node.sha256) && context.binding.node !== (proof?.node ?? policy.node.sha256)) throw new Error("Lookup runtime mismatch"); + const scope = { ...workspaceScope(root), producer: context.binding }; const key = identity({ version: 1, purpose, domain, inputs: captured.sha256, image: policy.image, bun: bunHash, bunVersion: Bun.version, policy: identity(policy), jobs: options.jobs, scope }); const stampDirectory = join(root, "tmp", proof ? "ci-build-qualification-cache" : "ci-build-cache"); if (publish) ownedDirectory(root, stampDirectory, true); @@ -202,7 +206,7 @@ async function execute(root: string, options: Options, policy: Qualification, qu for (const pkg of reused) console.log(`Up to date packages/${pkg} (qualified isolated operation)`); if (proof?.consumers && pending.length) throw new Error("Consumer proof requires every current output's successful same-operation receipt"); if (!pending.length && !proof?.consumers) { - const value = { version: 1, kind: "isolated-ci-build", purpose, domain, authority: qualificationOnly ? "none" : "qualified-production", state: qualificationOnly ? "unqualified-private-repeat" : "reused", key, selected, reused, compiled: [], phases, elapsedMs: performance.now() - started }; + const value = { version: 1, kind: "isolated-ci-build", purpose, domain, authority: qualificationOnly ? "none" : "qualified-production", state: qualificationOnly ? "unqualified-private-repeat" : "reused", context, key, selected, reused, compiled: [], phases, elapsedMs: performance.now() - started }; proof?.results.push(value); operationReceipt(root, value); return 0; @@ -257,7 +261,8 @@ async function execute(root: string, options: Options, policy: Qualification, qu if (interrupted || code !== 0) throw new Error(`Isolated compiler failed (${code})`); const producer = ownedJSON(join(state, "diagnostics/isolated-producer.json")).value; const runtime = producer.runtime; - if (runtime?.bun !== bunHash || runtime?.node?.path !== policy.node.path || ((proof?.node ?? policy.node.sha256) && runtime.node.sha256 !== (proof?.node ?? policy.node.sha256))) throw new Error("Consumed runtime mismatch"); + if (identity(producer.kernel) !== identity(context.binding.kernel) || identity(readBuildDaemonContext(dockerConfig)) !== identity(context.binding.daemon)) throw new Error("Actual producer context changed after lookup"); + if (runtime?.bun !== bunHash || runtime?.node?.path !== policy.node.path || runtime.node.sha256 !== context.binding.node || ((proof?.node ?? policy.node.sha256) && runtime.node.sha256 !== (proof?.node ?? policy.node.sha256))) throw new Error("Consumed runtime mismatch"); if (proof?.consumers) { if (producer.kind !== "packed-consumers" || producer.code !== 0 || producer.consumers?.completed !== true || identity(producer.consumers.packages) !== identity(selected)) throw new Error("Packed consumer receipt mismatch"); } else if (!successfulBuildProducer(producer, pending, { path: policy.node.path, sha256: qualificationOnly ? proof?.node ?? null : policy.node.sha256 }, bunHash)) throw new Error("Consumed compiler receipt mismatch"); @@ -287,13 +292,18 @@ async function execute(root: string, options: Options, policy: Qualification, qu } phases.publishMs = performance.now() - publishStart; if (proof) { proof.candidate ??= material; proof.node ??= producer.runtime.node.sha256; } - const value = { version: 1, kind: "isolated-ci-build", purpose: diagnostic ? `qualification-${diagnostic}` : purpose, domain, authority: qualificationOnly ? "none" : "qualified-production", artifactDirectory: qualificationOnly ? state : undefined, state: qualificationOnly ? "unqualified" : "compiled", action: proof ? "private-qualification-output" : qualificationOnly ? "qualification-only" : "publish", session, key, selected, reused, compiled: pending, outputs, producer, drained, removed, phases, elapsedMs: performance.now() - started }; + const value = { version: 1, kind: "isolated-ci-build", purpose: diagnostic ? `qualification-${diagnostic}` : purpose, domain, authority: qualificationOnly ? "none" : "qualified-production", artifactDirectory: qualificationOnly ? state : undefined, state: qualificationOnly ? "unqualified" : "compiled", action: proof ? "private-qualification-output" : qualificationOnly ? "qualification-only" : "publish", session, key, context, selected, reused, compiled: pending, outputs, producer, drained, removed, phases, elapsedMs: performance.now() - started }; proof?.results.push(value); operationReceipt(root, value); if (qualificationOnly && !proof) console.log(`Unqualified build artifacts retained at ${state}; no reuse stamp or workspace output published.`); return 0; } catch (error) { if (containerKnown) preserve = true; operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: containerKnown ? "failed" : "unsupported", action: containerKnown ? "fail" : "ordinary-rebuild", reason: containerKnown ? "producer-incomplete" : "boundary-unavailable", phases }); + if (error instanceof BuildContextCleanupError || interrupted) { + preserve = true; + operationReceipt(root, { version: 1, kind: "isolated-ci-build", state: "failed", action: "fail", session, reason: interrupted ? "context-lookup-interrupted" : "context-cleanup-incomplete", phases }); + throw error; + } if (!containerKnown && (!qualificationOnly || proof)) return; throw error; } diff --git a/scripts/ci-build-state.ts b/scripts/ci-build-state.ts index ae6cfc4ab5..922c038ba3 100644 --- a/scripts/ci-build-state.ts +++ b/scripts/ci-build-state.ts @@ -4,7 +4,7 @@ export type BuildPurpose = "production" | "qualification"; export const operationFor = (purpose: BuildPurpose) => purpose === "production" ? "fixed-isolated-ci-v1" : "fixed-isolated-ci-qualification-v1"; export type BuildMaterial = { producer: Record; configuration: Record; installation: string; bun: string }; export const buildProducerPaths = ["scripts/build.ts", "scripts/build-execution.ts", "scripts/build-receipt.ts", "scripts/build-repeat-observation.ts", "scripts/bin-modes.ts", "scripts/publishable-packages.ts", "scripts/workspace-lease.mjs", - "scripts/ci-build-files.ts", "scripts/ci-build-lifetime.ts", "scripts/ci-build-boundary.ts", "scripts/ci-build-operation.ts", "scripts/ci-build-entry.ts", "scripts/ci-build-state.ts", "scripts/ci-build-data.ts", "scripts/ci-build-trace.ts", "scripts/ci-build-consumers.ts", "scripts/ci-build-evidence.ts"]; + "scripts/ci-build-files.ts", "scripts/ci-build-lifetime.ts", "scripts/ci-build-context.ts", "scripts/ci-build-boundary.ts", "scripts/ci-build-operation.ts", "scripts/ci-build-entry.ts", "scripts/ci-build-state.ts", "scripts/ci-build-data.ts", "scripts/ci-build-trace.ts", "scripts/ci-build-consumers.ts", "scripts/ci-build-evidence.ts"]; const configuration = (entry: BuildFile) => !/(^|\/)node_modules(?:\/|$)/.test(entry.path) && (/(^|\/)package\.json$/.test(entry.path) || /(^|\/)tsconfig[^/]*\.json$/.test(entry.path) || /(^|\/)bunfig\.toml$/.test(entry.path) || entry.path.endsWith(".css")); export function buildMaterial(records: readonly BuildFile[], installation: string, bun: string): BuildMaterial { diff --git a/tests/build-concurrency.test.ts b/tests/build-concurrency.test.ts index 2bd70e4d13..4882b8f604 100644 --- a/tests/build-concurrency.test.ts +++ b/tests/build-concurrency.test.ts @@ -36,7 +36,7 @@ async function compilerDrain(storageFailure?: StorageFailure) { const release = join(root, "release"); try { mkdirSync(join(root, "scripts")); - for (const file of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-files.ts", "ci-build-lifetime.ts", "ci-build-state.ts", "ci-build-data.ts", "ci-build-trace.ts", "ci-build-consumers.ts", "ci-build-evidence.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "build-repeat-observation.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { + for (const file of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-files.ts", "ci-build-lifetime.ts", "ci-build-context.ts", "ci-build-state.ts", "ci-build-data.ts", "ci-build-trace.ts", "ci-build-consumers.ts", "ci-build-evidence.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "build-repeat-observation.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { copyFileSync(join(repo, "scripts", file), join(root, "scripts", file)); } mkdirSync(join(root, "node_modules/.bin"), { recursive: true }); @@ -127,7 +127,7 @@ async function successfulBuild(storageFailure?: StorageFailure) { const root = mkdtempSync(join(tmpdir(), "odysseus-compiler-levels-")); try { mkdirSync(join(root, "scripts")); - for (const file of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-files.ts", "ci-build-lifetime.ts", "ci-build-state.ts", "ci-build-data.ts", "ci-build-trace.ts", "ci-build-consumers.ts", "ci-build-evidence.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "build-repeat-observation.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { + for (const file of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-files.ts", "ci-build-lifetime.ts", "ci-build-context.ts", "ci-build-state.ts", "ci-build-data.ts", "ci-build-trace.ts", "ci-build-consumers.ts", "ci-build-evidence.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "build-repeat-observation.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { copyFileSync(join(repo, "scripts", file), join(root, "scripts", file)); } mkdirSync(join(root, "node_modules/.bin"), { recursive: true }); diff --git a/tests/build-freshness.test.ts b/tests/build-freshness.test.ts index b6b6d7753e..655e7324d8 100644 --- a/tests/build-freshness.test.ts +++ b/tests/build-freshness.test.ts @@ -18,7 +18,7 @@ function fixture() { write(root, "bun.lock", "{}\n"); write(root, "tsconfig.json", JSON.stringify({ compilerOptions: { target: "ES2019", module: "ESNext", moduleResolution: "Bundler", types: [], skipLibCheck: true } })); mkdirSync(join(root, "scripts")); - for (const file of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-files.ts", "ci-build-lifetime.ts", "ci-build-state.ts", "ci-build-data.ts", "ci-build-trace.ts", "ci-build-consumers.ts", "ci-build-evidence.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "build-repeat-observation.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { + for (const file of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-files.ts", "ci-build-lifetime.ts", "ci-build-context.ts", "ci-build-state.ts", "ci-build-data.ts", "ci-build-trace.ts", "ci-build-consumers.ts", "ci-build-evidence.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "build-repeat-observation.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) { copyFileSync(join(repo, "scripts", file), join(root, "scripts", file)); } const compiler = join(root, "node_modules/typescript"); diff --git a/tests/ci-build-context.test.ts b/tests/ci-build-context.test.ts new file mode 100644 index 0000000000..20bbda79ba --- /dev/null +++ b/tests/ci-build-context.test.ts @@ -0,0 +1,37 @@ +import { expect, spyOn, test } from "bun:test"; +import * as child from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BuildContextCleanupError, observeBuildContext } from "../scripts/ci-build-context"; + +for (const fault of ["none", "daemon-drift", "producer-exit", "malformed", "cleanup", "receipt", "cleanup-receipt"] as const) test(`actual context transport fails closed: ${fault}`, () => { + const state = mkdtempSync(join(tmpdir(), "odysseus-context-")), id = "a".repeat(64), image = "fixed-image"; + const kernel = { affinity: "0-1", boot: "b".repeat(64), kernel: "c".repeat(64), version: "d".repeat(64) }, node = "e".repeat(64); + const calls: string[][] = []; let session = "", info = 0, removed = false; + if (fault.includes("receipt")) writeFileSync(join(state, "context.json"), "owned existing receipt"); + const spawn = spyOn(child, "spawnSync").mockImplementation(((exe: string, args: string[], options: any) => { + expect(exe).toBe("/usr/bin/docker"); expect(options.timeout).toBe(10_000); + expect(args.slice(0, 4)).toEqual(["--host", "unix:///var/run/docker.sock", "--config", "/owned/config"]); + const argv = args.slice(4); calls.push(argv); let out = ""; + if (argv[0] === "info") out = JSON.stringify({ ID: fault === "daemon-drift" && info++ ? "other" : "owned", ServerVersion: "fixed", KernelVersion: "fixed", OSType: "linux", Architecture: "x86_64" }); + if (argv[0] === "create") { session = argv[argv.indexOf("--label") + 1]!.split("=")[1]!; out = id; } + if (argv[0] === "inspect") out = JSON.stringify([{ Id: id, State: { Running: false, ExitCode: fault === "producer-exit" ? 1 : 0 }, Config: { Image: image, User: `${process.getuid!()}:${process.getgid!()}`, Labels: { "brain-ci-build-session": session } }, HostConfig: { ReadonlyRootfs: true, NetworkMode: "none", Privileged: false, CapDrop: ["ALL"], SecurityOpt: ["no-new-privileges"] }, Mounts: [] }]); + if (argv[0] === "start") out = fault === "malformed" ? "{}" : JSON.stringify({ kernel, node }); + if (argv[0] === "rm") removed = true; + if (argv[0] === "container") out = fault.startsWith("cleanup") ? id : ""; + return { status: 0, signal: null, stdout: Buffer.from(out), stderr: Buffer.alloc(0) }; + }) as typeof child.spawnSync); + try { + if (fault === "none") { + const result = observeBuildContext("/owned/config", image, state); + expect(result.binding.kernel).toEqual(kernel); expect(result.binding.node).toBe(node); + expect(result.removed).toBe(true); + } else if (fault.startsWith("cleanup")) expect(() => observeBuildContext("/owned/config", image, state)).toThrow(BuildContextCleanupError); + else expect(() => observeBuildContext("/owned/config", image, state)).toThrow(); + expect(removed, "CONTEXT_REFUSAL_STILL_DRAINS_OWNED_CONTAINER").toBe(true); + for (const argv of calls.filter(argv => ["start", "stop", "wait", "rm"].includes(argv[0]!))) expect(argv.at(-1), "CONTEXT_CONTROLS_ONLY_VERIFIED_ID").toBe(id); + if (fault.includes("receipt")) expect(readFileSync(join(state, "context.json"), "utf8")).toBe("owned existing receipt"); + else if (fault !== "cleanup") expect(JSON.parse(readFileSync(join(state, "context.json"), "utf8")).success).toBe(fault === "none"); + } finally { spawn.mockRestore(); rmSync(state, { recursive: true }); } +}); diff --git a/tests/ci-build-evidence.test.ts b/tests/ci-build-evidence.test.ts index 3ca60851f8..ed4d7b3c61 100644 --- a/tests/ci-build-evidence.test.ts +++ b/tests/ci-build-evidence.test.ts @@ -11,7 +11,7 @@ function fixture() { const workspace = join(controller,"workspace"), state = join(workspace,"tmp/isolated-ci-build/operation-Ithaca"), session = "00000000-0000-4000-8000-000000000001"; mkdirSync(state,{recursive:true,mode:0o700}); const file=(path:string,bytes:string)=>{mkdirSync(join(state,path,".."),{recursive:true,mode:0o700});writeFileSync(join(state,path),bytes);}; - for(const path of ["invocation.json","entry.json","container-boundary.json","diagnostics/isolated-producer.json"])file(path,"{}"); + for(const path of ["context.json","invocation.json","entry.json","container-boundary.json","diagnostics/isolated-producer.json"])file(path,"{}"); file("cleanup.json",JSON.stringify({session,containerID:"a".repeat(64),drained:true,removed:true}));file("tool/bun","captured runtime fixture"); file("parents/common/dist/index.js",'export const harbour="Ithaca";\n'); const output=identity(outputFiles(join(state,"parents/common/dist"))); @@ -23,6 +23,7 @@ test("controller compaction retains exact output/runtime/evidence before removin const actual=compactBuildEvidence(f.workspace,f.controller,f.result), receipt=JSON.parse(readFileSync(join(actual.artifactDirectory,"retained.json"),"utf8")); expect(existsSync(f.state),"CI_BUILD_DRAINED_DUPLICATE_CAPTURE_REMOVED").toBe(false);expect(existsSync(join(other,"keep"))).toBe(true); expect(receipt.files.map((file:any)=>file.path)).toContain("cleanup.json");expect(receipt.removedCapture).toBe(true); + expect(receipt.files.map((file:any)=>file.path),"CI_BUILD_CONTEXT_RECEIPT_RETAINED").toContain("context.json"); expect(identity(outputFiles(join(f.controller,receipt.outputs.common.path))),"CI_BUILD_RETAINED_OUTPUT_IDENTITY").toBe(f.result.outputs.common); expect(digest(readFileSync(join(f.controller,"runtime",`bun-${f.result.producer.runtime.bun}`)))).toBe(f.result.producer.runtime.bun); }); diff --git a/tests/workspace-lease.test.ts b/tests/workspace-lease.test.ts index b1dc6dd83b..288123244c 100644 --- a/tests/workspace-lease.test.ts +++ b/tests/workspace-lease.test.ts @@ -151,7 +151,7 @@ test("shared verification overlaps, blocks a writer and rejects a nested build b mkdirSync(join(root, "scripts")); mkdirSync(join(root, "bin")); mkdirSync(join(root, "packages/geo/dist"), { recursive: true }); writeFileSync(join(root, "packages/geo/dist/retained"), "retained\n"); - for (const name of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-files.ts", "ci-build-lifetime.ts", "ci-build-state.ts", "ci-build-data.ts", "ci-build-trace.ts", "ci-build-consumers.ts", "ci-build-evidence.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "build-repeat-observation.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) copyFileSync(join(project, "scripts", name), join(root, "scripts", name)); + for (const name of ["build.ts", "build-execution.ts", "ci-build-boundary.ts", "ci-build-files.ts", "ci-build-lifetime.ts", "ci-build-context.ts", "ci-build-state.ts", "ci-build-data.ts", "ci-build-trace.ts", "ci-build-consumers.ts", "ci-build-evidence.ts", "ci-build-operation.ts", "ci-build-entry.ts", "ci-build-qualification.json", "build-receipt.ts", "build-repeat-observation.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs"]) copyFileSync(join(project, "scripts", name), join(root, "scripts", name)); writeFileSync(join(root, "bin/bunx"), "#!/bin/sh\nexit 77\n", { mode: 0o755 }); const first = start(root, "read", "hold", "first"), second = start(root, "read", "hold", "second"); await Promise.all([entered(root, "first"), entered(root, "second")]); From 3e5be8f234ca7c9ee69f555f6040be5d72997902 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Tue, 20 Oct 2026 00:39:06 +0200 Subject: [PATCH 14/15] chore(ci): remove obsolete caller kernel reader import --- scripts/ci-build-operation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci-build-operation.ts b/scripts/ci-build-operation.ts index e950a63708..e1fff000ba 100644 --- a/scripts/ci-build-operation.ts +++ b/scripts/ci-build-operation.ts @@ -6,7 +6,7 @@ import { buildMaterial, buildProducerPaths, matchingBuildReceipt, operationFor, import { spawnSync } from "node:child_process"; import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import { chmodSync, closeSync, existsSync, fstatSync, openSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, closeSync, existsSync, fstatSync, openSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; import { buildMounts, captureBuildInputs, captureDerivedOutput, captureOutputs, digest, identity, outputFiles, sameOutputIdentity } from "./ci-build-boundary"; import { selectBuildPackages } from "./publishable-packages"; From dfa31b3e72bd9e970c08a7161f725b22bcfd09f7 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Tue, 20 Oct 2026 07:36:29 +0200 Subject: [PATCH 15/15] fix(ci): preserve ordinary builds for malformed dormant policy --- docs/decisions/README.md | 2 +- docs/decisions/ci-utility.md | 6 ++-- docs/decisions/isolated-ci-build.md | 24 +++++++++++-- scripts/ci-build-operation.ts | 8 +++-- tests/build-freshness.test.ts | 55 +++++++++++++++++++++++++++-- 5 files changed, 85 insertions(+), 10 deletions(-) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 9aae3e59bb..c53a4bb137 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -13,7 +13,7 @@ somebody already learn the hard way?* | --- | --- | | [github-actions-ci.md](github-actions-ci.md) | GitHub Actions as the sole CI provider, with preserved automatic/local guarantees and safe fork execution. | | [ci-utility.md](ci-utility.md) | Complete affected hosted proof, cheap local feedback and concurrent ready-PR scheduling without removing tests or blocking unrelated merges. | -| [isolated-ci-build.md](isolated-ci-build.md) | One qualified fixed CI build operation, conservative fallback and actual input/output authority before unchanged-build reuse. | +| [isolated-ci-build.md](isolated-ci-build.md) | Dormant fixed CI build operation, explicit activation deferral and conservative ordinary-build fallback. | | [tag-aliases.md](tag-aliases.md) | No-go for the tested semantic alias pipeline: Jev recognizes the aliases, but candidate retrieval misses one found by the shipped audit skill. | | [example-corpus.md](example-corpus.md) | Odysseus as the sole example world across core, CLI, docs and presentation; separate technical representations and historical evidence. | | [design-kit.md](design-kit.md) | The design kit and the chat surface: D1–D53, dated. Component API, tokens, the light theme, the accessibility gate, the in-chat tool contracts, the classification pass, how the bridge tools reach the model, what a model-authored link may show, the follow-ups a model may offer, recoverable turn failures, the navigation, session-tracker and per-session draft design, ghost-text loading, and offline continuity with committed recording boundaries and measured browser limits. | diff --git a/docs/decisions/ci-utility.md b/docs/decisions/ci-utility.md index b41213fa55..ad4e5466fa 100644 --- a/docs/decisions/ci-utility.md +++ b/docs/decisions/ci-utility.md @@ -816,8 +816,10 @@ for coverage. Selected categories remain required by the unchanged aggregate. ## Fixed CI build output reuse The [isolated build decision](isolated-ci-build.md) records the maintainer's -#2319 ruling: develop and qualify one fixed first-party operation, preserving -ordinary rebuilding for local and unsupported contexts. CI intent does not +#2319 rulings: retain the dormant fixed first-party operation and defer activation, +preserving ordinary rebuilding for local and unsupported contexts. CI intent stays +off, production remains unqualified, and the original optimization criteria stay +open after the independently reviewable dormant delivery. CI intent does not authorize reuse. Complete effective inputs, drained output ownership, actual consumer compatibility and comparable hosted cost must be qualified before activation. This does not cache selected test results or relax their proof. diff --git a/docs/decisions/isolated-ci-build.md b/docs/decisions/isolated-ci-build.md index bfd2a62ab4..ca5bf1306d 100644 --- a/docs/decisions/isolated-ci-build.md +++ b/docs/decisions/isolated-ci-build.md @@ -1,10 +1,28 @@ # Isolated CI build output reuse -The [maintainer ruling on #2319](https://github.com/schlessera/brain-kit/issues/2319#issuecomment-6237827676) -selects one fixed first-party CI build operation. Ordinary local builds and +The [2026-10-20 maintainer ruling on #2319](https://github.com/schlessera/brain-kit/issues/2319#issuecomment-6284109466) +defers activation and retains the dormant operation and producer-context fix. +It narrows the earlier [fixed-operation ruling](https://github.com/schlessera/brain-kit/issues/2319#issuecomment-6237827676) +to an independently reviewable delivery under `Refs #2319`. Production +qualification remains unqualified and CI intent stays off. Ordinary local builds and unsupported contexts rebuild without requiring Docker. This is build output reuse, not reuse of test results or authority to omit selected proof. +The original optimization, ambient-input, native-provenance and comparable +hosted-cost acceptance criteria remain open in #2319 after a dormant delivery. +The ruling neither waives those criteria nor authorizes a stronger deterministic +runtime/native-build boundary; that would require a separate specification and +maintainer ruling. Required selected hosted proof of the dormant slice remains +separate from activation qualification. + +Finite local controls exposed concrete limits: direct vDSO clock reads bypass +the diagnostic libc interposition, kernel/device and hardware inputs remain +unqualified, and registry integrity for the consumed LightningCSS archive does +not establish its source-to-binary provenance. Equal output samples and a bound +producer context cannot close those gaps. Measured capture and lookup overhead +also lacks a comparable hosted baseline. These limits motivate deferral, not +a speedup or completed optimization claim. + The previous stamp could authorize a skip after an effective compiler config target or installed emitter changed. Package versions, link strings and a larger code manifest do not establish everything an active producer reads. @@ -22,6 +40,8 @@ Changing source invalidates the input identity. Unsupported effective aliases, changed executable configuration or installed producers, unavailable context and ambiguous state require ordinary compilation. A real producer failure propagates; it is not retried as an ordinary build to manufacture success. +Unreadable, aliased or malformed qualification policy also falls back before +Docker access; parsing JSON alone does not establish a valid policy shape. The fixed operation consumes captured bytes through read-only mounts, a fixed environment and local-only executable resolution, without network acquisition. diff --git a/scripts/ci-build-operation.ts b/scripts/ci-build-operation.ts index e1fff000ba..55073f6040 100644 --- a/scripts/ci-build-operation.ts +++ b/scripts/ci-build-operation.ts @@ -96,13 +96,15 @@ export async function attemptCiBuild(root: string, options: Options): Promise /^build-.*-probe\.json$/.test(name)); expect(names).toHaveLength(1); const receipt = JSON.parse(readFileSync(join(root, "tmp", names[0]!), "utf8")); @@ -56,6 +61,52 @@ function consume(root: string): number { return Number(new TextDecoder().decode(child.stdout).trim()); } +function withoutDocker(root: string, intent = "isolated-v1") { + const preload = join(root, "docker-denial.ts"); + writeFileSync(preload, `import cp from "node:child_process"; +import { appendFileSync } from "node:fs"; +import { mock } from "bun:test"; +const deny = (args) => { + if (args.some(arg => typeof arg === "string" && /(^|\\/)docker$/.test(arg))) { + appendFileSync(${JSON.stringify(join(root, "docker-attempts"))}, "attempt\\n"); + throw new Error("Docker unavailable in ordinary build regression"); + } +}; +const sync = cp.spawnSync; +mock.module("node:child_process", () => ({ ...cp, spawnSync: (...args) => { deny([args[0], ...(args[1] ?? [])]); return sync(...args); } })); +for (const name of ["spawn", "spawnSync"]) { + const original = Bun[name]; + Bun[name] = (...args) => { deny(Array.isArray(args[0]) ? args[0] : args[0].cmd); return original(...args); }; +} +`); + return { preload, env: { ...process.env, BRAIN_CI_BUILD_OPERATION: intent } }; +} + +for (const field of ["producer", "configuration"] as const) test(`malformed ${field} qualification preserves real ordinary compilation without Docker`, async () => { + const { root } = fixture(), options = withoutDocker(root); + const policy = JSON.parse(readFileSync(join(root, "scripts/ci-build-qualification.json"), "utf8")); + const hash = "a".repeat(64); + Object.assign(policy, { activation: "qualified", installation: hash, bun: hash, ambientEvidence: hash, compatibilityEvidence: hash, hostedCostEvidence: hash, + node: { path: "/usr/bin/node", sha256: hash }, producer: Object.fromEntries(buildProducerPaths.map(path => [path, hash])), configuration: { "tsconfig.json": hash }, [field]: null }); + write(root, "scripts/ci-build-qualification.json", JSON.stringify(policy)); + compiled(await runBuild(root, ["--for", "ithaca", "--jobs=2", "--force"], options), ["ithaca"]); + expect(consume(root), "MALFORMED_POLICY_FALLBACK_REACHES_REAL_CONSUMER").toBe(1); + expect(lstatSync(join(root, "docker-attempts"), { throwIfNoEntry: false }), "MALFORMED_POLICY_NEVER_INVOKES_DOCKER").toBeUndefined(); +}); + +test("dormant intent, invalid intent and aliased policy preserve ordinary builds without Docker", async () => { + const { root, outer } = fixture(); + for (const intent of ["isolated-v1", "invalid-operation"]) { + compiled(await runBuild(root, ["--for", "ithaca"], withoutDocker(root, intent)), ["ithaca"]); + expect(consume(root)).toBe(1); + } + const policy = join(root, "scripts/ci-build-qualification.json"); + copyFileSync(policy, join(outer, "policy.json")); rmSync(policy); symlinkSync("../../policy.json", policy); + compiled(await runBuild(root, ["--for", "ithaca", "--force"], withoutDocker(root)), ["ithaca"]); + expect(consume(root)).toBe(1); + expect(lstatSync(join(root, "docker-attempts"), { throwIfNoEntry: false }), "DORMANT_OR_UNSUPPORTED_PATH_NEVER_INVOKES_DOCKER").toBeUndefined(); +}); + test("effective aliased compiler configuration is rebuilt before consuming output", async () => { const { root, outer } = fixture(); const config = join(outer, "compiler.json"); writeFileSync(config, readFileSync(join(root, "tsconfig.json"))); rmSync(join(root, "tsconfig.json")); symlinkSync("../compiler.json", join(root, "tsconfig.json"));