From 51b1c7d72e3c166f88651df8388e98788696c63e Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:00:21 -0700 Subject: [PATCH] feat(attest): reproducible, minimal replay-runner Docker image (#9214) --- .github/workflows/replay-runner-image.yml | 112 ++++ package.json | 5 +- packages/loopover-engine/package.json | 12 + scripts/attested-backtest-run-core.ts | 14 +- scripts/attested-backtest-run.ts | 10 +- scripts/backtest-corpus-export-core.ts | 2 +- scripts/replay-runner-image-manifest-core.ts | 116 ++++ scripts/replay-runner-image-manifest.json | 13 + scripts/replay-runner-image-manifest.ts | 81 +++ scripts/replay-runner/Dockerfile | 84 +++ scripts/replay-runner/README.md | 88 +++ .../tsx-runtime/package-lock.json | 502 ++++++++++++++++++ .../replay-runner/tsx-runtime/package.json | 8 + scripts/snp-attester.ts | 2 +- .../replay-runner-image-manifest-core.test.ts | 103 ++++ 15 files changed, 1145 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/replay-runner-image.yml create mode 100644 scripts/replay-runner-image-manifest-core.ts create mode 100644 scripts/replay-runner-image-manifest.json create mode 100644 scripts/replay-runner-image-manifest.ts create mode 100644 scripts/replay-runner/Dockerfile create mode 100644 scripts/replay-runner/README.md create mode 100644 scripts/replay-runner/tsx-runtime/package-lock.json create mode 100644 scripts/replay-runner/tsx-runtime/package.json create mode 100644 test/unit/replay-runner-image-manifest-core.test.ts diff --git a/.github/workflows/replay-runner-image.yml b/.github/workflows/replay-runner-image.yml new file mode 100644 index 0000000000..2568b10988 --- /dev/null +++ b/.github/workflows/replay-runner-image.yml @@ -0,0 +1,112 @@ +# Reproducible replay-runner image CI (#9214, epic #8534). Proves the pinned, minimal attested-backtest-run +# image actually BUILDS and RUNS correctly. The fast, cheap drift check (comparing the committed manifest's +# declared-inputs digest against a fresh one) is already wired into `npm run test:ci` (per-PR, blocking) -- +# this workflow adds the one thing that check can't do without Docker: prove the pinned Dockerfile still +# builds and the resulting image actually produces an attested run. +# +# Push-to-main only, same reasoning as selfhost.yml's own build-boot job: a full Docker build is a slow, +# expensive check relative to this repo's PR volume, ci.yml's fast manifest-drift check already blocks the +# common failure mode (a source/lockfile/Dockerfile edit without updating the committed manifest) on every +# PR, and a build-breaking change is still caught within minutes of landing on main. +name: replay-runner-image + +on: + push: + branches: [main] + paths: + - "scripts/replay-runner/**" + - "scripts/attested-backtest-run.ts" + - "scripts/attested-backtest-run-core.ts" + - "scripts/backtest-corpus-export-core.ts" + - "scripts/snp-attester.ts" + - "scripts/replay-runner-image-manifest.ts" + - "scripts/replay-runner-image-manifest-core.ts" + - "scripts/replay-runner-image-manifest.json" + - "packages/loopover-engine/src/calibration/**" + - "packages/loopover-engine/package.json" + - ".github/workflows/replay-runner-image.yml" + +permissions: + contents: read + +concurrency: + group: replay-runner-image-${{ github.sha }} + cancel-in-progress: true + +jobs: + build-and-run: + name: build + attested-run smoke test + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version-file: .nvmrc + cache: "npm" + + - name: Install deps + run: npm ci --ignore-scripts + + - name: Verify the committed manifest matches the checked-out tree + run: npm run replay-runner-manifest:check + + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + + - name: Build the replay-runner image + run: | + docker buildx build \ + --cache-from type=gha \ + --cache-to type=gha,mode=max \ + --load \ + -f scripts/replay-runner/Dockerfile \ + -t loopover-replay-runner:ci . + + - name: Confirm the runtime user is non-root + run: | + user="$(docker run --rm --entrypoint whoami loopover-replay-runner:ci)" + test "$user" = "replay-runner" + + - name: Build a throwaway corpus fixture + run: | + node --experimental-strip-types -e ' + import { buildBacktestCorpusManifest } from "./scripts/backtest-corpus-export-core.ts"; + import { writeFileSync } from "node:fs"; + const cases = [{ targetKey: "acme/widgets#1", ruleId: "linked_issue_scope_mismatch", fired: true, overridden: false }]; + writeFileSync("/tmp/ci-corpus.json", JSON.stringify(buildBacktestCorpusManifest("linked_issue_scope_mismatch", cases))); + ' + + - name: Run an attested pass with the sample attester, with NO network access + run: | + docker run --rm --network none \ + -v /tmp/ci-corpus.json:/data/corpus.json:ro \ + loopover-replay-runner:ci \ + --rule-id linked_issue_scope_mismatch --corpus /data/corpus.json \ + --head-sha 0000000000000000000000000000000000000001 \ + --base-sha 0000000000000000000000000000000000000002 \ + --repo acme/widgets --pr 1 \ + | tee /tmp/ci-result.json + outcome_status="$(node -e 'console.log(JSON.parse(require("node:fs").readFileSync("/tmp/ci-result.json","utf8")).status)')" + test "$outcome_status" = "attested" + + - name: Confirm the fail-closed path (TEE claimed, no reachable hardware) + run: | + set +e + docker run --rm --network none \ + -v /tmp/ci-corpus.json:/data/corpus.json:ro \ + loopover-replay-runner:ci \ + --rule-id linked_issue_scope_mismatch --corpus /data/corpus.json \ + --head-sha 0000000000000000000000000000000000000001 \ + --base-sha 0000000000000000000000000000000000000002 \ + --repo acme/widgets --pr 1 \ + --attester snp --measurement "$(printf 'a%.0s' {1..64})" --runtime-claim tee + code=$? + set -e + test "$code" -eq 1 + + - name: Free disk + if: always() + run: docker image prune -af diff --git a/package.json b/package.json index 04f6d24960..cc0534f525 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,9 @@ "docs:drift-check": "node --experimental-strip-types scripts/check-docs-drift.ts", "coverage-boltons:check": "node --experimental-strip-types scripts/check-coverage-bolt-on-filenames.ts", "import-specifiers:check": "node --experimental-strip-types scripts/check-import-specifiers.ts", + "replay-runner-manifest": "tsx scripts/replay-runner-image-manifest.ts", + "replay-runner-manifest:write": "tsx scripts/replay-runner-image-manifest.ts --write", + "replay-runner-manifest:check": "tsx scripts/replay-runner-image-manifest.ts --check", "roadmap:drift-check": "node --experimental-strip-types scripts/check-roadmap-issue-drift.ts", "branding-drift:check": "node --experimental-strip-types scripts/check-branding-drift.ts", "branding-drift:update": "node --experimental-strip-types scripts/check-branding-drift.ts --update", @@ -109,7 +112,7 @@ "test:smoke:browser:install": "playwright install chromium", "test:smoke:browser": "node --experimental-strip-types scripts/smoke-ui-browser.ts", "pretest:ci": "npm run check-node-version", - "test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run ui:openapi:settings-parity && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run command-reference:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build", + "test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run ui:openapi:settings-parity && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run replay-runner-manifest:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run command-reference:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build", "test:release": "npm run test:ci && npm run changelog:check", "test:release:mcp": "npm run test:ci", "test:watch": "vitest", diff --git a/packages/loopover-engine/package.json b/packages/loopover-engine/package.json index d5a1b44cf3..d93a83bfb1 100644 --- a/packages/loopover-engine/package.json +++ b/packages/loopover-engine/package.json @@ -69,6 +69,18 @@ "./signals/slop": { "types": "./dist/signals/slop.d.ts", "default": "./dist/signals/slop.js" + }, + "./calibration/attester": { + "types": "./dist/calibration/attester.d.ts", + "default": "./dist/calibration/attester.js" + }, + "./calibration/attestation-envelope": { + "types": "./dist/calibration/attestation-envelope.d.ts", + "default": "./dist/calibration/attestation-envelope.js" + }, + "./calibration/backtest-corpus": { + "types": "./dist/calibration/backtest-corpus.d.ts", + "default": "./dist/calibration/backtest-corpus.js" } }, "files": [ diff --git a/scripts/attested-backtest-run-core.ts b/scripts/attested-backtest-run-core.ts index 316555385e..5659986346 100644 --- a/scripts/attested-backtest-run-core.ts +++ b/scripts/attested-backtest-run-core.ts @@ -6,12 +6,20 @@ // The fail-closed rule lives here rather than in the CLI because it is the security-relevant decision in this // feature: a runtime that CLAIMS a TEE must never be allowed to record a run as if attestation succeeded when // it did not (#9211). Keeping it pure is what lets it be exhaustively tested without hardware. -import type { AttestationEnvelope } from "@loopover/engine"; - -import { sqlStringLiteral } from "./backtest-logic-check-core"; +import type { AttestationEnvelope } from "@loopover/engine/calibration/attestation-envelope"; export const ATTESTED_BACKTEST_EVENT_TYPE = "calibration.attested_backtest_run"; +/** Single-quoted SQL string literal — mirrors backtest-corpus-export.ts's and backtest-logic-check-core.ts's + * own copies exactly. Duplicated here rather than imported from backtest-logic-check-core.ts on purpose: + * that file's own top-level `@loopover/engine` barrel import (unrelated exports it needs for its own + * purpose) would otherwise be dragged into this module's evaluation graph too -- and, via the runner's + * reproducible replay-runner image (#9214), into the measured container's trusted computing base, which is + * supposed to hold nothing beyond what replay itself needs. */ +function sqlStringLiteral(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + /** What the runtime claims about itself, independent of what the attester actually returned. `none` is an * ordinary un-attested dev run; `tee` asserts the workload believes it is inside a TEE runtime class. */ export type RuntimeAttestationClaim = "none" | "tee"; diff --git a/scripts/attested-backtest-run.ts b/scripts/attested-backtest-run.ts index 8e985a35c6..0c020a56d2 100644 --- a/scripts/attested-backtest-run.ts +++ b/scripts/attested-backtest-run.ts @@ -20,7 +20,15 @@ import { spawnSync } from "node:child_process"; import { randomBytes, randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; -import { assembleAttestationEnvelope, createSampleAttester, type Attester, type BacktestCase } from "@loopover/engine"; +// Subpath imports, not the engine barrel (#9214): the barrel's `export *` graph is reachable from +// miner/repo-map.ts, which has a top-level `import Parser from "web-tree-sitter"` -- a static import that +// forces web-tree-sitter (and, via its own on-disk resolution at call time, tree-sitter-wasms) onto any +// image that loads the barrel, even though this script never calls into repo-map's functions. The reproducible +// replay-runner image (scripts/replay-runner/Dockerfile) is measured inside an eventual TEE; minimizing what +// it depends on is a real security property (a smaller trusted computing base), not just a size optimization. +// Same rationale + mechanism as src/db/repositories.ts's `@loopover/engine/parse-pull-request-target-key`. +import { assembleAttestationEnvelope, createSampleAttester, type Attester } from "@loopover/engine/calibration/attester"; +import type { BacktestCase } from "@loopover/engine/calibration/backtest-corpus"; import { checksumCases } from "./backtest-corpus-export-core"; import { diff --git a/scripts/backtest-corpus-export-core.ts b/scripts/backtest-corpus-export-core.ts index a68e97d6f9..328e4e1f3d 100644 --- a/scripts/backtest-corpus-export-core.ts +++ b/scripts/backtest-corpus-export-core.ts @@ -3,7 +3,7 @@ // without re-querying D1. No IO here — the CLI (backtest-corpus-export.ts) does the wrangler/D1 reads and the // file write — so this stays unit-testable. Mirrors scripts/export-d1-core.ts's pure-core / thin-IO split. import { createHash } from "node:crypto"; -import type { BacktestCase } from "@loopover/engine"; +import type { BacktestCase } from "@loopover/engine/calibration/backtest-corpus"; export type BacktestCorpusManifest = { ruleId: string; diff --git a/scripts/replay-runner-image-manifest-core.ts b/scripts/replay-runner-image-manifest-core.ts new file mode 100644 index 0000000000..6ea9d11f1d --- /dev/null +++ b/scripts/replay-runner-image-manifest-core.ts @@ -0,0 +1,116 @@ +// Pure core for the replay-runner image's reproducibility manifest (#9214, epic #8534). Attestation over an +// unpinned, irreproducible workload is theater -- the launch measurement inside an envelope must correspond +// to an image anyone can independently rebuild and check. This module defines that check as a DECLARED-INPUTS +// digest (Dockerfile + lockfile + pinned base image + the exact source tree the image copies in), not as a +// digest of the BUILT Docker image itself. +// +// That choice is deliberate and documented (see replay-runner/README.md's "What is and isn't reproducible" +// section): `npm ci` against a committed lockfile and `tsc` compilation are both fully deterministic given +// identical inputs, so a declared-inputs digest is a sound proxy for "this exact image, rebuilt, is this exact +// image" -- but the DOCKER IMAGE ID itself (`docker inspect --format '{{.Id}}'`) is not claimed reproducible +// here: layer metadata (file mtimes, ownership bits as recorded by different Docker/BuildKit versions) is a +// known source of non-determinism this manifest does not attempt to solve. Anyone can independently verify +// the declared inputs never drifted; verifying the exact built image ID matches too is a stronger, separate +// claim this repo does not make. +import { createHash } from "node:crypto"; + +export type ReplayRunnerImageManifest = { + /** Bump when the digest's INPUT SET changes (a file added/removed from `sourceFiles`, or the hash algorithm + * changes) -- so an old manifest is never silently compared against a new definition of "the inputs". */ + schemaVersion: 1; + /** The pinned base image reference, digest-qualified (`node:22-slim@sha256:...`) -- copied verbatim from the + * Dockerfile's `FROM` line, never re-derived, so a base-image bump is visible as a manifest diff. */ + baseImageRef: string; + dockerfileSha256: string; + packageLockSha256: string; + /** Path -> sha256, sorted by path, of every source file the image's build stage copies in. A file added to + * the image without a matching entry here is exactly the drift this manifest exists to catch. */ + sourceFiles: Record; + /** Single sha256 over the canonicalized whole (see {@link buildReplayRunnerImageManifest}) -- the one value + * a third party compares to confirm nothing above drifted, without diffing every field by hand. */ + digest: string; +}; + +function sha256(content: string): string { + return createHash("sha256").update(content).digest("hex"); +} + +/** Canonicalize the manifest's own comparable fields into deterministic JSON before hashing. `sourceFiles` + * is trusted to arrive PRE-SORTED -- {@link buildReplayRunnerImageManifest} (this function's only caller) + * already sorts it before calling in, so re-sorting here would be redundant, untestable defensive code (its + * "out of order" branch could never fire against the one real call site). The property-order stability this + * function provides is JSON.stringify's own guarantee for string-keyed objects, not a re-sort. */ +function canonicalize(input: { + baseImageRef: string; + dockerfileSha256: string; + packageLockSha256: string; + sourceFiles: Record; +}): string { + return JSON.stringify({ + baseImageRef: input.baseImageRef, + dockerfileSha256: input.dockerfileSha256, + packageLockSha256: input.packageLockSha256, + sourceFiles: input.sourceFiles, + }); +} + +/** + * Build the manifest from raw inputs: the Dockerfile's and lockfile's own text, the pinned base image + * reference, and a map of every source-file path the image copies in to that file's own text. Pure -- no + * filesystem reads here, so the CLI (which does the reads) stays the only IO-touching layer, and this stays + * unit-testable without a real checkout. + */ +export function buildReplayRunnerImageManifest(input: { + baseImageRef: string; + dockerfileContent: string; + packageLockContent: string; + sourceFileContents: Record; +}): ReplayRunnerImageManifest { + // Two-way, not the three-way `a < b ? -1 : a > b ? 1 : 0` idiom sibling modules use (export-d1-core.ts's + // canonicalizeRow, backtest-corpus-export-core.ts's checksumCases): Object.entries keys are always + // distinct, so the "equal" arm those siblings carry can never fire here and would be untestable dead code. + const sourceFiles = Object.fromEntries( + Object.entries(input.sourceFileContents) + .map(([path, content]) => [path, sha256(content)] as const) + .sort(([a], [b]) => (a < b ? -1 : 1)), + ); + const dockerfileSha256 = sha256(input.dockerfileContent); + const packageLockSha256 = sha256(input.packageLockContent); + const digest = sha256(canonicalize({ baseImageRef: input.baseImageRef, dockerfileSha256, packageLockSha256, sourceFiles })); + return { schemaVersion: 1, baseImageRef: input.baseImageRef, dockerfileSha256, packageLockSha256, sourceFiles, digest }; +} + +export type ManifestDriftResult = + | { drifted: false } + | { drifted: true; reasons: string[] }; + +/** + * Compare a freshly-built manifest against the committed one. Reports EVERY differing field (base image, + * Dockerfile, lockfile, each added/removed/changed source file) rather than only the terminal digest + * mismatch, so a drift report tells a reviewer exactly what moved instead of just that something did. + */ +export function checkReplayRunnerImageManifestDrift(committed: ReplayRunnerImageManifest, fresh: ReplayRunnerImageManifest): ManifestDriftResult { + const reasons: string[] = []; + if (committed.schemaVersion !== fresh.schemaVersion) { + reasons.push(`schemaVersion: committed ${committed.schemaVersion}, fresh ${fresh.schemaVersion}`); + } + if (committed.baseImageRef !== fresh.baseImageRef) { + reasons.push(`baseImageRef: committed ${committed.baseImageRef}, fresh ${fresh.baseImageRef}`); + } + if (committed.dockerfileSha256 !== fresh.dockerfileSha256) { + reasons.push(`Dockerfile changed: committed ${committed.dockerfileSha256}, fresh ${fresh.dockerfileSha256}`); + } + if (committed.packageLockSha256 !== fresh.packageLockSha256) { + reasons.push(`package-lock.json changed: committed ${committed.packageLockSha256}, fresh ${fresh.packageLockSha256}`); + } + const committedPaths = new Set(Object.keys(committed.sourceFiles)); + const freshPaths = new Set(Object.keys(fresh.sourceFiles)); + for (const path of committedPaths) { + if (!freshPaths.has(path)) reasons.push(`source file removed: ${path}`); + else if (committed.sourceFiles[path] !== fresh.sourceFiles[path]) reasons.push(`source file changed: ${path}`); + } + for (const path of freshPaths) { + if (!committedPaths.has(path)) reasons.push(`source file added: ${path}`); + } + return reasons.length === 0 ? { drifted: false } : { drifted: true, reasons }; +} diff --git a/scripts/replay-runner-image-manifest.json b/scripts/replay-runner-image-manifest.json new file mode 100644 index 0000000000..dbbebeb0bc --- /dev/null +++ b/scripts/replay-runner-image-manifest.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 1, + "baseImageRef": "node:22-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca4403205b2966bd488f6b3", + "dockerfileSha256": "7e4d3c18fbc3a4153d7ba25e3cff8329b07fceb6a52a62350ba0f3c65f54f2ff", + "packageLockSha256": "d4678b51bd2ad413ebf6f016fae0720ac32e3d6328646c749a434972f3bff1a5", + "sourceFiles": { + "scripts/attested-backtest-run-core.ts": "e5f5659dc9204f9406c9874317d71cb02fd7f4c993c9f5caa92545595f522167", + "scripts/attested-backtest-run.ts": "0db6b462afdf80086eb68da793a6dccbe3d5751eceeb19e338d2746a1c319fa5", + "scripts/backtest-corpus-export-core.ts": "6a268410ea1d8041e95af345149aeb4848376c4c72127b801ee9da377b81e85e", + "scripts/snp-attester.ts": "b917e8541908150bd0c885fbe57e178247e5cd5f78f80eda7dc9e8b77da6deb3" + }, + "digest": "f07929d7c4e23136fae633c8750d92e7a464a0da70e5ac6d1841c7134dc45741" +} diff --git a/scripts/replay-runner-image-manifest.ts b/scripts/replay-runner-image-manifest.ts new file mode 100644 index 0000000000..c739663a9d --- /dev/null +++ b/scripts/replay-runner-image-manifest.ts @@ -0,0 +1,81 @@ +#!/usr/bin/env node +// Reproducible replay-runner image manifest (#9214, epic #8534). Thin IO wrapper over +// replay-runner-image-manifest-core.ts's pure functions -- reads the Dockerfile, its lockfile, and every +// source file the image copies in, then either regenerates the committed manifest or (--check) verifies it +// hasn't drifted. Mirrors backtest-corpus-export.ts's own pure-core / thin-IO split. +// +// npx tsx scripts/replay-runner-image-manifest.ts # regenerate and print +// npx tsx scripts/replay-runner-image-manifest.ts --write # regenerate and overwrite the committed file +// npx tsx scripts/replay-runner-image-manifest.ts --check # fail (exit 1) if the committed file has drifted +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { buildReplayRunnerImageManifest, checkReplayRunnerImageManifestDrift, type ReplayRunnerImageManifest } from "./replay-runner-image-manifest-core"; + +// Invoked from the repo root, like every other scripts/** CLI (see backtest-corpus-export.ts et al.) -- +// process.cwd() is the repo root by convention, not something this script re-derives from its own location. +const REPO_ROOT = process.cwd(); +const DOCKERFILE_PATH = "scripts/replay-runner/Dockerfile"; +const MANIFEST_PATH = "scripts/replay-runner-image-manifest.json"; +const BASE_IMAGE_REF = "node:22-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca4403205b2966bd488f6b3"; + +/** Every source file the Dockerfile's runtime stage copies in, individually enumerated (not a glob) -- see + * the Dockerfile's own comment for why this must stay a manually-maintained, exact list: an entry here + * drifting from what the Dockerfile actually COPYs is exactly the class of bug this manifest exists to + * catch, not something to paper over with a wildcard. */ +const SOURCE_FILE_PATHS = [ + "scripts/attested-backtest-run.ts", + "scripts/attested-backtest-run-core.ts", + "scripts/backtest-corpus-export-core.ts", + "scripts/snp-attester.ts", +] as const; + +function readRepoFile(relativePath: string): string { + return readFileSync(join(REPO_ROOT, relativePath), "utf8"); +} + +function buildFreshManifest(): ReplayRunnerImageManifest { + const sourceFileContents = Object.fromEntries(SOURCE_FILE_PATHS.map((path) => [path, readRepoFile(path)] as const)); + return buildReplayRunnerImageManifest({ + baseImageRef: BASE_IMAGE_REF, + dockerfileContent: readRepoFile(DOCKERFILE_PATH), + packageLockContent: readRepoFile("package-lock.json"), + sourceFileContents, + }); +} + +function main(): void { + const fresh = buildFreshManifest(); + + if (process.argv.includes("--check")) { + let committedRaw: string; + try { + committedRaw = readRepoFile(MANIFEST_PATH); + } catch { + process.stderr.write(`${MANIFEST_PATH} does not exist -- run without --check to generate it.\n`); + process.exit(1); + return; + } + const committed = JSON.parse(committedRaw) as ReplayRunnerImageManifest; + const result = checkReplayRunnerImageManifestDrift(committed, fresh); + if (!result.drifted) { + process.stdout.write("replay-runner image manifest: OK (no drift)\n"); + return; + } + process.stderr.write("replay-runner image manifest has DRIFTED (#9214) -- re-run `npm run replay-runner-manifest:write` and commit the result:\n"); + for (const reason of result.reasons) process.stderr.write(` - ${reason}\n`); + process.exit(1); + return; + } + + const rendered = `${JSON.stringify(fresh, null, 2)}\n`; + if (process.argv.includes("--write")) { + writeFileSync(join(REPO_ROOT, MANIFEST_PATH), rendered); + process.stdout.write(`wrote ${MANIFEST_PATH}\n`); + return; + } + process.stdout.write(rendered); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/replay-runner/Dockerfile b/scripts/replay-runner/Dockerfile new file mode 100644 index 0000000000..59de929a12 --- /dev/null +++ b/scripts/replay-runner/Dockerfile @@ -0,0 +1,84 @@ +# Reproducible replay-runner image (#9214, epic #8534). Runs the attested-backtest-run CLI headlessly: +# replay a rule's corpus, collect attestation evidence, and print the outcome as JSON. Build context = monorepo +# root (this script depends on the @loopover/engine workspace package): +# docker build -f scripts/replay-runner/Dockerfile -t loopover-replay-runner:latest . +# +# Base image is pinned BY DIGEST (not a mutable tag) -- see replay-runner-image-manifest.json's baseImageRef +# and replay-runner/README.md's "What is and isn't reproducible" section for what this pin does and doesn't +# guarantee. Bumping the base image is a manifest change, checked by `npm run replay-runner-manifest:check`. +# +# NO SECRETS ARE BAKED: the container never holds a GitHub token, a D1/Postgres credential, or an enrollment +# secret. Its own replay + attestation-collection path is corpus-in, JSON-out with no runtime network access; +# the optional `--persist` flag on the underlying CLI (which shells out to `wrangler d1 execute --remote`) is +# deliberately NOT reachable from this image's default command -- persistence is an operator-invoked, separate +# step outside what this measured image runs. + +FROM node:22-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca4403205b2966bd488f6b3 AS build +WORKDIR /app +# package.json/package-lock.json + the engine's own manifest BEFORE the rest of the tree, so `npm ci`'s layer +# only invalidates on a real dependency change -- same ordering as packages/discovery-index/Dockerfile. +# tsconfig.json is required too: packages/loopover-engine/tsconfig.json `extends` it. +# Deliberately NO --include-workspace-root: that flag would also install the ROOT package.json's OWN +# "dependencies" (the main Worker app's deps -- @anthropic-ai/claude-agent-sdk's full weight, sharp, +# @cloudflare, drizzle-orm, ...), none of which @loopover/engine's build or runtime needs -- typescript +# itself is @loopover/engine's OWN devDependency, not something borrowed from the root. Omitting the flag +# keeps this stage scoped to exactly what the engine package declares. +COPY package.json package-lock.json tsconfig.json ./ +COPY packages/loopover-engine/package.json packages/loopover-engine/package.json +RUN npm ci --workspace @loopover/engine --ignore-scripts +COPY packages/loopover-engine packages/loopover-engine +RUN npm run build --workspace @loopover/engine \ + && npm prune --omit=dev --workspace @loopover/engine --ignore-scripts + +# --- runtime-deps: tsx alone, via its OWN committed lockfile (scripts/replay-runner/tsx-runtime/), in an +# isolated install -- keeping the full root devDependency tree (typescript, eslint, vitest, ...) just to +# retain tsx would make this image an order of magnitude larger than "minimal" requires (#9214), and a bare +# `npm install tsx@x.y.z` with no lockfile would re-resolve tsx's own transitive deps at build time instead of +# reproducing the exact, pinned tree -- this stage stays `npm ci`, lockfile-only, like every other install in +# this Dockerfile. tsx is the ONLY thing scripts/**'s extensionless-specifier convention needs at runtime that +# isn't already a normal runtime dependency of @loopover/engine. +FROM node:22-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca4403205b2966bd488f6b3 AS runtime-deps +WORKDIR /app +COPY scripts/replay-runner/tsx-runtime/package.json scripts/replay-runner/tsx-runtime/package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts + +FROM node:22-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca4403205b2966bd488f6b3 AS runtime +RUN useradd --create-home --uid 10001 replay-runner +WORKDIR /app +ENV NODE_ENV=production +# `"type": "module"` here is load-bearing, not incidental: tsx (and Node's own loader) determine a file's +# module format from the nearest package.json, and without one present the entrypoint script's top-level +# await fails under tsx's CJS default. +COPY --from=build /app/package.json ./package.json +COPY --from=runtime-deps /app/node_modules ./node_modules +# @loopover/engine is constructed here directly from its own compiled output -- NOT `npm ci`'d -- because npm +# would install every dependency the FULL package declares (@anthropic-ai/claude-agent-sdk, tree-sitter-wasms, +# ...), regardless of which files this image actually imports. attester.js/attestation-envelope.js/ +# backtest-corpus.js's entire runtime closure is these three files plus node:crypto (verified: `grep -n +# "^import" packages/loopover-engine/dist/calibration/*.js` -- attested-backtest-run.ts's own subpath imports, +# #9214, are what make this closure reachable without ever loading the barrel's web-tree-sitter/ +# claude-agent-sdk-carrying re-export graph in the first place). This measured image is meant to run inside +# an eventual TEE (#8534); keeping its dependency footprint to exactly what replay needs is a real security +# property (a smaller trusted computing base), not a size optimization for its own sake. +COPY --from=build --chown=replay-runner:replay-runner /app/packages/loopover-engine/package.json ./node_modules/@loopover/engine/package.json +COPY --from=build --chown=replay-runner:replay-runner /app/packages/loopover-engine/dist/calibration/attester.js ./node_modules/@loopover/engine/dist/calibration/attester.js +COPY --from=build --chown=replay-runner:replay-runner /app/packages/loopover-engine/dist/calibration/attestation-envelope.js ./node_modules/@loopover/engine/dist/calibration/attestation-envelope.js +COPY --from=build --chown=replay-runner:replay-runner /app/packages/loopover-engine/dist/calibration/backtest-corpus.js ./node_modules/@loopover/engine/dist/calibration/backtest-corpus.js +# The runner script's own sources -- listed individually (rather than `COPY scripts/`) so this image's content +# is EXACTLY the set replay-runner-image-manifest.json enumerates under sourceFiles; adding a new script +# dependency here without a matching manifest entry is precisely the drift `replay-runner-manifest:check` catches. +COPY --chown=replay-runner:replay-runner scripts/attested-backtest-run.ts scripts/attested-backtest-run.ts +COPY --chown=replay-runner:replay-runner scripts/attested-backtest-run-core.ts scripts/attested-backtest-run-core.ts +COPY --chown=replay-runner:replay-runner scripts/backtest-corpus-export-core.ts scripts/backtest-corpus-export-core.ts +COPY --chown=replay-runner:replay-runner scripts/snp-attester.ts scripts/snp-attester.ts +USER replay-runner +# No HEALTHCHECK: this is a one-shot replay job (`docker run … `, exits after one attested run), not a +# long-running service -- there is no steady-state endpoint to probe, matching packages/loopover-miner's own +# batch-CLI image for the same reason. +# +# Run via tsx, not bare `node --experimental-strip-types`: scripts/**'s relative imports are extensionless +# (#9221, matching src/**'s Bundler resolution) -- tsx resolves that; plain Node's ESM loader cannot without +# an explicit extension. This is the SAME runner every scripts/** CLI already uses elsewhere in the repo (CI's +# calibration-advisory.yml, this repo's own real-subprocess test suites); it arrives here via the isolated +# runtime-deps stage above, not by keeping @loopover/engine's own devDependencies around. +ENTRYPOINT ["node_modules/.bin/tsx", "scripts/attested-backtest-run.ts"] diff --git a/scripts/replay-runner/README.md b/scripts/replay-runner/README.md new file mode 100644 index 0000000000..104ee2e710 --- /dev/null +++ b/scripts/replay-runner/README.md @@ -0,0 +1,88 @@ +# Replay-runner image + +The reproducible container image for `attested-backtest-run.ts` (#9214, epic #8534): replay a rule's +backtest corpus, collect attestation evidence binding which evaluation ran, and print the outcome as JSON. +Attestation over an unpinned, irreproducible workload is theater — this doc is how a third party checks that +this image is, in fact, pinned and reproducible, and exactly what that claim does and doesn't cover. + +## Build and run + +```sh +# from the repo root +docker build -f scripts/replay-runner/Dockerfile -t loopover-replay-runner:latest . + +docker run --rm --network none \ + -v /path/to/corpus.json:/data/corpus.json:ro \ + loopover-replay-runner:latest \ + --rule-id linked_issue_scope_mismatch --corpus /data/corpus.json \ + --head-sha <40 hex> --base-sha <40 hex> --repo owner/repo --pr 42 +``` + +`--network none` is not just an example flag — the image's default (sample-attester) path genuinely never +touches the network, which you can confirm yourself with the command above. See +`scripts/attested-backtest-run.ts`'s own header comment for the full flag reference (`--attester snp`, +`--runtime-claim tee`, etc.). + +No secrets are ever baked into the image: it never holds a GitHub token, a D1/Postgres credential, or an +enrollment secret. The CLI's own `--persist` flag (which shells out to `wrangler d1 execute --remote`) is +deliberately unreachable from this image's default invocation — persistence is a separate, operator-invoked +step outside what this measured image runs. + +## Reproduce the digest yourself + +`scripts/replay-runner-image-manifest.json` is the committed, checksummed record of exactly what this image +is built from. Recompute it and compare: + +```sh +npm ci --ignore-scripts +npm run replay-runner-manifest # prints a freshly computed manifest to stdout +npm run replay-runner-manifest:check # exits 0 iff it matches the committed file, else prints every diff +``` + +`replay-runner-manifest:check` runs on every push to `main` that touches this image (`.github/workflows/replay-runner-image.yml`), alongside an actual `docker build` of the pinned Dockerfile and a real attested run of the built image — so "the manifest matches" and "the image still builds and runs" are both continuously verified, not just asserted here. + +## What is and isn't reproducible + +The manifest's `digest` field is a **declared-inputs digest**, not a digest of the built Docker image +itself. Concretely, it's a SHA-256 over: + +- the pinned base image reference (`baseImageRef`, e.g. `node:22-slim@sha256:...`) — copied verbatim from + the Dockerfile's `FROM` line, so a base-image bump is a visible manifest diff, never a silent drift; +- the Dockerfile's own text (`dockerfileSha256`); +- the root `package-lock.json`'s text (`packageLockSha256`); +- every source file the image's runtime stage copies in, individually hashed (`sourceFiles`) — currently + `scripts/attested-backtest-run.ts`, `scripts/attested-backtest-run-core.ts`, + `scripts/backtest-corpus-export-core.ts`, and `scripts/snp-attester.ts` (see the Dockerfile's own comment + for why this is an explicit, individually-maintained list rather than a directory glob). + +**This is a sound proxy for "rebuilding reproduces the same image"** because both of the operations that +turn these inputs into a running container are themselves deterministic given identical inputs: +`npm ci` resolves dependencies from a lockfile (no version-range re-resolution), and `tsc`'s compilation of +`@loopover/engine` is a pure function of its source tree. If none of the five inputs above have changed, the +build process that consumes them hasn't changed either. + +**What this manifest does NOT claim**: that the *built Docker image's own ID* +(`docker inspect --format '{{.Id}}'`) is bit-for-bit reproducible across machines or BuildKit versions. Layer +metadata — file modification times, ownership bits, and BuildKit's own internal caching behavior — is a +known source of non-determinism in container builds generally, and this repo has not attempted to solve it +(no `SOURCE_DATE_EPOCH` normalization, no `--provenance=false` reproducibility tooling). A skeptic who +rebuilds this image on a different machine may get a different `docker inspect` ID even when every declared +input above matches exactly. That is a real, narrower gap than "the image is reproducible" — this manifest +proves the *inputs* never drifted; it does not prove the *build byte stream* is identical everywhere. + +If a future need justifies closing that narrower gap (e.g. binding a real TEE launch measurement to something +tighter than "these inputs, believed rebuilt correctly"), that is separate, harder work than this manifest — +raise it against epic #8534 rather than assuming this file already covers it. + +## Why a subpath import, not the `@loopover/engine` barrel + +`attested-backtest-run.ts` imports from `@loopover/engine/calibration/attester` and +`@loopover/engine/calibration/backtest-corpus`, not the package's default export. The barrel +(`@loopover/engine`'s `dist/index.js`) re-exports `miner/repo-map.ts`, which has a top-level +`import Parser from "web-tree-sitter"` — loading the barrel would pull `web-tree-sitter` (and, via its own +on-disk resolution, `tree-sitter-wasms`) into this image even though nothing here ever calls into +`repo-map.ts`. For an image meant to eventually run measured inside a TEE, keeping its dependency footprint to +exactly what replay needs is a real security property (a smaller trusted computing base), not a size +optimization for its own sake — see `scripts/attested-backtest-run.ts`'s own import comment for the same +rationale in more detail, and `src/db/repositories.ts`'s `@loopover/engine/parse-pull-request-target-key` +import for the established precedent this follows. diff --git a/scripts/replay-runner/tsx-runtime/package-lock.json b/scripts/replay-runner/tsx-runtime/package-lock.json new file mode 100644 index 0000000000..b94190678c --- /dev/null +++ b/scripts/replay-runner/tsx-runtime/package-lock.json @@ -0,0 +1,502 @@ +{ + "name": "loopover-replay-runner-tsx-runtime", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "loopover-replay-runner-tsx-runtime", + "dependencies": { + "tsx": "4.22.5" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.22.5", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.5.tgz", + "integrity": "sha512-F7JnSfPl5ASt6LqwWyUQ3T8BwN3q0eQEbFMYa2iRWaVQmmudo0d7fRmwM4O002gsvW1bs0yBYioutsAjqLJMvQ==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + } + } +} diff --git a/scripts/replay-runner/tsx-runtime/package.json b/scripts/replay-runner/tsx-runtime/package.json new file mode 100644 index 0000000000..61741dd0d5 --- /dev/null +++ b/scripts/replay-runner/tsx-runtime/package.json @@ -0,0 +1,8 @@ +{ + "name": "loopover-replay-runner-tsx-runtime", + "private": true, + "description": "Isolated, lockfile-pinned tsx install for the replay-runner image's runtime stage (#9214) -- kept out of the main lockfile so a change here never touches the whole monorepo's dependency graph.", + "dependencies": { + "tsx": "4.22.5" + } +} diff --git a/scripts/snp-attester.ts b/scripts/snp-attester.ts index f29bc94a53..a6217d9147 100644 --- a/scripts/snp-attester.ts +++ b/scripts/snp-attester.ts @@ -13,7 +13,7 @@ // claim (fail-closed) -- never a silent degrade. import { spawnSync } from "node:child_process"; -import type { Attester, AttestationCollection, AttestationCollectionRequest } from "@loopover/engine"; +import type { Attester, AttestationCollection, AttestationCollectionRequest } from "@loopover/engine/calibration/attester"; /** Where a CoCo attestation agent listens inside the guest. Overridable for a non-default topology. */ export const DEFAULT_ATTESTATION_AGENT_URL = "http://127.0.0.1:8006/aa/evidence"; diff --git a/test/unit/replay-runner-image-manifest-core.test.ts b/test/unit/replay-runner-image-manifest-core.test.ts new file mode 100644 index 0000000000..00728b9c6c --- /dev/null +++ b/test/unit/replay-runner-image-manifest-core.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import { + buildReplayRunnerImageManifest, + checkReplayRunnerImageManifestDrift, + type ReplayRunnerImageManifest, +} from "../../scripts/replay-runner-image-manifest-core"; + +const BASE_INPUT = { + baseImageRef: "node:22-slim@sha256:" + "a".repeat(64), + dockerfileContent: "FROM node:22-slim\nCOPY . .\n", + packageLockContent: '{"name":"loopover","lockfileVersion":3}', + sourceFileContents: { + "scripts/a.ts": "export const a = 1;", + "scripts/b.ts": "export const b = 2;", + }, +}; + +describe("buildReplayRunnerImageManifest", () => { + it("hashes every source file individually and produces a single top-level digest", () => { + const manifest = buildReplayRunnerImageManifest(BASE_INPUT); + expect(manifest.schemaVersion).toBe(1); + expect(manifest.baseImageRef).toBe(BASE_INPUT.baseImageRef); + expect(manifest.dockerfileSha256).toMatch(/^[0-9a-f]{64}$/); + expect(manifest.packageLockSha256).toMatch(/^[0-9a-f]{64}$/); + expect(manifest.digest).toMatch(/^[0-9a-f]{64}$/); + expect(Object.keys(manifest.sourceFiles)).toEqual(["scripts/a.ts", "scripts/b.ts"]); + expect(manifest.sourceFiles["scripts/a.ts"]).toMatch(/^[0-9a-f]{64}$/); + }); + + it("is deterministic: identical inputs produce a byte-identical manifest", () => { + expect(buildReplayRunnerImageManifest(BASE_INPUT)).toEqual(buildReplayRunnerImageManifest(BASE_INPUT)); + }); + + it("sorts sourceFiles by path regardless of input object key order", () => { + const manifest = buildReplayRunnerImageManifest({ + ...BASE_INPUT, + sourceFileContents: { "scripts/z.ts": "z", "scripts/a.ts": "a" }, + }); + expect(Object.keys(manifest.sourceFiles)).toEqual(["scripts/a.ts", "scripts/z.ts"]); + }); + + it("changes the digest when the Dockerfile content changes but nothing else does", () => { + const a = buildReplayRunnerImageManifest(BASE_INPUT); + const b = buildReplayRunnerImageManifest({ ...BASE_INPUT, dockerfileContent: BASE_INPUT.dockerfileContent + "\n# comment" }); + expect(a.dockerfileSha256).not.toBe(b.dockerfileSha256); + expect(a.digest).not.toBe(b.digest); + }); + + it("changes the digest when a single source file's content changes", () => { + const a = buildReplayRunnerImageManifest(BASE_INPUT); + const b = buildReplayRunnerImageManifest({ + ...BASE_INPUT, + sourceFileContents: { ...BASE_INPUT.sourceFileContents, "scripts/a.ts": "export const a = 2;" }, + }); + expect(a.sourceFiles["scripts/b.ts"]).toBe(b.sourceFiles["scripts/b.ts"]); // unaffected file unchanged + expect(a.digest).not.toBe(b.digest); + }); +}); + +describe("checkReplayRunnerImageManifestDrift", () => { + it("reports no drift when comparing a manifest to itself", () => { + const manifest = buildReplayRunnerImageManifest(BASE_INPUT); + expect(checkReplayRunnerImageManifestDrift(manifest, manifest)).toEqual({ drifted: false }); + }); + + it("reports baseImageRef, Dockerfile, and lockfile drift by name", () => { + const committed = buildReplayRunnerImageManifest(BASE_INPUT); + const fresh = buildReplayRunnerImageManifest({ ...BASE_INPUT, baseImageRef: "node:22-slim@sha256:" + "b".repeat(64), dockerfileContent: "changed", packageLockContent: "changed" }); + const result = checkReplayRunnerImageManifestDrift(committed, fresh); + expect(result.drifted).toBe(true); + if (!result.drifted) return; + expect(result.reasons.some((r) => r.startsWith("baseImageRef:"))).toBe(true); + expect(result.reasons.some((r) => r.startsWith("Dockerfile changed:"))).toBe(true); + expect(result.reasons.some((r) => r.startsWith("package-lock.json changed:"))).toBe(true); + }); + + it("reports a schemaVersion mismatch (a stale on-disk manifest read after a future schema bump)", () => { + const committed = buildReplayRunnerImageManifest(BASE_INPUT); + // `schemaVersion` is a numeric-literal-1 type by construction; a manifest read back from disk after a + // real schema migration would parse in with a DIFFERENT number, which the type system can't express here + // without a cast -- exactly what this branch exists to catch at runtime instead. + const fresh: ReplayRunnerImageManifest = { ...committed, schemaVersion: 2 as 1 }; + const result = checkReplayRunnerImageManifestDrift(committed, fresh); + expect(result.drifted).toBe(true); + if (!result.drifted) return; + expect(result.reasons).toContain("schemaVersion: committed 1, fresh 2"); + }); + + it("reports a changed source file by path, distinct from an added or removed one", () => { + const committed = buildReplayRunnerImageManifest(BASE_INPUT); + const fresh = buildReplayRunnerImageManifest({ + ...BASE_INPUT, + sourceFileContents: { "scripts/a.ts": "export const a = 999;", "scripts/c.ts": "export const c = 3;" }, // a changed, b removed, c added + }); + const result = checkReplayRunnerImageManifestDrift(committed, fresh); + expect(result.drifted).toBe(true); + if (!result.drifted) return; + expect(result.reasons).toContain("source file changed: scripts/a.ts"); + expect(result.reasons).toContain("source file removed: scripts/b.ts"); + expect(result.reasons).toContain("source file added: scripts/c.ts"); + }); +});