diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8e37d837..2a5fc654 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -74,10 +74,22 @@ jobs: - name: Build run: npm run build + - name: Resolve exact Daimon provenance fixture revision + id: daimon-provenance + run: | + node --input-type=module <<'NODE' >> "$GITHUB_OUTPUT" + import { readFileSync } from 'node:fs'; + import { parse } from 'yaml'; + const ref = parse(readFileSync('runtimes.yaml', 'utf8')).runtimes.daimon.ref; + if (!/^[a-f0-9]{40}$/.test(ref)) throw Error('Daimon provenance requires a full commit pin'); + console.log(`ref=${ref}`); + NODE + - name: Check out Daimon provenance fixture uses: actions/checkout@v4 with: repository: noopolis/daimon-harness + ref: ${{ steps.daimon-provenance.outputs.ref }} path: daimon-fixture # The shipped runtime refuses any Grok executable but the manifest-pinned diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e476053e..cf0e2677 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -162,3 +162,82 @@ jobs: env -u TYPESAFE_API_KEY src/training/dspy/.venv/bin/python -c 'import json, subprocess; result=subprocess.run(["src/training/dspy/.venv/bin/spawnfile-training-dspy"], input="{}\n", capture_output=True, text=True, check=False); terminal=json.loads(result.stdout); assert result.returncode == 1 and terminal["schema"] == "spawnfile.training.dspy.v2" and terminal["error"]["code"] == "invalid_request"' - name: Test the bridge and enforce per-module Python coverage run: env -u TYPESAFE_API_KEY npm run test:training-python + + # Run the release gate on every PR/main revision: ~6 runner minutes in + # parallel with coverage, with no path filter that can miss an upstream move. + source-provenance: + name: offline source provenance (linux/amd64) + timeout-minutes: 20 + runs-on: ubuntu-latest + steps: + - name: Check out Spawnfile + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: env -u TYPESAFE_API_KEY npm ci + + # Uses the checked-in native helpers, as the shipped package does. + # The root job separately rebuilds and syscall-tests both architectures. + - name: Build + run: env -u TYPESAFE_API_KEY npm run build + + - name: Resolve exact Daimon provenance fixture revision + id: daimon-provenance + run: | + node --input-type=module <<'NODE' >> "$GITHUB_OUTPUT" + import { readFileSync } from 'node:fs'; + import { parse } from 'yaml'; + const ref = parse(readFileSync('runtimes.yaml', 'utf8')).runtimes.daimon.ref; + if (!/^[a-f0-9]{40}$/.test(ref)) throw Error('Daimon provenance requires a full commit pin'); + console.log(`ref=${ref}`); + NODE + + - name: Check out Daimon provenance fixture + uses: actions/checkout@v4 + with: + repository: noopolis/daimon-harness + ref: ${{ steps.daimon-provenance.outputs.ref }} + path: daimon-fixture + + # The shipped runtime refuses any Grok executable but the manifest-pinned + # build, so the source build needs the real linux-x86_64 binary. Its URL, + # size, and digest come from the vendored Daimon contract manifest, so a + # re-vendored Daimon bump moves this download with no CI edit. + - name: Fetch the pinned linux-x86_64 Grok CLI + id: grok-cli + shell: bash + run: | + set -euo pipefail + + manifest=src/runtime/daimon/contract-manifest.json + read -r url bytes sha256 < <(node -e ' + const artifact = require("./" + process.argv[1]).grokEngineBroker?.grokCliArtifacts?.x64; + if (typeof artifact?.url !== "string" || !Number.isInteger(artifact.bytes) || !/^[a-f0-9]{64}$/.test(artifact.sha256 ?? "")) { + throw new Error("vendored Daimon contract manifest has no pinned linux-x86_64 Grok CLI artifact"); + } + process.stdout.write(`${artifact.url} ${artifact.bytes} ${artifact.sha256}\n`); + ' "${manifest}") + + file="${RUNNER_TEMP}/grok" + curl --fail --location --silent --show-error --output "${file}" -- "${url}" + test "$(stat -c %s -- "${file}")" -eq "${bytes}" + echo "${sha256} ${file}" | sha256sum --check --strict - + chmod 0755 -- "${file}" + echo "file=${file}" >> "${GITHUB_OUTPUT}" + + - name: Verify Git-free offline linux/amd64 source build + env: + SPAWNFILE_GROK_CLI_FILE: ${{ steps.grok-cli.outputs.file }} + SPAWNFILE_TEST_DAIMON_SOURCE: ${{ github.workspace }}/daimon-fixture + run: env -u TYPESAFE_API_KEY npm run test:source-provenance-docker + + - name: Remove Daimon provenance fixture and Grok CLI + if: always() + run: rm -rf -- daimon-fixture "${RUNNER_TEMP}/grok" diff --git a/package-lock.json b/package-lock.json index f63c5018..776c0c0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "spawnfile", - "version": "0.1.26", + "version": "0.1.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "spawnfile", - "version": "0.1.26", + "version": "0.1.27", "license": "MIT", "dependencies": { "@huggingface/jinja": "0.5.10", diff --git a/package.json b/package.json index c263ace0..30910059 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "spawnfile", - "version": "0.1.26", + "version": "0.1.27", "description": "Canonical source compiler for autonomous agents and teams.", "license": "MIT", "type": "module", diff --git a/scripts/README.md b/scripts/README.md index 49ba5496..723cd183 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -21,7 +21,7 @@ in the npm package. Scripts run as native TypeScript on Node 22.19+. | `verify-package-closure.ts` | `npm run verify:package-closure` | Verify the packed CLI and runtime closure | | `test-training-python.ts` | `npm run test:training-python`, CI | Run the real DSPy bridge pytest suite and enforce 90% lines and branches for every module and the total; install `src/training/dspy/requirements.lock` and the editable bridge in `src/training/dspy/.venv` first | | `product-state-volume-integration.test.ts` | `npm run test:product-state-volume`, CI | Test real volume preseed; Docker and host volume access required | -| `source-provenance-bundle.integration.test.ts` | `npm run test:source-provenance-docker` | Test the real offline Daimon archive build | +| `source-provenance-bundle.integration.test.ts` | `npm run test:source-provenance-docker` | PR/main and publish workflows: real offline Daimon archive build and tamper rejection; Docker, Daimon checkout (`SPAWNFILE_TEST_DAIMON_SOURCE`), pinned Linux Grok CLI (`SPAWNFILE_GROK_CLI_FILE`) | | `moltnet-source-provenance.integration.test.ts` | `npm run test:moltnet-source-provenance-docker` | Test the real offline Moltnet archive build | `source-provenance-bundle.ts`, `native-helper-artifacts.ts`, and diff --git a/scripts/native-helper-workflows.test.ts b/scripts/native-helper-workflows.test.ts index dc6e5dd8..f944751c 100644 --- a/scripts/native-helper-workflows.test.ts +++ b/scripts/native-helper-workflows.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; +import { parse } from "yaml"; const gate = "node --experimental-strip-types --test scripts/native-helper-artifacts.test.ts scripts/native-helper-integration.test.ts"; @@ -22,3 +23,42 @@ test("normal package build copies shipped helpers without invoking the native re assert.doesNotMatch(packageJson.scripts.build, /native\/build\.ts|docker/u); assert.equal(packageJson.scripts["build:native"], "node --experimental-strip-types ./src/deployment/native/build.ts"); }); + +test("PR/main runs the same offline source provenance gate and inputs as release", async () => { + type Step = { name: string; run?: string; env?: Record; uses?: string; with?: Record }; + type Job = { if?: string; "timeout-minutes": number; steps: Step[] }; + const ci = parse(await readFile(".github/workflows/test.yml", "utf8")) as { + on: { pull_request: { paths?: string[]; "paths-ignore"?: string[] } | null; push: { branches: string[]; paths?: string[]; "paths-ignore"?: string[] } }; + jobs: Record; + }; + const release = parse(await readFile(".github/workflows/publish.yml", "utf8")) as { jobs: { publish: Job } }; + assert.ok(Object.hasOwn(ci.on, "pull_request")); + assert.ok(ci.on.push.branches.includes("main")); + assert.equal(ci.on.push.paths, undefined); + assert.equal(ci.on.push["paths-ignore"], undefined); + assert.equal(ci.on.pull_request?.paths, undefined); + assert.equal(ci.on.pull_request?.["paths-ignore"], undefined); + const job = ci.jobs["source-provenance"]; + assert.ok(job); + assert.equal(job.if, undefined, "the gate must run on every PR/main revision"); + assert.ok(job["timeout-minutes"] >= 15 && job["timeout-minutes"] <= 30); + for (const name of ["Resolve exact Daimon provenance fixture revision", "Check out Daimon provenance fixture", "Fetch the pinned linux-x86_64 Grok CLI", + "Verify Git-free offline linux/amd64 source build"]) { + const actual = job.steps.find((step) => step.name === name); + const expected = release.jobs.publish.steps.find((step) => step.name === name); + assert.ok(actual && expected, name); + assert.deepEqual(actual.with, expected.with, name); + assert.deepEqual(actual.env, expected.env, name); + assert.equal(actual.run?.replace("env -u TYPESAFE_API_KEY ", ""), expected.run, name); + } + assert.equal(job.steps.find((step) => step.name === "Check out Daimon provenance fixture")?.with?.ref, + "${{ steps.daimon-provenance.outputs.ref }}"); + for (const candidate of [job, release.jobs.publish]) { + const resolve = candidate.steps.findIndex((step) => step.name === "Resolve exact Daimon provenance fixture revision"); + const checkout = candidate.steps.findIndex((step) => step.name === "Check out Daimon provenance fixture"); + assert.ok(resolve >= 0 && checkout > resolve, "resolve the pin before checkout, or an empty ref follows upstream HEAD"); + } + const gate = job.steps.findIndex((step) => step.run?.includes("npm run test:source-provenance-docker")); + const build = job.steps.findIndex((step) => step.run?.includes("npm run build")); + assert.ok(build >= 0 && gate > build, "the gate requires compiled materializer output"); +}); diff --git a/scripts/source-provenance-bundle.integration.test.ts b/scripts/source-provenance-bundle.integration.test.ts index fde28c2d..cff1a873 100644 --- a/scripts/source-provenance-bundle.integration.test.ts +++ b/scripts/source-provenance-bundle.integration.test.ts @@ -25,6 +25,13 @@ const pinnedGrokCli = (): string => { }; const sha512 = (file: string): string => `sha512:${createHash("sha512").update(readFileSync(file)).digest("hex")}`; +const assertRejected = (result: ReturnType, label: string, diagnostic: RegExp): void => { + assert.ifError(result.error); + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + assert.ok(result.status !== null && result.status > 0, `${label} must be rejected (status=${result.status})\n${output}`); + assert.match(output, diagnostic, `${label} must fail at its verifier`); +}; + // A full pass — offline closure, shipped artifact, wrapper image, literal org // container, and four tamper rejections — measured 328 s on a GitHub-hosted // runner, so the budget leaves room for a slower one rather than 32 s. @@ -82,7 +89,7 @@ test("actual Daimon lock produces a real offline linux/amd64 shipped artifact an const port = mapped.slice(mapped.lastIndexOf(":") + 1); const identityPath = path.join(repository, ".local-daimon-runtime-identity.json"), priorIdentity = existsSync(identityPath) ? readFileSync(identityPath) : null; try { - execFileSync("npm", ["run", "--silent", "build:local-daimon"], { cwd: repository, env: { ...process.env, AGY_CLI_SHA256: agySha, AGY_CLI_SHA512: sha512(agyTar), AGY_CLI_URL: "https://invalid.example/agy", AGY_CLI_VERSION: "fixture", CODEX_CLI_SHA256: codexSha, GROK_CLI_SHA256: grokSha, GROK_CLI_URL: pinnedManifest.grokEngineBroker.grokCliArtifacts.x64.url, GROK_CLI_VERSION: pinnedManifest.grokEngineBroker.grokCliVersion, SPAWNFILE_AGY_CLI_ARCHIVE: agyTar, SPAWNFILE_DAIMON_DEPENDENCY_BUNDLE: dependencyTar, SPAWNFILE_DAIMON_LOCAL_IMAGE_TAG: `127.0.0.1:${port}/noopolis/spawnfile-runtime-daimon:archive-wrapper`, SPAWNFILE_DAIMON_SOURCE_BUNDLE: sourceTar, SPAWNFILE_GROK_CLI_FILE: grok }, stdio: "ignore" }); + execFileSync("npm", ["run", "--silent", "build:local-daimon"], { cwd: repository, env: { ...process.env, SPAWNFILE_DAIMON_TARGET_ARCH: "amd64", AGY_CLI_SHA256: agySha, AGY_CLI_SHA512: sha512(agyTar), AGY_CLI_URL: "https://invalid.example/agy", AGY_CLI_VERSION: "fixture", CODEX_CLI_SHA256: codexSha, GROK_CLI_SHA256: grokSha, GROK_CLI_URL: pinnedManifest.grokEngineBroker.grokCliArtifacts.x64.url, GROK_CLI_VERSION: pinnedManifest.grokEngineBroker.grokCliVersion, SPAWNFILE_AGY_CLI_ARCHIVE: agyTar, SPAWNFILE_DAIMON_DEPENDENCY_BUNDLE: dependencyTar, SPAWNFILE_DAIMON_LOCAL_IMAGE_TAG: `127.0.0.1:${port}/noopolis/spawnfile-runtime-daimon:archive-wrapper`, SPAWNFILE_DAIMON_SOURCE_BUNDLE: sourceTar, SPAWNFILE_GROK_CLI_FILE: grok }, stdio: "inherit" }); const wrapperIdentity = JSON.parse(readFileSync(identityPath, "utf8")); assert.equal(wrapperIdentity.image_architecture, "amd64"); assert.match(wrapperIdentity.image_reference, new RegExp(`^127\\.0\\.0\\.1:${port}/noopolis/spawnfile-runtime-daimon@sha256:[a-f0-9]{64}$`, "u")); } finally { if (priorIdentity) writeFileSync(identityPath, priorIdentity); else rmSync(identityPath, { force: true }); } const receipt = { architecture: "amd64", daimon: { package_sha256: packageSha, source_inputs: sourceInputs, source_sha256: digest(path.join(packageContext, "source-inputs.json")) }, engines: { agy: { executable_sha256: agySha }, codex: { executable_sha256: codexSha }, grok: { executable_sha256: grokSha } }, manifest_sha256: manifestSha, provenance: { agy: { archive: { format: "tar.gz", sha512: sha512(agyTar), url: "https://invalid.example/agy", version: "fixture" } }, grok: { executable: { sha256: grokSha, url: pinnedManifest.grokEngineBroker.grokCliArtifacts.x64.url, version: pinnedManifest.grokEngineBroker.grokCliVersion } } }, version: "spawnfile.daimon-runtime-capability-receipt.v1" }; @@ -138,13 +145,41 @@ test("actual Daimon lock produces a real offline linux/amd64 shipped artifact an const fakeReceipt = validateSourceBundle(readFileSync(fakeTar)); mkdirSync(fakeContext); mkdirSync(fakeOutput); cpSync(fakeTar, path.join(fakeContext, "dependencies.tar")); const fakeArgs = args.map((value) => value === `dependency_bundle=${dependencyContext}` ? `dependency_bundle=${fakeContext}` : value === `DEPENDENCY_ARCHIVE_SHA256=${dependencyReceipt.archive_sha256}` ? `DEPENDENCY_ARCHIVE_SHA256=${fakeReceipt.archive_sha256}` : value === `DEPENDENCY_MANIFEST_SHA256=${dependencyReceipt.manifest_sha256}` ? `DEPENDENCY_MANIFEST_SHA256=${fakeReceipt.manifest_sha256}` : value === `type=local,dest=${output}` ? `type=local,dest=${fakeOutput}` : value); - assert.notEqual(spawnSync("docker", fakeArgs, { stdio: "ignore" }).status, 0); - for (const fault of ["missing", "wrong"]) { - const badRoot=path.join(temporary,`bad-native-${fault}`),badTar=path.join(temporary,`bad-native-${fault}.tar`),badContext=path.join(temporary,`bad-native-${fault}-context`),badOutput=path.join(temporary,`bad-native-${fault}-output`); - mkdirSync(badRoot);execFileSync("tar",["-xf",sourceTar,"-C",badRoot]);rmSync(path.join(badRoot,".spawnfile-source-manifest.json"),{force:true});const artifact=path.join(badRoot,"src","runtime","native","artifacts","daimon-engine-broker-x64");if(fault==="missing")rmSync(artifact);else writeFileSync(artifact,"wrong-native-artifact\n",{mode:0o755}); - const badReceipt=createSourceBundle(badRoot,badTar);mkdirSync(badContext);mkdirSync(badOutput);cpSync(badTar,path.join(badContext,"source.tar"));const badArgs=args.map((value)=>value===`source_bundle=${sourceContext}`?`source_bundle=${badContext}`:value===`SOURCE_ARCHIVE_SHA256=${sourceReceipt.archive_sha256}`?`SOURCE_ARCHIVE_SHA256=${badReceipt.archive_sha256}`:value===`SOURCE_MANIFEST_SHA256=${sourceReceipt.manifest_sha256}`?`SOURCE_MANIFEST_SHA256=${badReceipt.manifest_sha256}`:value===`type=local,dest=${output}`?`type=local,dest=${badOutput}`:value);assert.notEqual(spawnSync("docker",badArgs,{stdio:"ignore"}).status,0); + assertRejected(spawnSync("docker", fakeArgs, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }), "false Codex lock integrity", /npm error code (?:EINTEGRITY|ENOTCACHED)/u); + for (const fault of ["missing", "wrong"] as const) { + const badRoot = path.join(temporary, `bad-native-${fault}`), badTar = path.join(temporary, `bad-native-${fault}.tar`), + badContext = path.join(temporary, `bad-native-${fault}-context`), badOutput = path.join(temporary, `bad-native-${fault}-output`); + mkdirSync(badRoot); + execFileSync("tar", ["-xf", sourceTar, "-C", badRoot]); + rmSync(path.join(badRoot, ".spawnfile-source-manifest.json")); + // Daimon's engine ownership move relocated the source artifact; the pack + // and installed runtime still ship this exact x64 broker's bytes. + const artifact = path.join(badRoot, "src", "engines", "grok", "broker", "native", "artifacts", "daimon-engine-broker-x64"); + assert.deepEqual(readFileSync(artifact), packedBroker, "tamper target must be the shipped broker"); + execFileSync("npm", ["run", "verify:native"], { cwd: badRoot, stdio: "pipe" }); + if (fault === "missing") rmSync(artifact); + else writeFileSync(artifact, "wrong-native-artifact\n", { mode: 0o755 }); + const diagnostic = fault === "missing" ? /ENOENT[^\n]*daimon-engine-broker-x64/u : /native broker provenance mismatch/u; + // Check the verifier directly as well: a missing file breaking a later + // copy/build must not hide a verifier that silently ignores that file. + assertRejected(spawnSync("npm", ["run", "verify:native"], { cwd: badRoot, encoding: "utf8" }), `${fault} native verifier`, diagnostic); + const badReceipt = createSourceBundle(badRoot, badTar); + mkdirSync(badContext); mkdirSync(badOutput); cpSync(badTar, path.join(badContext, "source.tar")); + const badArgs = args.map((value) => value === `source_bundle=${sourceContext}` ? `source_bundle=${badContext}` + : value === `SOURCE_ARCHIVE_SHA256=${sourceReceipt.archive_sha256}` ? `SOURCE_ARCHIVE_SHA256=${badReceipt.archive_sha256}` + : value === `SOURCE_MANIFEST_SHA256=${sourceReceipt.manifest_sha256}` ? `SOURCE_MANIFEST_SHA256=${badReceipt.manifest_sha256}` + : value === `type=local,dest=${output}` ? `type=local,dest=${badOutput}` : value); + assertRejected(spawnSync("docker", badArgs, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }), `${fault} native source build`, diagnostic); } - const tampered = readFileSync(path.join(dependencyContext, "dependencies.tar")); const tamperOffset = tampered.length - 1025; tampered[tamperOffset] = (tampered[tamperOffset] ?? 0) ^ 1; writeFileSync(path.join(dependencyContext, "dependencies.tar"), tampered); - assert.notEqual(spawnSync("docker", args, { stdio: "ignore" }).status, 0); + // Alter whitespace in the real bundled lock, preserving valid JSON and + // dependency semantics. Only the archive digest should reject this case. + const tampered = readFileSync(path.join(dependencyContext, "dependencies.tar")); + const lockBytes = execFileSync("tar", ["-xOf", dependencyTar, "package-lock.json"]); + const lockOffset = tampered.indexOf(lockBytes), whitespace = lockBytes.indexOf(" "); + assert.ok(lockOffset >= 0 && whitespace >= 0, "archive tamper target must exist"); + tampered[lockOffset + whitespace] = 0x09; + writeFileSync(path.join(dependencyContext, "dependencies.tar"), tampered); + assert.notEqual(digest(path.join(dependencyContext, "dependencies.tar")), dependencyReceipt.archive_sha256); + assertRejected(spawnSync("docker", args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }), "changed dependency archive", /did not complete successfully.*exit code: 1/u); } finally { if (registry) spawnSync("docker", ["rm", "--force", registry], { stdio: "ignore" }); rmSync(temporary, { force: true, recursive: true }); } });