From 8589bc657dba64bdc330de0609c6bb5f0a55be78 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Tue, 1 Sep 2026 14:19:24 -0600 Subject: [PATCH 1/2] ci(prime): require real artifact graduation Refs #193 Refs #194 Refs #114 --- .../workflows/prime-artifact-graduation.yml | 191 +++++++++ .../scripts/prime-artifact-graduation.ts | 371 ++++++++++++++++++ ...gentArtifactGraduation.integration.test.ts | 228 +++++++++++ ...imeAgentArtifactGraduation.test-fixture.ts | 128 ++++++ .../prime/PrimeAgentDaemonBridge.test.ts | 44 ++- .../PrimeAgentDistributionVerifier.test.ts | 16 + .../prime/PrimeAgentDistributionVerifier.ts | 305 ++++++++++++++ ...AgentMultipleInstances.integration.test.ts | 228 ++++++++++- .../PrimeAgentRealDaemon.integration.test.ts | 52 ++- .../PrimeAgentRestartAdoption.real.test.mjs | 159 ++------ .../provider/prime/PrimeManagedMaintenance.ts | 1 - docs/README.md | 1 + .../prime-agent-distribution-verification.md | 21 + docs/internals/prime-agent-managed-install.md | 4 +- docs/operations/prime-artifact-graduation.md | 54 +++ packages/contracts/src/server.test.ts | 20 + packages/contracts/src/server.ts | 1 - ...prime-artifact-graduation-workflow.test.ts | 99 +++++ 18 files changed, 1766 insertions(+), 157 deletions(-) create mode 100644 .github/workflows/prime-artifact-graduation.yml create mode 100644 apps/server/scripts/prime-artifact-graduation.ts create mode 100644 apps/server/src/provider/prime/PrimeAgentArtifactGraduation.integration.test.ts create mode 100644 apps/server/src/provider/prime/PrimeAgentArtifactGraduation.test-fixture.ts create mode 100644 docs/operations/prime-artifact-graduation.md create mode 100644 scripts/prime-artifact-graduation-workflow.test.ts diff --git a/.github/workflows/prime-artifact-graduation.yml b/.github/workflows/prime-artifact-graduation.yml new file mode 100644 index 000000000..b4aa13579 --- /dev/null +++ b/.github/workflows/prime-artifact-graduation.yml @@ -0,0 +1,191 @@ +name: Prime artifact graduation + +on: + workflow_dispatch: + inputs: + preview_tag: + description: Exact immutable Pylon Prime preview tag (pylon-build-g-r) + required: true + type: string + second_preview_tag: + description: Optional later immutable preview tag for a real update proof + required: false + default: "" + type: string + stock_version: + description: Exact stock Prime Agent version + required: true + default: 0.8.1 + type: string + +permissions: + contents: read + +concurrency: + group: prime-artifact-graduation-${{ inputs.preview_tag }}-${{ inputs.second_preview_tag || 'no-update' }} + cancel-in-progress: false + +jobs: + graduate: + name: Verify and exercise exact Prime publication + runs-on: ubuntu-24.04 + timeout-minutes: 45 + environment: prime-graduation + steps: + - name: Validate immutable inputs and allocate runner-local paths + env: + PREVIEW_TAG: ${{ inputs.preview_tag }} + SECOND_PREVIEW_TAG: ${{ inputs.second_preview_tag }} + STOCK_VERSION: ${{ inputs.stock_version }} + run: | + set -euo pipefail + preview_pattern='^pylon-build-g[0-9a-f]{12}-r[1-9][0-9]*$' + version_pattern='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' + [[ "$PREVIEW_TAG" =~ $preview_pattern ]] + if test -n "$SECOND_PREVIEW_TAG"; then + [[ "$SECOND_PREVIEW_TAG" =~ $preview_pattern ]] + test "$SECOND_PREVIEW_TAG" != "$PREVIEW_TAG" + fi + [[ "$STOCK_VERSION" =~ $version_pattern ]] + install -d -m 700 "$RUNNER_TEMP/prime-graduation-results" "$RUNNER_TEMP/prime-sigstore-tuf" + { + echo "PYLON_PRIME_GRADUATION_REQUIRED=1" + echo "PYLON_PRIME_PREVIEW_TAG=$PREVIEW_TAG" + echo "PYLON_PRIME_SECOND_PREVIEW_TAG=$SECOND_PREVIEW_TAG" + echo "PYLON_PRIME_ARTIFACT_DIR=$RUNNER_TEMP/prime-preview" + if test -n "$SECOND_PREVIEW_TAG"; then + echo "PYLON_PRIME_SECOND_ARTIFACT_DIR=$RUNNER_TEMP/prime-preview-second" + else + echo "PYLON_PRIME_SECOND_ARTIFACT_DIR=" + fi + echo "PYLON_PRIME_STOCK_TARBALL=$RUNNER_TEMP/prime-stock/prime-agent-$STOCK_VERSION.tgz" + echo "PYLON_PRIME_AGENT_STOCK_ARTIFACT_BIN=$RUNNER_TEMP/prime-stock-install/node_modules/.bin/prime-agent" + echo "PYLON_PRIME_GRADUATION_RESULT=$RUNNER_TEMP/prime-graduation-results/cases.json" + echo "PYLON_REAL_PRIME_AGENT_MULTI_PROOF=1" + echo "PYLON_REAL_PRIME_AGENT_MULTI_COUNT=2" + } >> "$GITHUB_ENV" + + - name: Checkout exact Pylon revision + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@8a7496fd44e8a1b0a88a7459e36213b2fefc1d15 # v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Download exact public preview, attestations, and stock package + env: + PREVIEW_TAG: ${{ inputs.preview_tag }} + STOCK_VERSION: ${{ inputs.stock_version }} + run: | + set -euo pipefail + node apps/server/scripts/prime-artifact-graduation.ts download-preview \ + --tag "$PREVIEW_TAG" \ + --artifact-directory "$RUNNER_TEMP/prime-preview" + node apps/server/scripts/prime-artifact-graduation.ts download-stock \ + --version "$STOCK_VERSION" \ + --stock-directory "$RUNNER_TEMP/prime-stock" + + - name: Download optional exact second preview + if: inputs.second_preview_tag != '' + env: + SECOND_PREVIEW_TAG: ${{ inputs.second_preview_tag }} + run: | + set -euo pipefail + node apps/server/scripts/prime-artifact-graduation.ts download-preview \ + --tag "$SECOND_PREVIEW_TAG" \ + --artifact-directory "$RUNNER_TEMP/prime-preview-second" + + - name: Verify downloaded publication before any preview extraction or execution + env: + PREVIEW_TAG: ${{ inputs.preview_tag }} + run: | + set -euo pipefail + node apps/server/scripts/prime-artifact-graduation.ts verify-preview \ + --tag "$PREVIEW_TAG" \ + --artifact-directory "$RUNNER_TEMP/prime-preview" \ + --tuf-cache "$RUNNER_TEMP/prime-sigstore-tuf" \ + --output "$RUNNER_TEMP/prime-graduation-results/verification.json" + + - name: Verify optional second publication before any preview extraction or execution + if: inputs.second_preview_tag != '' + env: + SECOND_PREVIEW_TAG: ${{ inputs.second_preview_tag }} + run: | + set -euo pipefail + node apps/server/scripts/prime-artifact-graduation.ts verify-preview \ + --tag "$SECOND_PREVIEW_TAG" \ + --artifact-directory "$RUNNER_TEMP/prime-preview-second" \ + --tuf-cache "$RUNNER_TEMP/prime-sigstore-tuf" \ + --output "$RUNNER_TEMP/prime-graduation-results/second-verification.json" + + - name: Install exact stock fixture without lifecycle scripts + env: + STOCK_VERSION: ${{ inputs.stock_version }} + run: | + set -euo pipefail + npm install \ + --prefix "$RUNNER_TEMP/prime-stock-install" \ + --ignore-scripts \ + --no-audit \ + --no-fund \ + --package-lock=false \ + "$RUNNER_TEMP/prime-stock/prime-agent-$STOCK_VERSION.tgz" + test -x "$PYLON_PRIME_AGENT_STOCK_ARTIFACT_BIN" + + - name: Run real bridge, managed store, restart, crash-receipt, and native multi evidence + run: | + set -euo pipefail + vp test run \ + apps/server/src/provider/prime/PrimeAgentArtifactGraduation.integration.test.ts \ + apps/server/src/provider/prime/PrimeAgentDaemonBridge.test.ts \ + apps/server/src/provider/Drivers/PrimeAgentDriver.test.ts \ + apps/server/src/provider/prime/PrimeAgentRestartAdoption.real.test.mjs \ + apps/server/src/provider/prime/PrimeAgentMultipleInstances.integration.test.ts \ + --no-file-parallelism \ + --maxWorkers=1 \ + --reporter=json \ + --outputFile="$RUNNER_TEMP/prime-graduation-results/vitest.json" + + - name: Assert the protected gate ran with zero skips + run: | + set -euo pipefail + node apps/server/scripts/prime-artifact-graduation.ts assert-results \ + --test-output "$RUNNER_TEMP/prime-graduation-results/vitest.json" \ + --verification "$RUNNER_TEMP/prime-graduation-results/verification.json" \ + --graduation-result "$RUNNER_TEMP/prime-graduation-results/cases.json" \ + --output "$RUNNER_TEMP/prime-graduation-results/graduation-summary.json" + + - name: Publish bounded secret-free job summary + env: + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + # shellcheck disable=SC2016 + node -e ' + const fs = require("node:fs"); + const result = JSON.parse(fs.readFileSync(process.env.RUNNER_TEMP + "/prime-graduation-results/graduation-summary.json", "utf8")); + const preview = result.graduation.preview.map((entry) => `- ${entry.tag}: ${entry.rootSha256}`).join("\n"); + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `# Prime artifact graduation\n\n**Result:** passed\n\n**Run URL (required for stable approval):** ${process.env.RUN_URL}\n\n## Verified preview roots\n${preview}\n\n**Tests:** ${result.tests.passed} passed, ${result.tests.skipped} skipped\n`); + ' + + - name: Upload bounded graduation evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: prime-artifact-graduation-${{ inputs.preview_tag }} + path: | + ${{ runner.temp }}/prime-graduation-results/verification.json + ${{ runner.temp }}/prime-graduation-results/second-verification.json + ${{ runner.temp }}/prime-graduation-results/cases.json + ${{ runner.temp }}/prime-graduation-results/graduation-summary.json + if-no-files-found: error + retention-days: 30 + compression-level: 9 diff --git a/apps/server/scripts/prime-artifact-graduation.ts b/apps/server/scripts/prime-artifact-graduation.ts new file mode 100644 index 000000000..f332d0bdf --- /dev/null +++ b/apps/server/scripts/prime-artifact-graduation.ts @@ -0,0 +1,371 @@ +#!/usr/bin/env node +// @effect-diagnostics globalFetch:off +// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalConsole:off +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as Schema from "effect/Schema"; + +import { + PRIME_DISTRIBUTION_REPOSITORY, + PRIME_DISTRIBUTION_REPOSITORY_URL, + PRIME_GRADUATION_ASSETS_DIRECTORY, + PRIME_GRADUATION_ATTESTATIONS, + PRIME_GRADUATION_COMMIT_METADATA, + PRIME_GRADUATION_PREVIEW_WORKFLOW, + PRIME_GRADUATION_RELEASE_METADATA, + PRIME_PREVIEW_MANIFEST, + PRIME_PREVIEW_WORKFLOW, + PRIME_RELEASE_MANIFEST, + verifyPrimePublicationArtifactDirectory, +} from "../src/provider/prime/PrimeAgentDistributionVerifier.ts"; + +const MAX_JSON_BYTES = 4 * 1024 * 1024; +const MAX_ASSET_BYTES = 256 * 1024 * 1024; +const FETCH_TIMEOUT_MS = 30_000; +const PREVIEW_TAG = /^pylon-build-g[0-9a-f]{12}-r[1-9][0-9]*$/u; +const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; +const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; +const SHA256 = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/u)); +const GIT_SHA = Schema.String.check(Schema.isPattern(/^[0-9a-f]{40}$/u)); +const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)); +const GitHubRelease = Schema.Struct({ + id: PositiveInt, + tag_name: Schema.String, + draft: Schema.Boolean, + prerelease: Schema.Boolean, + immutable: Schema.Boolean, + assets: Schema.Array( + Schema.Struct({ + id: PositiveInt, + name: Schema.String, + size: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + browser_download_url: Schema.String, + digest: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), +}); +const ReleaseManifest = Schema.Struct({ + source: Schema.Struct({ repository: Schema.String, commit: GIT_SHA, tree: GIT_SHA }), + build: Schema.Struct({ id: Schema.String }), + assets: Schema.Array( + Schema.Struct({ + package: Schema.String, + file: Schema.String, + size: Schema.Int.check(Schema.isGreaterThan(0)), + sha256: SHA256, + sha512: Schema.String.check(Schema.isPattern(/^[0-9a-f]{128}$/u)), + }), + ), +}); +const VitestJson = Schema.Struct({ + numTotalTests: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + numPassedTests: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + numFailedTests: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + numPendingTests: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + success: Schema.Boolean, +}); +const decodeGitHubRelease = Schema.decodeUnknownSync(GitHubRelease); +const decodeReleaseManifest = Schema.decodeUnknownSync(ReleaseManifest); +const decodeVitestJson = Schema.decodeUnknownSync(VitestJson); + +function flag(name: string, required = true): string | undefined { + const index = process.argv.indexOf(`--${name}`); + const value = index < 0 ? undefined : process.argv[index + 1]; + if (required && (!value || value.startsWith("--"))) { + throw new Error(`Missing --${name}.`); + } + return value; +} + +function sha256(bytes: NodeJS.ArrayBufferView): string { + return NodeCrypto.createHash("sha256").update(bytes).digest("hex"); +} + +async function fetchBounded(url: string, maxBytes: number, accept: string): Promise { + const allowed = new Set([ + "api.github.com", + "github.com", + "objects.githubusercontent.com", + "release-assets.githubusercontent.com", + "raw.githubusercontent.com", + "registry.npmjs.org", + ]); + const parsed = new URL(url); + if (parsed.protocol !== "https:" || !allowed.has(parsed.hostname)) { + throw new Error("Artifact graduation rejected an untrusted download origin."); + } + const response = await fetch(url, { + headers: { accept, "user-agent": "pylon-prime-artifact-graduation" }, + redirect: "follow", + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + const final = new URL(response.url); + if (final.protocol !== "https:" || !allowed.has(final.hostname)) { + throw new Error("Artifact graduation followed an untrusted redirect."); + } + if (!response.ok || !response.body) { + throw new Error(`Artifact graduation download failed with HTTP ${response.status}.`); + } + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && declared > maxBytes) { + throw new Error("Artifact graduation download exceeds its bounded size."); + } + const chunks: Buffer[] = []; + let size = 0; + const reader = response.body.getReader(); + while (true) { + const next = await reader.read(); + if (next.done) break; + size += next.value.byteLength; + if (size > maxBytes) { + await reader.cancel(); + throw new Error("Artifact graduation download exceeds its bounded size."); + } + chunks.push(Buffer.from(next.value)); + } + return Buffer.concat(chunks, size); +} + +async function writeExclusive(path: string, bytes: Buffer): Promise { + const handle = await NodeFSP.open( + path, + NodeFS.constants.O_WRONLY | NodeFS.constants.O_CREAT | NodeFS.constants.O_EXCL, + 0o600, + ); + try { + await handle.writeFile(bytes); + await handle.sync(); + } finally { + await handle.close(); + } +} + +function parseJson(bytes: Buffer): unknown { + return JSON.parse(bytes.toString("utf8")) as unknown; +} + +async function makeEmptyDirectory(path: string): Promise { + const requested = NodePath.resolve(path); + const parent = await NodeFSP.realpath(NodePath.dirname(requested)); + const absolute = NodePath.join(parent, NodePath.basename(requested)); + await NodeFSP.mkdir(absolute, { recursive: false, mode: 0o700 }); + if ((await NodeFSP.realpath(absolute)) !== absolute) { + throw new Error("Artifact graduation output directory is not canonical."); + } + return absolute; +} + +async function downloadPreview(): Promise { + const tag = flag("tag")!; + const destination = await makeEmptyDirectory(flag("artifact-directory")!); + if (!PREVIEW_TAG.test(tag)) throw new Error("Preview tag is not one exact immutable build tag."); + const releaseBytes = await fetchBounded( + `https://api.github.com/repos/${PRIME_DISTRIBUTION_REPOSITORY}/releases/tags/${tag}`, + MAX_JSON_BYTES, + "application/vnd.github+json", + ); + const release = decodeGitHubRelease(parseJson(releaseBytes)); + if (release.tag_name !== tag || release.draft || !release.prerelease || !release.immutable) { + throw new Error("Preview release is not exact, immutable, and public prerelease material."); + } + if (release.assets.length < 1 || release.assets.length > 12) { + throw new Error("Preview release has an unbounded asset set."); + } + const names = new Set(); + const assetsDirectory = NodePath.join(destination, PRIME_GRADUATION_ASSETS_DIRECTORY); + await NodeFSP.mkdir(assetsDirectory, { mode: 0o700 }); + await writeExclusive(NodePath.join(destination, PRIME_GRADUATION_RELEASE_METADATA), releaseBytes); + for (const asset of release.assets) { + const expectedUrl = `${PRIME_DISTRIBUTION_REPOSITORY_URL}/releases/download/${tag}/${asset.name}`; + if ( + !SAFE_NAME.test(asset.name) || + names.has(asset.name) || + asset.browser_download_url !== expectedUrl || + asset.size < 1 || + asset.size > MAX_ASSET_BYTES || + typeof asset.digest !== "string" || + !/^sha256:[0-9a-f]{64}$/u.test(asset.digest) + ) { + throw new Error("Preview release asset name, URL, or size is not exact."); + } + names.add(asset.name); + const bytes = await fetchBounded(expectedUrl, MAX_ASSET_BYTES, "application/octet-stream"); + if (bytes.byteLength !== asset.size || asset.digest !== `sha256:${sha256(bytes)}`) { + throw new Error("Preview release asset size or GitHub digest changed."); + } + await writeExclusive(NodePath.join(assetsDirectory, asset.name), bytes); + } + if (!names.has(PRIME_RELEASE_MANIFEST) || !names.has(PRIME_PREVIEW_MANIFEST)) { + throw new Error("Preview release omits a required signed manifest."); + } + const releaseManifestBytes = await NodeFSP.readFile( + NodePath.join(assetsDirectory, PRIME_RELEASE_MANIFEST), + ); + const previewManifestBytes = await NodeFSP.readFile( + NodePath.join(assetsDirectory, PRIME_PREVIEW_MANIFEST), + ); + const manifest = decodeReleaseManifest(parseJson(releaseManifestBytes)); + if ( + manifest.source.repository !== PRIME_DISTRIBUTION_REPOSITORY_URL || + manifest.build.id !== tag || + manifest.assets.length + 2 !== names.size + ) { + throw new Error("Preview build manifest does not bind the requested release."); + } + const manifestNames = new Set(manifest.assets.map((asset) => asset.file)); + if ( + manifestNames.size !== manifest.assets.length || + [...manifestNames].some((name) => !names.has(name)) + ) { + throw new Error("Preview build manifest asset set is not exact."); + } + for (const asset of manifest.assets) { + const bytes = await NodeFSP.readFile(NodePath.join(assetsDirectory, asset.file)); + if ( + bytes.byteLength !== asset.size || + sha256(bytes) !== asset.sha256 || + NodeCrypto.createHash("sha512").update(bytes).digest("hex") !== asset.sha512 + ) { + throw new Error("Preview asset digest does not match its build manifest."); + } + } + const attestationBytes = await fetchBounded( + `https://api.github.com/repos/${PRIME_DISTRIBUTION_REPOSITORY}/attestations/sha256:${sha256(previewManifestBytes)}?predicate_type=${encodeURIComponent("https://slsa.dev/provenance/v1")}`, + MAX_JSON_BYTES, + "application/vnd.github+json", + ); + const commitBytes = await fetchBounded( + `https://api.github.com/repos/${PRIME_DISTRIBUTION_REPOSITORY}/git/commits/${manifest.source.commit}`, + MAX_JSON_BYTES, + "application/vnd.github+json", + ); + const workflowBytes = await fetchBounded( + `https://raw.githubusercontent.com/${PRIME_DISTRIBUTION_REPOSITORY}/${manifest.source.commit}/${PRIME_PREVIEW_WORKFLOW}`, + MAX_JSON_BYTES, + "application/octet-stream", + ); + await writeExclusive(NodePath.join(destination, PRIME_GRADUATION_ATTESTATIONS), attestationBytes); + await writeExclusive(NodePath.join(destination, PRIME_GRADUATION_COMMIT_METADATA), commitBytes); + await writeExclusive( + NodePath.join(destination, PRIME_GRADUATION_PREVIEW_WORKFLOW), + workflowBytes, + ); + console.log(`Downloaded immutable Prime preview ${tag}.`); +} + +async function downloadStock(): Promise { + const version = flag("version")!; + const destination = await makeEmptyDirectory(flag("stock-directory")!); + if (!VERSION.test(version)) throw new Error("Stock Prime version is not exact."); + const repository = "PrimeIntellect-ai/prime-agent"; + const tag = `v${version}`; + const assetName = `prime-agent-${version}.tgz`; + const expectedTarball = `https://github.com/${repository}/releases/download/${tag}/${assetName}`; + const releaseBytes = await fetchBounded( + `https://api.github.com/repos/${repository}/releases/tags/${tag}`, + MAX_JSON_BYTES, + "application/vnd.github+json", + ); + const release = decodeGitHubRelease(parseJson(releaseBytes)); + if (release.tag_name !== tag || release.draft || release.prerelease) { + throw new Error("Stock Prime release metadata is not the exact requested version."); + } + const matches = release.assets.filter((asset) => asset.name === assetName); + const asset = matches[0]; + if ( + matches.length !== 1 || + !asset || + asset.browser_download_url !== expectedTarball || + typeof asset.digest !== "string" || + !/^sha256:[0-9a-f]{64}$/u.test(asset.digest) + ) { + throw new Error("Stock Prime release has no exact digest-bearing root package."); + } + const tarball = await fetchBounded(expectedTarball, MAX_ASSET_BYTES, "application/octet-stream"); + if (asset.size !== tarball.byteLength || asset.digest !== `sha256:${sha256(tarball)}`) { + throw new Error("Stock Prime tarball does not match its exact GitHub asset digest."); + } + await writeExclusive(NodePath.join(destination, "github-release.json"), releaseBytes); + await writeExclusive(NodePath.join(destination, assetName), tarball); + console.log(`Downloaded exact stock Prime ${version}.`); +} + +async function verifyPreview(): Promise { + const tag = flag("tag")!; + const verified = await verifyPrimePublicationArtifactDirectory({ + tag, + artifactDirectory: flag("artifact-directory")!, + tufCachePath: flag("tuf-cache")!, + }); + const summary = { + schemaVersion: 1, + status: "verified", + repository: PRIME_DISTRIBUTION_REPOSITORY, + tag: verified.publication.buildId, + sequence: verified.publication.sequence, + sourceCommit: verified.publication.sourceCommit, + sourceTree: verified.publication.sourceTree, + rootSha256: verified.publication.rootSha256, + recipeRevision: verified.publication.recipeRevision, + assets: verified.assetDigests, + }; + await writeExclusive( + NodePath.resolve(flag("output")!), + Buffer.from(`${JSON.stringify(summary, null, 2)} +`), + ); + console.log(`Verified immutable Prime preview ${tag} with server-owned Sigstore policy.`); +} + +async function assertResults(): Promise { + const testOutput = decodeVitestJson( + parseJson(await NodeFSP.readFile(NodePath.resolve(flag("test-output")!))), + ); + if ( + !testOutput.success || + testOutput.numTotalTests < 4 || + testOutput.numPassedTests !== testOutput.numTotalTests || + testOutput.numFailedTests !== 0 || + testOutput.numPendingTests !== 0 + ) { + throw new Error("Prime artifact graduation requires all real proof tests and zero skips."); + } + const verification = parseJson(await NodeFSP.readFile(NodePath.resolve(flag("verification")!))); + const graduation = parseJson( + await NodeFSP.readFile(NodePath.resolve(flag("graduation-result")!)), + ); + const summary = { + schemaVersion: 1, + status: "passed", + verification, + graduation, + tests: { + total: testOutput.numTotalTests, + passed: testOutput.numPassedTests, + failed: 0, + skipped: 0, + }, + }; + await writeExclusive( + NodePath.resolve(flag("output")!), + Buffer.from(`${JSON.stringify(summary, null, 2)} +`), + ); + console.log( + `Prime artifact graduation passed ${testOutput.numPassedTests} tests with zero skips.`, + ); +} + +async function main(): Promise { + const command = process.argv[2]; + if (command === "download-preview") return await downloadPreview(); + if (command === "download-stock") return await downloadStock(); + if (command === "verify-preview") return await verifyPreview(); + if (command === "assert-results") return await assertResults(); + throw new Error("Expected download-preview, download-stock, verify-preview, or assert-results."); +} + +await main(); diff --git a/apps/server/src/provider/prime/PrimeAgentArtifactGraduation.integration.test.ts b/apps/server/src/provider/prime/PrimeAgentArtifactGraduation.integration.test.ts new file mode 100644 index 000000000..09d9cc4c8 --- /dev/null +++ b/apps/server/src/provider/prime/PrimeAgentArtifactGraduation.integration.test.ts @@ -0,0 +1,228 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; + +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { makePrimeArtifactGraduationHarness } from "./PrimeAgentArtifactGraduation.test-fixture.ts"; +import { loadPrimeAgentDaemonBridge } from "./PrimeAgentDaemonBridge.ts"; +import { PRIME_MANAGED_TOOL_DIRECTORY } from "./PrimeAgentManagedToolStore.ts"; + +const execFile = NodeUtil.promisify(NodeChildProcess.execFile); +const required = process.env.PYLON_PRIME_GRADUATION_REQUIRED === "1"; +const artifactDirectory = process.env.PYLON_PRIME_ARTIFACT_DIR?.trim(); +const previewTag = process.env.PYLON_PRIME_PREVIEW_TAG?.trim(); +const secondArtifactDirectory = process.env.PYLON_PRIME_SECOND_ARTIFACT_DIR?.trim(); +const secondPreviewTag = process.env.PYLON_PRIME_SECOND_PREVIEW_TAG?.trim(); +const stockBinaryPath = process.env.PYLON_PRIME_AGENT_STOCK_ARTIFACT_BIN?.trim(); +const stockTarballPath = process.env.PYLON_PRIME_STOCK_TARBALL?.trim(); +const resultPath = process.env.PYLON_PRIME_GRADUATION_RESULT?.trim(); +const configured = Boolean( + artifactDirectory && previewTag && stockBinaryPath && stockTarballPath && resultPath, +); + +if (required && !configured) { + throw new Error( + "Prime artifact graduation requires the exact preview fixture, stock fixture, and result destination.", + ); +} +if (required && Boolean(secondArtifactDirectory) !== Boolean(secondPreviewTag)) { + throw new Error("Prime artifact graduation requires both inputs for an optional second build."); +} + +function digest(bytes: NodeJS.ArrayBufferView | string): string { + return NodeCrypto.createHash("sha256").update(bytes).digest("hex"); +} + +async function treeDigest(root: string): Promise { + const hash = NodeCrypto.createHash("sha256"); + const visit = async (relative: string): Promise => { + const absolute = relative ? NodePath.join(root, relative) : root; + const entries = await NodeFSP.readdir(absolute, { withFileTypes: true }); + for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) { + const child = relative ? NodePath.join(relative, entry.name) : entry.name; + hash.update(child.split(NodePath.sep).join("/")); + if (entry.isDirectory()) { + hash.update("directory\0"); + await visit(child); + } else if (entry.isSymbolicLink()) { + hash.update("symlink\0"); + hash.update(await NodeFSP.readlink(NodePath.join(root, child))); + } else if (entry.isFile()) { + hash.update("file\0"); + hash.update(await NodeFSP.readFile(NodePath.join(root, child))); + } else { + throw new Error("Stock Prime fixture contains an unsupported filesystem entry."); + } + } + }; + await visit(""); + return hash.digest("hex"); +} + +it.skipIf(!configured)( + "graduates exact signed Prime bytes through install, use, update, rollback, stock, and cleanup", + async () => { + const stateDir = await NodeFSP.realpath( + await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pylon-prime-graduation-")), + ); + try { + const stockRoot = NodePath.resolve(stockBinaryPath!, "../../.."); + const stockTarballBefore = await NodeFSP.readFile(stockTarballPath!); + const stockTreeBefore = await treeDigest(stockRoot); + const harness = await makePrimeArtifactGraduationHarness({ + stateDir, + artifactDirectory: artifactDirectory!, + previewTag: previewTag!, + stockBinaryPath: stockBinaryPath!, + // eslint-disable-next-line t3code/no-global-process-runtime -- This opt-in host proof passes its actual POSIX runner platform into the production store. + platform: process.platform, + ...(secondArtifactDirectory ? { secondArtifactDirectory } : {}), + ...(secondPreviewTag ? { secondPreviewTag } : {}), + }); + + // eslint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- This one opt-in proof combines the Promise-owned store lifecycle with the bridge Effect. + const stockBridge = await Effect.runPromise(loadPrimeAgentDaemonBridge(stockBinaryPath!)); + expect(stockBridge.version).toBe("0.8.1"); + expect(stockBridge.negotiatedDaemonSessionCapabilitiesAvailable).toBe(false); + + const install = await harness.command({ + commandId: "graduation-install-preview", + action: "install", + channel: "preview", + allowPreview: true, + scheduleIfBusy: false, + }); + expect(install).toMatchObject({ + status: "succeeded", + buildId: harness.artifacts[0]!.publication.buildId, + }); + const installedStatus = await harness.status(); + expect(installedStatus.mode).toBe("managed"); + const installed = installedStatus.availableBuilds.find( + (build) => build.buildId === harness.artifacts[0]!.publication.buildId, + ); + expect(installed).toBeDefined(); + // eslint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- The verified store returns its launcher from the same Promise-owned lifecycle. + const previewBridge = await Effect.runPromise( + loadPrimeAgentDaemonBridge(installed!.binaryPath), + ); + expect(previewBridge.negotiatedDaemonSessionCapabilitiesAvailable).toBe(true); + const version = await execFile(installed!.binaryPath, ["--version"], { + timeout: 30_000, + maxBuffer: 256 * 1024, + windowsHide: true, + }); + expect(version.stdout).toContain(harness.artifacts[0]!.publication.packageVersion); + + let rollbackBuildId = harness.artifacts[0]!.publication.buildId; + if (harness.artifacts.length === 2) { + harness.useArtifact(1); + const update = await harness.command({ + commandId: "graduation-update-second-preview", + action: "update", + channel: "preview", + allowPreview: true, + scheduleIfBusy: false, + }); + expect(update).toMatchObject({ + status: "succeeded", + buildId: harness.artifacts[1]!.publication.buildId, + }); + } else { + const update = await harness.command({ + commandId: "graduation-update-exact-no-op", + action: "update", + channel: "preview", + allowPreview: true, + scheduleIfBusy: false, + }); + expect(update).toMatchObject({ status: "succeeded", buildId: rollbackBuildId }); + expect((await harness.status()).availableBuilds).toHaveLength(1); + } + + const rollback = await harness.command({ + commandId: "graduation-explicit-rollback", + action: "rollback", + buildId: rollbackBuildId, + scheduleIfBusy: false, + }); + expect(rollback).toMatchObject({ status: "succeeded", buildId: rollbackBuildId }); + expect(harness.binding().binaryPath).toContain(rollbackBuildId); + + const stock = await harness.command({ + commandId: "graduation-use-stock", + action: "use-stock", + scheduleIfBusy: false, + }); + expect(stock).toMatchObject({ status: "succeeded", buildId: null }); + expect(harness.binding().binaryPath).toBe(NodePath.resolve(stockBinaryPath!)); + + const managedRoot = NodePath.join(stateDir, ...PRIME_MANAGED_TOOL_DIRECTORY.split("/")); + const unowned = NodePath.join(managedRoot, "not-receipt-owned"); + await NodeFSP.mkdir(unowned, { mode: 0o700 }); + await NodeFSP.writeFile(NodePath.join(unowned, "sentinel"), "must remain\n", { mode: 0o600 }); + const cleanup = await harness.command({ + commandId: "graduation-cleanup", + action: "cleanup", + }); + expect(cleanup.status).toBe("succeeded"); + expect((await harness.status()).availableBuilds).toEqual([]); + await expect(NodeFSP.readFile(NodePath.join(unowned, "sentinel"), "utf8")).resolves.toBe( + "must remain\n", + ); + + const stockTarballAfter = await NodeFSP.readFile(stockTarballPath!); + expect(digest(stockTarballAfter)).toBe(digest(stockTarballBefore)); + expect(await treeDigest(stockRoot)).toBe(stockTreeBefore); + for (const receipt of [install, rollback, stock, cleanup]) { + expect(JSON.stringify(receipt)).not.toContain(stateDir); + expect(JSON.stringify(receipt)).not.toContain(artifactDirectory!); + expect(JSON.stringify(receipt)).not.toContain(stockRoot); + } + + const result = { + schemaVersion: 1, + status: "passed", + stockVersion: "0.8.1", + stockSha256: digest(stockTarballBefore), + preview: harness.artifacts.map((artifact) => ({ + tag: artifact.publication.buildId, + sequence: artifact.publication.sequence, + sourceCommit: artifact.publication.sourceCommit, + sourceTree: artifact.publication.sourceTree, + rootSha256: artifact.publication.rootSha256, + assets: artifact.assetDigests, + })), + cases: [ + "stock-bridge", + "signed-preview-capability", + "side-by-side-install", + "preview-start", + harness.artifacts.length === 2 ? "second-build-update" : "exact-update-no-op", + "rollback", + "use-stock", + "stock-bytes-unchanged", + "receipt-owned-cleanup", + ], + }; + await NodeFSP.mkdir(NodePath.dirname(resultPath!), { recursive: true }); + await NodeFSP.writeFile( + resultPath!, + `${JSON.stringify(result, null, 2)} +`, + { + mode: 0o600, + }, + ); + } finally { + await NodeFSP.rm(stateDir, { recursive: true, force: true }); + } + }, + 300_000, +); diff --git a/apps/server/src/provider/prime/PrimeAgentArtifactGraduation.test-fixture.ts b/apps/server/src/provider/prime/PrimeAgentArtifactGraduation.test-fixture.ts new file mode 100644 index 000000000..273e80d40 --- /dev/null +++ b/apps/server/src/provider/prime/PrimeAgentArtifactGraduation.test-fixture.ts @@ -0,0 +1,128 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import { + verifyPrimePublicationArtifactDirectory, + type PrimeGraduationVerifiedArtifact, +} from "./PrimeAgentDistributionVerifier.ts"; +import { + PrimeAgentManagedToolStore, + type PrimeManagedBinding, + type PrimeManagedCommandInput, + type PrimeManagedCommandReceipt, + type PrimeManagedInstanceStatus, + type PrimeManagedPublicationBundle, +} from "./PrimeAgentManagedToolStore.ts"; + +const INSTANCE_ID = "prime-artifact-graduation"; + +export interface PrimeArtifactGraduationHarness { + readonly artifacts: ReadonlyArray; + readonly store: PrimeAgentManagedToolStore; + readonly instanceId: string; + readonly stockBinaryPath: string; + useArtifact(index: number): void; + binding(): PrimeManagedBinding; + command(input: Omit): Promise; + status(): Promise; +} + +export async function makePrimeArtifactGraduationHarness(input: { + readonly stateDir: string; + readonly artifactDirectory: string; + readonly previewTag: string; + readonly stockBinaryPath: string; + readonly platform: NodeJS.Platform; + readonly secondArtifactDirectory?: string; + readonly secondPreviewTag?: string; +}): Promise { + const stateDir = await NodeFSP.realpath(NodePath.resolve(input.stateDir)); + const stockBinaryPath = NodePath.resolve(input.stockBinaryPath); + const first = await verifyPrimePublicationArtifactDirectory({ + tag: input.previewTag, + artifactDirectory: input.artifactDirectory, + tufCachePath: NodePath.join(stateDir, "sigstore-tuf"), + }); + const artifacts = [first]; + if (input.secondArtifactDirectory || input.secondPreviewTag) { + if (!input.secondArtifactDirectory || !input.secondPreviewTag) { + throw new Error( + "The optional second Prime graduation build requires both tag and directory.", + ); + } + artifacts.push( + await verifyPrimePublicationArtifactDirectory({ + tag: input.secondPreviewTag, + artifactDirectory: input.secondArtifactDirectory, + tufCachePath: NodePath.join(stateDir, "sigstore-tuf"), + }), + ); + } + + let artifactIndex = 0; + let revision = 0; + let currentBinding: PrimeManagedBinding = { + binaryPath: stockBinaryPath, + generation: `graduation-${revision}`, + }; + let reservation = 0; + const store = new PrimeAgentManagedToolStore({ + stateDir, + platform: input.platform, + dependencies: { + loadLatestVerifiedPublication: async (): Promise => { + const selected = artifacts[artifactIndex]; + if (!selected) throw new Error("The selected Prime graduation artifact is unavailable."); + return { + publication: selected.publication, + rootArtifactBytes: selected.rootArtifactBytes, + }; + }, + readBinding: async () => currentBinding, + listBindings: async () => [{ instanceId: INSTANCE_ID, binding: currentBinding }], + listOwnedRuntimeBuildReferences: async () => [], + reserveQuiescentBinding: async (_instanceId, expected) => { + if ( + expected.binaryPath !== currentBinding.binaryPath || + expected.generation !== currentBinding.generation + ) { + return { status: "busy" as const, reasons: ["binding changed"] }; + } + reservation += 1; + return { + status: "reserved" as const, + reservation: { token: `reservation-${reservation}` }, + }; + }, + commitBinding: async ({ expected, binaryPath }) => { + if ( + expected.binaryPath !== currentBinding.binaryPath || + expected.generation !== currentBinding.generation + ) { + throw new Error("Prime graduation binding changed before commit."); + } + revision += 1; + currentBinding = { binaryPath, generation: `graduation-${revision}` }; + return currentBinding; + }, + releaseReservation: async () => {}, + now: () => `2026-09-01T00:00:${String(revision).padStart(2, "0")}.000Z`, + }, + }); + await store.initialize(); + + return { + artifacts, + store, + instanceId: INSTANCE_ID, + stockBinaryPath, + useArtifact(index) { + if (!artifacts[index]) throw new Error("The requested Prime graduation artifact is absent."); + artifactIndex = index; + }, + binding: () => currentBinding, + command: (command) => store.command({ ...command, instanceId: INSTANCE_ID }), + status: () => store.status(INSTANCE_ID), + }; +} diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonBridge.test.ts b/apps/server/src/provider/prime/PrimeAgentDaemonBridge.test.ts index d9892c754..68f3d63e9 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonBridge.test.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonBridge.test.ts @@ -1,11 +1,14 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import { afterEach, describe, expect, it } from "@effect/vitest"; +import { makePrimeArtifactGraduationHarness } from "./PrimeAgentArtifactGraduation.test-fixture.ts"; import { isPathInside, loadPrimeAgentDaemonBridge, @@ -16,9 +19,14 @@ import { } from "./PrimeAgentDaemonBridge.ts"; const temporaryDirectories: Array = []; -const configuredNegotiatedProofArtifactBinary = - process.env.PYLON_PRIME_AGENT_NEGOTIATED_PROOF_ARTIFACT_BIN?.trim(); +const configuredGraduationArtifactDirectory = process.env.PYLON_PRIME_ARTIFACT_DIR?.trim(); +const configuredGraduationPreviewTag = process.env.PYLON_PRIME_PREVIEW_TAG?.trim(); const configuredStockArtifactBinary = process.env.PYLON_PRIME_AGENT_STOCK_ARTIFACT_BIN?.trim(); +const configuredNegotiatedProofArtifact = Boolean( + configuredGraduationArtifactDirectory && + configuredGraduationPreviewTag && + configuredStockArtifactBinary, +); function makeTemporaryDirectory(): string { const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "pylon-prime-bridge-")); @@ -420,11 +428,37 @@ export class DaemonClient`, }), ); - it.effect.skipIf(!configuredNegotiatedProofArtifactBinary)( - "loads the pinned negotiated-proof artifact through the public package bridge", + it.effect.skipIf(!configuredNegotiatedProofArtifact)( + "loads the verified graduation artifact through the public package bridge", () => Effect.gen(function* () { - const bridge = yield* loadPrimeAgentDaemonBridge(configuredNegotiatedProofArtifactBinary!); + const stateDir = yield* Effect.promise(() => + NodeFSP.realpath( + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "pylon-prime-bridge-graduation-")), + ), + ); + temporaryDirectories.push(stateDir); + const platform = yield* HostProcessPlatform; + const harness = yield* Effect.promise(() => + makePrimeArtifactGraduationHarness({ + stateDir, + artifactDirectory: configuredGraduationArtifactDirectory!, + previewTag: configuredGraduationPreviewTag!, + stockBinaryPath: configuredStockArtifactBinary!, + platform, + }), + ); + const installed = yield* Effect.promise(() => + harness.command({ + commandId: "bridge-graduation-install", + action: "install", + channel: "preview", + allowPreview: true, + scheduleIfBusy: false, + }), + ); + expect(installed.status).toBe("succeeded"); + const bridge = yield* loadPrimeAgentDaemonBridge(harness.binding().binaryPath); expect(bridge.version).toBe("0.8.1"); expect(bridge.protocolVersion).toBe(PRIME_AGENT_MIN_DAEMON_PROTOCOL_VERSION); diff --git a/apps/server/src/provider/prime/PrimeAgentDistributionVerifier.test.ts b/apps/server/src/provider/prime/PrimeAgentDistributionVerifier.test.ts index b9bfdf25b..384d8d441 100644 --- a/apps/server/src/provider/prime/PrimeAgentDistributionVerifier.test.ts +++ b/apps/server/src/provider/prime/PrimeAgentDistributionVerifier.test.ts @@ -35,6 +35,7 @@ import { type PrimePublicationFixture, type PrimeSlsaStatement, type VerifiedPrimePublication, + verifyPrimePublicationArtifactDirectory, verifyPrimePublicationFixture, } from "./PrimeAgentDistributionVerifier.ts"; @@ -778,6 +779,21 @@ describe("Pylon Prime publication verification", () => { requireRealPrimePublicationFixture({ tag: BUILD_ID, artifactDirectory: "/tmp/prime-proof" }), ).toEqual({ tag: BUILD_ID, artifactDirectory: "/tmp/prime-proof" }); }); + + it("rejects an incomplete downloaded artifact fixture before trust or archive use", async () => { + const directory = await NodeFSP.realpath( + await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pylon-prime-artifact-fixture-")), + ); + temporaryDirectories.push(directory); + + await expect( + verifyPrimePublicationArtifactDirectory({ + tag: BUILD_ID, + artifactDirectory: directory, + tufCachePath: NodePath.join(directory, "tuf"), + }), + ).rejects.toThrow(/fixture root set is not exact/u); + }); }); describe("Pylon Prime distribution classification and advisory", () => { diff --git a/apps/server/src/provider/prime/PrimeAgentDistributionVerifier.ts b/apps/server/src/provider/prime/PrimeAgentDistributionVerifier.ts index 0168d5064..c4db77e03 100644 --- a/apps/server/src/provider/prime/PrimeAgentDistributionVerifier.ts +++ b/apps/server/src/provider/prime/PrimeAgentDistributionVerifier.ts @@ -26,6 +26,11 @@ export const PRIME_STABLE_MANIFEST = "pylon-stable-channel-v1.json"; export const PRIME_RECEIPT_FILE = "managed-receipt-v1.json"; export const PRIME_HIGH_WATER_FILE = "channel-high-water-v1.json"; export const PRIME_RECEIPT_KEY_FILE = "receipt-auth-v1.key"; +export const PRIME_GRADUATION_RELEASE_METADATA = "github-release.json"; +export const PRIME_GRADUATION_COMMIT_METADATA = "github-commit.json"; +export const PRIME_GRADUATION_ATTESTATIONS = "github-attestations.json"; +export const PRIME_GRADUATION_PREVIEW_WORKFLOW = "pylon-preview-release.yml"; +export const PRIME_GRADUATION_ASSETS_DIRECTORY = "assets"; const GITHUB_REPOSITORY_ID = "1349002285"; const GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com"; @@ -319,6 +324,7 @@ const GitHubReleaseAssetSchema = Schema.Struct({ name: Schema.String, size: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), browser_download_url: Schema.String, + digest: Schema.optional(Schema.NullOr(Schema.String)), }); const GitHubReleaseSchema = Schema.Struct({ id: POSITIVE_INT, @@ -1999,6 +2005,305 @@ export function makeLatestPrimePublicationBundleLoader( }; } +const PRIME_GRADUATION_ASSET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; + +async function readPrimeGraduationFixtureFile( + root: string, + relativePath: string, + maxBytes: number, +): Promise { + const parts = relativePath.split("/"); + if ( + parts.length < 1 || + parts.some( + (part) => !part || part === "." || part === ".." || !PRIME_GRADUATION_ASSET_NAME.test(part), + ) + ) { + throw new Error("Prime graduation fixture contains an unsafe file name."); + } + const path = NodePath.join(root, ...parts); + if (NodePath.relative(root, path).startsWith("..")) { + throw new Error("Prime graduation fixture file escapes its root."); + } + const handle = await NodeFSP.open( + path, + NodeFS.constants.O_RDONLY | (NodeFS.constants.O_NOFOLLOW ?? 0), + ); + try { + const before = await handle.stat({ bigint: true }); + if (!before.isFile() || before.size < 1n || before.size > BigInt(maxBytes)) { + throw new Error("Prime graduation fixture file exceeds its bounded size."); + } + const bytes = Buffer.alloc(Number(before.size)); + let offset = 0; + while (offset < bytes.byteLength) { + const read = await handle.read(bytes, offset, bytes.byteLength - offset, offset); + if (read.bytesRead === 0) throw new Error("Prime graduation fixture file was truncated."); + offset += read.bytesRead; + } + const after = await handle.stat({ bigint: true }); + const pathAfter = await NodeFSP.lstat(path, { bigint: true }); + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + before.mtimeNs !== after.mtimeNs || + after.dev !== pathAfter.dev || + after.ino !== pathAfter.ino || + !pathAfter.isFile() + ) { + throw new Error("Prime graduation fixture file changed while it was read."); + } + return bytes; + } finally { + await handle.close(); + } +} + +function parsePrimeGraduationJson(bytes: Buffer, label: string): unknown { + try { + return JSON.parse(bytes.toString("utf8")) as unknown; + } catch (cause) { + throw new Error(`${label} is not JSON.`, { cause }); + } +} + +export interface PrimeGraduationVerifiedArtifact { + readonly publication: VerifiedPrimePublication; + readonly rootArtifactBytes: Buffer; + readonly assetDigests: ReadonlyArray<{ + readonly name: string; + readonly sha256: string; + readonly sha512: string; + readonly size: number; + }>; +} + +/** + * Re-verifies a fully downloaded immutable preview from runner-local bytes. This boundary performs + * the same Sigstore, source-policy, release-shape, and digest checks as the network loader before it + * returns any archive bytes to the managed tool store. + */ +export async function verifyPrimePublicationArtifactDirectory(input: { + readonly tag?: string; + readonly artifactDirectory?: string; + readonly tufCachePath?: string; +}): Promise { + const required = requireRealPrimePublicationFixture(input); + if (!/^pylon-build-g[0-9a-f]{12}-r[1-9][0-9]*$/u.test(required.tag)) { + throw new Error("Prime artifact graduation requires an immutable preview tag."); + } + const root = NodePath.resolve(required.artifactDirectory); + if ((await NodeFSP.realpath(root)) !== root) { + throw new Error("Prime graduation fixture directory is not canonical."); + } + const expectedRootEntries = new Set([ + PRIME_GRADUATION_RELEASE_METADATA, + PRIME_GRADUATION_COMMIT_METADATA, + PRIME_GRADUATION_ATTESTATIONS, + PRIME_GRADUATION_PREVIEW_WORKFLOW, + PRIME_GRADUATION_ASSETS_DIRECTORY, + ]); + const rootEntries = await NodeFSP.readdir(root, { withFileTypes: true }); + if ( + rootEntries.length !== expectedRootEntries.size || + rootEntries.some((entry) => { + const expectedDirectory = entry.name === PRIME_GRADUATION_ASSETS_DIRECTORY; + return ( + !expectedRootEntries.delete(entry.name) || + entry.isSymbolicLink() || + (expectedDirectory ? !entry.isDirectory() : !entry.isFile()) + ); + }) || + expectedRootEntries.size !== 0 + ) { + throw new Error("Prime graduation fixture root set is not exact."); + } + + const release = decodeGitHubRelease( + parsePrimeGraduationJson( + await readPrimeGraduationFixtureFile( + root, + PRIME_GRADUATION_RELEASE_METADATA, + MAX_RELEASE_RESPONSE_BYTES, + ), + "Prime graduation release metadata", + ), + ); + if ( + release.tag_name !== required.tag || + release.draft || + !release.prerelease || + !release.immutable + ) { + throw new Error("Prime graduation release is not the exact immutable preview tag."); + } + const releaseAssetNames = new Set(); + for (const asset of release.assets) { + if ( + !PRIME_GRADUATION_ASSET_NAME.test(asset.name) || + releaseAssetNames.has(asset.name) || + asset.browser_download_url !== + `${PRIME_DISTRIBUTION_REPOSITORY_URL}/releases/download/${required.tag}/${asset.name}` + ) { + throw new Error("Prime graduation release asset identity is not exact."); + } + releaseAssetNames.add(asset.name); + } + const assetDirectory = NodePath.join(root, PRIME_GRADUATION_ASSETS_DIRECTORY); + if ((await NodeFSP.realpath(assetDirectory)) !== assetDirectory) { + throw new Error("Prime graduation asset directory is not canonical."); + } + const localAssetEntries = await NodeFSP.readdir(assetDirectory, { withFileTypes: true }); + if ( + localAssetEntries.length !== releaseAssetNames.size || + localAssetEntries.some( + (entry) => entry.isSymbolicLink() || !entry.isFile() || !releaseAssetNames.delete(entry.name), + ) || + releaseAssetNames.size !== 0 + ) { + throw new Error("Prime graduation downloaded asset set is not exact."); + } + + const releaseManifestBytes = await readPrimeGraduationFixtureFile( + root, + `${PRIME_GRADUATION_ASSETS_DIRECTORY}/${PRIME_RELEASE_MANIFEST}`, + MAX_MANIFEST_BYTES, + ); + const previewManifestBytes = await readPrimeGraduationFixtureFile( + root, + `${PRIME_GRADUATION_ASSETS_DIRECTORY}/${PRIME_PREVIEW_MANIFEST}`, + MAX_MANIFEST_BYTES, + ); + for (const [name, bytes] of [ + [PRIME_RELEASE_MANIFEST, releaseManifestBytes], + [PRIME_PREVIEW_MANIFEST, previewManifestBytes], + ] as const) { + const metadata = releaseAsset(release, name); + if (metadata.size !== bytes.byteLength || metadata.digest !== `sha256:${sha256(bytes)}`) { + throw new Error("Prime graduation manifest bytes do not match GitHub asset metadata."); + } + } + const parsedRelease = parseReleaseManifest(releaseManifestBytes); + const expectedPreviewAssets = new Set([ + PRIME_RELEASE_MANIFEST, + PRIME_PREVIEW_MANIFEST, + ...parsedRelease.assets.map((asset) => asset.file), + ]); + if ( + release.assets.length !== expectedPreviewAssets.size || + release.assets.some((asset) => !expectedPreviewAssets.delete(asset.name)) || + expectedPreviewAssets.size !== 0 + ) { + throw new Error("Prime graduation release asset set does not match its build manifest."); + } + + const assetDigests: Array<{ + readonly name: string; + readonly sha256: string; + readonly sha512: string; + readonly size: number; + }> = []; + let rootArtifactBytes: Buffer | undefined; + for (const expected of parsedRelease.assets) { + const releaseAssetMetadata = releaseAsset(release, expected.file); + const bytes = await readPrimeGraduationFixtureFile( + root, + `${PRIME_GRADUATION_ASSETS_DIRECTORY}/${expected.file}`, + MAX_ROOT_ARTIFACT_BYTES, + ); + const sha256Digest = sha256(bytes); + const sha512Digest = sha512(bytes); + if ( + bytes.byteLength !== expected.size || + releaseAssetMetadata.size !== expected.size || + releaseAssetMetadata.digest !== `sha256:${sha256Digest}` || + sha256Digest !== expected.sha256 || + sha512Digest !== expected.sha512 + ) { + throw new Error("Prime graduation asset bytes do not match the signed build manifest."); + } + assetDigests.push({ + name: expected.file, + size: bytes.byteLength, + sha256: sha256Digest, + sha512: sha512Digest, + }); + if (expected.package === "prime-agent") rootArtifactBytes = bytes; + } + if (!rootArtifactBytes) throw new Error("Prime graduation fixture has no root Prime artifact."); + + const attestations = decodeGitHubAttestations( + parsePrimeGraduationJson( + await readPrimeGraduationFixtureFile( + root, + PRIME_GRADUATION_ATTESTATIONS, + MAX_ATTESTATION_RESPONSE_BYTES, + ), + "Prime graduation attestations", + ), + ).attestations.map((entry) => entry.bundle); + const subjectDigests = [ + ...parsedRelease.assets.map((asset) => asset.sha256), + sha256(releaseManifestBytes), + sha256(previewManifestBytes), + ]; + const fixture: PrimePublicationFixture = { + channel: "preview", + releaseManifestBytes, + previewManifestBytes, + rootArtifactBytes, + attestationBundlesBySubjectSha256: new Map( + subjectDigests.map((digest) => [digest, attestations] as const), + ), + }; + const commit = decodeGitHubCommit( + parsePrimeGraduationJson( + await readPrimeGraduationFixtureFile( + root, + PRIME_GRADUATION_COMMIT_METADATA, + MAX_RELEASE_RESPONSE_BYTES, + ), + "Prime graduation commit metadata", + ), + ); + const workflowBytes = await readPrimeGraduationFixtureFile( + root, + PRIME_GRADUATION_PREVIEW_WORKFLOW, + MAX_MANIFEST_BYTES, + ); + const trustedRoot = await getTrustedRoot({ + ...(input.tufCachePath ? { cachePath: input.tufCachePath } : {}), + timeout: FETCH_TIMEOUT_MS, + retry: { retries: 1 }, + }); + const publication = await verifyPrimePublicationFixture(fixture, { + verifyBundle: async (bundle, expected) => + verifyPrimeSigstoreBundle(bundle, trustedRoot, expected), + verifySourcePolicy: async (expected) => { + if ( + expected.workflow !== PRIME_PREVIEW_WORKFLOW || + expected.publicationPolicyRevision !== PRIME_PUBLICATION_POLICY.publicationPolicyRevision || + expected.commit !== commit.sha || + expected.tree !== commit.tree.sha || + sha256(workflowBytes) !== PRIME_PUBLICATION_POLICY.previewWorkflowSha256 + ) { + throw new Error( + "Prime graduation source head, tree, workflow, or policy revision is not exact.", + ); + } + }, + }); + if (publication.buildId !== required.tag) { + throw new Error("Prime graduation tag and verified build identity differ."); + } + return { + publication, + rootArtifactBytes, + assetDigests: assetDigests.toSorted((left, right) => compareText(left.name, right.name)), + }; +} + /** * A real immutable fixture gate for bridge CI. It is deliberately fail-closed: callers must supply * every byte and bundle through {@link verifyPrimePublicationFixture}; no marker or package metadata diff --git a/apps/server/src/provider/prime/PrimeAgentMultipleInstances.integration.test.ts b/apps/server/src/provider/prime/PrimeAgentMultipleInstances.integration.test.ts index c612cd819..ebe7b796d 100644 --- a/apps/server/src/provider/prime/PrimeAgentMultipleInstances.integration.test.ts +++ b/apps/server/src/provider/prime/PrimeAgentMultipleInstances.integration.test.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; +import * as NodeHttp from "node:http"; import * as NodePath from "node:path"; import * as NodeUtil from "node:util"; @@ -29,6 +30,7 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; +import { makePrimeArtifactGraduationHarness } from "./PrimeAgentArtifactGraduation.test-fixture.ts"; import { ServerConfig } from "../../config.ts"; import { clearMcpProviderSession, setMcpProviderSession } from "../../mcp/McpProviderSession.ts"; import { makePrimeAgentDaemonAdapter } from "./PrimeAgentDaemonAdapter.ts"; @@ -45,9 +47,14 @@ import { } from "./PrimeAgentDaemonSessionRuntime.ts"; import type { PrimeAgentRuntimeContext } from "./PrimeAgentRuntimeContext.ts"; -const configuredExecutable = process.env.PYLON_REAL_PRIME_AGENT?.trim(); +const configuredArtifactDirectory = process.env.PYLON_PRIME_ARTIFACT_DIR?.trim(); +const configuredPreviewTag = process.env.PYLON_PRIME_PREVIEW_TAG?.trim(); +const configuredStockBinary = process.env.PYLON_PRIME_AGENT_STOCK_ARTIFACT_BIN?.trim(); const configuredAuthHome = process.env.PYLON_REAL_PRIME_AGENT_AUTH_HOME?.trim(); const runMultipleInstanceProof = process.env.PYLON_REAL_PRIME_AGENT_MULTI_PROOF === "1"; +const configuredGraduationArtifact = Boolean( + configuredArtifactDirectory && configuredPreviewTag && configuredStockBinary, +); const configuredCount = Number(process.env.PYLON_REAL_PRIME_AGENT_MULTI_COUNT ?? "2"); const RESOURCE_CEILINGS = new Map< number, @@ -106,16 +113,53 @@ interface ResourceSnapshot { readonly socketCount: number; } -function copyAuthFixture(home: string): void { +function copyAuthFixture(home: string, fauxPort: number): void { NodeFS.mkdirSync(home, { recursive: true, mode: 0o700 }); - if (!configuredAuthHome) return; - for (const fileName of ["auth.json", "settings.json"]) { - const source = NodePath.join(configuredAuthHome, fileName); - if (!NodeFS.existsSync(source)) continue; - const destination = NodePath.join(home, fileName); - NodeFS.copyFileSync(source, destination); - NodeFS.chmodSync(destination, 0o600); + if (configuredAuthHome) { + for (const fileName of ["auth.json", "settings.json"]) { + const source = NodePath.join(configuredAuthHome, fileName); + if (!NodeFS.existsSync(source)) continue; + const destination = NodePath.join(home, fileName); + NodeFS.copyFileSync(source, destination); + NodeFS.chmodSync(destination, 0o600); + } } + NodeFS.writeFileSync( + NodePath.join(home, "models.json"), + `${JSON.stringify( + { + providers: { + "faux-multi": { + baseUrl: `http://127.0.0.1:${fauxPort}/v1`, + api: "openai-completions", + apiKey: "faux-graduation-key", + authHeader: true, + compat: { + supportsDeveloperRole: false, + supportsReasoningEffort: false, + supportsUsageInStreaming: false, + maxTokensField: "max_tokens", + }, + models: [ + { + id: "faux-multi", + name: "Faux Multi", + reasoning: false, + input: ["text"], + contextWindow: 128_000, + maxTokens: 4_096, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ], + }, + }, + }, + null, + 2, + )} +`, + { mode: 0o600 }, + ); } function proofEnvironment( @@ -136,6 +180,126 @@ function proofEnvironment( }); } +interface FauxMultiBackend { + readonly port: number; + readonly reconnectAdmission: Promise; + finishReconnect(): void; + close(): Promise; +} + +function startFauxMultiBackend(): Promise { + return new Promise((resolve, reject) => { + const sockets = new Set(); + let resolveReconnect!: () => void; + const reconnectAdmission = new Promise((admitted) => { + resolveReconnect = admitted; + }); + let reconnectResponse: NodeHttp.ServerResponse | undefined; + let reconnectToken: string | undefined; + const chunk = (content: string, finishReason: string | null) => + `data: ${JSON.stringify({ + id: "faux-multi", + object: "chat.completion.chunk", + created: 0, + model: "faux-multi/faux-multi", + choices: [ + { + index: 0, + delta: finishReason === null ? { role: "assistant", content } : {}, + finish_reason: finishReason, + }, + ], + })} + +`; + const finish = (response: NodeHttp.ServerResponse, token: string) => { + response.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "close", + }); + response.end(`${chunk(token, null)}${chunk("", "stop")}data: [DONE] + +`); + }; + const messageText = (content: unknown): string => { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((part) => { + if (typeof part === "string") return part; + if (typeof part !== "object" || part === null) return ""; + const text = (part as Readonly>).text; + return typeof text === "string" ? text : ""; + }) + .join(""); + }; + const server = NodeHttp.createServer((request, response) => { + if (request.method === "GET" && request.url?.endsWith("/models")) { + response.writeHead(200, { "Content-Type": "application/json", Connection: "close" }); + response.end(JSON.stringify({ object: "list", data: [] })); + return; + } + if (request.method !== "POST" || !request.url?.endsWith("/chat/completions")) { + response.writeHead(404, { Connection: "close" }); + response.end(); + return; + } + let body = ""; + request.setEncoding("utf8"); + request.on("data", (value: string) => { + body += value; + }); + request.once("end", () => { + const payload = JSON.parse(body) as { + readonly messages?: ReadonlyArray<{ readonly content?: unknown }>; + }; + const text = (payload.messages ?? []) + .toReversed() + .map((message) => messageText(message.content)) + .find((message) => message.includes("PYLON_NATIVE_")); + const token = /PYLON_NATIVE_[A-Z0-9_]+/u.exec(text ?? "")?.[0] ?? "PYLON_NATIVE_OK"; + if (token === "PYLON_NATIVE_AFTER_RECONNECT_OK") { + reconnectResponse = response; + reconnectToken = token; + resolveReconnect(); + return; + } + finish(response, token); + }); + }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + reject(new Error("Faux Prime multi backend has no TCP address.")); + return; + } + resolve({ + port: address.port, + reconnectAdmission, + finishReconnect() { + if (!reconnectResponse || !reconnectToken) { + throw new Error("Faux Prime reconnect request was not admitted."); + } + finish(reconnectResponse, reconnectToken); + reconnectResponse = undefined; + reconnectToken = undefined; + }, + close: () => + new Promise((closed, closeRejected) => { + for (const socket of sockets) socket.destroy(); + server.close((error) => (error ? closeRejected(error) : closed())); + }), + }); + }); + }); +} + function safeResponseField(value: unknown, field: string): string | undefined { if (typeof value !== "object" || value === null) return undefined; const fieldValue = (value as Record)[field]; @@ -269,7 +433,7 @@ function safeCauseCategory(cause: Cause.Cause): string { return Cause.hasInterruptsOnly(cause) ? "interrupted" : "defect"; } -it.live.skipIf(!configuredExecutable || !runMultipleInstanceProof)( +it.live.skipIf(!configuredGraduationArtifact || !runMultipleInstanceProof)( "proves exact native Prime N=1/N=2/N=4 isolation, removal, and reconnect without ACP", () => { const lifecycle = { @@ -280,9 +444,6 @@ it.live.skipIf(!configuredExecutable || !runMultipleInstanceProof)( }; const proof = Effect.scoped( Effect.gen(function* () { - if (!configuredExecutable || !NodePath.isAbsolute(configuredExecutable)) { - return yield* Effect.die(new Error("The configured real Prime executable is invalid.")); - } const resourceCeiling = RESOURCE_CEILINGS.get(configuredCount); if (resourceCeiling === undefined) { return yield* Effect.die( @@ -294,7 +455,6 @@ it.live.skipIf(!configuredExecutable || !runMultipleInstanceProof)( } const platform = yield* HostProcessPlatform; - const executablePath = configuredExecutable; const root = NodeFS.mkdtempSync( NodePath.join(process.env.TMPDIR ?? "/tmp", "pylon-prime-native-proof-"), ); @@ -304,6 +464,36 @@ it.live.skipIf(!configuredExecutable || !runMultipleInstanceProof)( yield* Effect.addFinalizer(() => Effect.sync(() => NodeFS.rmSync(root, { recursive: true, force: true })), ); + const graduationStateDir = NodePath.join(root, "graduation-state"); + NodeFS.mkdirSync(graduationStateDir, { recursive: true, mode: 0o700 }); + const graduation = yield* Effect.promise(() => + makePrimeArtifactGraduationHarness({ + stateDir: graduationStateDir, + artifactDirectory: configuredArtifactDirectory!, + previewTag: configuredPreviewTag!, + stockBinaryPath: configuredStockBinary!, + platform, + }), + ); + const installation = yield* Effect.promise(() => + graduation.command({ + commandId: "native-multi-graduation-install", + action: "install", + channel: "preview", + allowPreview: true, + scheduleIfBusy: false, + }), + ); + if (installation.status !== "succeeded") { + return yield* Effect.die( + new Error(`Verified Prime graduation install failed: ${installation.message}`), + ); + } + const executablePath = graduation.binding().binaryPath; + const fauxBackend = yield* Effect.acquireRelease( + Effect.promise(startFauxMultiBackend), + (backend) => Effect.promise(() => backend.close()), + ); const makeInstance = Effect.fn("makeRealPrimeNativeProofInstance")(function* ( index: number, @@ -313,7 +503,7 @@ it.live.skipIf(!configuredExecutable || !runMultipleInstanceProof)( const stateDir = NodePath.join(root, "state", name); const modelSentinel = `model-${index}`; const credentialSentinel = `credential-${index}`; - copyAuthFixture(home); + copyAuthFixture(home, fauxBackend.port); NodeFS.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); NodeFS.writeFileSync(NodePath.join(home, "model-sentinel"), modelSentinel, { mode: 0o600, @@ -464,7 +654,7 @@ it.live.skipIf(!configuredExecutable || !runMultipleInstanceProof)( providerInstanceId: instanceId, cwd: root, runtimeMode: "full-access", - modelSelection: { instanceId, model: "default" }, + modelSelection: { instanceId, model: "faux-multi/faux-multi" }, }); return { manager, adapter, checkpointRef }; }).pipe(Effect.provideService(Scope.Scope, scope)); @@ -712,11 +902,10 @@ it.live.skipIf(!configuredExecutable || !runMultipleInstanceProof)( } const reconnectTurn = yield* reconnecting.adapter.sendTurn({ threadId: reconnecting.threadId, - input: - "Use the IPython tool exactly once to print PYLON_NATIVE_RECONNECT_TOOL_OK, then reply with exactly PYLON_NATIVE_AFTER_RECONNECT_OK and nothing else.", + input: "Reply with exactly PYLON_NATIVE_AFTER_RECONNECT_OK and nothing else.", attachments: [], }); - yield* Deferred.await(reconnecting.reconnectToolObserved).pipe( + yield* Effect.promise(() => fauxBackend.reconnectAdmission).pipe( Effect.timeout(Duration.seconds(60)), ); disconnect(); @@ -724,6 +913,7 @@ it.live.skipIf(!configuredExecutable || !runMultipleInstanceProof)( Effect.timeout(Duration.seconds(30)), ); expect(NodeFS.existsSync(reconnecting.manager.socket)).toBe(true); + fauxBackend.finishReconnect(); yield* waitForTurn(reconnecting, reconnectTurn.turnId); expect(removed.map((instance) => instance.openCount.value)).toEqual(removedOpenCounts); diff --git a/apps/server/src/provider/prime/PrimeAgentRealDaemon.integration.test.ts b/apps/server/src/provider/prime/PrimeAgentRealDaemon.integration.test.ts index 54f240070..aee0cb325 100644 --- a/apps/server/src/provider/prime/PrimeAgentRealDaemon.integration.test.ts +++ b/apps/server/src/provider/prime/PrimeAgentRealDaemon.integration.test.ts @@ -12,6 +12,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Stream from "effect/Stream"; +import { makePrimeArtifactGraduationHarness } from "./PrimeAgentArtifactGraduation.test-fixture.ts"; import type { PrimeDaemonEvent } from "./PrimeAgentDaemonEvents.ts"; import { sanitizePrimeAgentDaemonEnvironment } from "./PrimeAgentDaemonBridge.ts"; import { makePrimeAgentDaemonManager } from "./PrimeAgentDaemonManager.ts"; @@ -20,11 +21,16 @@ import { type PrimeAgentDaemonSessionRuntime, } from "./PrimeAgentDaemonSessionRuntime.ts"; -const configuredExecutable = process.env.PYLON_REAL_PRIME_AGENT?.trim(); +const configuredArtifactDirectory = process.env.PYLON_PRIME_ARTIFACT_DIR?.trim(); +const configuredPreviewTag = process.env.PYLON_PRIME_PREVIEW_TAG?.trim(); +const configuredStockBinary = process.env.PYLON_PRIME_AGENT_STOCK_ARTIFACT_BIN?.trim(); const configuredAuthHome = process.env.PYLON_REAL_PRIME_AGENT_AUTH_HOME?.trim(); +const configuredGraduationArtifact = Boolean( + configuredArtifactDirectory && configuredPreviewTag && configuredStockBinary, +); const providerInstanceId = ProviderInstanceId.make("prime-real-integration"); -const makeTestIdentity = (agentHomePath: string) => ({ +const makeTestIdentity = (agentHomePath: string, executablePath: string) => ({ instanceId: providerInstanceId, generation: { _tag: "PrimeAgentRuntimeGeneration" as const }, configRevision: "real-integration-test", @@ -41,7 +47,7 @@ const makeTestIdentity = (agentHomePath: string) => ({ }), settings: { enabled: true, - binaryPath: configuredExecutable ?? "prime-agent", + binaryPath: executablePath, agentHomePath, launchArgs: "", customModels: [], @@ -139,19 +145,13 @@ function drainEvents(input: { ); } -it.live.skipIf(!configuredExecutable)( +it.live.skipIf(!configuredGraduationArtifact)( "covers real daemon Phase-1 turn, control, restart, interruption, and cleanup", () => Effect.scoped( Effect.gen(function* () { const platform = yield* HostProcessPlatform; if (platform === "win32") return; - if (!configuredExecutable || !NodePath.isAbsolute(configuredExecutable)) { - return yield* Effect.die( - new Error("PYLON_REAL_PRIME_AGENT must be an absolute executable path"), - ); - } - const fileSystem = yield* FileSystem.FileSystem; const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "pylon-real-prime-daemon-", @@ -177,13 +177,37 @@ it.live.skipIf(!configuredExecutable)( } } } + const graduation = yield* Effect.promise(() => + makePrimeArtifactGraduationHarness({ + stateDir, + artifactDirectory: configuredArtifactDirectory!, + previewTag: configuredPreviewTag!, + stockBinaryPath: configuredStockBinary!, + platform, + }), + ); + const installation = yield* Effect.promise(() => + graduation.command({ + commandId: "real-daemon-graduation-install", + action: "install", + channel: "preview", + allowPreview: true, + scheduleIfBusy: false, + }), + ); + if (installation.status !== "succeeded") { + return yield* Effect.die( + new Error(`Verified Prime graduation install failed: ${installation.message}`), + ); + } + const executablePath = graduation.binding().binaryPath; const first = yield* Effect.scoped( Effect.gen(function* () { - const identity = makeTestIdentity(agentHomePath); + const identity = makeTestIdentity(agentHomePath, executablePath); const runtimeContext = makeTestRuntimeContext(identity); const manager = yield* makePrimeAgentDaemonManager({ - executablePath: configuredExecutable, + executablePath, identity, stateDir, tempDir: "/tmp", @@ -382,10 +406,10 @@ it.live.skipIf(!configuredExecutable)( const restarted = yield* Effect.scoped( Effect.gen(function* () { - const identity = makeTestIdentity(agentHomePath); + const identity = makeTestIdentity(agentHomePath, executablePath); const runtimeContext = makeTestRuntimeContext(identity); const manager = yield* makePrimeAgentDaemonManager({ - executablePath: configuredExecutable, + executablePath, identity, stateDir, tempDir: "/tmp", diff --git a/apps/server/src/provider/prime/PrimeAgentRestartAdoption.real.test.mjs b/apps/server/src/provider/prime/PrimeAgentRestartAdoption.real.test.mjs index 1eef44f5a..16936c7c5 100644 --- a/apps/server/src/provider/prime/PrimeAgentRestartAdoption.real.test.mjs +++ b/apps/server/src/provider/prime/PrimeAgentRestartAdoption.real.test.mjs @@ -20,15 +20,17 @@ import * as Stream from "effect/Stream"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import { describe, expect, it } from "vite-plus/test"; -import { persistPrimeManagedReceipt } from "./PrimeAgentDistributionVerifier.ts"; +import { makePrimeArtifactGraduationHarness } from "./PrimeAgentArtifactGraduation.test-fixture.ts"; -const packageRoot = NodeProcess.env.PRIME_AGENT_REAL_PACKAGE_ROOT?.trim(); -const exactHead = "a3dd5ce633fef161d30ded9474f75a609a9e7a2a"; +const artifactDirectory = NodeProcess.env.PYLON_PRIME_ARTIFACT_DIR?.trim(); +const previewTag = NodeProcess.env.PYLON_PRIME_PREVIEW_TAG?.trim(); +const stockBinaryPath = NodeProcess.env.PYLON_PRIME_AGENT_STOCK_ARTIFACT_BIN?.trim(); const skipReason = NodeProcess.platform === "win32" - ? "native Windows is unsupported; run the POSIX proof in WSL2 with a Linux PRIME_AGENT_REAL_PACKAGE_ROOT" - : `set PRIME_AGENT_REAL_PACKAGE_ROOT to the built exact Prime checkout at ${exactHead}`; -const enabled = NodeProcess.platform !== "win32" && Boolean(packageRoot); + ? "native Windows is unsupported; run the POSIX proof in WSL2" + : "set the exact Prime graduation artifact directory, preview tag, and stock fixture"; +const enabled = + NodeProcess.platform !== "win32" && Boolean(artifactDirectory && previewTag && stockBinaryPath); const outerSafetyMs = 180_000; const maximumOutputBytes = 2 * 1024 * 1024; const providerInstanceId = "primeAgent"; @@ -304,7 +306,9 @@ const sanitizeServerEnvironment = (home) => { name.startsWith("RLM_") || name === "PRIME_AGENT_CODING_AGENT_DIR" || name === "PI_CODING_AGENT_DIR" || - name === "PRIME_AGENT_REAL_PACKAGE_ROOT" || + name === "PYLON_PRIME_ARTIFACT_DIR" || + name === "PYLON_PRIME_PREVIEW_TAG" || + name === "PYLON_PRIME_AGENT_STOCK_ARTIFACT_BIN" || name === "FORCE_COLOR" || name === "VITEST" || name.startsWith("VITEST_") || @@ -325,57 +329,32 @@ const sanitizeServerEnvironment = (home) => { }; }; -const createPrimeFacade = async (temp, sourceRoot, sourceCommit, sourceTree) => { - const codingAgentRoot = NodePath.join(sourceRoot, "packages", "coding-agent"); - const sdkEntry = NodePath.join(codingAgentRoot, "dist", "index.js"); - const cliEntry = NodePath.join(codingAgentRoot, "dist", "bundle", "cli.js"); - const aiEntry = NodePath.join(sourceRoot, "packages", "ai", "dist", "index.js"); - for (const required of [sdkEntry, cliEntry, aiEntry]) { - await NodeFSP.access(required); +const createPrimeFacade = async (stateDir) => { + const harness = await makePrimeArtifactGraduationHarness({ + stateDir, + artifactDirectory, + previewTag, + stockBinaryPath, + platform: NodeProcess.platform, + }); + const receipt = await harness.command({ + commandId: "restart-adoption-graduation-install", + action: "install", + channel: "preview", + allowPreview: true, + scheduleIfBusy: false, + }); + if (receipt.status !== "succeeded") { + throw new Error(`verified Prime graduation install failed: ${receipt.message}`); } - - const facadeRoot = NodePath.join(temp, "prime-package"); - await NodeFSP.mkdir(facadeRoot, { recursive: true, mode: 0o700 }); - const executable = NodePath.join(facadeRoot, "prime-agent"); - const moduleEntry = NodePath.join(facadeRoot, "index.mjs"); - const buildId = `pylon-build-g${sourceCommit.slice(0, 12)}-r1`; - await NodeFSP.writeFile( - executable, - `#!/usr/bin/env node\nprocess.argv[1] = ${JSON.stringify(cliEntry)};\nawait import(${JSON.stringify(NodeURL.pathToFileURL(cliEntry).href)});\n`, - { mode: 0o700 }, - ); - await NodeFSP.writeFile( - moduleEntry, - `export * from ${JSON.stringify(NodeURL.pathToFileURL(sdkEntry).href)};\n`, - "utf8", - ); - await NodeFSP.writeFile( - NodePath.join(facadeRoot, "package.json"), - `${JSON.stringify( - { - name: "prime-agent", - version: "0.8.1", - type: "module", - exports: "./index.mjs", - bin: { "prime-agent": "./prime-agent" }, - pylonDistribution: { - schemaVersion: 1, - repository: "https://github.com/pylon-code/prime-agent", - sourceCommit, - sourceTree, - buildId, - recipeRevision: 1, - node: "22.23.2", - npm: "11.10.1", - packageLockSha256: "0".repeat(64), - }, - }, - null, - 2, - )}\n`, - "utf8", - ); - return { facadeRoot, executable, sdkEntry, aiEntry, buildId }; + const status = await harness.status(); + const installed = status.availableBuilds.find((build) => build.buildId === receipt.buildId); + if (installed === undefined) throw new Error("verified Prime graduation build was not installed"); + return { + facadeRoot: installed.packageRoot, + executable: installed.binaryPath, + sdkEntry: NodePath.join(installed.packageRoot, "dist", "index.js"), + }; }; const writeFixtureModelConfig = async (agentHome, port) => { @@ -417,15 +396,7 @@ const writeFixtureModelConfig = async (agentHome, port) => { ); }; -const preparePylonState = async ( - baseDir, - executable, - agentHome, - facadeRoot, - buildId, - sourceCommit, - sourceTree, -) => { +const preparePylonState = async (baseDir, executable, agentHome) => { const stateDir = NodePath.join(baseDir, "userdata"); await NodeFSP.mkdir(stateDir, { recursive: true, mode: 0o700 }); await NodeFSP.writeFile( @@ -449,23 +420,6 @@ const preparePylonState = async ( )}\n`, { mode: 0o600 }, ); - await persistPrimeManagedReceipt({ - stateDir, - instanceId: providerInstanceId, - packageRoot: facadeRoot, - platform: NodeProcess.platform, - publication: { - channel: "preview", - sequenceEpoch: 1, - sequence: 1, - buildId, - sourceCommit, - sourceTree, - recipeRevision: 1, - rootAsset: "pylon-prime-agent-0.8.1.tgz", - rootSha256: "1".repeat(64), - }, - }); return stateDir; }; @@ -901,33 +855,12 @@ const runRestartedTurn = ({ wsUrl, threadId, fixture, onRecoveredActivity }) => ); describe.skipIf(!enabled)( - `Prime Agent repeated Pylon-server restart adoption (${enabled ? "enabled" : skipReason})`, + `Prime Agent downloaded-artifact Pylon restart adoption (${enabled ? "enabled" : skipReason})`, () => { it( "adopts one live owned worker across the real server boundary and cleans it authoritatively", async () => { const repoRoot = NodePath.resolve(import.meta.dirname, "../../../../.."); - const sourceRoot = NodePath.resolve(packageRoot); - const sourceHead = ( - await runCaptured( - "git", - ["-C", sourceRoot, "rev-parse", "HEAD"], - { stdio: ["ignore", "pipe", "pipe"] }, - 5_000, - "Prime source HEAD", - ) - ).trim(); - const sourceTree = ( - await runCaptured( - "git", - ["-C", sourceRoot, "rev-parse", "HEAD^{tree}"], - { stdio: ["ignore", "pipe", "pipe"] }, - 5_000, - "Prime source tree", - ) - ).trim(); - expect(sourceHead).toBe(exactHead); - const temp = await NodeFSP.realpath( await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pylon-repeated-server-adoption-")), ); @@ -978,18 +911,12 @@ describe.skipIf(!enabled)( ); fixture = await startFixtureBackend(); - const primeFacade = await createPrimeFacade(temp, sourceRoot, sourceHead, sourceTree); + const stateDir = NodePath.join(baseDir, "userdata"); + await NodeFSP.mkdir(stateDir, { recursive: true, mode: 0o700 }); + const primeFacade = await createPrimeFacade(stateDir); primeSdkEntry = primeFacade.sdkEntry; await writeFixtureModelConfig(agentHome, fixture.port); - const stateDir = await preparePylonState( - baseDir, - primeFacade.executable, - agentHome, - primeFacade.facadeRoot, - primeFacade.buildId, - sourceHead, - sourceTree, - ); + await preparePylonState(baseDir, primeFacade.executable, agentHome); const databasePath = NodePath.join(stateDir, "state.sqlite"); daemonSocket = NodePath.join( NodeOS.tmpdir(), @@ -1426,7 +1353,7 @@ describe.skipIf(!enabled)( ), ), primeFacade.facadeRoot, - sourceRoot, + artifactDirectory, home, agentHome, daemonSocket, diff --git a/apps/server/src/provider/prime/PrimeManagedMaintenance.ts b/apps/server/src/provider/prime/PrimeManagedMaintenance.ts index 05926c2ba..04844a2f4 100644 --- a/apps/server/src/provider/prime/PrimeManagedMaintenance.ts +++ b/apps/server/src/provider/prime/PrimeManagedMaintenance.ts @@ -217,7 +217,6 @@ export const make = Effect.fn("PrimeManagedMaintenance.make")(function* () { buildId: build.buildId, channel: build.channel, sequence: build.sequence, - binaryPath: build.binaryPath, })), scheduled: result.scheduled ? contractReceipt(result.scheduled) : null, operation: result.operation ? contractReceipt(result.operation) : null, diff --git a/docs/README.md b/docs/README.md index e7109cf05..688ad45e2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,5 +53,6 @@ policy in [CONTRIBUTING.md](../CONTRIBUTING.md); agent rules in [AGENTS.md](../A - [Observability](./operations/observability.md) - [Relay observability](./operations/relay-observability.md) - [Prime Agent managed rollback](./operations/prime-agent-managed-rollback.md) +- [Prime artifact graduation](./operations/prime-artifact-graduation.md) - [Rollback manual recovery](./operations/rollback-manual-recovery.md) - [Mobile app store screenshots](./operations/mobile-app-store-screenshots.md) diff --git a/docs/internals/prime-agent-distribution-verification.md b/docs/internals/prime-agent-distribution-verification.md index 2715f0418..149a87941 100644 --- a/docs/internals/prime-agent-distribution-verification.md +++ b/docs/internals/prime-agent-distribution-verification.md @@ -46,6 +46,27 @@ Focused tests use deterministic manifests and a cryptographic-verifier seam, the and SLSA binding separately. Bridge CI can supply the first immutable artifact set through the fail-closed real-fixture gate. The gate has no skip or metadata-only success mode. +## Protected real-artifact graduation + +The manually dispatched `Prime artifact graduation` workflow is the enforced public-artifact proof. It +downloads one exact immutable preview release into runner-temporary storage, then calls +`verifyPrimePublicationArtifactDirectory` over the local release metadata, complete asset set, manifests, +attestation bundles, source commit/tree receipt, and frozen signer workflow. That function uses the same +server-owned Sigstore verifier and publication policy as the network loader. It exposes verified archive +bytes only after every release, subject, source, workflow, recipe, and digest binding succeeds. + +The workflow then passes those bytes through the production managed tool store. Stock is independently +downloaded at exact version and installed with lifecycle scripts disabled. Real opt-in tests consume the +artifact directory rather than a source checkout or caller-supplied executable and repeat verification +before installing their private managed copy. Ordinary pull-request CI can skip these public-network +proofs; the protected workflow sets a required-fixture mode and rejects any skipped test. Its faux model +backend needs no cloud credential. + +The gate is deliberately non-publishing. A successful run emits only public build identities, digests, +case names, and aggregate counts. The [Prime artifact graduation runbook](../operations/prime-artifact-graduation.md) +requires its run URL before a separate human can approve Prime stable promotion. Native multi-instance +execution remains evidence only and does not change the product capability gate. + ## Private managed state Distribution state is scoped by a SHA-256 hash of the provider instance id below: diff --git a/docs/internals/prime-agent-managed-install.md b/docs/internals/prime-agent-managed-install.md index a3470c665..c927d20db 100644 --- a/docs/internals/prime-agent-managed-install.md +++ b/docs/internals/prime-agent-managed-install.md @@ -74,7 +74,9 @@ use-stock, and cleanup. Preview additionally requires `channel: preview` plus `a Web and desktop Provider Settings expose status, signed stable and explicit-preview actions, progress and terminal errors, exact rollback builds, switch-back, and cleanup. Mobile reads status for every Prime instance on each connected environment and directs host changes to web or desktop Provider -Settings. Native Windows returns WSL2 guidance before filesystem, network, provider, or runtime I/O. +Settings. Public maintenance build rows contain only build id, channel, and sequence. Binary paths and +package roots remain environment-native server state and never cross the RPC boundary. Native Windows +returns WSL2 guidance before filesystem, network, provider, or runtime I/O. ## Replay, offline, and cleanup rules diff --git a/docs/operations/prime-artifact-graduation.md b/docs/operations/prime-artifact-graduation.md new file mode 100644 index 000000000..93b862e7b --- /dev/null +++ b/docs/operations/prime-artifact-graduation.md @@ -0,0 +1,54 @@ +# Prime artifact graduation + +Use this runbook to decide whether one exact public Pylon Prime preview is eligible for a later stable +promotion. This gate is read-only. It does not publish, promote, tag, release, or dispatch the Prime +stable workflow. + +## One-time repository configuration + +Create the GitHub environment **`prime-graduation`** in `pylon-code/pylon` with: + +- required reviewers from the Pylon maintainer team; +- deployment branches limited to the protected Pylon product branch or an approved task branch; +- no environment secrets and no environment variables. + +The workflow uses only public GitHub release, GitHub attestation, Git source, Sigstore trust-root, and upstream stock-release +material. Its job token has `contents: read` only. Model credentials are neither configured nor +accepted; runtime proofs use a bounded faux backend. + +## Run the protected gate + +1. Open **Actions → Prime artifact graduation → Run workflow**. +2. Enter `preview_tag` as the complete immutable tag, for example + `pylon-build-g0123456789ab-r1`. Never use a branch, release list position, or `latest` URL. +3. Leave `stock_version` at `0.8.1` unless the stock compatibility baseline is deliberately reviewed. +4. Optionally enter a later immutable `second_preview_tag`. With it, the gate proves a real staged update + and rollback. Without it, the gate requires an exact signed update no-op and explicit same-build + rollback. +5. Approve the `prime-graduation` environment deployment after checking the requested tags. + +The job downloads every release asset, manifest, attestation response, source commit/tree receipt, +signer workflow, and stock tarball into `RUNNER_TEMP`. It verifies the preview with the server-owned +Sigstore and frozen source-policy implementation before a preview archive is parsed, extracted, imported, +or executed. The production managed tool store then installs the bundled CLI without a package manager or +lifecycle script. + +The enforced cases cover stock and signed-preview bridge capability, side-by-side installation, real +start/use, update or exact no-op, rollback, stock switch-back, unchanged stock bytes, receipt-owned-only +cleanup, repeated Pylon restart/crash receipt recovery, and native multiple-instance evidence. The native +multi result remains evidence only; it does not enable `supportsMultipleInstances` because the distinct +account, package-root, catalog, capacity, MCP, checkpoint, macOS, Linux, and WSL2 requirements remain +separate. + +## Evidence and stable approval + +A successful job uploads only bounded JSON with public tags, source identities, artifact digests, case +names, and aggregate test counts. It never uploads packages, executables, managed roots, provider homes, +credentials, tokens, PIDs, sockets, or raw test output. The gate rejects skipped proof tests. + +Copy the complete GitHub Actions run URL from the job summary into the Prime stable-promotion approval. +**Do not approve the Prime stable environment without that successful run URL for the exact preview tag.** +A successful Pylon run is evidence for a later human promotion decision; it is not promotion authority. + +On failure, do not retry with a mutable URL, relaxed verifier, injected acceptance hook, skipped test, or +lifecycle-enabled install. Fix or republish a new immutable preview and run the protected gate again. diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index a2331653f..23fcfe7cf 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -6,6 +6,7 @@ import { getServerProviderSupportedRuntimeModes, resolveServerProviderRuntimeMode, ServerConfig, + ServerPrimeManagedInstalledBuild, ServerProvider, ServerProviders, ServerUpsertKeybindingResult, @@ -14,6 +15,7 @@ import { } from "./server.ts"; const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); +const decodePrimeManagedInstalledBuild = Schema.decodeUnknownSync(ServerPrimeManagedInstalledBuild); const decodeServerProviders = Schema.decodeUnknownSync(ServerProviders); const decodeUpsertKeybindingResult = Schema.decodeUnknownSync(ServerUpsertKeybindingResult); const decodeAvailableEditors = Schema.decodeUnknownSync(ServerConfig.fields.availableEditors); @@ -30,6 +32,24 @@ const baseProviderSnapshot = { models: [], }; +describe("ServerPrimeManagedInstalledBuild", () => { + it("never serializes the environment-native managed launcher path", () => { + const parsed = decodePrimeManagedInstalledBuild({ + buildId: "pylon-build-g123456789abc-r1", + channel: "preview", + sequence: 1, + binaryPath: "/private/environment/provider-tools/prime-agent", + }); + + expect(parsed).toEqual({ + buildId: "pylon-build-g123456789abc-r1", + channel: "preview", + sequence: 1, + }); + expect(JSON.stringify(parsed)).not.toContain("/private/environment"); + }); +}); + describe("ServerProvider", () => { it("defaults capability arrays when decoding provider snapshots", () => { const parsed = decodeServerProvider({ diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index be6a7f600..15ebc9d11 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -250,7 +250,6 @@ export const ServerPrimeManagedInstalledBuild = Schema.Struct({ buildId: TrimmedNonEmptyString, channel: ServerProviderDistributionChannel, sequence: Schema.Int.check(Schema.isGreaterThan(0)), - binaryPath: TrimmedNonEmptyString, }); export type ServerPrimeManagedInstalledBuild = typeof ServerPrimeManagedInstalledBuild.Type; diff --git a/scripts/prime-artifact-graduation-workflow.test.ts b/scripts/prime-artifact-graduation-workflow.test.ts new file mode 100644 index 000000000..998e12584 --- /dev/null +++ b/scripts/prime-artifact-graduation-workflow.test.ts @@ -0,0 +1,99 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { expect, it } from "vite-plus/test"; +import { parse } from "yaml"; + +const root = NodePath.resolve(import.meta.dirname, ".."); +const workflowPath = NodePath.join(root, ".github/workflows/prime-artifact-graduation.yml"); +const source = NodeFS.readFileSync(workflowPath, "utf8"); +const workflow = parse(source) as Readonly>; + +function record(value: unknown, label: string): Readonly> { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be one mapping.`); + } + return value as Readonly>; +} + +it("keeps Prime artifact graduation manual, protected, read-only, and immutable", () => { + expect(workflow.name).toBe("Prime artifact graduation"); + const dispatch = record(record(workflow.on, "on").workflow_dispatch, "workflow_dispatch"); + const inputs = record(dispatch.inputs, "workflow_dispatch.inputs"); + expect(Object.keys(inputs).toSorted()).toEqual([ + "preview_tag", + "second_preview_tag", + "stock_version", + ]); + expect(record(inputs.preview_tag, "preview_tag")).toMatchObject({ + required: true, + type: "string", + }); + expect(record(inputs.second_preview_tag, "second_preview_tag")).toMatchObject({ + required: false, + default: "", + type: "string", + }); + expect(record(inputs.stock_version, "stock_version")).toMatchObject({ + required: true, + default: "0.8.1", + type: "string", + }); + expect(workflow.permissions).toEqual({ contents: "read" }); + const jobs = record(workflow.jobs, "jobs"); + const graduate = record(jobs.graduate, "jobs.graduate"); + expect(graduate.environment).toBe("prime-graduation"); + expect(graduate["runs-on"]).toBe("ubuntu-24.04"); +}); + +it("pins every action and exposes no publishing or secret-bearing surface", () => { + const uses = [...source.matchAll(/^\s*uses:\s*([^\s#]+)/gmu)].map((match) => match[1]!); + expect(uses.length).toBeGreaterThanOrEqual(3); + for (const action of uses) expect(action).toMatch(/^[^@\s]+@[0-9a-f]{40}$/u); + expect(source).not.toMatch(/\$\{\{\s*secrets\./u); + expect(source).not.toMatch( + /(?:npm publish|gh release|git push|create-release|stable dispatch)/iu, + ); + expect(source).not.toContain("/releases/latest"); + expect(source).not.toMatch(/curl[^\n]*latest/iu); + expect(source).toContain("persist-credentials: false"); +}); + +it("downloads to runner temp, verifies before preview extraction, and runs every real proof", () => { + const download = source.indexOf("download-preview"); + const verify = source.indexOf("verify-preview"); + const stockInstall = source.indexOf("npm install"); + const execute = source.indexOf("vp test run"); + expect(download).toBeGreaterThan(0); + expect(verify).toBeGreaterThan(download); + expect(stockInstall).toBeGreaterThan(verify); + expect(execute).toBeGreaterThan(stockInstall); + expect(source).toContain("$RUNNER_TEMP/prime-preview"); + expect(source).toContain("$RUNNER_TEMP/prime-stock"); + expect(source).toContain("--ignore-scripts"); + expect(source).not.toContain("--passWithNoTests"); + expect(source).not.toMatch(/(?:it|describe)\.skip/u); + expect(source).toContain("PYLON_PRIME_GRADUATION_REQUIRED=1"); + expect(source).toContain("assert-results"); + for (const testFile of [ + "PrimeAgentArtifactGraduation.integration.test.ts", + "PrimeAgentDaemonBridge.test.ts", + "PrimeAgentDriver.test.ts", + "PrimeAgentRestartAdoption.real.test.mjs", + "PrimeAgentMultipleInstances.integration.test.ts", + ]) { + expect(source).toContain(testFile); + } +}); + +it("uploads only bounded summaries and makes the stable-approval run URL explicit", () => { + const upload = source.slice(source.indexOf("Upload bounded graduation evidence")); + expect(upload).toContain("verification.json"); + expect(upload).toContain("cases.json"); + expect(upload).toContain("graduation-summary.json"); + expect(upload).not.toContain("vitest.json"); + expect(upload).not.toMatch(/\.tgz|node_modules|provider-tools/u); + expect(source).toContain("Run URL (required for stable approval)"); + expect(source).toContain("github.run_id"); +}); From c09508fb7e5064d0ebd8db25f1d77647a668259c Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Tue, 1 Sep 2026 17:18:54 -0600 Subject: [PATCH 2/2] fix(ci): pin stock graduation artifact --- .../workflows/prime-artifact-graduation.yml | 20 +--- .../scripts/prime-artifact-graduation.ts | 50 ++++---- ...gentArtifactGraduation.integration.test.ts | 33 +++++- .../prime/PrimeAgentStockArtifact.test.ts | 91 +++++++++++++++ .../provider/prime/PrimeAgentStockArtifact.ts | 109 ++++++++++++++++++ .../prime-agent-distribution-verification.md | 18 ++- docs/operations/prime-artifact-graduation.md | 70 +++++++---- ...prime-artifact-graduation-workflow.test.ts | 37 +++--- 8 files changed, 333 insertions(+), 95 deletions(-) create mode 100644 apps/server/src/provider/prime/PrimeAgentStockArtifact.test.ts create mode 100644 apps/server/src/provider/prime/PrimeAgentStockArtifact.ts diff --git a/.github/workflows/prime-artifact-graduation.yml b/.github/workflows/prime-artifact-graduation.yml index b4aa13579..9c9d37452 100644 --- a/.github/workflows/prime-artifact-graduation.yml +++ b/.github/workflows/prime-artifact-graduation.yml @@ -12,11 +12,6 @@ on: required: false default: "" type: string - stock_version: - description: Exact stock Prime Agent version - required: true - default: 0.8.1 - type: string permissions: contents: read @@ -36,17 +31,14 @@ jobs: env: PREVIEW_TAG: ${{ inputs.preview_tag }} SECOND_PREVIEW_TAG: ${{ inputs.second_preview_tag }} - STOCK_VERSION: ${{ inputs.stock_version }} run: | set -euo pipefail preview_pattern='^pylon-build-g[0-9a-f]{12}-r[1-9][0-9]*$' - version_pattern='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' [[ "$PREVIEW_TAG" =~ $preview_pattern ]] if test -n "$SECOND_PREVIEW_TAG"; then [[ "$SECOND_PREVIEW_TAG" =~ $preview_pattern ]] test "$SECOND_PREVIEW_TAG" != "$PREVIEW_TAG" fi - [[ "$STOCK_VERSION" =~ $version_pattern ]] install -d -m 700 "$RUNNER_TEMP/prime-graduation-results" "$RUNNER_TEMP/prime-sigstore-tuf" { echo "PYLON_PRIME_GRADUATION_REQUIRED=1" @@ -58,7 +50,7 @@ jobs: else echo "PYLON_PRIME_SECOND_ARTIFACT_DIR=" fi - echo "PYLON_PRIME_STOCK_TARBALL=$RUNNER_TEMP/prime-stock/prime-agent-$STOCK_VERSION.tgz" + echo "PYLON_PRIME_STOCK_TARBALL=$RUNNER_TEMP/prime-stock/prime-agent-0.8.1.tgz" echo "PYLON_PRIME_AGENT_STOCK_ARTIFACT_BIN=$RUNNER_TEMP/prime-stock-install/node_modules/.bin/prime-agent" echo "PYLON_PRIME_GRADUATION_RESULT=$RUNNER_TEMP/prime-graduation-results/cases.json" echo "PYLON_REAL_PRIME_AGENT_MULTI_PROOF=1" @@ -81,17 +73,15 @@ jobs: cache: true run-install: true - - name: Download exact public preview, attestations, and stock package + - name: Download exact public preview, attestations, and frozen stock package env: PREVIEW_TAG: ${{ inputs.preview_tag }} - STOCK_VERSION: ${{ inputs.stock_version }} run: | set -euo pipefail node apps/server/scripts/prime-artifact-graduation.ts download-preview \ --tag "$PREVIEW_TAG" \ --artifact-directory "$RUNNER_TEMP/prime-preview" node apps/server/scripts/prime-artifact-graduation.ts download-stock \ - --version "$STOCK_VERSION" \ --stock-directory "$RUNNER_TEMP/prime-stock" - name: Download optional exact second preview @@ -127,9 +117,7 @@ jobs: --tuf-cache "$RUNNER_TEMP/prime-sigstore-tuf" \ --output "$RUNNER_TEMP/prime-graduation-results/second-verification.json" - - name: Install exact stock fixture without lifecycle scripts - env: - STOCK_VERSION: ${{ inputs.stock_version }} + - name: Install frozen stock fixture without lifecycle scripts run: | set -euo pipefail npm install \ @@ -138,7 +126,7 @@ jobs: --no-audit \ --no-fund \ --package-lock=false \ - "$RUNNER_TEMP/prime-stock/prime-agent-$STOCK_VERSION.tgz" + "$PYLON_PRIME_STOCK_TARBALL" test -x "$PYLON_PRIME_AGENT_STOCK_ARTIFACT_BIN" - name: Run real bridge, managed store, restart, crash-receipt, and native multi evidence diff --git a/apps/server/scripts/prime-artifact-graduation.ts b/apps/server/scripts/prime-artifact-graduation.ts index f332d0bdf..8f9b6b69e 100644 --- a/apps/server/scripts/prime-artifact-graduation.ts +++ b/apps/server/scripts/prime-artifact-graduation.ts @@ -21,12 +21,16 @@ import { PRIME_RELEASE_MANIFEST, verifyPrimePublicationArtifactDirectory, } from "../src/provider/prime/PrimeAgentDistributionVerifier.ts"; +import { + PRIME_STOCK_ARTIFACT, + verifyPrimeStockArtifactBytes, + verifyPrimeStockReleaseMetadata, +} from "../src/provider/prime/PrimeAgentStockArtifact.ts"; const MAX_JSON_BYTES = 4 * 1024 * 1024; const MAX_ASSET_BYTES = 256 * 1024 * 1024; const FETCH_TIMEOUT_MS = 30_000; const PREVIEW_TAG = /^pylon-build-g[0-9a-f]{12}-r[1-9][0-9]*$/u; -const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; const SHA256 = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/u)); const GIT_SHA = Schema.String.check(Schema.isPattern(/^[0-9a-f]{40}$/u)); @@ -257,40 +261,28 @@ async function downloadPreview(): Promise { } async function downloadStock(): Promise { - const version = flag("version")!; const destination = await makeEmptyDirectory(flag("stock-directory")!); - if (!VERSION.test(version)) throw new Error("Stock Prime version is not exact."); - const repository = "PrimeIntellect-ai/prime-agent"; - const tag = `v${version}`; - const assetName = `prime-agent-${version}.tgz`; - const expectedTarball = `https://github.com/${repository}/releases/download/${tag}/${assetName}`; + const repositoryBytes = await fetchBounded( + `https://api.github.com/repos/${PRIME_STOCK_ARTIFACT.repository}`, + MAX_JSON_BYTES, + "application/vnd.github+json", + ); const releaseBytes = await fetchBounded( - `https://api.github.com/repos/${repository}/releases/tags/${tag}`, + `https://api.github.com/repos/${PRIME_STOCK_ARTIFACT.repository}/releases/${PRIME_STOCK_ARTIFACT.releaseId}`, MAX_JSON_BYTES, "application/vnd.github+json", ); - const release = decodeGitHubRelease(parseJson(releaseBytes)); - if (release.tag_name !== tag || release.draft || release.prerelease) { - throw new Error("Stock Prime release metadata is not the exact requested version."); - } - const matches = release.assets.filter((asset) => asset.name === assetName); - const asset = matches[0]; - if ( - matches.length !== 1 || - !asset || - asset.browser_download_url !== expectedTarball || - typeof asset.digest !== "string" || - !/^sha256:[0-9a-f]{64}$/u.test(asset.digest) - ) { - throw new Error("Stock Prime release has no exact digest-bearing root package."); - } - const tarball = await fetchBounded(expectedTarball, MAX_ASSET_BYTES, "application/octet-stream"); - if (asset.size !== tarball.byteLength || asset.digest !== `sha256:${sha256(tarball)}`) { - throw new Error("Stock Prime tarball does not match its exact GitHub asset digest."); - } + verifyPrimeStockReleaseMetadata(parseJson(repositoryBytes), parseJson(releaseBytes)); + const tarball = await fetchBounded( + PRIME_STOCK_ARTIFACT.url, + PRIME_STOCK_ARTIFACT.size, + "application/octet-stream", + ); + verifyPrimeStockArtifactBytes(tarball); + await writeExclusive(NodePath.join(destination, "github-repository.json"), repositoryBytes); await writeExclusive(NodePath.join(destination, "github-release.json"), releaseBytes); - await writeExclusive(NodePath.join(destination, assetName), tarball); - console.log(`Downloaded exact stock Prime ${version}.`); + await writeExclusive(NodePath.join(destination, PRIME_STOCK_ARTIFACT.assetName), tarball); + console.log(`Downloaded frozen stock Prime ${PRIME_STOCK_ARTIFACT.version}.`); } async function verifyPreview(): Promise { diff --git a/apps/server/src/provider/prime/PrimeAgentArtifactGraduation.integration.test.ts b/apps/server/src/provider/prime/PrimeAgentArtifactGraduation.integration.test.ts index 09d9cc4c8..472b28f21 100644 --- a/apps/server/src/provider/prime/PrimeAgentArtifactGraduation.integration.test.ts +++ b/apps/server/src/provider/prime/PrimeAgentArtifactGraduation.integration.test.ts @@ -12,6 +12,7 @@ import * as Effect from "effect/Effect"; import { makePrimeArtifactGraduationHarness } from "./PrimeAgentArtifactGraduation.test-fixture.ts"; import { loadPrimeAgentDaemonBridge } from "./PrimeAgentDaemonBridge.ts"; import { PRIME_MANAGED_TOOL_DIRECTORY } from "./PrimeAgentManagedToolStore.ts"; +import { PRIME_STOCK_ARTIFACT, verifyPrimeStockArtifactBytes } from "./PrimeAgentStockArtifact.ts"; const execFile = NodeUtil.promisify(NodeChildProcess.execFile); const required = process.env.PYLON_PRIME_GRADUATION_REQUIRED === "1"; @@ -74,6 +75,7 @@ it.skipIf(!configured)( try { const stockRoot = NodePath.resolve(stockBinaryPath!, "../../.."); const stockTarballBefore = await NodeFSP.readFile(stockTarballPath!); + verifyPrimeStockArtifactBytes(stockTarballBefore); const stockTreeBefore = await treeDigest(stockRoot); const harness = await makePrimeArtifactGraduationHarness({ stateDir, @@ -88,7 +90,7 @@ it.skipIf(!configured)( // eslint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- This one opt-in proof combines the Promise-owned store lifecycle with the bridge Effect. const stockBridge = await Effect.runPromise(loadPrimeAgentDaemonBridge(stockBinaryPath!)); - expect(stockBridge.version).toBe("0.8.1"); + expect(stockBridge.version).toBe(PRIME_STOCK_ARTIFACT.version); expect(stockBridge.negotiatedDaemonSessionCapabilitiesAvailable).toBe(false); const install = await harness.command({ @@ -123,6 +125,7 @@ it.skipIf(!configured)( let rollbackBuildId = harness.artifacts[0]!.publication.buildId; if (harness.artifacts.length === 2) { harness.useArtifact(1); + const secondArtifact = harness.artifacts[1]!; const update = await harness.command({ commandId: "graduation-update-second-preview", action: "update", @@ -132,8 +135,27 @@ it.skipIf(!configured)( }); expect(update).toMatchObject({ status: "succeeded", - buildId: harness.artifacts[1]!.publication.buildId, + buildId: secondArtifact.publication.buildId, }); + const updatedStatus = await harness.status(); + const selectedSecondBuild = updatedStatus.availableBuilds.find( + (build) => build.buildId === secondArtifact.publication.buildId, + ); + expect(selectedSecondBuild).toBeDefined(); + expect(harness.binding().binaryPath).toBe(selectedSecondBuild!.binaryPath); + // eslint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- This opt-in proof loads the exact launcher selected by the production managed store. + const secondBridge = await Effect.runPromise( + loadPrimeAgentDaemonBridge(selectedSecondBuild!.binaryPath), + ); + expect(secondBridge.version).toBe(secondArtifact.publication.packageVersion); + expect(secondBridge.negotiatedDaemonSessionCapabilitiesAvailable).toBe(true); + const secondVersion = await execFile(selectedSecondBuild!.binaryPath, ["--version"], { + timeout: 30_000, + maxBuffer: 256 * 1024, + windowsHide: true, + }); + expect(secondVersion.stdout).toContain(secondArtifact.publication.packageVersion); + expect(harness.binding().binaryPath).toBe(selectedSecondBuild!.binaryPath); } else { const update = await harness.command({ commandId: "graduation-update-exact-no-op", @@ -189,8 +211,9 @@ it.skipIf(!configured)( const result = { schemaVersion: 1, status: "passed", - stockVersion: "0.8.1", - stockSha256: digest(stockTarballBefore), + stockVersion: PRIME_STOCK_ARTIFACT.version, + stockSha256: PRIME_STOCK_ARTIFACT.sha256, + stockSha512: PRIME_STOCK_ARTIFACT.sha512, preview: harness.artifacts.map((artifact) => ({ tag: artifact.publication.buildId, sequence: artifact.publication.sequence, @@ -204,7 +227,7 @@ it.skipIf(!configured)( "signed-preview-capability", "side-by-side-install", "preview-start", - harness.artifacts.length === 2 ? "second-build-update" : "exact-update-no-op", + harness.artifacts.length === 2 ? "second-build-update-executed" : "exact-update-no-op", "rollback", "use-stock", "stock-bytes-unchanged", diff --git a/apps/server/src/provider/prime/PrimeAgentStockArtifact.test.ts b/apps/server/src/provider/prime/PrimeAgentStockArtifact.test.ts new file mode 100644 index 000000000..3d5a61a9a --- /dev/null +++ b/apps/server/src/provider/prime/PrimeAgentStockArtifact.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + PRIME_STOCK_ARTIFACT, + verifyPrimeStockArtifactIdentity, + verifyPrimeStockReleaseMetadata, +} from "./PrimeAgentStockArtifact.ts"; + +const repository = { + id: PRIME_STOCK_ARTIFACT.repositoryId, + full_name: PRIME_STOCK_ARTIFACT.repository, +}; +const asset = { + id: PRIME_STOCK_ARTIFACT.assetId, + name: PRIME_STOCK_ARTIFACT.assetName, + size: PRIME_STOCK_ARTIFACT.size, + browser_download_url: PRIME_STOCK_ARTIFACT.url, + digest: `sha256:${PRIME_STOCK_ARTIFACT.sha256}`, +}; +const release = { + id: PRIME_STOCK_ARTIFACT.releaseId, + tag_name: PRIME_STOCK_ARTIFACT.tag, + draft: false, + prerelease: false, + immutable: false, + assets: [asset], +}; + +describe("frozen stock Prime artifact", () => { + it("accepts only the reviewed repository, release, and asset metadata", () => { + expect(() => verifyPrimeStockReleaseMetadata(repository, release)).not.toThrow(); + for (const mutation of [ + { repository: { ...repository, id: repository.id + 1 }, release }, + { repository: { ...repository, full_name: "other/prime-agent" }, release }, + { repository, release: { ...release, id: release.id + 1 } }, + { repository, release: { ...release, tag_name: "v0.8.2" } }, + { + repository, + release: { ...release, assets: [{ ...asset, id: asset.id + 1 }] }, + }, + { + repository, + release: { ...release, assets: [{ ...asset, name: "prime-agent-0.8.2.tgz" }] }, + }, + { + repository, + release: { ...release, assets: [{ ...asset, size: asset.size + 1 }] }, + }, + { + repository, + release: { + ...release, + assets: [{ ...asset, browser_download_url: `${asset.browser_download_url}.mutated` }], + }, + }, + { + repository, + release: { ...release, assets: [{ ...asset, digest: `sha256:${"0".repeat(64)}` }] }, + }, + ]) { + expect(() => + verifyPrimeStockReleaseMetadata(mutation.repository, mutation.release), + ).toThrow(); + } + }); + + it("uses the frozen byte size, SHA-256, and SHA-512 as the trust root", () => { + const identity = { + size: PRIME_STOCK_ARTIFACT.size, + sha256: PRIME_STOCK_ARTIFACT.sha256, + sha512: PRIME_STOCK_ARTIFACT.sha512, + }; + expect(() => verifyPrimeStockArtifactIdentity(identity)).not.toThrow(); + for (const mutation of [ + { ...identity, size: identity.size + 1 }, + { ...identity, sha256: "0".repeat(64) }, + { ...identity, sha512: "0".repeat(128) }, + ]) { + expect(() => verifyPrimeStockArtifactIdentity(mutation)).toThrow(); + } + }); + + it("does not depend on GitHub supplying a live asset digest", () => { + expect(() => + verifyPrimeStockReleaseMetadata(repository, { + ...release, + assets: [{ ...asset, digest: null }], + }), + ).not.toThrow(); + }); +}); diff --git a/apps/server/src/provider/prime/PrimeAgentStockArtifact.ts b/apps/server/src/provider/prime/PrimeAgentStockArtifact.ts new file mode 100644 index 000000000..877aa3fb9 --- /dev/null +++ b/apps/server/src/provider/prime/PrimeAgentStockArtifact.ts @@ -0,0 +1,109 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; +import * as Schema from "effect/Schema"; + +export const PRIME_STOCK_ARTIFACT = Object.freeze({ + repository: "PrimeIntellect-ai/prime-agent", + repositoryId: 1_232_493_406, + version: "0.8.1", + releaseId: 376_894_763, + tag: "v0.8.1", + assetId: 530_304_956, + assetName: "prime-agent-0.8.1.tgz", + size: 9_616_163, + url: "https://github.com/PrimeIntellect-ai/prime-agent/releases/download/v0.8.1/prime-agent-0.8.1.tgz", + sha256: "46c24db1782dd31adc35d5c6cbcc75564faba6ced3bf2ccf03d836ee77134475", + sha512: + "28ce7328c386d6d54261ba6a7bebe3cd420bf6f625ed6cb6a9fae6ca4815988c767b8f3f0ff3d3a95037ab566a17e074b181039a3da2ec929f4c6712ba51931d", +}); + +const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)); +const GitHubRepository = Schema.Struct({ + id: PositiveInt, + full_name: Schema.String, +}); +const GitHubReleaseAsset = Schema.Struct({ + id: PositiveInt, + name: Schema.String, + size: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + browser_download_url: Schema.String, + digest: Schema.optional(Schema.NullOr(Schema.String)), +}); +const GitHubRelease = Schema.Struct({ + id: PositiveInt, + tag_name: Schema.String, + draft: Schema.Boolean, + prerelease: Schema.Boolean, + immutable: Schema.Boolean, + assets: Schema.Array(GitHubReleaseAsset), +}); +const decodeGitHubRepository = Schema.decodeUnknownSync(GitHubRepository); +const decodeGitHubRelease = Schema.decodeUnknownSync(GitHubRelease); + +type GitHubReleaseAssetMetadata = typeof GitHubReleaseAsset.Type; + +export function verifyPrimeStockReleaseMetadata( + repositoryInput: unknown, + releaseInput: unknown, +): GitHubReleaseAssetMetadata { + const repository = decodeGitHubRepository(repositoryInput); + const release = decodeGitHubRelease(releaseInput); + if ( + repository.id !== PRIME_STOCK_ARTIFACT.repositoryId || + repository.full_name !== PRIME_STOCK_ARTIFACT.repository + ) { + throw new Error("Stock Prime repository metadata does not match the frozen identity."); + } + if ( + release.id !== PRIME_STOCK_ARTIFACT.releaseId || + release.tag_name !== PRIME_STOCK_ARTIFACT.tag || + release.draft || + release.prerelease + ) { + throw new Error("Stock Prime release metadata does not match the frozen identity."); + } + const matchesById = release.assets.filter((asset) => asset.id === PRIME_STOCK_ARTIFACT.assetId); + const matchesByName = release.assets.filter( + (asset) => asset.name === PRIME_STOCK_ARTIFACT.assetName, + ); + const asset = matchesById[0]; + if ( + matchesById.length !== 1 || + matchesByName.length !== 1 || + !asset || + matchesByName[0] !== asset || + asset.name !== PRIME_STOCK_ARTIFACT.assetName || + asset.size !== PRIME_STOCK_ARTIFACT.size || + asset.browser_download_url !== PRIME_STOCK_ARTIFACT.url || + (asset.digest !== undefined && + asset.digest !== null && + asset.digest !== `sha256:${PRIME_STOCK_ARTIFACT.sha256}`) + ) { + throw new Error("Stock Prime asset metadata does not match the frozen identity."); + } + return asset; +} + +export interface PrimeStockArtifactIdentity { + readonly size: number; + readonly sha256: string; + readonly sha512: string; +} + +export function verifyPrimeStockArtifactIdentity(identity: PrimeStockArtifactIdentity): void { + if ( + identity.size !== PRIME_STOCK_ARTIFACT.size || + identity.sha256 !== PRIME_STOCK_ARTIFACT.sha256 || + identity.sha512 !== PRIME_STOCK_ARTIFACT.sha512 + ) { + throw new Error("Stock Prime bytes do not match the frozen size and digests."); + } +} + +export function verifyPrimeStockArtifactBytes(bytes: NodeJS.ArrayBufferView): void { + verifyPrimeStockArtifactIdentity({ + size: bytes.byteLength, + sha256: NodeCrypto.createHash("sha256").update(bytes).digest("hex"), + sha512: NodeCrypto.createHash("sha512").update(bytes).digest("hex"), + }); +} diff --git a/docs/internals/prime-agent-distribution-verification.md b/docs/internals/prime-agent-distribution-verification.md index 149a87941..15d89abc6 100644 --- a/docs/internals/prime-agent-distribution-verification.md +++ b/docs/internals/prime-agent-distribution-verification.md @@ -48,17 +48,23 @@ fail-closed real-fixture gate. The gate has no skip or metadata-only success mod ## Protected real-artifact graduation -The manually dispatched `Prime artifact graduation` workflow is the enforced public-artifact proof. It -downloads one exact immutable preview release into runner-temporary storage, then calls +The manually dispatched `Prime artifact graduation` workflow is the required public-artifact proof. +Its `environment` field only selects the repository-owned `prime-graduation` environment; the workflow +does not enforce that environment's reviewers, bypass policy, branch policy, secrets, or variables. The +runbook requires an API readback before dispatch. The job downloads one exact immutable preview release +into runner-temporary storage, then calls `verifyPrimePublicationArtifactDirectory` over the local release metadata, complete asset set, manifests, attestation bundles, source commit/tree receipt, and frozen signer workflow. That function uses the same server-owned Sigstore verifier and publication policy as the network loader. It exposes verified archive bytes only after every release, subject, source, workflow, recipe, and digest binding succeeds. -The workflow then passes those bytes through the production managed tool store. Stock is independently -downloaded at exact version and installed with lifecycle scripts disabled. Real opt-in tests consume the -artifact directory rather than a source checkout or caller-supplied executable and repeat verification -before installing their private managed copy. Ordinary pull-request CI can skip these public-network +The workflow then passes those bytes through the production managed tool store. Stock is not a dispatch +input. Pylon source freezes the reviewed Prime Agent 0.8.1 repository, release, asset identity, exact URL, +size, SHA-256, and SHA-512. The downloader checks live metadata only against that identity and treats the +independently pinned byte digests as the trust root before installing with lifecycle scripts disabled. +Real opt-in tests consume the artifact directory rather than a source checkout or caller-supplied +executable and repeat verification before installing their private managed copy. Ordinary pull-request +CI can skip these public-network proofs; the protected workflow sets a required-fixture mode and rejects any skipped test. Its faux model backend needs no cloud credential. diff --git a/docs/operations/prime-artifact-graduation.md b/docs/operations/prime-artifact-graduation.md index 93b862e7b..74dab2793 100644 --- a/docs/operations/prime-artifact-graduation.md +++ b/docs/operations/prime-artifact-graduation.md @@ -4,39 +4,61 @@ Use this runbook to decide whether one exact public Pylon Prime preview is eligi promotion. This gate is read-only. It does not publish, promote, tag, release, or dispatch the Prime stable workflow. -## One-time repository configuration +## Required repository configuration -Create the GitHub environment **`prime-graduation`** in `pylon-code/pylon` with: +The GitHub environment **`prime-graduation`** in `pylon-code/pylon` currently requires this exact +repository-owned configuration: -- required reviewers from the Pylon maintainer team; -- deployment branches limited to the protected Pylon product branch or an approved task branch; -- no environment secrets and no environment variables. +- required reviewer `rynfar`; +- `prevent_self_review: false`; +- `can_admins_bypass: false`; +- deployment branches limited to protected branches, with no custom branch policies; +- zero environment secrets and zero environment variables. -The workflow uses only public GitHub release, GitHub attestation, Git source, Sigstore trust-root, and upstream stock-release -material. Its job token has `contents: read` only. Model credentials are neither configured nor -accepted; runtime proofs use a bounded faux backend. +The workflow only names the environment. It does not create, repair, or verify this GitHub-hosted +configuration. An authorized maintainer must read it back before every dispatch: + +```bash +gh api repos/pylon-code/pylon/environments/prime-graduation \ + --jq '{name, protection_rules, deployment_branch_policy, can_admins_bypass}' +gh api repos/pylon-code/pylon/environments/prime-graduation/secrets --jq '.total_count' +gh api repos/pylon-code/pylon/environments/prime-graduation/variables --jq '.total_count' +``` + +Do not dispatch unless the first response matches every setting above and both counts are `0`. The job +token itself has `contents: read` only. Model credentials are neither configured nor accepted; runtime +proofs use a bounded faux backend. ## Run the protected gate -1. Open **Actions → Prime artifact graduation → Run workflow**. -2. Enter `preview_tag` as the complete immutable tag, for example +1. Complete the environment readback above. +2. Open **Actions → Prime artifact graduation → Run workflow**. +3. Enter `preview_tag` as the complete immutable tag, for example `pylon-build-g0123456789ab-r1`. Never use a branch, release list position, or `latest` URL. -3. Leave `stock_version` at `0.8.1` unless the stock compatibility baseline is deliberately reviewed. -4. Optionally enter a later immutable `second_preview_tag`. With it, the gate proves a real staged update - and rollback. Without it, the gate requires an exact signed update no-op and explicit same-build - rollback. +4. Optionally enter a later immutable `second_preview_tag`. With it, the gate installs, selects, loads, + and executes the exact second launcher before rollback. Without it, the gate requires an exact signed + update no-op and explicit same-build rollback. 5. Approve the `prime-graduation` environment deployment after checking the requested tags. -The job downloads every release asset, manifest, attestation response, source commit/tree receipt, -signer workflow, and stock tarball into `RUNNER_TEMP`. It verifies the preview with the server-owned -Sigstore and frozen source-policy implementation before a preview archive is parsed, extracted, imported, -or executed. The production managed tool store then installs the bundled CLI without a package manager or -lifecycle script. +The stock compatibility fixture is not a workflow input. Pylon source freezes stock Prime Agent 0.8.1 +to upstream repository id `1232493406`, release id `376894763`, asset id `530304956`, the exact public +asset URL and size `9616163`, SHA-256 +`46c24db1782dd31adc35d5c6cbcc75564faba6ced3bf2ccf03d836ee77134475`, and SHA-512 +`28ce7328c386d6d54261ba6a7bebe3cd420bf6f625ed6cb6a9fae6ca4815988c767b8f3f0ff3d3a95037ab566a17e074b181039a3da2ec929f4c6712ba51931d`. +The upstream release is not immutable. Its live metadata and optional GitHub digest are only identity +checks and cross-checks; the independently frozen size and two byte digests are the trust root. + +The job downloads every preview release asset, manifest, attestation response, source commit/tree +receipt, signer workflow, and the frozen stock tarball into `RUNNER_TEMP`. It verifies the stock +repository, release, asset identity, size, and both byte digests before install or import. It verifies +the preview with the server-owned Sigstore and frozen source policy before a preview archive is parsed, +extracted, imported, or executed. The production managed tool store then installs the bundled CLI +without a package manager or lifecycle script. -The enforced cases cover stock and signed-preview bridge capability, side-by-side installation, real -start/use, update or exact no-op, rollback, stock switch-back, unchanged stock bytes, receipt-owned-only -cleanup, repeated Pylon restart/crash receipt recovery, and native multiple-instance evidence. The native -multi result remains evidence only; it does not enable `supportsMultipleInstances` because the distinct +The cases cover stock and signed-preview bridge capability, side-by-side installation, real start/use, +update or exact no-op, rollback, stock switch-back, unchanged stock bytes, receipt-owned-only cleanup, +repeated Pylon restart/crash receipt recovery, and native multiple-instance evidence. The native multi +result remains evidence only. It does not enable `supportsMultipleInstances` because the distinct account, package-root, catalog, capacity, MCP, checkpoint, macOS, Linux, and WSL2 requirements remain separate. @@ -48,7 +70,7 @@ credentials, tokens, PIDs, sockets, or raw test output. The gate rejects skipped Copy the complete GitHub Actions run URL from the job summary into the Prime stable-promotion approval. **Do not approve the Prime stable environment without that successful run URL for the exact preview tag.** -A successful Pylon run is evidence for a later human promotion decision; it is not promotion authority. +A successful Pylon run is evidence for a later human promotion decision. It is not promotion authority. On failure, do not retry with a mutable URL, relaxed verifier, injected acceptance hook, skipped test, or lifecycle-enabled install. Fix or republish a new immutable preview and run the protected gate again. diff --git a/scripts/prime-artifact-graduation-workflow.test.ts b/scripts/prime-artifact-graduation-workflow.test.ts index 998e12584..af9934d75 100644 --- a/scripts/prime-artifact-graduation-workflow.test.ts +++ b/scripts/prime-artifact-graduation-workflow.test.ts @@ -9,6 +9,9 @@ const root = NodePath.resolve(import.meta.dirname, ".."); const workflowPath = NodePath.join(root, ".github/workflows/prime-artifact-graduation.yml"); const source = NodeFS.readFileSync(workflowPath, "utf8"); const workflow = parse(source) as Readonly>; +const publishingSurface = + /\b(?:npm publish|gh release|git push|create-release|stable dispatch)\b/iu; +const skippedProof = /\b(?:it|describe)\.skip\b/u; function record(value: unknown, label: string): Readonly> { if (typeof value !== "object" || value === null || Array.isArray(value)) { @@ -17,15 +20,20 @@ function record(value: unknown, label: string): Readonly return value as Readonly>; } +function assertNoPublishingOrSkippedProof(candidate: string): void { + if (publishingSurface.test(candidate)) { + throw new Error("Prime artifact graduation contains a publishing surface."); + } + if (skippedProof.test(candidate)) { + throw new Error("Prime artifact graduation contains a skipped proof."); + } +} + it("keeps Prime artifact graduation manual, protected, read-only, and immutable", () => { expect(workflow.name).toBe("Prime artifact graduation"); const dispatch = record(record(workflow.on, "on").workflow_dispatch, "workflow_dispatch"); const inputs = record(dispatch.inputs, "workflow_dispatch.inputs"); - expect(Object.keys(inputs).toSorted()).toEqual([ - "preview_tag", - "second_preview_tag", - "stock_version", - ]); + expect(Object.keys(inputs).toSorted()).toEqual(["preview_tag", "second_preview_tag"]); expect(record(inputs.preview_tag, "preview_tag")).toMatchObject({ required: true, type: "string", @@ -35,11 +43,6 @@ it("keeps Prime artifact graduation manual, protected, read-only, and immutable" default: "", type: "string", }); - expect(record(inputs.stock_version, "stock_version")).toMatchObject({ - required: true, - default: "0.8.1", - type: "string", - }); expect(workflow.permissions).toEqual({ contents: "read" }); const jobs = record(workflow.jobs, "jobs"); const graduate = record(jobs.graduate, "jobs.graduate"); @@ -52,14 +55,18 @@ it("pins every action and exposes no publishing or secret-bearing surface", () = expect(uses.length).toBeGreaterThanOrEqual(3); for (const action of uses) expect(action).toMatch(/^[^@\s]+@[0-9a-f]{40}$/u); expect(source).not.toMatch(/\$\{\{\s*secrets\./u); - expect(source).not.toMatch( - /(?:npm publish|gh release|git push|create-release|stable dispatch)/iu, - ); + expect(() => assertNoPublishingOrSkippedProof(source)).not.toThrow(); expect(source).not.toContain("/releases/latest"); expect(source).not.toMatch(/curl[^\n]*latest/iu); expect(source).toContain("persist-credentials: false"); }); +it("keeps mutation sentinels for publishing commands and skipped proofs", () => { + for (const mutation of ["npm publish", "gh release create v1", 'it.skip("proof", () => {})']) { + expect(() => assertNoPublishingOrSkippedProof(`${source}\n${mutation}\n`)).toThrow(); + } +}); + it("downloads to runner temp, verifies before preview extraction, and runs every real proof", () => { const download = source.indexOf("download-preview"); const verify = source.indexOf("verify-preview"); @@ -70,10 +77,10 @@ it("downloads to runner temp, verifies before preview extraction, and runs every expect(stockInstall).toBeGreaterThan(verify); expect(execute).toBeGreaterThan(stockInstall); expect(source).toContain("$RUNNER_TEMP/prime-preview"); - expect(source).toContain("$RUNNER_TEMP/prime-stock"); + expect(source).toContain("$RUNNER_TEMP/prime-stock/prime-agent-0.8.1.tgz"); expect(source).toContain("--ignore-scripts"); expect(source).not.toContain("--passWithNoTests"); - expect(source).not.toMatch(/(?:it|describe)\.skip/u); + expect(() => assertNoPublishingOrSkippedProof(source)).not.toThrow(); expect(source).toContain("PYLON_PRIME_GRADUATION_REQUIRED=1"); expect(source).toContain("assert-results"); for (const testFile of [