From f8e2bee66e5eee83ac86b184043a1b1b8ab10743 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 8 Oct 2026 07:02:16 -0500 Subject: [PATCH] fix: assemble verified host runner assets before release Reuse release verification producers for the supported native hosts. Validate source-bound data before publication and probe the installed Codex command in the existing offline consumer. Co-Authored-By: Paperclip --- .github/workflows/release-verify.yml | 190 +++++++++++++ .github/workflows/release.yml | 244 +++++++++++++++- .../scripts/release-provider-pack.mjs | 17 +- .../scripts/release-provider-pack.test.mjs | 160 ++++++++++- .../grok-public-install-sandbox.test.mjs | 29 +- .../release-verify-workflow.test.mjs | 109 ++++++- scripts/grok-public-install-sandbox.mjs | 26 ++ scripts/release-runner-artifacts.mjs | 265 ++++++++++++++++++ scripts/release.sh | 31 ++ scripts/verify-grok-npm-install.mjs | 9 +- 10 files changed, 1061 insertions(+), 19 deletions(-) create mode 100644 scripts/release-runner-artifacts.mjs diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index 76e5cd28bb3..47aa308f921 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -7,18 +7,28 @@ on: description: Commit SHA, branch, or tag to verify required: true type: string + runner_assets_only: + description: Build only the release daemon/provider-pack data after the caller's source gate + type: boolean + default: false + outputs: + runner_assets_artifact: + description: Run-bound verified release daemon/provider-pack data + value: ${{ jobs.runner_release_assemble.outputs.artifact_name }} # Caller-provided refs may name unmerged PR code. AWS is eligible only when # the caller runs on canonical master and verifies that event's exact SHA. # The organization group also restricts these workflow files to master. jobs: runner_chaos_evals: + if: ${{ !inputs.runner_assets_only }} name: Pre-release Runner chaos evals uses: ./.github/workflows/runner-chaos-evals.yml with: ref: ${{ inputs.ref }} typecheck: + if: ${{ !inputs.runner_assets_only }} name: Typecheck runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }} timeout-minutes: 20 @@ -97,6 +107,7 @@ jobs: run: pnpm -r typecheck general_tests: + if: ${{ !inputs.runner_assets_only }} name: General tests (${{ matrix.group_label }}) runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }} timeout-minutes: 20 @@ -204,6 +215,7 @@ jobs: fi serialized_tests: + if: ${{ !inputs.runner_assets_only }} name: Serialized tests (${{ matrix.shard_label }}) runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }} timeout-minutes: 20 @@ -253,6 +265,7 @@ jobs: run: pnpm test:run:serialized -- --shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }} runner_workflow_evals: + if: ${{ !inputs.runner_assets_only }} name: Runner workflow eval scorer contract runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }} timeout-minutes: 10 @@ -283,6 +296,7 @@ jobs: run: pnpm test:runner-workflow-evals verify_paperclip_runner: + if: ${{ !inputs.runner_assets_only }} name: Verify Paperclip Runner (${{ matrix.lane }}) runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }} timeout-minutes: 20 @@ -396,6 +410,7 @@ jobs: run: pnpm --filter @paperclipai/paperclip-runner build:rust build: + if: ${{ !inputs.runner_assets_only }} name: Build runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }} timeout-minutes: 20 @@ -424,3 +439,178 @@ jobs: - name: Build run: pnpm build + + # Only the missing release producers. Source verification stays with the + # caller; these unprivileged jobs neither publish nor receive provider secrets. + runner_release_binaries: + needs: runner_release_request + if: inputs.runner_assets_only + name: Release daemon (${{ matrix.target }}) + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - target: darwin-arm64 + runner: macos-15 + - target: darwin-x64 + runner: macos-15-intel + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + - name: Build the exact native release daemon + working-directory: packages/paperclip-runner + env: + SOURCE_SHA: ${{ inputs.ref }} + run: | + set -euo pipefail + [[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]] + test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + rustup show + cargo build --release --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd + - name: Record native architecture, checksum and executable metadata + env: + SOURCE_SHA: ${{ inputs.ref }} + TARGET: ${{ matrix.target }} + run: | + node scripts/release-runner-artifacts.mjs record-binary "$SOURCE_SHA" "$TARGET" \ + "$GITHUB_WORKSPACE/packages/paperclip-runner/runner/target/release/paperclip-runnerd" \ + "$RUNNER_TEMP/runner-release/$TARGET" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: runner-release-${{ inputs.ref }}-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.target }} + path: ${{ runner.temp }}/runner-release/ + if-no-files-found: error + retention-days: 7 + + runner_release_pack: + needs: runner_release_request + if: inputs.runner_assets_only + name: Actual release image Linux daemon and provider-pack identity + permissions: + contents: read + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + with: + version: 9.15.4 + run_install: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 + - name: Extract the actual exact-source image provider-pack + env: + SOURCE_SHA: ${{ inputs.ref }} + run: | + set -euo pipefail + [[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]] + test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + mkdir -p "$RUNNER_TEMP/runner-release-input" + owner="paperclip-release-pack-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + trap 'docker rm -f "$owner" >/dev/null 2>&1 || true' EXIT + # Canary images publish after npm. Build the existing image target + # without registry credentials and consume its actual source bytes. + pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile + image="$owner" + docker buildx build --platform linux/amd64 --target runner-provider-pack \ + --build-arg "PAPERCLIP_BUILD_COMMIT=$SOURCE_SHA" \ + --label "org.opencontainers.image.revision=$SOURCE_SHA" \ + --cache-from type=registry,ref=ghcr.io/paperclipai/paperclip:buildcache-amd64 \ + --tag "$image" --load . + pack_path=/provider-pack/provider-pack.json + docker image inspect "$image" > "$RUNNER_TEMP/runner-release-input/image.json" + daemon_path=/app/server/dist/vendor/paperclip-runner/bin/paperclip-runnerd + docker create --name "$owner" --network none --user 1000:1000 --read-only \ + --cap-drop ALL --security-opt no-new-privileges \ + --entrypoint "$daemon_path" "$image" --build-metadata >/dev/null + docker cp "$owner:$pack_path" "$RUNNER_TEMP/runner-release-input/provider-pack.json" + docker cp "$owner:$daemon_path" "$RUNNER_TEMP/runner-release-input/paperclip-runnerd" + # Execute only inside the source image. Its libc may be newer than + # ubuntu-latest; host execution would not prove this image artifact. + timeout 30s docker start --attach "$owner" > "$RUNNER_TEMP/runner-release-input/daemon-metadata.json" + test "$(docker inspect --format '{{.State.ExitCode}}' "$owner")" = 0 + node scripts/release-runner-artifacts.mjs record-image-binary "$SOURCE_SHA" \ + "$RUNNER_TEMP/runner-release-input/paperclip-runnerd" "$RUNNER_TEMP/runner-release-input/daemon-metadata.json" \ + "$RUNNER_TEMP/runner-release-input/image.json" "$RUNNER_TEMP/runner-release/linux-x64" + node scripts/release-runner-artifacts.mjs record-pack "$SOURCE_SHA" \ + "$RUNNER_TEMP/runner-release-input/provider-pack.json" "$RUNNER_TEMP/runner-release-input/image.json" \ + "$RUNNER_TEMP/runner-release/provider-pack" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: runner-release-${{ inputs.ref }}-${{ github.run_id }}-${{ github.run_attempt }}-provider-pack + path: ${{ runner.temp }}/runner-release/ + if-no-files-found: error + retention-days: 7 + + runner_release_assemble: + if: inputs.runner_assets_only + name: Assemble verified release runner data + needs: [runner_release_binaries, runner_release_pack] + permissions: + contents: read + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + artifact_name: ${{ steps.identity.outputs.artifact_name }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: runner-release-${{ inputs.ref }}-${{ github.run_id }}-${{ github.run_attempt }}-* + path: ${{ runner.temp }}/runner-release-inputs + merge-multiple: true + - name: Run the sole source-aware assembler and bound its data envelope + env: + SOURCE_SHA: ${{ inputs.ref }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + node scripts/release-runner-artifacts.mjs assemble "$SOURCE_SHA" \ + "$RUNNER_TEMP/runner-release-inputs" "$RUNNER_TEMP/runner-release-assembled" + - name: Record run-bound assembled artifact + id: identity + env: + SOURCE_SHA: ${{ inputs.ref }} + run: echo "artifact_name=runner-release-assembled-$SOURCE_SHA-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ${{ steps.identity.outputs.artifact_name }} + path: | + ${{ runner.temp }}/runner-release-assembled/runner-release-assets.tar.gz + ${{ runner.temp }}/runner-release-assembled/runner-release-assets.tar.gz.sha256 + if-no-files-found: error + retention-days: 7 + + runner_release_request: + if: inputs.runner_assets_only + name: Validate immutable runner producer inputs + permissions: {} + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Reject mutable sources before any producer checkout + env: + SOURCE_SHA: ${{ inputs.ref }} + run: | + set -euo pipefail + [[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8fdbe42109..a03c60e3b96 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -337,8 +337,11 @@ jobs: run: node scripts/cloud-source-verification.mjs "$SOURCE_SHA" publish_canary: - if: github.event_name == 'push' - needs: verify_canary + if: >- + !cancelled() && github.event_name == 'push' && needs.verify_canary.result == 'success' && + needs.pin_runner_release_source.result == 'success' && + (needs.pin_runner_release_source.outputs.required != 'true' || needs.release_runner_assets.result == 'success') + needs: [verify_canary, pin_runner_release_source, release_runner_assets] runs-on: ubuntu-latest timeout-minutes: 150 environment: npm-canary @@ -350,10 +353,14 @@ jobs: # For the explicit docker.yml dispatch below. actions: write + defaults: + run: + working-directory: source steps: - name: Checkout repository uses: actions/checkout@v7 with: + path: source fetch-depth: 0 - name: Setup pnpm @@ -366,6 +373,33 @@ jobs: with: node-version: 24 cache: pnpm + cache-dependency-path: source/pnpm-lock.yaml + + - name: Checkout trusted release transfer tools + if: needs.pin_runner_release_source.outputs.required == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + path: trusted + persist-credentials: false + - name: Download this run's verified runner data + if: needs.pin_runner_release_source.outputs.required == 'true' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ needs.release_runner_assets.outputs.runner_assets_artifact }} + path: ${{ runner.temp }}/runner-release-archive + - name: Validate runner data against the selected source before publishing + if: needs.pin_runner_release_source.outputs.required == 'true' + env: + SOURCE_SHA: ${{ needs.pin_runner_release_source.outputs.source_sha }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + node "$GITHUB_WORKSPACE/trusted/scripts/release-runner-artifacts.mjs" unpack "$SOURCE_SHA" \ + "$RUNNER_TEMP/runner-release-archive/runner-release-assets.tar.gz" \ + "$RUNNER_TEMP/runner-release-archive/runner-release-assets.tar.gz.sha256" \ + "$RUNNER_TEMP/runner-release-assets" "$GITHUB_WORKSPACE/source" + echo "PAPERCLIP_RELEASE_RUNNER_ASSETS=$RUNNER_TEMP/runner-release-assets" >> "$GITHUB_ENV" - name: Validate release package manifest run: node ./scripts/release-package-map.mjs check @@ -587,11 +621,13 @@ jobs: artifact_name: nightly-release-smoke publish_nightly: - needs: [select_nightly, smoke_nightly] + needs: [select_nightly, smoke_nightly, pin_runner_release_source, release_runner_assets] # Publish when smoke passed, or when smoke was deliberately skipped by a # dry-run dispatch (the publish itself is a dry-run in that case). if: >- !cancelled() && + needs.pin_runner_release_source.result == 'success' && + (needs.pin_runner_release_source.outputs.required != 'true' || needs.release_runner_assets.result == 'success') && needs.select_nightly.outputs.proceed == 'true' && (needs.smoke_nightly.result == 'success' || (needs.smoke_nightly.result == 'skipped' && github.event_name == 'workflow_dispatch' && inputs.dry_run)) @@ -611,10 +647,14 @@ jobs: id-token: write actions: write + defaults: + run: + working-directory: source steps: - name: Checkout repository uses: actions/checkout@v7 with: + path: source ref: ${{ needs.select_nightly.outputs.sha }} fetch-depth: 0 @@ -628,6 +668,33 @@ jobs: with: node-version: 24 cache: pnpm + cache-dependency-path: source/pnpm-lock.yaml + + - name: Checkout trusted release transfer tools + if: needs.pin_runner_release_source.outputs.required == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + path: trusted + persist-credentials: false + - name: Download this run's verified runner data + if: needs.pin_runner_release_source.outputs.required == 'true' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ needs.release_runner_assets.outputs.runner_assets_artifact }} + path: ${{ runner.temp }}/runner-release-archive + - name: Validate runner data against the selected source before publishing + if: needs.pin_runner_release_source.outputs.required == 'true' + env: + SOURCE_SHA: ${{ needs.pin_runner_release_source.outputs.source_sha }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + node "$GITHUB_WORKSPACE/trusted/scripts/release-runner-artifacts.mjs" unpack "$SOURCE_SHA" \ + "$RUNNER_TEMP/runner-release-archive/runner-release-assets.tar.gz" \ + "$RUNNER_TEMP/runner-release-archive/runner-release-assets.tar.gz.sha256" \ + "$RUNNER_TEMP/runner-release-assets" "$GITHUB_WORKSPACE/source" + echo "PAPERCLIP_RELEASE_RUNNER_ASSETS=$RUNNER_TEMP/runner-release-assets" >> "$GITHUB_ENV" - name: Validate release package manifest run: node ./scripts/release-package-map.mjs check @@ -855,9 +922,11 @@ jobs: ref: ${{ needs.select_beta.outputs.sha }} publish_beta: - needs: [select_beta, verify_beta_candidate] + needs: [select_beta, verify_beta_candidate, pin_runner_release_source, release_runner_assets] if: >- !cancelled() && + needs.pin_runner_release_source.result == 'success' && + (needs.pin_runner_release_source.outputs.required != 'true' || needs.release_runner_assets.result == 'success') && needs.select_beta.result == 'success' && (needs.verify_beta_candidate.result == 'success' || (needs.verify_beta_candidate.result == 'skipped' && needs.select_beta.outputs.mode == 'promote')) @@ -877,10 +946,14 @@ jobs: outputs: beta_version: ${{ steps.result.outputs.beta_version }} + defaults: + run: + working-directory: source steps: - name: Checkout repository uses: actions/checkout@v7 with: + path: source ref: ${{ needs.select_beta.outputs.sha }} fetch-depth: 0 @@ -894,6 +967,33 @@ jobs: with: node-version: 24 cache: pnpm + cache-dependency-path: source/pnpm-lock.yaml + + - name: Checkout trusted release transfer tools + if: needs.pin_runner_release_source.outputs.required == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + path: trusted + persist-credentials: false + - name: Download this run's verified runner data + if: needs.pin_runner_release_source.outputs.required == 'true' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ needs.release_runner_assets.outputs.runner_assets_artifact }} + path: ${{ runner.temp }}/runner-release-archive + - name: Validate runner data against the selected source before publishing + if: needs.pin_runner_release_source.outputs.required == 'true' + env: + SOURCE_SHA: ${{ needs.pin_runner_release_source.outputs.source_sha }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + node "$GITHUB_WORKSPACE/trusted/scripts/release-runner-artifacts.mjs" unpack "$SOURCE_SHA" \ + "$RUNNER_TEMP/runner-release-archive/runner-release-assets.tar.gz" \ + "$RUNNER_TEMP/runner-release-archive/runner-release-assets.tar.gz.sha256" \ + "$RUNNER_TEMP/runner-release-assets" "$GITHUB_WORKSPACE/source" + echo "PAPERCLIP_RELEASE_RUNNER_ASSETS=$RUNNER_TEMP/runner-release-assets" >> "$GITHUB_ENV" - name: Validate release package manifest run: node ./scripts/release-package-map.mjs check @@ -1240,17 +1340,24 @@ jobs: ref: ${{ needs.preflight_stable.outputs.sha }} preview_stable: - if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && inputs.dry_run - needs: [preflight_stable, verify_stable] + if: >- + !cancelled() && github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && inputs.dry_run && + needs.verify_stable.result == 'success' && needs.pin_runner_release_source.result == 'success' && + (needs.pin_runner_release_source.outputs.required != 'true' || needs.release_runner_assets.result == 'success') + needs: [preflight_stable, verify_stable, pin_runner_release_source, release_runner_assets] runs-on: ubuntu-latest timeout-minutes: 45 permissions: contents: read + defaults: + run: + working-directory: source steps: - name: Checkout repository uses: actions/checkout@v7 with: + path: source fetch-depth: 0 ref: ${{ needs.preflight_stable.outputs.sha }} @@ -1264,6 +1371,33 @@ jobs: with: node-version: 24 cache: pnpm + cache-dependency-path: source/pnpm-lock.yaml + + - name: Checkout trusted release transfer tools + if: needs.pin_runner_release_source.outputs.required == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + path: trusted + persist-credentials: false + - name: Download this run's verified runner data + if: needs.pin_runner_release_source.outputs.required == 'true' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ needs.release_runner_assets.outputs.runner_assets_artifact }} + path: ${{ runner.temp }}/runner-release-archive + - name: Validate runner data against the selected source before publishing + if: needs.pin_runner_release_source.outputs.required == 'true' + env: + SOURCE_SHA: ${{ needs.pin_runner_release_source.outputs.source_sha }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + node "$GITHUB_WORKSPACE/trusted/scripts/release-runner-artifacts.mjs" unpack "$SOURCE_SHA" \ + "$RUNNER_TEMP/runner-release-archive/runner-release-assets.tar.gz" \ + "$RUNNER_TEMP/runner-release-archive/runner-release-assets.tar.gz.sha256" \ + "$RUNNER_TEMP/runner-release-assets" "$GITHUB_WORKSPACE/source" + echo "PAPERCLIP_RELEASE_RUNNER_ASSETS=$RUNNER_TEMP/runner-release-assets" >> "$GITHUB_ENV" - name: Validate release package manifest run: node ./scripts/release-package-map.mjs check @@ -1282,8 +1416,11 @@ jobs: ./scripts/release.sh "${args[@]}" publish_stable: - if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run - needs: [preflight_stable, verify_stable] + if: >- + !cancelled() && github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run && + needs.verify_stable.result == 'success' && needs.pin_runner_release_source.result == 'success' && + (needs.pin_runner_release_source.outputs.required != 'true' || needs.release_runner_assets.result == 'success') + needs: [preflight_stable, verify_stable, pin_runner_release_source, release_runner_assets] runs-on: ubuntu-latest timeout-minutes: 150 environment: npm-stable @@ -1294,10 +1431,14 @@ jobs: outputs: stable_version: ${{ steps.tag.outputs.version }} + defaults: + run: + working-directory: source steps: - name: Checkout repository uses: actions/checkout@v7 with: + path: source fetch-depth: 0 ref: ${{ needs.preflight_stable.outputs.sha }} @@ -1311,6 +1452,33 @@ jobs: with: node-version: 24 cache: pnpm + cache-dependency-path: source/pnpm-lock.yaml + + - name: Checkout trusted release transfer tools + if: needs.pin_runner_release_source.outputs.required == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + path: trusted + persist-credentials: false + - name: Download this run's verified runner data + if: needs.pin_runner_release_source.outputs.required == 'true' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ needs.release_runner_assets.outputs.runner_assets_artifact }} + path: ${{ runner.temp }}/runner-release-archive + - name: Validate runner data against the selected source before publishing + if: needs.pin_runner_release_source.outputs.required == 'true' + env: + SOURCE_SHA: ${{ needs.pin_runner_release_source.outputs.source_sha }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + node "$GITHUB_WORKSPACE/trusted/scripts/release-runner-artifacts.mjs" unpack "$SOURCE_SHA" \ + "$RUNNER_TEMP/runner-release-archive/runner-release-assets.tar.gz" \ + "$RUNNER_TEMP/runner-release-archive/runner-release-assets.tar.gz.sha256" \ + "$RUNNER_TEMP/runner-release-assets" "$GITHUB_WORKSPACE/source" + echo "PAPERCLIP_RELEASE_RUNNER_ASSETS=$RUNNER_TEMP/runner-release-assets" >> "$GITHUB_ENV" - name: Install dependencies run: pnpm install --no-frozen-lockfile @@ -1477,3 +1645,63 @@ jobs: echo "- Open and merge the PR (a human opens it so CI runs):" echo " https://github.com/${GITHUB_REPOSITORY}/compare/master...${branch}?expand=1" } >> "$GITHUB_STEP_SUMMARY" + + # Resolve one applicable release channel after its existing gate. Optional + # channels are deliberately skipped, so use explicit results, never an + # implicit all-needs-success dependency that would suppress promotions. + pin_runner_release_source: + needs: [verify_canary, select_nightly, select_beta, verify_beta_candidate, preflight_stable, verify_stable] + if: >- + !cancelled() && + ((github.event_name == 'push' && needs.verify_canary.result == 'success') || + (needs.select_nightly.result == 'success' && needs.select_nightly.outputs.proceed == 'true') || + (needs.select_beta.result == 'success' && + (needs.verify_beta_candidate.result == 'success' || needs.select_beta.outputs.mode == 'promote')) || + (needs.preflight_stable.result == 'success' && needs.verify_stable.result == 'success')) + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + source_sha: ${{ steps.pin.outputs.source_sha }} + required: ${{ steps.pin.outputs.required }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false + - name: Pin the gated source and identify its release assembly contract + id: pin + env: + EVENT_NAME: ${{ github.event_name }} + CANARY_SHA: ${{ github.sha }} + NIGHTLY_SHA: ${{ needs.select_nightly.outputs.sha }} + BETA_SHA: ${{ needs.select_beta.outputs.sha }} + STABLE_SHA: ${{ needs.preflight_stable.outputs.sha }} + run: | + set -euo pipefail + if [[ "$EVENT_NAME" == push ]]; then source_sha="$CANARY_SHA"; + elif [[ -n "$NIGHTLY_SHA" ]]; then source_sha="$NIGHTLY_SHA"; + elif [[ -n "$BETA_SHA" ]]; then source_sha="$BETA_SHA"; + else source_sha="$STABLE_SHA"; fi + [[ "$source_sha" =~ ^[a-f0-9]{40}$ ]] + git cat-file -e "$source_sha^{commit}" + required=false + # Promoted older sources retain their original contract. New sources + # opt in with the checked-in hook and may never publish partial assets. + if git show "$source_sha:scripts/release.sh" | grep -F 'PAPERCLIP_RELEASE_RUNNER_ASSETS' >/dev/null; then + git cat-file -e "$source_sha:scripts/release-runner-artifacts.mjs" + required=true + fi + echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT" + echo "required=$required" >> "$GITHUB_OUTPUT" + + release_runner_assets: + needs: pin_runner_release_source + if: needs.pin_runner_release_source.outputs.required == 'true' + permissions: + contents: read + uses: ./.github/workflows/release-verify.yml + with: + ref: ${{ needs.pin_runner_release_source.outputs.source_sha }} + runner_assets_only: true diff --git a/packages/paperclip-runner/scripts/release-provider-pack.mjs b/packages/paperclip-runner/scripts/release-provider-pack.mjs index cedbde6bbf2..a44bbba3cfb 100644 --- a/packages/paperclip-runner/scripts/release-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/release-provider-pack.mjs @@ -7,7 +7,14 @@ const canonical = value => Array.isArray(value) ? `[${value.map(canonical).join( : JSON.stringify(value); /** Release assembly must reject a self-consistent pack built for another release. */ -export function verifyReleaseProviderPack(identity, sourceRevision) { +export function verifyReleaseProviderPack(identity, sourceRevision, sourceProfiles = profiles, sourceDistributions = distributions) { + if (sourceProfiles?.schema !== "paperclip.acpx-profiles.v1" || typeof sourceProfiles.acpxVersion !== "string" || !sourceProfiles.acpxVersion + || ["grok", "claude", "codex", "cursor"].some(agent => !/^sha256:[a-f0-9]{64}$/.test(sourceProfiles?.profiles?.[agent]?.commandDigest ?? "")) + || typeof sourceProfiles?.profiles?.cursor?.agentServerVersion !== "string" || !sourceProfiles.profiles.cursor.agentServerVersion + || sourceDistributions?.schema !== "paperclip.cursor_distribution.v1" + || !/^[a-f0-9]{64}$/.test(sourceDistributions?.platforms?.["linux-x64"]?.closureSha256 ?? "")) { + throw new Error("Invalid release qualification snapshots"); + } const payload = identity?.payload; if (identity?.schema !== "paperclip-runner/remote-provider-pack/v1" || !payload || payload.target?.platform !== "linux" || payload.target?.architecture !== "x64" @@ -15,14 +22,14 @@ export function verifyReleaseProviderPack(identity, sourceRevision) { if (identity.digest !== `sha256:${createHash("sha256").update(canonical(payload)).digest("hex")}`) { throw new Error("Remote provider-pack payload digest mismatch"); } - if (payload.pins?.acpx !== profiles.acpxVersion - || ["grok", "claude", "codex"].some(agent => payload.acpxProfileDigests?.[agent] !== profiles.profiles[agent].commandDigest)) { + if (payload.pins?.acpx !== sourceProfiles.acpxVersion + || ["grok", "claude", "codex"].some(agent => payload.acpxProfileDigests?.[agent] !== sourceProfiles.profiles[agent].commandDigest)) { throw new Error("Remote provider-pack ACPX profiles do not match this release"); } - const current = profiles.profiles.cursor; + const current = sourceProfiles.profiles.cursor; for (const cursor of [payload.providers?.cursor, ...(Object.hasOwn(payload.candidateProviders ?? {}, "cursor") ? [payload.candidateProviders.cursor] : [])]) { if (!cursor || cursor.version !== current.agentServerVersion || cursor.profileDigest !== current.commandDigest - || cursor.closureDigest !== `sha256:${distributions.platforms["linux-x64"].closureSha256}` + || cursor.closureDigest !== `sha256:${sourceDistributions.platforms["linux-x64"].closureSha256}` || cursor.qualification !== "qualified" || cursor.path !== "provider-assets/cursor/linux-x64" || !/^sha256:[a-f0-9]{64}$/.test(cursor.sha256 ?? "")) { throw new Error("Remote provider-pack Cursor identity does not match this release"); diff --git a/packages/paperclip-runner/scripts/release-provider-pack.test.mjs b/packages/paperclip-runner/scripts/release-provider-pack.test.mjs index 33d6de5874f..bba8b7f8687 100644 --- a/packages/paperclip-runner/scripts/release-provider-pack.test.mjs +++ b/packages/paperclip-runner/scripts/release-provider-pack.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; -import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -9,6 +9,8 @@ import test from "node:test"; import profiles from "../acpx-profiles.json" with { type: "json" }; import distributions from "../cursor-distributions.json" with { type: "json" }; import { verifyReleaseProviderPack } from "./release-provider-pack.mjs"; +import { makeRunnerReleaseArchive, readRunnerReleaseArchive, verifyReleaseDaemonMetadata, verifyRunnerReleaseData } from "../../../scripts/release-runner-artifacts.mjs"; +import { gzipSync, gunzipSync } from "node:zlib"; const revision = "a".repeat(40); const digest = "sha256:" + "b".repeat(64); @@ -26,6 +28,162 @@ function fixture() { function seal(payload) { return { schema: "paperclip-runner/remote-provider-pack/v1", payload, digest: `sha256:${createHash("sha256").update(canonical(payload)).digest("hex")}` }; } +function daemonMetadata() { return { schema: 'paperclip-runner/runnerd-build-metadata/v1', binaryName: 'paperclip-runnerd', + packageName: '@paperclipai/paperclip-runner', packageVersion: '0.0.0', binaryContractVersion: 2, nativeExecutionVersion: 1, + harnessDriverVersion: 1, prp: { name: 'paperclip.runner', minimumVersion: 1, maximumVersion: 2 }, + durableSessionCapabilities: ['unlimited_runtime', 'connection_lease_renewal'], + prpTransportModes: ['dial_ws_loopback', 'dial_wss', 'listen_ws'] }; } + +test('release daemon compatibility rejects lookalikes, incompatible contracts and missing production capabilities', () => { + assert.equal(verifyReleaseDaemonMetadata(daemonMetadata()).binaryContractVersion, 2); + for (const mismatch of [ + { schema: 'other' }, { binaryName: 'other' }, { packageName: 'other' }, { packageVersion: '' }, + { binaryContractVersion: 999 }, { nativeExecutionVersion: 2 }, { harnessDriverVersion: 2 }, + { prp: { name: 'other', minimumVersion: 1, maximumVersion: 2 } }, + { prp: { name: 'paperclip.runner', minimumVersion: 2, maximumVersion: 2 } }, + { prp: { name: 'paperclip.runner', minimumVersion: 1, maximumVersion: 1 } }, + { durableSessionCapabilities: ['unlimited_runtime'] }, { prpTransportModes: ['dial_ws_loopback'] }, + ]) assert.throws(() => verifyReleaseDaemonMetadata({ ...daemonMetadata(), ...mismatch })); +}); + +function releaseData() { + const files = new Map(), platforms = {}; + for (const target of ["darwin-arm64", "darwin-x64", "linux-x64"]) { + const bytes = Buffer.alloc(64); + if (target === "linux-x64") { bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(62, 18); } + else { bytes.writeUInt32LE(0xfeedfacf, 0); bytes.writeUInt32LE(target === "darwin-arm64" ? 0x0100000c : 0x01000007, 4); } + files.set(`bin/${target}/paperclip-runnerd`, bytes); + platforms[target] = { path: `${target}/paperclip-runnerd`, sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}` }; + } + const pack = seal(fixture()); + files.set("remote-provider-packs/linux-x64/provider-pack.json", Buffer.from(JSON.stringify(pack))); + files.set("bin/release-manifest.json", Buffer.from(JSON.stringify({ schema: "paperclip.runner.release-binaries.v1", sourceRevision: revision, + platforms, remoteProviderPack: { target: "linux-x64", sourceRevision: revision, digest: pack.digest } }))); + files.set("assembly-receipt.json", Buffer.from(JSON.stringify({ schema: "paperclip.runner.release-assembly.v1", sourceRevision: revision, + sourcePins: { profiles: `sha256:${createHash("sha256").update(readFileSync(new URL('../acpx-profiles.json', import.meta.url))).digest('hex')}`, + distributions: `sha256:${createHash("sha256").update(readFileSync(new URL('../cursor-distributions.json', import.meta.url))).digest('hex')}` }, + providerImage: { sourceRevision: revision, platform: "linux/amd64", imageId: digest }, + linuxDaemonImage: { sourceRevision: revision, platform: "linux/amd64", imageId: digest }, + daemonMetadata: Object.fromEntries(Object.keys(platforms).map(target => [target, daemonMetadata()])), + files: Object.fromEntries([...files].map(([path, bytes]) => [path, `sha256:${createHash("sha256").update(bytes).digest("hex")}`])) }))); + return files; +} + +test("release transfer preserves only the assembled daemon and actual provider-pack data", () => { + const files = releaseData(), archive = makeRunnerReleaseArchive(files, revision); + const checksum = `sha256:${createHash("sha256").update(archive).digest("hex")}`; + assert.deepEqual(readRunnerReleaseArchive(archive, revision, checksum), files); + assert.throws(() => readRunnerReleaseArchive(archive, "c".repeat(40), checksum), /source mismatch/); + assert.throws(() => readRunnerReleaseArchive(archive, revision, digest), /archive checksum mismatch/); +}); + +test('the real transfer CLI validates before extraction, restores executable modes and refuses existing output', () => { + const root = mkdtempSync(join(tmpdir(), 'runner-release-transfer-')); + const helper = fileURLToPath(new URL('../../../scripts/release-runner-artifacts.mjs', import.meta.url)); + try { + const archive = makeRunnerReleaseArchive(releaseData(), revision), archivePath = join(root, 'assets.tar.gz'); + const checksumPath = join(root, 'assets.sha256'), output = join(root, 'output'); + writeFileSync(archivePath, archive); writeFileSync(checksumPath, digest); + const invoke = (...args) => spawnSync(process.execPath, [helper, ...args], { encoding: 'utf8' }); + let result = invoke('unpack', revision, archivePath, checksumPath, output); + assert.equal(result.status, 1); assert.match(result.stderr, /archive checksum mismatch/); + assert.equal(existsSync(output), false, 'No data can be written before the entire envelope passes'); + writeFileSync(checksumPath, `sha256:${createHash('sha256').update(archive).digest('hex')}`); + result = invoke('unpack', revision, archivePath, checksumPath, output); + assert.equal(result.status, 0, result.stderr); + assert.equal(lstatSync(join(output, 'bin/darwin-arm64/paperclip-runnerd')).mode & 0o777, 0o755); + assert.equal(lstatSync(join(output, 'assembly-receipt.json')).mode & 0o777, 0o644); + result = invoke('unpack', revision, archivePath, checksumPath, output); + assert.equal(result.status, 1); assert.match(result.stderr, /new absolute owned directory/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('image-sourced Linux daemon recording validates data without executing its ELF on the host', () => { + const root = mkdtempSync(join(tmpdir(), 'runner-image-daemon-record-')); + const helper = fileURLToPath(new URL('../../../scripts/release-runner-artifacts.mjs', import.meta.url)); + try { + // This header fixture cannot execute. Successful recording proves this + // transfer leg consumes image-probed metadata rather than host execution. + const binary = join(root, 'runnerd'), metadata = join(root, 'metadata.json'), imagePath = join(root, 'image.json'); + const image = `ghcr.io/paperclipai/paperclip@${digest}`; + writeFileSync(binary, releaseData().get('bin/linux-x64/paperclip-runnerd'), { mode: 0o755 }); + writeFileSync(metadata, JSON.stringify(daemonMetadata())); + const imageData = { Os: 'linux', Architecture: 'amd64', Id: digest, RepoDigests: [image], + Config: { Labels: { 'org.opencontainers.image.revision': revision }, Env: ['PRIVATE_TOKEN=do-not-export'] } }; + writeFileSync(imagePath, JSON.stringify([imageData])); + const output = join(root, 'output'); + let result = spawnSync(process.execPath, [helper, 'record-image-binary', revision, binary, metadata, imagePath, output, image], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + const receipt = readFileSync(join(output, 'receipt.json'), 'utf8'); + assert.equal(JSON.parse(receipt).providerImage.requestedImage, image); + assert.equal(JSON.parse(receipt).sha256, `sha256:${createHash('sha256').update(readFileSync(binary)).digest('hex')}`); + assert.doesNotMatch(receipt, /PRIVATE_TOKEN|do-not-export|Config/); + imageData.Config.Labels['org.opencontainers.image.revision'] = 'c'.repeat(40); + writeFileSync(imagePath, JSON.stringify([imageData])); + const wrong = join(root, 'wrong'); + result = spawnSync(process.execPath, [helper, 'record-image-binary', revision, binary, metadata, imagePath, wrong, image], { encoding: 'utf8' }); + assert.equal(result.status, 1); assert.equal(existsSync(wrong), false); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("release transfer rejects links, unsafe or duplicate paths, truncation and permissions before extraction", () => { + const archive = makeRunnerReleaseArchive(releaseData(), revision); + for (const corrupt of [ + tar => { tar.fill(0, 0, 100); tar.write("../paperclip-runnerd", 0); }, + tar => { tar[156] = 50; }, + tar => { tar.subarray(0, 100).copy(tar, 1024); }, + tar => { tar.write("00004755\0", 100, 8); }, + ]) { + const tar = gunzipSync(archive); corrupt(tar); + const headerStart = tar[1024] === tar[0] && tar.subarray(1024, 1124).equals(tar.subarray(0, 100)) ? 1024 : 0; + const header = tar.subarray(headerStart, headerStart + 512); header.fill(32, 148, 156); + const sum = [...header].reduce((sum, byte) => sum + byte, 0); + header.write(sum.toString(8).padStart(6, "0") + "\0 ", 148, 8); + const bytes = gzipSync(tar), hash = `sha256:${createHash("sha256").update(bytes).digest("hex")}`; + assert.throws(() => readRunnerReleaseArchive(bytes, revision, hash), /Unsafe|forbidden|permissions/); + } + const truncated = archive.subarray(0, archive.length - 4); + assert.throws(() => readRunnerReleaseArchive(truncated, revision, `sha256:${createHash("sha256").update(truncated).digest("hex")}`)); +}); + +test("release data rejects rehashed wrong targets and provider-image provenance", () => { + const files = releaseData(); + files.set("bin/darwin-arm64/paperclip-runnerd", files.get("bin/linux-x64/paperclip-runnerd")); + const receipt = JSON.parse(files.get("assembly-receipt.json")); + receipt.files["bin/darwin-arm64/paperclip-runnerd"] = `sha256:${createHash("sha256").update(files.get("bin/darwin-arm64/paperclip-runnerd")).digest("hex")}`; + files.set("assembly-receipt.json", Buffer.from(JSON.stringify(receipt))); + assert.throws(() => verifyRunnerReleaseData(files, revision), /architecture mismatch/); + for (const changes of [{ sourceRevision: "c".repeat(40) }, { requestedImage: `ghcr.io/paperclipai/paperclip@${digest}`, repoDigests: [] }]) { + const source = releaseData(), receipt = JSON.parse(source.get("assembly-receipt.json")); + Object.assign(receipt.providerImage, changes); source.set("assembly-receipt.json", Buffer.from(JSON.stringify(receipt))); + assert.throws(() => verifyRunnerReleaseData(source, revision), /image (?:source|digest) mismatch/); + } + const incompatible = releaseData(), incompatibleReceipt = JSON.parse(incompatible.get('assembly-receipt.json')); + incompatibleReceipt.daemonMetadata['linux-x64'].nativeExecutionVersion = 999; + incompatible.set('assembly-receipt.json', Buffer.from(JSON.stringify(incompatibleReceipt))); + assert.throws(() => verifyRunnerReleaseData(incompatible, revision), /Incompatible daemon nativeExecutionVersion/); + const mixed = releaseData(), mixedReceipt = JSON.parse(mixed.get('assembly-receipt.json')); + mixedReceipt.linuxDaemonImage.imageId = `sha256:${'c'.repeat(64)}`; + mixed.set('assembly-receipt.json', Buffer.from(JSON.stringify(mixedReceipt))); + assert.throws(() => verifyRunnerReleaseData(mixed, revision), /same verified image/); +}); + +test("release transfer binds qualification JSON snapshots to the selected source", () => { + const files = releaseData(); + assert.throws(() => verifyRunnerReleaseData(files, revision, { profiles: Buffer.from('{}'), distributions: Buffer.from('{}') }), /source profile snapshot mismatch/); + for (const snapshots of [[{}, distributions], [profiles, {}], [null, null]]) { + assert.throws(() => verifyReleaseProviderPack(seal(fixture()), revision, ...snapshots), /Invalid release qualification snapshots/); + } + const noCursorVersion = structuredClone(profiles); delete noCursorVersion.profiles.cursor.agentServerVersion; + const malformedClosure = structuredClone(distributions); malformedClosure.platforms['linux-x64'].closureSha256 = 'unknown'; + for (const snapshots of [[noCursorVersion, distributions], [profiles, malformedClosure]]) { + assert.throws(() => verifyReleaseProviderPack(seal(fixture()), revision, ...snapshots), /Invalid release qualification snapshots/); + } + const altered = structuredClone(profiles); altered.profiles.claude.commandDigest = digest; + assert.throws(() => verifyReleaseProviderPack(seal(fixture()), revision, altered, distributions), /ACPX profiles/); + assert.equal(verifyReleaseProviderPack(seal(fixture()), revision, profiles, distributions).payload.runnerSourceRevision, revision); +}); + test("accepts a pack whose source and profiles match the assembled release", () => { const manifest = seal(fixture()); assert.equal(verifyReleaseProviderPack(manifest, revision), manifest); }); diff --git a/scripts/__tests__/grok-public-install-sandbox.test.mjs b/scripts/__tests__/grok-public-install-sandbox.test.mjs index 316b417d316..b2ffa291e42 100644 --- a/scripts/__tests__/grok-public-install-sandbox.test.mjs +++ b/scripts/__tests__/grok-public-install-sandbox.test.mjs @@ -1,7 +1,10 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import test from 'node:test'; -import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, grokConsumerDockerArgs } from '../grok-public-install-sandbox.mjs'; +import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, grokConsumerDockerArgs, installedCodexProbeSource } from '../grok-public-install-sandbox.mjs'; const paths = { assets: '/private/staging/assets', consumer: '/private/staging/consumer', cache: '/private/staging/cache', uid: 1001, gid: 1001 }; const values = (args, flag) => args.flatMap((value, index) => value === flag ? [args[index + 1]] : []); @@ -50,3 +53,25 @@ test('verification never elevates PR-controlled provisioning or cleanup on the h assert.doesNotMatch(source, /\bsudo\b/); assert.ok(source.includes("const prerequisite = join(root, 'native/grok')")); }); + +test('the installed Codex probe exercises the public export and rejects incomplete or mismatched packages', () => { + const root = mkdtempSync(join(tmpdir(), 'installed-codex-probe-')); + try { + const consumer = join(root, 'consumer'); mkdirSync(consumer); + const index = join(consumer, 'index.mjs'), command = join(consumer, 'codex'), probe = join(root, 'probe.mjs'); + const executable = version => writeFileSync(command, `#!/bin/sh\n[ "$1" = --version ] || exit 65\nprintf '%s\\n' 'codex-cli ${version}'\n`, { mode: 0o755 }); + writeFileSync(probe, installedCodexProbeSource(index, consumer, '0.160.0')); + const run = () => spawnSync(process.execPath, [probe], { encoding: 'utf8', timeout: 10_000, env: { PATH: '/usr/bin:/bin', NODE_PATH: '' } }); + writeFileSync(index, `export const resolvePinnedCodexCommand = () => ${JSON.stringify(command)};`); + executable('0.160.0'); + let result = run(); assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).pinnedCodexCommandVerified, true); + assert.equal(JSON.parse(result.stdout).providerCalls, 0); + executable('9.9.9'); result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /qualified pin/); + rmSync(command); result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /ENOENT/); + const outside = join(root, 'outside'); writeFileSync(outside, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + writeFileSync(index, `export const resolvePinnedCodexCommand = () => ${JSON.stringify(outside)};`); + result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /inside the installed consumer/); + writeFileSync(index, 'export const unrelated = true;'); result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /must export the pinned Codex resolver/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs index 293280c2d14..71df7871ce3 100644 --- a/scripts/__tests__/release-verify-workflow.test.mjs +++ b/scripts/__tests__/release-verify-workflow.test.mjs @@ -15,6 +15,110 @@ function readWorkflow(name) { return readFileSync(path.join(repoRoot, ".github/workflows", name), "utf8"); } +function job(workflow, name) { + return workflow.split(`\n ${name}:\n`)[1]?.split(/\n [a-z_]+:\n/)[0] ?? ''; +} +function condition(block, needs, github = {}, inputs = {}, isCancelled = false) { + const expression = block.match(/ if: >-\n((?: .+\n)+)/)?.[1]?.trim(); + assert.ok(expression, 'Routing must use an explicit condition, including skipped ancestors'); + return Function('needs', 'github', 'inputs', 'cancelled', `return (${expression})`)(needs, github, inputs, () => isCancelled); +} + +test('release producer mode validates immutable data inputs and adds only the missing native target jobs', () => { + const workflow = readWorkflow('release-verify.yml'); + assert.match(workflow, /runner_assets_only:[\s\S]*?default: false/); + for (const name of ['runner_chaos_evals', 'typecheck', 'general_tests', 'serialized_tests', 'runner_workflow_evals', 'verify_paperclip_runner', 'build']) { + // The default source gate keeps all its prior checks. Producer-only mode + // must not create a second broad suite or repeat paid eval authorization. + assert.match(job(workflow, name), /if: \$\{\{ !inputs\.runner_assets_only \}\}/, name); + } + const request = job(workflow, 'runner_release_request'); + assert.doesNotMatch(request, /uses: actions\/checkout|secrets\./); + const validation = request.split(' run: |\n')[1]; + assert.ok(validation); + const source = 'a'.repeat(40); + for (const [SOURCE_SHA, expected] of [[source, 0], ['master', 1], ['', 1]]) { + const result = spawnSync('bash', ['-c', validation], { env: { ...process.env, SOURCE_SHA }, encoding: 'utf8' }); + assert.equal(result.status, expected, result.stderr); + } + for (const name of ['runner_release_binaries', 'runner_release_pack']) { + const producer = job(workflow, name); + assert.match(producer, /needs: runner_release_request/); + assert.match(producer, /contents: read/); + assert.doesNotMatch(producer, /secrets\.|contents: write|id-token: write|packages: write|--push|--password/); + assert.match(producer, /test "\$\(git rev-parse HEAD\)" = "\$SOURCE_SHA"/); + } + const binaries = job(workflow, 'runner_release_binaries'); + assert.deepEqual([...binaries.matchAll(/- target: (.+)/g)].map(match => match[1]), ['darwin-arm64', 'darwin-x64']); + assert.match(binaries, /cargo build --release.*--locked/); + assert.match(binaries, /record-binary/); + const pack = job(workflow, 'runner_release_pack'); + assert.doesNotMatch(pack, /docker pull|IMAGE_DIGEST/); + assert.match(pack, /--target runner-provider-pack/); + assert.match(pack, /docker cp "\$owner:\$pack_path"/); + assert.match(pack, /daemon_path=\/app\/server\/dist\/vendor\/paperclip-runner\/bin\/paperclip-runnerd/); + assert.match(pack, /--entrypoint "\$daemon_path" "\$image" --build-metadata/); + assert.match(pack, /docker cp "\$owner:\$daemon_path"/); + assert.match(pack, /timeout 30s docker start --attach "\$owner"/); + assert.match(pack, /record-image-binary/); + assert.match(pack, /trap 'docker rm -f "\$owner"/); + const helper = readFileSync(path.join(repoRoot, 'scripts/release-runner-artifacts.mjs'), 'utf8'); + assert.match(helper, /stage-release-runner-binaries\.mjs/); + assert.equal((helper.match(/execFileSync\(process\.execPath/g) ?? []).length, 1, 'Only the existing assembler owns staging'); +}); + +test('channel assembly routing preserves skipped promotions and blocks incomplete data before publishers', () => { + const workflow = readWorkflow('release.yml'); + const skipped = () => ({ result: 'skipped', outputs: {} }); + const base = () => Object.fromEntries(['verify_canary', 'select_nightly', 'select_beta', 'verify_beta_candidate', 'preflight_stable', 'verify_stable'].map(name => [name, skipped()])); + const pin = job(workflow, 'pin_runner_release_source'); + assert.equal(condition(pin, base(), { event_name: 'workflow_dispatch' }), false); + for (const [name, extra, event] of [ + ['verify_canary', {}, 'push'], ['select_nightly', { proceed: 'true' }, 'schedule'], + ['select_beta', { mode: 'promote' }, 'workflow_dispatch'], + ]) { + const needs = base(); needs[name] = { result: 'success', outputs: extra }; + assert.equal(condition(pin, needs, { event_name: event }), true, `${name} with unrelated skipped gates`); + assert.equal(condition(pin, needs, { event_name: event }, {}, true), false); + } + const stable = base(); stable.preflight_stable.result = 'success'; stable.verify_stable.result = 'success'; + assert.equal(condition(pin, stable, { event_name: 'workflow_dispatch' }), true); + stable.verify_stable.result = 'failure'; + assert.equal(condition(pin, stable, { event_name: 'workflow_dispatch' }), false); + assert.match(pin, /git show "\$source_sha:scripts\/release\.sh" \| grep -F 'PAPERCLIP_RELEASE_RUNNER_ASSETS'/); + assert.match(pin, /git cat-file -e "\$source_sha:scripts\/release-runner-artifacts\.mjs"/); + + const ready = { verify_canary: { result: 'success' }, + select_nightly: { result: 'success', outputs: { proceed: 'true' } }, smoke_nightly: { result: 'success' }, + select_beta: { result: 'success', outputs: { mode: 'promote' } }, verify_beta_candidate: skipped(), + verify_stable: { result: 'success' }, + pin_runner_release_source: { result: 'success', outputs: { required: 'true' } }, release_runner_assets: { result: 'success' } }; + for (const [name, github, inputs] of [ + ['publish_canary', { event_name: 'push' }, {}], ['publish_nightly', { event_name: 'schedule' }, {}], + ['publish_beta', { event_name: 'workflow_dispatch' }, {}], + ['preview_stable', { event_name: 'workflow_dispatch' }, { channel: 'stable', dry_run: true }], + ['publish_stable', { event_name: 'workflow_dispatch' }, { channel: 'stable', dry_run: false }], + ]) { + const block = job(workflow, name); + assert.equal(condition(block, ready, github, inputs), true, name); + for (const result of ['failure', 'cancelled', 'skipped']) { + assert.equal(condition(block, { ...ready, release_runner_assets: { result } }, github, inputs), false, `${name}/${result}`); + } + const older = { ...ready, pin_runner_release_source: { result: 'success', outputs: { required: 'false' } }, release_runner_assets: skipped() }; + assert.equal(condition(block, older, github, inputs), true, `${name} older promoted contract`); + assert.match(block, /working-directory: source/); + const validation = block.match(/name: Validate runner data against[\s\S]*?(?=\n - name:)/)?.[0]; + assert.ok(validation); + assert.match(validation, /node "\$GITHUB_WORKSPACE\/trusted\/scripts\/release-runner-artifacts\.mjs" unpack/); + assert.match(validation, /"\$RUNNER_TEMP\/runner-release-assets" "\$GITHUB_WORKSPACE\/source"/); + assert.doesNotMatch(validation, /node scripts\//, 'Credentialed publishers may consume only trusted transfer tooling'); + } + const release = readFileSync(path.join(repoRoot, 'scripts/release.sh'), 'utf8'); + assert.ok(release.indexOf('pnpm build\n# Production publication') < release.indexOf('PAPERCLIP_RELEASE_RUNNER_ASSETS/bin')); + assert.ok(release.indexOf('PAPERCLIP_RELEASE_RUNNER_ASSETS/bin') < release.indexOf('node "$REPO_ROOT/scripts/build-standalone-public-packages.mjs"')); + assert.match(release, /elif \[ "\$dry_run" = false \]; then\n\s+release_fail/); +}); + test("chaos verification isolates callers that verify the same source commit", () => { const chaosWorkflow = readWorkflow("runner-chaos-evals.yml"); const group = chaosWorkflow.match(/^ group: (.+)$/m)?.[1]; @@ -45,7 +149,10 @@ test("canary reuses exact-source proof while stable keeps full verification", () assert.match(canary, /SOURCE_SHA: \$\{\{ github\.sha \}\}/); assert.match(canary, /run: node scripts\/cloud-source-verification\.mjs "\$SOURCE_SHA"/); assert.doesNotMatch(canary, /release-verify\.yml|continue-on-error|always\(\)/); - assert.match(releaseWorkflow, /publish_canary:\n\s+if: github\.event_name == 'push'\n\s+needs: verify_canary/); + const publishCanary = job(releaseWorkflow, 'publish_canary'); + assert.match(publishCanary, /needs: \[verify_canary, pin_runner_release_source, release_runner_assets\]/); + assert.match(publishCanary, /needs\.verify_canary\.result == 'success'/); + assert.match(publishCanary, /needs\.release_runner_assets\.result == 'success'/); // The stable lane is gated on the stable channel since the nightly lane // was added; a `needs:` line (for example a preflight job) may sit between // the gate and the delegation. diff --git a/scripts/grok-public-install-sandbox.mjs b/scripts/grok-public-install-sandbox.mjs index 07dc9207841..e53a8fb2530 100644 --- a/scripts/grok-public-install-sandbox.mjs +++ b/scripts/grok-public-install-sandbox.mjs @@ -7,6 +7,32 @@ export const GROK_PUBLIC_INSTALL_LIFECYCLE = [ 'npm', 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts', ]; +// Execute this in the existing offline public consumer, never in the checkout. +// The public index and returned command must both come from the installed graph. +export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersion) { + return ` + import assert from 'node:assert/strict'; + import { execFileSync } from 'node:child_process'; + import { accessSync, constants, realpathSync, statSync } from 'node:fs'; + import { isAbsolute, relative, sep } from 'node:path'; + import { pathToFileURL } from 'node:url'; + const root = realpathSync(${JSON.stringify(consumerRoot)}); + const contained = path => { const value = relative(root, realpathSync(path)); return value !== '' && value !== '..' && !value.startsWith('..' + sep) && !isAbsolute(value); }; + const index = ${JSON.stringify(indexPath)}; + assert.ok(contained(index), 'Codex public index must be installed in the consumer'); + const { resolvePinnedCodexCommand } = await import(pathToFileURL(index).href); + assert.equal(typeof resolvePinnedCodexCommand, 'function', 'Installed public index must export the pinned Codex resolver'); + const command = resolvePinnedCodexCommand(); + assert.ok(isAbsolute(command) && contained(command), 'Pinned Codex must resolve inside the installed consumer'); + assert.ok(statSync(command).isFile(), 'Pinned Codex must be a regular executable'); + accessSync(command, constants.X_OK); + const version = execFileSync(command, ['--version'], { timeout: 30_000, maxBuffer: 128 * 1024, encoding: 'utf8', + env: { PATH: '/usr/local/bin:/usr/bin:/bin', HOME: '/tmp', NODE_PATH: '' } }).trim(); + assert.equal(version, 'codex-cli ' + ${JSON.stringify(expectedVersion)}, 'Installed Codex version must match the qualified pin'); + console.log(JSON.stringify({ pinnedCodexCommandVerified: true, pinnedCodexVersion: ${JSON.stringify(expectedVersion)}, providerCalls: 0 })); + `; +} + export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, gid, download = false, prerequisite, temporarySizeMb = 256 }) { if (!Number.isSafeInteger(uid) || uid <= 0 || !Number.isSafeInteger(gid) || gid <= 0) { throw new Error('Public-install verification requires an unprivileged host user'); diff --git a/scripts/release-runner-artifacts.mjs b/scripts/release-runner-artifacts.mjs new file mode 100644 index 00000000000..c695bf79252 --- /dev/null +++ b/scripts/release-runner-artifacts.mjs @@ -0,0 +1,265 @@ +// Data transfer for the existing release assembler. Producer jobs have no +// publishing credentials; consumers never execute binaries from the archive. +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { gzipSync, gunzipSync } from 'node:zlib'; +import { runnerBinaryTarget } from '../packages/paperclip-runner/src/live/runner-binary.ts'; +import { verifyReleaseProviderPack } from '../packages/paperclip-runner/scripts/release-provider-pack.mjs'; + +const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +export const RELEASE_RUNNER_TARGETS = ['darwin-arm64', 'darwin-x64', 'linux-x64']; +const dataPaths = [...RELEASE_RUNNER_TARGETS.map(target => `bin/${target}/paperclip-runnerd`), + 'bin/release-manifest.json', 'remote-provider-packs/linux-x64/provider-pack.json']; +const archivePaths = [...dataPaths, 'assembly-receipt.json']; +const maxBinary = 128 * 1024 * 1024; +const maxArchive = 3 * maxBinary + 2 * 1024 * 1024; +const digest = value => `sha256:${createHash('sha256').update(value).digest('hex')}`; +const revision = value => assert.match(value ?? '', /^[a-f0-9]{40}$/, 'Runner artifacts require a full source SHA'); +function file(path, limit = maxBinary) { + const stat = lstatSync(path); + assert.ok(stat.isFile() && !stat.isSymbolicLink() && stat.size <= limit, `Unsafe or oversized artifact ${path}`); + return readFileSync(path); +} +function inside(root, path, limit = maxBinary) { + const components = path.split('/'); + for (let length = 0; length < components.length; length++) { + const directory = length === 0 ? root : join(root, ...components.slice(0, length)); + const stat = lstatSync(directory); + assert.ok(stat.isDirectory() && !stat.isSymbolicLink(), 'Artifact directories must not be symlinks'); + } + return file(join(root, path), limit); +} +function destination(root, path) { + assert.ok(archivePaths.includes(path), 'Unknown artifact destination'); + const components = path.split('/'); + for (let length = 0; length < components.length; length++) { + const directory = length === 0 ? root : join(root, ...components.slice(0, length)); + if (!existsSync(directory)) mkdirSync(directory); + const stat = lstatSync(directory); + assert.ok(stat.isDirectory() && !stat.isSymbolicLink(), 'Artifact output directories must not be symlinks'); + } + const output = join(root, path); + let existing; + try { existing = lstatSync(output); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + assert.ok(!existing || existing.isFile() && !existing.isSymbolicLink() && existing.nlink === 1, + 'Refuse to overwrite an artifact link or non-file'); + return output; +} +function newDirectory(path) { + assert.ok(isAbsolute(path) && resolve(path) === path && path !== '/' && !existsSync(path), 'Artifact output must be a new absolute owned directory'); + mkdirSync(path, { recursive: true, mode: 0o700 }); +} +function writeJson(path, value) { writeFileSync(path, JSON.stringify(value, null, 2) + '\n'); } +function imageProvenance(path, sourceRevision, requestedImage = '') { + const [image] = JSON.parse(file(path, 512 * 1024)); + assert.equal(image.Os, 'linux'); assert.equal(image.Architecture, 'amd64'); + assert.equal(image.Config?.Labels?.['org.opencontainers.image.revision'], sourceRevision); + assert.match(image.Id, /^sha256:[a-f0-9]{64}$/); + if (requestedImage) { + assert.match(requestedImage, /^ghcr\.io\/paperclipai\/paperclip@sha256:[a-f0-9]{64}$/); + assert.ok(image.RepoDigests?.includes(requestedImage)); + } + return { sourceRevision, platform: 'linux/amd64', imageId: image.Id, requestedImage, repoDigests: image.RepoDigests ?? [] }; +} + +function sourcePins(sourceRoot = repo) { + return { profiles: file(join(sourceRoot, 'packages/paperclip-runner/acpx-profiles.json'), 512 * 1024), + distributions: file(join(sourceRoot, 'packages/paperclip-runner/cursor-distributions.json'), 512 * 1024) }; +} +// The unbuilt producer cannot import the compiled eval join. Check its same +// daemon contracts here, including the durable/remote capabilities needed by +// production. Metadata is a compatibility claim, not embedded source proof. +export function verifyReleaseDaemonMetadata(metadata) { + assert.equal(metadata?.schema, 'paperclip-runner/runnerd-build-metadata/v1'); + assert.equal(metadata.binaryName, 'paperclip-runnerd', 'Unexpected release daemon name'); + assert.equal(metadata.packageName, '@paperclipai/paperclip-runner', 'Unexpected release daemon package'); + assert.ok(typeof metadata.packageVersion === 'string' && metadata.packageVersion.trim(), 'Missing daemon package version'); + for (const [field, expected] of [['binaryContractVersion', 2], ['nativeExecutionVersion', 1], ['harnessDriverVersion', 1]]) { + assert.equal(metadata[field], expected, `Incompatible daemon ${field}`); + } + assert.equal(metadata.prp?.name, 'paperclip.runner', 'Incompatible daemon protocol family'); + assert.ok(Number.isSafeInteger(metadata.prp.minimumVersion) && metadata.prp.minimumVersion === 1 && + Number.isSafeInteger(metadata.prp.maximumVersion) && metadata.prp.maximumVersion >= 2, 'Incompatible daemon protocol versions'); + for (const [field, required] of [ + ['durableSessionCapabilities', ['unlimited_runtime', 'connection_lease_renewal']], + ['prpTransportModes', ['dial_ws_loopback', 'dial_wss', 'listen_ws']], + ]) { + assert.ok(Array.isArray(metadata[field]) && required.every(capability => metadata[field].includes(capability)), + `Missing release daemon ${field}`); + } + return metadata; +} +export function verifyRunnerReleaseData(files, sourceRevision, pins = sourcePins()) { + revision(sourceRevision); + assert.deepEqual([...files.keys()].sort(), [...archivePaths].sort(), 'Release archive must contain exactly the known data files'); + const receipt = JSON.parse(files.get('assembly-receipt.json')); + assert.equal(receipt.schema, 'paperclip.runner.release-assembly.v1'); + assert.equal(receipt.sourceRevision, sourceRevision, 'Assembly source mismatch'); + assert.deepEqual(Object.keys(receipt.files ?? {}).sort(), [...dataPaths].sort()); + for (const path of dataPaths) assert.equal(digest(files.get(path)), receipt.files[path], `Assembly artifact digest mismatch: ${path}`); + const manifest = JSON.parse(files.get('bin/release-manifest.json')); + assert.equal(manifest.schema, 'paperclip.runner.release-binaries.v1'); + assert.equal(manifest.sourceRevision, sourceRevision, 'Daemon source mismatch'); + assert.deepEqual(Object.keys(manifest.platforms ?? {}).sort(), [...RELEASE_RUNNER_TARGETS].sort(), 'Missing or duplicate release target'); + assert.deepEqual(Object.keys(receipt.daemonMetadata ?? {}).sort(), [...RELEASE_RUNNER_TARGETS].sort(), 'Missing daemon compatibility receipt'); + for (const target of RELEASE_RUNNER_TARGETS) { + const path = `bin/${target}/paperclip-runnerd`, artifact = manifest.platforms[target]; + assert.equal(artifact.path, `${target}/paperclip-runnerd`, 'Unsafe daemon path'); + assert.equal(runnerBinaryTarget(files.get(path)), target, 'Daemon architecture mismatch'); + assert.equal(digest(files.get(path)), artifact.sha256, 'Daemon digest mismatch'); + verifyReleaseDaemonMetadata(receipt.daemonMetadata[target]); + } + assert.equal(receipt.sourcePins?.profiles, digest(pins.profiles), 'Checked-out source profile snapshot mismatch'); + assert.equal(receipt.sourcePins?.distributions, digest(pins.distributions), 'Checked-out source distribution snapshot mismatch'); + const pack = verifyReleaseProviderPack(JSON.parse(files.get('remote-provider-packs/linux-x64/provider-pack.json')), sourceRevision, + JSON.parse(pins.profiles), JSON.parse(pins.distributions)); + assert.equal(manifest.remoteProviderPack?.target, 'linux-x64'); + assert.equal(manifest.remoteProviderPack?.digest, pack.digest, 'Provider pack assembly identity mismatch'); + assert.equal(manifest.remoteProviderPack?.sourceRevision, sourceRevision); + assert.equal(receipt.providerImage?.sourceRevision, sourceRevision, 'Provider image source mismatch'); + assert.equal(receipt.providerImage?.platform, 'linux/amd64'); + assert.match(receipt.providerImage?.imageId ?? '', /^sha256:[a-f0-9]{64}$/); + if (receipt.providerImage.requestedImage) { + assert.match(receipt.providerImage.requestedImage, /^ghcr\.io\/paperclipai\/paperclip@sha256:[a-f0-9]{64}$/); + assert.ok(receipt.providerImage.repoDigests?.includes(receipt.providerImage.requestedImage), 'Provider image digest mismatch'); + } + assert.deepEqual(receipt.linuxDaemonImage, receipt.providerImage, 'Linux daemon and provider pack must come from the same verified image'); + return receipt; +} + +// A deliberately small ustar data envelope: no directories, links, extensions, +// executable hooks or arbitrary paths. Validate the entire envelope before any +// destination file exists, including compressed/uncompressed bounds and hashes. +export function readRunnerReleaseArchive(bytes, sourceRevision, checksum, pins) { + assert.ok(bytes.length <= maxArchive, 'Release archive is oversized'); + assert.equal(digest(bytes), checksum, 'Release archive checksum mismatch'); + const tar = gunzipSync(bytes, { maxOutputLength: maxArchive }); + const files = new Map(); + const octal = bytes => { + const value = bytes.toString('ascii').replace(/\0.*$/s, '').trim(); + assert.match(value, /^[0-7]+$/, 'Invalid archive numeric field'); + return parseInt(value, 8); + }; + let offset = 0; + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512); + if (header.every(byte => byte === 0)) { + assert.ok(tar.subarray(offset).every(byte => byte === 0), 'Unexpected archive trailer'); + break; + } + const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/s, ''); + assert.ok(archivePaths.includes(name) && !files.has(name), 'Unsafe, unexpected or duplicate archive path'); + assert.ok(header.subarray(345, 500).every(byte => byte === 0), 'Archive prefixes are unsupported'); + assert.ok(header[156] === 0 || header[156] === 48, 'Archive links and extensions are forbidden'); + assert.ok(header.subarray(157, 257).every(byte => byte === 0), 'Archive link target is forbidden'); + const sum = [...header].reduce((sum, byte, index) => sum + (index >= 148 && index < 156 ? 32 : byte), 0); + assert.equal(octal(header.subarray(148, 156)), sum, 'Archive header checksum mismatch'); + const size = octal(header.subarray(124, 136)); + assert.ok(size <= (name.endsWith('paperclip-runnerd') ? maxBinary : 512 * 1024) && offset + 512 + size <= tar.length, 'Oversized or truncated archive member'); + const mode = octal(header.subarray(100, 108)); + assert.equal(mode, name.endsWith('paperclip-runnerd') ? 0o755 : 0o644, 'Unexpected archive file permissions'); + files.set(name, tar.subarray(offset + 512, offset + 512 + size)); + offset += 512 + Math.ceil(size / 512) * 512; + } + assert.ok(offset + 1024 <= tar.length, 'Archive must have a complete end marker'); + verifyRunnerReleaseData(files, sourceRevision, pins); + return files; +} + +export function makeRunnerReleaseArchive(files, sourceRevision) { + verifyRunnerReleaseData(files, sourceRevision); + const chunks = []; + for (const path of archivePaths) { + const bytes = files.get(path), header = Buffer.alloc(512); + header.write(path); + const field = (offset, width, value) => header.write(value.toString(8).padStart(width - 1, '0') + '\0', offset, width, 'ascii'); + field(100, 8, path.endsWith('paperclip-runnerd') ? 0o755 : 0o644); + field(108, 8, 0); field(116, 8, 0); field(124, 12, bytes.length); field(136, 12, 0); + header.fill(32, 148, 156); header[156] = 48; + header.write('ustar\0', 257, 6, 'ascii'); header.write('00', 263, 2, 'ascii'); + const sum = [...header].reduce((sum, byte) => sum + byte, 0); + header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 8, 'ascii'); + chunks.push(header, bytes, Buffer.alloc((512 - bytes.length % 512) % 512)); + } + chunks.push(Buffer.alloc(1024)); + return gzipSync(Buffer.concat(chunks)); +} + +async function main() { + const [mode, sourceRevision, ...args] = process.argv.slice(2); + revision(sourceRevision); + if (mode === 'record-binary') { + const [target, binary, output, ...extra] = args; + assert.ok(RELEASE_RUNNER_TARGETS.includes(target) && !extra.length); + const bytes = file(binary); assert.equal(runnerBinaryTarget(bytes), target); + assert.equal(`${process.platform}-${process.arch}`, target, 'Execute metadata only on the daemon native target'); + const metadata = JSON.parse(execFileSync(binary, ['--build-metadata'], { timeout: 15_000, maxBuffer: 128 * 1024 })); + verifyReleaseDaemonMetadata(metadata); + newDirectory(output); writeFileSync(join(output, 'paperclip-runnerd'), bytes, { mode: 0o755 }); + writeJson(join(output, 'receipt.json'), { sourceRevision, target, sha256: digest(bytes), metadata }); + } else if (mode === 'record-image-binary') { + const [binary, metadataPath, imageMetadata, output, requestedImage = '', ...extra] = args; + assert.equal(extra.length, 0); + const bytes = file(binary); assert.equal(runnerBinaryTarget(bytes), 'linux-x64'); + // Metadata was executed in this same image, whose libc matches the daemon. + // The host validates data only and must never execute the extracted ELF. + const metadata = verifyReleaseDaemonMetadata(JSON.parse(file(metadataPath, 128 * 1024))); + const providerImage = imageProvenance(imageMetadata, sourceRevision, requestedImage); + newDirectory(output); writeFileSync(join(output, 'paperclip-runnerd'), bytes, { mode: 0o755 }); + writeJson(join(output, 'receipt.json'), { sourceRevision, target: 'linux-x64', sha256: digest(bytes), metadata, providerImage }); + } else if (mode === 'record-pack') { + const [packPath, imageMetadata, output, requestedImage = '', ...extra] = args; assert.equal(extra.length, 0); + const bytes = file(packPath, 512 * 1024); verifyReleaseProviderPack(JSON.parse(bytes), sourceRevision); + const providerImage = imageProvenance(imageMetadata, sourceRevision, requestedImage); + newDirectory(output); writeFileSync(join(output, 'provider-pack.json'), bytes); + writeJson(join(output, 'receipt.json'), { sourceRevision, sha256: digest(bytes), providerImage }); + } else if (mode === 'assemble') { + const [artifacts, output, ...extra] = args; assert.equal(extra.length, 0); + const platforms = {}, daemonMetadata = {}; + let linuxDaemonImage; + assert.deepEqual((await import('node:fs')).readdirSync(artifacts).sort(), [...RELEASE_RUNNER_TARGETS, 'provider-pack'].sort()); + for (const target of RELEASE_RUNNER_TARGETS) { + const path = join(artifacts, target, 'paperclip-runnerd'), bytes = inside(artifacts, `${target}/paperclip-runnerd`); + const receipt = JSON.parse(inside(artifacts, `${target}/receipt.json`, 128 * 1024)); + assert.equal(receipt.sourceRevision, sourceRevision); assert.equal(receipt.target, target); + daemonMetadata[target] = verifyReleaseDaemonMetadata(receipt.metadata); + if (target === 'linux-x64') linuxDaemonImage = receipt.providerImage; + assert.equal(receipt.sha256, digest(bytes)); assert.equal(runnerBinaryTarget(bytes), target); + platforms[target] = { path: resolve(path), sha256: receipt.sha256 }; + } + const packPath = join(artifacts, 'provider-pack/provider-pack.json'), bytes = inside(artifacts, 'provider-pack/provider-pack.json', 512 * 1024); + const packReceipt = JSON.parse(inside(artifacts, 'provider-pack/receipt.json', 128 * 1024)); + assert.equal(packReceipt.sourceRevision, sourceRevision); assert.equal(packReceipt.sha256, digest(bytes)); + assert.deepEqual(linuxDaemonImage, packReceipt.providerImage, 'Linux daemon and provider pack must come from the same verified image'); + verifyReleaseProviderPack(JSON.parse(bytes), sourceRevision); + newDirectory(output); + const manifestPath = join(output, 'assembler-input.json'); + writeJson(manifestPath, { sourceRevision, platforms, remoteProviderPack: { path: resolve(packPath), sha256: packReceipt.sha256 } }); + // The existing assembler owns source/profile-aware staging. Never replace + // it with a second daemon assembly implementation in this transfer helper. + execFileSync(process.execPath, [join(repo, 'packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs'), manifestPath], { stdio: 'inherit' }); + const files = new Map(dataPaths.map(path => [path, file(join(repo, 'packages/paperclip-runner/dist', path), path.endsWith('paperclip-runnerd') ? maxBinary : 512 * 1024)])); + const pins = sourcePins(); + const receipt = { schema: 'paperclip.runner.release-assembly.v1', sourceRevision, + sourcePins: { profiles: digest(pins.profiles), distributions: digest(pins.distributions) }, daemonMetadata, + providerImage: packReceipt.providerImage, linuxDaemonImage, files: Object.fromEntries([...files].map(([path, bytes]) => [path, digest(bytes)])) }; + files.set('assembly-receipt.json', Buffer.from(JSON.stringify(receipt, null, 2) + '\n')); + const archive = makeRunnerReleaseArchive(files, sourceRevision); + writeFileSync(join(output, 'runner-release-assets.tar.gz'), archive); + writeFileSync(join(output, 'runner-release-assets.tar.gz.sha256'), digest(archive) + '\n'); + } else if (mode === 'unpack') { + const [archive, checksum, output, sourceRoot = repo, ...extra] = args; assert.equal(extra.length, 0); + const files = readRunnerReleaseArchive(file(archive, maxArchive), sourceRevision, file(checksum, 256).toString().trim(), sourcePins(sourceRoot)); + newDirectory(output); + for (const [path, bytes] of files) { + writeFileSync(destination(output, path), bytes, { mode: path.endsWith('paperclip-runnerd') ? 0o755 : 0o644 }); + } + console.log(JSON.stringify({ sourceRevision, targets: RELEASE_RUNNER_TARGETS, archiveSha256: digest(file(archive, maxArchive)), providerCalls: 0 })); + } else throw new Error('Unknown release artifact operation'); +} +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) await main(); diff --git a/scripts/release.sh b/scripts/release.sh index 7238c4b2939..6a7e47b75b4 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -284,6 +284,37 @@ release_info "" release_info "==> Step 2/7: Building workspace artifacts..." cd "$REPO_ROOT" pnpm build +# Production publication requires the data envelope from the separately gated, +# credential-free assembler. The publisher never executes its producer tooling. +# A developer dry-run may still inspect a host-only payload; it does not qualify +# a release. Official dry-runs receive the same complete assets as publication. +if [ -n "${PAPERCLIP_RELEASE_RUNNER_ASSETS:-}" ]; then + node --input-type=module - "$PAPERCLIP_RELEASE_RUNNER_ASSETS" "$CURRENT_SHA" <<'NODE' +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { lstatSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +const [assets, source] = process.argv.slice(2); +const receipt = JSON.parse(readFileSync(join(assets, 'assembly-receipt.json'))); +assert.equal(receipt.schema, 'paperclip.runner.release-assembly.v1'); +assert.equal(receipt.sourceRevision, source, 'Release runner source mismatch'); +const paths = ['bin/darwin-arm64/paperclip-runnerd', 'bin/darwin-x64/paperclip-runnerd', 'bin/linux-x64/paperclip-runnerd', + 'bin/release-manifest.json', 'remote-provider-packs/linux-x64/provider-pack.json']; +assert.deepEqual(Object.keys(receipt.files).sort(), paths.sort()); +for (const path of paths) { + const target = join(assets, path), stat = lstatSync(target); + assert.ok(stat.isFile() && !stat.isSymbolicLink()); + assert.equal(`sha256:${createHash('sha256').update(readFileSync(target)).digest('hex')}`, receipt.files[path]); +} +NODE + cp -Rf "$PAPERCLIP_RELEASE_RUNNER_ASSETS/bin" "$REPO_ROOT/packages/paperclip-runner/dist/" + cp -Rf "$PAPERCLIP_RELEASE_RUNNER_ASSETS/remote-provider-packs" "$REPO_ROOT/packages/paperclip-runner/dist/" + cp -Rf "$REPO_ROOT/packages/paperclip-runner/dist/." "$REPO_ROOT/server/dist/vendor/paperclip-runner/" +elif [ "$dry_run" = false ]; then + release_fail "complete release runner assets are required; use the gated release workflow or supply PAPERCLIP_RELEASE_RUNNER_ASSETS." +else + release_info " Host-only dry-run: release runner matrix is not qualified." +fi node "$REPO_ROOT/scripts/build-standalone-public-packages.mjs" bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh" for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do diff --git a/scripts/verify-grok-npm-install.mjs b/scripts/verify-grok-npm-install.mjs index ee1788f9df5..a113dde548c 100644 --- a/scripts/verify-grok-npm-install.mjs +++ b/scripts/verify-grok-npm-install.mjs @@ -9,7 +9,7 @@ import { tmpdir } from 'node:os'; import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { materializePublishManifest, prepareBundledPackage } from './prepare-bundled-package.mjs'; -import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, grokConsumerDockerArgs } from './grok-public-install-sandbox.mjs'; +import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, grokConsumerDockerArgs, installedCodexProbeSource } from './grok-public-install-sandbox.mjs'; const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..'); assert.equal(process.platform, 'linux', 'Run this verification on disposable EC2 Linux, not a developer host'); const root = mkdtempSync(join(tmpdir(), 'paperclip-grok-public-install-')); @@ -94,6 +94,11 @@ try { assert.equal(existsSync(prerequisite), false, 'npm must not provision Grok'); const server = join(consumer, 'node_modules/@paperclipai/server'); const installed = join(server, 'dist/vendor/paperclip-runner'); + const codexVersion = JSON.parse(readFileSync(join(repo, 'packages/paperclip-runner/acpx-profiles.json'), 'utf8')).profiles.codex.agentRuntimeVersion; + assert.match(codexVersion, /^\d+\.\d+\.\d+$/); + writeFileSync(join(assets, 'codex-probe.mjs'), installedCodexProbeSource( + '/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/index.js', '/consumer', codexVersion), { mode: 0o644 }); + isolated(['node', '/packages/codex-probe.mjs']); assert.ok(existsSync(join(installed, 'providers/grok/launcher.cjs'))); assert.equal(existsSync(join(consumer, 'node_modules/@paperclipai/grok-acp')), false); assert.equal(existsSync(join(installed, 'providers/grok/bin')), false); @@ -114,7 +119,7 @@ try { // Only the positive probe sees this file at the canonical sandbox path. run(process.execPath, [join(repo, 'packages/paperclip-runner/scripts/provision-grok.mjs'), prerequisite]); isolated(['node', '/packages/probe.mjs', 'present'], { prerequisite }); - console.log(JSON.stringify({ schema: 'paperclip.grok.public-npm-install.v1', sourceRevision, releaseVersion, lifecycleScriptsEnabled: true, lifecycleSentinelVerified: true, lifecycleNetwork: 'none', consumerImage: GROK_PUBLIC_INSTALL_IMAGE, consumerUid, consumerLockPreserved: true, cleanNpmInstall: true, packageCount: needed.size, builtinLauncherPresent: true, separateGrokPackage: false, npmProvisionedBinary: false, missingPrerequisiteRejected: true, provisionedBinaryVerified: true, commandLeaseVerified: true, providerCalls: 0 })); + console.log(JSON.stringify({ schema: 'paperclip.grok.public-npm-install.v1', sourceRevision, releaseVersion, lifecycleScriptsEnabled: true, lifecycleSentinelVerified: true, lifecycleNetwork: 'none', consumerImage: GROK_PUBLIC_INSTALL_IMAGE, consumerUid, consumerLockPreserved: true, cleanNpmInstall: true, packageCount: needed.size, pinnedCodexCommandVerified: true, pinnedCodexVersion: codexVersion, builtinLauncherPresent: true, separateGrokPackage: false, npmProvisionedBinary: false, missingPrerequisiteRejected: true, provisionedBinaryVerified: true, commandLeaseVerified: true, providerCalls: 0 })); } finally { rmSync(root, { recursive: true, force: true }); }