diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 6bd70e6..b9dd0fb 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -273,9 +273,9 @@ jobs: const minimumFiles = 50; const maximumFiles = 60; const minimumPackedBytes = 140_000; - const maximumPackedBytes = 180_000; + const maximumPackedBytes = 220_000; const minimumUnpackedBytes = 650_000; - const maximumUnpackedBytes = 810_000; + const maximumUnpackedBytes = 1_010_000; const maximumMetadataBytes = 250_000; const expectedName = "@hraness/direct"; const expectedVersion = process.env.EXPECTED_VERSION; diff --git a/AGENTS.md b/AGENTS.md index a9e0b5c..60a4d0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ - Keep core code product-, platform-, and framework-neutral. Put React, browser globals, and Node-only tooling behind explicit subpaths. - Build Bun host `@hraness/direct/tooling/*` entries separately. Keep every development-only export out of the default, core, React, testing, and web graphs, and prove the separation through the packed-consumer boundary gate. Ship the Bombadil campaign subpath as TypeScript source because 0.7.2 resolves no package export conditions, and keep it free of filesystem and process APIs because its compiler loads that subpath into a browser specification. - Pin optional browser tools exactly. The Bombadil integration supports 0.7.2 only, treats its JSONL trace as foreign bounded input, and must attest the canonical Direct manifest and probe after every run rather than trust a zero exit status. +- Constrain every Bombadil run to exclusive UUID leaves, owned process groups, bounded files and totals, a final descriptor-bound inventory, and a sanitized receipt. Public CI may upload only the exact receipt/summary leaf; raw traces and diagnostics require explicit bounded private vetting. Give each product-owned named snapshot an exact fail-closed parser or predicate. - Keep React Native and Expo imports in the reference example; `@hraness/direct/react` remains the platform-neutral React binding. - Keep `.js` extensions on relative TypeScript import and export specifiers; the published source type surface must compile under both Bundler and NodeNext resolution. - Treat this repository as the complete project. Files and Git prose may use only its public names, paths, commands, and examples; do not refer to or infer any non-public source, system, product, package, path, or implementation detail. diff --git a/README.md b/README.md index 51f256b..1da786c 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,21 @@ the exact native 0.7.2 binary, attests the bounded trace with Direct's canonical parsers, writes pass or failure artifacts plus a compact exploration summary, and releases its owned processes. Use `runDirectBombadilFuzzMatrix` when a product owns several scenarios; it runs them serially and requires one exact -campaign selector for replay. +campaign selector for replay. Matrix upload plans are public-summary only and +publish one atomic parent leaf; run a selected campaign directly for bounded +access-controlled private diagnostics. + +Scheduled wrappers should precompute one lowercase UUID and pass it through +the runner's `artifactRun` option. Resolve the exact leaf with +`resolveDirectBombadilUploadLeaf` and upload only that leaf with `if: always()`. +Its default +public mode contains a bounded sanitized receipt and summary, including for +rejected or failed runs. Raw traces, logs, screenshots, paths, labels, typed +values, queries, and foreign errors stay local unless an access-controlled job +explicitly selects the bounded `private-vetted` mode. +Parse retained JSON from `unknown` with the four exported +`parseDirectBombadil*Receipt` and `parseDirectBombadil*Summary` functions; +never cast `JSON.parse` output to an evidence type. Startup is the only repairable contract phase. It must reach one exact Direct observation within ten seconds. From that sample onward, activation identity, @@ -329,6 +343,12 @@ snapshots that expose semantic state without retaining page content. Run short 12–30 second campaigns while editing and longer 60–300 second matrices in a scheduled diagnostic lane. Inspect and replay a retained failing trace, then promote the smallest readable failure to a deterministic product regression. +Give every product-owned named snapshot an exact fail-closed parser or type +predicate. A local random walk discovers reachable surprises; an Antithesis +environment supplies deterministic simulation and reproducibility around the +same bounded properties. Do not treat either one as a replacement for Direct's +deterministic scenarios, semantic assertions, production-boundary checks, or +ordinary browser gates. When a campaign must exercise an interaction, require a named product value to change after the intended action kind, as well as after a non-Wait action, so bootstrap, idle, prerequisite, and unrelated transitions do not satisfy the diff --git a/dist/tooling/bombadil.js b/dist/tooling/bombadil.js index 341bc26..c697614 100644 --- a/dist/tooling/bombadil.js +++ b/dist/tooling/bombadil.js @@ -1,11 +1,20 @@ // @bun // src/tooling/bombadil-runner.ts -import { createReadStream } from "fs"; -import { readFile, realpath, stat, writeFile as writeFile2 } from "fs/promises"; -import { isAbsolute, join as join2, relative, resolve } from "path"; +import { constants as fileSystemConstants } from "fs"; +import { + lstat, + mkdir as mkdir2, + open, + opendir, + readFile, + realpath, + rename as rename2, + rm as rm2, + stat +} from "fs/promises"; +import { extname, isAbsolute, join as join2, relative, resolve } from "path"; import process2 from "process"; -import { createInterface } from "readline"; -import { createHash } from "crypto"; +import { createHash, randomUUID as randomUUID2 } from "crypto"; // src/core/result.ts function ok(value) { @@ -978,10 +987,35 @@ async function collectStream(stream, logLimit) { output = tail(`${output}${decoder.decode(chunk.value, { stream: true })}`, logLimit); } } +function verificationProcessGroupExists(processId) { + try { + process.kill(-processId, 0); + return true; + } catch (error) { + if (error.code === "ESRCH") + return false; + if (error.code === "EPERM") + return true; + throw error; + } +} +async function waitForVerificationProcessGroupExit(processId, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (verificationProcessGroupExists(processId)) { + if (Date.now() >= deadline) { + throw new Error(`verification server process group ${String(processId)} survived cleanup`); + } + await Bun.sleep(Math.min(25, Math.max(1, deadline - Date.now()))); + } +} function spawnVerificationServer(options) { + const detachedProcessGroup = options.detachedProcessGroup ?? false; + const omittedEnvironment = new Set(options.omitEnvironment ?? []); + const environment = Object.fromEntries(Object.entries({ ...process.env, ...options.env }).filter(([name]) => !omittedEnvironment.has(name))); const process_ = Bun.spawn([...options.command], { cwd: options.cwd, - env: { ...process.env, ...options.env }, + detached: detachedProcessGroup, + env: environment, stdin: "ignore", stdout: "pipe", stderr: "pipe" @@ -992,12 +1026,31 @@ function spawnVerificationServer(options) { collectStream(process_.stderr, logLimit) ]).then(([stdout, stderr]) => tail(`${stdout} ${stderr}`.trim(), logLimit)); + const signal = (value) => { + if (detachedProcessGroup) { + try { + process.kill(-process_.pid, value); + return; + } catch (error) { + if (error.code !== "ESRCH") + throw error; + } + } + if (process_.exitCode === null) + process_.kill(value); + }; return { exited: process_.exited, exitCode: () => process_.exitCode, + ...detachedProcessGroup ? { + killDescendants: async (timeoutMs) => { + signal("SIGKILL"); + await waitForVerificationProcessGroupExit(process_.pid, timeoutMs); + } + } : {}, output, - terminate: () => process_.kill("SIGTERM"), - kill: () => process_.kill("SIGKILL") + terminate: () => signal("SIGTERM"), + kill: () => signal("SIGKILL") }; } async function settleWithin(promise, timeoutMs) { @@ -1037,6 +1090,7 @@ async function stopVerificationServerWithOutput(server, stopTimeoutMs = DEFAULT_ throw new Error(`verification server did not exit within ${stopTimeoutMs}ms after SIGKILL`); } } + await server.killDescendants?.(stopTimeoutMs); const output = await settleWithin(server.output, stopTimeoutMs); if (!output.settled) { throw new Error(`verification server output did not settle within ${stopTimeoutMs}ms after exit`); @@ -1124,19 +1178,6 @@ ${output2}`); throw new Error(output === "" ? timeoutMessage : `${timeoutMessage}: ${output}`); } -async function createArtifactRun(options) { - const generatedAt = options.generatedAt ?? new Date().toISOString(); - const processId = options.processId ?? process.pid; - const runId = `${generatedAt.replaceAll(/[^0-9A-Za-z]/gu, "-")}-${processId}`; - const runDirectory = join(options.artifactRoot, runId); - await mkdir(runDirectory, { recursive: true }); - return { - artifactRoot: options.artifactRoot, - generatedAt, - manifestPath: join(options.artifactRoot, "manifest.json"), - runDirectory - }; -} async function writeJsonAtomically(path, value) { const temporaryPath = join(dirname(path), `.${process.pid}-${randomUUID()}.tmp`); try { @@ -1144,7 +1185,9 @@ async function writeJsonAtomically(path, value) { `, "utf8"); await rename(temporaryPath, path); } catch (error) { - await rm(temporaryPath, { force: true }); + await rm(temporaryPath, { force: true }).catch(() => { + return; + }); throw error; } } @@ -1158,8 +1201,111 @@ var DEFAULT_STARTUP_TIMEOUT_MS = 60000; var MAX_STARTUP_TIMEOUT_MS = 120000; var LOG_LIMIT = 24000; var ARTIFACT_SCHEMA = "direct.bombadil-run/v1"; +var ARTIFACT_RECEIPT_SCHEMA = "direct.bombadil-artifact-receipt/v1"; +var ARTIFACT_SUMMARY_SCHEMA = "direct.bombadil-upload-summary/v1"; +var MATRIX_RECEIPT_SCHEMA = "direct.bombadil-matrix-receipt/v1"; +var MATRIX_SUMMARY_SCHEMA = "direct.bombadil-matrix-summary/v1"; +var ARTIFACT_FAILURE_CODES = new Set([ + "artifact-policy", + "configuration-rejected", + "exploration-policy", + "interrupted", + "persistence", + "process", + "server", + "trace-attestation", + "writer-settlement", + "unknown" +]); +var ARTIFACT_RECEIPT_KEYS = new Set([ + "completedAt", + "diagnosticsRetained", + "failureCode", + "inventory", + "mode", + "policy", + "runId", + "schema", + "status" +]); +var ARTIFACT_RECEIPT_INVENTORY_KEYS = new Set([ + "entryCount", + "fileCount", + "inventorySha256", + "totalBytes" +]); +var ARTIFACT_POLICY_RECEIPT_KEYS = new Set([ + "maxDepth", + "maxEntries", + "maxFileBytes", + "maxFiles", + "maxPathBytes", + "maxTotalBytes" +]); +var RUN_SUMMARY_KEYS = new Set([ + "artifactName", + "attestation", + "exploration", + "failureCode", + "scenario", + "schema", + "status" +]); +var RUN_SUMMARY_ATTESTATION_KEYS = new Set([ + "invalidObservationCount", + "observationCount", + "validObservationCount" +]); +var RUN_SUMMARY_EXPLORATION_KEYS = new Set([ + "actionCount", + "nonWaitActionCount", + "policySatisfied", + "traceBytes", + "traceLineCount", + "traceSha256" +]); +var MATRIX_RECEIPT_KEYS = new Set([ + "campaigns", + "completedAt", + "failureCode", + "mode", + "omittedCampaignCount", + "runId", + "schema", + "status" +]); +var MATRIX_CAMPAIGN_RECEIPT_KEYS = new Set([ + "campaignId", + "index", + "receipt", + "status" +]); +var MATRIX_SUMMARY_KEYS = new Set([ + "campaigns", + "failureCode", + "schema", + "status" +]); +var MATRIX_SUMMARY_CAMPAIGNS_KEYS = new Set([ + "failed", + "notRun", + "notSelected", + "omitted", + "passed", + "rejected", + "total" +]); +var SHA256_PATTERN = /^[0-9a-f]{64}$/u; +var ARTIFACT_EVIDENCE_JSON_LIMITS = Object.freeze({ + maxDepth: 8, + maxNodes: 2048, + maxStringBytes: 64 * 1024 +}); var SCENARIO_PATTERN = /^[a-z][a-z0-9]*(?:[._/-][a-z0-9]+)*$/u; var ARTIFACT_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +var MAX_ARTIFACT_IDENTIFIER_LENGTH = 80; +var MAX_MATRIX_CAMPAIGNS = 32; +var ARTIFACT_COORDINATION_ENVIRONMENT = "DIRECT_BOMBADIL_RUN_ID"; var ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/u; var QUERY_PARAMETER_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_.-]*$/u; var PROTOTYPE_PROPERTY_NAMES = new Set(["__proto__", "constructor", "prototype"]); @@ -1178,6 +1324,31 @@ var REPLAY_WALL_CLOCK_TIMEOUT_MS = MAX_TIME_LIMIT_SECONDS * 1000 + RANDOM_RUN_OV var PROCESS_TERMINATION_GRACE_MS = 5000; var MIN_PROCESS_OUTPUT_DRAIN_MS = 500; var SERVER_OUTPUT_TIMEOUT_MS = 3000; +var ARTIFACT_MONITOR_INTERVAL_MS = 100; +var DEFAULT_ARTIFACT_MAX_ENTRIES = 4096; +var DEFAULT_ARTIFACT_MAX_FILES = 2048; +var DEFAULT_ARTIFACT_MAX_TOTAL_BYTES = 128 * 1024 * 1024; +var DEFAULT_ARTIFACT_MAX_FILE_BYTES = 64 * 1024 * 1024; +var DEFAULT_ARTIFACT_MAX_DEPTH = 32; +var DEFAULT_ARTIFACT_MAX_PATH_BYTES = 4096; +var MAX_ARTIFACT_ENTRIES = 16384; +var MAX_ARTIFACT_FILES = 8192; +var MAX_ARTIFACT_TOTAL_BYTES = 256 * 1024 * 1024; +var MAX_ARTIFACT_FILE_BYTES = 64 * 1024 * 1024; +var MAX_ARTIFACT_DEPTH = 64; +var MAX_ARTIFACT_PATH_BYTES = 4096; +var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +var ARTIFACT_PATH_PART_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u; +var PRIVATE_DIAGNOSTIC_EXTENSIONS = new Set([ + ".jpeg", + ".jpg", + ".json", + ".jsonl", + ".log", + ".png", + ".txt", + ".webp" +]); var DIRECT_BROWSER_BRIDGE_SCHEMA = "direct.browser-bridge/v2"; var TRACE_LINE_KEYS = new Set(["action", "snapshots", "state", "timestamp", "violations"]); var TRACE_SNAPSHOT_KEYS = new Set(["index", "name", "time", "value"]); @@ -1284,6 +1455,28 @@ var DIRECT_OBSERVATION_KEYS = new Set([ "violations", "violationsValid" ]); +var PROCESS_INTERRUPT_SIGNALS = ["SIGINT", "SIGTERM"]; + +class BombadilArtifactPolicyError extends Error { + constructor(message) { + super(message); + this.name = "BombadilArtifactPolicyError"; + } +} + +class BombadilWriterSettlementError extends Error { + constructor(message, cause) { + super(message, { cause }); + this.name = "BombadilWriterSettlementError"; + } +} + +class BombadilPersistenceError extends AggregateError { + constructor(message, errors) { + super(errors, message, { cause: errors[0] }); + this.name = "BombadilPersistenceError"; + } +} function readOptionValue(arguments_, index, option) { const value = arguments_[index + 1]; if (value === undefined || value.startsWith("-")) { @@ -1340,6 +1533,9 @@ function hasControlCharacters3(value) { function isRecord2(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } +function isReadonlyStringArray(value) { + return Array.isArray(value) && value.every((entry) => typeof entry === "string"); +} function hasExactKeys(value, expected) { const keys = Object.keys(value); return keys.length === expected.size && keys.every((key) => expected.has(key)); @@ -1351,6 +1547,1269 @@ function compareCodeUnits(left, right) { return 1; return 0; } +function boundedArtifactInteger(options) { + const value = options.value ?? options.defaultValue; + if (!Number.isSafeInteger(value) || value < 1 || value > options.maximum) { + throw new Error(`${options.label} must be an integer between 1 and ${String(options.maximum)}`); + } + return value; +} +function validateArtifactPolicy(input) { + const value = input ?? {}; + return Object.freeze({ + maxDepth: boundedArtifactInteger({ + defaultValue: DEFAULT_ARTIFACT_MAX_DEPTH, + label: "artifactPolicy.maxDepth", + maximum: MAX_ARTIFACT_DEPTH, + value: value.maxDepth + }), + maxEntries: boundedArtifactInteger({ + defaultValue: DEFAULT_ARTIFACT_MAX_ENTRIES, + label: "artifactPolicy.maxEntries", + maximum: MAX_ARTIFACT_ENTRIES, + value: value.maxEntries + }), + maxFileBytes: boundedArtifactInteger({ + defaultValue: DEFAULT_ARTIFACT_MAX_FILE_BYTES, + label: "artifactPolicy.maxFileBytes", + maximum: MAX_ARTIFACT_FILE_BYTES, + value: value.maxFileBytes + }), + maxFiles: boundedArtifactInteger({ + defaultValue: DEFAULT_ARTIFACT_MAX_FILES, + label: "artifactPolicy.maxFiles", + maximum: MAX_ARTIFACT_FILES, + value: value.maxFiles + }), + maxPathBytes: boundedArtifactInteger({ + defaultValue: DEFAULT_ARTIFACT_MAX_PATH_BYTES, + label: "artifactPolicy.maxPathBytes", + maximum: MAX_ARTIFACT_PATH_BYTES, + value: value.maxPathBytes + }), + maxTotalBytes: boundedArtifactInteger({ + defaultValue: DEFAULT_ARTIFACT_MAX_TOTAL_BYTES, + label: "artifactPolicy.maxTotalBytes", + maximum: MAX_ARTIFACT_TOTAL_BYTES, + value: value.maxTotalBytes + }) + }); +} +function normalizeFuzzRunOptions(input) { + if (input === undefined || isReadonlyStringArray(input)) { + return { + arguments: Object.freeze([...input ?? []]), + artifactRun: null + }; + } + const options = input; + const artifactRun = options.artifactRun; + if (!isRecord2(options)) + throw new Error("Bombadil run options must be an object or argument array"); + const keys = Object.keys(options); + if (keys.some((key) => key !== "arguments" && key !== "artifactRun")) { + throw new Error("Bombadil run options contain an unknown field"); + } + const arguments_ = options.arguments ?? []; + if (!isReadonlyStringArray(arguments_)) { + throw new Error("Bombadil run options arguments must be a string array"); + } + return { + arguments: Object.freeze([...arguments_]), + artifactRun: artifactRun ?? null + }; +} +function validateArtifactRunPlan(input) { + const repositoryRoot = resolve(input.repositoryRoot); + if (!isAbsolute(input.repositoryRoot) || repositoryRoot !== input.repositoryRoot) { + throw new Error("artifactRun.repositoryRoot must be an absolute normalized path"); + } + if (!UUID_PATTERN.test(input.runId)) { + throw new Error("artifactRun.runId must be a lowercase RFC 4122 UUID"); + } + const uploadMode = input.uploadMode ?? "public-summary"; + if (uploadMode !== "public-summary" && uploadMode !== "private-vetted") { + throw new Error("artifactRun.uploadMode must be public-summary or private-vetted"); + } + return Object.freeze({ repositoryRoot, runId: input.runId, uploadMode }); +} +function isBoundedArtifactIdentifier(value) { + return value.length <= MAX_ARTIFACT_IDENTIFIER_LENGTH && ARTIFACT_NAME_PATTERN.test(value); +} +function isBoundedScenarioIdentifier(value) { + return value.length <= 120 && SCENARIO_PATTERN.test(value); +} +function requireEvidenceRecord(value, keys, label) { + if (!isRecord2(value) || !hasExactKeys(value, keys)) { + throw new Error(`${label} must contain exactly its documented fields`); + } + return value; +} +function requireEvidenceInteger(value, label, maximum = Number.MAX_SAFE_INTEGER) { + if (!Number.isSafeInteger(value) || value < 0 || value > maximum) { + throw new Error(`${label} must be a nonnegative safe integer no greater than ${String(maximum)}`); + } + return value; +} +function requireEvidencePositiveInteger(value, label, maximum) { + const parsed = requireEvidenceInteger(value, label, maximum); + if (parsed === 0) + throw new Error(`${label} must be greater than zero`); + return parsed; +} +function requireEvidenceSha256(value, label) { + if (typeof value !== "string" || !SHA256_PATTERN.test(value)) { + throw new Error(`${label} must be a lowercase SHA-256 digest`); + } + return value; +} +function requireEvidenceTimestamp(value, label) { + if (typeof value !== "string") + throw new Error(`${label} must be an ISO timestamp`); + const milliseconds = Date.parse(value); + if (!Number.isFinite(milliseconds) || new Date(milliseconds).toISOString() !== value) { + throw new Error(`${label} must be a canonical ISO timestamp`); + } + return value; +} +function parseEvidenceFailureCode(value, label) { + if (value === null) + return null; + if (typeof value !== "string" || !ARTIFACT_FAILURE_CODES.has(value)) { + throw new Error(`${label} is not a known Bombadil failure code`); + } + return value; +} +function requireEvidenceStatus(value, label) { + if (value !== "failed" && value !== "passed" && value !== "rejected") { + throw new Error(`${label} must be failed, passed, or rejected`); + } + return value; +} +function requireFailureStatusConsistency(status, failureCode, label) { + if (status === "passed" !== (failureCode === null)) { + throw new Error(`${label} status and failureCode are inconsistent`); + } + if (status === "rejected" && failureCode !== "configuration-rejected") { + throw new Error(`${label} rejected status requires configuration-rejected`); + } +} +function parseArtifactReceiptUnchecked(input) { + const value = requireEvidenceRecord(input, ARTIFACT_RECEIPT_KEYS, "Bombadil receipt"); + if (value.schema !== ARTIFACT_RECEIPT_SCHEMA) { + throw new Error("Bombadil receipt schema is unsupported"); + } + const completedAt = requireEvidenceTimestamp(value.completedAt, "Bombadil receipt completedAt"); + if (typeof value.diagnosticsRetained !== "boolean") { + throw new Error("Bombadil receipt diagnosticsRetained must be boolean"); + } + const failureCode = parseEvidenceFailureCode(value.failureCode, "Bombadil receipt failureCode"); + const status = requireEvidenceStatus(value.status, "Bombadil receipt status"); + requireFailureStatusConsistency(status, failureCode, "Bombadil receipt"); + if (value.mode !== "private-vetted" && value.mode !== "public-summary") { + throw new Error("Bombadil receipt mode is unsupported"); + } + if (value.diagnosticsRetained && value.mode !== "private-vetted") { + throw new Error("Public Bombadil receipts cannot retain diagnostics"); + } + if (typeof value.runId !== "string" || !UUID_PATTERN.test(value.runId)) { + throw new Error("Bombadil receipt runId must be a lowercase RFC 4122 UUID"); + } + const rawPolicy = requireEvidenceRecord(value.policy, ARTIFACT_POLICY_RECEIPT_KEYS, "Bombadil receipt policy"); + const policy = Object.freeze({ + maxDepth: requireEvidencePositiveInteger(rawPolicy.maxDepth, "Bombadil receipt policy.maxDepth", MAX_ARTIFACT_DEPTH), + maxEntries: requireEvidencePositiveInteger(rawPolicy.maxEntries, "Bombadil receipt policy.maxEntries", MAX_ARTIFACT_ENTRIES), + maxFileBytes: requireEvidencePositiveInteger(rawPolicy.maxFileBytes, "Bombadil receipt policy.maxFileBytes", MAX_ARTIFACT_FILE_BYTES), + maxFiles: requireEvidencePositiveInteger(rawPolicy.maxFiles, "Bombadil receipt policy.maxFiles", MAX_ARTIFACT_FILES), + maxPathBytes: requireEvidencePositiveInteger(rawPolicy.maxPathBytes, "Bombadil receipt policy.maxPathBytes", MAX_ARTIFACT_PATH_BYTES), + maxTotalBytes: requireEvidencePositiveInteger(rawPolicy.maxTotalBytes, "Bombadil receipt policy.maxTotalBytes", MAX_ARTIFACT_TOTAL_BYTES) + }); + const rawInventory = requireEvidenceRecord(value.inventory, ARTIFACT_RECEIPT_INVENTORY_KEYS, "Bombadil receipt inventory"); + const entryCount = requireEvidenceInteger(rawInventory.entryCount, "Bombadil receipt inventory.entryCount", policy.maxEntries); + const fileCount = requireEvidenceInteger(rawInventory.fileCount, "Bombadil receipt inventory.fileCount", policy.maxFiles); + const totalBytes = requireEvidenceInteger(rawInventory.totalBytes, "Bombadil receipt inventory.totalBytes", policy.maxTotalBytes); + if (fileCount > entryCount) { + throw new Error("Bombadil receipt inventory.fileCount cannot exceed entryCount"); + } + if (fileCount === 0 && totalBytes !== 0) { + throw new Error("Bombadil receipt inventory bytes require at least one file"); + } + const inventorySha256 = rawInventory.inventorySha256 === null ? null : requireEvidenceSha256(rawInventory.inventorySha256, "Bombadil receipt inventory.inventorySha256"); + if (entryCount === 0 && (fileCount !== 0 || totalBytes !== 0 || inventorySha256 !== null) || entryCount > 0 && inventorySha256 === null) { + throw new Error("Bombadil receipt empty-inventory fields are inconsistent"); + } + if (status === "passed" && (entryCount === 0 || fileCount === 0 || totalBytes === 0) || status === "passed" && value.mode === "private-vetted" && !value.diagnosticsRetained || failureCode === "interrupted" && value.diagnosticsRetained || failureCode === "configuration-rejected" && status !== "rejected" || failureCode === "writer-settlement" && (value.diagnosticsRetained || entryCount !== 0 || fileCount !== 0 || totalBytes !== 0) || status === "rejected" && (value.diagnosticsRetained || entryCount !== 0 || fileCount !== 0 || totalBytes !== 0)) { + throw new Error("Bombadil receipt terminal state and retained evidence are inconsistent"); + } + return Object.freeze({ + schema: ARTIFACT_RECEIPT_SCHEMA, + completedAt, + diagnosticsRetained: value.diagnosticsRetained, + failureCode, + inventory: Object.freeze({ entryCount, fileCount, inventorySha256, totalBytes }), + mode: value.mode, + policy, + runId: value.runId, + status + }); +} +function parseRunSummaryUnchecked(input) { + const value = requireEvidenceRecord(input, RUN_SUMMARY_KEYS, "Bombadil run summary"); + if (value.schema !== ARTIFACT_SUMMARY_SCHEMA) { + throw new Error("Bombadil run summary schema is unsupported"); + } + if (typeof value.artifactName !== "string" || !isBoundedArtifactIdentifier(value.artifactName)) { + throw new Error("Bombadil run summary artifactName is invalid"); + } + if (typeof value.scenario !== "string" || !isBoundedScenarioIdentifier(value.scenario)) { + throw new Error("Bombadil run summary scenario is invalid"); + } + const failureCode = parseEvidenceFailureCode(value.failureCode, "Bombadil run summary failureCode"); + const status = requireEvidenceStatus(value.status, "Bombadil run summary status"); + requireFailureStatusConsistency(status, failureCode, "Bombadil run summary"); + let attestation = null; + if (value.attestation !== null) { + const raw = requireEvidenceRecord(value.attestation, RUN_SUMMARY_ATTESTATION_KEYS, "Bombadil run summary attestation"); + const observationCount = requireEvidenceInteger(raw.observationCount, "Bombadil run summary attestation.observationCount", TRACE_MAX_LINES); + const invalidObservationCount = requireEvidenceInteger(raw.invalidObservationCount, "Bombadil run summary attestation.invalidObservationCount", observationCount); + const validObservationCount = requireEvidenceInteger(raw.validObservationCount, "Bombadil run summary attestation.validObservationCount", observationCount); + if (invalidObservationCount + validObservationCount !== observationCount) { + throw new Error("Bombadil run summary attestation counts do not reconcile"); + } + if (observationCount === 0 || validObservationCount === 0) { + throw new Error("Bombadil run summary attestation must contain a valid observation"); + } + attestation = Object.freeze({ + invalidObservationCount, + observationCount, + validObservationCount + }); + } + let exploration = null; + if (value.exploration !== null) { + const raw = requireEvidenceRecord(value.exploration, RUN_SUMMARY_EXPLORATION_KEYS, "Bombadil run summary exploration"); + const traceLineCount = requireEvidenceInteger(raw.traceLineCount, "Bombadil run summary exploration.traceLineCount", TRACE_MAX_LINES); + const actionCount = requireEvidenceInteger(raw.actionCount, "Bombadil run summary exploration.actionCount", traceLineCount); + const nonWaitActionCount = requireEvidenceInteger(raw.nonWaitActionCount, "Bombadil run summary exploration.nonWaitActionCount", actionCount); + if (typeof raw.policySatisfied !== "boolean") { + throw new Error("Bombadil run summary exploration.policySatisfied must be boolean"); + } + exploration = Object.freeze({ + actionCount, + nonWaitActionCount, + policySatisfied: raw.policySatisfied, + traceBytes: requireEvidenceInteger(raw.traceBytes, "Bombadil run summary exploration.traceBytes", TRACE_MAX_BYTES), + traceLineCount, + traceSha256: requireEvidenceSha256(raw.traceSha256, "Bombadil run summary exploration.traceSha256") + }); + if (exploration.traceBytes === 0 || exploration.traceLineCount === 0) { + throw new Error("Bombadil run summary exploration trace must be nonempty"); + } + } + if (status === "passed" && (attestation === null || attestation.observationCount === 0 || attestation.validObservationCount === 0 || exploration === null || !exploration.policySatisfied || attestation.observationCount !== exploration.traceLineCount)) { + throw new Error("A passed Bombadil run summary requires attested policy-satisfying evidence"); + } + if (attestation !== null && exploration !== null && attestation.observationCount !== exploration.traceLineCount) { + throw new Error("Bombadil run summary trace counts do not reconcile"); + } + if (status === "rejected" && (attestation !== null || exploration !== null)) { + throw new Error("A rejected Bombadil run summary cannot claim trace evidence"); + } + if (failureCode === "configuration-rejected" && status !== "rejected") { + throw new Error("A configuration-rejected Bombadil run summary must be rejected"); + } + if (failureCode === "writer-settlement" && (attestation !== null || exploration !== null)) { + throw new Error("A writer-settlement Bombadil run summary cannot claim trace evidence"); + } + return Object.freeze({ + schema: ARTIFACT_SUMMARY_SCHEMA, + artifactName: value.artifactName, + attestation, + exploration, + failureCode, + scenario: value.scenario, + status + }); +} +function parseMatrixReceiptUnchecked(input) { + const value = requireEvidenceRecord(input, MATRIX_RECEIPT_KEYS, "Bombadil matrix receipt"); + if (value.schema !== MATRIX_RECEIPT_SCHEMA || value.mode !== "public-summary") { + throw new Error("Bombadil matrix receipt schema or mode is unsupported"); + } + const completedAt = requireEvidenceTimestamp(value.completedAt, "Bombadil matrix receipt completedAt"); + const failureCode = parseEvidenceFailureCode(value.failureCode, "Bombadil matrix receipt failureCode"); + if (value.status !== "failed" && value.status !== "passed") { + throw new Error("Bombadil matrix receipt status must be failed or passed"); + } + requireFailureStatusConsistency(value.status, failureCode, "Bombadil matrix receipt"); + if (typeof value.runId !== "string" || !UUID_PATTERN.test(value.runId)) { + throw new Error("Bombadil matrix receipt runId must be a lowercase RFC 4122 UUID"); + } + if (!Array.isArray(value.campaigns) || value.campaigns.length > MAX_MATRIX_CAMPAIGNS) { + throw new Error("Bombadil matrix receipt campaigns exceed the bounded matrix size"); + } + const campaignIds = new Set; + const campaigns = value.campaigns.map((inputCampaign, index) => { + const campaign = requireEvidenceRecord(inputCampaign, MATRIX_CAMPAIGN_RECEIPT_KEYS, `Bombadil matrix receipt campaign ${String(index)}`); + if (campaign.index !== index) { + throw new Error("Bombadil matrix receipt campaign indices must be ordered and contiguous"); + } + const campaignId = campaign.campaignId; + if (campaignId !== null && (typeof campaignId !== "string" || !isBoundedArtifactIdentifier(campaignId) || campaignIds.has(campaignId))) { + throw new Error("Bombadil matrix receipt campaign IDs must be unique bounded identifiers"); + } + if (campaignId !== null) + campaignIds.add(campaignId); + if (campaign.status !== "failed" && campaign.status !== "not-run" && campaign.status !== "not-selected" && campaign.status !== "passed" && campaign.status !== "rejected") { + throw new Error("Bombadil matrix receipt campaign status is unsupported"); + } + const expectedReceipt = campaignId === null ? null : `campaigns/${campaignId}/receipt.json`; + if (campaign.receipt !== null && (typeof campaign.receipt !== "string" || campaign.receipt !== expectedReceipt)) { + throw new Error("Bombadil matrix child receipt path is not canonical"); + } + if ((campaign.status === "not-run" || campaign.status === "not-selected") && campaign.receipt !== null || campaign.status === "passed" && campaign.receipt !== expectedReceipt || campaignId === null && (campaign.status !== "rejected" || campaign.receipt !== null)) { + throw new Error("Bombadil matrix child terminal state is inconsistent"); + } + return Object.freeze({ + campaignId, + index, + receipt: campaign.receipt, + status: campaign.status + }); + }); + const omittedCampaignCount = requireEvidenceInteger(value.omittedCampaignCount, "Bombadil matrix receipt omittedCampaignCount"); + if (value.status === "passed" && (omittedCampaignCount !== 0 || !campaigns.some((campaign) => campaign.status === "passed") || campaigns.some((campaign) => campaign.status === "failed" || campaign.status === "not-run" || campaign.status === "rejected"))) { + throw new Error("A passed Bombadil matrix receipt has a nonterminal child"); + } + return Object.freeze({ + schema: MATRIX_RECEIPT_SCHEMA, + campaigns: Object.freeze(campaigns), + completedAt, + failureCode, + mode: "public-summary", + omittedCampaignCount, + runId: value.runId, + status: value.status + }); +} +function parseMatrixSummaryUnchecked(input) { + const value = requireEvidenceRecord(input, MATRIX_SUMMARY_KEYS, "Bombadil matrix summary"); + if (value.schema !== MATRIX_SUMMARY_SCHEMA) { + throw new Error("Bombadil matrix summary schema is unsupported"); + } + const failureCode = parseEvidenceFailureCode(value.failureCode, "Bombadil matrix summary failureCode"); + if (value.status !== "failed" && value.status !== "passed") { + throw new Error("Bombadil matrix summary status must be failed or passed"); + } + requireFailureStatusConsistency(value.status, failureCode, "Bombadil matrix summary"); + const rawCampaigns = requireEvidenceRecord(value.campaigns, MATRIX_SUMMARY_CAMPAIGNS_KEYS, "Bombadil matrix summary campaigns"); + const total = requireEvidenceInteger(rawCampaigns.total, "Bombadil matrix summary campaigns.total", MAX_MATRIX_CAMPAIGNS); + const campaigns = Object.freeze({ + failed: requireEvidenceInteger(rawCampaigns.failed, "Bombadil matrix summary failed", total), + notRun: requireEvidenceInteger(rawCampaigns.notRun, "Bombadil matrix summary notRun", total), + notSelected: requireEvidenceInteger(rawCampaigns.notSelected, "Bombadil matrix summary notSelected", total), + omitted: requireEvidenceInteger(rawCampaigns.omitted, "Bombadil matrix summary omitted"), + passed: requireEvidenceInteger(rawCampaigns.passed, "Bombadil matrix summary passed", total), + rejected: requireEvidenceInteger(rawCampaigns.rejected, "Bombadil matrix summary rejected", total), + total + }); + if (campaigns.failed + campaigns.notRun + campaigns.notSelected + campaigns.passed + campaigns.rejected !== campaigns.total) { + throw new Error("Bombadil matrix summary campaign counts do not reconcile"); + } + if (value.status === "passed" && (campaigns.failed !== 0 || campaigns.notRun !== 0 || campaigns.rejected !== 0 || campaigns.omitted !== 0 || campaigns.passed === 0)) { + throw new Error("A passed Bombadil matrix summary contains unsuccessful campaigns"); + } + return Object.freeze({ + schema: MATRIX_SUMMARY_SCHEMA, + campaigns, + failureCode, + status: value.status + }); +} +function artifactEvidenceError(error) { + return Object.freeze({ + code: "invalid-bombadil-artifact-evidence", + message: renderUnknown(error) + }); +} +function cloneArtifactEvidence(input) { + const parsed = parseJsonValue(input, ARTIFACT_EVIDENCE_JSON_LIMITS); + if (!parsed.ok) { + throw new Error(`Bombadil artifact evidence is not bounded inert JSON: ${parsed.error.message}`); + } + return parsed.value; +} +function parseDirectBombadilArtifactReceipt(input) { + try { + return ok(parseArtifactReceiptUnchecked(cloneArtifactEvidence(input))); + } catch (error) { + return err(artifactEvidenceError(error)); + } +} +function parseDirectBombadilSanitizedRunSummary(input) { + try { + return ok(parseRunSummaryUnchecked(cloneArtifactEvidence(input))); + } catch (error) { + return err(artifactEvidenceError(error)); + } +} +function parseDirectBombadilMatrixReceipt(input) { + try { + return ok(parseMatrixReceiptUnchecked(cloneArtifactEvidence(input))); + } catch (error) { + return err(artifactEvidenceError(error)); + } +} +function parseDirectBombadilMatrixSummary(input) { + try { + return ok(parseMatrixSummaryUnchecked(cloneArtifactEvidence(input))); + } catch (error) { + return err(artifactEvidenceError(error)); + } +} +function resolveDirectBombadilUploadLeaf(input) { + const plan = validateArtifactRunPlan(input); + return join2(plan.repositoryRoot, "artifacts", "direct-bombadil-upload", plan.runId); +} +async function requireSafeDirectory(path, label) { + let metadata; + try { + metadata = await lstat(path); + } catch { + throw new BombadilArtifactPolicyError(`${label} does not exist`); + } + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new BombadilArtifactPolicyError(`${label} must be a non-symlink directory`); + } +} +async function ensureSafeDirectoryChain(repositoryRoot, parts) { + await requireSafeDirectory(repositoryRoot, "repositoryRoot"); + let current = repositoryRoot; + for (const part of parts) { + if (!ARTIFACT_PATH_PART_PATTERN.test(part) || part === "." || part === "..") { + throw new BombadilArtifactPolicyError("Artifact directory contains an unsafe path component"); + } + current = join2(current, part); + try { + await mkdir2(current, { mode: 448 }); + } catch (error) { + if (!isRecord2(error) || error.code !== "EEXIST") + throw error; + } + await requireSafeDirectory(current, `Artifact directory ${part}`); + const resolved = await realpath(current); + if (!isWithin(repositoryRoot, resolved) || resolved !== current) { + throw new BombadilArtifactPolicyError("Artifact directory escaped repositoryRoot"); + } + } + return current; +} +async function createExclusiveDirectory(path, label) { + try { + await mkdir2(path, { mode: 448 }); + } catch (error) { + if (isRecord2(error) && error.code === "EEXIST") { + throw new BombadilArtifactPolicyError(`${label} already exists`); + } + throw error; + } + await requireSafeDirectory(path, label); +} +async function createBombadilArtifactRun(options) { + if (!UUID_PATTERN.test(options.runId)) { + throw new BombadilArtifactPolicyError("Bombadil raw artifact run ID must be a UUID"); + } + const artifactRoot = await ensureSafeDirectoryChain(options.repositoryRoot, [ + "artifacts", + "direct-bombadil", + options.artifactName + ]); + const runDirectory = join2(artifactRoot, options.runId); + await createExclusiveDirectory(runDirectory, "Bombadil artifact run leaf"); + return { + artifactRoot, + manifestPath: join2(artifactRoot, "manifest.json"), + runDirectory + }; +} +async function prepareArtifactUploadSession(planInput) { + const plan = validateArtifactRunPlan(planInput); + let repositoryRoot; + try { + repositoryRoot = await realpath(plan.repositoryRoot); + } catch (error) { + if (!isRecord2(error) || error.code !== "ENOENT") { + throw new BombadilArtifactPolicyError(`artifactRun.repositoryRoot could not be proven safe: ${renderUnknown(error)}`); + } + repositoryRoot = null; + } + if (repositoryRoot === null || repositoryRoot !== plan.repositoryRoot) { + throw new BombadilArtifactPolicyError("artifactRun.repositoryRoot must resolve to its exact configured directory"); + } + const root = await ensureSafeDirectoryChain(repositoryRoot, [ + "artifacts", + "direct-bombadil-upload" + ]); + const finalDirectory = join2(root, plan.runId); + let finalMetadata; + try { + finalMetadata = await lstat(finalDirectory); + } catch (error) { + if (!isRecord2(error) || error.code !== "ENOENT") { + throw new BombadilArtifactPolicyError(`Bombadil upload run leaf could not be inspected: ${renderUnknown(error)}`); + } + finalMetadata = null; + } + if (finalMetadata !== null) { + throw new BombadilArtifactPolicyError("Bombadil upload run leaf already exists"); + } + const stagingDirectory = join2(root, `.staging-${plan.runId}`); + return { + finalDirectory, + mode: plan.uploadMode, + publication: "atomic-leaf", + receiptPath: join2(finalDirectory, "receipt.json"), + runId: plan.runId, + stagingDirectory + }; +} +async function requireArtifactUploadLeafAbsent(session2) { + let existing; + try { + existing = await lstat(session2.finalDirectory); + } catch (error) { + if (!isRecord2(error) || error.code !== "ENOENT") { + throw new BombadilArtifactPolicyError(`Bombadil upload run leaf could not be inspected: ${renderUnknown(error)}`); + } + existing = null; + } + if (existing !== null) { + throw new BombadilArtifactPolicyError("Bombadil upload run leaf appeared before publication"); + } +} +async function commitArtifactUploadSession(session2) { + await rename2(session2.stagingDirectory, session2.finalDirectory); +} +function validateArtifactRelativePath(relativePath, policy) { + const parts = relativePath.split("/"); + if (relativePath.length === 0 || relativePath.includes("\\") || Buffer.byteLength(relativePath, "utf8") > policy.maxPathBytes || parts.length > policy.maxDepth || parts.some((part) => part === "" || part === "." || part === ".." || part.startsWith(".") || !ARTIFACT_PATH_PART_PATTERN.test(part))) { + throw new BombadilArtifactPolicyError(`Bombadil emitted unsafe artifact path ${relativePath}`); + } + return parts; +} +function artifactOutputFileIsAllowed(relativePath) { + return relativePath === "trace.jsonl" || PRIVATE_DIAGNOSTIC_EXTENSIONS.has(extname(relativePath).toLowerCase()); +} +function sameBigIntFileMetadata(left, right) { + return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.ctimeNs === right.ctimeNs && left.mtimeNs === right.mtimeNs; +} +async function withClosedArtifactHandle(handle, operation) { + let value; + let operationFailure = null; + try { + value = await operation(); + } catch (error) { + operationFailure = error; + } + let closeFailure = null; + try { + await handle.close(); + } catch (error) { + closeFailure = error; + } + if (operationFailure !== null) { + if (closeFailure !== null) { + throw new AggregateError([operationFailure, closeFailure], "Bombadil artifact operation and descriptor cleanup both failed", { cause: operationFailure }); + } + throw operationFailure; + } + if (closeFailure !== null) + throw closeFailure; + return value; +} +async function hashBoundRegularFile(options) { + const flags = fileSystemConstants.O_RDONLY | fileSystemConstants.O_NOFOLLOW | fileSystemConstants.O_NONBLOCK; + const handle = await open(options.path, flags); + return await withClosedArtifactHandle(handle, async () => { + const before = await handle.stat({ bigint: true }); + if (!before.isFile() || before.nlink !== 1n || !options.expected.isFile() || options.expected.nlink !== 1n || !sameBigIntFileMetadata(before, options.expected)) { + throw new BombadilArtifactPolicyError(`Bombadil artifact ${options.relativePath} changed identity before inspection`); + } + const size = Number(before.size); + if (!Number.isSafeInteger(size) || size > options.policy.maxFileBytes) { + throw new BombadilArtifactPolicyError(`Bombadil artifact ${options.relativePath} exceeds the per-file byte quota`); + } + const hash = createHash("sha256"); + const buffer = Buffer.allocUnsafe(64 * 1024); + let offset = 0; + while (offset < size) { + const length = Math.min(buffer.length, size - offset); + const read = await handle.read(buffer, 0, length, offset); + if (read.bytesRead === 0) { + throw new BombadilArtifactPolicyError(`Bombadil artifact ${options.relativePath} changed while inspected`); + } + hash.update(buffer.subarray(0, read.bytesRead)); + offset += read.bytesRead; + } + const after = await handle.stat({ bigint: true }); + if (!sameBigIntFileMetadata(before, after)) { + throw new BombadilArtifactPolicyError(`Bombadil artifact ${options.relativePath} changed while inspected`); + } + return { + device: before.dev, + inode: before.ino, + relativePath: options.relativePath, + sha256: hash.digest("hex"), + size + }; + }); +} +async function readBoundRegularFileBytes(options) { + const flags = fileSystemConstants.O_RDONLY | fileSystemConstants.O_NOFOLLOW | fileSystemConstants.O_NONBLOCK; + let handle; + try { + handle = await open(options.path, flags); + } catch { + throw new BombadilArtifactPolicyError(`${options.label} is not an openable regular file`); + } + try { + return await withClosedArtifactHandle(handle, async () => { + const before = await handle.stat({ bigint: true }); + const size = Number(before.size); + if (!before.isFile() || before.nlink !== 1n || !Number.isSafeInteger(size) || size < 1 || size > options.maximumBytes) { + throw new BombadilArtifactPolicyError(`${options.label} is not a bounded regular file`); + } + if (options.expected !== undefined && (before.dev !== options.expected.device || before.ino !== options.expected.inode || size !== options.expected.size)) { + throw new BombadilArtifactPolicyError(`${options.label} changed after inventory`); + } + const bytes = Buffer.allocUnsafe(size); + let offset = 0; + while (offset < size) { + const read = await handle.read(bytes, offset, size - offset, offset); + if (read.bytesRead === 0) { + throw new BombadilArtifactPolicyError(`${options.label} changed while being read`); + } + offset += read.bytesRead; + } + const after = await handle.stat({ bigint: true }); + if (!sameBigIntFileMetadata(before, after)) { + throw new BombadilArtifactPolicyError(`${options.label} changed while being read`); + } + if (options.expected !== undefined && sha256(bytes) !== options.expected.sha256) { + throw new BombadilArtifactPolicyError(`${options.label} hash changed after inventory`); + } + return bytes; + }); + } catch (error) { + throw error instanceof BombadilArtifactPolicyError ? error : new BombadilArtifactPolicyError(`${options.label} could not be read safely: ${renderUnknown(error)}`); + } +} +function decodeTraceLines(bytes) { + let text; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new Error("Bombadil trace is not valid UTF-8"); + } + const lines = text.split(/\r?\n/u); + if (lines.at(-1) === "") + lines.pop(); + return lines; +} +async function scanBombadilArtifactTree(options) { + let rootMetadata; + try { + rootMetadata = await lstat(options.root, { bigint: true }); + } catch (error) { + if (!isRecord2(error) || error.code !== "ENOENT") { + throw new BombadilArtifactPolicyError(`Bombadil output root could not be inspected: ${renderUnknown(error)}`); + } + rootMetadata = null; + } + if (rootMetadata === null) { + if (options.rootMayBeAbsent === true) { + return { + directories: Object.freeze([]), + entryCount: 0, + files: Object.freeze([]), + fileCount: 0, + inventorySha256: sha256(""), + totalBytes: 0 + }; + } + throw new BombadilArtifactPolicyError("Bombadil output directory does not exist"); + } + if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) { + throw new BombadilArtifactPolicyError("Bombadil output root must be a non-symlink directory"); + } + const directories = []; + const files = []; + let entryCount = 0; + let totalBytes = 0; + const pending = [{ + absolutePath: options.root, + relativePath: "" + }]; + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined) + continue; + await options.beforeDirectoryOpen?.(current.absolutePath); + const directory = await opendir(current.absolutePath).catch((error) => { + if (options.allowTransientEntryAbsence === true && isRecord2(error) && error.code === "ENOENT") { + throw error; + } + throw new BombadilArtifactPolicyError(`Bombadil artifact directory could not be opened safely: ${renderUnknown(error)}`); + }); + try { + await withClosedArtifactHandle(directory, async () => { + while (true) { + const entry = await directory.read(); + if (entry === null) + break; + const relativePath = current.relativePath === "" ? entry.name : `${current.relativePath}/${entry.name}`; + validateArtifactRelativePath(relativePath, options.policy); + entryCount += 1; + if (entryCount > options.policy.maxEntries) { + throw new BombadilArtifactPolicyError("Bombadil artifact entry quota was exceeded"); + } + const absolutePath = join2(current.absolutePath, entry.name); + await options.beforeEntryInspect?.(absolutePath); + const metadata = await lstat(absolutePath, { bigint: true }); + if (metadata.isSymbolicLink()) { + throw new BombadilArtifactPolicyError(`Bombadil emitted a symbolic link at ${relativePath}`); + } + if (metadata.isDirectory()) { + directories.push(relativePath); + pending.push({ absolutePath, relativePath }); + continue; + } + if (!metadata.isFile() || metadata.nlink !== 1n) { + throw new BombadilArtifactPolicyError(`Bombadil emitted a non-regular or multiply-linked file at ${relativePath}`); + } + if (!artifactOutputFileIsAllowed(relativePath)) { + throw new BombadilArtifactPolicyError(`Bombadil emitted a file outside the artifact allowlist at ${relativePath}`); + } + if (files.length + 1 > options.policy.maxFiles) { + throw new BombadilArtifactPolicyError("Bombadil artifact file quota was exceeded"); + } + const fileSize = Number(metadata.size); + if (!Number.isSafeInteger(fileSize) || fileSize > options.policy.maxFileBytes) { + throw new BombadilArtifactPolicyError(`Bombadil artifact ${relativePath} exceeds the per-file byte quota`); + } + totalBytes += fileSize; + if (!Number.isSafeInteger(totalBytes) || totalBytes > options.policy.maxTotalBytes) { + throw new BombadilArtifactPolicyError("Bombadil aggregate artifact byte quota was exceeded"); + } + files.push(options.hashFiles ? await hashBoundRegularFile({ + expected: metadata, + path: absolutePath, + policy: options.policy, + relativePath + }) : { + device: 0n, + inode: 0n, + relativePath, + sha256: "", + size: fileSize + }); + } + }); + } catch (error) { + if (options.allowTransientEntryAbsence === true && isRecord2(error) && error.code === "ENOENT") { + throw error; + } + throw error instanceof BombadilArtifactPolicyError ? error : new BombadilArtifactPolicyError(`Bombadil artifact directory could not be inspected safely: ${renderUnknown(error)}`); + } + } + let finalRootMetadata; + try { + finalRootMetadata = await lstat(options.root, { bigint: true }); + } catch (error) { + throw new BombadilArtifactPolicyError(`Bombadil output root could not be revalidated: ${renderUnknown(error)}`); + } + if (!finalRootMetadata.isDirectory() || finalRootMetadata.isSymbolicLink() || finalRootMetadata.dev !== rootMetadata.dev || finalRootMetadata.ino !== rootMetadata.ino) { + throw new BombadilArtifactPolicyError("Bombadil output root changed during inspection"); + } + directories.sort(compareCodeUnits); + files.sort((left, right) => compareCodeUnits(left.relativePath, right.relativePath)); + const inventorySha256 = sha256([ + ...directories.map((directory) => `D\x00${directory} +`), + ...files.map((file) => `F\x00${file.relativePath}\x00${String(file.size)}\x00${file.sha256} +`) + ].join("")); + return { + directories: Object.freeze(directories), + entryCount, + files: Object.freeze(files), + fileCount: files.length, + inventorySha256, + totalBytes + }; +} +async function ensureSafeChildDirectories(root, parts) { + await requireSafeDirectory(root, "Bombadil upload staging root"); + let current = root; + for (const part of parts) { + if (!ARTIFACT_PATH_PART_PATTERN.test(part) || part.startsWith(".")) { + throw new BombadilArtifactPolicyError("Bombadil upload path contains an unsafe component"); + } + current = join2(current, part); + try { + await mkdir2(current, { mode: 448 }); + } catch (error) { + if (!isRecord2(error) || error.code !== "EEXIST") + throw error; + } + await requireSafeDirectory(current, "Bombadil upload directory"); + const resolved = await realpath(current); + if (!isWithin(root, resolved) || resolved !== current) { + throw new BombadilArtifactPolicyError("Bombadil upload directory escaped staging root"); + } + } + return current; +} +async function writeExclusiveBytes(path, bytes) { + const flags = fileSystemConstants.O_WRONLY | fileSystemConstants.O_CREAT | fileSystemConstants.O_EXCL | fileSystemConstants.O_NOFOLLOW; + const handle = await open(path, flags, 384); + await withClosedArtifactHandle(handle, async () => { + let offset = 0; + while (offset < bytes.byteLength) { + const written = await handle.write(bytes, offset, bytes.byteLength - offset, offset); + if (written.bytesWritten === 0) + throw new Error("Exclusive artifact write made no progress"); + offset += written.bytesWritten; + } + await handle.sync(); + }); +} +async function writeExpectedJson(root, relativePath, value) { + const parts = relativePath.split("/"); + const fileName = parts.pop(); + if (fileName === undefined || !ARTIFACT_PATH_PART_PATTERN.test(fileName)) { + throw new BombadilArtifactPolicyError("Sanitized upload path is invalid"); + } + const directory = await ensureSafeChildDirectories(root, parts); + const bytes = Buffer.from(`${JSON.stringify(value, null, 2)} +`, "utf8"); + await writeExclusiveBytes(join2(directory, fileName), bytes); + return { + relativePath, + sha256: sha256(bytes), + size: bytes.byteLength + }; +} +function expectedUploadDirectories(files) { + const directories = new Set; + for (const file of files) { + const parts = file.relativePath.split("/"); + parts.pop(); + for (let index = 1;index <= parts.length; index += 1) { + directories.add(parts.slice(0, index).join("/")); + } + } + return Object.freeze([...directories].sort(compareCodeUnits)); +} +async function validateExpectedUploadTree(root, expectedInput) { + const expected = [...expectedInput].sort((left, right) => compareCodeUnits(left.relativePath, right.relativePath)); + if (new Set(expected.map((file) => file.relativePath)).size !== expected.length) { + throw new BombadilArtifactPolicyError("Sanitized upload contains duplicate file paths"); + } + const directories = expectedUploadDirectories(expected); + const maximumPathBytes = Math.max(1, ...expected.map((file) => Buffer.byteLength(file.relativePath, "utf8"))); + const inventory = await scanBombadilArtifactTree({ + hashFiles: true, + policy: { + maxDepth: Math.max(1, ...expected.map((file) => file.relativePath.split("/").length)), + maxEntries: Math.max(1, expected.length + directories.length), + maxFileBytes: Math.max(1, ...expected.map((file) => file.size)), + maxFiles: Math.max(1, expected.length), + maxPathBytes: maximumPathBytes, + maxTotalBytes: Math.max(1, expected.reduce((total, file) => total + file.size, 0)) + }, + root + }); + if (inventory.directories.length !== directories.length || inventory.directories.some((directory, index) => directory !== directories[index]) || inventory.files.length !== expected.length || inventory.files.some((file, index) => { + const wanted = expected[index]; + return wanted === undefined || file.relativePath !== wanted.relativePath || file.sha256 !== wanted.sha256 || file.size !== wanted.size; + })) { + throw new BombadilArtifactPolicyError("Sanitized upload tree differs from its exact expected inventory"); + } +} +async function copyVerifiedArtifactFile(options) { + const parts = options.file.relativePath.split("/"); + const fileName = parts.pop(); + if (fileName === undefined) + throw new BombadilArtifactPolicyError("Artifact copy path is empty"); + const destinationDirectory = await ensureSafeChildDirectories(options.destinationRoot, parts); + const destinationPath = join2(destinationDirectory, fileName); + const sourcePath = join2(options.sourceRoot, ...options.file.relativePath.split("/")); + const sourceFlags = fileSystemConstants.O_RDONLY | fileSystemConstants.O_NOFOLLOW | fileSystemConstants.O_NONBLOCK; + const destinationFlags = fileSystemConstants.O_WRONLY | fileSystemConstants.O_CREAT | fileSystemConstants.O_EXCL | fileSystemConstants.O_NOFOLLOW; + const source = await open(sourcePath, sourceFlags); + let destination = null; + let copyFailure = null; + try { + const before = await source.stat({ bigint: true }); + if (!before.isFile() || before.nlink !== 1n || before.dev !== options.file.device || before.ino !== options.file.inode || Number(before.size) !== options.file.size) { + throw new BombadilArtifactPolicyError(`Bombadil artifact ${options.file.relativePath} changed before private copy`); + } + destination = await open(destinationPath, destinationFlags, 384); + const hash = createHash("sha256"); + const buffer = Buffer.allocUnsafe(64 * 1024); + let offset = 0; + while (offset < options.file.size) { + const read = await source.read(buffer, 0, Math.min(buffer.length, options.file.size - offset), offset); + if (read.bytesRead === 0) { + throw new BombadilArtifactPolicyError(`Bombadil artifact ${options.file.relativePath} changed during private copy`); + } + hash.update(buffer.subarray(0, read.bytesRead)); + let writtenOffset = 0; + while (writtenOffset < read.bytesRead) { + const written = await destination.write(buffer, writtenOffset, read.bytesRead - writtenOffset, offset + writtenOffset); + if (written.bytesWritten === 0) + throw new Error("Private artifact copy made no progress"); + writtenOffset += written.bytesWritten; + } + offset += read.bytesRead; + } + await destination.sync(); + const after = await source.stat({ bigint: true }); + if (!sameBigIntFileMetadata(before, after) || hash.digest("hex") !== options.file.sha256) { + throw new BombadilArtifactPolicyError(`Bombadil artifact ${options.file.relativePath} changed during private copy`); + } + } catch (error) { + copyFailure = error; + await rm2(destinationPath, { force: true }).catch(() => { + return; + }); + } + let closeFailure = null; + try { + await closeBombadilArtifactCopyHandles(destination, source); + } catch (error) { + closeFailure = error; + } + if (copyFailure !== null) { + if (closeFailure !== null) { + throw new AggregateError([copyFailure, closeFailure], "Bombadil artifact copy and descriptor cleanup both failed", { cause: copyFailure }); + } + throw copyFailure; + } + if (closeFailure !== null) + throw closeFailure; +} +async function closeBombadilArtifactCopyHandles(destination, source) { + const failures = []; + if (destination !== null) { + try { + await destination.close(); + } catch (error) { + failures.push(error); + } + } + try { + await source.close(); + } catch (error) { + failures.push(error); + } + if (failures.length === 1) + throw failures[0]; + if (failures.length > 1) { + throw new AggregateError(failures, "Both Bombadil artifact copy descriptors failed to close"); + } +} +function emptyArtifactInventory() { + return { + directories: Object.freeze([]), + entryCount: 0, + files: Object.freeze([]), + fileCount: 0, + inventorySha256: sha256(""), + totalBytes: 0 + }; +} +function artifactFailureCode(error) { + if (error instanceof BombadilPersistenceError) + return "persistence"; + if (error instanceof BombadilWriterSettlementError) + return "writer-settlement"; + if (error instanceof BombadilArtifactPolicyError) + return "artifact-policy"; + const message = renderUnknown(error); + if (message.includes("interrupted") || message.includes("SIGINT") || message.includes("SIGTERM")) { + return "interrupted"; + } + if (message.includes("exploration policy")) + return "exploration-policy"; + if (message.includes("trace") || message.includes("Direct contract")) + return "trace-attestation"; + if (message.includes("server") || message.includes("reachable")) + return "server"; + if (message.includes("Bombadil")) + return "process"; + return "unknown"; +} +function failureAsError(error) { + return error instanceof Error ? error : new Error(renderUnknown(error)); +} +function combinePersistenceFailure(primary, persistence, message = "Bombadil persistence also failed") { + return new BombadilPersistenceError(`${renderUnknown(primary)}; ${message}`, [primary, persistence]); +} +async function publishFailureAndThrow(primary, publish) { + try { + await publish(); + } catch (persistence) { + throw combinePersistenceFailure(primary, persistence, "sanitized Bombadil receipt publication also failed"); + } + throw failureAsError(primary); +} +function createArtifactReceipt(options) { + return Object.freeze({ + schema: ARTIFACT_RECEIPT_SCHEMA, + completedAt: options.completedAt.toISOString(), + diagnosticsRetained: options.diagnosticsRetained, + failureCode: options.failureCode, + inventory: Object.freeze({ + entryCount: options.inventory.entryCount, + fileCount: options.inventory.fileCount, + inventorySha256: options.inventory.entryCount === 0 ? null : options.inventory.inventorySha256, + totalBytes: options.inventory.totalBytes + }), + mode: options.session.mode, + policy: options.policy, + runId: options.session.runId, + status: options.status + }); +} +function createSanitizedRunSummary(options) { + return Object.freeze({ + schema: ARTIFACT_SUMMARY_SCHEMA, + artifactName: options.artifactName, + scenario: options.scenario, + status: options.status, + failureCode: options.failureCode, + attestation: options.attestation === null ? null : Object.freeze({ + invalidObservationCount: options.attestation.invalidObservationCount, + observationCount: options.attestation.observationCount, + validObservationCount: options.attestation.validObservationCount + }), + exploration: options.explorationSummary === null ? null : Object.freeze({ + actionCount: options.explorationSummary.actions.total, + nonWaitActionCount: options.explorationSummary.actions.nonWaitCount, + policySatisfied: options.explorationSummary.policy.satisfied, + traceBytes: options.explorationSummary.trace.bytes, + traceLineCount: options.explorationSummary.trace.lineCount, + traceSha256: options.explorationSummary.trace.sha256 + }) + }); +} +async function resetUploadStaging(session2) { + await rm2(session2.stagingDirectory, { force: true, recursive: true }); + await createExclusiveDirectory(session2.stagingDirectory, "Bombadil upload staging leaf"); +} +async function withOwnedUploadStaging(session2, operation) { + await createExclusiveDirectory(session2.stagingDirectory, "Bombadil upload staging leaf"); + try { + return await operation(); + } catch (error) { + try { + await rm2(session2.stagingDirectory, { force: true, recursive: true }); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], "Bombadil upload staging operation and cleanup both failed", { cause: error }); + } + throw error; + } +} +async function publishRunUpload(options) { + let failure = options.failure; + let failureCode = failure === null ? null : options.failureCode ?? artifactFailureCode(failure); + let status = options.status; + const observeInterruption = () => { + if (failure !== null || options.abortSignal?.aborted !== true) + return false; + const signal = options.interruptedSignal?.() ?? null; + failure = new Error(signal === null ? "Bombadil fuzzing was interrupted" : `Bombadil fuzzing was interrupted by ${signal}`); + failureCode = "interrupted"; + status = "failed"; + return true; + }; + observeInterruption(); + if (options.session.publication === "deferred" && options.session.mode !== "public-summary") { + throw new BombadilArtifactPolicyError("Bombadil matrices support public-summary uploads only"); + } + if (options.session.publication === "deferred") { + const receipt = createArtifactReceipt({ + completedAt: options.completedAt, + diagnosticsRetained: false, + failureCode, + inventory: options.inventory, + policy: options.policy, + session: options.session, + status + }); + const summary = createSanitizedRunSummary({ + artifactName: options.artifactName, + attestation: options.attestation, + explorationSummary: options.explorationSummary, + failureCode, + scenario: options.scenario, + status + }); + if (options.session.deferredPayload.value !== null) { + throw new BombadilArtifactPolicyError("Bombadil deferred upload state is invalid"); + } + options.session.deferredPayload.value = Object.freeze({ receipt, summary }); + return { failure, receipt }; + } + const session2 = options.session; + return await withOwnedUploadStaging(session2, async () => { + const expectedFiles = []; + let diagnosticsRetained = false; + if (session2.mode === "private-vetted" && options.privateDiagnosticsAllowed && failureCode !== "interrupted") { + try { + const diagnosticsRoot = await ensureSafeChildDirectories(session2.stagingDirectory, ["diagnostics", "bombadil-output"]); + for (const file of options.inventory.files) { + await copyVerifiedArtifactFile({ + destinationRoot: diagnosticsRoot, + file, + sourceRoot: options.localOutputPath + }); + expectedFiles.push({ + relativePath: `diagnostics/bombadil-output/${file.relativePath}`, + sha256: file.sha256, + size: file.size + }); + } + const controlledLogs = await ensureSafeChildDirectories(session2.stagingDirectory, ["diagnostics", "host"]); + const processLogBytes = Buffer.from(options.processLog, "utf8"); + const serverLogBytes = Buffer.from(options.serverLog, "utf8"); + await writeExclusiveBytes(join2(controlledLogs, "bombadil.log"), processLogBytes); + await writeExclusiveBytes(join2(controlledLogs, "server.log"), serverLogBytes); + expectedFiles.push({ + relativePath: "diagnostics/host/bombadil.log", + sha256: sha256(processLogBytes), + size: processLogBytes.byteLength + }, { + relativePath: "diagnostics/host/server.log", + sha256: sha256(serverLogBytes), + size: serverLogBytes.byteLength + }); + diagnosticsRetained = true; + } catch (error) { + const persistence = new BombadilPersistenceError("Bombadil private diagnostics could not be persisted", [error]); + failure = failure === null ? persistence : combinePersistenceFailure(failure, persistence); + failureCode = "persistence"; + status = "failed"; + await resetUploadStaging(session2); + expectedFiles.length = 0; + } + } + const stageSanitizedPayload = async () => { + const receipt2 = createArtifactReceipt({ + completedAt: options.completedAt, + diagnosticsRetained, + failureCode, + inventory: options.inventory, + policy: options.policy, + session: session2, + status + }); + const summary = createSanitizedRunSummary({ + artifactName: options.artifactName, + attestation: options.attestation, + explorationSummary: options.explorationSummary, + failureCode, + scenario: options.scenario, + status + }); + expectedFiles.push(await writeExpectedJson(session2.stagingDirectory, "summary.json", summary), await writeExpectedJson(session2.stagingDirectory, "receipt.json", receipt2)); + await validateExpectedUploadTree(session2.stagingDirectory, expectedFiles); + return receipt2; + }; + let receipt = await stageSanitizedPayload(); + await options.beforeCommitCheck?.(); + await requireArtifactUploadLeafAbsent(session2); + if (observeInterruption()) { + diagnosticsRetained = false; + await resetUploadStaging(session2); + expectedFiles.length = 0; + receipt = await stageSanitizedPayload(); + await requireArtifactUploadLeafAbsent(session2); + } + await commitArtifactUploadSession(session2); + return { failure, receipt }; + }); +} +async function publishMatrixUpload(options) { + const uploadMode = options.session.mode; + if (uploadMode !== "public-summary") { + throw new BombadilArtifactPolicyError("Bombadil matrix upload session must be public-summary"); + } + let failure = options.failure; + let failureCode = failure === null ? null : options.failureCode ?? artifactFailureCode(failure); + let status = failure === null ? "passed" : "failed"; + const observeInterruption = () => { + if (failure !== null || options.abortSignal?.aborted !== true) + return false; + const signal = options.interruptedSignal?.() ?? null; + failure = new Error(signal === null ? "Bombadil matrix was interrupted" : `Bombadil matrix was interrupted by ${signal}`); + failureCode = "interrupted"; + status = "failed"; + return true; + }; + observeInterruption(); + return await withOwnedUploadStaging(options.session, async () => { + const counts = new Map; + for (const campaign of options.campaigns) { + counts.set(campaign.status, (counts.get(campaign.status) ?? 0) + 1); + } + const expectedFiles = []; + const stageMatrixPayload = async () => { + const receipt = Object.freeze({ + schema: MATRIX_RECEIPT_SCHEMA, + completedAt: options.completedAt.toISOString(), + failureCode, + mode: uploadMode, + runId: options.session.runId, + status, + omittedCampaignCount: options.omittedCampaignCount ?? 0, + campaigns: Object.freeze(options.campaigns.map((campaign) => Object.freeze(campaign))) + }); + const summary = Object.freeze({ + schema: MATRIX_SUMMARY_SCHEMA, + failureCode, + status, + campaigns: Object.freeze({ + failed: counts.get("failed") ?? 0, + notRun: counts.get("not-run") ?? 0, + notSelected: counts.get("not-selected") ?? 0, + passed: counts.get("passed") ?? 0, + rejected: counts.get("rejected") ?? 0, + total: options.campaigns.length, + omitted: options.omittedCampaignCount ?? 0 + }) + }); + for (const child of options.children) { + expectedFiles.push(await writeExpectedJson(options.session.stagingDirectory, `campaigns/${child.campaignId}/summary.json`, child.payload.summary), await writeExpectedJson(options.session.stagingDirectory, `campaigns/${child.campaignId}/receipt.json`, child.payload.receipt)); + } + expectedFiles.push(await writeExpectedJson(options.session.stagingDirectory, "summary.json", summary), await writeExpectedJson(options.session.stagingDirectory, "receipt.json", receipt)); + await validateExpectedUploadTree(options.session.stagingDirectory, expectedFiles); + }; + await stageMatrixPayload(); + await options.beforeCommitCheck?.(); + await requireArtifactUploadLeafAbsent(options.session); + if (observeInterruption()) { + await resetUploadStaging(options.session); + expectedFiles.length = 0; + await stageMatrixPayload(); + await requireArtifactUploadLeafAbsent(options.session); + } + await commitArtifactUploadSession(options.session); + return { failure }; + }); +} function parseTraceDirectObservation(value) { if (!isRecord2(value) || !hasExactKeys(value, DIRECT_OBSERVATION_KEYS)) { throw new Error("Bombadil trace has an invalid named direct observation"); @@ -1727,71 +3186,66 @@ function parseTraceLine(line, lineNumber, strictDiagnosticSnapshotNames) { }; } async function attestDirectBombadilTrace(options) { - const metadata = await stat(options.tracePath).catch(() => null); - if (metadata === null || !metadata.isFile() || metadata.size === 0) { - throw new Error("Bombadil did not produce a nonempty trace.jsonl"); - } - if (metadata.size > TRACE_MAX_BYTES) { - throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_BYTES)} bytes`); - } - const stream = createReadStream(options.tracePath, { encoding: "utf8" }); - const lines = createInterface({ input: stream, crlfDelay: Infinity }); + const traceBytes = await readBoundRegularFileBytes({ + label: "Bombadil trace.jsonl", + maximumBytes: TRACE_MAX_BYTES, + path: options.tracePath + }); + return attestDirectBombadilTraceBytes({ ...options, traceBytes }); +} +function attestDirectBombadilTraceBytes(options) { + const lines = decodeTraceLines(options.traceBytes); let observationCount = 0; let invalidObservationCount = 0; let validObservationCount = 0; let initial = null; let final = null; let finalWasInvalid = false; - try { - for await (const line of lines) { - observationCount += 1; - if (observationCount > TRACE_MAX_LINES) { - throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_LINES)} lines`); - } - if (Buffer.byteLength(line, "utf8") > TRACE_MAX_LINE_BYTES) { - throw new Error(`Bombadil trace line ${String(observationCount)} is too large`); - } - const observation = parseDirectTraceLine(line, observationCount); - const exact = exactTraceDirectObservation(observation); - if (exact === null) { - if (initial !== null) { - throw new Error("Bombadil trace lost the Direct bridge after exact activation"); - } - invalidObservationCount += 1; - finalWasInvalid = true; - continue; - } - validObservationCount += 1; - final = exact; - finalWasInvalid = false; - if (initial === null) { - if (exact.source !== "scenario" || exact.scenario !== options.expectedScenario || exact.route !== options.expectedRoute) { - throw new Error("Bombadil trace first valid Direct activation does not match the requested scenario and route"); - } - initial = { - activationHash: exact.activationHash, - catalogHash: exact.catalogHash, - route: exact.route, - scenario: exact.scenario, - source: exact.source - }; - } - if (exact.source !== "scenario") { - throw new Error("Bombadil trace left scenario activation during the run"); - } - if (exact.scenario !== initial.scenario || exact.route !== initial.route || exact.activationHash !== initial.activationHash) { - throw new Error("Bombadil trace Direct activation changed during the run"); - } - if (exact.catalogHash !== initial.catalogHash) { - throw new Error("Bombadil trace Direct catalog changed during the run"); + for (const line of lines) { + observationCount += 1; + if (observationCount > TRACE_MAX_LINES) { + throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_LINES)} lines`); + } + if (Buffer.byteLength(line, "utf8") > TRACE_MAX_LINE_BYTES) { + throw new Error(`Bombadil trace line ${String(observationCount)} is too large`); + } + const observation = parseDirectTraceLine(line, observationCount); + const exact = exactTraceDirectObservation(observation); + if (exact === null) { + if (initial !== null) { + throw new Error("Bombadil trace lost the Direct bridge after exact activation"); } - if (observation.violations.some((value) => value !== 0)) { - throw new Error("Bombadil trace contains a nonzero Direct violation counter"); + invalidObservationCount += 1; + finalWasInvalid = true; + continue; + } + validObservationCount += 1; + final = exact; + finalWasInvalid = false; + if (initial === null) { + if (exact.source !== "scenario" || exact.scenario !== options.expectedScenario || exact.route !== options.expectedRoute) { + throw new Error("Bombadil trace first valid Direct activation does not match the requested scenario and route"); } + initial = { + activationHash: exact.activationHash, + catalogHash: exact.catalogHash, + route: exact.route, + scenario: exact.scenario, + source: exact.source + }; + } + if (exact.source !== "scenario") { + throw new Error("Bombadil trace left scenario activation during the run"); + } + if (exact.scenario !== initial.scenario || exact.route !== initial.route || exact.activationHash !== initial.activationHash) { + throw new Error("Bombadil trace Direct activation changed during the run"); + } + if (exact.catalogHash !== initial.catalogHash) { + throw new Error("Bombadil trace Direct catalog changed during the run"); + } + if (observation.violations.some((value) => value !== 0)) { + throw new Error("Bombadil trace contains a nonzero Direct violation counter"); } - } finally { - lines.close(); - stream.destroy(); } if (initial === null || final === null) { throw new Error("Bombadil trace never reached a valid Direct contract"); @@ -1823,13 +3277,14 @@ function sortedCountRecord(values) { return Object.freeze(Object.fromEntries([...values.entries()].sort(([left], [right]) => compareCodeUnits(left, right)))); } async function summarizeDirectBombadilTrace(options) { - const metadata = await stat(options.tracePath).catch(() => null); - if (metadata === null || !metadata.isFile() || metadata.size === 0) { - throw new Error("Bombadil did not produce a nonempty trace.jsonl"); - } - if (metadata.size > TRACE_MAX_BYTES) { - throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_BYTES)} bytes`); - } + const traceBytes = await readBoundRegularFileBytes({ + label: "Bombadil trace.jsonl", + maximumBytes: TRACE_MAX_BYTES, + path: options.tracePath + }); + return summarizeDirectBombadilTraceBytes({ ...options, traceBytes }); +} +function summarizeDirectBombadilTraceBytes(options) { let targetUrl; try { targetUrl = new URL(options.targetUrl); @@ -1869,118 +3324,112 @@ async function summarizeDirectBombadilTrace(options) { let stableTarget = true; let trackedUnrelatedSnapshotNameCount = 0; const unrelatedSnapshotNameLimit = Math.max(0, TRACE_MAX_NAMED_SNAPSHOT_NAMES - strictDiagnosticSnapshotNames.size); - const stream = createReadStream(options.tracePath, { encoding: "utf8" }); - const lines = createInterface({ input: stream, crlfDelay: Infinity }); - try { - for await (const line of lines) { - lineCount += 1; - if (lineCount > TRACE_MAX_LINES) { - throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_LINES)} lines`); - } - if (Buffer.byteLength(line, "utf8") > TRACE_MAX_LINE_BYTES) { - throw new Error(`Bombadil trace line ${String(lineCount)} is too large`); - } - const parsed = parseTraceLine(line, lineCount, strictDiagnosticSnapshotNames); - const rawRelativeUrl = `${parsed.state.url.pathname}${parsed.state.url.search}${parsed.state.url.hash}`; - rawUrlFingerprints.add(sha256(rawRelativeUrl)); - if (rawUrlFingerprints.size > TRACE_MAX_DISTINCT_URLS) { - throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_DISTINCT_URLS)} distinct raw URL fingerprints`); - } - if (parsed.state.currentHash !== null) { - rawNonNullHashCount += 1; - rawTransitionHashes.add(String(parsed.state.currentHash)); - } - for (const name of parsed.propertyViolationNames) { - if (!propertyViolations.has(name) && propertyViolations.size >= TRACE_MAX_PROPERTY_NAMES) { - throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_PROPERTY_NAMES)} property names`); - } - propertyViolations.set(name, (propertyViolations.get(name) ?? 0) + 1); - } - for (const [sourceName, outputName] of Object.entries(RESOURCE_FIELD_MAP)) { - resources[outputName] = Math.max(resources[outputName], parsed.state.resources[sourceName]); - } - const currentObservationIsExact = exactTraceDirectObservation(parsed.directObservation) !== null; - if (!currentObservationIsExact) { - previousObservationWasExact = false; - continue; + const lines = decodeTraceLines(options.traceBytes); + for (const line of lines) { + lineCount += 1; + if (lineCount > TRACE_MAX_LINES) { + throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_LINES)} lines`); + } + if (Buffer.byteLength(line, "utf8") > TRACE_MAX_LINE_BYTES) { + throw new Error(`Bombadil trace line ${String(lineCount)} is too large`); + } + const parsed = parseTraceLine(line, lineCount, strictDiagnosticSnapshotNames); + const rawRelativeUrl = `${parsed.state.url.pathname}${parsed.state.url.search}${parsed.state.url.hash}`; + rawUrlFingerprints.add(sha256(rawRelativeUrl)); + if (rawUrlFingerprints.size > TRACE_MAX_DISTINCT_URLS) { + throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_DISTINCT_URLS)} distinct raw URL fingerprints`); + } + if (parsed.state.currentHash !== null) { + rawNonNullHashCount += 1; + rawTransitionHashes.add(String(parsed.state.currentHash)); + } + for (const name of parsed.propertyViolationNames) { + if (!propertyViolations.has(name) && propertyViolations.size >= TRACE_MAX_PROPERTY_NAMES) { + throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_PROPERTY_NAMES)} property names`); } - policyObservationCount += 1; - const actionFollowsExactObservation = previousObservationWasExact; - const recordedActionKind = actionFollowsExactObservation ? parsed.action?.kind ?? null : null; - if (actionFollowsExactObservation && parsed.action !== null) { - totalActions += 1; - actionCounts.set(parsed.action.kind, (actionCounts.get(parsed.action.kind) ?? 0) + 1); - if (parsed.action.kind === "Wait") { - waitStreak += 1; - maxWaitStreak = Math.max(maxWaitStreak, waitStreak); - } else { - nonWaitCount += 1; - waitStreak = 0; - } - if (parsed.action.targetTag !== null) { - if (!targetTags.has(parsed.action.targetTag) && targetTags.size >= 128) { - throw new Error("Bombadil trace exceeds 128 distinct action target tags"); - } - targetTags.set(parsed.action.targetTag, (targetTags.get(parsed.action.targetTag) ?? 0) + 1); - } - } else if (actionFollowsExactObservation) { + propertyViolations.set(name, (propertyViolations.get(name) ?? 0) + 1); + } + for (const [sourceName, outputName] of Object.entries(RESOURCE_FIELD_MAP)) { + resources[outputName] = Math.max(resources[outputName], parsed.state.resources[sourceName]); + } + const currentObservationIsExact = exactTraceDirectObservation(parsed.directObservation) !== null; + if (!currentObservationIsExact) { + previousObservationWasExact = false; + continue; + } + policyObservationCount += 1; + const actionFollowsExactObservation = previousObservationWasExact; + const recordedActionKind = actionFollowsExactObservation ? parsed.action?.kind ?? null : null; + if (actionFollowsExactObservation && parsed.action !== null) { + totalActions += 1; + actionCounts.set(parsed.action.kind, (actionCounts.get(parsed.action.kind) ?? 0) + 1); + if (parsed.action.kind === "Wait") { + waitStreak += 1; + maxWaitStreak = Math.max(maxWaitStreak, waitStreak); + } else { + nonWaitCount += 1; waitStreak = 0; } - const relativeUrl = `${parsed.state.url.pathname}${parsed.state.url.search}${parsed.state.url.hash}`; - urlFingerprints.add(sha256(relativeUrl)); - if (urlFingerprints.size > TRACE_MAX_DISTINCT_URLS) { - throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_DISTINCT_URLS)} distinct URL fingerprints`); - } - stableTarget &&= parsed.state.url.href === targetUrl.href; - if (parsed.state.currentHash !== null) { - nonNullHashCount += 1; - transitionHashes.add(String(parsed.state.currentHash)); - } - for (const snapshot of parsed.namedSnapshots) { - let entry = snapshots.get(snapshot.name); - if (entry === undefined) { - const isStrictSnapshot = snapshot.name === "direct" || strictDiagnosticSnapshotNames.has(snapshot.name); - if (!isStrictSnapshot && trackedUnrelatedSnapshotNameCount >= unrelatedSnapshotNameLimit) { - continue; - } - if (snapshots.size >= TRACE_MAX_NAMED_SNAPSHOT_NAMES) { - throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_NAMED_SNAPSHOT_NAMES)} named snapshots`); - } - entry = { - changeAfterActionKind: new Map, - changeAfterNonWaitCount: 0, - lastObservationIndex: null, - lastValueSha256: null, - observationCount: 0, - values: new Set - }; - snapshots.set(snapshot.name, entry); - if (!isStrictSnapshot) - trackedUnrelatedSnapshotNameCount += 1; + if (parsed.action.targetTag !== null) { + if (!targetTags.has(parsed.action.targetTag) && targetTags.size >= 128) { + throw new Error("Bombadil trace exceeds 128 distinct action target tags"); } - if (!entry.values.has(snapshot.valueSha256) && entry.values.size >= TRACE_MAX_DISTINCT_SNAPSHOT_VALUES_PER_NAME) { - if (snapshot.name === "direct" || strictDiagnosticSnapshotNames.has(snapshot.name)) { - throw new Error(`Bombadil trace named snapshot ${snapshot.name} exceeds ${String(TRACE_MAX_DISTINCT_SNAPSHOT_VALUES_PER_NAME)} distinct values`); - } + targetTags.set(parsed.action.targetTag, (targetTags.get(parsed.action.targetTag) ?? 0) + 1); + } + } else if (actionFollowsExactObservation) { + waitStreak = 0; + } + const relativeUrl = `${parsed.state.url.pathname}${parsed.state.url.search}${parsed.state.url.hash}`; + urlFingerprints.add(sha256(relativeUrl)); + if (urlFingerprints.size > TRACE_MAX_DISTINCT_URLS) { + throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_DISTINCT_URLS)} distinct URL fingerprints`); + } + stableTarget &&= parsed.state.url.href === targetUrl.href; + if (parsed.state.currentHash !== null) { + nonNullHashCount += 1; + transitionHashes.add(String(parsed.state.currentHash)); + } + for (const snapshot of parsed.namedSnapshots) { + let entry = snapshots.get(snapshot.name); + if (entry === undefined) { + const isStrictSnapshot = snapshot.name === "direct" || strictDiagnosticSnapshotNames.has(snapshot.name); + if (!isStrictSnapshot && trackedUnrelatedSnapshotNameCount >= unrelatedSnapshotNameLimit) { continue; } - const changedAfterRecordedAction = recordedActionKind !== null && entry.lastObservationIndex === policyObservationCount - 1 && entry.lastValueSha256 !== null && entry.lastValueSha256 !== snapshot.valueSha256; - if (changedAfterRecordedAction) { - entry.changeAfterActionKind.set(recordedActionKind, (entry.changeAfterActionKind.get(recordedActionKind) ?? 0) + 1); + if (snapshots.size >= TRACE_MAX_NAMED_SNAPSHOT_NAMES) { + throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_NAMED_SNAPSHOT_NAMES)} named snapshots`); } - if (changedAfterRecordedAction && recordedActionKind !== "Wait") { - entry.changeAfterNonWaitCount += 1; + entry = { + changeAfterActionKind: new Map, + changeAfterNonWaitCount: 0, + lastObservationIndex: null, + lastValueSha256: null, + observationCount: 0, + values: new Set + }; + snapshots.set(snapshot.name, entry); + if (!isStrictSnapshot) + trackedUnrelatedSnapshotNameCount += 1; + } + if (!entry.values.has(snapshot.valueSha256) && entry.values.size >= TRACE_MAX_DISTINCT_SNAPSHOT_VALUES_PER_NAME) { + if (snapshot.name === "direct" || strictDiagnosticSnapshotNames.has(snapshot.name)) { + throw new Error(`Bombadil trace named snapshot ${snapshot.name} exceeds ${String(TRACE_MAX_DISTINCT_SNAPSHOT_VALUES_PER_NAME)} distinct values`); } - entry.lastObservationIndex = policyObservationCount; - entry.lastValueSha256 = snapshot.valueSha256; - entry.observationCount += 1; - entry.values.add(snapshot.valueSha256); + continue; + } + const changedAfterRecordedAction = recordedActionKind !== null && entry.lastObservationIndex === policyObservationCount - 1 && entry.lastValueSha256 !== null && entry.lastValueSha256 !== snapshot.valueSha256; + if (changedAfterRecordedAction) { + entry.changeAfterActionKind.set(recordedActionKind, (entry.changeAfterActionKind.get(recordedActionKind) ?? 0) + 1); } - previousObservationWasExact = true; + if (changedAfterRecordedAction && recordedActionKind !== "Wait") { + entry.changeAfterNonWaitCount += 1; + } + entry.lastObservationIndex = policyObservationCount; + entry.lastValueSha256 = snapshot.valueSha256; + entry.observationCount += 1; + entry.values.add(snapshot.valueSha256); } - } finally { - lines.close(); - stream.destroy(); + previousObservationWasExact = true; } if (lineCount === 0) throw new Error("Bombadil did not produce a nonempty trace.jsonl"); @@ -2020,13 +3469,12 @@ async function summarizeDirectBombadilTrace(options) { policyFailures.push("the browser did not remain on the exact target URL"); } } - const traceBytes = await readFile(options.tracePath); return Object.freeze({ schema: "direct.bombadil-exploration-summary/v2", trace: Object.freeze({ - bytes: metadata.size, + bytes: options.traceBytes.byteLength, lineCount, - sha256: sha256(traceBytes) + sha256: sha256(options.traceBytes) }), actions: Object.freeze({ byKind: sortedCountRecord(actionCounts), @@ -2328,13 +3776,13 @@ function validateDirectBombadilFuzzConfig(config, baseUrlOverride) { if (!isAbsolute(config.repositoryRoot) || repositoryRoot !== config.repositoryRoot) { throw new Error("repositoryRoot must be an absolute normalized path"); } - if (!ARTIFACT_NAME_PATTERN.test(config.artifactName)) { + if (!isBoundedArtifactIdentifier(config.artifactName)) { throw new Error("artifactName must be a safe lowercase kebab identifier"); } if (config.label.trim().length === 0 || config.label.length > 160 || hasControlCharacters3(config.label)) { throw new Error("label must contain 1-160 visible characters"); } - if (config.scenario.length > 120 || !SCENARIO_PATTERN.test(config.scenario)) { + if (!isBoundedScenarioIdentifier(config.scenario)) { throw new Error("scenario must be a valid Direct scenario identifier"); } if (config.expectedRoute.trim().length === 0 || config.expectedRoute.length > 256 || hasControlCharacters3(config.expectedRoute)) { @@ -2377,6 +3825,7 @@ function validateDirectBombadilFuzzConfig(config, baseUrlOverride) { const targetQuery = validateTargetQuery(config.targetQuery ?? {}); const viewport = validateViewport(config.viewport); const explorationPolicy = validateExplorationPolicy(config.explorationPolicy); + const artifactPolicy = validateArtifactPolicy(config.artifactPolicy); const startupTimeoutMs = config.server.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS; if (!Number.isSafeInteger(startupTimeoutMs) || startupTimeoutMs < 1000 || startupTimeoutMs > MAX_STARTUP_TIMEOUT_MS) { throw new Error(`server.startupTimeoutMs must be an integer between 1000 and ${String(MAX_STARTUP_TIMEOUT_MS)}`); @@ -2385,6 +3834,7 @@ function validateDirectBombadilFuzzConfig(config, baseUrlOverride) { const port = new URL(baseUrl).port; return { ...config, + artifactPolicy, repositoryRoot, specificationPath, baseUrl, @@ -2481,30 +3931,90 @@ function captureStream(stream, maximumLength = LOG_LIMIT) { function signalProcessGroup(process_, signal) { try { process2.kill(-process_.pid, signal); - } catch { + return; + } catch (error) { + if (!isRecord2(error) || error.code !== "ESRCH") + throw error; if (process_.exitCode === null) process_.kill(signal); } } -async function terminateProcessGroup(process_, graceMs) { - signalProcessGroup(process_, "SIGTERM"); - await Bun.sleep(graceMs); - signalProcessGroup(process_, "SIGKILL"); - await Promise.race([process_.exited.then(() => { +function processGroupMayExist(processId) { + try { + process2.kill(-processId, 0); + return true; + } catch (error) { + if (isRecord2(error) && error.code === "ESRCH") + return false; + if (isRecord2(error) && error.code === "EPERM") + return true; + throw error; + } +} +async function waitForProcessGroupExit(processId, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (processGroupMayExist(processId)) { + if (Date.now() >= deadline) { + throw new Error(`Bombadil process group ${String(processId)} survived cleanup`); + } + await Bun.sleep(Math.min(25, Math.max(1, deadline - Date.now()))); + } +} +async function waitForBombadilLeaderExit(process_, timeoutMs) { + if (process_.exitCode !== null) return; - }), Bun.sleep(graceMs)]); + const exited = await Promise.race([ + process_.exited.then(() => true), + Bun.sleep(timeoutMs).then(() => false) + ]); + if (!exited && process_.exitCode === null) { + throw new Error(`Bombadil process ${String(process_.pid)} survived cleanup`); + } +} +async function settleBombadilProcessGroup(options) { + try { + signalProcessGroup(options.process, "SIGKILL"); + await waitForBombadilLeaderExit(options.process, options.timeoutMs); + await waitForProcessGroupExit(options.process.pid, options.timeoutMs); + } catch (error) { + throw new BombadilWriterSettlementError(`Bombadil process group ${String(options.process.pid)} did not settle safely`, error); + } +} +async function monitorBombadilArtifactTree(options) { + while (!options.abortSignal.aborted) { + try { + await scanBombadilArtifactTree({ + allowTransientEntryAbsence: true, + hashFiles: false, + policy: options.policy, + root: options.outputPath, + rootMayBeAbsent: true + }); + } catch (error) { + if (isRecord2(error) && error.code === "ENOENT") {} else { + throw error instanceof BombadilArtifactPolicyError ? error : new BombadilArtifactPolicyError("Bombadil artifact monitor could not inspect output"); + } + } + await Bun.sleep(ARTIFACT_MONITOR_INTERVAL_MS); + } } async function runBombadilNativeProcess(invocation) { + const artifactPolicy = validateArtifactPolicy(invocation.artifactPolicy); + const childEnvironment = Object.fromEntries(Object.entries({ + ...process2.env, + NO_COLOR: "1" + }).filter(([name]) => name !== ARTIFACT_COORDINATION_ENVIRONMENT)); const process_ = Bun.spawn([...invocation.command], { cwd: invocation.cwd, detached: true, - env: { ...process2.env, NO_COLOR: "1" }, + env: childEnvironment, stdin: "ignore", stdout: "pipe", stderr: "pipe" }); let timeout; let abortListener; + const monitorAbortController = new AbortController; const timeoutPromise = new Promise((resolveTimeout) => { timeout = setTimeout(() => resolveTimeout("timeout"), invocation.wallClockTimeoutMs); }); @@ -2522,17 +4032,44 @@ async function runBombadilNativeProcess(invocation) { const stdoutCapture = captureStream(process_.stdout); const stderrCapture = captureStream(process_.stderr); const outputPromise = Promise.all([stdoutCapture.result, stderrCapture.result]); + const artifactMonitor = monitorBombadilArtifactTree({ + abortSignal: monitorAbortController.signal, + outputPath: invocation.outputPath, + policy: artifactPolicy + }).then(() => ({ kind: "monitor-stopped" }), (error) => ({ kind: "artifact-policy", error })); const outcome = await Promise.race([ process_.exited.then((exitCode) => ({ kind: "exited", exitCode })), timeoutPromise.then(() => ({ kind: "timeout" })), - abortPromise.then(() => ({ kind: "aborted" })) + abortPromise.then(() => ({ kind: "aborted" })), + artifactMonitor ]); + if (outcome.kind === "monitor-stopped") { + throw new BombadilArtifactPolicyError("Bombadil artifact monitor stopped unexpectedly"); + } const terminationGraceMs = invocation.terminationGraceMs ?? PROCESS_TERMINATION_GRACE_MS; - if (outcome.kind === "exited") { - signalProcessGroup(process_, "SIGKILL"); - } else { - await terminateProcessGroup(process_, terminationGraceMs); + try { + await settleBombadilProcessGroup({ + process: process_, + timeoutMs: terminationGraceMs + }); + } catch (error) { + stdoutCapture.stop(); + stderrCapture.stop(); + throw error; + } + let finalArtifactFailure = null; + try { + await scanBombadilArtifactTree({ + hashFiles: false, + policy: artifactPolicy, + root: invocation.outputPath, + rootMayBeAbsent: true + }); + } catch (error) { + finalArtifactFailure = error instanceof BombadilArtifactPolicyError ? error : new BombadilArtifactPolicyError(`Bombadil final artifact inventory could not be proven safe: ${renderUnknown(error)}`); } + monitorAbortController.abort(); + const finalMonitorOutcome = await artifactMonitor; const outputSettled = await Promise.race([ outputPromise.then(() => true, () => true), Bun.sleep(Math.max(terminationGraceMs, MIN_PROCESS_OUTPUT_DRAIN_MS)).then(() => false) @@ -2542,6 +4079,15 @@ async function runBombadilNativeProcess(invocation) { stderrCapture.stop(); } const [stdout, stderr] = await outputPromise; + if (outcome.kind === "artifact-policy") { + throw outcome.error instanceof BombadilArtifactPolicyError ? outcome.error : new BombadilArtifactPolicyError("Bombadil artifact policy was violated"); + } + if (finalMonitorOutcome.kind === "artifact-policy") { + throw finalMonitorOutcome.error instanceof BombadilArtifactPolicyError ? finalMonitorOutcome.error : new BombadilArtifactPolicyError("Bombadil artifact policy was violated"); + } + if (finalArtifactFailure !== null) { + throw finalArtifactFailure instanceof BombadilArtifactPolicyError ? finalArtifactFailure : new BombadilArtifactPolicyError("Bombadil artifact policy was violated"); + } return { exitCode: outcome.kind === "exited" ? outcome.exitCode : process_.exitCode ?? 137, stderr, @@ -2549,6 +4095,7 @@ async function runBombadilNativeProcess(invocation) { termination: outcome.kind === "exited" ? null : outcome.kind }; } finally { + monitorAbortController.abort(); if (timeout !== undefined) clearTimeout(timeout); if (abortListener !== undefined) { @@ -2556,11 +4103,18 @@ async function runBombadilNativeProcess(invocation) { } } } +var processEvents = process2; var defaultDependencies = { acquireServer: acquireVerificationServer, createAbortController: () => new AbortController, + createRunId: randomUUID2, now: () => new Date, runBombadil: runBombadilNativeProcess, + signalController: { + forward: (signal) => process2.kill(process2.pid, signal), + once: (signal, listener) => processEvents.once(signal, listener), + removeListener: (signal, listener) => processEvents.removeListener(signal, listener) + }, serverOutputTimeoutMs: SERVER_OUTPUT_TIMEOUT_MS, spawnServer: spawnVerificationServer, stopServer: stopVerificationServer @@ -2718,22 +4272,24 @@ function parseMatrixCampaignArgument(arguments_) { return { arguments: Object.freeze(forwarded), campaignId, help }; } function validateCampaignMatrix(campaigns) { - if (campaigns.length === 0 || campaigns.length > 32) { - throw new Error("Bombadil campaign matrix must contain 1-32 campaigns"); + if (campaigns.length === 0 || campaigns.length > MAX_MATRIX_CAMPAIGNS) { + throw new Error(`Bombadil campaign matrix must contain 1-${String(MAX_MATRIX_CAMPAIGNS)} campaigns`); } const ids = new Set; for (const campaign of campaigns) { - if (!ARTIFACT_NAME_PATTERN.test(campaign.id) || ids.has(campaign.id)) { + if (!isBoundedArtifactIdentifier(campaign.id) || ids.has(campaign.id)) { throw new Error("Bombadil campaign IDs must be unique lowercase kebab identifiers"); } ids.add(campaign.id); } return campaigns; } -async function runDirectBombadilFuzzMatrix(campaignsInput, arguments_ = process2.argv.slice(2), dependencyOverrides = {}) { - const campaigns = validateCampaignMatrix(campaignsInput); - const parsed = parseMatrixCampaignArgument(arguments_); - if (parsed.help) { +async function runDirectBombadilFuzzMatrix(campaignsInput, input = process2.argv.slice(2), dependencyOverrides = {}) { + const dependencies = { ...defaultDependencies, ...dependencyOverrides }; + const normalizedOptions = normalizeFuzzRunOptions(input); + if (normalizedOptions.arguments.some((argument) => argument === "--help" || argument === "-h")) { + const campaigns = validateCampaignMatrix(campaignsInput); + parseMatrixCampaignArgument(normalizedOptions.arguments); process2.stdout.write(`${[ helpText(campaigns[0]?.config.baseUrl ?? ""), " --campaign Run one campaign; required with --replay", @@ -2744,22 +4300,211 @@ async function runDirectBombadilFuzzMatrix(campaignsInput, arguments_ = process2 `); return { kind: "help" }; } - const selected = parsed.campaignId === null ? campaigns : campaigns.filter((campaign) => campaign.id === parsed.campaignId); - if (selected.length === 0) { - throw new Error(`Unknown Bombadil campaign ${parsed.campaignId ?? ""}`); - } - if (parsed.campaignId === null && parsed.arguments.some((argument) => argument === "--replay" || argument.startsWith("--replay="))) { - throw new Error("--replay requires exactly one --campaign in matrix mode"); + const matrixAbortController = dependencies.createAbortController?.() ?? new AbortController; + let interruptedSignal = null; + const interrupt = (signal) => { + interruptedSignal ??= signal; + matrixAbortController.abort(); + }; + const processSignals = dependencies.signalController; + for (const signal of PROCESS_INTERRUPT_SIGNALS) + processSignals.once(signal, interrupt); + const releaseSignalHandlers = () => { + for (const signal of PROCESS_INTERRUPT_SIGNALS) { + processSignals.removeListener(signal, interrupt); + } + }; + let invalidMatrixUploadMode; + let matrixPlan; + let uploadSession; + try { + const firstRepositoryRoot = campaignsInput[0]?.config.repositoryRoot; + if (normalizedOptions.artifactRun === null && firstRepositoryRoot === undefined) { + throw new Error(`Bombadil campaign matrix must contain 1-${String(MAX_MATRIX_CAMPAIGNS)} campaigns`); + } + const requestedMatrixPlan = normalizedOptions.artifactRun ?? { + repositoryRoot: await realpath(resolve(firstRepositoryRoot ?? "")), + runId: dependencies.createRunId(), + uploadMode: "public-summary" + }; + const requestedMatrixUploadMode = requestedMatrixPlan.uploadMode ?? "public-summary"; + invalidMatrixUploadMode = requestedMatrixUploadMode !== "public-summary"; + matrixPlan = { + repositoryRoot: requestedMatrixPlan.repositoryRoot, + runId: requestedMatrixPlan.runId, + uploadMode: "public-summary" + }; + uploadSession = await prepareArtifactUploadSession(matrixPlan); + } catch (error) { + releaseSignalHandlers(); + const signalToForward = interruptedSignal; + if (signalToForward !== null) + processSignals.forward(signalToForward); + throw error; } - const results = []; - for (const campaign of selected) { - const result = await runDirectBombadilFuzz(campaign.config, parsed.arguments, dependencyOverrides); - if (result.kind !== "run") { - throw new Error("Bombadil campaign unexpectedly returned help during matrix execution"); + try { + let campaigns; + let parsed; + let selected; + try { + if (invalidMatrixUploadMode) { + throw new Error("Bombadil matrices support public-summary uploads only"); + } + campaigns = validateCampaignMatrix(campaignsInput); + parsed = parseMatrixCampaignArgument(normalizedOptions.arguments); + selected = parsed.campaignId === null ? campaigns : campaigns.filter((campaign) => campaign.id === parsed.campaignId); + if (selected.length === 0) { + throw new Error(`Unknown Bombadil campaign ${parsed.campaignId ?? ""}`); + } + if (parsed.campaignId === null && parsed.arguments.some((argument) => argument === "--replay" || argument.startsWith("--replay="))) { + throw new Error("--replay requires exactly one --campaign in matrix mode"); + } + for (const campaign of selected) { + if (interruptedSignal !== null) + throw new Error("Bombadil matrix was interrupted"); + const campaignArguments = parseDirectBombadilFuzzArguments(parsed.arguments, campaign.config.baseUrl); + if (campaignArguments.kind !== "run") { + throw new Error("Bombadil matrix campaign unexpectedly entered help mode"); + } + const lexicalConfig = validateDirectBombadilFuzzConfig(campaign.config, campaignArguments.baseUrl); + const resolvedPaths = await resolveDirectBombadilRealPaths(lexicalConfig, resolveReplayPath(lexicalConfig.repositoryRoot, campaignArguments.replayPath)); + if (resolvedPaths.config.repositoryRoot !== matrixPlan.repositoryRoot) { + throw new BombadilArtifactPolicyError("Every Bombadil matrix campaign must share artifactRun.repositoryRoot"); + } + } + } catch (error) { + const boundedCampaigns = campaignsInput.slice(0, MAX_MATRIX_CAMPAIGNS); + const retainedCampaignIds = new Set; + const entries2 = boundedCampaigns.map((campaign, index) => { + const boundedCampaignId = isBoundedArtifactIdentifier(campaign.id) ? campaign.id : null; + const campaignId = boundedCampaignId !== null && !retainedCampaignIds.has(boundedCampaignId) ? boundedCampaignId : null; + if (campaignId !== null) + retainedCampaignIds.add(campaignId); + return { + campaignId, + index, + receipt: null, + status: "rejected" + }; + }); + return await publishFailureAndThrow(error, async () => { + await publishMatrixUpload({ + abortSignal: matrixAbortController.signal, + beforeCommitCheck: dependencies.beforeArtifactCommit, + campaigns: entries2, + children: [], + completedAt: dependencies.now(), + failure: error, + failureCode: interruptedSignal === null ? "configuration-rejected" : "interrupted", + interruptedSignal: () => interruptedSignal, + omittedCampaignCount: Math.max(0, campaignsInput.length - entries2.length), + session: uploadSession + }); + }); + } + const results = []; + const entries = campaigns.map((campaign, index) => ({ + campaignId: campaign.id, + index, + receipt: null, + status: selected.includes(campaign) ? "not-run" : "not-selected" + })); + const children = []; + let executionFailure = null; + let executionFailureCode; + for (const campaign of selected) { + if (interruptedSignal !== null) { + executionFailure = new Error("Bombadil matrix was interrupted"); + break; + } + const campaignIndex = campaigns.indexOf(campaign); + const deferredPayload = { value: null }; + const childSession = { + deferredPayload, + finalDirectory: join2(uploadSession.finalDirectory, "campaigns", campaign.id), + mode: uploadSession.mode, + publication: "deferred", + receiptPath: join2(uploadSession.finalDirectory, "campaigns", campaign.id, "receipt.json"), + runId: uploadSession.runId + }; + try { + const result = await runDirectBombadilFuzzInternal(campaign.config, parsed.arguments, dependencyOverrides, { + abortSignal: matrixAbortController.signal, + forwardSignal: false, + interruptedSignal: () => interruptedSignal, + plan: matrixPlan, + session: childSession + }); + if (result.kind !== "run" || deferredPayload.value === null) { + throw new Error("Bombadil campaign did not finalize its sanitized receipt"); + } + children.push({ campaignId: campaign.id, payload: deferredPayload.value }); + results.push({ campaignId: campaign.id, result }); + entries[campaignIndex] = { + campaignId: campaign.id, + index: campaignIndex, + receipt: `campaigns/${campaign.id}/receipt.json`, + status: "passed" + }; + } catch (error) { + executionFailure = error; + const childPayload = deferredPayload.value; + if (childPayload !== null) { + children.push({ campaignId: campaign.id, payload: childPayload }); + executionFailureCode = childPayload.receipt.failureCode ?? undefined; + } + entries[campaignIndex] = { + campaignId: campaign.id, + index: campaignIndex, + receipt: childPayload === null ? null : `campaigns/${campaign.id}/receipt.json`, + status: childPayload?.receipt.status === "rejected" ? "rejected" : "failed" + }; + break; + } + } + if (executionFailure === null && interruptedSignal !== null) { + executionFailure = new Error("Bombadil matrix was interrupted"); + executionFailureCode = "interrupted"; + } + if (executionFailure !== null) { + await publishFailureAndThrow(executionFailure, async () => { + await publishMatrixUpload({ + abortSignal: matrixAbortController.signal, + beforeCommitCheck: dependencies.beforeArtifactCommit, + campaigns: entries, + children, + completedAt: dependencies.now(), + failure: executionFailure, + ...executionFailureCode === undefined ? {} : { failureCode: executionFailureCode }, + interruptedSignal: () => interruptedSignal, + session: uploadSession + }); + }); } - results.push({ campaignId: campaign.id, result }); + const published = await publishMatrixUpload({ + abortSignal: matrixAbortController.signal, + beforeCommitCheck: dependencies.beforeArtifactCommit, + campaigns: entries, + children, + completedAt: dependencies.now(), + failure: null, + interruptedSignal: () => interruptedSignal, + session: uploadSession + }); + if (published.failure !== null) + throw failureAsError(published.failure); + return { + kind: "matrix", + receiptPath: uploadSession.receiptPath, + results: Object.freeze(results), + uploadArtifactPath: uploadSession.finalDirectory + }; + } finally { + releaseSignalHandlers(); + const signalToForward = interruptedSignal; + if (signalToForward !== null) + processSignals.forward(signalToForward); } - return { kind: "matrix", results: Object.freeze(results) }; } function throwIfBombadilRunAborted(signal) { if (signal.aborted) @@ -2772,242 +4517,434 @@ function terminateAbortedOwnedServer(signal, server) { server.terminate(); throwIfBombadilRunAborted(signal); } -async function runDirectBombadilFuzz(config, arguments_ = process2.argv.slice(2), dependencyOverrides = {}) { - const parsed = parseDirectBombadilFuzzArguments(arguments_, config.baseUrl); - if (parsed.kind === "help") { +async function runDirectBombadilFuzzInternal(config, input = process2.argv.slice(2), dependencyOverrides = {}, preparedUpload) { + const dependencies = { ...defaultDependencies, ...dependencyOverrides }; + const normalizedOptions = normalizeFuzzRunOptions(input); + if (normalizedOptions.arguments.some((argument) => argument === "--help" || argument === "-h")) { + parseDirectBombadilFuzzArguments(normalizedOptions.arguments, config.baseUrl); process2.stdout.write(`${helpText(config.baseUrl)} `); return { kind: "help" }; } - const lexicalConfig = validateDirectBombadilFuzzConfig(config, parsed.baseUrl); - const lexicalReplayPath = resolveReplayPath(lexicalConfig.repositoryRoot, parsed.replayPath); - const resolvedPaths = await resolveDirectBombadilRealPaths(lexicalConfig, lexicalReplayPath); - const validated = resolvedPaths.config; - const replayPath = resolvedPaths.replayPath; - const dependencies = { ...defaultDependencies, ...dependencyOverrides }; - const generatedAt = dependencies.now(); - const artifactRun = await createArtifactRun({ - artifactRoot: validated.artifactRoot, - generatedAt: generatedAt.toISOString() - }); - const outputPath = join2(artifactRun.runDirectory, "bombadil"); - const tracePath = join2(outputPath, "trace.jsonl"); const abortController = dependencies.createAbortController?.() ?? new AbortController; - const invocation = createDirectBombadilInvocation({ - baseUrl: validated.baseUrl, - bombadilExecutable: validated.bombadilExecutable, - entryPath: validated.entryPath, - outputPath, - replayPath, - repositoryRoot: validated.repositoryRoot, - scenario: validated.scenario, - specificationPath: validated.specificationPath, - targetQuery: validated.targetQuery, - timeLimitSeconds: parsed.timeLimitSeconds, - viewport: validated.viewport - }); - const abortableInvocation = { ...invocation, abortSignal: abortController.signal }; - const serverCommand = validated.server.command.map((argument) => argument === "{port}" ? validated.port : argument); - let bombadilVersion = null; - let lease = null; - let ownedServer = null; - let processResult = null; - let attestation = null; - let attestationFailure = null; - let explorationSummary = null; - let explorationSummaryFailure = null; - let rawTracePath = null; - let serverOutput = ""; - let serverOutputFailure = null; - let failure = null; let interruptedSignal = null; + let ownedServer = null; const interrupt = (signal) => { interruptedSignal ??= signal; abortController.abort(); if (ownedServer?.exitCode() === null) ownedServer.terminate(); }; - const interruptSignals = ["SIGINT", "SIGTERM"]; - const processSignals = process2; - for (const signal of interruptSignals) + const processSignals = dependencies.signalController; + for (const signal of PROCESS_INTERRUPT_SIGNALS) processSignals.once(signal, interrupt); + const abortFromPreparedMatrix = () => { + interruptedSignal ??= preparedUpload?.interruptedSignal?.() ?? null; + abortController.abort(); + if (ownedServer?.exitCode() === null) + ownedServer.terminate(); + }; + if (preparedUpload?.abortSignal !== undefined) { + if (preparedUpload.abortSignal.aborted) + abortFromPreparedMatrix(); + else + preparedUpload.abortSignal.addEventListener("abort", abortFromPreparedMatrix, { once: true }); + } try { + const generatedAt = dependencies.now(); + const artifactPlan = preparedUpload?.plan ?? normalizedOptions.artifactRun ?? { + repositoryRoot: await realpath(resolve(config.repositoryRoot)), + runId: dependencies.createRunId(), + uploadMode: "public-summary" + }; + const uploadSession = preparedUpload?.session ?? await prepareArtifactUploadSession(artifactPlan); + let parsed; + let validated; + let replayPath; try { - await requireRegularFile(validated.bombadilExecutable, "The root Bombadil executable"); - bombadilVersion = await readExactBombadilVersion(validated.repositoryRoot); throwIfBombadilRunAborted(abortController.signal); - try { - lease = await dependencies.acquireServer({ - abortSignal: abortController.signal, - baseUrl: validated.baseUrl, - label: validated.label, - readinessPath: validated.server.readinessPath, - reuseExistingLocalServer: false, - startupTimeoutMs: validated.server.startupTimeoutMs, - startServer: () => { - throwIfBombadilRunAborted(abortController.signal); - ownedServer = dependencies.spawnServer({ - command: serverCommand, - cwd: validated.server.cwd, - ...validated.server.env === undefined ? {} : { env: validated.server.env } - }); - terminateAbortedOwnedServer(abortController.signal, ownedServer); - return ownedServer; - } - }); - } catch (error) { - if (abortController.signal.aborted) - throwIfBombadilRunAborted(abortController.signal); - throw error; + const parsedInput = parseDirectBombadilFuzzArguments(normalizedOptions.arguments, config.baseUrl); + if (parsedInput.kind !== "run") { + throw new Error("Bombadil help was not handled before artifact allocation"); } - if (abortController.signal.aborted) { - const acquiredOwnedServer = ownedServer; - if (acquiredOwnedServer?.exitCode() === null) - acquiredOwnedServer.terminate(); - throwIfBombadilRunAborted(abortController.signal); - } - let processFailure = null; - try { - processResult = await dependencies.runBombadil(abortableInvocation); - } catch (error) { - processFailure = error; - } - const traceMetadata = await stat(tracePath).catch(() => null); - if (traceMetadata?.isFile() === true && traceMetadata.size > 0) { - rawTracePath = tracePath; + parsed = parsedInput; + const lexicalConfig = validateDirectBombadilFuzzConfig(config, parsed.baseUrl); + const lexicalReplayPath = resolveReplayPath(lexicalConfig.repositoryRoot, parsed.replayPath); + const resolvedPaths = await resolveDirectBombadilRealPaths(lexicalConfig, lexicalReplayPath); + validated = resolvedPaths.config; + replayPath = resolvedPaths.replayPath; + throwIfBombadilRunAborted(abortController.signal); + if (validated.repositoryRoot !== resolve(artifactPlan.repositoryRoot)) { + throw new BombadilArtifactPolicyError("artifactRun.repositoryRoot must equal the campaign repositoryRoot"); } - try { - attestation = await attestDirectBombadilTrace({ - expectedRoute: validated.expectedRoute, - expectedScenario: validated.scenario, - tracePath + } catch (error) { + const policy = (() => { + try { + return validateArtifactPolicy(config.artifactPolicy); + } catch { + return validateArtifactPolicy(undefined); + } + })(); + return await publishFailureAndThrow(error, async () => { + await publishRunUpload({ + abortSignal: abortController.signal, + artifactName: isBoundedArtifactIdentifier(config.artifactName) ? config.artifactName : "rejected", + beforeCommitCheck: dependencies.beforeArtifactCommit, + attestation: null, + completedAt: dependencies.now(), + explorationSummary: null, + failure: error, + failureCode: abortController.signal.aborted ? "interrupted" : "configuration-rejected", + inventory: emptyArtifactInventory(), + interruptedSignal: () => interruptedSignal, + localOutputPath: config.repositoryRoot, + policy, + privateDiagnosticsAllowed: false, + processLog: "", + scenario: isBoundedScenarioIdentifier(config.scenario) ? config.scenario : "rejected", + serverLog: "", + session: uploadSession, + status: abortController.signal.aborted ? "failed" : "rejected" }); - } catch (error) { - attestationFailure = error; - } - try { - explorationSummary = await summarizeDirectBombadilTrace({ - ...validated.explorationPolicy === null ? {} : { explorationPolicy: validated.explorationPolicy }, - targetUrl: invocation.targetUrl, - tracePath + }); + } + let artifactRun; + try { + throwIfBombadilRunAborted(abortController.signal); + artifactRun = await createBombadilArtifactRun({ + artifactName: validated.artifactName, + repositoryRoot: validated.repositoryRoot, + runId: dependencies.createRunId() + }); + throwIfBombadilRunAborted(abortController.signal); + } catch (error) { + return await publishFailureAndThrow(error, async () => { + await publishRunUpload({ + abortSignal: abortController.signal, + artifactName: validated.artifactName, + beforeCommitCheck: dependencies.beforeArtifactCommit, + attestation: null, + completedAt: dependencies.now(), + explorationSummary: null, + failure: error, + inventory: emptyArtifactInventory(), + interruptedSignal: () => interruptedSignal, + localOutputPath: validated.repositoryRoot, + policy: validated.artifactPolicy, + privateDiagnosticsAllowed: false, + processLog: "", + scenario: validated.scenario, + serverLog: "", + session: uploadSession, + status: "failed" }); + }); + } + const outputPath = join2(artifactRun.runDirectory, "bombadil"); + const tracePath = join2(outputPath, "trace.jsonl"); + const invocation = createDirectBombadilInvocation({ + baseUrl: validated.baseUrl, + bombadilExecutable: validated.bombadilExecutable, + entryPath: validated.entryPath, + outputPath, + replayPath, + repositoryRoot: validated.repositoryRoot, + scenario: validated.scenario, + specificationPath: validated.specificationPath, + targetQuery: validated.targetQuery, + timeLimitSeconds: parsed.timeLimitSeconds, + viewport: validated.viewport + }); + const abortableInvocation = { + ...invocation, + abortSignal: abortController.signal, + artifactPolicy: validated.artifactPolicy + }; + const serverCommand = validated.server.command.map((argument) => argument === "{port}" ? validated.port : argument); + let bombadilVersion = null; + let lease = null; + let processResult = null; + let attestation = null; + let attestationFailure = null; + let explorationSummary = null; + let explorationSummaryFailure = null; + let artifactInventory = emptyArtifactInventory(); + let artifactInventoryVetted = false; + let rawTracePath = null; + let serverOutput = ""; + let serverOutputFailure = null; + let failure = null; + let writersSettled = true; + { + try { + await requireRegularFile(validated.bombadilExecutable, "The root Bombadil executable"); + bombadilVersion = await readExactBombadilVersion(validated.repositoryRoot); + throwIfBombadilRunAborted(abortController.signal); + try { + lease = await dependencies.acquireServer({ + abortSignal: abortController.signal, + baseUrl: validated.baseUrl, + label: validated.label, + readinessPath: validated.server.readinessPath, + reuseExistingLocalServer: false, + startupTimeoutMs: validated.server.startupTimeoutMs, + startServer: () => { + throwIfBombadilRunAborted(abortController.signal); + ownedServer = dependencies.spawnServer({ + command: serverCommand, + cwd: validated.server.cwd, + detachedProcessGroup: true, + ...validated.server.env === undefined ? {} : { env: validated.server.env }, + omitEnvironment: [ARTIFACT_COORDINATION_ENVIRONMENT] + }); + terminateAbortedOwnedServer(abortController.signal, ownedServer); + return ownedServer; + } + }); + } catch (error) { + if (abortController.signal.aborted) + throwIfBombadilRunAborted(abortController.signal); + throw error; + } + if (abortController.signal.aborted) { + const acquiredOwnedServer = ownedServer; + if (acquiredOwnedServer?.exitCode() === null) + acquiredOwnedServer.terminate(); + throwIfBombadilRunAborted(abortController.signal); + } + let processFailure = null; + try { + processResult = await dependencies.runBombadil(abortableInvocation); + } catch (error) { + processFailure = error; + } + if (processFailure !== null) { + throw processFailure instanceof Error ? processFailure : new Error(renderUnknown(processFailure)); + } + if (processResult === null) + throw new Error("Bombadil did not return a process result"); + if (processResult.termination === "timeout") { + throw new Error(`Bombadil exceeded its ${String(invocation.wallClockTimeoutMs)}ms wall-clock limit`); + } + if (processResult.termination === "aborted") { + throw new Error("Bombadil process was interrupted"); + } + if (processResult.exitCode !== 0) { + throw new Error(`Bombadil exited with status ${String(processResult.exitCode)}`); + } } catch (error) { - explorationSummaryFailure = error; - } - if (processFailure !== null) { - throw processFailure instanceof Error ? processFailure : new Error(renderUnknown(processFailure)); - } - if (processResult === null) - throw new Error("Bombadil did not return a process result"); - if (processResult.termination === "timeout") { - throw new Error(`Bombadil exceeded its ${String(invocation.wallClockTimeoutMs)}ms wall-clock limit`); - } - if (processResult.termination === "aborted") { - throw new Error("Bombadil process was interrupted"); + if (error instanceof BombadilWriterSettlementError) + writersSettled = false; + failure = error; } - if (processResult.exitCode !== 0) { - throw new Error(`Bombadil exited with status ${String(processResult.exitCode)}`); + const serverToStop = lease?.source === "started" ? lease.server : ownedServer; + if (serverToStop !== null) { + try { + await dependencies.stopServer(serverToStop); + } catch (error) { + writersSettled = false; + failure = new BombadilWriterSettlementError("Bombadil server writers were not proven absent", failure === null ? error : new AggregateError([failure, error], "Bombadil run and server cleanup both failed")); + } } - if (attestationFailure !== null) { - throw attestationFailure instanceof Error ? attestationFailure : new Error(renderUnknown(attestationFailure)); + const serverAfterRun = ownedServer; + if (serverAfterRun !== null && writersSettled) { + try { + serverOutput = await readServerOutputBounded(serverAfterRun, dependencies.serverOutputTimeoutMs); + } catch (error) { + serverOutputFailure = error; + failure ??= error; + } } - if (explorationSummaryFailure !== null) { - throw explorationSummaryFailure instanceof Error ? explorationSummaryFailure : new Error(renderUnknown(explorationSummaryFailure)); + if (writersSettled) { + try { + try { + artifactInventory = await scanBombadilArtifactTree({ + hashFiles: true, + policy: validated.artifactPolicy, + root: outputPath + }); + } catch (error) { + throw error instanceof BombadilArtifactPolicyError ? error : new BombadilArtifactPolicyError(`Bombadil artifact inventory could not be proven safe: ${renderUnknown(error)}`); + } + artifactInventoryVetted = true; + const trace = artifactInventory.files.find((file) => file.relativePath === "trace.jsonl"); + if (trace === undefined || trace.size === 0) { + const missingTrace = new BombadilArtifactPolicyError("Bombadil did not produce a retained nonempty trace.jsonl"); + attestationFailure = missingTrace; + throw missingTrace; + } + rawTracePath = tracePath; + const traceBytes = await readBoundRegularFileBytes({ + expected: trace, + label: "Bombadil trace.jsonl", + maximumBytes: TRACE_MAX_BYTES, + path: tracePath + }); + try { + attestation = attestDirectBombadilTraceBytes({ + expectedRoute: validated.expectedRoute, + expectedScenario: validated.scenario, + traceBytes + }); + } catch (error) { + attestationFailure = error; + } + try { + explorationSummary = summarizeDirectBombadilTraceBytes({ + ...validated.explorationPolicy === null ? {} : { explorationPolicy: validated.explorationPolicy }, + targetUrl: invocation.targetUrl, + traceBytes + }); + } catch (error) { + explorationSummaryFailure = error; + } + if (attestationFailure !== null) { + throw attestationFailure instanceof Error ? attestationFailure : new Error(renderUnknown(attestationFailure)); + } + if (explorationSummaryFailure !== null) { + throw explorationSummaryFailure instanceof Error ? explorationSummaryFailure : new Error(renderUnknown(explorationSummaryFailure)); + } + if (explorationSummary?.policy.satisfied !== true) { + throw new Error(`Bombadil exploration policy was not satisfied: ${explorationSummary?.policy.failures.join("; ") ?? "summary unavailable"}`); + } + } catch (error) { + failure ??= error; + } + } else { + artifactInventory = emptyArtifactInventory(); + failure ??= new BombadilWriterSettlementError("Bombadil writers were not proven absent; artifact inspection was suppressed", new Error("writer settlement unavailable")); } - if (explorationSummary?.policy.satisfied !== true) { - throw new Error(`Bombadil exploration policy was not satisfied: ${explorationSummary?.policy.failures.join("; ") ?? "summary unavailable"}`); + } + const signalAfterRun = interruptedSignal; + if (signalAfterRun !== null && failure === null) { + failure = new Error(`Bombadil fuzzing was interrupted by ${signalAfterRun}`); + } + const logPath = join2(artifactRun.runDirectory, "bombadil.log"); + const serverLogPath = join2(artifactRun.runDirectory, "server.log"); + const explorationSummaryPath = join2(artifactRun.runDirectory, "exploration-summary.json"); + const log = [processResult?.stdout ?? "", processResult?.stderr ?? ""].filter((part) => part.length > 0).join(` +`); + try { + await writeExclusiveBytes(logPath, Buffer.from(`${log}${log.length > 0 ? ` +` : ""}`, "utf8")); + await writeExclusiveBytes(serverLogPath, Buffer.from(`${serverOutput}${serverOutput.length > 0 ? ` +` : ""}`, "utf8")); + if (explorationSummary !== null) { + await writeJsonAtomically(explorationSummaryPath, explorationSummary); } } catch (error) { - failure = error; + const persistence = new BombadilPersistenceError("Bombadil local diagnostic logs could not be persisted", [error]); + failure = failure === null ? persistence : combinePersistenceFailure(failure, persistence); } - const serverToStop = lease?.source === "started" ? lease.server : ownedServer; - if (serverToStop !== null) { - try { - await dependencies.stopServer(serverToStop); - } catch (error) { - failure ??= error; - } + let completedAt = dependencies.now(); + const createRecord = () => ({ + schema: ARTIFACT_SCHEMA, + evidenceClass: "diagnostic-fuzz", + artifactName: validated.artifactName, + label: validated.label, + status: failure === null ? "passed" : "failed", + generatedAt: generatedAt.toISOString(), + completedAt: completedAt.toISOString(), + durationMs: Math.max(0, completedAt.getTime() - generatedAt.getTime()), + scenario: validated.scenario, + expectedRoute: validated.expectedRoute, + baseUrl: validated.baseUrl, + entryPath: validated.entryPath, + targetQuery: validated.targetQuery, + targetUrl: invocation.targetUrl, + viewport: validated.viewport, + artifactPolicy: validated.artifactPolicy, + artifactInventory: { + entryCount: artifactInventory.entryCount, + fileCount: artifactInventory.fileCount, + inventorySha256: artifactInventory.inventorySha256, + totalBytes: artifactInventory.totalBytes, + files: artifactInventory.files.map((file) => ({ + path: file.relativePath, + sha256: file.sha256, + size: file.size + })) + }, + explorationPolicy: validated.explorationPolicy, + specificationPath: validated.specificationPath, + replayPath, + timeLimitSeconds: replayPath === null ? parsed.timeLimitSeconds : null, + serverSource: lease?.source ?? null, + bombadil: { + version: bombadilVersion, + executable: validated.bombadilExecutable, + exitCode: processResult?.exitCode ?? null, + termination: processResult?.termination ?? null, + outputPath, + rawTracePath, + tracePath: attestation === null ? null : tracePath, + logPath + }, + server: { + logPath: serverLogPath, + logPresent: serverOutput.length > 0, + outputFailure: serverOutputFailure === null ? null : renderUnknown(serverOutputFailure) + }, + attestation, + attestationFailure: attestationFailure === null ? null : renderUnknown(attestationFailure), + explorationSummary, + explorationSummaryPath: explorationSummary === null ? null : explorationSummaryPath, + explorationSummaryFailure: explorationSummaryFailure === null ? null : renderUnknown(explorationSummaryFailure), + initialDirect: attestation?.initial ?? null, + interruptedSignal, + failure: failure === null ? null : renderUnknown(failure) + }); + const runRecordPath = join2(artifactRun.runDirectory, "run.json"); + try { + await writeJsonAtomically(runRecordPath, createRecord()); + } catch (error) { + const persistence = new BombadilPersistenceError("Bombadil local run record could not be persisted", [error]); + failure = failure === null ? persistence : combinePersistenceFailure(failure, persistence); } - const serverAfterRun = ownedServer; - if (serverAfterRun !== null) { - try { - serverOutput = await readServerOutputBounded(serverAfterRun, dependencies.serverOutputTimeoutMs); - } catch (error) { - serverOutputFailure = error; - failure ??= error; - } + const failureBeforeUpload = failure; + const signalBeforeUpload = interruptedSignal; + if (signalBeforeUpload !== null && failure === null) { + failure = new Error(`Bombadil fuzzing was interrupted by ${signalBeforeUpload}`); } - } finally { - for (const signal of interruptSignals) { - processSignals.removeListener(signal, interrupt); + let published; + try { + published = await publishRunUpload({ + abortSignal: abortController.signal, + artifactName: validated.artifactName, + beforeCommitCheck: dependencies.beforeArtifactCommit, + attestation, + completedAt, + explorationSummary, + failure, + inventory: artifactInventory, + interruptedSignal: () => interruptedSignal, + localOutputPath: outputPath, + policy: validated.artifactPolicy, + privateDiagnosticsAllowed: writersSettled && artifactInventoryVetted, + processLog: `${log}${log.length > 0 ? ` +` : ""}`, + scenario: validated.scenario, + serverLog: `${serverOutput}${serverOutput.length > 0 ? ` +` : ""}`, + session: uploadSession, + status: failure === null ? "passed" : "failed" + }); + } catch (persistence) { + if (failure === null) + throw persistence; + throw combinePersistenceFailure(failure, persistence, "sanitized Bombadil receipt publication also failed"); } - } - const capturedSignal = interruptedSignal; - if (capturedSignal !== null && failure === null) { - failure = new Error(`Bombadil fuzzing was interrupted by ${capturedSignal}`); - } - const completedAt = dependencies.now(); - const status = failure === null ? "passed" : "failed"; - const logPath = join2(artifactRun.runDirectory, "bombadil.log"); - const serverLogPath = join2(artifactRun.runDirectory, "server.log"); - const explorationSummaryPath = join2(artifactRun.runDirectory, "exploration-summary.json"); - const record = { - schema: ARTIFACT_SCHEMA, - evidenceClass: "diagnostic-fuzz", - artifactName: validated.artifactName, - label: validated.label, - status, - generatedAt: generatedAt.toISOString(), - completedAt: completedAt.toISOString(), - durationMs: Math.max(0, completedAt.getTime() - generatedAt.getTime()), - scenario: validated.scenario, - expectedRoute: validated.expectedRoute, - baseUrl: validated.baseUrl, - entryPath: validated.entryPath, - targetQuery: validated.targetQuery, - targetUrl: invocation.targetUrl, - viewport: validated.viewport, - explorationPolicy: validated.explorationPolicy, - specificationPath: validated.specificationPath, - replayPath, - timeLimitSeconds: replayPath === null ? parsed.timeLimitSeconds : null, - serverSource: lease?.source ?? null, - bombadil: { - version: bombadilVersion, - executable: validated.bombadilExecutable, - exitCode: processResult?.exitCode ?? null, - termination: processResult?.termination ?? null, - outputPath, - rawTracePath, - tracePath: attestation === null ? null : tracePath, - logPath - }, - server: { - logPath: serverLogPath, - logPresent: serverOutput.length > 0, - outputFailure: serverOutputFailure === null ? null : renderUnknown(serverOutputFailure) - }, - attestation, - attestationFailure: attestationFailure === null ? null : renderUnknown(attestationFailure), - explorationSummary, - explorationSummaryPath: explorationSummary === null ? null : explorationSummaryPath, - explorationSummaryFailure: explorationSummaryFailure === null ? null : renderUnknown(explorationSummaryFailure), - initialDirect: attestation?.initial ?? null, - interruptedSignal: capturedSignal, - failure: failure === null ? null : renderUnknown(failure) - }; - const log = [processResult?.stdout ?? "", processResult?.stderr ?? ""].filter((part) => part.length > 0).join(` -`); - try { - await writeFile2(logPath, `${log}${log.length > 0 ? ` -` : ""}`, "utf8"); - await writeFile2(serverLogPath, `${serverOutput}${serverOutput.length > 0 ? ` -` : ""}`, "utf8"); - if (explorationSummary !== null) { - await writeJsonAtomically(explorationSummaryPath, explorationSummary); - } - await writeJsonAtomically(join2(artifactRun.runDirectory, "run.json"), record); - await writeJsonAtomically(artifactRun.manifestPath, record); + failure = published.failure; + completedAt = dependencies.now(); + if (failure !== failureBeforeUpload) { + await writeJsonAtomically(runRecordPath, createRecord()).catch(() => { + return; + }); + } + await writeJsonAtomically(artifactRun.manifestPath, createRecord()).catch(() => { + return; + }); + const status = failure === null ? "passed" : "failed"; const exploration = explorationSummary === null ? "exploration=unavailable" : [ `nonWait=${String(explorationSummary.actions.nonWaitCount)}`, `maxWaitStreak=${String(explorationSummary.actions.maxWaitStreak)}`, @@ -3029,27 +4966,47 @@ async function runDirectBombadilFuzz(config, arguments_ = process2.argv.slice(2) kind: "run", artifactDirectory: artifactRun.runDirectory, manifestPath: artifactRun.manifestPath, - status: "passed" + receiptPath: uploadSession.receiptPath, + status: "passed", + uploadArtifactPath: uploadSession.finalDirectory }; } finally { - if (capturedSignal !== null) { - process2.kill(process2.pid, capturedSignal); + preparedUpload?.abortSignal?.removeEventListener("abort", abortFromPreparedMatrix); + for (const signal of PROCESS_INTERRUPT_SIGNALS) { + processSignals.removeListener(signal, interrupt); + } + const signalToForward = interruptedSignal; + if (signalToForward !== null && preparedUpload?.forwardSignal !== false) { + processSignals.forward(signalToForward); } } } +async function runDirectBombadilFuzz(config, input = process2.argv.slice(2), dependencyOverrides = {}) { + return await runDirectBombadilFuzzInternal(config, input, dependencyOverrides); +} // src/tooling/bombadil.ts var attestDirectBombadilTrace2 = attestDirectBombadilTrace; var summarizeDirectBombadilTrace2 = summarizeDirectBombadilTrace; -function runDirectBombadilFuzz2(config, arguments_) { - return arguments_ === undefined ? runDirectBombadilFuzz(config) : runDirectBombadilFuzz(config, arguments_); +var parseDirectBombadilArtifactReceipt2 = parseDirectBombadilArtifactReceipt; +var parseDirectBombadilSanitizedRunSummary2 = parseDirectBombadilSanitizedRunSummary; +var parseDirectBombadilMatrixReceipt2 = parseDirectBombadilMatrixReceipt; +var parseDirectBombadilMatrixSummary2 = parseDirectBombadilMatrixSummary; +var resolveDirectBombadilUploadLeaf2 = resolveDirectBombadilUploadLeaf; +function runDirectBombadilFuzz2(config, argumentsOrOptions) { + return argumentsOrOptions === undefined ? runDirectBombadilFuzz(config) : runDirectBombadilFuzz(config, argumentsOrOptions); } -function runDirectBombadilFuzzMatrix2(campaigns, arguments_) { - return arguments_ === undefined ? runDirectBombadilFuzzMatrix(campaigns) : runDirectBombadilFuzzMatrix(campaigns, arguments_); +function runDirectBombadilFuzzMatrix2(campaigns, argumentsOrOptions) { + return argumentsOrOptions === undefined ? runDirectBombadilFuzzMatrix(campaigns) : runDirectBombadilFuzzMatrix(campaigns, argumentsOrOptions); } export { summarizeDirectBombadilTrace2 as summarizeDirectBombadilTrace, runDirectBombadilFuzzMatrix2 as runDirectBombadilFuzzMatrix, runDirectBombadilFuzz2 as runDirectBombadilFuzz, + resolveDirectBombadilUploadLeaf2 as resolveDirectBombadilUploadLeaf, + parseDirectBombadilSanitizedRunSummary2 as parseDirectBombadilSanitizedRunSummary, + parseDirectBombadilMatrixSummary2 as parseDirectBombadilMatrixSummary, + parseDirectBombadilMatrixReceipt2 as parseDirectBombadilMatrixReceipt, + parseDirectBombadilArtifactReceipt2 as parseDirectBombadilArtifactReceipt, attestDirectBombadilTrace2 as attestDirectBombadilTrace }; diff --git a/dist/tooling/browser-verification-entry.js b/dist/tooling/browser-verification-entry.js index 77fa758..17d938f 100644 --- a/dist/tooling/browser-verification-entry.js +++ b/dist/tooling/browser-verification-entry.js @@ -1293,10 +1293,35 @@ async function collectStream(stream, logLimit) { output = tail(`${output}${decoder.decode(chunk.value, { stream: true })}`, logLimit); } } +function verificationProcessGroupExists(processId) { + try { + process.kill(-processId, 0); + return true; + } catch (error) { + if (error.code === "ESRCH") + return false; + if (error.code === "EPERM") + return true; + throw error; + } +} +async function waitForVerificationProcessGroupExit(processId, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (verificationProcessGroupExists(processId)) { + if (Date.now() >= deadline) { + throw new Error(`verification server process group ${String(processId)} survived cleanup`); + } + await Bun.sleep(Math.min(25, Math.max(1, deadline - Date.now()))); + } +} function spawnVerificationServer(options) { + const detachedProcessGroup = options.detachedProcessGroup ?? false; + const omittedEnvironment = new Set(options.omitEnvironment ?? []); + const environment = Object.fromEntries(Object.entries({ ...process.env, ...options.env }).filter(([name]) => !omittedEnvironment.has(name))); const process_ = Bun.spawn([...options.command], { cwd: options.cwd, - env: { ...process.env, ...options.env }, + detached: detachedProcessGroup, + env: environment, stdin: "ignore", stdout: "pipe", stderr: "pipe" @@ -1307,12 +1332,31 @@ function spawnVerificationServer(options) { collectStream(process_.stderr, logLimit) ]).then(([stdout, stderr]) => tail(`${stdout} ${stderr}`.trim(), logLimit)); + const signal = (value) => { + if (detachedProcessGroup) { + try { + process.kill(-process_.pid, value); + return; + } catch (error) { + if (error.code !== "ESRCH") + throw error; + } + } + if (process_.exitCode === null) + process_.kill(value); + }; return { exited: process_.exited, exitCode: () => process_.exitCode, + ...detachedProcessGroup ? { + killDescendants: async (timeoutMs) => { + signal("SIGKILL"); + await waitForVerificationProcessGroupExit(process_.pid, timeoutMs); + } + } : {}, output, - terminate: () => process_.kill("SIGTERM"), - kill: () => process_.kill("SIGKILL") + terminate: () => signal("SIGTERM"), + kill: () => signal("SIGKILL") }; } async function runVerificationCommand(options) { @@ -1384,6 +1428,7 @@ async function stopVerificationServerWithOutput(server, stopTimeoutMs = DEFAULT_ throw new Error(`verification server did not exit within ${stopTimeoutMs}ms after SIGKILL`); } } + await server.killDescendants?.(stopTimeoutMs); const output = await settleWithin(server.output, stopTimeoutMs); if (!output.settled) { throw new Error(`verification server output did not settle within ${stopTimeoutMs}ms after exit`); @@ -1491,7 +1536,9 @@ async function writeJsonAtomically(path, value) { `, "utf8"); await rename(temporaryPath, path); } catch (error) { - await rm(temporaryPath, { force: true }); + await rm(temporaryPath, { force: true }).catch(() => { + return; + }); throw error; } } diff --git a/docs/verification.md b/docs/verification.md index 4c5bbcf..f803312 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -334,15 +334,16 @@ Create a Bun wrapper such as `direct/fuzz-browser.ts`: ```ts #!/usr/bin/env bun +import { realpath } from "node:fs/promises"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { runDirectBombadilFuzz } from "@hraness/direct/tooling/bombadil"; const directRoot = fileURLToPath(new URL(".", import.meta.url)); -const repositoryRoot = resolve(directRoot, "../../.."); +const repositoryRoot = await realpath(resolve(directRoot, "../../..")); -await runDirectBombadilFuzz({ +const config = { artifactName: "todos", baseUrl: "http://127.0.0.1:5173", entryPath: "/direct/", @@ -379,7 +380,19 @@ await runDirectBombadilFuzz({ readinessPath: "/direct/", startupTimeoutMs: 30_000, }, -}, process.argv.slice(2)); +} as const; + +const runId = process.env.DIRECT_BOMBADIL_RUN_ID; +await runDirectBombadilFuzz(config, runId === undefined + ? process.argv.slice(2) + : { + arguments: process.argv.slice(2), + artifactRun: { + repositoryRoot, + runId, + uploadMode: "public-summary", + }, + }); ``` `baseUrl` must be an HTTP root origin on `127.0.0.1` or `localhost` with an @@ -432,6 +445,25 @@ Run random exploration for 12 to 300 seconds. The default is 20 seconds: bun direct/fuzz-browser.ts --time-limit 20s ``` +For a scheduled run, create one lowercase RFC 4122 UUID before starting the +wrapper and publish its exact upload leaf as a job output. Pass that UUID in +`artifactRun`; then an `if: always()` upload step can target only +`artifacts/direct-bombadil-upload/`, including terminal failures after +the runner accepts a valid options envelope and establishes the canonical +plan's unclaimed upload session. The runner strips +`DIRECT_BOMBADIL_RUN_ID` from native and server children. Never upload the +artifact-name root or +the whole repository artifact directory because either can include older raw +runs. + +```sh +run_id="$(bun -e 'console.log(crypto.randomUUID())')" +repository_root="$(git rev-parse --show-toplevel)" +repository_root="$(cd "$repository_root" && pwd -P)" +printf 'path=%s/artifacts/direct-bombadil-upload/%s\n' "$repository_root" "$run_id" >> "$GITHUB_OUTPUT" +DIRECT_BOMBADIL_RUN_ID="$run_id" bun direct/fuzz-browser.ts --time-limit 20s +``` + Use 12–30 seconds in the edit loop. A scheduled diagnostic lane can run each campaign for 60–300 seconds, serially, with an outer job timeout and retained failure artifacts. Random exploration should supplement the deterministic @@ -441,17 +473,29 @@ particular random path. For multiple product scenarios, pass a bounded matrix to the shared runner: ```ts -import { runDirectBombadilFuzzMatrix } from "@hraness/direct/tooling/bombadil"; +import { + resolveDirectBombadilUploadLeaf, + runDirectBombadilFuzzMatrix, +} from "@hraness/direct/tooling/bombadil"; + +const runId = process.env.DIRECT_BOMBADIL_RUN_ID; +if (runId === undefined) throw new Error("DIRECT_BOMBADIL_RUN_ID is required"); +const artifactRun = { repositoryRoot, runId, uploadMode: "public-summary" } as const; +console.log(resolveDirectBombadilUploadLeaf(artifactRun)); await runDirectBombadilFuzzMatrix([ { id: "populated", config: populatedCampaign }, { id: "empty", config: emptyCampaign }, -], process.argv.slice(2)); +], { arguments: process.argv.slice(2), artifactRun }); ``` Without `--campaign`, the matrix runs every unique campaign serially. Select one for focused work with `--campaign empty`. Replay is intentionally rejected without that selector so a trace cannot be applied to the wrong scenario. +Matrices accept only `public-summary` upload plans and publish one atomic parent +leaf after every selected campaign reaches a terminal state. Run one selected +campaign directly when access-controlled `private-vetted` diagnostics are +required. Use `--base-url` to select another local root origin. Use `--replay` with a repository-local `.jsonl` trace instead of `--time-limit` to reproduce a prior @@ -498,17 +542,21 @@ The runner invokes the exact native binary at the consumer repository root with headless mode, JavaScript instrumentation disabled, a bounded output directory, and exit-on-violation for random exploration. An outer wall-clock deadline covers the native process. Timeout, interruption, or exit triggers -bounded process-group cleanup; timeout and interruption use TERM then KILL, -while a completed leader cannot leave descendants holding output pipes. The +bounded process-group cleanup; timeout, interruption, and artifact quota +breaches use immediate KILL, and a completed leader cannot +leave descendants holding output pipes. The configured local server is always stopped through the shared browser-verification lease helpers, and its output drain remains bounded even when cleanup itself fails. -Each attempt writes `run.json`, `exploration-summary.json`, `bombadil.log`, and `server.log` below -`artifacts/direct-bombadil///`, including failures. The -rolling `manifest.json` points to the latest record. `rawTracePath` reports a -regular nonempty trace even if attestation fails; `tracePath` is present only -after exact attestation. The v2 summary strictly parses the 0.7.2 envelopes and +Once the run leaf exists, local diagnostics are written below +`artifacts/direct-bombadil///`. They can include +`run.json`, `exploration-summary.json`, `bombadil.log`, `server.log`, and the +native output. Early configuration rejection can precede that local leaf. The +rolling `manifest.json` is a convenience pointer, not authoritative evidence. +The exclusive UUID receipt and upload leaf are authoritative. `rawTracePath` +reports a regular nonempty trace even if attestation fails; `tracePath` is +present only after exact attestation. The v2 summary strictly parses the 0.7.2 envelopes and records the raw trace SHA-256, action-kind and safe target-tag counts, non-Wait count and longest Wait streak, origin-relative URL fingerprints, non-null transition-hash cardinality, canonical named-snapshot value hashes, property @@ -522,15 +570,38 @@ paths. These diagnostics describe what Bombadil happened to explore. They do not measure code, state, interaction, or Direct catalog coverage, and the raw trace remains authoritative. -Keep all generated artifacts out of source control by default. Upload a failed -scheduled run to access-controlled CI storage with a bounded retention period; -the raw trace can contain screenshots, query values, typed text, accessibility -labels, extracted values, and local paths. Preserve it long enough to inspect -and replay. Once the defect is understood, add the smallest deterministic -regression at the owning parser, reducer, port, component, semantic browser, or -Direct scenario boundary. Verify that regression fails before the fix and -passes after it. Retain a reviewed trace fixture only when replay itself adds -durable value; otherwise remove the sensitive trace after promotion. +Keep all generated artifacts out of source control by default. The default +`public-summary` upload leaf contains only a newly constructed bounded receipt +and sanitized summary. It excludes raw traces, screenshots, URLs, query +values, typed text, accessible labels, logs, absolute paths, and foreign error +messages. `private-vetted` is an explicit opt-in for access-controlled CI; it +descriptor-copies only allowlisted native files that pass the campaign count, +depth, path, per-file, and aggregate-byte quotas. Two host logs have separate +fixed capture bounds. The resulting upload must then match a newly constructed +exact-tree inventory and hashes. Symlinks, hard links, special files, unstable +files, and unapproved extensions fail closed. Live polling is best-effort +disk-pressure containment. The final post-cleanup inventory and +descriptor-bound hashes are the authoritative retained-artifact gate. +`diagnosticsRetained` says whether a private leaf retained vetted raw files. +Parse disk JSON from `unknown` with `parseDirectBombadilArtifactReceipt`, +`parseDirectBombadilSanitizedRunSummary`, `parseDirectBombadilMatrixReceipt`, +or `parseDirectBombadilMatrixSummary`; never cast `JSON.parse` output. The last +synchronous interruption check precedes atomic rename dispatch. A later signal +belongs to the caller after terminal publication. + +Bombadil and the configured server run in owned process groups, which the host +settles before the final scan. Node does not expose `openat`, so this boundary +does not claim containment against a hostile concurrent process running as the +same user. Repository scheduling and exclusive run leaves remain required. +If either writer group cannot be proven absent, the runner skips raw inventory, +attestation, and private copying and publishes only a sanitized +`writer-settlement` failure receipt. +Preserve private diagnostics only long enough to inspect and replay. Once the +defect is understood, add the smallest deterministic regression at the owning +parser, reducer, port, component, semantic browser, or Direct scenario +boundary. Verify that regression fails before the fix and passes after it. +Retain a reviewed trace fixture only when replay itself adds durable value; +otherwise remove the sensitive trace after promotion. ## Report coverage without promotion diff --git a/scripts/npm-publish-workflow.test.ts b/scripts/npm-publish-workflow.test.ts index a461459..28b2797 100644 --- a/scripts/npm-publish-workflow.test.ts +++ b/scripts/npm-publish-workflow.test.ts @@ -25,7 +25,26 @@ const publishingGuideUrl = new URL("../docs/publishing.md", import.meta.url); const agentGuideUrl = new URL("../AGENTS.md", import.meta.url); const npmRegistry = "https://registry.npmjs.org"; const repository = fileURLToPath(new URL("../", import.meta.url)); -const firstPublicSourceCommit = "c6aa5a49c531b45216e3fb043b6e0ab8a392c13d"; +const historicalRecoverySources = [ + { + commit: "c6aa5a49c531b45216e3fb043b6e0ab8a392c13d", + expectedFileCount: 56, + expectedUnpackedBytes: 697_651, + version: "0.7.5", + }, + { + commit: "3f7c821ffaff1d28ccbde1c635d95f584c1af875", + version: "0.7.6", + }, + { + commit: "8953550e298df061e9b9f4081aced158e497b906", + version: "0.7.7", + }, + { + commit: "13e5fa5d4628706d113252420b57579090363ffc", + version: "0.7.8", + }, +] as const; function workflowStepScript(workflow: string, name: string): string { const stepMarker = ` - name: ${name}\n`; @@ -265,9 +284,9 @@ describe("npm release workflows", () => { "const minimumFiles = 50", "const maximumFiles = 60", "const minimumPackedBytes = 140_000", - "const maximumPackedBytes = 180_000", + "const maximumPackedBytes = 220_000", "const minimumUnpackedBytes = 650_000", - "const maximumUnpackedBytes = 810_000", + "const maximumUnpackedBytes = 1_010_000", "record.files.length !== record.entryCount", "unpackedSize !== record.unpackedSize", 'createHash("sha1")', @@ -792,73 +811,82 @@ describe("canonical npm package identity", () => { } }); - test("current tools prepare and smoke the exact v0.7.5 source without tagged helpers", async () => { - const work = await mkdtemp(join(tmpdir(), "direct-release-recovery-test-")); - try { - const sourceArchive = join(work, "v0.7.5-source.tar"); - const sourceTree = join(work, "source"); - const packageOutput = join(work, "package"); - await mkdir(sourceTree); - await run([ - "git", - "cat-file", - "-e", - `${firstPublicSourceCommit}^{commit}`, - ], repository); - await run([ - "git", - "archive", - "--format=tar", - `--output=${sourceArchive}`, - firstPublicSourceCommit, - ], repository); - await run(["tar", "-xf", sourceArchive, "-C", sourceTree], repository); - - const manifest = JSON.parse(await readFile(join(sourceTree, "package.json"), "utf8")) as { - readonly name?: unknown; - readonly scripts?: Readonly>; - readonly version?: unknown; - }; - expect(manifest.name).toBe("@hraness/direct"); - expect(manifest.version).toBe("0.7.5"); - expect(manifest.scripts?.prepack).toBe("bun run check"); - - await rm(join(sourceTree, "scripts"), { recursive: true }); - expect(await readdir(sourceTree)).not.toContain("node_modules"); - await run([ - process.execPath, - "--no-env-file", - "--config=/dev/null", - "run", - fileURLToPath(packagePreparationUrl), - packageOutput, - ], sourceTree); - - const filename = "hraness-direct-0.7.5.tgz"; - expect(new Set(await readdir(packageOutput))).toEqual(new Set([ - filename, - "npm-pack.json", - ])); - const inventory = await inspectPackageArtifact(join(packageOutput, filename)); - expect(inventory.fileCount).toBe(56); - expect(inventory.unpackedBytes).toBe(697_651); - - await run([ - process.execPath, - "--no-env-file", - "--config=/dev/null", - "run", - fileURLToPath(packageSmokeUrl), - "--archive", - join(packageOutput, filename), - "--pack-json", - join(packageOutput, "npm-pack.json"), - ], sourceTree); - const finalSourceEntries = await readdir(sourceTree); - expect(finalSourceEntries).not.toContain("scripts"); - expect(finalSourceEntries).not.toContain("node_modules"); - } finally { - await rm(work, { force: true, recursive: true }); - } - }, 180_000); + for (const release of historicalRecoverySources) test( + `current tools prepare and smoke exact v${release.version} source without tagged helpers`, + async () => { + const work = await mkdtemp(join(tmpdir(), "direct-release-recovery-test-")); + try { + const sourceArchive = join(work, `v${release.version}-source.tar`); + const sourceTree = join(work, "source"); + const packageOutput = join(work, "package"); + await mkdir(sourceTree); + await run([ + "git", + "cat-file", + "-e", + `${release.commit}^{commit}`, + ], repository); + await run([ + "git", + "archive", + "--format=tar", + `--output=${sourceArchive}`, + release.commit, + ], repository); + await run(["tar", "-xf", sourceArchive, "-C", sourceTree], repository); + + const manifest = JSON.parse(await readFile(join(sourceTree, "package.json"), "utf8")) as { + readonly name?: unknown; + readonly scripts?: Readonly>; + readonly version?: unknown; + }; + expect(manifest.name).toBe("@hraness/direct"); + expect(manifest.version).toBe(release.version); + expect(manifest.scripts?.prepack).toBe("bun run check"); + + await rm(join(sourceTree, "scripts"), { recursive: true }); + expect(await readdir(sourceTree)).not.toContain("node_modules"); + await run([ + process.execPath, + "--no-env-file", + "--config=/dev/null", + "run", + fileURLToPath(packagePreparationUrl), + packageOutput, + ], sourceTree); + + const filename = `hraness-direct-${release.version}.tgz`; + expect(new Set(await readdir(packageOutput))).toEqual(new Set([ + filename, + "npm-pack.json", + ])); + const inventory = await inspectPackageArtifact(join(packageOutput, filename)); + if ("expectedFileCount" in release) { + expect(inventory.fileCount).toBe(release.expectedFileCount); + expect(inventory.unpackedBytes).toBe(release.expectedUnpackedBytes); + } else { + expect(inventory.fileCount).toBeGreaterThan(0); + expect(inventory.unpackedBytes).toBeGreaterThan(0); + } + + await run([ + process.execPath, + "--no-env-file", + "--config=/dev/null", + "run", + fileURLToPath(packageSmokeUrl), + "--archive", + join(packageOutput, filename), + "--pack-json", + join(packageOutput, "npm-pack.json"), + ], sourceTree); + const finalSourceEntries = await readdir(sourceTree); + expect(finalSourceEntries).not.toContain("scripts"); + expect(finalSourceEntries).not.toContain("node_modules"); + } finally { + await rm(work, { force: true, recursive: true }); + } + }, + 180_000, + ); }); diff --git a/scripts/package-artifact.ts b/scripts/package-artifact.ts index 759445b..f15fc4f 100644 --- a/scripts/package-artifact.ts +++ b/scripts/package-artifact.ts @@ -9,8 +9,8 @@ const maximumTarBytes = 2_000_000; const packageBudget = Object.freeze({ entryCount: { min: 50, max: 120 }, fileCount: { min: 50, max: 60 }, - packedBytes: { min: 140_000, max: 180_000 }, - unpackedBytes: { min: 650_000, max: 810_000 }, + packedBytes: { min: 140_000, max: 220_000 }, + unpackedBytes: { min: 650_000, max: 1_010_000 }, }); const requiredPaths = Object.freeze([ diff --git a/scripts/package-smoke.ts b/scripts/package-smoke.ts index e126a56..72917bf 100644 --- a/scripts/package-smoke.ts +++ b/scripts/package-smoke.ts @@ -328,6 +328,180 @@ function typeScriptConfig(options: { }, null, 2)}\n`; } +type BombadilFeatureProfile = "artifact-delivery" | "baseline" | "matrix"; + +function selectBombadilFeatureProfile(version: string): BombadilFeatureProfile { + if (Bun.semver.order(version, "0.7.9") >= 0) return "artifact-delivery"; + if (Bun.semver.order(version, "0.7.7") >= 0) return "matrix"; + return "baseline"; +} + +function bombadilToolingTypeChecks(profile: BombadilFeatureProfile): string { + if (profile === "artifact-delivery") { + return ` + type BombadilRunnerArity = Parameters["length"]; + type BombadilRunnerInput = Parameters[1]; + type BombadilMatrixInput = Parameters[1]; + const supportedBombadilRunnerArities: readonly BombadilRunnerArity[] = [1, 2]; + const supportedBombadilArguments = ["--time-limit=12s"] as const; + const supportedBombadilRunnerInput: BombadilRunnerInput = { + arguments: supportedBombadilArguments, + artifactRun: { + repositoryRoot: "/absolute/repository", + runId: "00000000-0000-4000-8000-000000000001", + uploadMode: "public-summary", + }, + }; + const supportedBombadilTupleInput: BombadilRunnerInput = supportedBombadilArguments; + const supportedBombadilMatrixInput: BombadilMatrixInput = { + arguments: supportedBombadilArguments, + artifactRun: { + repositoryRoot: "/absolute/repository", + runId: "00000000-0000-4000-8000-000000000002", + uploadMode: "public-summary", + }, + }; + // @ts-expect-error Packaged matrix uploads are public-summary only. + const unsupportedPrivateBombadilMatrixInput: BombadilMatrixInput = { + artifactRun: { + repositoryRoot: "/absolute/repository", + runId: "00000000-0000-4000-8000-000000000003", + uploadMode: "private-vetted", + }, + }; + // @ts-expect-error Public tooling does not expose dependency injection. + const unsupportedBombadilRunnerArity: BombadilRunnerArity = 3; + void [supportedBombadilMatrixInput, supportedBombadilRunnerArities, supportedBombadilRunnerInput, supportedBombadilTupleInput, unsupportedBombadilRunnerArity, unsupportedPrivateBombadilMatrixInput]; + `; + } + const matrixChecks = profile === "matrix" + ? ` + type BombadilMatrixInput = Parameters[1]; + const supportedBombadilMatrixInput: BombadilMatrixInput = supportedBombadilArguments; + void supportedBombadilMatrixInput; + ` + : ""; + return ` + type BombadilRunnerArity = Parameters["length"]; + type BombadilRunnerInput = Parameters[1]; + const supportedBombadilRunnerArities: readonly BombadilRunnerArity[] = [1, 2]; + const supportedBombadilArguments = ["--time-limit=12s"] as const; + const supportedBombadilRunnerInput: BombadilRunnerInput = supportedBombadilArguments; + // @ts-expect-error Public tooling does not expose dependency injection. + const unsupportedBombadilRunnerArity: BombadilRunnerArity = 3; + void [supportedBombadilRunnerArities, supportedBombadilRunnerInput, unsupportedBombadilRunnerArity]; + ${matrixChecks}`; +} + +function bombadilRuntimeImports(profile: BombadilFeatureProfile): string { + const importedNames = profile === "artifact-delivery" + ? [ + "parseDirectBombadilArtifactReceipt", + "parseDirectBombadilMatrixReceipt", + "parseDirectBombadilMatrixSummary", + "parseDirectBombadilSanitizedRunSummary", + "resolveDirectBombadilUploadLeaf", + "runDirectBombadilFuzz", + ] + : ["runDirectBombadilFuzz"]; + return `import {\n${importedNames.map((name) => ` ${name},`).join("\n")}\n } from "@hraness/direct/tooling/bombadil";`; +} + +function bombadilArtifactDeliverySmoke(profile: BombadilFeatureProfile): string { + if (profile !== "artifact-delivery") return ""; + return ` + const sha256 = "a".repeat(64); + const policy = { + maxDepth: 32, + maxEntries: 4096, + maxFileBytes: 67108864, + maxFiles: 2048, + maxPathBytes: 4096, + maxTotalBytes: 134217728, + }; + const runId = "00000000-0000-4000-8000-000000000001"; + const receipt = { + schema: "direct.bombadil-artifact-receipt/v1", + completedAt: "2026-08-29T00:00:00.000Z", + diagnosticsRetained: false, + failureCode: null, + inventory: { entryCount: 1, fileCount: 1, inventorySha256: sha256, totalBytes: 1 }, + mode: "public-summary", + policy, + runId, + status: "passed", + }; + const summary = { + schema: "direct.bombadil-upload-summary/v1", + artifactName: "package-smoke", + attestation: { invalidObservationCount: 0, observationCount: 1, validObservationCount: 1 }, + exploration: { + actionCount: 0, + nonWaitActionCount: 0, + policySatisfied: true, + traceBytes: 1, + traceLineCount: 1, + traceSha256: sha256, + }, + failureCode: null, + scenario: "package.ready", + status: "passed", + }; + const matrixReceipt = { + schema: "direct.bombadil-matrix-receipt/v1", + campaigns: [{ + campaignId: "package-smoke", + index: 0, + receipt: "campaigns/package-smoke/receipt.json", + status: "passed", + }], + completedAt: "2026-08-29T00:00:00.000Z", + failureCode: null, + mode: "public-summary", + omittedCampaignCount: 0, + runId, + status: "passed", + }; + const matrixSummary = { + schema: "direct.bombadil-matrix-summary/v1", + campaigns: { + failed: 0, + notRun: 0, + notSelected: 0, + omitted: 0, + passed: 1, + rejected: 0, + total: 1, + }, + failureCode: null, + status: "passed", + }; + if ( + !parseDirectBombadilArtifactReceipt(receipt).ok + || !parseDirectBombadilSanitizedRunSummary(summary).ok + || !parseDirectBombadilMatrixReceipt(matrixReceipt).ok + || !parseDirectBombadilMatrixSummary(matrixSummary).ok + ) { + throw new Error("Bombadil package evidence parsers rejected exact valid fixtures"); + } + if ( + parseDirectBombadilArtifactReceipt({ ...receipt, extra: true }).ok + || parseDirectBombadilMatrixReceipt({ ...matrixReceipt, schema: "wrong" }).ok + || parseDirectBombadilSanitizedRunSummary({ ...summary, failureCode: "unknown" }).ok + ) { + throw new Error("Bombadil package evidence parsers accepted malformed fixtures"); + } + const uploadLeaf = resolveDirectBombadilUploadLeaf({ + repositoryRoot: "/absolute/repository", + runId, + uploadMode: "public-summary", + }); + if (uploadLeaf !== "/absolute/repository/artifacts/direct-bombadil-upload/" + runId) { + throw new Error("Bombadil upload-leaf resolver returned an unexpected path"); + } + `; +} + const repository = process.cwd(); const packageManifest = await Bun.file(join(repository, "package.json")).json(); if ( @@ -338,6 +512,7 @@ if ( ) { throw new Error("package.json must declare a string version"); } +const bombadilFeatureProfile = selectBombadilFeatureProfile(packageManifest.version); const work = await mkdtemp(join(tmpdir(), "hraness-package-smoke-")); try { const packageInput = parsePackageInput(process.argv.slice(2), repository); @@ -386,13 +561,10 @@ try { `await Promise.all(${JSON.stringify(importSpecifiers)}.map((specifier) => import(specifier)))`, ], consumer); await writeFile(join(consumer, "runtime-index.ts"), typeImportSource(runtimeImportSpecifiers)); - await writeFile(join(consumer, "tooling-index.ts"), `${typeImportSource(toolingTypeImportSpecifiers)} - type BombadilRunnerArity = Parameters["length"]; - const supportedBombadilRunnerArities: readonly BombadilRunnerArity[] = [1, 2]; - // @ts-expect-error Public tooling does not expose dependency injection. - const unsupportedBombadilRunnerArity: BombadilRunnerArity = 3; - void [supportedBombadilRunnerArities, unsupportedBombadilRunnerArity]; - `); + await writeFile( + join(consumer, "tooling-index.ts"), + `${typeImportSource(toolingTypeImportSpecifiers)}${bombadilToolingTypeChecks(bombadilFeatureProfile)}`, + ); await writeFile(join(consumer, "tsconfig.bundler.json"), typeScriptConfig({ include: "runtime-index.ts", module: "Preserve", @@ -444,9 +616,7 @@ try { normalizeRootHttpOrigin, readDirectBrowserContract, } from "@hraness/direct/tooling/browser-verification"; - import { - runDirectBombadilFuzz, - } from "@hraness/direct/tooling/bombadil"; + ${bombadilRuntimeImports(bombadilFeatureProfile)} import { findForbiddenMarkers } from "@hraness/direct/tooling/bundle-boundary"; if (normalizeRootHttpOrigin("https://example.test/") !== "https://example.test") { @@ -465,6 +635,7 @@ try { if (typeof runDirectBombadilFuzz !== "function") { throw new Error("Bombadil host tooling runner is missing"); } + ${bombadilArtifactDeliverySmoke(bombadilFeatureProfile)} type CampaignProperties = DirectBombadilProperties; void (undefined as unknown as CampaignProperties); `); @@ -501,6 +672,10 @@ try { "browser-verification", "@antithesishq/bombadil", "direct.bombadil-run/v1", + "direct.bombadil-artifact-receipt/v1", + "direct.bombadil-upload-summary/v1", + "direct.bombadil-matrix-receipt/v1", + "direct.bombadil-matrix-summary/v1", "bundle-boundary", "node:crypto", "node:fs", diff --git a/skills/direct/references/verification.md b/skills/direct/references/verification.md index 7a395ec..7506c91 100644 --- a/skills/direct/references/verification.md +++ b/skills/direct/references/verification.md @@ -41,9 +41,26 @@ consumer. Keep the default browser properties, exported Direct formulas, and conservative Direct action generator in the campaign; keep product-specific actions and assertions local. Random runs must be 12 to 300 seconds. Require the runner's canonical post-run trace attestation even when Bombadil exits -zero, and retain raw trace, process log, server log, and failure artifacts. -Treat the result as diagnostic fuzz evidence, not as a semantic product check -or proof of any replaced system. +zero. Give every product-owned named snapshot an exact fail-closed parser or +type predicate. Treat local random walks as diagnostic exploration, not as the +deterministic simulated workload used by an Antithesis environment and not as +a semantic product check or proof of any replaced system. + +Precompute a lowercase UUID for scheduled runs and pass one exact +`artifactRun` plan. Use `resolveDirectBombadilUploadLeaf`; point `if: always()` +only at that leaf. Public CI may retain only the +bounded sanitized receipt and summary. Raw traces, screenshots, logs, paths, +foreign messages, queries, labels, and typed values require explicit +`private-vetted` access-controlled storage. Keep quotas fail-closed, require +the final descriptor-bound inventory after both process groups settle, and +never upload an artifact root that can sweep another run. +Parse retained JSON from `unknown` with `parseDirectBombadilArtifactReceipt`, +`parseDirectBombadilSanitizedRunSummary`, `parseDirectBombadilMatrixReceipt`, +or `parseDirectBombadilMatrixSummary`; never cast `JSON.parse` output. + +Campaign matrices are public-summary only and publish one atomic parent leaf +after every selected child is terminal. Run one selected campaign directly +when bounded private diagnostics are required. For the agent-browser path, use one task-owned local Chromium session and process for a sequential batch of at most eight scenarios. Before each scenario, call `window new` for a fresh diff --git a/src/exports.test.ts b/src/exports.test.ts index 6672cd7..11a73c8 100644 --- a/src/exports.test.ts +++ b/src/exports.test.ts @@ -7,6 +7,12 @@ import * as testing from "@hraness/direct/testing"; import * as browserVerification from "@hraness/direct/tooling/browser-verification"; import * as bombadil from "@hraness/direct/tooling/bombadil"; import type { DirectBombadilProperties } from "@hraness/direct/tooling/bombadil-campaign"; +import type { + DirectBombadilFuzzMatrixResult, + DirectBombadilFuzzResult, + DirectBombadilFuzzRunInput, + DirectBombadilMatrixRunInput, +} from "@hraness/direct/tooling/bombadil"; import * as bundleBoundary from "@hraness/direct/tooling/bundle-boundary"; import * as web from "@hraness/direct/web"; @@ -60,6 +66,11 @@ describe("public package exports", () => { test("host tooling stays behind explicit subpaths", () => { expect(Object.keys(bombadil).toSorted()).toEqual([ "attestDirectBombadilTrace", + "parseDirectBombadilArtifactReceipt", + "parseDirectBombadilMatrixReceipt", + "parseDirectBombadilMatrixSummary", + "parseDirectBombadilSanitizedRunSummary", + "resolveDirectBombadilUploadLeaf", "runDirectBombadilFuzz", "runDirectBombadilFuzzMatrix", "summarizeDirectBombadilTrace", @@ -69,6 +80,11 @@ describe("public package exports", () => { expect(typeof browserVerification.readDirectBrowserContract).toBe("function"); expect(typeof bombadil.runDirectBombadilFuzz).toBe("function"); expect(typeof bombadil.attestDirectBombadilTrace).toBe("function"); + expect(typeof bombadil.parseDirectBombadilArtifactReceipt).toBe("function"); + expect(typeof bombadil.parseDirectBombadilMatrixReceipt).toBe("function"); + expect(typeof bombadil.parseDirectBombadilMatrixSummary).toBe("function"); + expect(typeof bombadil.parseDirectBombadilSanitizedRunSummary).toBe("function"); + expect(typeof bombadil.resolveDirectBombadilUploadLeaf).toBe("function"); expect(typeof bombadil.runDirectBombadilFuzzMatrix).toBe("function"); expect(typeof bombadil.summarizeDirectBombadilTrace).toBe("function"); expect(typeof bundleBoundary.checkBundleBoundary).toBe("function"); @@ -79,10 +95,53 @@ describe("public package exports", () => { expect("checkBundleBoundary" in testing).toBeFalse(); type PublicRunnerArity = Parameters["length"]; const supportedRunnerArities: readonly PublicRunnerArity[] = [1, 2]; + const supportedRunOptions: DirectBombadilFuzzRunInput = { + arguments: ["--time-limit=12s"], + artifactRun: { + repositoryRoot: "/absolute/repository", + runId: "00000000-0000-4000-8000-000000000001", + uploadMode: "public-summary", + }, + }; + const supportedArgumentTuple = ["--time-limit=12s"] as const; + const supportedTupleInput: DirectBombadilFuzzRunInput = supportedArgumentTuple; + const legacyRunResult: DirectBombadilFuzzResult = { + artifactDirectory: "/absolute/repository/artifacts/direct-bombadil/package/run", + kind: "run", + manifestPath: "/absolute/repository/artifacts/direct-bombadil/package/manifest.json", + status: "passed", + }; + const legacyMatrixResult: DirectBombadilFuzzMatrixResult = { + kind: "matrix", + results: [{ campaignId: "package", result: legacyRunResult }], + }; + const supportedMatrixOptions: DirectBombadilMatrixRunInput = { + arguments: supportedArgumentTuple, + artifactRun: { + repositoryRoot: "/absolute/repository", + runId: "00000000-0000-4000-8000-000000000002", + uploadMode: "public-summary", + }, + }; + const unsupportedPrivateMatrixOptions: DirectBombadilMatrixRunInput = { + artifactRun: { + repositoryRoot: "/absolute/repository", + runId: "00000000-0000-4000-8000-000000000003", + // @ts-expect-error Matrix uploads are always sanitized public summaries. + uploadMode: "private-vetted", + }, + }; // @ts-expect-error Dependency injection stays internal to package tests. const unsupportedRunnerArity: PublicRunnerArity = 3; expect(supportedRunnerArities).toEqual([1, 2]); - void unsupportedRunnerArity; + void [ + supportedMatrixOptions, + legacyMatrixResult, + supportedRunOptions, + supportedTupleInput, + unsupportedPrivateMatrixOptions, + unsupportedRunnerArity, + ]; type CampaignProperties = DirectBombadilProperties; void (undefined as unknown as CampaignProperties); }); diff --git a/src/tooling/bombadil-runner.test.ts b/src/tooling/bombadil-runner.test.ts index 45e668c..ca05f6d 100644 --- a/src/tooling/bombadil-runner.test.ts +++ b/src/tooling/bombadil-runner.test.ts @@ -1,14 +1,33 @@ import { afterEach, describe, expect, test } from "bun:test"; import { defineDirect } from "@hraness/direct"; import { createDirectSession } from "@hraness/direct/testing"; -import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { getEventListeners } from "node:events"; +import { + chmod, + link, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { attestDirectBombadilTrace, + closeBombadilArtifactCopyHandles, createDirectBombadilInvocation, + inspectBombadilArtifactTreeForTest, + parseDirectBombadilArtifactReceipt, parseDirectBombadilFuzzArguments, + parseDirectBombadilMatrixReceipt, + parseDirectBombadilMatrixSummary, + parseDirectBombadilSanitizedRunSummary, + resolveDirectBombadilUploadLeaf, runBombadilNativeProcess, runDirectBombadilFuzz, runDirectBombadilFuzzMatrix, @@ -16,6 +35,7 @@ import { validateDirectBombadilFuzzConfig, type DirectBombadilFuzzConfig, type DirectBombadilInvocation, + type DirectBombadilMatrixRunInput, type DirectBombadilRunnerDependencies, } from "./bombadil-runner.js"; import type { @@ -23,13 +43,28 @@ import type { ServerLease, } from "./browser-verification.js"; +const ARTIFACT_IO_TEST_TIMEOUT_MS = 30_000; const temporaryDirectories: string[] = []; +function artifactRunPlan< + UploadMode extends "private-vetted" | "public-summary" = "public-summary", +>( + repositoryRoot: string, + suffix: number, + uploadMode: UploadMode = "public-summary" as UploadMode, +) { + return { + repositoryRoot, + runId: `00000000-0000-4000-8000-${String(suffix).padStart(12, "0")}`, + uploadMode, + } as const; +} + afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { force: true, recursive: true }) )); -}); +}, ARTIFACT_IO_TEST_TIMEOUT_MS); function nativeBinaryName(): string { if (process.platform === "darwin" && process.arch === "arm64") { @@ -48,7 +83,9 @@ async function fixture(): Promise<{ readonly config: DirectBombadilFuzzConfig; readonly repositoryRoot: string; }> { - const repositoryRoot = await mkdtemp(join(tmpdir(), "direct-bombadil-runner-")); + const repositoryRoot = await realpath( + await mkdtemp(join(tmpdir(), "direct-bombadil-runner-")), + ); temporaryDirectories.push(repositoryRoot); const productRoot = join(repositoryRoot, "projects", "fixture"); const specificationPath = join(productRoot, "direct", "bombadil-campaign.ts"); @@ -352,7 +389,88 @@ async function rejection(promise: Promise): Promise { throw new Error("Expected the operation to reject"); } +type ProcessKill = ( + processId: number, + signal?: NodeJS.Signals | number, +) => boolean; + +async function withProcessKillAdapter( + createAdapter: (kill: ProcessKill) => ProcessKill, + operation: () => Promise, +): Promise { + const descriptor = Object.getOwnPropertyDescriptor(process, "kill"); + if (descriptor === undefined) throw new Error("process.kill descriptor is unavailable"); + const originalKill = process.kill.bind(process); + const kill: ProcessKill = (processId, signal) => originalKill(processId, signal); + Object.defineProperty(process, "kill", { + ...descriptor, + value: createAdapter(kill), + }); + try { + return await operation(); + } finally { + Object.defineProperty(process, "kill", descriptor); + } +} + +async function waitForMissingProcessGroup(processGroupId: number): Promise { + const deadline = Date.now() + 1_000; + for (;;) { + try { + process.kill(-processGroupId, 0); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ESRCH") return; + if (code !== "EPERM") throw error; + } + if (Date.now() >= deadline) { + throw new Error(`Process group ${String(processGroupId)} survived its test cleanup`); + } + await Bun.sleep(10); + } +} + +type ControllableSignal = Parameters< + DirectBombadilRunnerDependencies["signalController"]["forward"] +>[0]; + +function controllableSignals(): { + readonly controller: DirectBombadilRunnerDependencies["signalController"]; + readonly emit: (signal: ControllableSignal) => void; + readonly forwarded: ControllableSignal[]; + readonly listenerCount: () => number; +} { + const listeners = new Map void>>(); + const forwarded: ControllableSignal[] = []; + return { + controller: { + forward: (signal) => { + forwarded.push(signal); + }, + once: (signal, listener) => { + const signalListeners = listeners.get(signal) ?? new Set(); + signalListeners.add(listener); + listeners.set(signal, signalListeners); + }, + removeListener: (signal, listener) => { + listeners.get(signal)?.delete(listener); + }, + }, + emit: (signal) => { + const signalListeners = [...(listeners.get(signal) ?? [])]; + listeners.delete(signal); + for (const listener of signalListeners) listener(signal); + }, + forwarded, + listenerCount: () => [...listeners.values()].reduce( + (total, signalListeners) => total + signalListeners.size, + 0, + ), + }; +} + function dependencies(options: { + readonly afterTrace?: (invocation: DirectBombadilInvocation) => Promise; readonly exitCode?: number; readonly failAcquire?: boolean; readonly noTrace?: boolean; @@ -368,6 +486,7 @@ function dependencies(options: { readonly serverCommands: string[][]; } { const calls: string[] = []; + const signals = controllableSignals(); const serverCommands: string[][] = []; const server = fakeServer( calls, @@ -403,13 +522,16 @@ function dependencies(options: { `node_modules/@antithesishq/bombadil/binaries/${nativeBinaryName()}`, ); return (async () => { - if (options.noTrace !== true) { + if (options.noTrace === true) { + await mkdir(invocation.outputPath, { recursive: true }); + } else { await writeTrace( join(invocation.outputPath, "trace.jsonl"), options.observations ?? [absentObservation(), directObservation()], options.traceLineOptions, ); } + await options.afterTrace?.(invocation); return { exitCode: options.exitCode ?? 0, stdout: "bombadil stdout", @@ -418,6 +540,7 @@ function dependencies(options: { }; })(); }, + signalController: signals.controller, ...(options.serverOutputTimeoutMs === undefined ? {} : { serverOutputTimeoutMs: options.serverOutputTimeoutMs }), @@ -578,10 +701,18 @@ describe("Direct Bombadil configuration and invocation", () => { ...config, artifactName: "../escape", })).toThrow("artifactName"); + expect(() => validateDirectBombadilFuzzConfig({ + ...config, + artifactName: "a".repeat(81), + })).toThrow("artifactName"); expect(() => validateDirectBombadilFuzzConfig({ ...config, scenario: "Unsafe Scenario", })).toThrow("scenario"); + expect(() => validateDirectBombadilFuzzConfig({ + ...config, + scenario: "a".repeat(121), + })).toThrow("scenario"); expect(() => validateDirectBombadilFuzzConfig({ ...config, expectedRoute: "", @@ -705,12 +836,32 @@ describe("Direct Bombadil configuration and invocation", () => { const cwdLink = join(cwdFixture.repositoryRoot, "escaped-cwd"); await symlink(outside, cwdLink); const cwdRuntime = dependencies(); - expect((await rejection(runDirectBombadilFuzz({ + const cwdPlan = artifactRunPlan(cwdFixture.repositoryRoot, 43); + const publicationFailure = new Error("forced sanitized receipt publication failure"); + const cwdError = await rejection(runDirectBombadilFuzz({ ...cwdFixture.config, server: { ...cwdFixture.config.server, cwd: cwdLink }, - }, [], cwdRuntime.overrides))).message).toContain( + }, { arguments: [], artifactRun: cwdPlan }, { + ...cwdRuntime.overrides, + beforeArtifactCommit: () => { + throw publicationFailure; + }, + })); + expect(cwdError).toBeInstanceOf(AggregateError); + expect(cwdError.message).toContain( "server.cwd resolves outside repositoryRoot", ); + expect(cwdError.message).toContain( + "sanitized Bombadil receipt publication also failed", + ); + const persistenceErrors = (cwdError as AggregateError).errors; + expect(persistenceErrors).toHaveLength(2); + expect(persistenceErrors[0]).toBeInstanceOf(Error); + expect((persistenceErrors[0] as Error).message).toBe( + "server.cwd resolves outside repositoryRoot", + ); + expect(cwdError.cause).toBe(persistenceErrors[0]); + expect(persistenceErrors[1]).toBe(publicationFailure); expect(cwdRuntime.calls).toEqual([]); const replayFixture = await fixture(); @@ -723,7 +874,7 @@ describe("Direct Bombadil configuration and invocation", () => { replayRuntime.overrides, ))).message).toContain("--replay resolves outside repositoryRoot"); expect(replayRuntime.calls).toEqual([]); - }); + }, ARTIFACT_IO_TEST_TIMEOUT_MS); test("builds an argv-only native invocation with both Direct query bindings", () => { const invocation = createDirectBombadilInvocation({ @@ -788,27 +939,43 @@ describe("Direct Bombadil configuration and invocation", () => { describe("Direct Bombadil campaign matrix", () => { test("runs unique bounded campaigns serially and selects exactly one", async () => { - const { config } = await fixture(); + const { config, repositoryRoot } = await fixture(); const campaigns = [{ id: "primary", config }, { id: "secondary", config: { ...config, artifactName: "fixture-secondary" }, }] as const; const allRuntime = dependencies(); + const allSignals = controllableSignals(); + const matrixController = new AbortController(); + let controllerCount = 0; const all = await runDirectBombadilFuzzMatrix( campaigns, ["--time-limit=12s"], - allRuntime.overrides, + { + ...allRuntime.overrides, + createAbortController: () => { + controllerCount += 1; + return controllerCount === 1 ? matrixController : new AbortController(); + }, + signalController: allSignals.controller, + }, ); expect(all).toMatchObject({ kind: "matrix", results: [{ campaignId: "primary" }, { campaignId: "secondary" }], }); expect(allRuntime.calls.filter((call) => call === "run-bombadil")).toHaveLength(2); + expect(allSignals.listenerCount()).toBe(0); + expect(getEventListeners(matrixController.signal, "abort")).toHaveLength(0); const selectedRuntime = dependencies(); + const selectedPlan = artifactRunPlan(repositoryRoot, 20); const selected = await runDirectBombadilFuzzMatrix( campaigns, - ["--campaign=secondary", "--time-limit=12s"], + { + arguments: ["--campaign=secondary", "--time-limit=12s"], + artifactRun: selectedPlan, + }, selectedRuntime.overrides, ); expect(selected).toMatchObject({ @@ -816,6 +983,17 @@ describe("Direct Bombadil campaign matrix", () => { results: [{ campaignId: "secondary" }], }); expect(selectedRuntime.calls.filter((call) => call === "run-bombadil")).toHaveLength(1); + if (selected.kind !== "matrix") throw new Error("Expected a matrix result"); + expect(JSON.parse(await readFile(selected.receiptPath, "utf8"))).toMatchObject({ + campaigns: [ + { campaignId: "primary", receipt: null, status: "not-selected" }, + { + campaignId: "secondary", + receipt: "campaigns/secondary/receipt.json", + status: "passed", + }, + ], + }); }); test("rejects ambiguous replay, duplicate IDs, and unknown selection", async () => { @@ -833,6 +1011,526 @@ describe("Direct Bombadil campaign matrix", () => { { id: "same", config }, { id: "same", config: { ...config, artifactName: "other" } }, ], []))).message).toContain("unique lowercase kebab"); + }, ARTIFACT_IO_TEST_TIMEOUT_MS); + + test("publishes rejected and partially executed matrix terminal states", async () => { + const { config, repositoryRoot } = await fixture(); + const duplicatePlan = artifactRunPlan(repositoryRoot, 21); + await rejection(runDirectBombadilFuzzMatrix([ + { id: "same", config }, + { id: "same", config: { ...config, artifactName: "other" } }, + ], { arguments: [], artifactRun: duplicatePlan })); + const duplicateReceipt = parseDirectBombadilMatrixReceipt(JSON.parse(await readFile(join( + repositoryRoot, + "artifacts", + "direct-bombadil-upload", + duplicatePlan.runId, + "receipt.json", + ), "utf8"))); + expect(duplicateReceipt.ok).toBeTrue(); + if (!duplicateReceipt.ok) throw new Error("Expected a parser-valid rejected matrix receipt"); + expect(duplicateReceipt.value).toMatchObject({ + schema: "direct.bombadil-matrix-receipt/v1", + failureCode: "configuration-rejected", + status: "failed", + campaigns: [ + { campaignId: "same", index: 0, receipt: null, status: "rejected" }, + { campaignId: null, index: 1, receipt: null, status: "rejected" }, + ], + }); + + const runtime = dependencies(); + const baseRunBombadil = runtime.overrides.runBombadil; + if (baseRunBombadil === undefined) throw new Error("Expected fixture Bombadil dependency"); + let invocationCount = 0; + const partialPlan = artifactRunPlan(repositoryRoot, 22); + await rejection(runDirectBombadilFuzzMatrix([ + { id: "first", config }, + { id: "second", config: { ...config, artifactName: "fixture-second" } }, + { id: "third", config: { ...config, artifactName: "fixture-third" } }, + ], { arguments: [], artifactRun: partialPlan }, { + ...runtime.overrides, + runBombadil: async (invocation) => { + invocationCount += 1; + const result = await baseRunBombadil(invocation); + return invocationCount === 2 ? { ...result, exitCode: 9 } : result; + }, + })); + expect(invocationCount).toBe(2); + const partialReceipt = JSON.parse(await readFile(join( + repositoryRoot, + "artifacts", + "direct-bombadil-upload", + partialPlan.runId, + "receipt.json", + ), "utf8")) as Record; + expect(partialReceipt).toMatchObject({ + schema: "direct.bombadil-matrix-receipt/v1", + status: "failed", + campaigns: [ + { campaignId: "first", status: "passed" }, + { campaignId: "second", status: "failed" }, + { campaignId: "third", status: "not-run" }, + ], + }); + }); + + test("bounds rejected matrices and rejects private matrix uploads with a public receipt", async () => { + const { config, repositoryRoot } = await fixture(); + const campaigns = Array.from({ length: 34 }, (_, index) => ({ + config, + id: `campaign-${String(index)}`, + })); + const oversizedPlan = artifactRunPlan(repositoryRoot, 23); + await rejection(runDirectBombadilFuzzMatrix(campaigns, { + arguments: [], + artifactRun: oversizedPlan, + })); + const oversizedReceipt = JSON.parse(await readFile(join( + repositoryRoot, + "artifacts", + "direct-bombadil-upload", + oversizedPlan.runId, + "receipt.json", + ), "utf8")) as Record; + expect(oversizedReceipt).toMatchObject({ + mode: "public-summary", + omittedCampaignCount: 2, + status: "failed", + }); + expect(oversizedReceipt.campaigns).toHaveLength(32); + + const privatePlan = artifactRunPlan(repositoryRoot, 24, "private-vetted"); + const privateInput = { + arguments: [], + artifactRun: privatePlan, + } as unknown as DirectBombadilMatrixRunInput; + const privateError = await rejection(runDirectBombadilFuzzMatrix( + [{ id: "primary", config }], + privateInput, + )); + expect(privateError.message).toContain("public-summary"); + const privateReceipt = JSON.parse(await readFile(join( + repositoryRoot, + "artifacts", + "direct-bombadil-upload", + privatePlan.runId, + "receipt.json", + ), "utf8")) as Record; + expect(privateReceipt).toMatchObject({ + failureCode: "configuration-rejected", + mode: "public-summary", + status: "failed", + }); + + const longIdPlan = artifactRunPlan(repositoryRoot, 25); + await rejection(runDirectBombadilFuzzMatrix([ + { id: "a".repeat(81), config }, + ], { arguments: [], artifactRun: longIdPlan })); + expect(JSON.parse(await readFile(join( + repositoryRoot, + "artifacts", + "direct-bombadil-upload", + longIdPlan.runId, + "receipt.json", + ), "utf8"))).toMatchObject({ + campaigns: [{ campaignId: null, status: "rejected" }], + }); + }); + + test("publishes one interrupted matrix leaf before forwarding its signal", async () => { + const { config, repositoryRoot } = await fixture(); + const runtime = dependencies(); + const baseRunBombadil = runtime.overrides.runBombadil; + if (baseRunBombadil === undefined) throw new Error("Expected fixture Bombadil dependency"); + const signals = controllableSignals(); + const plan = artifactRunPlan(repositoryRoot, 26); + const error = await rejection(runDirectBombadilFuzzMatrix( + [{ id: "primary", config }, { + id: "secondary", + config: { ...config, artifactName: "fixture-secondary" }, + }], + { arguments: [], artifactRun: plan }, + { + ...runtime.overrides, + runBombadil: async (invocation) => { + const result = await baseRunBombadil(invocation); + signals.emit("SIGTERM"); + return result; + }, + signalController: signals.controller, + }, + )); + expect(error.message).toContain("SIGTERM"); + expect(signals.forwarded).toEqual(["SIGTERM"]); + expect(signals.listenerCount()).toBe(0); + expect(JSON.parse(await readFile(join( + repositoryRoot, + "artifacts", + "direct-bombadil-upload", + plan.runId, + "receipt.json", + ), "utf8"))).toMatchObject({ + failureCode: "interrupted", + campaigns: [ + { campaignId: "primary", status: "failed" }, + { campaignId: "secondary", status: "not-run" }, + ], + status: "failed", + }); + }); + + test("preserves a child configuration rejection in the parent receipt", async () => { + const { config, repositoryRoot } = await fixture(); + const mutableConfig = { ...config }; + let controllerCount = 0; + const plan = artifactRunPlan(repositoryRoot, 27); + await rejection(runDirectBombadilFuzzMatrix( + [{ id: "primary", config: mutableConfig }, { + id: "secondary", + config: { ...config, artifactName: "fixture-secondary" }, + }], + { arguments: [], artifactRun: plan }, + { + ...dependencies().overrides, + createAbortController: () => { + controllerCount += 1; + if (controllerCount === 2) mutableConfig.artifactName = "../rejected"; + return new AbortController(); + }, + }, + )); + expect(JSON.parse(await readFile(join( + resolveDirectBombadilUploadLeaf(plan), + "receipt.json", + ), "utf8"))).toMatchObject({ + failureCode: "configuration-rejected", + campaigns: [ + { campaignId: "primary", status: "rejected" }, + { campaignId: "secondary", status: "not-run" }, + ], + status: "failed", + }); + expect(JSON.parse(await readFile(join( + resolveDirectBombadilUploadLeaf(plan), + "campaigns", + "primary", + "receipt.json", + ), "utf8"))).toMatchObject({ + failureCode: "configuration-rejected", + status: "rejected", + }); + expect(await readFile(join( + resolveDirectBombadilUploadLeaf(plan), + "campaigns", + "primary", + "summary.json", + ), "utf8")).toContain("direct.bombadil-upload-summary/v1"); + }); + + test("interrupts a child before acquisition and leaves no signal listeners", async () => { + const { config, repositoryRoot } = await fixture(); + const signals = controllableSignals(); + const runtime = dependencies(); + const plan = artifactRunPlan(repositoryRoot, 28); + let runIdCount = 0; + await rejection(runDirectBombadilFuzzMatrix( + [{ id: "primary", config }, { + id: "secondary", + config: { ...config, artifactName: "fixture-secondary" }, + }], + { arguments: [], artifactRun: plan }, + { + ...runtime.overrides, + createRunId: () => { + runIdCount += 1; + if (runIdCount === 1) signals.emit("SIGTERM"); + return `10000000-0000-4000-8000-${String(runIdCount).padStart(12, "0")}`; + }, + signalController: signals.controller, + }, + )); + expect(runtime.calls).toEqual([]); + expect(signals.forwarded).toEqual(["SIGTERM"]); + expect(signals.listenerCount()).toBe(0); + expect(JSON.parse(await readFile(join( + resolveDirectBombadilUploadLeaf(plan), + "receipt.json", + ), "utf8"))).toMatchObject({ + failureCode: "interrupted", + campaigns: [ + { campaignId: "primary", status: "failed" }, + { campaignId: "secondary", status: "not-run" }, + ], + }); + }, ARTIFACT_IO_TEST_TIMEOUT_MS); + + test("converts a parent-publication interruption and releases child abort listeners", async () => { + const { config, repositoryRoot } = await fixture(); + const signals = controllableSignals(); + const matrixController = new AbortController(); + let controllerCount = 0; + let commitCount = 0; + const plan = artifactRunPlan(repositoryRoot, 29); + const error = await rejection(runDirectBombadilFuzzMatrix( + [{ id: "primary", config }, { + id: "secondary", + config: { ...config, artifactName: "fixture-secondary" }, + }], + { arguments: [], artifactRun: plan }, + { + ...dependencies().overrides, + beforeArtifactCommit: () => { + commitCount += 1; + signals.emit("SIGTERM"); + }, + createAbortController: () => { + controllerCount += 1; + return controllerCount === 1 ? matrixController : new AbortController(); + }, + signalController: signals.controller, + }, + )); + expect(error.message).toContain("SIGTERM"); + expect(commitCount).toBe(1); + expect(signals.forwarded).toEqual(["SIGTERM"]); + expect(signals.listenerCount()).toBe(0); + expect(getEventListeners(matrixController.signal, "abort")).toHaveLength(0); + expect(JSON.parse(await readFile(join( + resolveDirectBombadilUploadLeaf(plan), + "receipt.json", + ), "utf8"))).toMatchObject({ + failureCode: "interrupted", + campaigns: [{ status: "passed" }, { status: "passed" }], + status: "failed", + }); + }, ARTIFACT_IO_TEST_TIMEOUT_MS); + + test("removes a failed matrix publication staging leaf", async () => { + const { config, repositoryRoot } = await fixture(); + const plan = artifactRunPlan(repositoryRoot, 42); + const error = await rejection(runDirectBombadilFuzzMatrix( + [{ id: "primary", config }], + { arguments: [], artifactRun: plan }, + { + ...dependencies().overrides, + beforeArtifactCommit: () => { + throw new Error("matrix precommit rejected"); + }, + }, + )); + expect(error.message).toContain("matrix precommit rejected"); + expect(await readdir(dirname(resolveDirectBombadilUploadLeaf(plan)))).toEqual([]); + }); +}); + +describe("Direct Bombadil sanitized evidence contracts", () => { + test("closes the source descriptor even when destination cleanup fails", async () => { + const calls: string[] = []; + const destinationError = new Error("destination close failed"); + const error = await rejection(closeBombadilArtifactCopyHandles( + { + close: async () => { + calls.push("destination"); + throw destinationError; + }, + }, + { + close: async () => { + calls.push("source"); + }, + }, + )); + expect(error).toBe(destinationError); + expect(calls).toEqual(["destination", "source"]); + + const both = await rejection(closeBombadilArtifactCopyHandles( + { close: async () => { throw new Error("destination"); } }, + { close: async () => { throw new Error("source"); } }, + )); + expect(both).toBeInstanceOf(AggregateError); + expect((both as AggregateError).errors).toHaveLength(2); + }); + + test("round-trips all four emitted evidence files and resolves the exact upload leaf", async () => { + const { config, repositoryRoot } = await fixture(); + const runPlan = artifactRunPlan(repositoryRoot, 31); + const run = await runDirectBombadilFuzz(config, { + arguments: [], + artifactRun: runPlan, + }, dependencies().overrides); + if (run.kind !== "run") throw new Error("Expected a run result"); + expect(run.uploadArtifactPath).toBe(resolveDirectBombadilUploadLeaf(runPlan)); + const runReceipt = parseDirectBombadilArtifactReceipt(JSON.parse(await readFile( + join(run.uploadArtifactPath, "receipt.json"), + "utf8", + ))); + const runSummary = parseDirectBombadilSanitizedRunSummary(JSON.parse(await readFile( + join(run.uploadArtifactPath, "summary.json"), + "utf8", + ))); + + const matrixPlan = artifactRunPlan(repositoryRoot, 32); + const matrix = await runDirectBombadilFuzzMatrix( + [{ id: "primary", config }], + { arguments: [], artifactRun: matrixPlan }, + dependencies().overrides, + ); + if (matrix.kind !== "matrix") throw new Error("Expected a matrix result"); + const matrixReceipt = parseDirectBombadilMatrixReceipt(JSON.parse(await readFile( + join(matrix.uploadArtifactPath, "receipt.json"), + "utf8", + ))); + const matrixSummary = parseDirectBombadilMatrixSummary(JSON.parse(await readFile( + join(matrix.uploadArtifactPath, "summary.json"), + "utf8", + ))); + for (const parsed of [runReceipt, runSummary, matrixReceipt, matrixSummary]) { + expect(parsed.ok).toBeTrue(); + if (parsed.ok) expect(Object.isFrozen(parsed.value)).toBeTrue(); + } + if (!runReceipt.ok || !matrixReceipt.ok || !matrixSummary.ok) { + throw new Error("Expected parsed Bombadil evidence"); + } + expect(Object.isFrozen(runReceipt.value.inventory)).toBeTrue(); + expect(Object.isFrozen(runReceipt.value.policy)).toBeTrue(); + expect(Object.isFrozen(matrixReceipt.value.campaigns)).toBeTrue(); + expect(Object.isFrozen(matrixSummary.value.campaigns)).toBeTrue(); + expect(resolveDirectBombadilUploadLeaf({ + repositoryRoot, + runId: runPlan.runId, + })).toBe(run.uploadArtifactPath); + expect(resolveDirectBombadilUploadLeaf({ + ...runPlan, + uploadMode: "private-vetted", + })).toBe(run.uploadArtifactPath); + expect(() => resolveDirectBombadilUploadLeaf({ + ...runPlan, + repositoryRoot: `${repositoryRoot}/../invalid`, + })).toThrow("absolute normalized path"); + expect(() => resolveDirectBombadilUploadLeaf({ + ...runPlan, + runId: "not-a-uuid", + })).toThrow("lowercase RFC 4122 UUID"); + expect(() => resolveDirectBombadilUploadLeaf({ + ...runPlan, + uploadMode: "invalid" as "public-summary", + })).toThrow("public-summary or private-vetted"); + }); + + test("rejects hostile values, exact-key tampering, and impossible terminal states", async () => { + const { config, repositoryRoot } = await fixture(); + const runPlan = artifactRunPlan(repositoryRoot, 33); + const run = await runDirectBombadilFuzz(config, { + arguments: [], + artifactRun: runPlan, + }, dependencies().overrides); + if (run.kind !== "run") throw new Error("Expected a run result"); + const receipt = record(JSON.parse(await readFile( + join(run.uploadArtifactPath, "receipt.json"), + "utf8", + )), "run receipt"); + const summary = record(JSON.parse(await readFile( + join(run.uploadArtifactPath, "summary.json"), + "utf8", + )), "run summary"); + expect(parseDirectBombadilArtifactReceipt({ ...receipt, extra: true }).ok).toBeFalse(); + expect(parseDirectBombadilArtifactReceipt({ ...receipt, schema: "wrong" }).ok).toBeFalse(); + expect(parseDirectBombadilArtifactReceipt({ + ...receipt, + inventory: { + ...record(receipt.inventory, "run receipt inventory"), + fileCount: 0, + }, + }).ok).toBeFalse(); + expect(parseDirectBombadilArtifactReceipt({ + ...receipt, + inventory: { + entryCount: 0, + fileCount: 0, + inventorySha256: null, + totalBytes: 0, + }, + }).ok).toBeFalse(); + expect(parseDirectBombadilArtifactReceipt({ + ...receipt, + diagnosticsRetained: true, + failureCode: "interrupted", + mode: "private-vetted", + status: "failed", + }).ok).toBeFalse(); + expect(parseDirectBombadilArtifactReceipt({ + ...receipt, + failureCode: "writer-settlement", + status: "failed", + }).ok).toBeFalse(); + const accessorReceipt = Object.defineProperty({ ...receipt }, "status", { + enumerable: true, + get: () => "passed", + }); + expect(parseDirectBombadilArtifactReceipt(accessorReceipt).ok).toBeFalse(); + expect(parseDirectBombadilArtifactReceipt(new Proxy(receipt, { + ownKeys: () => { + throw new Error("hostile proxy"); + }, + })).ok).toBeFalse(); + expect(parseDirectBombadilSanitizedRunSummary({ + ...summary, + attestation: null, + }).ok).toBeFalse(); + const failedSummary = { ...summary, failureCode: "unknown", status: "failed" }; + expect(parseDirectBombadilSanitizedRunSummary({ + ...failedSummary, + attestation: { + invalidObservationCount: 0, + observationCount: 0, + validObservationCount: 0, + }, + }).ok).toBeFalse(); + expect(parseDirectBombadilSanitizedRunSummary({ + ...summary, + failureCode: "writer-settlement", + status: "failed", + }).ok).toBeFalse(); + expect(parseDirectBombadilSanitizedRunSummary({ + ...failedSummary, + exploration: { + ...record(summary.exploration, "run summary exploration"), + traceLineCount: 1, + }, + }).ok).toBeFalse(); + + const matrixPlan = artifactRunPlan(repositoryRoot, 34); + const matrix = await runDirectBombadilFuzzMatrix( + [{ id: "primary", config }], + { arguments: [], artifactRun: matrixPlan }, + dependencies().overrides, + ); + if (matrix.kind !== "matrix") throw new Error("Expected a matrix result"); + const matrixReceipt = record(JSON.parse(await readFile( + join(matrix.uploadArtifactPath, "receipt.json"), + "utf8", + )), "matrix receipt"); + const matrixSummary = record(JSON.parse(await readFile( + join(matrix.uploadArtifactPath, "summary.json"), + "utf8", + )), "matrix summary"); + const campaigns = matrixReceipt.campaigns; + if (!Array.isArray(campaigns)) throw new Error("Expected matrix campaigns"); + expect(parseDirectBombadilMatrixReceipt({ + ...matrixReceipt, + campaigns: campaigns.map((campaign) => ({ + ...record(campaign, "matrix campaign"), + receipt: "campaigns/primary/other.json", + })), + }).ok).toBeFalse(); + expect(parseDirectBombadilMatrixSummary({ + ...matrixSummary, + campaigns: { + ...record(matrixSummary.campaigns, "matrix summary campaigns"), + omitted: 1, + }, + }).ok).toBeFalse(); }); }); @@ -1020,13 +1718,13 @@ describe("Direct Bombadil trace attestation", () => { expectedRoute: "/surface", expectedScenario: "surface.ready", tracePath, - }))).message).toContain("nonempty trace.jsonl"); + }))).message).toContain("not an openable regular file"); await writeFile(tracePath, "", "utf8"); expect((await rejection(attestDirectBombadilTrace({ expectedRoute: "/surface", expectedScenario: "surface.ready", tracePath, - }))).message).toContain("nonempty trace.jsonl"); + }))).message).toContain("not a bounded regular file"); }); }); @@ -1589,6 +2287,153 @@ describe("Direct Bombadil exploration summary", () => { }); describe("Direct Bombadil process lifecycle", () => { + test("tolerates only live-scan entry disappearance and fails final proof closed", async () => { + const directory = await mkdtemp(join(tmpdir(), "direct-bombadil-entry-race-")); + temporaryDirectories.push(directory); + const transientPath = join(directory, "transient.log"); + const policy = { + maxDepth: 4, + maxEntries: 8, + maxFileBytes: 1_024, + maxFiles: 4, + maxPathBytes: 256, + maxTotalBytes: 2_048, + }; + await writeFile(transientPath, "transient\n"); + const transient = await rejection(inspectBombadilArtifactTreeForTest({ + allowTransientEntryAbsence: true, + beforeEntryInspect: async (path) => { + await rm(path); + }, + hashFiles: false, + policy, + root: directory, + })); + expect((transient as NodeJS.ErrnoException).code).toBe("ENOENT"); + + await writeFile(transientPath, "final\n"); + const final = await rejection(inspectBombadilArtifactTreeForTest({ + beforeEntryInspect: async (path) => { + await rm(path); + }, + hashFiles: true, + policy, + root: directory, + })); + expect(final.name).toBe("BombadilArtifactPolicyError"); + expect(final.message).toContain("could not be inspected safely"); + + const nested = join(directory, "nested"); + await mkdir(nested); + await writeFile(join(nested, "trace.log"), "transient\n"); + const nestedTransient = await rejection(inspectBombadilArtifactTreeForTest({ + allowTransientEntryAbsence: true, + beforeDirectoryOpen: async (path) => { + if (path === nested) await rm(path, { recursive: true }); + }, + hashFiles: false, + policy, + root: directory, + })); + expect((nestedTransient as NodeJS.ErrnoException).code).toBe("ENOENT"); + + await mkdir(nested); + await writeFile(join(nested, "trace.log"), "final\n"); + const nestedFinal = await rejection(inspectBombadilArtifactTreeForTest({ + beforeDirectoryOpen: async (path) => { + if (path === nested) await rm(path, { recursive: true }); + }, + hashFiles: true, + policy, + root: directory, + })); + expect(nestedFinal.name).toBe("BombadilArtifactPolicyError"); + expect(nestedFinal.message).toMatch( + /Bombadil artifact directory could not be (?:opened|inspected) safely:/u, + ); + }); + + test("omits the upload coordination UUID from the native process environment", async () => { + const directory = await mkdtemp(join(tmpdir(), "direct-bombadil-native-environment-")); + temporaryDirectories.push(directory); + const previous = process.env.DIRECT_BOMBADIL_RUN_ID; + process.env.DIRECT_BOMBADIL_RUN_ID = "child-visible-secret"; + const running = runBombadilNativeProcess({ + command: [ + process.execPath, + "-e", + "console.log(process.env.DIRECT_BOMBADIL_RUN_ID ?? 'absent')", + ], + cwd: directory, + outputPath: directory, + targetUrl: "http://127.0.0.1:4919/", + wallClockTimeoutMs: 5_000, + }); + if (previous === undefined) delete process.env.DIRECT_BOMBADIL_RUN_ID; + else process.env.DIRECT_BOMBADIL_RUN_ID = previous; + const result = await running; + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe("absent"); + }); + + test("aborts the owned process group when live artifacts exceed quota", async () => { + const directory = await mkdtemp(join(tmpdir(), "direct-bombadil-artifact-quota-")); + temporaryDirectories.push(directory); + const overflowPath = join(directory, "overflow.log"); + const source = [ + "const fs = require('node:fs');", + `fs.writeFileSync(${JSON.stringify(overflowPath)}, Buffer.alloc(4096));`, + "process.on('SIGTERM', () => {});", + "setInterval(() => {}, 1000);", + ].join(" "); + const startedAt = Date.now(); + const error = await rejection(runBombadilNativeProcess({ + artifactPolicy: { + maxDepth: 4, + maxEntries: 8, + maxFileBytes: 1_024, + maxFiles: 4, + maxPathBytes: 256, + maxTotalBytes: 2_048, + }, + command: [process.execPath, "-e", source], + cwd: directory, + outputPath: directory, + targetUrl: "http://127.0.0.1:4919/", + terminationGraceMs: 50, + wallClockTimeoutMs: 5_000, + })); + expect(error.message).toContain("per-file byte quota"); + expect(Date.now() - startedAt).toBeLessThan(2_000); + }); + + test("promotes an artifact-policy result that races a clean process exit", async () => { + const directory = await mkdtemp(join(tmpdir(), "direct-bombadil-artifact-exit-race-")); + temporaryDirectories.push(directory); + const overflowPath = join(directory, "overflow.log"); + const error = await rejection(runBombadilNativeProcess({ + artifactPolicy: { + maxDepth: 4, + maxEntries: 8, + maxFileBytes: 1_024, + maxFiles: 4, + maxPathBytes: 256, + maxTotalBytes: 2_048, + }, + command: [ + process.execPath, + "-e", + `require("node:fs").writeFileSync(${JSON.stringify(overflowPath)}, Buffer.alloc(4096));`, + ], + cwd: directory, + outputPath: directory, + targetUrl: "http://127.0.0.1:4919/", + terminationGraceMs: 50, + wallClockTimeoutMs: 5_000, + })); + expect(error.message).toContain("per-file byte quota"); + }); + test("cleans descendants and inherited pipes after a normal leader exit", async () => { const directory = await mkdtemp(join(tmpdir(), "direct-bombadil-normal-exit-")); temporaryDirectories.push(directory); @@ -1617,6 +2462,117 @@ describe("Direct Bombadil process lifecycle", () => { expect(Date.now() - startedAt).toBeLessThan(3_500); }, 10_000); + test("retries a transient EPERM process-group probe without signaling again", async () => { + const directory = await mkdtemp(join(tmpdir(), "direct-bombadil-probe-eperm-")); + temporaryDirectories.push(directory); + const childSource = [ + "process.on('SIGTERM', () => {});", + "setTimeout(() => process.exit(0), 5000);", + "setInterval(() => {}, 1000);", + ].join(" "); + const leaderSource = [ + "const { spawn } = require('node:child_process');", + `const child = spawn(process.execPath, ['-e', ${JSON.stringify(childSource)}], { stdio: ['ignore', 'inherit', 'inherit'] });`, + "child.unref();", + "console.log('normal leader output');", + ].join(" "); + let processGroupId: number | null = null; + let processGroupKills = 0; + let processGroupProbes = 0; + const result = await withProcessKillAdapter( + (kill) => (processId, signal) => { + if (processId < 0 && signal === "SIGKILL") { + processGroupId = -processId; + processGroupKills += 1; + } + if ( + processGroupId !== null + && processId === -processGroupId + && signal === 0 + ) { + processGroupProbes += 1; + if (processGroupProbes === 1) { + throw Object.assign(new Error("synthetic transient process-group probe"), { + code: "EPERM", + }); + } + } + return kill(processId, signal); + }, + async () => await runBombadilNativeProcess({ + command: [process.execPath, "-e", leaderSource], + cwd: directory, + outputPath: directory, + targetUrl: "http://127.0.0.1:4919/", + terminationGraceMs: 100, + wallClockTimeoutMs: 5_000, + }), + ); + expect(result).toMatchObject({ exitCode: 0, termination: null }); + expect(result.stdout).toContain("normal leader output"); + expect(processGroupKills).toBe(1); + expect(processGroupProbes).toBeGreaterThanOrEqual(2); + }, 10_000); + + test("fails closed after a persistent EPERM process-group probe", async () => { + const directory = await mkdtemp(join(tmpdir(), "direct-bombadil-probe-eperm-timeout-")); + temporaryDirectories.push(directory); + const processIdPath = join(directory, "process.pid"); + const controller = new AbortController(); + let processGroupId: number | null = null; + let processGroupKills = 0; + let processGroupProbes = 0; + const failure = await withProcessKillAdapter( + (kill) => (processId, signal) => { + if (processId < 0 && signal === "SIGKILL") { + processGroupId = -processId; + processGroupKills += 1; + } + if ( + processGroupId !== null + && processId === -processGroupId + && signal === 0 + ) { + processGroupProbes += 1; + throw Object.assign(new Error("synthetic persistent process-group probe"), { + code: "EPERM", + }); + } + return kill(processId, signal); + }, + async () => { + const running = runBombadilNativeProcess({ + abortSignal: controller.signal, + command: [ + process.execPath, + "-e", + `require("node:fs").writeFileSync(${JSON.stringify(processIdPath)}, String(process.pid)); setTimeout(() => process.exit(0), 5000); setInterval(() => {}, 1000);`, + ], + cwd: directory, + outputPath: directory, + targetUrl: "http://127.0.0.1:4919/", + terminationGraceMs: 50, + wallClockTimeoutMs: 5_000, + }); + for (let attempt = 0; attempt < 100 && !(await Bun.file(processIdPath).exists()); attempt += 1) { + await Bun.sleep(10); + } + expect(await Bun.file(processIdPath).exists()).toBeTrue(); + controller.abort(); + return await rejection(running); + }, + ); + expect(failure.name).toBe("BombadilWriterSettlementError"); + expect(failure.message).toContain("did not settle safely"); + expect(processGroupKills).toBe(1); + expect(processGroupProbes).toBeGreaterThanOrEqual(2); + const settledProcessGroupId = processGroupId; + if (settledProcessGroupId === null) { + throw new Error("Expected an owned process-group signal"); + } + await waitForMissingProcessGroup(settledProcessGroupId); + }, 10_000); + test("kills an uncooperative native child after the outer wall-clock limit", async () => { const directory = await mkdtemp(join(tmpdir(), "direct-bombadil-timeout-")); temporaryDirectories.push(directory); @@ -1660,7 +2616,7 @@ describe("Direct Bombadil process lifecycle", () => { expect(Date.now() - startedAt).toBeLessThan(2_000); }); - test("aborts and escalates an uncooperative native child promptly", async () => { + test("kills an uncooperative native child immediately on abort", async () => { const directory = await mkdtemp(join(tmpdir(), "direct-bombadil-abort-")); temporaryDirectories.push(directory); const controller = new AbortController(); @@ -1683,6 +2639,58 @@ describe("Direct Bombadil process lifecycle", () => { expect(result.stdout).toContain("abort output"); expect(Date.now() - startedAt).toBeLessThan(2_000); }); + + test("gives an aborted artifact writer no quota-growing TERM grace", async () => { + const directory = await mkdtemp(join(tmpdir(), "direct-bombadil-abort-quota-")); + temporaryDirectories.push(directory); + const growingPath = join(directory, "growing.log"); + const controller = new AbortController(); + const source = [ + "const fs = require('node:fs');", + `const path = ${JSON.stringify(growingPath)};`, + "process.on('SIGTERM', () => fs.appendFileSync(path, Buffer.alloc(8192)));", + "fs.writeFileSync(path, Buffer.alloc(256));", + "setInterval(() => {}, 1000);", + ].join(" "); + const running = runBombadilNativeProcess({ + abortSignal: controller.signal, + artifactPolicy: { + maxDepth: 4, + maxEntries: 8, + maxFileBytes: 4_096, + maxFiles: 4, + maxPathBytes: 256, + maxTotalBytes: 8_192, + }, + command: [process.execPath, "-e", source], + cwd: directory, + outputPath: directory, + targetUrl: "http://127.0.0.1:4919/", + terminationGraceMs: 2_000, + wallClockTimeoutMs: 5_000, + }); + let ready = false; + for (let attempt = 0; attempt < 100; attempt += 1) { + try { + await readFile(growingPath); + ready = true; + break; + } catch { + await Bun.sleep(10); + } + } + if (!ready) { + controller.abort(); + await running; + throw new Error("Bombadil writer did not publish its readiness file"); + } + const startedAt = Date.now(); + controller.abort(); + const result = await running; + expect(result.termination).toBe("aborted"); + expect((await readFile(growingPath)).byteLength).toBeLessThanOrEqual(4_096); + expect(Date.now() - startedAt).toBeLessThan(1_500); + }); }); describe("Direct Bombadil run lifecycle", () => { @@ -1741,6 +2749,190 @@ describe("Direct Bombadil run lifecycle", () => { schema: "direct.bombadil-exploration-summary/v2", trace: { lineCount: 2 }, }); + if (result.kind !== "run") throw new Error("Expected a Bombadil run result"); + expect((await readdir(result.uploadArtifactPath)).sort()).toEqual([ + "receipt.json", + "summary.json", + ]); + expect(JSON.parse(await readFile(result.receiptPath, "utf8"))).toMatchObject({ + schema: "direct.bombadil-artifact-receipt/v1", + failureCode: null, + mode: "public-summary", + status: "passed", + inventory: { fileCount: 1 }, + }); + }); + + test("publishes only sanitized files publicly and descriptor-vetted files privately", async () => { + const { config, repositoryRoot } = await fixture(); + const sentinel = "secret-query-and-log-sentinel"; + const publicRuntime = dependencies(); + const publicResult = await runDirectBombadilFuzz({ + ...config, + targetQuery: { token: sentinel }, + }, { + arguments: [], + artifactRun: artifactRunPlan(repositoryRoot, 11), + }, publicRuntime.overrides); + if (publicResult.kind !== "run") throw new Error("Expected a public Bombadil run result"); + const publicPayload = (await Promise.all((await readdir(publicResult.uploadArtifactPath)).map( + async (name) => await readFile(join(publicResult.uploadArtifactPath, name), "utf8"), + ))).join("\n"); + expect(publicPayload).not.toContain(sentinel); + expect(publicPayload).not.toContain(repositoryRoot); + expect(publicPayload).not.toContain("bombadil stdout"); + expect(JSON.parse(await readFile(publicResult.receiptPath, "utf8"))).toMatchObject({ + diagnosticsRetained: false, + mode: "public-summary", + }); + + const privateRuntime = dependencies(); + const privateResult = await runDirectBombadilFuzz(config, { + arguments: [], + artifactRun: artifactRunPlan(repositoryRoot, 12, "private-vetted"), + }, privateRuntime.overrides); + if (privateResult.kind !== "run") throw new Error("Expected a private Bombadil run result"); + expect((await readdir(privateResult.uploadArtifactPath)).sort()).toEqual([ + "diagnostics", + "receipt.json", + "summary.json", + ]); + expect(await readFile( + join(privateResult.uploadArtifactPath, "diagnostics", "bombadil-output", "trace.jsonl"), + "utf8", + )).toContain('"name":"direct"'); + expect(await readFile( + join(privateResult.uploadArtifactPath, "diagnostics", "host", "bombadil.log"), + "utf8", + )).toContain("bombadil stdout"); + expect(JSON.parse(await readFile(privateResult.receiptPath, "utf8"))).toMatchObject({ + diagnosticsRetained: true, + mode: "private-vetted", + }); + }); + + test("rejects symlink artifacts and publishes a receipt without raw diagnostics", async () => { + const { config, repositoryRoot } = await fixture(); + const outside = join(repositoryRoot, "outside.txt"); + await writeFile(outside, "do not copy\n"); + const runtime = dependencies({ + afterTrace: async (invocation) => { + await symlink(outside, join(invocation.outputPath, "escaped.txt")); + }, + }); + const plan = artifactRunPlan(repositoryRoot, 13, "private-vetted"); + const error = await rejection(runDirectBombadilFuzz(config, { + arguments: [], + artifactRun: plan, + }, runtime.overrides)); + expect(error.message).toContain("symbolic link"); + const upload = join(repositoryRoot, "artifacts", "direct-bombadil-upload", plan.runId); + expect((await readdir(upload)).sort()).toEqual(["receipt.json", "summary.json"]); + expect(JSON.parse(await readFile(join(upload, "receipt.json"), "utf8"))).toMatchObject({ + diagnosticsRetained: false, + failureCode: "artifact-policy", + status: "failed", + }); + }); + + test("rejects multiply-linked artifacts before private copying", async () => { + const { config, repositoryRoot } = await fixture(); + const runtime = dependencies({ + afterTrace: async (invocation) => { + await link( + join(invocation.outputPath, "trace.jsonl"), + join(invocation.outputPath, "duplicate.jsonl"), + ); + }, + }); + const plan = artifactRunPlan(repositoryRoot, 15, "private-vetted"); + const error = await rejection(runDirectBombadilFuzz(config, { + arguments: [], + artifactRun: plan, + }, runtime.overrides)); + expect(error.message).toContain("multiply-linked"); + const upload = join(repositoryRoot, "artifacts", "direct-bombadil-upload", plan.runId); + expect((await readdir(upload)).sort()).toEqual(["receipt.json", "summary.json"]); + }); + + test("includes tagged empty directories in the authoritative inventory hash", async () => { + const { config, repositoryRoot } = await fixture(); + const baselinePlan = artifactRunPlan(repositoryRoot, 16); + await runDirectBombadilFuzz(config, { + arguments: [], + artifactRun: baselinePlan, + }, dependencies().overrides); + const baselineReceipt = record(JSON.parse(await readFile(join( + repositoryRoot, + "artifacts", + "direct-bombadil-upload", + baselinePlan.runId, + "receipt.json", + ), "utf8")), "baseline receipt"); + + const emptyDirectoryPlan = artifactRunPlan(repositoryRoot, 17); + await runDirectBombadilFuzz(config, { + arguments: [], + artifactRun: emptyDirectoryPlan, + }, dependencies({ + afterTrace: async (invocation) => { + await mkdir(join(invocation.outputPath, "empty")); + }, + }).overrides); + const emptyDirectoryReceipt = record(JSON.parse(await readFile(join( + repositoryRoot, + "artifacts", + "direct-bombadil-upload", + emptyDirectoryPlan.runId, + "receipt.json", + ), "utf8")), "empty-directory receipt"); + const baselineInventory = record(baselineReceipt.inventory, "baseline inventory"); + const emptyDirectoryInventory = record( + emptyDirectoryReceipt.inventory, + "empty-directory inventory", + ); + expect(emptyDirectoryInventory).toMatchObject({ entryCount: 2, fileCount: 1 }); + expect(emptyDirectoryInventory.inventorySha256).not.toBe( + baselineInventory.inventorySha256, + ); + }); + + test("preserves the primary failure when sanitized receipt publication also fails", async () => { + const { config, repositoryRoot } = await fixture(); + const plan = artifactRunPlan(repositoryRoot, 18); + await mkdir(join( + repositoryRoot, + "artifacts", + "direct-bombadil-upload", + `.staging-${plan.runId}`, + ), { recursive: true }); + const error = await rejection(runDirectBombadilFuzz(config, { + arguments: [], + artifactRun: plan, + }, dependencies({ exitCode: 9 }).overrides)); + expect(error).toBeInstanceOf(AggregateError); + expect(error.message).toContain("exited with status 9"); + expect(error.message).toContain("receipt publication also failed"); + }); + + test("publishes a sanitized rejection receipt before invalid configuration can spawn", async () => { + const { config, repositoryRoot } = await fixture(); + const runtime = dependencies(); + const plan = artifactRunPlan(repositoryRoot, 14); + const error = await rejection(runDirectBombadilFuzz({ + ...config, + artifactName: "../escape", + targetQuery: { token: "configuration-secret" }, + }, { arguments: [], artifactRun: plan }, runtime.overrides)); + expect(error.message).toContain("artifactName"); + expect(runtime.calls).toEqual([]); + const upload = join(repositoryRoot, "artifacts", "direct-bombadil-upload", plan.runId); + const payload = await readFile(join(upload, "receipt.json"), "utf8"); + expect(payload).not.toContain("configuration-secret"); + expect(JSON.parse(payload)).toMatchObject({ + failureCode: "configuration-rejected", + status: "rejected", + }); }); test("runs with policy-owned evidence despite arbitrary unrelated named snapshots", async () => { @@ -1777,6 +2969,111 @@ describe("Direct Bombadil run lifecycle", () => { }); }); + test("publishes an interrupted receipt when a signal wins during preflight", async () => { + const { config, repositoryRoot } = await fixture(); + const runtime = dependencies(); + const signals = controllableSignals(); + const runId = "00000000-0000-4000-8000-000000000035"; + let runIdCount = 0; + const error = await rejection(runDirectBombadilFuzz(config, [], { + ...runtime.overrides, + createRunId: () => { + runIdCount += 1; + signals.emit("SIGTERM"); + return runId; + }, + signalController: signals.controller, + })); + expect(error.message).toContain("interrupted"); + expect(runtime.calls).toEqual([]); + expect(signals.forwarded).toEqual(["SIGTERM"]); + expect(signals.listenerCount()).toBe(0); + expect(runIdCount).toBe(1); + expect(JSON.parse(await readFile(join( + resolveDirectBombadilUploadLeaf({ repositoryRoot, runId }), + "receipt.json", + ), "utf8"))).toMatchObject({ + diagnosticsRetained: false, + failureCode: "interrupted", + status: "failed", + }); + }); + + test("publishes an interrupted receipt when a signal wins during acquisition", async () => { + const { config, repositoryRoot } = await fixture(); + const runtime = dependencies(); + const signals = controllableSignals(); + const plan = artifactRunPlan(repositoryRoot, 36); + await rejection(runDirectBombadilFuzz(config, { + arguments: [], + artifactRun: plan, + }, { + ...runtime.overrides, + acquireServer: async () => { + runtime.calls.push("acquire-server"); + signals.emit("SIGINT"); + throw new Error("acquisition interrupted"); + }, + signalController: signals.controller, + })); + expect(runtime.calls).toEqual(["acquire-server"]); + expect(signals.forwarded).toEqual(["SIGINT"]); + expect(signals.listenerCount()).toBe(0); + expect(JSON.parse(await readFile(join( + resolveDirectBombadilUploadLeaf(plan), + "receipt.json", + ), "utf8"))).toMatchObject({ + failureCode: "interrupted", + status: "failed", + }); + }); + + test("converts a pre-commit signal into one interrupted immutable leaf", async () => { + const { config, repositoryRoot } = await fixture(); + const runtime = dependencies(); + const signals = controllableSignals(); + const plan = artifactRunPlan(repositoryRoot, 37, "private-vetted"); + let commitCount = 0; + const error = await rejection(runDirectBombadilFuzz(config, { + arguments: [], + artifactRun: plan, + }, { + ...runtime.overrides, + beforeArtifactCommit: () => { + commitCount += 1; + signals.emit("SIGTERM"); + }, + signalController: signals.controller, + })); + expect(error.message).toContain("SIGTERM"); + expect(commitCount).toBe(1); + expect(signals.forwarded).toEqual(["SIGTERM"]); + expect(signals.listenerCount()).toBe(0); + const upload = resolveDirectBombadilUploadLeaf(plan); + expect((await readdir(upload)).sort()).toEqual(["receipt.json", "summary.json"]); + expect(JSON.parse(await readFile(join(upload, "receipt.json"), "utf8"))).toMatchObject({ + diagnosticsRetained: false, + failureCode: "interrupted", + status: "failed", + }); + }); + + test("removes private diagnostics when a publication precheck rejects", async () => { + const { config, repositoryRoot } = await fixture(); + const plan = artifactRunPlan(repositoryRoot, 41, "private-vetted"); + const error = await rejection(runDirectBombadilFuzz(config, { + arguments: [], + artifactRun: plan, + }, { + ...dependencies().overrides, + beforeArtifactCommit: () => { + throw new Error("run precommit rejected"); + }, + })); + expect(error.message).toContain("run precommit rejected"); + expect(await readdir(dirname(resolveDirectBombadilUploadLeaf(plan)))).toEqual([]); + }); + test("does not spawn when cancellation wins before server startup", async () => { const { config } = await fixture(); const runtime = dependencies(); @@ -1999,16 +3296,105 @@ describe("Direct Bombadil run lifecycle", () => { expect(await readFile(String(server.logPath), "utf8")).toContain("server output"); }); - test("bounds server output after cleanup fails and still writes artifacts", async () => { + test("bounds a server-output drain independently of writer settlement", async () => { + const { config, repositoryRoot } = await fixture(); + const runtime = dependencies({ + neverServerOutput: true, + serverOutputTimeoutMs: 10, + }); + const plan = artifactRunPlan(repositoryRoot, 38); + const startedAt = Date.now(); + const error = await rejection(runDirectBombadilFuzz(config, { + arguments: [], + artifactRun: plan, + }, runtime.overrides)); + expect(error.message).toContain("server output did not settle"); + expect(Date.now() - startedAt).toBeLessThan(1_000); + const manifest = record(JSON.parse(await readFile(join( + repositoryRoot, + "artifacts", + "direct-bombadil", + "fixture-product", + "manifest.json", + ), "utf8")), "manifest"); + expect(record(manifest.server, "server").outputFailure).toBeString(); + expect(JSON.parse(await readFile(join( + resolveDirectBombadilUploadLeaf(plan), + "receipt.json", + ), "utf8"))).toMatchObject({ + failureCode: "server", + status: "failed", + }); + }); + + test("classifies local evidence-write failure as persistence", async () => { + const { config, repositoryRoot } = await fixture(); + const plan = artifactRunPlan(repositoryRoot, 39, "private-vetted"); + const runtime = dependencies({ + afterTrace: async (invocation) => { + await mkdir(join(dirname(invocation.outputPath), "bombadil.log")); + }, + }); + const error = await rejection(runDirectBombadilFuzz(config, { + arguments: [], + artifactRun: plan, + }, runtime.overrides)); + expect(error.message).toContain("local diagnostic logs could not be persisted"); + const upload = resolveDirectBombadilUploadLeaf(plan); + expect((await readdir(upload)).sort()).toEqual([ + "diagnostics", + "receipt.json", + "summary.json", + ]); + expect(JSON.parse(await readFile(join( + upload, + "receipt.json", + ), "utf8"))).toMatchObject({ + diagnosticsRetained: true, + failureCode: "persistence", + mode: "private-vetted", + status: "failed", + }); + }); + + test("classifies an unreadable allowlisted output as artifact-policy", async () => { + const { config, repositoryRoot } = await fixture(); + const plan = artifactRunPlan(repositoryRoot, 40); + const runtime = dependencies({ + afterTrace: async (invocation) => { + await chmod(join(invocation.outputPath, "trace.jsonl"), 0o000); + }, + }); + const error = await rejection(runDirectBombadilFuzz(config, { + arguments: [], + artifactRun: plan, + }, runtime.overrides)); + expect(error.name).toBe("BombadilArtifactPolicyError"); + expect(error.message).toContain( + "Bombadil artifact directory could not be inspected safely", + ); + expect(JSON.parse(await readFile(join( + resolveDirectBombadilUploadLeaf(plan), + "receipt.json", + ), "utf8"))).toMatchObject({ + failureCode: "artifact-policy", + status: "failed", + }); + }); + + test("suppresses artifact inspection and private copying when server cleanup fails", async () => { const { config, repositoryRoot } = await fixture(); const runtime = dependencies({ neverServerOutput: true, serverOutputTimeoutMs: 10, stopFailure: true, }); + const plan = artifactRunPlan(repositoryRoot, 19, "private-vetted"); const startedAt = Date.now(); - expect((await rejection(runDirectBombadilFuzz(config, [], runtime.overrides))).message) - .toContain("server cleanup failed"); + expect((await rejection(runDirectBombadilFuzz(config, { + arguments: [], + artifactRun: plan, + }, runtime.overrides))).message).toContain("writers were not proven absent"); expect(Date.now() - startedAt).toBeLessThan(1_000); const manifest = JSON.parse(await readFile( join(repositoryRoot, "artifacts", "direct-bombadil", "fixture-product", "manifest.json"), @@ -2016,13 +3402,28 @@ describe("Direct Bombadil run lifecycle", () => { )) as Record; expect(manifest).toMatchObject({ status: "failed", - failure: "Error: server cleanup failed", + failure: expect.stringContaining("BombadilWriterSettlementError"), server: { logPresent: false, - outputFailure: expect.stringContaining("did not settle within 10ms"), + outputFailure: null, }, }); const server = record(manifest.server, "server"); expect(await readFile(String(server.logPath), "utf8")).toBe(""); + const upload = join(repositoryRoot, "artifacts", "direct-bombadil-upload", plan.runId); + expect((await readdir(upload)).sort()).toEqual(["receipt.json", "summary.json"]); + expect(JSON.parse(await readFile(join(upload, "receipt.json"), "utf8"))).toMatchObject({ + failureCode: "writer-settlement", + inventory: { entryCount: 0, fileCount: 0, inventorySha256: null }, + status: "failed", + }); + expect(parseDirectBombadilArtifactReceipt(JSON.parse(await readFile( + join(upload, "receipt.json"), + "utf8", + ))).ok).toBeTrue(); + expect(parseDirectBombadilSanitizedRunSummary(JSON.parse(await readFile( + join(upload, "summary.json"), + "utf8", + ))).ok).toBeTrue(); }); }); diff --git a/src/tooling/bombadil-runner.ts b/src/tooling/bombadil-runner.ts index 57107e8..21108a7 100644 --- a/src/tooling/bombadil-runner.ts +++ b/src/tooling/bombadil-runner.ts @@ -1,9 +1,19 @@ -import { createReadStream } from "node:fs"; -import { readFile, realpath, stat, writeFile } from "node:fs/promises"; -import { isAbsolute, join, relative, resolve } from "node:path"; +import { EventEmitter } from "node:events"; +import { constants as fileSystemConstants, type BigIntStats } from "node:fs"; +import { + lstat, + mkdir, + open, + opendir, + readFile, + realpath, + rename, + rm, + stat, +} from "node:fs/promises"; +import { extname, isAbsolute, join, relative, resolve } from "node:path"; import process from "node:process"; -import { createInterface } from "node:readline"; -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { parseDirectProbeSnapshot, @@ -13,11 +23,12 @@ import { FIXTURE_QUERY_KEY, SCENARIO_QUERY_KEY, } from "@hraness/direct"; +import { parseJsonValue } from "../core/json.js"; +import { err, ok, type Result } from "../core/result.js"; import { acquireVerificationServer, canAutomaticallyStartLocalServer, - createArtifactRun, normalizeRootHttpOrigin, renderUnknown, spawnVerificationServer, @@ -36,8 +47,111 @@ const DEFAULT_STARTUP_TIMEOUT_MS = 60_000; const MAX_STARTUP_TIMEOUT_MS = 120_000; const LOG_LIMIT = 24_000; const ARTIFACT_SCHEMA = "direct.bombadil-run/v1"; +const ARTIFACT_RECEIPT_SCHEMA = "direct.bombadil-artifact-receipt/v1"; +const ARTIFACT_SUMMARY_SCHEMA = "direct.bombadil-upload-summary/v1"; +const MATRIX_RECEIPT_SCHEMA = "direct.bombadil-matrix-receipt/v1"; +const MATRIX_SUMMARY_SCHEMA = "direct.bombadil-matrix-summary/v1"; +const ARTIFACT_FAILURE_CODES = new Set([ + "artifact-policy", + "configuration-rejected", + "exploration-policy", + "interrupted", + "persistence", + "process", + "server", + "trace-attestation", + "writer-settlement", + "unknown", +]); +const ARTIFACT_RECEIPT_KEYS = new Set([ + "completedAt", + "diagnosticsRetained", + "failureCode", + "inventory", + "mode", + "policy", + "runId", + "schema", + "status", +]); +const ARTIFACT_RECEIPT_INVENTORY_KEYS = new Set([ + "entryCount", + "fileCount", + "inventorySha256", + "totalBytes", +]); +const ARTIFACT_POLICY_RECEIPT_KEYS = new Set([ + "maxDepth", + "maxEntries", + "maxFileBytes", + "maxFiles", + "maxPathBytes", + "maxTotalBytes", +]); +const RUN_SUMMARY_KEYS = new Set([ + "artifactName", + "attestation", + "exploration", + "failureCode", + "scenario", + "schema", + "status", +]); +const RUN_SUMMARY_ATTESTATION_KEYS = new Set([ + "invalidObservationCount", + "observationCount", + "validObservationCount", +]); +const RUN_SUMMARY_EXPLORATION_KEYS = new Set([ + "actionCount", + "nonWaitActionCount", + "policySatisfied", + "traceBytes", + "traceLineCount", + "traceSha256", +]); +const MATRIX_RECEIPT_KEYS = new Set([ + "campaigns", + "completedAt", + "failureCode", + "mode", + "omittedCampaignCount", + "runId", + "schema", + "status", +]); +const MATRIX_CAMPAIGN_RECEIPT_KEYS = new Set([ + "campaignId", + "index", + "receipt", + "status", +]); +const MATRIX_SUMMARY_KEYS = new Set([ + "campaigns", + "failureCode", + "schema", + "status", +]); +const MATRIX_SUMMARY_CAMPAIGNS_KEYS = new Set([ + "failed", + "notRun", + "notSelected", + "omitted", + "passed", + "rejected", + "total", +]); +const SHA256_PATTERN = /^[0-9a-f]{64}$/u; +const ARTIFACT_EVIDENCE_JSON_LIMITS = Object.freeze({ + maxDepth: 8, + maxNodes: 2_048, + maxStringBytes: 64 * 1024, +}); const SCENARIO_PATTERN = /^[a-z][a-z0-9]*(?:[._/-][a-z0-9]+)*$/u; const ARTIFACT_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const MAX_ARTIFACT_IDENTIFIER_LENGTH = 80; +const MAX_MATRIX_CAMPAIGNS = 32; +const ARTIFACT_COORDINATION_ENVIRONMENT = "DIRECT_BOMBADIL_RUN_ID"; const ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/u; const QUERY_PARAMETER_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_.-]*$/u; const PROTOTYPE_PROPERTY_NAMES = new Set(["__proto__", "constructor", "prototype"]); @@ -56,6 +170,31 @@ const REPLAY_WALL_CLOCK_TIMEOUT_MS = MAX_TIME_LIMIT_SECONDS * 1_000 + RANDOM_RUN const PROCESS_TERMINATION_GRACE_MS = 5_000; const MIN_PROCESS_OUTPUT_DRAIN_MS = 500; const SERVER_OUTPUT_TIMEOUT_MS = 3_000; +const ARTIFACT_MONITOR_INTERVAL_MS = 100; +const DEFAULT_ARTIFACT_MAX_ENTRIES = 4_096; +const DEFAULT_ARTIFACT_MAX_FILES = 2_048; +const DEFAULT_ARTIFACT_MAX_TOTAL_BYTES = 128 * 1024 * 1024; +const DEFAULT_ARTIFACT_MAX_FILE_BYTES = 64 * 1024 * 1024; +const DEFAULT_ARTIFACT_MAX_DEPTH = 32; +const DEFAULT_ARTIFACT_MAX_PATH_BYTES = 4_096; +const MAX_ARTIFACT_ENTRIES = 16_384; +const MAX_ARTIFACT_FILES = 8_192; +const MAX_ARTIFACT_TOTAL_BYTES = 256 * 1024 * 1024; +const MAX_ARTIFACT_FILE_BYTES = 64 * 1024 * 1024; +const MAX_ARTIFACT_DEPTH = 64; +const MAX_ARTIFACT_PATH_BYTES = 4_096; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const ARTIFACT_PATH_PART_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u; +const PRIVATE_DIAGNOSTIC_EXTENSIONS = new Set([ + ".jpeg", + ".jpg", + ".json", + ".jsonl", + ".log", + ".png", + ".txt", + ".webp", +]); const DIRECT_BROWSER_BRIDGE_SCHEMA = "direct.browser-bridge/v2"; const TRACE_LINE_KEYS = new Set(["action", "snapshots", "state", "timestamp", "violations"]); const TRACE_SNAPSHOT_KEYS = new Set(["index", "name", "time", "value"]); @@ -171,6 +310,138 @@ export interface DirectBombadilServerConfig { readonly startupTimeoutMs?: number; } +export interface DirectBombadilArtifactPolicy { + readonly maxDepth?: number; + readonly maxEntries?: number; + readonly maxFileBytes?: number; + readonly maxFiles?: number; + readonly maxPathBytes?: number; + readonly maxTotalBytes?: number; +} + +export type DirectBombadilUploadMode = "private-vetted" | "public-summary"; + +export interface DirectBombadilArtifactRunPlan { + readonly repositoryRoot: string; + readonly runId: string; + readonly uploadMode?: DirectBombadilUploadMode; +} + +export interface DirectBombadilFuzzRunOptions { + readonly arguments?: readonly string[]; + readonly artifactRun?: DirectBombadilArtifactRunPlan; +} + +export type DirectBombadilFuzzRunInput = + | readonly string[] + | DirectBombadilFuzzRunOptions; + +export interface DirectBombadilMatrixRunOptions { + readonly arguments?: readonly string[]; + readonly artifactRun?: Omit & { + readonly uploadMode?: "public-summary"; + }; +} + +export type DirectBombadilMatrixRunInput = + | readonly string[] + | DirectBombadilMatrixRunOptions; + +export type DirectBombadilArtifactFailureCode = + | "artifact-policy" + | "configuration-rejected" + | "exploration-policy" + | "interrupted" + | "persistence" + | "process" + | "server" + | "trace-attestation" + | "writer-settlement" + | "unknown"; + +export interface DirectBombadilArtifactReceipt { + readonly schema: typeof ARTIFACT_RECEIPT_SCHEMA; + readonly completedAt: string; + readonly diagnosticsRetained: boolean; + readonly failureCode: DirectBombadilArtifactFailureCode | null; + readonly inventory: { + readonly entryCount: number; + readonly fileCount: number; + readonly inventorySha256: string | null; + readonly totalBytes: number; + }; + readonly mode: DirectBombadilUploadMode; + readonly policy: Required; + readonly runId: string; + readonly status: "failed" | "passed" | "rejected"; +} + +export interface DirectBombadilArtifactParseError { + readonly code: "invalid-bombadil-artifact-evidence"; + readonly message: string; +} + +export interface DirectBombadilSanitizedRunSummary { + readonly schema: typeof ARTIFACT_SUMMARY_SCHEMA; + readonly artifactName: string; + readonly attestation: null | { + readonly invalidObservationCount: number; + readonly observationCount: number; + readonly validObservationCount: number; + }; + readonly exploration: null | { + readonly actionCount: number; + readonly nonWaitActionCount: number; + readonly policySatisfied: boolean; + readonly traceBytes: number; + readonly traceLineCount: number; + readonly traceSha256: string; + }; + readonly failureCode: DirectBombadilArtifactFailureCode | null; + readonly scenario: string; + readonly status: "failed" | "passed" | "rejected"; +} + +export type DirectBombadilMatrixCampaignStatus = + | "failed" + | "not-run" + | "not-selected" + | "passed" + | "rejected"; + +export interface DirectBombadilMatrixCampaignReceiptEntry { + readonly campaignId: string | null; + readonly index: number; + readonly receipt: string | null; + readonly status: DirectBombadilMatrixCampaignStatus; +} + +export interface DirectBombadilMatrixReceipt { + readonly schema: typeof MATRIX_RECEIPT_SCHEMA; + readonly campaigns: readonly DirectBombadilMatrixCampaignReceiptEntry[]; + readonly completedAt: string; + readonly failureCode: DirectBombadilArtifactFailureCode | null; + readonly mode: "public-summary"; + readonly omittedCampaignCount: number; + readonly runId: string; + readonly status: "failed" | "passed"; +} + +export interface DirectBombadilMatrixSummary { + readonly schema: typeof MATRIX_SUMMARY_SCHEMA; + readonly campaigns: { + readonly failed: number; + readonly notRun: number; + readonly notSelected: number; + readonly omitted: number; + readonly passed: number; + readonly rejected: number; + readonly total: number; + }; + readonly failureCode: DirectBombadilArtifactFailureCode | null; + readonly status: "failed" | "passed"; +} + export type DirectBombadilActionKind = (typeof ACTION_KINDS)[number]; export interface DirectBombadilViewportConfig { @@ -254,6 +525,7 @@ export interface DirectBombadilExplorationSummary { } export interface DirectBombadilFuzzConfig { + readonly artifactPolicy?: DirectBombadilArtifactPolicy; readonly artifactName: string; readonly baseUrl: string; readonly entryPath?: `/${string}`; @@ -286,6 +558,13 @@ export type DirectBombadilFuzzResult = readonly status: "passed"; }; +type DirectBombadilFuzzExecutionResult = + | Extract + | (Extract & { + readonly receiptPath: string; + readonly uploadArtifactPath: string; + }); + export interface DirectBombadilFuzzCampaign { readonly config: DirectBombadilFuzzConfig; readonly id: string; @@ -301,8 +580,21 @@ export type DirectBombadilFuzzMatrixResult = }[]; }; +type DirectBombadilFuzzMatrixExecutionResult = + | Extract + | { + readonly kind: "matrix"; + readonly receiptPath: string; + readonly results: readonly { + readonly campaignId: string; + readonly result: Extract; + }[]; + readonly uploadArtifactPath: string; + }; + export interface DirectBombadilInvocation { readonly abortSignal?: AbortSignal; + readonly artifactPolicy?: DirectBombadilArtifactPolicy; readonly command: readonly string[]; readonly cwd: string; readonly outputPath: string; @@ -354,35 +646,48 @@ export interface DirectBombadilTraceAttestation { export interface DirectBombadilRunnerDependencies { readonly acquireServer: typeof acquireVerificationServer; + readonly beforeArtifactCommit?: () => Promise | void; readonly createAbortController?: () => AbortController; + readonly createRunId: () => string; readonly now: () => Date; readonly runBombadil: ( invocation: DirectBombadilInvocation, ) => Promise; + readonly signalController: ProcessSignalController; readonly serverOutputTimeoutMs: number; readonly spawnServer: (options: { readonly command: readonly string[]; readonly cwd: string; + readonly detachedProcessGroup?: boolean; readonly env?: Readonly>; + readonly omitEnvironment?: readonly string[]; }) => ManagedVerificationServer; readonly stopServer: typeof stopVerificationServer; } +const PROCESS_INTERRUPT_SIGNALS = ["SIGINT", "SIGTERM"] as const; +type ProcessInterruptSignal = (typeof PROCESS_INTERRUPT_SIGNALS)[number]; + interface ProcessSignalEmitter { readonly once: ( - signal: NodeJS.Signals, - listener: (signal: NodeJS.Signals) => void, + signal: ProcessInterruptSignal, + listener: (signal: ProcessInterruptSignal) => void, ) => unknown; readonly removeListener: ( - signal: NodeJS.Signals, - listener: (signal: NodeJS.Signals) => void, + signal: ProcessInterruptSignal, + listener: (signal: ProcessInterruptSignal) => void, ) => unknown; } +interface ProcessSignalController extends ProcessSignalEmitter { + readonly forward: (signal: ProcessInterruptSignal) => void; +} + type ValidatedConfig = Omit< DirectBombadilFuzzConfig, - "explorationPolicy" | "server" | "viewport" + "artifactPolicy" | "explorationPolicy" | "server" | "viewport" > & { + readonly artifactPolicy: ValidatedArtifactPolicy; readonly artifactRoot: string; readonly baseUrl: string; readonly bombadilExecutable: string; @@ -397,6 +702,81 @@ type ValidatedConfig = Omit< }; }; +type ValidatedArtifactPolicy = Required; + +interface ArtifactInventoryFile { + readonly device: bigint; + readonly inode: bigint; + readonly relativePath: string; + readonly sha256: string; + readonly size: number; +} + +interface ArtifactInventory { + readonly directories: readonly string[]; + readonly entryCount: number; + readonly files: readonly ArtifactInventoryFile[]; + readonly fileCount: number; + readonly inventorySha256: string; + readonly totalBytes: number; +} + +interface ArtifactUploadSessionBase { + readonly finalDirectory: string; + readonly mode: DirectBombadilUploadMode; + readonly receiptPath: string; + readonly runId: string; +} + +interface AtomicArtifactUploadSession extends ArtifactUploadSessionBase { + readonly publication: "atomic-leaf"; + readonly stagingDirectory: string; +} + +interface DeferredArtifactUploadSession extends ArtifactUploadSessionBase { + readonly deferredPayload: { value: SanitizedRunUploadPayload | null }; + readonly publication: "deferred"; +} + +type ArtifactUploadSession = AtomicArtifactUploadSession | DeferredArtifactUploadSession; + +interface SanitizedRunUploadPayload { + readonly receipt: DirectBombadilArtifactReceipt; + readonly summary: DirectBombadilSanitizedRunSummary; +} + +interface ExpectedUploadFile { + readonly relativePath: string; + readonly sha256: string; + readonly size: number; +} + +interface NormalizedFuzzRunOptions { + readonly arguments: readonly string[]; + readonly artifactRun: DirectBombadilArtifactRunPlan | null; +} + +class BombadilArtifactPolicyError extends Error { + public constructor(message: string) { + super(message); + this.name = "BombadilArtifactPolicyError"; + } +} + +class BombadilWriterSettlementError extends Error { + public constructor(message: string, cause: unknown) { + super(message, { cause }); + this.name = "BombadilWriterSettlementError"; + } +} + +class BombadilPersistenceError extends AggregateError { + public constructor(message: string, errors: readonly unknown[]) { + super(errors, message, { cause: errors[0] }); + this.name = "BombadilPersistenceError"; + } +} + interface ValidatedViewport { readonly deviceScaleFactor: number; readonly height: number; @@ -494,6 +874,10 @@ function isRecord(value: unknown): value is Readonly> { return typeof value === "object" && value !== null && !Array.isArray(value); } +function isReadonlyStringArray(value: unknown): value is readonly string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === "string"); +} + function hasExactKeys( value: Readonly>, expected: ReadonlySet, @@ -508,6 +892,1924 @@ function compareCodeUnits(left: string, right: string): number { return 0; } +function boundedArtifactInteger(options: { + readonly label: string; + readonly maximum: number; + readonly value: number | undefined; + readonly defaultValue: number; +}): number { + const value = options.value ?? options.defaultValue; + if (!Number.isSafeInteger(value) || value < 1 || value > options.maximum) { + throw new Error(`${options.label} must be an integer between 1 and ${String(options.maximum)}`); + } + return value; +} + +function validateArtifactPolicy( + input: DirectBombadilArtifactPolicy | undefined, +): ValidatedArtifactPolicy { + const value = input ?? {}; + return Object.freeze({ + maxDepth: boundedArtifactInteger({ + defaultValue: DEFAULT_ARTIFACT_MAX_DEPTH, + label: "artifactPolicy.maxDepth", + maximum: MAX_ARTIFACT_DEPTH, + value: value.maxDepth, + }), + maxEntries: boundedArtifactInteger({ + defaultValue: DEFAULT_ARTIFACT_MAX_ENTRIES, + label: "artifactPolicy.maxEntries", + maximum: MAX_ARTIFACT_ENTRIES, + value: value.maxEntries, + }), + maxFileBytes: boundedArtifactInteger({ + defaultValue: DEFAULT_ARTIFACT_MAX_FILE_BYTES, + label: "artifactPolicy.maxFileBytes", + maximum: MAX_ARTIFACT_FILE_BYTES, + value: value.maxFileBytes, + }), + maxFiles: boundedArtifactInteger({ + defaultValue: DEFAULT_ARTIFACT_MAX_FILES, + label: "artifactPolicy.maxFiles", + maximum: MAX_ARTIFACT_FILES, + value: value.maxFiles, + }), + maxPathBytes: boundedArtifactInteger({ + defaultValue: DEFAULT_ARTIFACT_MAX_PATH_BYTES, + label: "artifactPolicy.maxPathBytes", + maximum: MAX_ARTIFACT_PATH_BYTES, + value: value.maxPathBytes, + }), + maxTotalBytes: boundedArtifactInteger({ + defaultValue: DEFAULT_ARTIFACT_MAX_TOTAL_BYTES, + label: "artifactPolicy.maxTotalBytes", + maximum: MAX_ARTIFACT_TOTAL_BYTES, + value: value.maxTotalBytes, + }), + }); +} + +function normalizeFuzzRunOptions( + input: DirectBombadilFuzzRunInput | DirectBombadilMatrixRunInput | undefined, +): NormalizedFuzzRunOptions { + if (input === undefined || isReadonlyStringArray(input)) { + return { + arguments: Object.freeze([...(input ?? [])]), + artifactRun: null, + }; + } + const options: DirectBombadilFuzzRunOptions | DirectBombadilMatrixRunOptions = input; + const artifactRun = options.artifactRun; + if (!isRecord(options)) throw new Error("Bombadil run options must be an object or argument array"); + const keys = Object.keys(options); + if (keys.some((key) => key !== "arguments" && key !== "artifactRun")) { + throw new Error("Bombadil run options contain an unknown field"); + } + const arguments_ = options.arguments ?? []; + if (!isReadonlyStringArray(arguments_)) { + throw new Error("Bombadil run options arguments must be a string array"); + } + return { + arguments: Object.freeze([...arguments_]), + artifactRun: artifactRun ?? null, + }; +} + +function validateArtifactRunPlan( + input: DirectBombadilArtifactRunPlan, +): DirectBombadilArtifactRunPlan & { readonly uploadMode: DirectBombadilUploadMode } { + const repositoryRoot = resolve(input.repositoryRoot); + if (!isAbsolute(input.repositoryRoot) || repositoryRoot !== input.repositoryRoot) { + throw new Error("artifactRun.repositoryRoot must be an absolute normalized path"); + } + if (!UUID_PATTERN.test(input.runId)) { + throw new Error("artifactRun.runId must be a lowercase RFC 4122 UUID"); + } + const uploadMode = input.uploadMode ?? "public-summary"; + if (uploadMode !== "public-summary" && uploadMode !== "private-vetted") { + throw new Error("artifactRun.uploadMode must be public-summary or private-vetted"); + } + return Object.freeze({ repositoryRoot, runId: input.runId, uploadMode }); +} + +function isBoundedArtifactIdentifier(value: string): boolean { + return value.length <= MAX_ARTIFACT_IDENTIFIER_LENGTH + && ARTIFACT_NAME_PATTERN.test(value); +} + +function isBoundedScenarioIdentifier(value: string): boolean { + return value.length <= 120 && SCENARIO_PATTERN.test(value); +} + +function requireEvidenceRecord( + value: unknown, + keys: ReadonlySet, + label: string, +): Readonly> { + if (!isRecord(value) || !hasExactKeys(value, keys)) { + throw new Error(`${label} must contain exactly its documented fields`); + } + return value; +} + +function requireEvidenceInteger( + value: unknown, + label: string, + maximum = Number.MAX_SAFE_INTEGER, +): number { + if (!Number.isSafeInteger(value) || (value as number) < 0 || (value as number) > maximum) { + throw new Error(`${label} must be a nonnegative safe integer no greater than ${String(maximum)}`); + } + return value as number; +} + +function requireEvidencePositiveInteger( + value: unknown, + label: string, + maximum: number, +): number { + const parsed = requireEvidenceInteger(value, label, maximum); + if (parsed === 0) throw new Error(`${label} must be greater than zero`); + return parsed; +} + +function requireEvidenceSha256(value: unknown, label: string): string { + if (typeof value !== "string" || !SHA256_PATTERN.test(value)) { + throw new Error(`${label} must be a lowercase SHA-256 digest`); + } + return value; +} + +function requireEvidenceTimestamp(value: unknown, label: string): string { + if (typeof value !== "string") throw new Error(`${label} must be an ISO timestamp`); + const milliseconds = Date.parse(value); + if (!Number.isFinite(milliseconds) || new Date(milliseconds).toISOString() !== value) { + throw new Error(`${label} must be a canonical ISO timestamp`); + } + return value; +} + +function parseEvidenceFailureCode( + value: unknown, + label: string, +): DirectBombadilArtifactFailureCode | null { + if (value === null) return null; + if (typeof value !== "string" || !ARTIFACT_FAILURE_CODES.has( + value as DirectBombadilArtifactFailureCode, + )) { + throw new Error(`${label} is not a known Bombadil failure code`); + } + return value as DirectBombadilArtifactFailureCode; +} + +function requireEvidenceStatus( + value: unknown, + label: string, +): "failed" | "passed" | "rejected" { + if (value !== "failed" && value !== "passed" && value !== "rejected") { + throw new Error(`${label} must be failed, passed, or rejected`); + } + return value; +} + +function requireFailureStatusConsistency( + status: "failed" | "passed" | "rejected", + failureCode: DirectBombadilArtifactFailureCode | null, + label: string, +): void { + if ((status === "passed") !== (failureCode === null)) { + throw new Error(`${label} status and failureCode are inconsistent`); + } + if (status === "rejected" && failureCode !== "configuration-rejected") { + throw new Error(`${label} rejected status requires configuration-rejected`); + } +} + +function parseArtifactReceiptUnchecked(input: unknown): DirectBombadilArtifactReceipt { + const value = requireEvidenceRecord(input, ARTIFACT_RECEIPT_KEYS, "Bombadil receipt"); + if (value.schema !== ARTIFACT_RECEIPT_SCHEMA) { + throw new Error("Bombadil receipt schema is unsupported"); + } + const completedAt = requireEvidenceTimestamp(value.completedAt, "Bombadil receipt completedAt"); + if (typeof value.diagnosticsRetained !== "boolean") { + throw new Error("Bombadil receipt diagnosticsRetained must be boolean"); + } + const failureCode = parseEvidenceFailureCode(value.failureCode, "Bombadil receipt failureCode"); + const status = requireEvidenceStatus(value.status, "Bombadil receipt status"); + requireFailureStatusConsistency(status, failureCode, "Bombadil receipt"); + if (value.mode !== "private-vetted" && value.mode !== "public-summary") { + throw new Error("Bombadil receipt mode is unsupported"); + } + if (value.diagnosticsRetained && value.mode !== "private-vetted") { + throw new Error("Public Bombadil receipts cannot retain diagnostics"); + } + if (typeof value.runId !== "string" || !UUID_PATTERN.test(value.runId)) { + throw new Error("Bombadil receipt runId must be a lowercase RFC 4122 UUID"); + } + const rawPolicy = requireEvidenceRecord( + value.policy, + ARTIFACT_POLICY_RECEIPT_KEYS, + "Bombadil receipt policy", + ); + const policy = Object.freeze({ + maxDepth: requireEvidencePositiveInteger( + rawPolicy.maxDepth, + "Bombadil receipt policy.maxDepth", + MAX_ARTIFACT_DEPTH, + ), + maxEntries: requireEvidencePositiveInteger( + rawPolicy.maxEntries, + "Bombadil receipt policy.maxEntries", + MAX_ARTIFACT_ENTRIES, + ), + maxFileBytes: requireEvidencePositiveInteger( + rawPolicy.maxFileBytes, + "Bombadil receipt policy.maxFileBytes", + MAX_ARTIFACT_FILE_BYTES, + ), + maxFiles: requireEvidencePositiveInteger( + rawPolicy.maxFiles, + "Bombadil receipt policy.maxFiles", + MAX_ARTIFACT_FILES, + ), + maxPathBytes: requireEvidencePositiveInteger( + rawPolicy.maxPathBytes, + "Bombadil receipt policy.maxPathBytes", + MAX_ARTIFACT_PATH_BYTES, + ), + maxTotalBytes: requireEvidencePositiveInteger( + rawPolicy.maxTotalBytes, + "Bombadil receipt policy.maxTotalBytes", + MAX_ARTIFACT_TOTAL_BYTES, + ), + }); + const rawInventory = requireEvidenceRecord( + value.inventory, + ARTIFACT_RECEIPT_INVENTORY_KEYS, + "Bombadil receipt inventory", + ); + const entryCount = requireEvidenceInteger( + rawInventory.entryCount, + "Bombadil receipt inventory.entryCount", + policy.maxEntries, + ); + const fileCount = requireEvidenceInteger( + rawInventory.fileCount, + "Bombadil receipt inventory.fileCount", + policy.maxFiles, + ); + const totalBytes = requireEvidenceInteger( + rawInventory.totalBytes, + "Bombadil receipt inventory.totalBytes", + policy.maxTotalBytes, + ); + if (fileCount > entryCount) { + throw new Error("Bombadil receipt inventory.fileCount cannot exceed entryCount"); + } + if (fileCount === 0 && totalBytes !== 0) { + throw new Error("Bombadil receipt inventory bytes require at least one file"); + } + const inventorySha256 = rawInventory.inventorySha256 === null + ? null + : requireEvidenceSha256( + rawInventory.inventorySha256, + "Bombadil receipt inventory.inventorySha256", + ); + if ( + (entryCount === 0 && (fileCount !== 0 || totalBytes !== 0 || inventorySha256 !== null)) + || (entryCount > 0 && inventorySha256 === null) + ) { + throw new Error("Bombadil receipt empty-inventory fields are inconsistent"); + } + if ( + (status === "passed" && (entryCount === 0 || fileCount === 0 || totalBytes === 0)) + || (status === "passed" && value.mode === "private-vetted" && !value.diagnosticsRetained) + || (failureCode === "interrupted" && value.diagnosticsRetained) + || (failureCode === "configuration-rejected" && status !== "rejected") + || ( + failureCode === "writer-settlement" + && (value.diagnosticsRetained || entryCount !== 0 || fileCount !== 0 || totalBytes !== 0) + ) + || ( + status === "rejected" + && (value.diagnosticsRetained || entryCount !== 0 || fileCount !== 0 || totalBytes !== 0) + ) + ) { + throw new Error("Bombadil receipt terminal state and retained evidence are inconsistent"); + } + return Object.freeze({ + schema: ARTIFACT_RECEIPT_SCHEMA, + completedAt, + diagnosticsRetained: value.diagnosticsRetained, + failureCode, + inventory: Object.freeze({ entryCount, fileCount, inventorySha256, totalBytes }), + mode: value.mode, + policy, + runId: value.runId, + status, + }); +} + +function parseRunSummaryUnchecked(input: unknown): DirectBombadilSanitizedRunSummary { + const value = requireEvidenceRecord(input, RUN_SUMMARY_KEYS, "Bombadil run summary"); + if (value.schema !== ARTIFACT_SUMMARY_SCHEMA) { + throw new Error("Bombadil run summary schema is unsupported"); + } + if (typeof value.artifactName !== "string" || !isBoundedArtifactIdentifier(value.artifactName)) { + throw new Error("Bombadil run summary artifactName is invalid"); + } + if (typeof value.scenario !== "string" || !isBoundedScenarioIdentifier(value.scenario)) { + throw new Error("Bombadil run summary scenario is invalid"); + } + const failureCode = parseEvidenceFailureCode( + value.failureCode, + "Bombadil run summary failureCode", + ); + const status = requireEvidenceStatus(value.status, "Bombadil run summary status"); + requireFailureStatusConsistency(status, failureCode, "Bombadil run summary"); + let attestation: DirectBombadilSanitizedRunSummary["attestation"] = null; + if (value.attestation !== null) { + const raw = requireEvidenceRecord( + value.attestation, + RUN_SUMMARY_ATTESTATION_KEYS, + "Bombadil run summary attestation", + ); + const observationCount = requireEvidenceInteger( + raw.observationCount, + "Bombadil run summary attestation.observationCount", + TRACE_MAX_LINES, + ); + const invalidObservationCount = requireEvidenceInteger( + raw.invalidObservationCount, + "Bombadil run summary attestation.invalidObservationCount", + observationCount, + ); + const validObservationCount = requireEvidenceInteger( + raw.validObservationCount, + "Bombadil run summary attestation.validObservationCount", + observationCount, + ); + if (invalidObservationCount + validObservationCount !== observationCount) { + throw new Error("Bombadil run summary attestation counts do not reconcile"); + } + if (observationCount === 0 || validObservationCount === 0) { + throw new Error("Bombadil run summary attestation must contain a valid observation"); + } + attestation = Object.freeze({ + invalidObservationCount, + observationCount, + validObservationCount, + }); + } + let exploration: DirectBombadilSanitizedRunSummary["exploration"] = null; + if (value.exploration !== null) { + const raw = requireEvidenceRecord( + value.exploration, + RUN_SUMMARY_EXPLORATION_KEYS, + "Bombadil run summary exploration", + ); + const traceLineCount = requireEvidenceInteger( + raw.traceLineCount, + "Bombadil run summary exploration.traceLineCount", + TRACE_MAX_LINES, + ); + const actionCount = requireEvidenceInteger( + raw.actionCount, + "Bombadil run summary exploration.actionCount", + traceLineCount, + ); + const nonWaitActionCount = requireEvidenceInteger( + raw.nonWaitActionCount, + "Bombadil run summary exploration.nonWaitActionCount", + actionCount, + ); + if (typeof raw.policySatisfied !== "boolean") { + throw new Error("Bombadil run summary exploration.policySatisfied must be boolean"); + } + exploration = Object.freeze({ + actionCount, + nonWaitActionCount, + policySatisfied: raw.policySatisfied, + traceBytes: requireEvidenceInteger( + raw.traceBytes, + "Bombadil run summary exploration.traceBytes", + TRACE_MAX_BYTES, + ), + traceLineCount, + traceSha256: requireEvidenceSha256( + raw.traceSha256, + "Bombadil run summary exploration.traceSha256", + ), + }); + if (exploration.traceBytes === 0 || exploration.traceLineCount === 0) { + throw new Error("Bombadil run summary exploration trace must be nonempty"); + } + } + if ( + status === "passed" + && ( + attestation === null + || attestation.observationCount === 0 + || attestation.validObservationCount === 0 + || exploration === null + || !exploration.policySatisfied + || attestation.observationCount !== exploration.traceLineCount + ) + ) { + throw new Error("A passed Bombadil run summary requires attested policy-satisfying evidence"); + } + if ( + attestation !== null + && exploration !== null + && attestation.observationCount !== exploration.traceLineCount + ) { + throw new Error("Bombadil run summary trace counts do not reconcile"); + } + if (status === "rejected" && (attestation !== null || exploration !== null)) { + throw new Error("A rejected Bombadil run summary cannot claim trace evidence"); + } + if (failureCode === "configuration-rejected" && status !== "rejected") { + throw new Error("A configuration-rejected Bombadil run summary must be rejected"); + } + if (failureCode === "writer-settlement" && (attestation !== null || exploration !== null)) { + throw new Error("A writer-settlement Bombadil run summary cannot claim trace evidence"); + } + return Object.freeze({ + schema: ARTIFACT_SUMMARY_SCHEMA, + artifactName: value.artifactName, + attestation, + exploration, + failureCode, + scenario: value.scenario, + status, + }); +} + +function parseMatrixReceiptUnchecked(input: unknown): DirectBombadilMatrixReceipt { + const value = requireEvidenceRecord(input, MATRIX_RECEIPT_KEYS, "Bombadil matrix receipt"); + if (value.schema !== MATRIX_RECEIPT_SCHEMA || value.mode !== "public-summary") { + throw new Error("Bombadil matrix receipt schema or mode is unsupported"); + } + const completedAt = requireEvidenceTimestamp( + value.completedAt, + "Bombadil matrix receipt completedAt", + ); + const failureCode = parseEvidenceFailureCode( + value.failureCode, + "Bombadil matrix receipt failureCode", + ); + if (value.status !== "failed" && value.status !== "passed") { + throw new Error("Bombadil matrix receipt status must be failed or passed"); + } + requireFailureStatusConsistency(value.status, failureCode, "Bombadil matrix receipt"); + if (typeof value.runId !== "string" || !UUID_PATTERN.test(value.runId)) { + throw new Error("Bombadil matrix receipt runId must be a lowercase RFC 4122 UUID"); + } + if (!Array.isArray(value.campaigns) || value.campaigns.length > MAX_MATRIX_CAMPAIGNS) { + throw new Error("Bombadil matrix receipt campaigns exceed the bounded matrix size"); + } + const campaignIds = new Set(); + const campaigns = value.campaigns.map((inputCampaign, index) => { + const campaign = requireEvidenceRecord( + inputCampaign, + MATRIX_CAMPAIGN_RECEIPT_KEYS, + `Bombadil matrix receipt campaign ${String(index)}`, + ); + if (campaign.index !== index) { + throw new Error("Bombadil matrix receipt campaign indices must be ordered and contiguous"); + } + const campaignId = campaign.campaignId; + if ( + campaignId !== null + && ( + typeof campaignId !== "string" + || !isBoundedArtifactIdentifier(campaignId) + || campaignIds.has(campaignId) + ) + ) { + throw new Error("Bombadil matrix receipt campaign IDs must be unique bounded identifiers"); + } + if (campaignId !== null) campaignIds.add(campaignId); + if ( + campaign.status !== "failed" + && campaign.status !== "not-run" + && campaign.status !== "not-selected" + && campaign.status !== "passed" + && campaign.status !== "rejected" + ) { + throw new Error("Bombadil matrix receipt campaign status is unsupported"); + } + const expectedReceipt = campaignId === null + ? null + : `campaigns/${campaignId}/receipt.json`; + if ( + campaign.receipt !== null + && (typeof campaign.receipt !== "string" || campaign.receipt !== expectedReceipt) + ) { + throw new Error("Bombadil matrix child receipt path is not canonical"); + } + if ( + ((campaign.status === "not-run" || campaign.status === "not-selected") + && campaign.receipt !== null) + || (campaign.status === "passed" && campaign.receipt !== expectedReceipt) + || (campaignId === null && (campaign.status !== "rejected" || campaign.receipt !== null)) + ) { + throw new Error("Bombadil matrix child terminal state is inconsistent"); + } + return Object.freeze({ + campaignId, + index, + receipt: campaign.receipt as string | null, + status: campaign.status, + }); + }); + const omittedCampaignCount = requireEvidenceInteger( + value.omittedCampaignCount, + "Bombadil matrix receipt omittedCampaignCount", + ); + if ( + value.status === "passed" + && ( + omittedCampaignCount !== 0 + || !campaigns.some((campaign) => campaign.status === "passed") + || campaigns.some((campaign) => + campaign.status === "failed" + || campaign.status === "not-run" + || campaign.status === "rejected" + ) + ) + ) { + throw new Error("A passed Bombadil matrix receipt has a nonterminal child"); + } + return Object.freeze({ + schema: MATRIX_RECEIPT_SCHEMA, + campaigns: Object.freeze(campaigns), + completedAt, + failureCode, + mode: "public-summary", + omittedCampaignCount, + runId: value.runId, + status: value.status, + }); +} + +function parseMatrixSummaryUnchecked(input: unknown): DirectBombadilMatrixSummary { + const value = requireEvidenceRecord(input, MATRIX_SUMMARY_KEYS, "Bombadil matrix summary"); + if (value.schema !== MATRIX_SUMMARY_SCHEMA) { + throw new Error("Bombadil matrix summary schema is unsupported"); + } + const failureCode = parseEvidenceFailureCode( + value.failureCode, + "Bombadil matrix summary failureCode", + ); + if (value.status !== "failed" && value.status !== "passed") { + throw new Error("Bombadil matrix summary status must be failed or passed"); + } + requireFailureStatusConsistency(value.status, failureCode, "Bombadil matrix summary"); + const rawCampaigns = requireEvidenceRecord( + value.campaigns, + MATRIX_SUMMARY_CAMPAIGNS_KEYS, + "Bombadil matrix summary campaigns", + ); + const total = requireEvidenceInteger( + rawCampaigns.total, + "Bombadil matrix summary campaigns.total", + MAX_MATRIX_CAMPAIGNS, + ); + const campaigns = Object.freeze({ + failed: requireEvidenceInteger(rawCampaigns.failed, "Bombadil matrix summary failed", total), + notRun: requireEvidenceInteger(rawCampaigns.notRun, "Bombadil matrix summary notRun", total), + notSelected: requireEvidenceInteger( + rawCampaigns.notSelected, + "Bombadil matrix summary notSelected", + total, + ), + omitted: requireEvidenceInteger(rawCampaigns.omitted, "Bombadil matrix summary omitted"), + passed: requireEvidenceInteger(rawCampaigns.passed, "Bombadil matrix summary passed", total), + rejected: requireEvidenceInteger(rawCampaigns.rejected, "Bombadil matrix summary rejected", total), + total, + }); + if ( + campaigns.failed + + campaigns.notRun + + campaigns.notSelected + + campaigns.passed + + campaigns.rejected + !== campaigns.total + ) { + throw new Error("Bombadil matrix summary campaign counts do not reconcile"); + } + if ( + value.status === "passed" + && ( + campaigns.failed !== 0 + || campaigns.notRun !== 0 + || campaigns.rejected !== 0 + || campaigns.omitted !== 0 + || campaigns.passed === 0 + ) + ) { + throw new Error("A passed Bombadil matrix summary contains unsuccessful campaigns"); + } + return Object.freeze({ + schema: MATRIX_SUMMARY_SCHEMA, + campaigns, + failureCode, + status: value.status, + }); +} + +function artifactEvidenceError(error: unknown): DirectBombadilArtifactParseError { + return Object.freeze({ + code: "invalid-bombadil-artifact-evidence", + message: renderUnknown(error), + }); +} + +function cloneArtifactEvidence(input: unknown): unknown { + const parsed = parseJsonValue(input, ARTIFACT_EVIDENCE_JSON_LIMITS); + if (!parsed.ok) { + throw new Error(`Bombadil artifact evidence is not bounded inert JSON: ${parsed.error.message}`); + } + return parsed.value; +} + +/** Parse, clone, and freeze a foreign sanitized Bombadil run receipt. */ +export function parseDirectBombadilArtifactReceipt( + input: unknown, +): Result { + try { + return ok(parseArtifactReceiptUnchecked(cloneArtifactEvidence(input))); + } catch (error) { + return err(artifactEvidenceError(error)); + } +} + +/** Parse, clone, and freeze a foreign sanitized Bombadil run summary. */ +export function parseDirectBombadilSanitizedRunSummary( + input: unknown, +): Result { + try { + return ok(parseRunSummaryUnchecked(cloneArtifactEvidence(input))); + } catch (error) { + return err(artifactEvidenceError(error)); + } +} + +/** Parse, clone, and freeze a foreign sanitized Bombadil matrix receipt. */ +export function parseDirectBombadilMatrixReceipt( + input: unknown, +): Result { + try { + return ok(parseMatrixReceiptUnchecked(cloneArtifactEvidence(input))); + } catch (error) { + return err(artifactEvidenceError(error)); + } +} + +/** Parse, clone, and freeze a foreign sanitized Bombadil matrix summary. */ +export function parseDirectBombadilMatrixSummary( + input: unknown, +): Result { + try { + return ok(parseMatrixSummaryUnchecked(cloneArtifactEvidence(input))); + } catch (error) { + return err(artifactEvidenceError(error)); + } +} + +/** Resolve the lexically validated exact upload leaf for an `if: always()` caller. */ +export function resolveDirectBombadilUploadLeaf( + input: DirectBombadilArtifactRunPlan, +): string { + const plan = validateArtifactRunPlan(input); + return join(plan.repositoryRoot, "artifacts", "direct-bombadil-upload", plan.runId); +} + +async function requireSafeDirectory(path: string, label: string): Promise { + let metadata; + try { + metadata = await lstat(path); + } catch { + throw new BombadilArtifactPolicyError(`${label} does not exist`); + } + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new BombadilArtifactPolicyError(`${label} must be a non-symlink directory`); + } +} + +async function ensureSafeDirectoryChain( + repositoryRoot: string, + parts: readonly string[], +): Promise { + await requireSafeDirectory(repositoryRoot, "repositoryRoot"); + let current = repositoryRoot; + for (const part of parts) { + if (!ARTIFACT_PATH_PART_PATTERN.test(part) || part === "." || part === "..") { + throw new BombadilArtifactPolicyError("Artifact directory contains an unsafe path component"); + } + current = join(current, part); + try { + await mkdir(current, { mode: 0o700 }); + } catch (error) { + if (!isRecord(error) || error.code !== "EEXIST") throw error; + } + await requireSafeDirectory(current, `Artifact directory ${part}`); + const resolved = await realpath(current); + if (!isWithin(repositoryRoot, resolved) || resolved !== current) { + throw new BombadilArtifactPolicyError("Artifact directory escaped repositoryRoot"); + } + } + return current; +} + +async function createExclusiveDirectory(path: string, label: string): Promise { + try { + await mkdir(path, { mode: 0o700 }); + } catch (error) { + if (isRecord(error) && error.code === "EEXIST") { + throw new BombadilArtifactPolicyError(`${label} already exists`); + } + throw error; + } + await requireSafeDirectory(path, label); +} + +async function createBombadilArtifactRun(options: { + readonly artifactName: string; + readonly repositoryRoot: string; + readonly runId: string; +}): Promise<{ + readonly artifactRoot: string; + readonly manifestPath: string; + readonly runDirectory: string; +}> { + if (!UUID_PATTERN.test(options.runId)) { + throw new BombadilArtifactPolicyError("Bombadil raw artifact run ID must be a UUID"); + } + const artifactRoot = await ensureSafeDirectoryChain(options.repositoryRoot, [ + "artifacts", + "direct-bombadil", + options.artifactName, + ]); + const runDirectory = join(artifactRoot, options.runId); + await createExclusiveDirectory(runDirectory, "Bombadil artifact run leaf"); + return { + artifactRoot, + manifestPath: join(artifactRoot, "manifest.json"), + runDirectory, + }; +} + +async function prepareArtifactUploadSession( + planInput: DirectBombadilArtifactRunPlan, +): Promise { + const plan = validateArtifactRunPlan(planInput); + let repositoryRoot: string | null; + try { + repositoryRoot = await realpath(plan.repositoryRoot); + } catch (error) { + if (!isRecord(error) || error.code !== "ENOENT") { + throw new BombadilArtifactPolicyError( + `artifactRun.repositoryRoot could not be proven safe: ${renderUnknown(error)}`, + ); + } + repositoryRoot = null; + } + if (repositoryRoot === null || repositoryRoot !== plan.repositoryRoot) { + throw new BombadilArtifactPolicyError( + "artifactRun.repositoryRoot must resolve to its exact configured directory", + ); + } + const root = await ensureSafeDirectoryChain(repositoryRoot, [ + "artifacts", + "direct-bombadil-upload", + ]); + const finalDirectory = join(root, plan.runId); + let finalMetadata; + try { + finalMetadata = await lstat(finalDirectory); + } catch (error) { + if (!isRecord(error) || error.code !== "ENOENT") { + throw new BombadilArtifactPolicyError( + `Bombadil upload run leaf could not be inspected: ${renderUnknown(error)}`, + ); + } + finalMetadata = null; + } + if (finalMetadata !== null) { + throw new BombadilArtifactPolicyError("Bombadil upload run leaf already exists"); + } + const stagingDirectory = join(root, `.staging-${plan.runId}`); + return { + finalDirectory, + mode: plan.uploadMode, + publication: "atomic-leaf", + receiptPath: join(finalDirectory, "receipt.json"), + runId: plan.runId, + stagingDirectory, + }; +} + +async function requireArtifactUploadLeafAbsent( + session: AtomicArtifactUploadSession, +): Promise { + let existing; + try { + existing = await lstat(session.finalDirectory); + } catch (error) { + if (!isRecord(error) || error.code !== "ENOENT") { + throw new BombadilArtifactPolicyError( + `Bombadil upload run leaf could not be inspected: ${renderUnknown(error)}`, + ); + } + existing = null; + } + if (existing !== null) { + throw new BombadilArtifactPolicyError("Bombadil upload run leaf appeared before publication"); + } +} + +async function commitArtifactUploadSession( + session: AtomicArtifactUploadSession, +): Promise { + // The validated staging tree becomes immutable evidence at this dispatch. + // Nothing fallible may run after the atomic rename. + await rename(session.stagingDirectory, session.finalDirectory); +} + +function validateArtifactRelativePath( + relativePath: string, + policy: ValidatedArtifactPolicy, +): readonly string[] { + const parts = relativePath.split("/"); + if ( + relativePath.length === 0 + || relativePath.includes("\\") + || Buffer.byteLength(relativePath, "utf8") > policy.maxPathBytes + || parts.length > policy.maxDepth + || parts.some((part) => + part === "" + || part === "." + || part === ".." + || part.startsWith(".") + || !ARTIFACT_PATH_PART_PATTERN.test(part) + ) + ) { + throw new BombadilArtifactPolicyError(`Bombadil emitted unsafe artifact path ${relativePath}`); + } + return parts; +} + +function artifactOutputFileIsAllowed(relativePath: string): boolean { + return relativePath === "trace.jsonl" + || PRIVATE_DIAGNOSTIC_EXTENSIONS.has(extname(relativePath).toLowerCase()); +} + +function sameBigIntFileMetadata( + left: Readonly<{ dev: bigint; ino: bigint; size: bigint; ctimeNs: bigint; mtimeNs: bigint }>, + right: Readonly<{ dev: bigint; ino: bigint; size: bigint; ctimeNs: bigint; mtimeNs: bigint }>, +): boolean { + return left.dev === right.dev + && left.ino === right.ino + && left.size === right.size + && left.ctimeNs === right.ctimeNs + && left.mtimeNs === right.mtimeNs; +} + +async function withClosedArtifactHandle( + handle: Readonly<{ close: () => Promise }>, + operation: () => Promise, +): Promise { + let value: Value | undefined; + let operationFailure: unknown = null; + try { + value = await operation(); + } catch (error) { + operationFailure = error; + } + let closeFailure: unknown = null; + try { + await handle.close(); + } catch (error) { + closeFailure = error; + } + if (operationFailure !== null) { + if (closeFailure !== null) { + throw new AggregateError( + [operationFailure, closeFailure], + "Bombadil artifact operation and descriptor cleanup both failed", + { cause: operationFailure }, + ); + } + throw operationFailure; + } + if (closeFailure !== null) throw closeFailure; + return value as Value; +} + +async function hashBoundRegularFile(options: { + readonly expected: BigIntStats; + readonly path: string; + readonly policy: ValidatedArtifactPolicy; + readonly relativePath: string; +}): Promise { + const flags = fileSystemConstants.O_RDONLY + | fileSystemConstants.O_NOFOLLOW + | fileSystemConstants.O_NONBLOCK; + const handle = await open(options.path, flags); + return await withClosedArtifactHandle(handle, async () => { + const before = await handle.stat({ bigint: true }); + if ( + !before.isFile() + || before.nlink !== 1n + || !options.expected.isFile() + || options.expected.nlink !== 1n + || !sameBigIntFileMetadata(before, options.expected) + ) { + throw new BombadilArtifactPolicyError( + `Bombadil artifact ${options.relativePath} changed identity before inspection`, + ); + } + const size = Number(before.size); + if (!Number.isSafeInteger(size) || size > options.policy.maxFileBytes) { + throw new BombadilArtifactPolicyError( + `Bombadil artifact ${options.relativePath} exceeds the per-file byte quota`, + ); + } + const hash = createHash("sha256"); + const buffer = Buffer.allocUnsafe(64 * 1024); + let offset = 0; + while (offset < size) { + const length = Math.min(buffer.length, size - offset); + const read = await handle.read(buffer, 0, length, offset); + if (read.bytesRead === 0) { + throw new BombadilArtifactPolicyError( + `Bombadil artifact ${options.relativePath} changed while inspected`, + ); + } + hash.update(buffer.subarray(0, read.bytesRead)); + offset += read.bytesRead; + } + const after = await handle.stat({ bigint: true }); + if (!sameBigIntFileMetadata(before, after)) { + throw new BombadilArtifactPolicyError( + `Bombadil artifact ${options.relativePath} changed while inspected`, + ); + } + return { + device: before.dev, + inode: before.ino, + relativePath: options.relativePath, + sha256: hash.digest("hex"), + size, + }; + }); +} + +async function readBoundRegularFileBytes(options: { + readonly expected?: ArtifactInventoryFile; + readonly label: string; + readonly maximumBytes: number; + readonly path: string; +}): Promise { + const flags = fileSystemConstants.O_RDONLY + | fileSystemConstants.O_NOFOLLOW + | fileSystemConstants.O_NONBLOCK; + let handle; + try { + handle = await open(options.path, flags); + } catch { + throw new BombadilArtifactPolicyError(`${options.label} is not an openable regular file`); + } + try { + return await withClosedArtifactHandle(handle, async () => { + const before = await handle.stat({ bigint: true }); + const size = Number(before.size); + if ( + !before.isFile() + || before.nlink !== 1n + || !Number.isSafeInteger(size) + || size < 1 + || size > options.maximumBytes + ) { + throw new BombadilArtifactPolicyError(`${options.label} is not a bounded regular file`); + } + if ( + options.expected !== undefined + && ( + before.dev !== options.expected.device + || before.ino !== options.expected.inode + || size !== options.expected.size + ) + ) { + throw new BombadilArtifactPolicyError(`${options.label} changed after inventory`); + } + const bytes = Buffer.allocUnsafe(size); + let offset = 0; + while (offset < size) { + const read = await handle.read(bytes, offset, size - offset, offset); + if (read.bytesRead === 0) { + throw new BombadilArtifactPolicyError(`${options.label} changed while being read`); + } + offset += read.bytesRead; + } + const after = await handle.stat({ bigint: true }); + if (!sameBigIntFileMetadata(before, after)) { + throw new BombadilArtifactPolicyError(`${options.label} changed while being read`); + } + if ( + options.expected !== undefined + && sha256(bytes) !== options.expected.sha256 + ) { + throw new BombadilArtifactPolicyError(`${options.label} hash changed after inventory`); + } + return bytes; + }); + } catch (error) { + throw error instanceof BombadilArtifactPolicyError + ? error + : new BombadilArtifactPolicyError( + `${options.label} could not be read safely: ${renderUnknown(error)}`, + ); + } +} + +function decodeTraceLines(bytes: Uint8Array): readonly string[] { + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new Error("Bombadil trace is not valid UTF-8"); + } + const lines = text.split(/\r?\n/u); + if (lines.at(-1) === "") lines.pop(); + return lines; +} + +async function scanBombadilArtifactTree(options: { + readonly allowTransientEntryAbsence?: boolean; + readonly beforeDirectoryOpen?: (absolutePath: string) => Promise | void; + readonly beforeEntryInspect?: (absolutePath: string) => Promise | void; + readonly hashFiles: boolean; + readonly policy: ValidatedArtifactPolicy; + readonly root: string; + readonly rootMayBeAbsent?: boolean; +}): Promise { + let rootMetadata: BigIntStats | null; + try { + rootMetadata = await lstat(options.root, { bigint: true }); + } catch (error) { + if (!isRecord(error) || error.code !== "ENOENT") { + throw new BombadilArtifactPolicyError( + `Bombadil output root could not be inspected: ${renderUnknown(error)}`, + ); + } + rootMetadata = null; + } + if (rootMetadata === null) { + if (options.rootMayBeAbsent === true) { + return { + directories: Object.freeze([]), + entryCount: 0, + files: Object.freeze([]), + fileCount: 0, + inventorySha256: sha256(""), + totalBytes: 0, + }; + } + throw new BombadilArtifactPolicyError("Bombadil output directory does not exist"); + } + if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) { + throw new BombadilArtifactPolicyError("Bombadil output root must be a non-symlink directory"); + } + const directories: string[] = []; + const files: ArtifactInventoryFile[] = []; + let entryCount = 0; + let totalBytes = 0; + const pending: Array<{ readonly absolutePath: string; readonly relativePath: string }> = [{ + absolutePath: options.root, + relativePath: "", + }]; + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined) continue; + await options.beforeDirectoryOpen?.(current.absolutePath); + const directory = await opendir(current.absolutePath).catch((error: unknown) => { + if ( + options.allowTransientEntryAbsence === true + && isRecord(error) + && error.code === "ENOENT" + ) { + throw error; + } + throw new BombadilArtifactPolicyError( + `Bombadil artifact directory could not be opened safely: ${renderUnknown(error)}`, + ); + }); + try { + await withClosedArtifactHandle(directory, async () => { + while (true) { + const entry = await directory.read(); + if (entry === null) break; + const relativePath = current.relativePath === "" + ? entry.name + : `${current.relativePath}/${entry.name}`; + validateArtifactRelativePath(relativePath, options.policy); + entryCount += 1; + if (entryCount > options.policy.maxEntries) { + throw new BombadilArtifactPolicyError("Bombadil artifact entry quota was exceeded"); + } + const absolutePath = join(current.absolutePath, entry.name); + await options.beforeEntryInspect?.(absolutePath); + const metadata = await lstat(absolutePath, { bigint: true }); + if (metadata.isSymbolicLink()) { + throw new BombadilArtifactPolicyError( + `Bombadil emitted a symbolic link at ${relativePath}`, + ); + } + if (metadata.isDirectory()) { + directories.push(relativePath); + pending.push({ absolutePath, relativePath }); + continue; + } + if (!metadata.isFile() || metadata.nlink !== 1n) { + throw new BombadilArtifactPolicyError( + `Bombadil emitted a non-regular or multiply-linked file at ${relativePath}`, + ); + } + if (!artifactOutputFileIsAllowed(relativePath)) { + throw new BombadilArtifactPolicyError( + `Bombadil emitted a file outside the artifact allowlist at ${relativePath}`, + ); + } + if (files.length + 1 > options.policy.maxFiles) { + throw new BombadilArtifactPolicyError("Bombadil artifact file quota was exceeded"); + } + const fileSize = Number(metadata.size); + if (!Number.isSafeInteger(fileSize) || fileSize > options.policy.maxFileBytes) { + throw new BombadilArtifactPolicyError( + `Bombadil artifact ${relativePath} exceeds the per-file byte quota`, + ); + } + totalBytes += fileSize; + if (!Number.isSafeInteger(totalBytes) || totalBytes > options.policy.maxTotalBytes) { + throw new BombadilArtifactPolicyError( + "Bombadil aggregate artifact byte quota was exceeded", + ); + } + files.push(options.hashFiles + ? await hashBoundRegularFile({ + expected: metadata, + path: absolutePath, + policy: options.policy, + relativePath, + }) + : { + device: 0n, + inode: 0n, + relativePath, + sha256: "", + size: fileSize, + }); + } + }); + } catch (error) { + if ( + options.allowTransientEntryAbsence === true + && isRecord(error) + && error.code === "ENOENT" + ) { + throw error; + } + throw error instanceof BombadilArtifactPolicyError + ? error + : new BombadilArtifactPolicyError( + `Bombadil artifact directory could not be inspected safely: ${renderUnknown(error)}`, + ); + } + } + let finalRootMetadata: BigIntStats; + try { + finalRootMetadata = await lstat(options.root, { bigint: true }); + } catch (error) { + throw new BombadilArtifactPolicyError( + `Bombadil output root could not be revalidated: ${renderUnknown(error)}`, + ); + } + if ( + !finalRootMetadata.isDirectory() + || finalRootMetadata.isSymbolicLink() + || finalRootMetadata.dev !== rootMetadata.dev + || finalRootMetadata.ino !== rootMetadata.ino + ) { + throw new BombadilArtifactPolicyError("Bombadil output root changed during inspection"); + } + directories.sort(compareCodeUnits); + files.sort((left, right) => compareCodeUnits(left.relativePath, right.relativePath)); + const inventorySha256 = sha256([ + ...directories.map((directory) => `D\0${directory}\n`), + ...files.map((file) => + `F\0${file.relativePath}\0${String(file.size)}\0${file.sha256}\n` + ), + ].join("")); + return { + directories: Object.freeze(directories), + entryCount, + files: Object.freeze(files), + fileCount: files.length, + inventorySha256, + totalBytes, + }; +} + +/** @internal Exercise transient versus authoritative artifact scans in package tests. */ +export async function inspectBombadilArtifactTreeForTest(options: { + readonly allowTransientEntryAbsence?: boolean; + readonly beforeDirectoryOpen?: (absolutePath: string) => Promise | void; + readonly beforeEntryInspect?: (absolutePath: string) => Promise | void; + readonly hashFiles: boolean; + readonly policy: DirectBombadilArtifactPolicy; + readonly root: string; +}): Promise { + await scanBombadilArtifactTree({ + ...options, + policy: validateArtifactPolicy(options.policy), + }); +} + +async function ensureSafeChildDirectories( + root: string, + parts: readonly string[], +): Promise { + await requireSafeDirectory(root, "Bombadil upload staging root"); + let current = root; + for (const part of parts) { + if (!ARTIFACT_PATH_PART_PATTERN.test(part) || part.startsWith(".")) { + throw new BombadilArtifactPolicyError("Bombadil upload path contains an unsafe component"); + } + current = join(current, part); + try { + await mkdir(current, { mode: 0o700 }); + } catch (error) { + if (!isRecord(error) || error.code !== "EEXIST") throw error; + } + await requireSafeDirectory(current, "Bombadil upload directory"); + const resolved = await realpath(current); + if (!isWithin(root, resolved) || resolved !== current) { + throw new BombadilArtifactPolicyError("Bombadil upload directory escaped staging root"); + } + } + return current; +} + +async function writeExclusiveBytes(path: string, bytes: Uint8Array): Promise { + const flags = fileSystemConstants.O_WRONLY + | fileSystemConstants.O_CREAT + | fileSystemConstants.O_EXCL + | fileSystemConstants.O_NOFOLLOW; + const handle = await open(path, flags, 0o600); + await withClosedArtifactHandle(handle, async () => { + let offset = 0; + while (offset < bytes.byteLength) { + const written = await handle.write(bytes, offset, bytes.byteLength - offset, offset); + if (written.bytesWritten === 0) throw new Error("Exclusive artifact write made no progress"); + offset += written.bytesWritten; + } + await handle.sync(); + }); +} + +async function writeExpectedJson( + root: string, + relativePath: string, + value: unknown, +): Promise { + const parts = relativePath.split("/"); + const fileName = parts.pop(); + if (fileName === undefined || !ARTIFACT_PATH_PART_PATTERN.test(fileName)) { + throw new BombadilArtifactPolicyError("Sanitized upload path is invalid"); + } + const directory = await ensureSafeChildDirectories(root, parts); + const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8"); + await writeExclusiveBytes(join(directory, fileName), bytes); + return { + relativePath, + sha256: sha256(bytes), + size: bytes.byteLength, + }; +} + +function expectedUploadDirectories( + files: readonly ExpectedUploadFile[], +): readonly string[] { + const directories = new Set(); + for (const file of files) { + const parts = file.relativePath.split("/"); + parts.pop(); + for (let index = 1; index <= parts.length; index += 1) { + directories.add(parts.slice(0, index).join("/")); + } + } + return Object.freeze([...directories].sort(compareCodeUnits)); +} + +async function validateExpectedUploadTree( + root: string, + expectedInput: readonly ExpectedUploadFile[], +): Promise { + const expected = [...expectedInput].sort((left, right) => + compareCodeUnits(left.relativePath, right.relativePath) + ); + if (new Set(expected.map((file) => file.relativePath)).size !== expected.length) { + throw new BombadilArtifactPolicyError("Sanitized upload contains duplicate file paths"); + } + const directories = expectedUploadDirectories(expected); + const maximumPathBytes = Math.max( + 1, + ...expected.map((file) => Buffer.byteLength(file.relativePath, "utf8")), + ); + const inventory = await scanBombadilArtifactTree({ + hashFiles: true, + policy: { + maxDepth: Math.max(1, ...expected.map((file) => file.relativePath.split("/").length)), + maxEntries: Math.max(1, expected.length + directories.length), + maxFileBytes: Math.max(1, ...expected.map((file) => file.size)), + maxFiles: Math.max(1, expected.length), + maxPathBytes: maximumPathBytes, + maxTotalBytes: Math.max(1, expected.reduce((total, file) => total + file.size, 0)), + }, + root, + }); + if ( + inventory.directories.length !== directories.length + || inventory.directories.some((directory, index) => directory !== directories[index]) + || inventory.files.length !== expected.length + || inventory.files.some((file, index) => { + const wanted = expected[index]; + return wanted === undefined + || file.relativePath !== wanted.relativePath + || file.sha256 !== wanted.sha256 + || file.size !== wanted.size; + }) + ) { + throw new BombadilArtifactPolicyError( + "Sanitized upload tree differs from its exact expected inventory", + ); + } +} + +async function copyVerifiedArtifactFile(options: { + readonly destinationRoot: string; + readonly file: ArtifactInventoryFile; + readonly sourceRoot: string; +}): Promise { + const parts = options.file.relativePath.split("/"); + const fileName = parts.pop(); + if (fileName === undefined) throw new BombadilArtifactPolicyError("Artifact copy path is empty"); + const destinationDirectory = await ensureSafeChildDirectories( + options.destinationRoot, + parts, + ); + const destinationPath = join(destinationDirectory, fileName); + const sourcePath = join(options.sourceRoot, ...options.file.relativePath.split("/")); + const sourceFlags = fileSystemConstants.O_RDONLY + | fileSystemConstants.O_NOFOLLOW + | fileSystemConstants.O_NONBLOCK; + const destinationFlags = fileSystemConstants.O_WRONLY + | fileSystemConstants.O_CREAT + | fileSystemConstants.O_EXCL + | fileSystemConstants.O_NOFOLLOW; + const source = await open(sourcePath, sourceFlags); + let destination: Awaited> | null = null; + let copyFailure: unknown = null; + try { + const before = await source.stat({ bigint: true }); + if ( + !before.isFile() + || before.nlink !== 1n + || before.dev !== options.file.device + || before.ino !== options.file.inode + || Number(before.size) !== options.file.size + ) { + throw new BombadilArtifactPolicyError( + `Bombadil artifact ${options.file.relativePath} changed before private copy`, + ); + } + destination = await open(destinationPath, destinationFlags, 0o600); + const hash = createHash("sha256"); + const buffer = Buffer.allocUnsafe(64 * 1024); + let offset = 0; + while (offset < options.file.size) { + const read = await source.read( + buffer, + 0, + Math.min(buffer.length, options.file.size - offset), + offset, + ); + if (read.bytesRead === 0) { + throw new BombadilArtifactPolicyError( + `Bombadil artifact ${options.file.relativePath} changed during private copy`, + ); + } + hash.update(buffer.subarray(0, read.bytesRead)); + let writtenOffset = 0; + while (writtenOffset < read.bytesRead) { + const written = await destination.write( + buffer, + writtenOffset, + read.bytesRead - writtenOffset, + offset + writtenOffset, + ); + if (written.bytesWritten === 0) throw new Error("Private artifact copy made no progress"); + writtenOffset += written.bytesWritten; + } + offset += read.bytesRead; + } + await destination.sync(); + const after = await source.stat({ bigint: true }); + if ( + !sameBigIntFileMetadata(before, after) + || hash.digest("hex") !== options.file.sha256 + ) { + throw new BombadilArtifactPolicyError( + `Bombadil artifact ${options.file.relativePath} changed during private copy`, + ); + } + } catch (error) { + copyFailure = error; + await rm(destinationPath, { force: true }).catch(() => undefined); + } + let closeFailure: unknown = null; + try { + await closeBombadilArtifactCopyHandles(destination, source); + } catch (error) { + closeFailure = error; + } + if (copyFailure !== null) { + if (closeFailure !== null) { + throw new AggregateError( + [copyFailure, closeFailure], + "Bombadil artifact copy and descriptor cleanup both failed", + { cause: copyFailure }, + ); + } + throw copyFailure; + } + if (closeFailure !== null) throw closeFailure; +} + +/** @internal Close both descriptor-bound copy handles even when one close fails. */ +export async function closeBombadilArtifactCopyHandles( + destination: Readonly<{ close: () => Promise }> | null, + source: Readonly<{ close: () => Promise }>, +): Promise { + const failures: unknown[] = []; + if (destination !== null) { + try { + await destination.close(); + } catch (error) { + failures.push(error); + } + } + try { + await source.close(); + } catch (error) { + failures.push(error); + } + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) { + throw new AggregateError(failures, "Both Bombadil artifact copy descriptors failed to close"); + } +} + +function emptyArtifactInventory(): ArtifactInventory { + return { + directories: Object.freeze([]), + entryCount: 0, + files: Object.freeze([]), + fileCount: 0, + inventorySha256: sha256(""), + totalBytes: 0, + }; +} + +function artifactFailureCode(error: unknown): DirectBombadilArtifactFailureCode { + if (error instanceof BombadilPersistenceError) return "persistence"; + if (error instanceof BombadilWriterSettlementError) return "writer-settlement"; + if (error instanceof BombadilArtifactPolicyError) return "artifact-policy"; + const message = renderUnknown(error); + if (message.includes("interrupted") || message.includes("SIGINT") || message.includes("SIGTERM")) { + return "interrupted"; + } + if (message.includes("exploration policy")) return "exploration-policy"; + if (message.includes("trace") || message.includes("Direct contract")) return "trace-attestation"; + if (message.includes("server") || message.includes("reachable")) return "server"; + if (message.includes("Bombadil")) return "process"; + return "unknown"; +} + +function failureAsError(error: unknown): Error { + return error instanceof Error ? error : new Error(renderUnknown(error)); +} + +function combinePersistenceFailure( + primary: unknown, + persistence: unknown, + message = "Bombadil persistence also failed", +): BombadilPersistenceError { + return new BombadilPersistenceError( + `${renderUnknown(primary)}; ${message}`, + [primary, persistence], + ); +} + +async function publishFailureAndThrow( + primary: unknown, + publish: () => Promise, +): Promise { + try { + await publish(); + } catch (persistence) { + throw combinePersistenceFailure( + primary, + persistence, + "sanitized Bombadil receipt publication also failed", + ); + } + throw failureAsError(primary); +} + +function createArtifactReceipt(options: { + readonly completedAt: Date; + readonly diagnosticsRetained: boolean; + readonly failureCode: DirectBombadilArtifactFailureCode | null; + readonly inventory: ArtifactInventory; + readonly policy: ValidatedArtifactPolicy; + readonly session: ArtifactUploadSession; + readonly status: "failed" | "passed" | "rejected"; +}): DirectBombadilArtifactReceipt { + return Object.freeze({ + schema: ARTIFACT_RECEIPT_SCHEMA, + completedAt: options.completedAt.toISOString(), + diagnosticsRetained: options.diagnosticsRetained, + failureCode: options.failureCode, + inventory: Object.freeze({ + entryCount: options.inventory.entryCount, + fileCount: options.inventory.fileCount, + inventorySha256: options.inventory.entryCount === 0 + ? null + : options.inventory.inventorySha256, + totalBytes: options.inventory.totalBytes, + }), + mode: options.session.mode, + policy: options.policy, + runId: options.session.runId, + status: options.status, + }); +} + +function createSanitizedRunSummary(options: { + readonly artifactName: string; + readonly attestation: DirectBombadilTraceAttestation | null; + readonly explorationSummary: DirectBombadilExplorationSummary | null; + readonly failureCode: DirectBombadilArtifactFailureCode | null; + readonly scenario: string; + readonly status: "failed" | "passed" | "rejected"; +}): DirectBombadilSanitizedRunSummary { + return Object.freeze({ + schema: ARTIFACT_SUMMARY_SCHEMA, + artifactName: options.artifactName, + scenario: options.scenario, + status: options.status, + failureCode: options.failureCode, + attestation: options.attestation === null + ? null + : Object.freeze({ + invalidObservationCount: options.attestation.invalidObservationCount, + observationCount: options.attestation.observationCount, + validObservationCount: options.attestation.validObservationCount, + }), + exploration: options.explorationSummary === null + ? null + : Object.freeze({ + actionCount: options.explorationSummary.actions.total, + nonWaitActionCount: options.explorationSummary.actions.nonWaitCount, + policySatisfied: options.explorationSummary.policy.satisfied, + traceBytes: options.explorationSummary.trace.bytes, + traceLineCount: options.explorationSummary.trace.lineCount, + traceSha256: options.explorationSummary.trace.sha256, + }), + }); +} + +async function resetUploadStaging(session: AtomicArtifactUploadSession): Promise { + await rm(session.stagingDirectory, { force: true, recursive: true }); + await createExclusiveDirectory(session.stagingDirectory, "Bombadil upload staging leaf"); +} + +async function withOwnedUploadStaging( + session: AtomicArtifactUploadSession, + operation: () => Promise, +): Promise { + await createExclusiveDirectory(session.stagingDirectory, "Bombadil upload staging leaf"); + try { + return await operation(); + } catch (error) { + try { + await rm(session.stagingDirectory, { force: true, recursive: true }); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "Bombadil upload staging operation and cleanup both failed", + { cause: error }, + ); + } + throw error; + } +} + +async function publishRunUpload(options: { + readonly abortSignal?: AbortSignal; + readonly artifactName: string; + readonly beforeCommitCheck?: (() => Promise | void) | undefined; + readonly attestation: DirectBombadilTraceAttestation | null; + readonly completedAt: Date; + readonly explorationSummary: DirectBombadilExplorationSummary | null; + readonly failure: unknown; + readonly failureCode?: DirectBombadilArtifactFailureCode; + readonly inventory: ArtifactInventory; + readonly interruptedSignal?: () => ProcessInterruptSignal | null; + readonly localOutputPath: string; + readonly policy: ValidatedArtifactPolicy; + readonly privateDiagnosticsAllowed: boolean; + readonly scenario: string; + readonly serverLog: string; + readonly processLog: string; + readonly session: ArtifactUploadSession; + readonly status: "failed" | "passed" | "rejected"; +}): Promise<{ + readonly failure: unknown; + readonly receipt: DirectBombadilArtifactReceipt; +}> { + let failure = options.failure; + let failureCode = failure === null + ? null + : options.failureCode ?? artifactFailureCode(failure); + let status = options.status; + const observeInterruption = (): boolean => { + if (failure !== null || options.abortSignal?.aborted !== true) return false; + const signal = options.interruptedSignal?.() ?? null; + failure = new Error( + signal === null + ? "Bombadil fuzzing was interrupted" + : `Bombadil fuzzing was interrupted by ${signal}`, + ); + failureCode = "interrupted"; + status = "failed"; + return true; + }; + observeInterruption(); + if (options.session.publication === "deferred" && options.session.mode !== "public-summary") { + throw new BombadilArtifactPolicyError( + "Bombadil matrices support public-summary uploads only", + ); + } + if (options.session.publication === "deferred") { + const receipt = createArtifactReceipt({ + completedAt: options.completedAt, + diagnosticsRetained: false, + failureCode, + inventory: options.inventory, + policy: options.policy, + session: options.session, + status, + }); + const summary = createSanitizedRunSummary({ + artifactName: options.artifactName, + attestation: options.attestation, + explorationSummary: options.explorationSummary, + failureCode, + scenario: options.scenario, + status, + }); + if (options.session.deferredPayload.value !== null) { + throw new BombadilArtifactPolicyError("Bombadil deferred upload state is invalid"); + } + options.session.deferredPayload.value = Object.freeze({ receipt, summary }); + return { failure, receipt }; + } + const session = options.session; + return await withOwnedUploadStaging(session, async () => { + const expectedFiles: ExpectedUploadFile[] = []; + let diagnosticsRetained = false; + if ( + session.mode === "private-vetted" + && options.privateDiagnosticsAllowed + && failureCode !== "interrupted" + ) { + try { + const diagnosticsRoot = await ensureSafeChildDirectories( + session.stagingDirectory, + ["diagnostics", "bombadil-output"], + ); + for (const file of options.inventory.files) { + await copyVerifiedArtifactFile({ + destinationRoot: diagnosticsRoot, + file, + sourceRoot: options.localOutputPath, + }); + expectedFiles.push({ + relativePath: `diagnostics/bombadil-output/${file.relativePath}`, + sha256: file.sha256, + size: file.size, + }); + } + const controlledLogs = await ensureSafeChildDirectories( + session.stagingDirectory, + ["diagnostics", "host"], + ); + const processLogBytes = Buffer.from(options.processLog, "utf8"); + const serverLogBytes = Buffer.from(options.serverLog, "utf8"); + await writeExclusiveBytes(join(controlledLogs, "bombadil.log"), processLogBytes); + await writeExclusiveBytes(join(controlledLogs, "server.log"), serverLogBytes); + expectedFiles.push( + { + relativePath: "diagnostics/host/bombadil.log", + sha256: sha256(processLogBytes), + size: processLogBytes.byteLength, + }, + { + relativePath: "diagnostics/host/server.log", + sha256: sha256(serverLogBytes), + size: serverLogBytes.byteLength, + }, + ); + diagnosticsRetained = true; + } catch (error) { + const persistence = new BombadilPersistenceError( + "Bombadil private diagnostics could not be persisted", + [error], + ); + failure = failure === null + ? persistence + : combinePersistenceFailure(failure, persistence); + failureCode = "persistence"; + status = "failed"; + await resetUploadStaging(session); + expectedFiles.length = 0; + } + } + const stageSanitizedPayload = async (): Promise => { + const receipt = createArtifactReceipt({ + completedAt: options.completedAt, + diagnosticsRetained, + failureCode, + inventory: options.inventory, + policy: options.policy, + session, + status, + }); + const summary = createSanitizedRunSummary({ + artifactName: options.artifactName, + attestation: options.attestation, + explorationSummary: options.explorationSummary, + failureCode, + scenario: options.scenario, + status, + }); + expectedFiles.push( + await writeExpectedJson(session.stagingDirectory, "summary.json", summary), + await writeExpectedJson(session.stagingDirectory, "receipt.json", receipt), + ); + await validateExpectedUploadTree(session.stagingDirectory, expectedFiles); + return receipt; + }; + let receipt = await stageSanitizedPayload(); + await options.beforeCommitCheck?.(); + await requireArtifactUploadLeafAbsent(session); + if (observeInterruption()) { + diagnosticsRetained = false; + await resetUploadStaging(session); + expectedFiles.length = 0; + receipt = await stageSanitizedPayload(); + await requireArtifactUploadLeafAbsent(session); + } + // Signals observed after this synchronous check belong to the caller after + // terminal publication has begun. The immutable rename remains uninterruptible. + await commitArtifactUploadSession(session); + return { failure, receipt }; + }); +} + +type MatrixCampaignTerminalStatus = DirectBombadilMatrixCampaignStatus; +type MatrixCampaignReceiptEntry = DirectBombadilMatrixCampaignReceiptEntry; + +interface MatrixSanitizedChild { + readonly campaignId: string; + readonly payload: SanitizedRunUploadPayload; +} + +async function publishMatrixUpload(options: { + readonly abortSignal?: AbortSignal; + readonly beforeCommitCheck?: (() => Promise | void) | undefined; + readonly campaigns: readonly MatrixCampaignReceiptEntry[]; + readonly children: readonly MatrixSanitizedChild[]; + readonly completedAt: Date; + readonly failure: unknown; + readonly failureCode?: DirectBombadilArtifactFailureCode; + readonly interruptedSignal?: () => ProcessInterruptSignal | null; + readonly omittedCampaignCount?: number; + readonly session: AtomicArtifactUploadSession; +}): Promise<{ readonly failure: unknown }> { + const uploadMode = options.session.mode; + if (uploadMode !== "public-summary") { + throw new BombadilArtifactPolicyError("Bombadil matrix upload session must be public-summary"); + } + let failure = options.failure; + let failureCode = failure === null + ? null + : options.failureCode ?? artifactFailureCode(failure); + let status: "failed" | "passed" = failure === null ? "passed" : "failed"; + const observeInterruption = (): boolean => { + if (failure !== null || options.abortSignal?.aborted !== true) return false; + const signal = options.interruptedSignal?.() ?? null; + failure = new Error( + signal === null + ? "Bombadil matrix was interrupted" + : `Bombadil matrix was interrupted by ${signal}`, + ); + failureCode = "interrupted"; + status = "failed"; + return true; + }; + observeInterruption(); + return await withOwnedUploadStaging(options.session, async () => { + const counts = new Map(); + for (const campaign of options.campaigns) { + counts.set(campaign.status, (counts.get(campaign.status) ?? 0) + 1); + } + const expectedFiles: ExpectedUploadFile[] = []; + const stageMatrixPayload = async (): Promise => { + const receipt: DirectBombadilMatrixReceipt = Object.freeze({ + schema: MATRIX_RECEIPT_SCHEMA, + completedAt: options.completedAt.toISOString(), + failureCode, + mode: uploadMode, + runId: options.session.runId, + status, + omittedCampaignCount: options.omittedCampaignCount ?? 0, + campaigns: Object.freeze(options.campaigns.map((campaign) => Object.freeze(campaign))), + }); + const summary: DirectBombadilMatrixSummary = Object.freeze({ + schema: MATRIX_SUMMARY_SCHEMA, + failureCode, + status, + campaigns: Object.freeze({ + failed: counts.get("failed") ?? 0, + notRun: counts.get("not-run") ?? 0, + notSelected: counts.get("not-selected") ?? 0, + passed: counts.get("passed") ?? 0, + rejected: counts.get("rejected") ?? 0, + total: options.campaigns.length, + omitted: options.omittedCampaignCount ?? 0, + }), + }); + for (const child of options.children) { + expectedFiles.push( + await writeExpectedJson( + options.session.stagingDirectory, + `campaigns/${child.campaignId}/summary.json`, + child.payload.summary, + ), + await writeExpectedJson( + options.session.stagingDirectory, + `campaigns/${child.campaignId}/receipt.json`, + child.payload.receipt, + ), + ); + } + expectedFiles.push( + await writeExpectedJson(options.session.stagingDirectory, "summary.json", summary), + await writeExpectedJson(options.session.stagingDirectory, "receipt.json", receipt), + ); + await validateExpectedUploadTree(options.session.stagingDirectory, expectedFiles); + }; + await stageMatrixPayload(); + await options.beforeCommitCheck?.(); + await requireArtifactUploadLeafAbsent(options.session); + if (observeInterruption()) { + await resetUploadStaging(options.session); + expectedFiles.length = 0; + await stageMatrixPayload(); + await requireArtifactUploadLeafAbsent(options.session); + } + // The atomic rename is the matrix terminal-publication boundary. + await commitArtifactUploadSession(options.session); + return { failure }; + }); +} + function parseTraceDirectObservation(value: unknown): TraceDirectObservation { if (!isRecord(value) || !hasExactKeys(value, DIRECT_OBSERVATION_KEYS)) { throw new Error("Bombadil trace has an invalid named direct observation"); @@ -1120,24 +3422,27 @@ export async function attestDirectBombadilTrace(options: { readonly expectedScenario: string; readonly tracePath: string; }): Promise { - const metadata = await stat(options.tracePath).catch(() => null); - if (metadata === null || !metadata.isFile() || metadata.size === 0) { - throw new Error("Bombadil did not produce a nonempty trace.jsonl"); - } - if (metadata.size > TRACE_MAX_BYTES) { - throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_BYTES)} bytes`); - } + const traceBytes = await readBoundRegularFileBytes({ + label: "Bombadil trace.jsonl", + maximumBytes: TRACE_MAX_BYTES, + path: options.tracePath, + }); + return attestDirectBombadilTraceBytes({ ...options, traceBytes }); +} - const stream = createReadStream(options.tracePath, { encoding: "utf8" }); - const lines = createInterface({ input: stream, crlfDelay: Infinity }); +function attestDirectBombadilTraceBytes(options: { + readonly expectedRoute: string; + readonly expectedScenario: string; + readonly traceBytes: Uint8Array; +}): DirectBombadilTraceAttestation { + const lines = decodeTraceLines(options.traceBytes); let observationCount = 0; let invalidObservationCount = 0; let validObservationCount = 0; let initial: DirectBombadilTraceBinding | null = null; let final: ExactTraceDirectObservation | null = null; let finalWasInvalid = false; - try { - for await (const line of lines) { + for (const line of lines) { observationCount += 1; if (observationCount > TRACE_MAX_LINES) { throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_LINES)} lines`); @@ -1190,10 +3495,6 @@ export async function attestDirectBombadilTrace(options: { if (observation.violations.some((value) => value !== 0)) { throw new Error("Bombadil trace contains a nonzero Direct violation counter"); } - } - } finally { - lines.close(); - stream.destroy(); } if (initial === null || final === null) { @@ -1240,13 +3541,19 @@ export async function summarizeDirectBombadilTrace(options: { readonly targetUrl: string; readonly tracePath: string; }): Promise { - const metadata = await stat(options.tracePath).catch(() => null); - if (metadata === null || !metadata.isFile() || metadata.size === 0) { - throw new Error("Bombadil did not produce a nonempty trace.jsonl"); - } - if (metadata.size > TRACE_MAX_BYTES) { - throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_BYTES)} bytes`); - } + const traceBytes = await readBoundRegularFileBytes({ + label: "Bombadil trace.jsonl", + maximumBytes: TRACE_MAX_BYTES, + path: options.tracePath, + }); + return summarizeDirectBombadilTraceBytes({ ...options, traceBytes }); +} + +function summarizeDirectBombadilTraceBytes(options: { + readonly explorationPolicy?: DirectBombadilExplorationPolicy; + readonly targetUrl: string; + readonly traceBytes: Uint8Array; +}): DirectBombadilExplorationSummary { let targetUrl: URL; try { targetUrl = new URL(options.targetUrl); @@ -1296,10 +3603,8 @@ export async function summarizeDirectBombadilTrace(options: { 0, TRACE_MAX_NAMED_SNAPSHOT_NAMES - strictDiagnosticSnapshotNames.size, ); - const stream = createReadStream(options.tracePath, { encoding: "utf8" }); - const lines = createInterface({ input: stream, crlfDelay: Infinity }); - try { - for await (const line of lines) { + const lines = decodeTraceLines(options.traceBytes); + for (const line of lines) { lineCount += 1; if (lineCount > TRACE_MAX_LINES) { throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_LINES)} lines`); @@ -1440,10 +3745,6 @@ export async function summarizeDirectBombadilTrace(options: { entry.values.add(snapshot.valueSha256); } previousObservationWasExact = true; - } - } finally { - lines.close(); - stream.destroy(); } if (lineCount === 0) throw new Error("Bombadil did not produce a nonempty trace.jsonl"); @@ -1494,13 +3795,12 @@ export async function summarizeDirectBombadilTrace(options: { policyFailures.push("the browser did not remain on the exact target URL"); } } - const traceBytes = await readFile(options.tracePath); return Object.freeze({ schema: "direct.bombadil-exploration-summary/v2", trace: Object.freeze({ - bytes: metadata.size, + bytes: options.traceBytes.byteLength, lineCount, - sha256: sha256(traceBytes), + sha256: sha256(options.traceBytes), }), actions: Object.freeze({ byKind: sortedCountRecord(actionCounts), @@ -1915,7 +4215,7 @@ export function validateDirectBombadilFuzzConfig( if (!isAbsolute(config.repositoryRoot) || repositoryRoot !== config.repositoryRoot) { throw new Error("repositoryRoot must be an absolute normalized path"); } - if (!ARTIFACT_NAME_PATTERN.test(config.artifactName)) { + if (!isBoundedArtifactIdentifier(config.artifactName)) { throw new Error("artifactName must be a safe lowercase kebab identifier"); } if ( @@ -1926,8 +4226,7 @@ export function validateDirectBombadilFuzzConfig( throw new Error("label must contain 1-160 visible characters"); } if ( - config.scenario.length > 120 - || !SCENARIO_PATTERN.test(config.scenario) + !isBoundedScenarioIdentifier(config.scenario) ) { throw new Error("scenario must be a valid Direct scenario identifier"); } @@ -1977,6 +4276,7 @@ export function validateDirectBombadilFuzzConfig( const targetQuery = validateTargetQuery(config.targetQuery ?? {}); const viewport = validateViewport(config.viewport); const explorationPolicy = validateExplorationPolicy(config.explorationPolicy); + const artifactPolicy = validateArtifactPolicy(config.artifactPolicy); const startupTimeoutMs = config.server.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS; if ( !Number.isSafeInteger(startupTimeoutMs) @@ -1992,6 +4292,7 @@ export function validateDirectBombadilFuzzConfig( const port = new URL(baseUrl).port; return { ...config, + artifactPolicy, repositoryRoot, specificationPath, baseUrl, @@ -2118,39 +4419,122 @@ function captureStream( function signalProcessGroup( process_: ReturnType, - signal: "SIGKILL" | "SIGTERM", + signal: "SIGKILL", ): void { try { process.kill(-process_.pid, signal); - } catch { + return; + } catch (error) { + if (!isRecord(error) || error.code !== "ESRCH") throw error; if (process_.exitCode === null) process_.kill(signal); } } -async function terminateProcessGroup( +function processGroupMayExist(processId: number): boolean { + try { + process.kill(-processId, 0); + return true; + } catch (error) { + if (isRecord(error) && error.code === "ESRCH") return false; + // EPERM does not prove absence. Keep polling the already-killed group; + // settlement must never authorize a second signal from a failed probe. + if (isRecord(error) && error.code === "EPERM") return true; + throw error; + } +} + +async function waitForProcessGroupExit( + processId: number, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (processGroupMayExist(processId)) { + if (Date.now() >= deadline) { + throw new Error(`Bombadil process group ${String(processId)} survived cleanup`); + } + await Bun.sleep(Math.min(25, Math.max(1, deadline - Date.now()))); + } +} + +async function waitForBombadilLeaderExit( process_: ReturnType, - graceMs: number, + timeoutMs: number, ): Promise { - signalProcessGroup(process_, "SIGTERM"); - await Bun.sleep(graceMs); - // The group may still contain descendants after its leader exits on TERM. - signalProcessGroup(process_, "SIGKILL"); - await Promise.race([process_.exited.then(() => undefined), Bun.sleep(graceMs)]); + if (process_.exitCode !== null) return; + const exited = await Promise.race([ + process_.exited.then(() => true), + Bun.sleep(timeoutMs).then(() => false), + ]); + if (!exited && process_.exitCode === null) { + throw new Error(`Bombadil process ${String(process_.pid)} survived cleanup`); + } +} + +async function settleBombadilProcessGroup(options: { + readonly process: ReturnType; + readonly timeoutMs: number; +}): Promise { + try { + signalProcessGroup(options.process, "SIGKILL"); + await waitForBombadilLeaderExit(options.process, options.timeoutMs); + await waitForProcessGroupExit(options.process.pid, options.timeoutMs); + } catch (error) { + throw new BombadilWriterSettlementError( + `Bombadil process group ${String(options.process.pid)} did not settle safely`, + error, + ); + } +} + +async function monitorBombadilArtifactTree(options: { + readonly abortSignal: AbortSignal; + readonly outputPath: string; + readonly policy: ValidatedArtifactPolicy; +}): Promise { + while (!options.abortSignal.aborted) { + try { + await scanBombadilArtifactTree({ + allowTransientEntryAbsence: true, + hashFiles: false, + policy: options.policy, + root: options.outputPath, + rootMayBeAbsent: true, + }); + } catch (error) { + if (isRecord(error) && error.code === "ENOENT") { + // A live producer may atomically replace or remove an entry. The final + // stopped-process scan is authoritative; polling only bounds growth. + } else { + throw error instanceof BombadilArtifactPolicyError + ? error + : new BombadilArtifactPolicyError("Bombadil artifact monitor could not inspect output"); + } + } + await Bun.sleep(ARTIFACT_MONITOR_INTERVAL_MS); + } } export async function runBombadilNativeProcess( invocation: DirectBombadilInvocation, ): Promise { + const artifactPolicy = validateArtifactPolicy(invocation.artifactPolicy); + const childEnvironment: Record = Object.fromEntries( + Object.entries({ + ...process.env, + NO_COLOR: "1", + }).filter(([name]) => name !== ARTIFACT_COORDINATION_ENVIRONMENT), + ); const process_ = Bun.spawn([...invocation.command], { cwd: invocation.cwd, detached: true, - env: { ...process.env, NO_COLOR: "1" }, + env: childEnvironment, stdin: "ignore", stdout: "pipe", stderr: "pipe", }); let timeout: ReturnType | undefined; let abortListener: (() => void) | undefined; + const monitorAbortController = new AbortController(); const timeoutPromise = new Promise<"timeout">((resolveTimeout) => { timeout = setTimeout(() => resolveTimeout("timeout"), invocation.wallClockTimeoutMs); }); @@ -2167,23 +4551,52 @@ export async function runBombadilNativeProcess( const stdoutCapture = captureStream(process_.stdout); const stderrCapture = captureStream(process_.stderr); const outputPromise = Promise.all([stdoutCapture.result, stderrCapture.result]); + const artifactMonitor = monitorBombadilArtifactTree({ + abortSignal: monitorAbortController.signal, + outputPath: invocation.outputPath, + policy: artifactPolicy, + }).then( + () => ({ kind: "monitor-stopped" as const }), + (error: unknown) => ({ kind: "artifact-policy" as const, error }), + ); const outcome = await Promise.race([ process_.exited.then((exitCode) => ({ kind: "exited" as const, exitCode })), timeoutPromise.then(() => ({ kind: "timeout" as const })), abortPromise.then(() => ({ kind: "aborted" as const })), + artifactMonitor, ]); + if (outcome.kind === "monitor-stopped") { + throw new BombadilArtifactPolicyError("Bombadil artifact monitor stopped unexpectedly"); + } const terminationGraceMs = invocation.terminationGraceMs ?? PROCESS_TERMINATION_GRACE_MS; - if (outcome.kind === "exited") { - // The native leader is done. Any member left in its group is stale and - // may otherwise keep inherited output pipes open indefinitely. - signalProcessGroup(process_, "SIGKILL"); - } else { - await terminateProcessGroup( - process_, - terminationGraceMs, - ); + try { + await settleBombadilProcessGroup({ + process: process_, + timeoutMs: terminationGraceMs, + }); + } catch (error) { + stdoutCapture.stop(); + stderrCapture.stop(); + throw error; + } + let finalArtifactFailure: unknown = null; + try { + await scanBombadilArtifactTree({ + hashFiles: false, + policy: artifactPolicy, + root: invocation.outputPath, + rootMayBeAbsent: true, + }); + } catch (error) { + finalArtifactFailure = error instanceof BombadilArtifactPolicyError + ? error + : new BombadilArtifactPolicyError( + `Bombadil final artifact inventory could not be proven safe: ${renderUnknown(error)}`, + ); } + monitorAbortController.abort(); + const finalMonitorOutcome = await artifactMonitor; const outputSettled = await Promise.race([ outputPromise.then( () => true, @@ -2196,6 +4609,21 @@ export async function runBombadilNativeProcess( stderrCapture.stop(); } const [stdout, stderr] = await outputPromise; + if (outcome.kind === "artifact-policy") { + throw outcome.error instanceof BombadilArtifactPolicyError + ? outcome.error + : new BombadilArtifactPolicyError("Bombadil artifact policy was violated"); + } + if (finalMonitorOutcome.kind === "artifact-policy") { + throw finalMonitorOutcome.error instanceof BombadilArtifactPolicyError + ? finalMonitorOutcome.error + : new BombadilArtifactPolicyError("Bombadil artifact policy was violated"); + } + if (finalArtifactFailure !== null) { + throw finalArtifactFailure instanceof BombadilArtifactPolicyError + ? finalArtifactFailure + : new BombadilArtifactPolicyError("Bombadil artifact policy was violated"); + } return { exitCode: outcome.kind === "exited" ? outcome.exitCode : process_.exitCode ?? 137, stderr, @@ -2203,6 +4631,7 @@ export async function runBombadilNativeProcess( termination: outcome.kind === "exited" ? null : outcome.kind, }; } finally { + monitorAbortController.abort(); if (timeout !== undefined) clearTimeout(timeout); if (abortListener !== undefined) { invocation.abortSignal?.removeEventListener("abort", abortListener); @@ -2210,11 +4639,19 @@ export async function runBombadilNativeProcess( } } +const processEvents: EventEmitter = process; + const defaultDependencies: DirectBombadilRunnerDependencies = { acquireServer: acquireVerificationServer, createAbortController: () => new AbortController(), + createRunId: randomUUID, now: () => new Date(), runBombadil: runBombadilNativeProcess, + signalController: { + forward: (signal) => process.kill(process.pid, signal), + once: (signal, listener) => processEvents.once(signal, listener), + removeListener: (signal, listener) => processEvents.removeListener(signal, listener), + }, serverOutputTimeoutMs: SERVER_OUTPUT_TIMEOUT_MS, spawnServer: spawnVerificationServer, stopServer: stopVerificationServer, @@ -2419,12 +4856,14 @@ function parseMatrixCampaignArgument(arguments_: readonly string[]): { function validateCampaignMatrix( campaigns: readonly DirectBombadilFuzzCampaign[], ): readonly DirectBombadilFuzzCampaign[] { - if (campaigns.length === 0 || campaigns.length > 32) { - throw new Error("Bombadil campaign matrix must contain 1-32 campaigns"); + if (campaigns.length === 0 || campaigns.length > MAX_MATRIX_CAMPAIGNS) { + throw new Error( + `Bombadil campaign matrix must contain 1-${String(MAX_MATRIX_CAMPAIGNS)} campaigns`, + ); } const ids = new Set(); for (const campaign of campaigns) { - if (!ARTIFACT_NAME_PATTERN.test(campaign.id) || ids.has(campaign.id)) { + if (!isBoundedArtifactIdentifier(campaign.id) || ids.has(campaign.id)) { throw new Error("Bombadil campaign IDs must be unique lowercase kebab identifiers"); } ids.add(campaign.id); @@ -2435,12 +4874,14 @@ function validateCampaignMatrix( /** Runs a bounded product-owned campaign matrix serially. */ export async function runDirectBombadilFuzzMatrix( campaignsInput: readonly DirectBombadilFuzzCampaign[], - arguments_: readonly string[] = process.argv.slice(2), + input: DirectBombadilMatrixRunInput = process.argv.slice(2), dependencyOverrides: Partial = {}, -): Promise { - const campaigns = validateCampaignMatrix(campaignsInput); - const parsed = parseMatrixCampaignArgument(arguments_); - if (parsed.help) { +): Promise { + const dependencies = { ...defaultDependencies, ...dependencyOverrides }; + const normalizedOptions = normalizeFuzzRunOptions(input); + if (normalizedOptions.arguments.some((argument) => argument === "--help" || argument === "-h")) { + const campaigns = validateCampaignMatrix(campaignsInput); + parseMatrixCampaignArgument(normalizedOptions.arguments); process.stdout.write(`${[ helpText(campaigns[0]?.config.baseUrl ?? ""), " --campaign Run one campaign; required with --replay", @@ -2449,36 +4890,245 @@ export async function runDirectBombadilFuzzMatrix( ].join("\n")}\n`); return { kind: "help" }; } - const selected = parsed.campaignId === null - ? campaigns - : campaigns.filter((campaign) => campaign.id === parsed.campaignId); - if (selected.length === 0) { - throw new Error(`Unknown Bombadil campaign ${parsed.campaignId ?? ""}`); + const matrixAbortController = dependencies.createAbortController?.() ?? new AbortController(); + let interruptedSignal: ProcessInterruptSignal | null = null; + const interrupt = (signal: ProcessInterruptSignal): void => { + interruptedSignal ??= signal; + matrixAbortController.abort(); + }; + const processSignals = dependencies.signalController; + for (const signal of PROCESS_INTERRUPT_SIGNALS) processSignals.once(signal, interrupt); + const releaseSignalHandlers = (): void => { + for (const signal of PROCESS_INTERRUPT_SIGNALS) { + processSignals.removeListener(signal, interrupt); + } + }; + let invalidMatrixUploadMode: boolean; + let matrixPlan: DirectBombadilArtifactRunPlan; + let uploadSession: AtomicArtifactUploadSession; + try { + const firstRepositoryRoot = campaignsInput[0]?.config.repositoryRoot; + if (normalizedOptions.artifactRun === null && firstRepositoryRoot === undefined) { + throw new Error( + `Bombadil campaign matrix must contain 1-${String(MAX_MATRIX_CAMPAIGNS)} campaigns`, + ); + } + const requestedMatrixPlan = normalizedOptions.artifactRun ?? { + repositoryRoot: await realpath(resolve(firstRepositoryRoot ?? "")), + runId: dependencies.createRunId(), + uploadMode: "public-summary" as const, + }; + const requestedMatrixUploadMode = ( + requestedMatrixPlan as { readonly uploadMode?: unknown } + ).uploadMode ?? "public-summary"; + invalidMatrixUploadMode = requestedMatrixUploadMode !== "public-summary"; + matrixPlan = { + repositoryRoot: requestedMatrixPlan.repositoryRoot, + runId: requestedMatrixPlan.runId, + uploadMode: "public-summary", + }; + uploadSession = await prepareArtifactUploadSession(matrixPlan); + } catch (error) { + releaseSignalHandlers(); + const signalToForward = interruptedSignal as ProcessInterruptSignal | null; + if (signalToForward !== null) processSignals.forward(signalToForward); + throw error; } - if ( - parsed.campaignId === null - && parsed.arguments.some((argument) => - argument === "--replay" || argument.startsWith("--replay=") - ) - ) { - throw new Error("--replay requires exactly one --campaign in matrix mode"); - } - const results: Array<{ - readonly campaignId: string; - readonly result: Extract; - }> = []; - for (const campaign of selected) { - const result = await runDirectBombadilFuzz( - campaign.config, - parsed.arguments, - dependencyOverrides, - ); - if (result.kind !== "run") { - throw new Error("Bombadil campaign unexpectedly returned help during matrix execution"); + try { + let campaigns: readonly DirectBombadilFuzzCampaign[]; + let parsed: ReturnType; + let selected: readonly DirectBombadilFuzzCampaign[]; + try { + if (invalidMatrixUploadMode) { + throw new Error("Bombadil matrices support public-summary uploads only"); + } + campaigns = validateCampaignMatrix(campaignsInput); + parsed = parseMatrixCampaignArgument(normalizedOptions.arguments); + selected = parsed.campaignId === null + ? campaigns + : campaigns.filter((campaign) => campaign.id === parsed.campaignId); + if (selected.length === 0) { + throw new Error(`Unknown Bombadil campaign ${parsed.campaignId ?? ""}`); + } + if ( + parsed.campaignId === null + && parsed.arguments.some((argument) => + argument === "--replay" || argument.startsWith("--replay=") + ) + ) { + throw new Error("--replay requires exactly one --campaign in matrix mode"); + } + for (const campaign of selected) { + if (interruptedSignal !== null) throw new Error("Bombadil matrix was interrupted"); + const campaignArguments = parseDirectBombadilFuzzArguments( + parsed.arguments, + campaign.config.baseUrl, + ); + if (campaignArguments.kind !== "run") { + throw new Error("Bombadil matrix campaign unexpectedly entered help mode"); + } + const lexicalConfig = validateDirectBombadilFuzzConfig( + campaign.config, + campaignArguments.baseUrl, + ); + const resolvedPaths = await resolveDirectBombadilRealPaths( + lexicalConfig, + resolveReplayPath(lexicalConfig.repositoryRoot, campaignArguments.replayPath), + ); + if (resolvedPaths.config.repositoryRoot !== matrixPlan.repositoryRoot) { + throw new BombadilArtifactPolicyError( + "Every Bombadil matrix campaign must share artifactRun.repositoryRoot", + ); + } + } + } catch (error) { + const boundedCampaigns = campaignsInput.slice(0, MAX_MATRIX_CAMPAIGNS); + const retainedCampaignIds = new Set(); + const entries = boundedCampaigns.map((campaign, index): MatrixCampaignReceiptEntry => { + const boundedCampaignId = isBoundedArtifactIdentifier(campaign.id) ? campaign.id : null; + const campaignId = boundedCampaignId !== null && !retainedCampaignIds.has(boundedCampaignId) + ? boundedCampaignId + : null; + if (campaignId !== null) retainedCampaignIds.add(campaignId); + return { + campaignId, + index, + receipt: null, + status: "rejected", + }; + }); + return await publishFailureAndThrow(error, async () => { + await publishMatrixUpload({ + abortSignal: matrixAbortController.signal, + beforeCommitCheck: dependencies.beforeArtifactCommit, + campaigns: entries, + children: [], + completedAt: dependencies.now(), + failure: error, + failureCode: interruptedSignal === null ? "configuration-rejected" : "interrupted", + interruptedSignal: () => interruptedSignal, + omittedCampaignCount: Math.max(0, campaignsInput.length - entries.length), + session: uploadSession, + }); + }); + } + + const results: Array<{ + readonly campaignId: string; + readonly result: Extract; + }> = []; + const entries: MatrixCampaignReceiptEntry[] = campaigns.map((campaign, index) => ({ + campaignId: campaign.id, + index, + receipt: null, + status: selected.includes(campaign) ? "not-run" : "not-selected", + })); + const children: MatrixSanitizedChild[] = []; + let executionFailure: unknown = null; + let executionFailureCode: DirectBombadilArtifactFailureCode | undefined; + for (const campaign of selected) { + if (interruptedSignal !== null) { + executionFailure = new Error("Bombadil matrix was interrupted"); + break; + } + const campaignIndex = campaigns.indexOf(campaign); + const deferredPayload = { value: null as SanitizedRunUploadPayload | null }; + const childSession: DeferredArtifactUploadSession = { + deferredPayload, + finalDirectory: join(uploadSession.finalDirectory, "campaigns", campaign.id), + mode: uploadSession.mode, + publication: "deferred", + receiptPath: join( + uploadSession.finalDirectory, + "campaigns", + campaign.id, + "receipt.json", + ), + runId: uploadSession.runId, + }; + try { + const result = await runDirectBombadilFuzzInternal( + campaign.config, + parsed.arguments, + dependencyOverrides, + { + abortSignal: matrixAbortController.signal, + forwardSignal: false, + interruptedSignal: () => interruptedSignal, + plan: matrixPlan, + session: childSession, + }, + ); + if (result.kind !== "run" || deferredPayload.value === null) { + throw new Error("Bombadil campaign did not finalize its sanitized receipt"); + } + children.push({ campaignId: campaign.id, payload: deferredPayload.value }); + results.push({ campaignId: campaign.id, result }); + entries[campaignIndex] = { + campaignId: campaign.id, + index: campaignIndex, + receipt: `campaigns/${campaign.id}/receipt.json`, + status: "passed", + }; + } catch (error) { + executionFailure = error; + const childPayload = deferredPayload.value; + if (childPayload !== null) { + children.push({ campaignId: campaign.id, payload: childPayload }); + executionFailureCode = childPayload.receipt.failureCode ?? undefined; + } + entries[campaignIndex] = { + campaignId: campaign.id, + index: campaignIndex, + receipt: childPayload === null + ? null + : `campaigns/${campaign.id}/receipt.json`, + status: childPayload?.receipt.status === "rejected" ? "rejected" : "failed", + }; + break; + } + } + if (executionFailure === null && interruptedSignal !== null) { + executionFailure = new Error("Bombadil matrix was interrupted"); + executionFailureCode = "interrupted"; + } + if (executionFailure !== null) { + await publishFailureAndThrow(executionFailure, async () => { + await publishMatrixUpload({ + abortSignal: matrixAbortController.signal, + beforeCommitCheck: dependencies.beforeArtifactCommit, + campaigns: entries, + children, + completedAt: dependencies.now(), + failure: executionFailure, + ...(executionFailureCode === undefined ? {} : { failureCode: executionFailureCode }), + interruptedSignal: () => interruptedSignal, + session: uploadSession, + }); + }); } - results.push({ campaignId: campaign.id, result }); + const published = await publishMatrixUpload({ + abortSignal: matrixAbortController.signal, + beforeCommitCheck: dependencies.beforeArtifactCommit, + campaigns: entries, + children, + completedAt: dependencies.now(), + failure: null, + interruptedSignal: () => interruptedSignal, + session: uploadSession, + }); + if (published.failure !== null) throw failureAsError(published.failure); + return { + kind: "matrix", + receiptPath: uploadSession.receiptPath, + results: Object.freeze(results), + uploadArtifactPath: uploadSession.finalDirectory, + }; + } finally { + releaseSignalHandlers(); + const signalToForward = interruptedSignal as ProcessInterruptSignal | null; + if (signalToForward !== null) processSignals.forward(signalToForward); } - return { kind: "matrix", results: Object.freeze(results) }; } function throwIfBombadilRunAborted(signal: AbortSignal): void { @@ -2495,37 +5145,154 @@ function terminateAbortedOwnedServer( } /** Runs one bounded diagnostic Bombadil campaign and always releases its server lease. */ -export async function runDirectBombadilFuzz( +async function runDirectBombadilFuzzInternal( config: DirectBombadilFuzzConfig, - arguments_: readonly string[] = process.argv.slice(2), + input: DirectBombadilFuzzRunInput = process.argv.slice(2), dependencyOverrides: Partial = {}, -): Promise { - const parsed = parseDirectBombadilFuzzArguments(arguments_, config.baseUrl); - if (parsed.kind === "help") { + preparedUpload?: Readonly<{ + readonly abortSignal?: AbortSignal; + readonly forwardSignal?: boolean; + readonly interruptedSignal?: () => ProcessInterruptSignal | null; + readonly plan: DirectBombadilArtifactRunPlan; + readonly session: ArtifactUploadSession; + }>, +): Promise { + const dependencies = { ...defaultDependencies, ...dependencyOverrides }; + const normalizedOptions = normalizeFuzzRunOptions(input); + if (normalizedOptions.arguments.some((argument) => argument === "--help" || argument === "-h")) { + parseDirectBombadilFuzzArguments(normalizedOptions.arguments, config.baseUrl); process.stdout.write(`${helpText(config.baseUrl)}\n`); return { kind: "help" }; } - - const lexicalConfig = validateDirectBombadilFuzzConfig(config, parsed.baseUrl); - const lexicalReplayPath = resolveReplayPath( - lexicalConfig.repositoryRoot, - parsed.replayPath, - ); - const resolvedPaths = await resolveDirectBombadilRealPaths( - lexicalConfig, - lexicalReplayPath, - ); - const validated = resolvedPaths.config; - const replayPath = resolvedPaths.replayPath; - const dependencies = { ...defaultDependencies, ...dependencyOverrides }; + const abortController = dependencies.createAbortController?.() ?? new AbortController(); + let interruptedSignal: ProcessInterruptSignal | null = null; + let ownedServer: ManagedVerificationServer | null = null; + const interrupt = (signal: ProcessInterruptSignal): void => { + interruptedSignal ??= signal; + abortController.abort(); + if (ownedServer?.exitCode() === null) ownedServer.terminate(); + }; + // @types/bun augments Node's process events and has changed this overload + // across patch releases. Bind the stable signal subset used by this runner. + const processSignals = dependencies.signalController; + for (const signal of PROCESS_INTERRUPT_SIGNALS) processSignals.once(signal, interrupt); + const abortFromPreparedMatrix = (): void => { + interruptedSignal ??= preparedUpload?.interruptedSignal?.() ?? null; + abortController.abort(); + if (ownedServer?.exitCode() === null) ownedServer.terminate(); + }; + if (preparedUpload?.abortSignal !== undefined) { + if (preparedUpload.abortSignal.aborted) abortFromPreparedMatrix(); + else preparedUpload.abortSignal.addEventListener("abort", abortFromPreparedMatrix, { once: true }); + } + try { const generatedAt = dependencies.now(); - const artifactRun = await createArtifactRun({ - artifactRoot: validated.artifactRoot, - generatedAt: generatedAt.toISOString(), - }); + const artifactPlan = preparedUpload?.plan ?? normalizedOptions.artifactRun ?? { + repositoryRoot: await realpath(resolve(config.repositoryRoot)), + runId: dependencies.createRunId(), + uploadMode: "public-summary" as const, + }; + const uploadSession = preparedUpload?.session + ?? await prepareArtifactUploadSession(artifactPlan); + let parsed: Extract; + let validated: ValidatedConfig; + let replayPath: string | null; + try { + throwIfBombadilRunAborted(abortController.signal); + const parsedInput = parseDirectBombadilFuzzArguments( + normalizedOptions.arguments, + config.baseUrl, + ); + if (parsedInput.kind !== "run") { + throw new Error("Bombadil help was not handled before artifact allocation"); + } + parsed = parsedInput; + const lexicalConfig = validateDirectBombadilFuzzConfig(config, parsed.baseUrl); + const lexicalReplayPath = resolveReplayPath( + lexicalConfig.repositoryRoot, + parsed.replayPath, + ); + const resolvedPaths = await resolveDirectBombadilRealPaths( + lexicalConfig, + lexicalReplayPath, + ); + validated = resolvedPaths.config; + replayPath = resolvedPaths.replayPath; + throwIfBombadilRunAborted(abortController.signal); + if (validated.repositoryRoot !== resolve(artifactPlan.repositoryRoot)) { + throw new BombadilArtifactPolicyError( + "artifactRun.repositoryRoot must equal the campaign repositoryRoot", + ); + } + } catch (error) { + const policy = (() => { + try { + return validateArtifactPolicy(config.artifactPolicy); + } catch { + return validateArtifactPolicy(undefined); + } + })(); + return await publishFailureAndThrow(error, async () => { + await publishRunUpload({ + abortSignal: abortController.signal, + artifactName: isBoundedArtifactIdentifier(config.artifactName) + ? config.artifactName + : "rejected", + beforeCommitCheck: dependencies.beforeArtifactCommit, + attestation: null, + completedAt: dependencies.now(), + explorationSummary: null, + failure: error, + failureCode: abortController.signal.aborted + ? "interrupted" + : "configuration-rejected", + inventory: emptyArtifactInventory(), + interruptedSignal: () => interruptedSignal, + localOutputPath: config.repositoryRoot, + policy, + privateDiagnosticsAllowed: false, + processLog: "", + scenario: isBoundedScenarioIdentifier(config.scenario) ? config.scenario : "rejected", + serverLog: "", + session: uploadSession, + status: abortController.signal.aborted ? "failed" : "rejected", + }); + }); + } + let artifactRun: Awaited>; + try { + throwIfBombadilRunAborted(abortController.signal); + artifactRun = await createBombadilArtifactRun({ + artifactName: validated.artifactName, + repositoryRoot: validated.repositoryRoot, + runId: dependencies.createRunId(), + }); + throwIfBombadilRunAborted(abortController.signal); + } catch (error) { + return await publishFailureAndThrow(error, async () => { + await publishRunUpload({ + abortSignal: abortController.signal, + artifactName: validated.artifactName, + beforeCommitCheck: dependencies.beforeArtifactCommit, + attestation: null, + completedAt: dependencies.now(), + explorationSummary: null, + failure: error, + inventory: emptyArtifactInventory(), + interruptedSignal: () => interruptedSignal, + localOutputPath: validated.repositoryRoot, + policy: validated.artifactPolicy, + privateDiagnosticsAllowed: false, + processLog: "", + scenario: validated.scenario, + serverLog: "", + session: uploadSession, + status: "failed", + }); + }); + } const outputPath = join(artifactRun.runDirectory, "bombadil"); const tracePath = join(outputPath, "trace.jsonl"); - const abortController = dependencies.createAbortController?.() ?? new AbortController(); const invocation = createDirectBombadilInvocation({ baseUrl: validated.baseUrl, bombadilExecutable: validated.bombadilExecutable, @@ -2539,35 +5306,30 @@ export async function runDirectBombadilFuzz( timeLimitSeconds: parsed.timeLimitSeconds, viewport: validated.viewport, }); - const abortableInvocation = { ...invocation, abortSignal: abortController.signal }; + const abortableInvocation = { + ...invocation, + abortSignal: abortController.signal, + artifactPolicy: validated.artifactPolicy, + }; const serverCommand = validated.server.command.map((argument) => argument === "{port}" ? validated.port : argument ); let bombadilVersion: string | null = null; let lease: ServerLease | null = null; - let ownedServer: ManagedVerificationServer | null = null; let processResult: BombadilProcessResult | null = null; let attestation: DirectBombadilTraceAttestation | null = null; let attestationFailure: unknown = null; let explorationSummary: DirectBombadilExplorationSummary | null = null; let explorationSummaryFailure: unknown = null; + let artifactInventory = emptyArtifactInventory(); + let artifactInventoryVetted = false; let rawTracePath: string | null = null; let serverOutput = ""; let serverOutputFailure: unknown = null; let failure: unknown = null; - let interruptedSignal: NodeJS.Signals | null = null; - const interrupt = (signal: NodeJS.Signals): void => { - interruptedSignal ??= signal; - abortController.abort(); - if (ownedServer?.exitCode() === null) ownedServer.terminate(); - }; - const interruptSignals = ["SIGINT", "SIGTERM"] as const; - // @types/bun augments Node's process events and has changed this overload - // across patch releases. Bind the stable signal subset used by this runner. - const processSignals = process as unknown as ProcessSignalEmitter; - for (const signal of interruptSignals) processSignals.once(signal, interrupt); - try { + let writersSettled = true; + { try { await requireRegularFile(validated.bombadilExecutable, "The root Bombadil executable"); bombadilVersion = await readExactBombadilVersion(validated.repositoryRoot); @@ -2586,7 +5348,9 @@ export async function runDirectBombadilFuzz( ownedServer = dependencies.spawnServer({ command: serverCommand, cwd: validated.server.cwd, + detachedProcessGroup: true, ...(validated.server.env === undefined ? {} : { env: validated.server.env }), + omitEnvironment: [ARTIFACT_COORDINATION_ENVIRONMENT], }); terminateAbortedOwnedServer(abortController.signal, ownedServer); return ownedServer; @@ -2607,30 +5371,6 @@ export async function runDirectBombadilFuzz( } catch (error) { processFailure = error; } - const traceMetadata = await stat(tracePath).catch(() => null); - if (traceMetadata?.isFile() === true && traceMetadata.size > 0) { - rawTracePath = tracePath; - } - try { - attestation = await attestDirectBombadilTrace({ - expectedRoute: validated.expectedRoute, - expectedScenario: validated.scenario, - tracePath, - }); - } catch (error) { - attestationFailure = error; - } - try { - explorationSummary = await summarizeDirectBombadilTrace({ - ...(validated.explorationPolicy === null - ? {} - : { explorationPolicy: validated.explorationPolicy }), - targetUrl: invocation.targetUrl, - tracePath, - }); - } catch (error) { - explorationSummaryFailure = error; - } if (processFailure !== null) { throw processFailure instanceof Error ? processFailure @@ -2648,22 +5388,8 @@ export async function runDirectBombadilFuzz( if (processResult.exitCode !== 0) { throw new Error(`Bombadil exited with status ${String(processResult.exitCode)}`); } - if (attestationFailure !== null) { - throw attestationFailure instanceof Error - ? attestationFailure - : new Error(renderUnknown(attestationFailure)); - } - if (explorationSummaryFailure !== null) { - throw explorationSummaryFailure instanceof Error - ? explorationSummaryFailure - : new Error(renderUnknown(explorationSummaryFailure)); - } - if (explorationSummary?.policy.satisfied !== true) { - throw new Error( - `Bombadil exploration policy was not satisfied: ${explorationSummary?.policy.failures.join("; ") ?? "summary unavailable"}`, - ); - } } catch (error) { + if (error instanceof BombadilWriterSettlementError) writersSettled = false; failure = error; } @@ -2672,11 +5398,17 @@ export async function runDirectBombadilFuzz( try { await dependencies.stopServer(serverToStop); } catch (error) { - failure ??= error; + writersSettled = false; + failure = new BombadilWriterSettlementError( + "Bombadil server writers were not proven absent", + failure === null + ? error + : new AggregateError([failure, error], "Bombadil run and server cleanup both failed"), + ); } } const serverAfterRun = ownedServer as ManagedVerificationServer | null; - if (serverAfterRun !== null) { + if (serverAfterRun !== null && writersSettled) { try { serverOutput = await readServerOutputBounded( serverAfterRun, @@ -2687,87 +5419,236 @@ export async function runDirectBombadilFuzz( failure ??= error; } } - } finally { - for (const signal of interruptSignals) { - processSignals.removeListener(signal, interrupt); + if (writersSettled) { + try { + try { + artifactInventory = await scanBombadilArtifactTree({ + hashFiles: true, + policy: validated.artifactPolicy, + root: outputPath, + }); + } catch (error) { + throw error instanceof BombadilArtifactPolicyError + ? error + : new BombadilArtifactPolicyError( + `Bombadil artifact inventory could not be proven safe: ${renderUnknown(error)}`, + ); + } + artifactInventoryVetted = true; + const trace = artifactInventory.files.find((file) => file.relativePath === "trace.jsonl"); + if (trace === undefined || trace.size === 0) { + const missingTrace = new BombadilArtifactPolicyError( + "Bombadil did not produce a retained nonempty trace.jsonl", + ); + attestationFailure = missingTrace; + throw missingTrace; + } + rawTracePath = tracePath; + const traceBytes = await readBoundRegularFileBytes({ + expected: trace, + label: "Bombadil trace.jsonl", + maximumBytes: TRACE_MAX_BYTES, + path: tracePath, + }); + try { + attestation = attestDirectBombadilTraceBytes({ + expectedRoute: validated.expectedRoute, + expectedScenario: validated.scenario, + traceBytes, + }); + } catch (error) { + attestationFailure = error; + } + try { + explorationSummary = summarizeDirectBombadilTraceBytes({ + ...(validated.explorationPolicy === null + ? {} + : { explorationPolicy: validated.explorationPolicy }), + targetUrl: invocation.targetUrl, + traceBytes, + }); + } catch (error) { + explorationSummaryFailure = error; + } + if (attestationFailure !== null) { + throw attestationFailure instanceof Error + ? attestationFailure + : new Error(renderUnknown(attestationFailure)); + } + if (explorationSummaryFailure !== null) { + throw explorationSummaryFailure instanceof Error + ? explorationSummaryFailure + : new Error(renderUnknown(explorationSummaryFailure)); + } + if (explorationSummary?.policy.satisfied !== true) { + throw new Error( + `Bombadil exploration policy was not satisfied: ${explorationSummary?.policy.failures.join("; ") ?? "summary unavailable"}`, + ); + } + } catch (error) { + failure ??= error; + } + } else { + artifactInventory = emptyArtifactInventory(); + failure ??= new BombadilWriterSettlementError( + "Bombadil writers were not proven absent; artifact inspection was suppressed", + new Error("writer settlement unavailable"), + ); } } - const capturedSignal = interruptedSignal as NodeJS.Signals | null; - if (capturedSignal !== null && failure === null) { - failure = new Error(`Bombadil fuzzing was interrupted by ${capturedSignal}`); + const signalAfterRun = interruptedSignal as ProcessInterruptSignal | null; + if (signalAfterRun !== null && failure === null) { + failure = new Error(`Bombadil fuzzing was interrupted by ${signalAfterRun}`); } - const completedAt = dependencies.now(); - const status = failure === null ? "passed" : "failed"; const logPath = join(artifactRun.runDirectory, "bombadil.log"); const serverLogPath = join(artifactRun.runDirectory, "server.log"); const explorationSummaryPath = join( artifactRun.runDirectory, "exploration-summary.json", ); - const record = { - schema: ARTIFACT_SCHEMA, - evidenceClass: "diagnostic-fuzz", - artifactName: validated.artifactName, - label: validated.label, - status, - generatedAt: generatedAt.toISOString(), - completedAt: completedAt.toISOString(), - durationMs: Math.max(0, completedAt.getTime() - generatedAt.getTime()), - scenario: validated.scenario, - expectedRoute: validated.expectedRoute, - baseUrl: validated.baseUrl, - entryPath: validated.entryPath, - targetQuery: validated.targetQuery, - targetUrl: invocation.targetUrl, - viewport: validated.viewport, - explorationPolicy: validated.explorationPolicy, - specificationPath: validated.specificationPath, - replayPath, - timeLimitSeconds: replayPath === null ? parsed.timeLimitSeconds : null, - serverSource: lease?.source ?? null, - bombadil: { - version: bombadilVersion, - executable: validated.bombadilExecutable, - exitCode: processResult?.exitCode ?? null, - termination: processResult?.termination ?? null, - outputPath, - rawTracePath, - tracePath: attestation === null ? null : tracePath, - logPath, - }, - server: { - logPath: serverLogPath, - logPresent: serverOutput.length > 0, - outputFailure: serverOutputFailure === null ? null : renderUnknown(serverOutputFailure), - }, - attestation, - attestationFailure: attestationFailure === null ? null : renderUnknown(attestationFailure), - explorationSummary, - explorationSummaryPath: explorationSummary === null ? null : explorationSummaryPath, - explorationSummaryFailure: explorationSummaryFailure === null - ? null - : renderUnknown(explorationSummaryFailure), - initialDirect: attestation?.initial ?? null, - interruptedSignal: capturedSignal, - failure: failure === null ? null : renderUnknown(failure), - } as const; const log = [processResult?.stdout ?? "", processResult?.stderr ?? ""] .filter((part) => part.length > 0) .join("\n"); - try { - await writeFile(logPath, `${log}${log.length > 0 ? "\n" : ""}`, "utf8"); - await writeFile( - serverLogPath, - `${serverOutput}${serverOutput.length > 0 ? "\n" : ""}`, - "utf8", - ); - if (explorationSummary !== null) { - await writeJsonAtomically(explorationSummaryPath, explorationSummary); + try { + await writeExclusiveBytes( + logPath, + Buffer.from(`${log}${log.length > 0 ? "\n" : ""}`, "utf8"), + ); + await writeExclusiveBytes( + serverLogPath, + Buffer.from(`${serverOutput}${serverOutput.length > 0 ? "\n" : ""}`, "utf8"), + ); + if (explorationSummary !== null) { + await writeJsonAtomically(explorationSummaryPath, explorationSummary); + } + } catch (error) { + const persistence = new BombadilPersistenceError( + "Bombadil local diagnostic logs could not be persisted", + [error], + ); + failure = failure === null + ? persistence + : combinePersistenceFailure(failure, persistence); + } + + let completedAt = dependencies.now(); + const createRecord = (): unknown => ({ + schema: ARTIFACT_SCHEMA, + evidenceClass: "diagnostic-fuzz", + artifactName: validated.artifactName, + label: validated.label, + status: failure === null ? "passed" : "failed", + generatedAt: generatedAt.toISOString(), + completedAt: completedAt.toISOString(), + durationMs: Math.max(0, completedAt.getTime() - generatedAt.getTime()), + scenario: validated.scenario, + expectedRoute: validated.expectedRoute, + baseUrl: validated.baseUrl, + entryPath: validated.entryPath, + targetQuery: validated.targetQuery, + targetUrl: invocation.targetUrl, + viewport: validated.viewport, + artifactPolicy: validated.artifactPolicy, + artifactInventory: { + entryCount: artifactInventory.entryCount, + fileCount: artifactInventory.fileCount, + inventorySha256: artifactInventory.inventorySha256, + totalBytes: artifactInventory.totalBytes, + files: artifactInventory.files.map((file) => ({ + path: file.relativePath, + sha256: file.sha256, + size: file.size, + })), + }, + explorationPolicy: validated.explorationPolicy, + specificationPath: validated.specificationPath, + replayPath, + timeLimitSeconds: replayPath === null ? parsed.timeLimitSeconds : null, + serverSource: lease?.source ?? null, + bombadil: { + version: bombadilVersion, + executable: validated.bombadilExecutable, + exitCode: processResult?.exitCode ?? null, + termination: processResult?.termination ?? null, + outputPath, + rawTracePath, + tracePath: attestation === null ? null : tracePath, + logPath, + }, + server: { + logPath: serverLogPath, + logPresent: serverOutput.length > 0, + outputFailure: serverOutputFailure === null ? null : renderUnknown(serverOutputFailure), + }, + attestation, + attestationFailure: attestationFailure === null ? null : renderUnknown(attestationFailure), + explorationSummary, + explorationSummaryPath: explorationSummary === null ? null : explorationSummaryPath, + explorationSummaryFailure: explorationSummaryFailure === null + ? null + : renderUnknown(explorationSummaryFailure), + initialDirect: attestation?.initial ?? null, + interruptedSignal: interruptedSignal as ProcessInterruptSignal | null, + failure: failure === null ? null : renderUnknown(failure), + }); + const runRecordPath = join(artifactRun.runDirectory, "run.json"); + try { + await writeJsonAtomically(runRecordPath, createRecord()); + } catch (error) { + const persistence = new BombadilPersistenceError( + "Bombadil local run record could not be persisted", + [error], + ); + failure = failure === null + ? persistence + : combinePersistenceFailure(failure, persistence); + } + + const failureBeforeUpload = failure; + const signalBeforeUpload = interruptedSignal as ProcessInterruptSignal | null; + if (signalBeforeUpload !== null && failure === null) { + failure = new Error(`Bombadil fuzzing was interrupted by ${signalBeforeUpload}`); + } + let published: Awaited>; + try { + published = await publishRunUpload({ + abortSignal: abortController.signal, + artifactName: validated.artifactName, + beforeCommitCheck: dependencies.beforeArtifactCommit, + attestation, + completedAt, + explorationSummary, + failure, + inventory: artifactInventory, + interruptedSignal: () => interruptedSignal, + localOutputPath: outputPath, + policy: validated.artifactPolicy, + privateDiagnosticsAllowed: writersSettled && artifactInventoryVetted, + processLog: `${log}${log.length > 0 ? "\n" : ""}`, + scenario: validated.scenario, + serverLog: `${serverOutput}${serverOutput.length > 0 ? "\n" : ""}`, + session: uploadSession, + status: failure === null ? "passed" : "failed", + }); + } catch (persistence) { + if (failure === null) throw persistence; + throw combinePersistenceFailure( + failure, + persistence, + "sanitized Bombadil receipt publication also failed", + ); } - await writeJsonAtomically(join(artifactRun.runDirectory, "run.json"), record); - await writeJsonAtomically(artifactRun.manifestPath, record); + failure = published.failure; + completedAt = dependencies.now(); + if (failure !== failureBeforeUpload) { + await writeJsonAtomically(runRecordPath, createRecord()).catch(() => undefined); + } + // The rolling pointer is only a local convenience. The exclusive UUID leaf + // and its receipt are the authoritative upload identity. + await writeJsonAtomically(artifactRun.manifestPath, createRecord()).catch(() => undefined); + const status = failure === null ? "passed" : "failed"; const exploration = explorationSummary === null ? "exploration=unavailable" : [ @@ -2793,11 +5674,26 @@ export async function runDirectBombadilFuzz( kind: "run", artifactDirectory: artifactRun.runDirectory, manifestPath: artifactRun.manifestPath, + receiptPath: uploadSession.receiptPath, status: "passed", + uploadArtifactPath: uploadSession.finalDirectory, }; } finally { - if (capturedSignal !== null) { - process.kill(process.pid, capturedSignal); + preparedUpload?.abortSignal?.removeEventListener("abort", abortFromPreparedMatrix); + for (const signal of PROCESS_INTERRUPT_SIGNALS) { + processSignals.removeListener(signal, interrupt); + } + const signalToForward = interruptedSignal as ProcessInterruptSignal | null; + if (signalToForward !== null && preparedUpload?.forwardSignal !== false) { + processSignals.forward(signalToForward); } } } + +export async function runDirectBombadilFuzz( + config: DirectBombadilFuzzConfig, + input: DirectBombadilFuzzRunInput = process.argv.slice(2), + dependencyOverrides: Partial = {}, +): Promise { + return await runDirectBombadilFuzzInternal(config, input, dependencyOverrides); +} diff --git a/src/tooling/bombadil.ts b/src/tooling/bombadil.ts index ec72ce2..5fc4a69 100644 --- a/src/tooling/bombadil.ts +++ b/src/tooling/bombadil.ts @@ -1,14 +1,21 @@ import { attestDirectBombadilTrace as attestTrace, + parseDirectBombadilArtifactReceipt as parseArtifactReceipt, + parseDirectBombadilMatrixReceipt as parseMatrixReceipt, + parseDirectBombadilMatrixSummary as parseMatrixSummary, + parseDirectBombadilSanitizedRunSummary as parseRunSummary, + resolveDirectBombadilUploadLeaf as resolveUploadLeaf, runDirectBombadilFuzz as runFuzz, runDirectBombadilFuzzMatrix as runMatrix, summarizeDirectBombadilTrace as summarizeTrace, } from "./bombadil-runner.js"; import type { - DirectBombadilFuzzConfig, DirectBombadilFuzzCampaign, + DirectBombadilFuzzConfig, DirectBombadilFuzzMatrixResult, DirectBombadilFuzzResult, + DirectBombadilFuzzRunInput, + DirectBombadilMatrixRunInput, } from "./bombadil-runner.js"; /** Host-side exact attestation for one bounded Bombadil 0.7.2 JSONL trace. */ @@ -17,32 +24,66 @@ export const attestDirectBombadilTrace: typeof attestTrace = attestTrace; /** Derives bounded diagnostic navigation metadata without replacing the raw trace. */ export const summarizeDirectBombadilTrace: typeof summarizeTrace = summarizeTrace; +/** Parse, clone, and freeze a foreign sanitized Bombadil run receipt. */ +export const parseDirectBombadilArtifactReceipt: typeof parseArtifactReceipt = parseArtifactReceipt; + +/** Parse, clone, and freeze a foreign sanitized Bombadil run summary. */ +export const parseDirectBombadilSanitizedRunSummary: typeof parseRunSummary = parseRunSummary; + +/** Parse, clone, and freeze a foreign sanitized Bombadil matrix receipt. */ +export const parseDirectBombadilMatrixReceipt: typeof parseMatrixReceipt = parseMatrixReceipt; + +/** Parse, clone, and freeze a foreign sanitized Bombadil matrix summary. */ +export const parseDirectBombadilMatrixSummary: typeof parseMatrixSummary = parseMatrixSummary; + +/** Resolve the exact precomputed upload leaf used by failure-safe CI upload steps. */ +export const resolveDirectBombadilUploadLeaf: typeof resolveUploadLeaf = resolveUploadLeaf; + /** Runs one bounded local Bombadil campaign and preserves diagnostic artifacts. */ export function runDirectBombadilFuzz( config: DirectBombadilFuzzConfig, - arguments_?: readonly string[], + argumentsOrOptions?: DirectBombadilFuzzRunInput, ): Promise { - return arguments_ === undefined ? runFuzz(config) : runFuzz(config, arguments_); + return argumentsOrOptions === undefined + ? runFuzz(config) + : runFuzz(config, argumentsOrOptions); } /** Runs a bounded product campaign matrix serially and selects one for replay. */ export function runDirectBombadilFuzzMatrix( campaigns: readonly DirectBombadilFuzzCampaign[], - arguments_?: readonly string[], + argumentsOrOptions?: DirectBombadilMatrixRunInput, ): Promise { - return arguments_ === undefined ? runMatrix(campaigns) : runMatrix(campaigns, arguments_); + return argumentsOrOptions === undefined + ? runMatrix(campaigns) + : runMatrix(campaigns, argumentsOrOptions); } export type { DirectBombadilActionKind, + DirectBombadilArtifactFailureCode, + DirectBombadilArtifactParseError, + DirectBombadilArtifactPolicy, + DirectBombadilArtifactReceipt, + DirectBombadilArtifactRunPlan, DirectBombadilExplorationPolicy, DirectBombadilExplorationSummary, DirectBombadilFuzzCampaign, DirectBombadilFuzzConfig, DirectBombadilFuzzMatrixResult, DirectBombadilFuzzResult, + DirectBombadilFuzzRunInput, + DirectBombadilFuzzRunOptions, + DirectBombadilMatrixCampaignReceiptEntry, + DirectBombadilMatrixCampaignStatus, + DirectBombadilMatrixReceipt, + DirectBombadilMatrixRunInput, + DirectBombadilMatrixRunOptions, + DirectBombadilMatrixSummary, + DirectBombadilSanitizedRunSummary, DirectBombadilServerConfig, DirectBombadilTraceAttestation, DirectBombadilTraceBinding, + DirectBombadilUploadMode, DirectBombadilViewportConfig, } from "./bombadil-runner.js"; diff --git a/src/tooling/browser-verification.test.ts b/src/tooling/browser-verification.test.ts index 13e36e6..9356939 100644 --- a/src/tooling/browser-verification.test.ts +++ b/src/tooling/browser-verification.test.ts @@ -33,6 +33,7 @@ import { runVerificationCommand, serializeAgentBrowserLaunchArguments, serverIsReachable, + spawnVerificationServer, stopVerificationServer, tail, writeJsonAtomically, @@ -154,6 +155,57 @@ async function rejection(promise: Promise): Promise { throw new Error("Expected the operation to reject."); } +type ProcessKill = ( + processId: number, + signal?: NodeJS.Signals | number, +) => boolean; + +async function withProcessKillAdapter( + createAdapter: (kill: ProcessKill) => ProcessKill, + operation: () => Promise, +): Promise { + const descriptor = Object.getOwnPropertyDescriptor(process, "kill"); + if (descriptor === undefined) throw new Error("process.kill descriptor is unavailable"); + const originalKill = process.kill.bind(process); + const kill: ProcessKill = (processId, signal) => originalKill(processId, signal); + Object.defineProperty(process, "kill", { + ...descriptor, + value: createAdapter(kill), + }); + try { + return await operation(); + } finally { + Object.defineProperty(process, "kill", descriptor); + } +} + +async function waitForMissingProcess(processId: number): Promise { + const deadline = Date.now() + 1_000; + for (;;) { + try { + process.kill(processId, 0); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ESRCH") return; + if (code !== "EPERM") throw error; + } + if (Date.now() >= deadline) { + throw new Error(`Process ${String(processId)} survived its test cleanup`); + } + await Bun.sleep(10); + } +} + +async function forceCleanupProcess(processId: number): Promise { + try { + process.kill(processId, "SIGKILL"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return; + throw error; + } + await waitForMissingProcess(processId); +} + describe("browser verification targets", () => { test("normalizes only credential-free HTTP server roots", () => { expect(normalizeRootHttpOrigin("https://example.test/")).toBe("https://example.test"); @@ -583,6 +635,149 @@ describe("Direct browser contract binding", () => { }); describe("server leases", () => { + test("omits coordination secrets from managed server environments", async () => { + const directory = await mkdtemp(join(tmpdir(), "direct-server-environment-")); + temporaryDirectories.push(directory); + const server = spawnVerificationServer({ + command: [ + process.execPath, + "-e", + "console.log(process.env.DIRECT_BOMBADIL_RUN_ID ?? 'absent')", + ], + cwd: directory, + env: { DIRECT_BOMBADIL_RUN_ID: "child-visible-secret" }, + omitEnvironment: ["DIRECT_BOMBADIL_RUN_ID"], + }); + await server.exited; + expect(await server.output).toBe("absent"); + }); + + test("stops descendants only for an explicitly detached owned server group", async () => { + const directory = await mkdtemp(join(tmpdir(), "direct-server-process-group-")); + temporaryDirectories.push(directory); + const childPidPath = join(directory, "child.pid"); + const source = [ + "const { spawn } = require('node:child_process');", + "const fs = require('node:fs');", + "const child = spawn(process.execPath, ['-e', `process.on('SIGTERM', () => {}); setTimeout(() => process.exit(0), 5000); setInterval(() => {}, 1000);`], { stdio: 'ignore' });", + "child.unref();", + `fs.writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid));`, + ].join(" "); + const server = spawnVerificationServer({ + command: [process.execPath, "-e", source], + cwd: directory, + detachedProcessGroup: true, + }); + let childPid: number | null = null; + let childMissing = false; + let stopped = false; + let processGroupId: number | null = null; + let processGroupKills = 0; + let processGroupProbes = 0; + try { + for (let attempt = 0; attempt < 100 && !(await Bun.file(childPidPath).exists()); attempt += 1) { + await Bun.sleep(10); + } + expect(await Bun.file(childPidPath).exists()).toBeTrue(); + childPid = Number.parseInt(await Bun.file(childPidPath).text(), 10); + await server.exited; + await withProcessKillAdapter( + (kill) => (processId, signal) => { + if (processId < 0 && signal === "SIGKILL") { + processGroupId = -processId; + processGroupKills += 1; + } + if ( + processGroupId !== null + && processId === -processGroupId + && signal === 0 + ) { + processGroupProbes += 1; + if (processGroupProbes === 1) { + throw Object.assign(new Error("synthetic transient process-group probe"), { + code: "EPERM", + }); + } + } + return kill(processId, signal); + }, + async () => await stopVerificationServer(server, 500), + ); + stopped = true; + expect(processGroupKills).toBe(1); + expect(processGroupProbes).toBeGreaterThanOrEqual(2); + expect(Number.isSafeInteger(childPid)).toBeTrue(); + await waitForMissingProcess(childPid); + childMissing = true; + } finally { + if (!stopped) await stopVerificationServer(server, 500); + if (childPid !== null && !childMissing) await forceCleanupProcess(childPid); + } + }); + + test("fails closed after persistent EPERM while stopping detached descendants", async () => { + const directory = await mkdtemp(join(tmpdir(), "direct-server-process-group-eperm-")); + temporaryDirectories.push(directory); + const childPidPath = join(directory, "child.pid"); + const source = [ + "const { spawn } = require('node:child_process');", + "const fs = require('node:fs');", + "const child = spawn(process.execPath, ['-e', `process.on('SIGTERM', () => {}); setTimeout(() => process.exit(0), 5000); setInterval(() => {}, 1000);`], { stdio: 'ignore' });", + "child.unref();", + `fs.writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid));`, + ].join(" "); + const server = spawnVerificationServer({ + command: [process.execPath, "-e", source], + cwd: directory, + detachedProcessGroup: true, + }); + let childPid: number | null = null; + let childMissing = false; + try { + for (let attempt = 0; attempt < 100 && !(await Bun.file(childPidPath).exists()); attempt += 1) { + await Bun.sleep(10); + } + expect(await Bun.file(childPidPath).exists()).toBeTrue(); + childPid = Number.parseInt(await Bun.file(childPidPath).text(), 10); + await server.exited; + let processGroupId: number | null = null; + let processGroupKills = 0; + let processGroupProbes = 0; + const failure = await withProcessKillAdapter( + (kill) => (processId, signal) => { + if (processId < 0 && signal === "SIGKILL") { + processGroupId = -processId; + processGroupKills += 1; + } + if ( + processGroupId !== null + && processId === -processGroupId + && signal === 0 + ) { + processGroupProbes += 1; + throw Object.assign(new Error("synthetic persistent process-group probe"), { + code: "EPERM", + }); + } + return kill(processId, signal); + }, + async () => await rejection(stopVerificationServer(server, 50)), + ); + expect(failure.message).toContain("survived cleanup"); + expect(processGroupKills).toBe(1); + expect(processGroupProbes).toBeGreaterThanOrEqual(2); + expect(Number.isSafeInteger(childPid)).toBeTrue(); + await waitForMissingProcess(childPid); + childMissing = true; + } finally { + if (childPid === null) { + await stopVerificationServer(server, 500); + } else if (!childMissing) { + await forceCleanupProcess(childPid); + } + } + }); + test("bounds one-shot verification commands and reports their exact outcome", async () => { expect(await runVerificationCommand({ command: [process.execPath, "-e", "console.log('built')"], diff --git a/src/tooling/browser-verification.ts b/src/tooling/browser-verification.ts index 46ca7f0..85bce85 100644 --- a/src/tooling/browser-verification.ts +++ b/src/tooling/browser-verification.ts @@ -243,6 +243,7 @@ export function createDirectBrowserContractReader< export interface ManagedVerificationServer { readonly exited: Promise; readonly exitCode: () => number | null; + readonly killDescendants?: (timeoutMs: number) => void | Promise; readonly output: Promise; readonly terminate: () => void; readonly kill: () => void; @@ -680,15 +681,50 @@ async function collectStream(stream: ReadableStream, logLimit: numbe } } +function verificationProcessGroupExists(processId: number): boolean { + try { + process.kill(-processId, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + // EPERM leaves group absence unproven, so poll until ESRCH or timeout. + if ((error as NodeJS.ErrnoException).code === "EPERM") return true; + throw error; + } +} + +async function waitForVerificationProcessGroupExit( + processId: number, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (verificationProcessGroupExists(processId)) { + if (Date.now() >= deadline) { + throw new Error(`verification server process group ${String(processId)} survived cleanup`); + } + await Bun.sleep(Math.min(25, Math.max(1, deadline - Date.now()))); + } +} + export function spawnVerificationServer(options: { readonly command: readonly string[]; readonly cwd: string; + readonly detachedProcessGroup?: boolean; readonly env?: Readonly>; readonly logLimit?: number; + readonly omitEnvironment?: readonly string[]; }): ManagedVerificationServer { + const detachedProcessGroup = options.detachedProcessGroup ?? false; + const omittedEnvironment = new Set(options.omitEnvironment ?? []); + const environment: Record = Object.fromEntries( + Object.entries({ ...process.env, ...options.env }).filter( + ([name]) => !omittedEnvironment.has(name), + ), + ); const process_ = Bun.spawn([...options.command], { cwd: options.cwd, - env: { ...process.env, ...options.env }, + detached: detachedProcessGroup, + env: environment, stdin: "ignore", stdout: "pipe", stderr: "pipe", @@ -699,12 +735,33 @@ export function spawnVerificationServer(options: { collectStream(process_.stderr, logLimit), ]).then(([stdout, stderr]) => tail(`${stdout}\n${stderr}`.trim(), logLimit)); + const signal = (value: "SIGKILL" | "SIGTERM"): void => { + if (detachedProcessGroup) { + try { + process.kill(-process_.pid, value); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + // The group may already be gone; fall back to the leader only while it lives. + } + } + if (process_.exitCode === null) process_.kill(value); + }; + return { exited: process_.exited, exitCode: () => process_.exitCode, + ...(detachedProcessGroup + ? { + killDescendants: async (timeoutMs: number): Promise => { + signal("SIGKILL"); + await waitForVerificationProcessGroupExit(process_.pid, timeoutMs); + }, + } + : {}), output, - terminate: () => process_.kill("SIGTERM"), - kill: () => process_.kill("SIGKILL"), + terminate: () => signal("SIGTERM"), + kill: () => signal("SIGKILL"), }; } @@ -797,6 +854,9 @@ async function stopVerificationServerWithOutput( ); } } + // A detached leader can exit before descendants close inherited output pipes. + // Reap only a process group that the verifier explicitly owns. + await server.killDescendants?.(stopTimeoutMs); const output = await settleWithin(server.output, stopTimeoutMs); if (!output.settled) { throw new Error( @@ -960,7 +1020,7 @@ export async function writeJsonAtomically(path: string, value: unknown): Promise await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); await rename(temporaryPath, path); } catch (error) { - await rm(temporaryPath, { force: true }); + await rm(temporaryPath, { force: true }).catch(() => undefined); throw error; } }