From 7b8d29a856b54217234131474ea47ea1a38fb0aa Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 07:48:52 -0500 Subject: [PATCH] feat: qualify macOS Codex runner defaults and release artifacts Co-Authored-By: Paperclip --- .github/workflows/release-smoke.yml | 132 ++++++++++++++ .github/workflows/release-verify.yml | 77 ++++++++ .github/workflows/release.yml | 120 ++++++++++++- doc/plans/2026-10-06-default-agent-runner.md | 63 ++++++- .../scripts/release-provider-pack.test.mjs | 21 ++- .../scripts/stage-release-runner-binaries.mjs | 166 +++++++++++++----- .../scripts/stage-runner-binary.test.mjs | 57 +++++- packages/shared/src/agent-runner.ts | 5 +- .../grok-public-install-sandbox.test.mjs | 58 +++++- .../release-verify-workflow.test.mjs | 89 +++++++++- scripts/dev-runner-native-binary.mjs | 16 +- scripts/grok-public-install-sandbox.mjs | 80 ++++++++- scripts/release.sh | 10 ++ scripts/verify-grok-npm-install.mjs | 104 ++++++++++- server/src/__tests__/adapter-routes.test.ts | 5 +- .../src/__tests__/agent-skills-routes.test.ts | 4 +- .../agents-pending-approval-config.test.ts | 5 +- server/src/__tests__/built-in-agents.test.ts | 14 +- .../src/__tests__/company-portability.test.ts | 6 +- .../dev-runner-native-binary.test.ts | 18 ++ .../__tests__/plugin-managed-agents.test.ts | 8 +- .../services/agent-runner-selection.test.ts | 27 +-- tests/runner-e2e/README.md | 23 ++- tests/runner-e2e/installed-cli.test.ts | 75 +++++++- tests/runner-e2e/installed-cli.ts | 101 ++++++++++- ui/src/pages/NewAgent.test.tsx | 12 +- 26 files changed, 1185 insertions(+), 111 deletions(-) diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index e0aae6d1a7..eeb7d71928 100644 --- a/.github/workflows/release-smoke.yml +++ b/.github/workflows/release-smoke.yml @@ -23,6 +23,11 @@ on: required: false default: release-smoke type: string + qualification_source_sha: + description: Optional immutable source SHA for provider-free three-target package qualification + required: false + default: "" + type: string workflow_call: inputs: paperclip_version: @@ -36,6 +41,13 @@ on: required: false default: release-smoke type: string + qualification_source_sha: + required: false + default: "" + type: string + +permissions: + contents: read jobs: # The Docker smoke below can never exercise the background-service leg of @@ -45,6 +57,7 @@ jobs: # on the runner VM's systemd and require the installed service to end up # serving. smoke_service: + if: inputs.qualification_source_sha == '' runs-on: ubuntu-latest timeout-minutes: 30 @@ -98,6 +111,7 @@ jobs: retention-days: 14 smoke: + if: inputs.qualification_source_sha == '' runs-on: ubuntu-latest timeout-minutes: 45 @@ -198,3 +212,121 @@ jobs: - name: Stop Docker smoke container if: always() run: docker rm -f "$SMOKE_CONTAINER_NAME" >/dev/null 2>&1 || true + + qualification_daemons: + if: inputs.qualification_source_sha != '' + uses: ./.github/workflows/release-verify.yml + with: + ref: ${{ inputs.qualification_source_sha }} + runner_binaries_only: true + + qualification_pack: + if: inputs.qualification_source_sha != '' + needs: qualification_daemons + name: Assemble and verify public Linux packages + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.qualification_source_sha }} + persist-credentials: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + with: + version: 9.15.4 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: ${{ needs.qualification_daemons.outputs.runner_artifact_prefix }}-* + path: ${{ runner.temp }}/release-runner-inputs + merge-multiple: true + - name: Bind the immutable Linux producer source + env: + SOURCE_SHA: ${{ inputs.qualification_source_sha }} + run: | + set -euo pipefail + [[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]] || exit 1 + [[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] || exit 1 + - name: Build normal workspace outputs and public CLI + run: | + pnpm install --no-frozen-lockfile + pnpm build + bash scripts/build-npm.sh --skip-checks --skip-typecheck + mv cli/package.dev.json cli/package.json + - name: Assemble exact-source daemons before server packing + env: + SOURCE_SHA: ${{ inputs.qualification_source_sha }} + run: | + node packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs \ + assemble-daemons "$RUNNER_TEMP/release-runner-inputs" "$SOURCE_SHA" + cp -R packages/paperclip-runner/dist/bin/. server/dist/vendor/paperclip-runner/bin/ + - name: Verify clean public installation and retain the same package bytes + env: + PAPERCLIP_RUNNER_PUBLIC_PACKS_DIR: ${{ runner.temp }}/public-runner-packs + run: node scripts/verify-grok-npm-install.mjs + - name: Locate completed package assembly + id: pack_identity + if: ${{ !cancelled() }} + run: | + if [[ -s "$RUNNER_TEMP/public-runner-packs/manifest.json" ]]; then + echo "available=true" >> "$GITHUB_OUTPUT" + fi + - name: Retain completed package assembly even after a later Linux failure + if: ${{ !cancelled() && steps.pack_identity.outputs.available == 'true' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ${{ inputs.artifact_name }}-public-packages + path: ${{ runner.temp }}/public-runner-packs/ + if-no-files-found: error + retention-days: 14 + + qualification_mac: + if: ${{ !cancelled() && inputs.qualification_source_sha != '' && needs.qualification_daemons.result == 'success' }} + needs: [qualification_daemons, qualification_pack] + name: Clean installed package (${{ matrix.target }}) + runs-on: ${{ matrix.host }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - target: darwin-arm64 + host: macos-15 + - target: darwin-x64 + host: macos-15-intel + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.qualification_source_sha }} + persist-credentials: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + with: + version: 9.15.4 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ inputs.artifact_name }}-public-packages + path: ${{ runner.temp }}/public-runner-packs + - name: Install only the existing verifier tooling + run: pnpm install --ignore-scripts --no-frozen-lockfile + - name: Verify host identity and actual clean npm consumer + env: + SOURCE_SHA: ${{ inputs.qualification_source_sha }} + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + [[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] || exit 1 + [[ "$(node -p 'process.platform + "-" + process.arch')" == "$TARGET" ]] || exit 1 + node scripts/verify-grok-npm-install.mjs --mac-consumer \ + "$RUNNER_TEMP/public-runner-packs" "$SOURCE_SHA" "$RUNNER_TEMP/mac-public-install.json" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: ${{ inputs.artifact_name }}-${{ matrix.target }} + path: ${{ runner.temp }}/mac-public-install.json + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index 492b7e62b4..c914506e5b 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_binaries_only: + description: Build exact-source release daemons without publishing or provider calls + required: false + default: false + type: boolean + outputs: + runner_artifact_prefix: + value: ${{ jobs.runner_binaries.outputs.artifact_prefix }} # 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_binaries_only }} name: Pre-release Runner chaos evals uses: ./.github/workflows/runner-chaos-evals.yml with: ref: ${{ inputs.ref }} typecheck: + if: ${{ !inputs.runner_binaries_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_binaries_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 @@ -205,6 +216,7 @@ jobs: fi serialized_tests: + if: ${{ !inputs.runner_binaries_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 @@ -254,6 +266,7 @@ jobs: run: pnpm test:run:serialized -- --shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }} runner_workflow_evals: + if: ${{ !inputs.runner_binaries_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 @@ -284,6 +297,7 @@ jobs: run: pnpm test:runner-workflow-evals verify_paperclip_runner: + if: ${{ !inputs.runner_binaries_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 @@ -412,6 +426,7 @@ jobs: run: pnpm --filter @paperclipai/paperclip-runner build:rust build: + if: ${{ !inputs.runner_binaries_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 @@ -440,3 +455,65 @@ jobs: - name: Build run: pnpm build + + runner_binaries: + if: inputs.runner_binaries_only + name: Release daemon (${{ matrix.target }}) + runs-on: ${{ matrix.host }} + timeout-minutes: 30 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - target: linux-x64 + host: ubuntu-latest + - target: darwin-arm64 + host: macos-15 + - target: darwin-x64 + host: macos-15-intel + outputs: + artifact_prefix: ${{ steps.identity.outputs.artifact_prefix }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + with: + version: 9.15.4 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + - name: Bind immutable producer source and target + id: identity + env: + SOURCE_SHA: ${{ inputs.ref }} + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + [[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]] || exit 1 + [[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] || exit 1 + [[ "$(node -p 'process.platform + "-" + process.arch')" == "$TARGET" ]] || exit 1 + echo "artifact_prefix=runner-release-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" + - name: Install dependencies and build production TypeScript contract + run: | + pnpm install --no-frozen-lockfile + pnpm --filter @paperclipai/paperclip-runner build:typescript + - name: Build the host daemon with the pinned toolchain + working-directory: packages/paperclip-runner + run: pnpm build:binary + - name: Record actual daemon metadata and immutable bytes + env: + SOURCE_SHA: ${{ inputs.ref }} + TARGET: ${{ matrix.target }} + run: | + node packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs record "$SOURCE_SHA" "$TARGET" \ + packages/paperclip-runner/dist/bin/paperclip-runnerd "$RUNNER_TEMP/runner-release/$TARGET" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ${{ steps.identity.outputs.artifact_prefix }}-${{ matrix.target }} + path: ${{ runner.temp }}/runner-release/ + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8fdbe4210..696590353c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -336,9 +336,54 @@ jobs: SOURCE_SHA: ${{ github.sha }} run: node scripts/cloud-source-verification.mjs "$SOURCE_SHA" + release_runner_source: + # Old promotions retain their already-shipped packaging contract. New + # sources opt into mandatory three-target assembly in their release.sh. + needs: [verify_canary, select_nightly, select_beta, preflight_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.preflight_stable.result == 'success') + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + sha: ${{ steps.source.outputs.sha }} + required: ${{ steps.source.outputs.required }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.preflight_stable.outputs.sha || needs.select_beta.outputs.sha || needs.select_nightly.outputs.sha || github.sha }} + persist-credentials: false + - name: Read the exact source packaging contract + id: source + env: + SOURCE_SHA: ${{ needs.preflight_stable.outputs.sha || needs.select_beta.outputs.sha || needs.select_nightly.outputs.sha || github.sha }} + run: | + set -euo pipefail + [[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]] || exit 1 + [[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] || exit 1 + echo "sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT" + source_script="$(git show "$SOURCE_SHA:scripts/release.sh")" || exit 1 + if printf '%s\n' "$source_script" | grep -F 'assemble-daemons "$PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR"' > /dev/null; then + echo "required=true" >> "$GITHUB_OUTPUT" + else + echo "required=false" >> "$GITHUB_OUTPUT" + fi + + release_runner_binaries: + needs: release_runner_source + if: needs.release_runner_source.outputs.required == 'true' + uses: ./.github/workflows/release-verify.yml + with: + ref: ${{ needs.release_runner_source.outputs.sha }} + runner_binaries_only: true + publish_canary: - if: github.event_name == 'push' - needs: verify_canary + if: ${{ !cancelled() && github.event_name == 'push' && needs.verify_canary.result == 'success' && needs.release_runner_source.result == 'success' && (needs.release_runner_source.outputs.required == 'false' || needs.release_runner_binaries.result == 'success') }} + needs: [verify_canary, release_runner_source, release_runner_binaries] runs-on: ubuntu-latest timeout-minutes: 150 environment: npm-canary @@ -356,6 +401,17 @@ jobs: with: fetch-depth: 0 + - name: Download exact-source release daemon receipts + if: needs.release_runner_source.outputs.required == 'true' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* + path: ${{ runner.temp }}/release-runner-inputs + merge-multiple: true + - name: Bind daemon inputs for release assembly + if: needs.release_runner_source.outputs.required == 'true' + run: echo "PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR=$RUNNER_TEMP/release-runner-inputs" >> "$GITHUB_ENV" + - name: Setup pnpm uses: pnpm/action-setup@v6 with: @@ -587,12 +643,14 @@ jobs: artifact_name: nightly-release-smoke publish_nightly: - needs: [select_nightly, smoke_nightly] + needs: [select_nightly, smoke_nightly, release_runner_source, release_runner_binaries] # 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.select_nightly.outputs.proceed == 'true' && + needs.release_runner_source.result == 'success' && + (needs.release_runner_source.outputs.required == 'false' || needs.release_runner_binaries.result == 'success') && (needs.smoke_nightly.result == 'success' || (needs.smoke_nightly.result == 'skipped' && github.event_name == 'workflow_dispatch' && inputs.dry_run)) runs-on: ubuntu-latest @@ -618,6 +676,17 @@ jobs: ref: ${{ needs.select_nightly.outputs.sha }} fetch-depth: 0 + - name: Download exact-source release daemon receipts + if: needs.release_runner_source.outputs.required == 'true' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* + path: ${{ runner.temp }}/release-runner-inputs + merge-multiple: true + - name: Bind daemon inputs for release assembly + if: needs.release_runner_source.outputs.required == 'true' + run: echo "PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR=$RUNNER_TEMP/release-runner-inputs" >> "$GITHUB_ENV" + - name: Setup pnpm uses: pnpm/action-setup@v6 with: @@ -855,10 +924,12 @@ jobs: ref: ${{ needs.select_beta.outputs.sha }} publish_beta: - needs: [select_beta, verify_beta_candidate] + needs: [select_beta, verify_beta_candidate, release_runner_source, release_runner_binaries] if: >- !cancelled() && needs.select_beta.result == 'success' && + needs.release_runner_source.result == 'success' && + (needs.release_runner_source.outputs.required == 'false' || needs.release_runner_binaries.result == 'success') && (needs.verify_beta_candidate.result == 'success' || (needs.verify_beta_candidate.result == 'skipped' && needs.select_beta.outputs.mode == 'promote')) runs-on: ubuntu-latest @@ -884,6 +955,17 @@ jobs: ref: ${{ needs.select_beta.outputs.sha }} fetch-depth: 0 + - name: Download exact-source release daemon receipts + if: needs.release_runner_source.outputs.required == 'true' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* + path: ${{ runner.temp }}/release-runner-inputs + merge-multiple: true + - name: Bind daemon inputs for release assembly + if: needs.release_runner_source.outputs.required == 'true' + run: echo "PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR=$RUNNER_TEMP/release-runner-inputs" >> "$GITHUB_ENV" + - name: Setup pnpm uses: pnpm/action-setup@v6 with: @@ -1240,8 +1322,8 @@ 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.preflight_stable.result == 'success' && needs.verify_stable.result == 'success' && needs.release_runner_source.result == 'success' && (needs.release_runner_source.outputs.required == 'false' || needs.release_runner_binaries.result == 'success') }} + needs: [preflight_stable, verify_stable, release_runner_source, release_runner_binaries] runs-on: ubuntu-latest timeout-minutes: 45 permissions: @@ -1254,6 +1336,17 @@ jobs: fetch-depth: 0 ref: ${{ needs.preflight_stable.outputs.sha }} + - name: Download exact-source release daemon receipts + if: needs.release_runner_source.outputs.required == 'true' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* + path: ${{ runner.temp }}/release-runner-inputs + merge-multiple: true + - name: Bind daemon inputs for release assembly + if: needs.release_runner_source.outputs.required == 'true' + run: echo "PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR=$RUNNER_TEMP/release-runner-inputs" >> "$GITHUB_ENV" + - name: Setup pnpm uses: pnpm/action-setup@v6 with: @@ -1282,8 +1375,8 @@ 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.preflight_stable.result == 'success' && needs.verify_stable.result == 'success' && needs.release_runner_source.result == 'success' && (needs.release_runner_source.outputs.required == 'false' || needs.release_runner_binaries.result == 'success') }} + needs: [preflight_stable, verify_stable, release_runner_source, release_runner_binaries] runs-on: ubuntu-latest timeout-minutes: 150 environment: npm-stable @@ -1301,6 +1394,17 @@ jobs: fetch-depth: 0 ref: ${{ needs.preflight_stable.outputs.sha }} + - name: Download exact-source release daemon receipts + if: needs.release_runner_source.outputs.required == 'true' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* + path: ${{ runner.temp }}/release-runner-inputs + merge-multiple: true + - name: Bind daemon inputs for release assembly + if: needs.release_runner_source.outputs.required == 'true' + run: echo "PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR=$RUNNER_TEMP/release-runner-inputs" >> "$GITHUB_ENV" + - name: Setup pnpm uses: pnpm/action-setup@v6 with: diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index a8e1e59cde..b0e097be32 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -1,6 +1,67 @@ # Runner defaults: staged delivery -Updated 2026-10-09. +Updated 2026-10-12. + +## Current amendment: include macOS in this PR + +The user requires macOS support in the same Codex-default PR. The earlier +Linux-only scope and macOS deferral below are superseded. Qualify Linux x64, +macOS arm64 and macOS x64 through the shipped package path. Windows and Linux +ARM keep their legacy defaults. Existing agents retain recorded execution. +The user explicitly invoked `dotta:paperclip-test-drive`. Real task/follow-up +acceptance is required on this Apple Silicon Mac. Intel builds must be attempted; +an attended Intel provider journey is not required. Reuse the healthy test drive +and the existing Product E2E helpers rather than create another test system. + +Keep the current PR and branch: [#15683](https://github.com/paperclipai/paperclip/pull/15683), +`codex/codex-runner-default`. The last pushed source is +`6ca302426b768791b499d2d563bbca4276f2c908`. Its prior CI has 52 passing checks, +four conditional skips, fresh 5/5 review and eight resolved threads. These do +not qualify the new macOS changes. Current master has real merge conflicts; +integrate the fetched master after checkpointing the owned macOS changes. + +The [test-drive server](http://127.0.0.1:3125/COD/agents/all) is running through +the normal dev supervisor at the pushed source. Health and bootstrap are ready; +the actual listener belongs to this worktree. The preserved Codex QA agent is +idle on `codex_local`, with its saved unrelated title edit intact. Dependencies +and ignored configuration had been cleared, so startup restored them and the +normal CLI signing keys while retaining the database and encrypted account key. +The existing cached development daemon is from a different Rust tree. It avoids +a local Rust build and is not macOS qualification evidence for this candidate. + +Implementation ownership: + +- Packaging worker: reuse the existing daemon assembler, release verification + and release-smoke workflows to produce source-bound Linux and both macOS + artifacts. Pack the normal npm graph, then test those same tarballs on actual + Mac hosts. Retain the Linux and provider-pack controls. Never bundle Codex + native payloads; use the installed official npm dependency and real protocol + compatibility. Compile Rust and Docker only on remote hosts. +- Selection worker: extend the shared availability contract and existing + creation, onboarding, discovery, approval, import and UI fixtures. Preserve + explicit legacy choices, external overrides and existing execution. +- Runtime worker: verify the existing Mac native launch, account isolation, + file delivery and continuity seams; fix only actual gaps. Keep the Linux-only + sandboxed workspace-command capability gated on unsupported platforms. +- Lead: integrate, run targeted and required checks, verify useful Mac native + task/follow-up artifacts and ordinary editing, refresh the test-drive on the + verified revision, and update the same PR and evidence record. + +Reuse existing evaluations and assertions. Provider-free packaging and protocol +checks do not substitute for authenticated task completion. Preserve the $250 +ceiling and historical unknown/cleanup holds; reconcile only documented +unstarted future reservations before any new provider turns. No additional +paid Linux campaign is authorized by this amendment. Embedded-browser-only +login, no automatic agent migration, and no merge or production deployment +remain constraints. + +Next action: integrate the macOS packaging and policy changes, reconcile the +confirmed master conflicts, freeze the source, and run remote qualification for +the Mac artifacts. Native Apple Silicon live acceptance, new-head CI/review, managed +staging execution, code-owner approval and actual post-merge Cloud composition +remain unverified. Prior staging image/preflight evidence is retained at +[37962635959](https://github.com/paperclipai/paperclip/actions/runs/37962635959) +and [37965658123](https://github.com/paperclipai/paperclip-cloud/actions/runs/37965658123). ## Outcome and ownership diff --git a/packages/paperclip-runner/scripts/release-provider-pack.test.mjs b/packages/paperclip-runner/scripts/release-provider-pack.test.mjs index 33d6de5874..f1015a2ffc 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, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -9,6 +9,7 @@ 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 { existingReleaseProviderPack } from "./stage-release-runner-binaries.mjs"; const revision = "a".repeat(40); const digest = "sha256:" + "b".repeat(64); @@ -47,7 +48,7 @@ test("rejects omitted Cursor, conflicting legacy inventory, stale sources and fo }); test("release assembly rejects rehashed stale or missing packs before staging any files", () => { - const root = mkdtempSync(join(tmpdir(), "release-provider-pack-")); + const root = realpathSync(mkdtempSync(join(tmpdir(), "release-provider-pack-"))); const source = dirname(dirname(fileURLToPath(import.meta.url))); try { // Run the actual assembler in an isolated layout so it cannot overwrite built artifacts. @@ -77,3 +78,19 @@ test("release assembly rejects rehashed stale or missing packs before staging an } } finally { rmSync(root, { recursive: true, force: true }); } }); + + +test("daemon-only assembly retains an existing same-source Linux provider-pack identity", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "release-provider-retained-"))); + try { + assert.equal(existingReleaseProviderPack(root, revision), undefined); + const file = join(root, "dist/remote-provider-packs/linux-x64/provider-pack.json"); + mkdirSync(dirname(file), { recursive: true }); + const pack = seal(fixture()); writeFileSync(file, JSON.stringify(pack)); + assert.deepEqual(existingReleaseProviderPack(root, revision), pack); + assert.throws(() => existingReleaseProviderPack(root, "c".repeat(40)), /release source/); + const wrongTarget = fixture(); wrongTarget.target = { platform: "darwin", architecture: "arm64" }; + writeFileSync(file, JSON.stringify(seal(wrongTarget))); + assert.throws(() => existingReleaseProviderPack(root, revision), /Linux x64 target/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs b/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs index f0e505363a..447603e38a 100644 --- a/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs +++ b/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs @@ -3,53 +3,133 @@ // Manifest: {sourceRevision, platforms: {"darwin-arm64": {path, sha256}, ...}, remoteProviderPack: {path, sha256}}. import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; -import { chmodSync, copyFileSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { runnerBinaryTarget } from "../src/live/runner-binary.ts"; import { verifyReleaseProviderPack } from "./release-provider-pack.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const required = ["darwin-arm64", "darwin-x64", "linux-x64"]; -if (process.argv.length !== 3) throw Error("Usage: stage-release-runner-binaries.mjs MANIFEST.json"); -const manifest = JSON.parse(readFileSync(process.argv[2], "utf8")); -if (!/^[a-f0-9]{40}$/.test(manifest.sourceRevision) || !manifest.platforms || Object.keys(manifest.platforms).sort().join() !== [...required].sort().join()) throw Error("Release daemon manifest must bind one source and all three supported targets"); -const verified = required.map(target => { - const artifact = manifest.platforms[target]; - if (!artifact || !isAbsolute(artifact.path) || !/^sha256:[a-f0-9]{64}$/.test(artifact.sha256)) throw Error(`Invalid ${target} release artifact`); - const bytes = readFileSync(artifact.path), sha256 = "sha256:" + createHash("sha256").update(bytes).digest("hex"); - if (sha256 !== artifact.sha256 || runnerBinaryTarget(bytes) !== target) throw Error(`Release daemon ${target} digest or architecture mismatch`); - return { target, source: artifact.path, sha256 }; -}); -if (!manifest.remoteProviderPack) throw Error("Release assembly requires the matching Linux remote provider pack"); -const entry = manifest.remoteProviderPack; -if (!isAbsolute(entry.path) || !/^sha256:[a-f0-9]{64}$/.test(entry.sha256)) throw Error("Invalid remote provider-pack release artifact"); -const packBytes = readFileSync(entry.path); -if (packBytes.length > 512 * 1024 || "sha256:" + createHash("sha256").update(packBytes).digest("hex") !== entry.sha256) throw Error("Remote provider-pack release manifest digest mismatch"); -const packIdentity = verifyReleaseProviderPack(JSON.parse(packBytes), manifest.sourceRevision); -const platforms = {}; -for (const artifact of verified) { - const relative = `${artifact.target}/paperclip-runnerd`, destination = join(root, "dist", "bin", relative); - mkdirSync(dirname(destination), { recursive: true }); copyFileSync(artifact.source, destination); chmodSync(destination, 0o755); - // Copied Mach-O inodes need a fresh ad-hoc signature on macOS. Record the - // resulting packaged bytes, as well as the verified build artifact identity. - if (process.platform === "darwin" && artifact.target.startsWith("darwin-")) { - execFileSync("codesign", ["--force", "--sign", "-", destination], { stdio: "pipe" }); - } - const sha256 = "sha256:" + createHash("sha256").update(readFileSync(destination)).digest("hex"); - platforms[artifact.target] = { path: relative, sha256, sourceSha256: artifact.sha256 }; +export const RELEASE_RUNNER_TARGETS = Object.freeze(["darwin-arm64", "darwin-x64", "linux-x64"]); +const required = RELEASE_RUNNER_TARGETS; +const digest = bytes => "sha256:" + createHash("sha256").update(bytes).digest("hex"); +function regularFile(path, maximumSize) { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size < 1 || stat.size > maximumSize + || realpathSync(path) !== resolve(path)) throw Error("Release artifact must be a bounded canonical regular file"); + return readFileSync(path); } -{ - const destination = join(root, "dist", "remote-provider-packs", "linux-x64", "provider-pack.json"); - mkdirSync(dirname(destination), { recursive: true }); - // Replace the inode so repeat assembly can preserve a read-only manifest. - const staged = `${destination}.staging-${process.pid}`; - try { - writeFileSync(staged, packBytes, { mode: 0o444, flag: "wx" }); - renameSync(staged, destination); - } finally { - rmSync(staged, { force: true }); - } + +// Production TypeScript contracts are built by the existing producer step. +// Foreign artifacts are data: the assembler never executes another target. +async function metadataContract() { + const { PAPERCLIP_RUNNER_BUILD_METADATA } = await import("../dist/evals/build-metadata.js"); + const { parsePaperclipRunnerdBuildMetadata } = await import("../dist/evals/runnerd-artifact.js"); + return metadata => verifyReleaseRunnerMetadata(parsePaperclipRunnerdBuildMetadata(metadata), PAPERCLIP_RUNNER_BUILD_METADATA); } -writeFileSync(join(root, "dist", "bin", "release-manifest.json"), JSON.stringify({schema:"paperclip.runner.release-binaries.v1",sourceRevision:manifest.sourceRevision,platforms, - remoteProviderPack: { target: "linux-x64", digest: packIdentity.digest, sourceRevision: packIdentity.payload.runnerSourceRevision }},null,2)+"\n"); -console.log("Staged verified release daemons for " + required.join(", ")); +export function verifyReleaseRunnerMetadata(metadata, expected) { + if (metadata.binaryContractVersion !== expected.contracts.runnerdArtifact + || metadata.nativeExecutionVersion !== expected.contracts.nativeExecution + || metadata.harnessDriverVersion !== expected.contracts.harnessDriver + || metadata.packageName !== expected.package.name || metadata.packageVersion !== expected.package.version + || metadata.prp?.name !== expected.prp.name || metadata.prp.minimumVersion > expected.prp.maximumVersion + || metadata.prp.maximumVersion < expected.prp.minimumVersion) throw Error("Release daemon build metadata is incompatible with the production contract"); + return metadata; +} + +export function releaseDaemonManifest(artifactDirectory, sourceRevision, verifyMetadata) { + if (!/^[a-f0-9]{40}$/.test(sourceRevision)) throw Error("Release daemons require an immutable source revision"); + if (readdirSync(artifactDirectory).sort().join() !== [...required].sort().join()) throw Error("Release daemons require exactly the three supported target directories"); + const platforms = {}; + for (const target of required) { + const directory = join(resolve(artifactDirectory), target); + if (readdirSync(directory).sort().join() !== "paperclip-runnerd,receipt.json") throw Error("Release target must contain only its daemon and receipt"); + const receipt = JSON.parse(regularFile(join(directory, "receipt.json"), 64 * 1024)); + const path = join(directory, "paperclip-runnerd"), bytes = regularFile(path, 256 * 1024 * 1024); + if (receipt.schema !== "paperclip.runner.release-artifact.v1" || receipt.sourceRevision !== sourceRevision + || receipt.target !== target || receipt.sha256 !== digest(bytes) || runnerBinaryTarget(bytes) !== target) { + throw Error(`Release daemon ${target} source, digest or architecture mismatch`); + } + verifyMetadata(receipt.buildMetadata); + platforms[target] = { path, sha256: receipt.sha256, buildMetadata: receipt.buildMetadata }; + } + return { sourceRevision, platforms }; +} + +export function existingReleaseProviderPack(packageRoot, sourceRevision) { + const file = join(packageRoot, "dist/remote-provider-packs/linux-x64/provider-pack.json"); + if (!existsSync(file)) return undefined; + return verifyReleaseProviderPack(JSON.parse(regularFile(file, 512 * 1024)), sourceRevision); +} + +async function main() { + if (process.argv[2] === "record") { + const [, , , sourceRevision, target, source, output] = process.argv; + if (process.argv.length !== 7 || !/^[a-f0-9]{40}$/.test(sourceRevision) || !required.includes(target) + || target !== `${process.platform}-${process.arch}`) throw Error("Record requires the exact source and actual producer host target"); + const bytes = regularFile(resolve(source), 256 * 1024 * 1024); + if (runnerBinaryTarget(bytes) !== target) throw Error("Built daemon architecture does not match its producer"); + const buildMetadata = JSON.parse(execFileSync(resolve(source), ["--build-metadata"], { + timeout: 15_000, maxBuffer: 64 * 1024, encoding: "utf8", + })); + (await metadataContract())(buildMetadata); + mkdirSync(output, { recursive: true }); + copyFileSync(source, join(output, "paperclip-runnerd")); + writeFileSync(join(output, "receipt.json"), JSON.stringify({ schema: "paperclip.runner.release-artifact.v1", + sourceRevision, target, sha256: digest(bytes), buildMetadata }, null, 2) + "\n"); + return; + } + const daemonOnly = process.argv[2] === "assemble-daemons"; + if (daemonOnly ? process.argv.length !== 5 : process.argv.length !== 3) throw Error("Usage: stage-release-runner-binaries.mjs MANIFEST.json | assemble-daemons ARTIFACT_DIRECTORY SOURCE_SHA"); + const manifest = daemonOnly + ? releaseDaemonManifest(process.argv[3], process.argv[4], await metadataContract()) + : JSON.parse(regularFile(resolve(process.argv[2]), 512 * 1024)); + if (!/^[a-f0-9]{40}$/.test(manifest.sourceRevision) || !manifest.platforms || Object.keys(manifest.platforms).sort().join() !== [...required].sort().join()) throw Error("Release daemon manifest must bind one source and all three supported targets"); + const verified = required.map(target => { + const artifact = manifest.platforms[target]; + if (!artifact || !isAbsolute(artifact.path) || !/^sha256:[a-f0-9]{64}$/.test(artifact.sha256)) throw Error(`Invalid ${target} release artifact`); + const bytes = regularFile(artifact.path, 256 * 1024 * 1024), sha256 = "sha256:" + createHash("sha256").update(bytes).digest("hex"); + if (sha256 !== artifact.sha256 || runnerBinaryTarget(bytes) !== target) throw Error(`Release daemon ${target} digest or architecture mismatch`); + return { target, source: artifact.path, sha256 }; + }); + if (!manifest.remoteProviderPack && !daemonOnly) throw Error("Release assembly requires the matching Linux remote provider pack"); + let packBytes, packIdentity; + if (manifest.remoteProviderPack) { + const entry = manifest.remoteProviderPack; + if (!isAbsolute(entry.path) || !/^sha256:[a-f0-9]{64}$/.test(entry.sha256)) throw Error("Invalid remote provider-pack release artifact"); + packBytes = regularFile(entry.path, 512 * 1024); + if (packBytes.length > 512 * 1024 || "sha256:" + createHash("sha256").update(packBytes).digest("hex") !== entry.sha256) throw Error("Remote provider-pack release manifest digest mismatch"); + packIdentity = verifyReleaseProviderPack(JSON.parse(packBytes), manifest.sourceRevision); + } else if (daemonOnly) { + // Preserve a qualified same-source Linux pack already materialized by the + // normal producer. It never becomes a Mac provider-pack qualification. + packIdentity = existingReleaseProviderPack(root, manifest.sourceRevision); + } + const platforms = {}; + for (const artifact of verified) { + const relative = `${artifact.target}/paperclip-runnerd`, destination = join(root, "dist", "bin", relative); + mkdirSync(dirname(destination), { recursive: true }); copyFileSync(artifact.source, destination); chmodSync(destination, 0o755); + // Copied Mach-O inodes need a fresh ad-hoc signature on macOS. Record the + // resulting packaged bytes, as well as the verified build artifact identity. + if (process.platform === "darwin" && artifact.target.startsWith("darwin-")) { + execFileSync("codesign", ["--force", "--sign", "-", destination], { stdio: "pipe", timeout: 30_000 }); + } + const sha256 = "sha256:" + createHash("sha256").update(readFileSync(destination)).digest("hex"); + platforms[artifact.target] = { path: relative, sha256, sourceSha256: artifact.sha256 }; + } + if (packBytes) { + const destination = join(root, "dist", "remote-provider-packs", "linux-x64", "provider-pack.json"); + mkdirSync(dirname(destination), { recursive: true }); + // Replace the inode so repeat assembly can preserve a read-only manifest. + const staged = `${destination}.staging-${process.pid}`; + try { + writeFileSync(staged, packBytes, { mode: 0o444, flag: "wx" }); + renameSync(staged, destination); + } finally { + rmSync(staged, { force: true }); + } + } + writeFileSync(join(root, "dist", "bin", "release-manifest.json"), JSON.stringify({schema:"paperclip.runner.release-binaries.v1",sourceRevision:manifest.sourceRevision,platforms, + ...(packIdentity ? { remoteProviderPack: { target: "linux-x64", digest: packIdentity.digest, sourceRevision: packIdentity.payload.runnerSourceRevision } } : {})},null,2)+"\n"); + console.log("Staged verified release daemons for " + required.join(", ")); +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); diff --git a/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs b/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs index 81696b8ba6..dafb9b23f0 100644 --- a/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs +++ b/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs @@ -1,11 +1,66 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { once } from "node:events"; -import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import path from "node:path"; import { test } from "node:test"; import { stageRunnerBinary } from "./stage-runner-binary.mjs"; +import { RELEASE_RUNNER_TARGETS, releaseDaemonManifest, verifyReleaseRunnerMetadata } from "./stage-release-runner-binaries.mjs"; + +const contract = { package: { name: "@paperclipai/paperclip-runner", version: "0.0.0" }, + contracts: { runnerdArtifact: 2, nativeExecution: 1, harnessDriver: 1 }, prp: { name: "paperclip.runner", minimumVersion: 1, maximumVersion: 3 } }; +const metadata = { binaryContractVersion: 2, nativeExecutionVersion: 1, harnessDriverVersion: 1, + packageName: "@paperclipai/paperclip-runner", packageVersion: "0.0.0", prp: { name: "paperclip.runner", minimumVersion: 1, maximumVersion: 2 } }; + +test("release assembly binds all three actual targets, source, hashes and compatible metadata", async () => { + const root = await realpath(await mkdtemp(path.join(tmpdir(), "runner-release-inputs-"))), source = "a".repeat(40); + const verify = value => verifyReleaseRunnerMetadata(value, contract); + const receiptPath = target => path.join(root, target, "receipt.json"); + try { + for (const target of RELEASE_RUNNER_TARGETS) { + await mkdir(path.join(root, target)); + 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); bytes.writeUInt32LE(target === "darwin-x64" ? 0x01000007 : 0x0100000c, 4); } + await writeFile(path.join(root, target, "paperclip-runnerd"), bytes); + await writeFile(receiptPath(target), JSON.stringify({ schema: "paperclip.runner.release-artifact.v1", sourceRevision: source, + target, sha256: "sha256:" + createHash("sha256").update(bytes).digest("hex"), buildMetadata: metadata })); + } + const actual = releaseDaemonManifest(root, source, verify); + assert.deepEqual(Object.keys(actual.platforms), RELEASE_RUNNER_TARGETS); + assert.equal(actual.remoteProviderPack, undefined, "Daemon-only data does not qualify a provider pack"); + assert.throws(() => releaseDaemonManifest(root, "b".repeat(40), verify), /source, digest or architecture/); + const original = await readFile(receiptPath("darwin-arm64"), "utf8"), receipt = JSON.parse(original); + for (const delta of [{ target: "darwin-x64" }, { sha256: "sha256:" + "0".repeat(64) }, { sourceRevision: "b".repeat(40) }, + { buildMetadata: { ...metadata, binaryContractVersion: 99 } }]) { + await writeFile(receiptPath("darwin-arm64"), JSON.stringify({ ...receipt, ...delta })); + assert.throws(() => releaseDaemonManifest(root, source, verify)); + } + await writeFile(receiptPath("darwin-arm64"), original); + const binary = path.join(root, "darwin-arm64/paperclip-runnerd"), bytes = await readFile(binary); + await writeFile(binary, await readFile(path.join(root, "darwin-x64/paperclip-runnerd"))); + assert.throws(() => releaseDaemonManifest(root, source, verify), /digest or architecture/); + await rm(binary); await symlink(path.join(root, "darwin-x64/paperclip-runnerd"), binary); + assert.throws(() => releaseDaemonManifest(root, source, verify), /regular file/); + await rm(binary); await writeFile(binary, bytes); + await mkdir(path.join(root, "win32-x64")); + assert.throws(() => releaseDaemonManifest(root, source, verify), /exactly the three/); + await rm(path.join(root, "win32-x64"), { recursive: true }); + await rm(receiptPath("linux-x64")); + assert.throws(() => releaseDaemonManifest(root, source, verify), /only its daemon and receipt/); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("release metadata rejects incompatible native, harness and protocol contracts without semver heuristics", () => { + assert.equal(verifyReleaseRunnerMetadata(metadata, contract), metadata); + for (const delta of [{ nativeExecutionVersion: 9 }, { harnessDriverVersion: 9 }, { packageName: "untrusted" }, + { prp: { ...metadata.prp, name: "other" } }, { prp: { ...metadata.prp, minimumVersion: 4, maximumVersion: 5 } }]) { + assert.throws(() => verifyReleaseRunnerMetadata({ ...metadata, ...delta }, contract), /incompatible/); + } +}); async function withOwnedChildren(root, action) { const owned = []; diff --git a/packages/shared/src/agent-runner.ts b/packages/shared/src/agent-runner.ts index 324d163c48..62f633d70c 100644 --- a/packages/shared/src/agent-runner.ts +++ b/packages/shared/src/agent-runner.ts @@ -12,10 +12,11 @@ export function paperclipRunnerProfileForHarness(harness: string): { provider: " return harness === "codex_local" ? { provider: "codex" } : undefined; } -/** Public server packages currently ship a Linux x64 daemon; source-build targets are not release qualification. */ +/** Creation defaults follow the shipped Linux x64 and macOS release artifacts. */ export function paperclipRunnerSupportsPlatform(harness: string, platform: string, architecture: string): boolean { return !!paperclipRunnerProfileForHarness(harness) - && platform === "linux" && architecture === "x64"; + && ((platform === "linux" && architecture === "x64") + || (platform === "darwin" && (architecture === "arm64" || architecture === "x64"))); } /** Recover harness identity without changing execution or guessing unknown providers. */ diff --git a/scripts/__tests__/grok-public-install-sandbox.test.mjs b/scripts/__tests__/grok-public-install-sandbox.test.mjs index d084ee9be8..ab9ff9bffa 100644 --- a/scripts/__tests__/grok-public-install-sandbox.test.mjs +++ b/scripts/__tests__/grok-public-install-sandbox.test.mjs @@ -4,7 +4,7 @@ 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, assertInstalledCodexPlatformProvenance, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource } from '../grok-public-install-sandbox.mjs'; +import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledCodexPlatformProvenance, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource, installedDaemonProbeSource, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource } 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]] : []); @@ -141,6 +141,18 @@ test('the installed Codex probe exercises the public export and rejects incomple writeFileSync(legacyCommand, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); writeFileSync(index, `export const resolvePinnedCodexCommand = () => ${JSON.stringify(legacyCommand)};`); result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /Selected Codex platform version must match the qualified pin/); + // Native Codex uses protocol compatibility, not the ACPX version allowlist. + // Keep exact official-npm host provenance and the selected resource boundary. + mkdirSync(join(legacy, 'vendor')); + writeFileSync(legacyCommand, '#!/bin/sh\nprintf "codex-cli 0.156.1\\n"\n', { mode: 0o755 }); + writeFileSync(security, securitySource.replaceAll(JSON.stringify(nativeVendor), JSON.stringify(join(legacy, 'vendor')))); + writeFileSync(probe, installedCodexProbeSource(index, consumer, undefined, { nativeCodex: true })); + result = run(); assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).installedCodexVersion, 'codex-cli 0.156.1'); + assert.equal(JSON.parse(result.stdout).codexQualifiedInstallationVerified, undefined); + writeFileSync(probe, installedCodexProbeSource(index, consumer, '0.160.0')); + writeFileSync(security, securitySource); + writeFileSync(index, `import { writeFileSync } from 'node:fs'; export const resolvePinnedCodexCommand = () => { writeFileSync(${JSON.stringify(join(platform, 'package.json'))}, ${JSON.stringify(JSON.stringify({ ...manifest, altered: true }))}); return ${JSON.stringify(command)}; };`); @@ -229,3 +241,47 @@ test('consumer provenance rejects bundled, foreign, inconsistent and non-officia assert.throws(() => check(entry, [{ path: 'node_modules/@openai/codex-darwin-arm64', manifest }]), /only the host/); assert.throws(() => check(entry, [{ path, manifest: { ...manifest, name: 'untrusted' } }]), /identity/); }); + + +test('hosted Mac lifecycle denies OS networking and writes outside owned scratch', () => { + const profile = macPublicInstallLifecycleProfile('/Users/runner/work/_temp/owned-check'); + assert.match(profile, /\(deny network\*\)/); + assert.match(profile, /\(deny file-write\*/); + assert.match(profile, /subpath "\/Users\/runner\/work\/_temp\/owned-check"/); + assert.throws(() => macPublicInstallLifecycleProfile('relative'), /absolute/); + assert.throws(() => macPublicInstallLifecycleProfile('/tmp/owned\0bad'), /absolute/); + const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8'); + assert.match(source, /process.env.GITHUB_ACTIONS, 'true'/); + assert.match(source, /networkProbe, 'allowed'/); + assert.match(source, /networkProbe, 'denied'/); + assert.match(source, /error.code,'EPERM'/); + assert.match(source, /'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts'/); + assert.match(source, /readFileSync\(sentinel, 'utf8'\), 'ok'/); + assert.match(source, /readFileSync\(join\(consumer, 'package-lock.json'\), 'utf8'\), lock/); + assert.match(source, /finally \{ rmSync\(root, \{ recursive: true, force: true \}\); \}/); +}); + +test('installed daemon proof uses the production host resolver and complete source-bound manifest', () => { + const source = installedDaemonProbeSource('/consumer/server/dist/vendor/paperclip-runner', '/consumer', 'a'.repeat(40)); + assert.match(source, /defaultCapabilityRunnerdBinary\(\)/); + assert.match(source, /Object.keys\(manifest.platforms\).sort\(\), targets.sort\(\)/); + assert.match(source, /manifest.sourceRevision, "a{40}"/); + assert.match(source, /runnerBinaryTarget\(bytes\), target/); + assert.match(source, /createHash\('sha256'\)/); + assert.match(source, /metadata.binaryContractVersion, expected.contracts.runnerdArtifact/); + assert.doesNotMatch(source, /PAPERCLIP_RUNNERD_BINARY|runner\/target\/debug|execFileSync\('paperclip-runnerd'/); +}); + + +test('Mac startup wiring normalizes only validated manifest digests and uses server-scoped tooling', () => { + const source = macInstalledStartupProbeSource('/owned/consumer', '/owned/consumer/server', 'sha256:' + 'a'.repeat(64), '/source/tests/runner-e2e/installed-cli.ts'); + assert.match(source, /expectedRunnerSha256:"a{64}"/); + assert.doesNotMatch(source, /expectedRunnerSha256:"sha256:/); + for (const digest of ['a'.repeat(64), 'sha256:bad', 'sha512:' + 'a'.repeat(64), 'sha256:' + 'A'.repeat(64)]) { + assert.throws(() => macInstalledStartupProbeSource('/owned/consumer', '/owned/consumer/server', digest, '/source/helper.ts'), /verified SHA-256/); + } + const verifier = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8'); + assert.match(verifier, /\['--import', 'tsx', startupFile\], join\(repo, 'server'\)/); + const closedEnvironment = verifier.match(/const env = \{ PATH: `\$\{tools\}[\s\S]*?\};/)[0]; + assert.doesNotMatch(closedEnvironment, /NODE_PATH|NODE_OPTIONS|\.\.\.process.env/); +}); diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs index 98717aa8af..41b171cd4c 100644 --- a/scripts/__tests__/release-verify-workflow.test.mjs +++ b/scripts/__tests__/release-verify-workflow.test.mjs @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; +import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import test from "node:test"; @@ -38,14 +39,14 @@ test("chaos verification isolates callers that verify the same source commit", ( test("canary reuses exact-source proof while stable keeps full verification", () => { const releaseWorkflow = readWorkflow("release.yml"); - const canary = releaseWorkflow.split(" verify_canary:\n")[1].split("\n publish_canary:")[0]; + const canary = releaseWorkflow.match(/ verify_canary:\n[\s\S]*?(?=\n [a-z_]+:)/)[0]; assert.match(canary, /github\.repository == 'paperclipai\/paperclip' && github\.event_name == 'push' && github\.ref == 'refs\/heads\/master'/); assert.match(canary, /actions: read/); assert.match(canary, /ref: \$\{\{ github\.sha \}\}/); 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/); + assert.match(releaseWorkflow, /publish_canary:\n\s+if: .*github\.event_name == 'push'.*\n\s+needs: \[verify_canary, release_runner_source, release_runner_binaries\]/); // 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. @@ -480,3 +481,85 @@ test("direct protocol concurrency override only lowers the configured ceiling", if (expected !== null) assert.equal(result.stdout, expected); } }); + +test("release daemon production uses exact-source native hosts and no publisher or provider permissions", () => { + const workflow = readWorkflow("release-verify.yml"); + const producer = workflow.split(" runner_binaries:\n")[1]; + for (const [target, host] of [["linux-x64", "ubuntu-latest"], ["darwin-arm64", "macos-15"], ["darwin-x64", "macos-15-intel"]]) { + assert.ok(producer.includes(`- target: ${target}\n host: ${host}`)); + } + assert.match(producer, /persist-credentials: false/); + assert.match(producer, /contents: read/); + assert.doesNotMatch(producer, /secrets\.|id-token: write|contents: write|npm publish|environment:/); + assert.match(producer, /\[\[ "\$SOURCE_SHA" =~ \^\[a-f0-9\]\{40\}\$ \]\]/); + assert.match(producer, /git rev-parse HEAD/); + assert.match(producer, /process.platform \+ "-" \+ process.arch/); + assert.match(producer, /pnpm build:binary/); + assert.match(producer, /stage-release-runner-binaries.mjs record/); + for (const job of ["runner_chaos_evals", "typecheck", "general_tests", "serialized_tests", "runner_workflow_evals", "verify_paperclip_runner", "build"]) { + assert.ok(workflow.includes(`${job}:\n if: \${{ !inputs.runner_binaries_only }}`)); + } + const release = readWorkflow("release.yml"); + for (const job of ["publish_canary", "publish_nightly", "publish_beta", "preview_stable", "publish_stable"]) { + const block = release.split(` ${job}:\n`)[1].split(/\n [a-z_]+:\n/)[0]; + assert.match(block, /needs: .*release_runner_binaries/); + assert.match(block, /pattern: \$\{\{ needs.release_runner_binaries.outputs.runner_artifact_prefix \}\}-\*/); + assert.match(block, /PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR/); + } + const script = readFileSync(path.join(repoRoot, 'scripts/release.sh'), 'utf8'); + assert.ok(script.indexOf('assemble-daemons "$PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR"') < script.indexOf('scripts/build-standalone-public-packages.mjs')); + assert.match(script, /cp -R .*paperclip-runner\/dist\/bin\/\." .*server\/dist\/vendor\/paperclip-runner\/bin\//); + assert.match(script, /elif \[ "\$dry_run" = false \]; then[\s\S]*?release_fail "Public releases require/); +}); + +test("premerge Mac qualification consumes the same Linux-packed npm bytes without publishing", () => { + const smoke = readWorkflow("release-smoke.yml"); + assert.match(smoke, /smoke_service:\n\s+if: inputs.qualification_source_sha == ''/); + assert.match(smoke, /smoke:\n\s+if: inputs.qualification_source_sha == ''/); + const qualification = smoke.split(" qualification_daemons:\n")[1]; + assert.doesNotMatch(qualification, /secrets\.|npm publish|id-token: write|contents: write|continue-on-error/); + assert.match(qualification, /runner_binaries_only: true/); + assert.match(qualification, /PAPERCLIP_RUNNER_PUBLIC_PACKS_DIR/); + assert.match(qualification, /if: \$\{\{ !cancelled\(\) && steps.pack_identity.outputs.available == 'true' \}\}/); + assert.match(qualification, /name: \$\{\{ inputs.artifact_name \}\}-public-packages/); + assert.match(qualification, /--mac-consumer/); + assert.match(qualification, /host: macos-15\n/); + assert.match(qualification, /host: macos-15-intel\n/); + assert.match(qualification, /pnpm install --ignore-scripts --no-frozen-lockfile/); +}); + + +test("historical promotion sources keep their packaging path while new sources require daemon assembly", () => { + const workflow = readWorkflow("release.yml"); + const selector = workflow.split(' - name: Read the exact source packaging contract')[1].split('\n release_runner_binaries:')[0]; + const script = selector.split(' run: |\n')[1].split('\n').map(line => line.startsWith(' ') ? line.slice(10) : line).join('\n'); + const root = mkdtempSync(path.join(tmpdir(), 'release-source-contract-')); + try { + const tools = path.join(root, 'tools'); mkdirSync(tools); + writeFileSync(path.join(tools, 'git'), '#!/bin/sh\ncase "$1" in\nrev-parse) printf "%s\\n" "$FIXTURE_HEAD";;\nshow) cat "$FIXTURE_SOURCE";;\n*) exit 3;;\nesac\n', { mode: 0o755 }); + const source = path.join(root, 'release.sh'), output = path.join(root, 'outputs'); + for (const [contents, required] of [['pnpm build\n', false], ['assemble-daemons "$PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR" "$SOURCE_SHA"\n', true]]) { + writeFileSync(source, contents); writeFileSync(output, ''); + const result = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { encoding: 'utf8', env: { + PATH: `${tools}:/usr/bin:/bin`, SOURCE_SHA: 'a'.repeat(40), FIXTURE_HEAD: 'a'.repeat(40), FIXTURE_SOURCE: source, GITHUB_OUTPUT: output, + } }); + assert.equal(result.status, 0, result.stderr); + assert.match(readFileSync(output, 'utf8'), new RegExp(`required=${required}`)); + } + const result = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { encoding: 'utf8', env: { + PATH: `${tools}:/usr/bin:/bin`, SOURCE_SHA: 'a'.repeat(40), FIXTURE_HEAD: 'b'.repeat(40), FIXTURE_SOURCE: source, GITHUB_OUTPUT: output, + } }); + assert.equal(result.status, 1, 'Wrong checkout cannot opt out of mandatory assembly'); + const unreadable = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { encoding: 'utf8', env: { + PATH: `${tools}:/usr/bin:/bin`, SOURCE_SHA: 'a'.repeat(40), FIXTURE_HEAD: 'a'.repeat(40), FIXTURE_SOURCE: path.join(root, 'absent'), GITHUB_OUTPUT: output, + } }); + assert.equal(unreadable.status, 1, 'A failed immutable source read must not masquerade as a legacy source'); + + } finally { rmSync(root, { recursive: true, force: true }); } + for (const job of ['publish_canary','publish_nightly','publish_beta','preview_stable','publish_stable']) { + const block = workflow.split(` ${job}:\n`)[1].split(/\n [a-z_]+:\n/)[0]; + assert.match(block, /needs.release_runner_source.result == 'success'/); + assert.match(block, /needs.release_runner_source.outputs.required == 'false' \|\| needs.release_runner_binaries.result == 'success'/); + assert.match(block, /Download exact-source release daemon receipts\n\s+if: needs.release_runner_source.outputs.required == 'true'/); + } +}); diff --git a/scripts/dev-runner-native-binary.mjs b/scripts/dev-runner-native-binary.mjs index ecf9cabede..6ad6d2d31e 100644 --- a/scripts/dev-runner-native-binary.mjs +++ b/scripts/dev-runner-native-binary.mjs @@ -43,15 +43,25 @@ export function paperclipRunnerBinaryNeedsBuild({ nativeRunnerRequired, configuredBinary, platform = process.platform, + architecture = process.arch, }) { if (!nativeRunnerRequired) return false; if (configuredBinary?.trim()) return false; const executable = platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd"; const packageRoot = path.join(repoRoot, "packages", "paperclip-runner"); - const stagedBinary = path.join(packageRoot, "dist", "bin", executable); - const binaryStat = statSync(stagedBinary, { throwIfNoEntry: false }); - if (!binaryStat?.isFile()) return true; + // Match production resolution: a staged release target takes precedence over + // the flat source-build output. Remote Mac builds must not trigger Cargo just + // because only the normal darwin- release executable is present. + let binaryStat; + for (const binary of [ + path.join(packageRoot, "dist", "bin", `${platform}-${architecture}`, executable), + path.join(packageRoot, "dist", "bin", executable), + ]) { + const candidate = statSync(binary, { throwIfNoEntry: false }); + if (candidate?.isFile()) { binaryStat = candidate; break; } + } + if (!binaryStat) return true; const runnerRoot = path.join(packageRoot, "runner"); const buildInputs = [ diff --git a/scripts/grok-public-install-sandbox.mjs b/scripts/grok-public-install-sandbox.mjs index 8ce7663810..4259da033f 100644 --- a/scripts/grok-public-install-sandbox.mjs +++ b/scripts/grok-public-install-sandbox.mjs @@ -60,7 +60,7 @@ export function assertInstalledCodexPlatformProvenance(lock, installed, target) // 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) { +export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersion, { nativeCodex = false } = {}) { return ` import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; @@ -118,11 +118,11 @@ export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersi assert.ok(selectedPlatform, 'Codex wrapper must resolve an official host platform package from its installed graph'); const selectedMetadata = JSON.parse(readFileSync(selectedPlatformManifest, 'utf8')); assert.deepEqual(selectedMetadata, selectedPlatform.manifest, 'Selected Codex platform metadata must match its scanned provenance'); - assert.equal(selectedMetadata.version, ${JSON.stringify(expectedVersion)} + '-' + process.platform + '-' + process.arch, - 'Selected Codex platform version must match the qualified pin'); + ${nativeCodex ? '' : `assert.equal(selectedMetadata.version, ${JSON.stringify(expectedVersion)} + '-' + process.platform + '-' + process.arch, + 'Selected Codex platform version must match the qualified pin');`} 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'); + env: { PATH: process.env.PATH, HOME: process.env.HOME, NODE_PATH: '' } }).trim(); + ${nativeCodex ? "assert.match(version, /^codex-cli /, 'Installed official Codex command must execute');" : `assert.equal(version, 'codex-cli ' + ${JSON.stringify(expectedVersion)}, 'Installed Codex version must match the qualified pin');`} const vendor = dirname(index); const integrityPath = join(vendor, 'drivers/acpx/installation-integrity.js'); const profilesPath = join(vendor, 'drivers/acpx/qualified-profiles.js'); @@ -138,19 +138,81 @@ export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersi assert.ok(!readRoots.includes(platformRoot) && !readRoots.includes(root) && !readRoots.includes(sandboxSource.HOME), 'Codex sandbox must not grant enclosing npm or credential-home access'); const sandboxArgs = createIsolatedCodexAppServerArgs(sandboxSource, readRoots).join('\\n'); assert.ok(sandboxArgs.includes(JSON.stringify(nativeVendor) + '="read"'), 'Codex sandbox configuration must retain the selected native executable resources'); - const { createAcpxPackageJsonResolver, verifyQualifiedAcpxInstallation } = await import(pathToFileURL(integrityPath).href); + ${nativeCodex ? '' : `const { createAcpxPackageJsonResolver, verifyQualifiedAcpxInstallation } = await import(pathToFileURL(integrityPath).href); const { resolveQualifiedAcpxProfile } = await import(pathToFileURL(profilesPath).href); const serverRoot = resolve(vendor, '../../..'); assert.ok(contained(serverRoot), 'Codex package authority must be the installed server'); const installation = await verifyQualifiedAcpxInstallation(resolveQualifiedAcpxProfile('codex', 'gpt-5.4'), createAcpxPackageJsonResolver(serverRoot, join(serverRoot, 'package.json'))); const lease = await installation.openCommand(); - await lease.close(); - console.log(JSON.stringify({ pinnedCodexCommandVerified: true, pinnedCodexVersion: ${JSON.stringify(expectedVersion)}, - ...provenance, codexSelectedPlatformPath: selectedPlatform.path, codexSelectedPlatformVersion: selectedMetadata.version, codexNativeSandboxResourcesVerified: true, codexQualifiedInstallationVerified: true, codexCommandLeaseVerified: true, providerCalls: 0 })); + await lease.close();`} + console.log(JSON.stringify({ pinnedCodexCommandVerified: true, installedCodexVersion: version, + ...provenance, codexSelectedPlatformPath: selectedPlatform.path, codexSelectedPlatformVersion: selectedMetadata.version, + codexNativeSandboxResourcesVerified: true, + ${nativeCodex ? '' : `pinnedCodexVersion: ${JSON.stringify(expectedVersion)}, codexQualifiedInstallationVerified: true, codexCommandLeaseVerified: true,`} + providerCalls: 0 })); `; } +/** The installed consumer must select its packaged host daemon, never PATH. */ +export function installedDaemonProbeSource(outputRoot, consumerRoot, sourceRevision) { + return ` + import assert from 'node:assert/strict'; + import { createHash } from 'node:crypto'; + import { execFileSync } from 'node:child_process'; + import { readFileSync, realpathSync } from 'node:fs'; + import { isAbsolute, join, relative, sep } from 'node:path'; + import { pathToFileURL } from 'node:url'; + const root = realpathSync(${JSON.stringify(consumerRoot)}), output = realpathSync(${JSON.stringify(outputRoot)}); + const inside = path => { const value = relative(root, realpathSync(path)); return value !== '' && value !== '..' && !value.startsWith('..' + sep) && !isAbsolute(value); }; + assert.ok(inside(output)); + const { defaultCapabilityRunnerdBinary } = await import(pathToFileURL(join(output, 'live/runnerd-codex-transport.js')).href); + const { runnerBinaryTarget } = await import(pathToFileURL(join(output, 'live/runner-binary.js')).href); + const { parsePaperclipRunnerdBuildMetadata } = await import(pathToFileURL(join(output, 'evals/runnerd-artifact.js')).href); + const { PAPERCLIP_RUNNER_BUILD_METADATA: expected } = await import(pathToFileURL(join(output, 'evals/build-metadata.js')).href); + const manifest = JSON.parse(readFileSync(join(output, 'bin/release-manifest.json'), 'utf8')); + const targets = ['darwin-arm64', 'darwin-x64', 'linux-x64']; + assert.equal(manifest.schema, 'paperclip.runner.release-binaries.v1'); + assert.equal(manifest.sourceRevision, ${JSON.stringify(sourceRevision)}); + assert.deepEqual(Object.keys(manifest.platforms).sort(), targets.sort()); + for (const target of targets) { + const record = manifest.platforms[target]; + assert.equal(record.path, target + '/paperclip-runnerd'); + const file = join(output, 'bin', record.path); assert.ok(inside(file)); + const bytes = readFileSync(file); + assert.equal(runnerBinaryTarget(bytes), target); + assert.equal('sha256:' + createHash('sha256').update(bytes).digest('hex'), record.sha256); + } + const target = process.platform + '-' + process.arch, selected = defaultCapabilityRunnerdBinary(); + assert.ok(inside(selected)); + assert.equal(realpathSync(selected), realpathSync(join(output, 'bin', manifest.platforms[target].path)), 'Production must select the packaged host daemon'); + const metadata = parsePaperclipRunnerdBuildMetadata(JSON.parse(execFileSync(selected, ['--build-metadata'], { + timeout: 15000, maxBuffer: 64 * 1024, encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, NODE_PATH: '' }, + }))); + assert.equal(metadata.binaryContractVersion, expected.contracts.runnerdArtifact); + assert.equal(metadata.nativeExecutionVersion, expected.contracts.nativeExecution); + assert.equal(metadata.harnessDriverVersion, expected.contracts.harnessDriver); + assert.equal(metadata.prp.name, expected.prp.name); + assert.ok(metadata.prp.minimumVersion <= expected.prp.maximumVersion && metadata.prp.maximumVersion >= expected.prp.minimumVersion); + console.log(JSON.stringify({ installedRunnerDaemonVerified: true, target, sourceRevision: manifest.sourceRevision, + sha256: manifest.platforms[target].sha256, buildMetadata: metadata, providerCalls: 0 })); + `; +} + +// Hosted, credential-free Mac consumers defer only npm lifecycle execution. +// Offline npm flags are not an OS network boundary; sandbox-exec supplies it. +export function macPublicInstallLifecycleProfile(ownedRoot) { + if (!ownedRoot.startsWith('/') || ownedRoot.includes('\0')) throw new Error('Mac lifecycle requires an absolute owned root'); + return `(version 1)\n(allow default)\n(deny network*)\n(deny file-write* (require-all (require-not (subpath ${JSON.stringify(ownedRoot)})) (require-not (literal "/dev/null"))))\n`; +} + +export function macInstalledStartupProbeSource(consumerRoot, serverRoot, runnerDigest, helperPath) { + if (!/^sha256:[a-f0-9]{64}$/.test(runnerDigest)) throw new Error('Installed Mac startup requires the verified SHA-256 manifest identity'); + return `import { probeInstalledCodexStartup } from ${JSON.stringify(helperPath)}; + console.log(JSON.stringify(await probeInstalledCodexStartup({consumerRoot:${JSON.stringify(consumerRoot)}, + serverRoot:${JSON.stringify(serverRoot)},expectedRunnerSha256:${JSON.stringify(runnerDigest.slice(7))}})));`; +} + export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, gid, download = false, prerequisite, temporarySizeMiB = 256, temporaryExecutable = false }) { 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.sh b/scripts/release.sh index 7238c4b293..a75d4e827a 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -284,6 +284,16 @@ release_info "" release_info "==> Step 2/7: Building workspace artifacts..." cd "$REPO_ROOT" pnpm build +# Hosted producers supply data-only, hash/source/architecture-bound daemon +# receipts. Assemble after the build (which stages its host binary), before +# public packages are packed. The server vendors this same compiled runner. +if [ -n "${PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR:-}" ]; then + node "$REPO_ROOT/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs" \ + assemble-daemons "$PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR" "$(git rev-parse HEAD)" + cp -R "$REPO_ROOT/packages/paperclip-runner/dist/bin/." "$REPO_ROOT/server/dist/vendor/paperclip-runner/bin/" +elif [ "$dry_run" = false ]; then + release_fail "Public releases require the three hosted runner daemon artifacts; use the supported Release workflow." +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 b3c2db21d2..98b0de0429 100644 --- a/scripts/verify-grok-npm-install.mjs +++ b/scripts/verify-grok-npm-install.mjs @@ -4,14 +4,95 @@ // explicit and separate from npm installation, and is removed in finally. import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { createServer } from 'node:net'; 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, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource } from './grok-public-install-sandbox.mjs'; +import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource, installedDaemonProbeSource, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource } from './grok-public-install-sandbox.mjs'; import { retainRunnerQualificationPackages } from './retain-runner-qualification-packages.mjs'; const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +async function verifyMacConsumer(packageDirectory, sourceRevision, receiptPath) { + assert.equal(process.env.GITHUB_ACTIONS, 'true', 'Mac clean consumers run only on disposable hosted CI'); + assert.equal(process.platform, 'darwin'); + assert.ok(['arm64', 'x64'].includes(process.arch)); + assert.match(sourceRevision, /^[a-f0-9]{40}$/); + const packages = realpathSync(packageDirectory); + const manifest = JSON.parse(readFileSync(join(packages, 'manifest.json'), 'utf8')); + assert.equal(manifest.schema, 'paperclip.runner.public-packages/v1'); + assert.equal(manifest.sourceRevision, sourceRevision); + assert.ok(manifest.archives.some(entry => entry.name === '@paperclipai/server')); + assert.ok(manifest.archives.some(entry => entry.name === 'paperclipai')); + assert.equal(new Set(manifest.archives.map(entry => entry.name)).size, manifest.archives.length); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'paperclip-mac-public-install-'))); + try { + const consumer = join(root, 'consumer'), home = join(root, 'home'), tools = join(root, 'tools'); + for (const directory of [consumer, home, tools, join(root, 'tmp'), join(root, 'cache')]) mkdirSync(directory); + const npmCli = realpathSync(execFileSync('/usr/bin/which', ['npm'], { encoding: 'utf8' }).trim()); + symlinkSync(process.execPath, join(tools, 'node')); + // npm subprocesses get a Node-only owned tool directory; no ambient harness + // command can hide missing installed dependencies. + const env = { PATH: `${tools}:/usr/bin:/bin:/usr/sbin:/sbin`, HOME: home, TMPDIR: join(root, 'tmp'), + npm_config_cache: join(root, 'cache'), npm_config_audit: 'false', npm_config_fund: 'false' }; + const run = (command, args, cwd = consumer) => execFileSync(command, args, { cwd, env, + timeout: 10 * 60_000, maxBuffer: 32 * 1024 * 1024, encoding: 'utf8' }); + const tarballs = manifest.archives.map(entry => { + assert.match(entry.file, /^[A-Za-z0-9._-]+\.tgz$/); assert.match(entry.sha256, /^[a-f0-9]{64}$/); + const path = join(packages, entry.file); + assert.equal(realpathSync(path), path); + assert.equal(createHash('sha256').update(readFileSync(path)).digest('hex'), entry.sha256); + assertNoBundledCodexPayloads(run('/usr/bin/tar', ['-tzf', path]).trim().split('\n')); + return path; + }); + writeFileSync(join(consumer, 'package.json'), JSON.stringify({ private: true, type: 'module' })); + const sentinelSource = join(root, 'lifecycle-sentinel'); mkdirSync(sentinelSource); + writeFileSync(join(sentinelSource, 'package.json'), JSON.stringify({ name: 'paperclip-verification-lifecycle-sentinel', version: '1.0.0', + scripts: { postinstall: 'node -e "require(\'node:fs\').writeFileSync(\'lifecycle-ran\', \'ok\')"' } })); + run(process.execPath, [npmCli, 'pack', '--ignore-scripts', '--pack-destination', root], sentinelSource); + const sentinelTar = join(root, readdirSync(root).find(name => name.endsWith('.tgz'))); + run(process.execPath, [npmCli, 'install', '--ignore-scripts', '--omit=dev', ...tarballs, sentinelTar]); + const sentinel = join(consumer, 'node_modules/paperclip-verification-lifecycle-sentinel/lifecycle-ran'); + assert.equal(existsSync(sentinel), false); + const lock = readFileSync(join(consumer, 'package-lock.json'), 'utf8'); + const profile = join(root, 'lifecycle.sb'); writeFileSync(profile, macPublicInstallLifecycleProfile(root)); + const listener = createServer(socket => socket.end()); + await new Promise((resolve, reject) => { listener.once('error', reject); listener.listen(0, '127.0.0.1', resolve); }); + try { + const port = listener.address().port; + const networkProbe = `import assert from 'node:assert/strict'; import net from 'node:net'; + const socket = net.connect({host:'127.0.0.1',port:${port}}); socket.setTimeout(3000,()=>process.exit(2)); + socket.once('connect',()=>{socket.destroy(); ${"process.argv[1] === 'denied' ? process.exit(3) : process.exit(0)"};}); + socket.once('error',error=>{assert.equal(process.argv[1],'denied'); assert.equal(error.code,'EPERM');});`; + run(process.execPath, ['--input-type=module', '-e', networkProbe, 'allowed']); + run('/usr/bin/sandbox-exec', ['-f', profile, process.execPath, '--input-type=module', '-e', networkProbe, 'denied']); + } finally { await new Promise(resolve => listener.close(resolve)); } + run('/usr/bin/sandbox-exec', ['-f', profile, process.execPath, npmCli, 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts', '--foreground-scripts']); + assert.equal(readFileSync(sentinel, 'utf8'), 'ok'); + assert.equal(readFileSync(join(consumer, 'package-lock.json'), 'utf8'), lock); + for (const entry of manifest.archives) { + assert.equal(JSON.parse(readFileSync(join(consumer, 'node_modules', entry.name, 'package.json'))).version, manifest.releaseVersion); + } + const server = join(consumer, 'node_modules/@paperclipai/server'), installed = join(server, 'dist/vendor/paperclip-runner'); + const probe = (name, source) => { const file = join(root, name); writeFileSync(file, source); return JSON.parse(run(process.execPath, [file]).trim()); }; + const daemon = probe('daemon-probe.mjs', installedDaemonProbeSource(installed, consumer, sourceRevision)); + const codex = probe('codex-probe.mjs', installedCodexProbeSource(join(installed, 'index.js'), consumer, undefined, { nativeCodex: true })); + const startupScript = macInstalledStartupProbeSource(consumer, server, daemon.sha256, join(repo, 'tests/runner-e2e/installed-cli.ts')); + const startupFile = join(root, 'startup-probe.mts'); writeFileSync(startupFile, startupScript); + const startup = JSON.parse(run(process.execPath, ['--import', 'tsx', startupFile], join(repo, 'server')).trim()); + const receipt = { schema: 'paperclip.runner.mac-public-install.v1', sourceRevision, platform: process.platform, architecture: process.arch, + cleanNpmInstall: true, packageCount: manifest.archives.length, codexPackedPayloadsOmitted: true, + consumerLockPreserved: true, lifecycleSentinelVerified: true, lifecycleNetwork: 'sandbox-exec denied (loopback negative control)', + ...daemon, ...codex, startup, providerCalls: 0 }; + writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + '\n'); + console.log(JSON.stringify(receipt)); + } finally { rmSync(root, { recursive: true, force: true }); } +} +if (process.argv[2] === '--mac-consumer') { + assert.equal(process.argv.length, 6, 'Usage: --mac-consumer PACKAGE_DIRECTORY SOURCE_SHA RECEIPT_PATH'); + await verifyMacConsumer(process.argv[3], process.argv[4], process.argv[5]); +} else { 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-')); const prerequisite = join(root, 'native/grok'); @@ -64,6 +145,24 @@ try { assertNoBundledCodexPayloads(run('tar', ['-tzf', tarball]).toString().trim().split('\n')); tarballs.push(tarball); } + // Preserve the exact Linux-produced public tarballs for independent Mac + // consumers even if a later Linux runtime probe fails. This is a packaging + // receipt, never an installation or provider-admission pass. + if (process.env.PAPERCLIP_RUNNER_PUBLIC_PACKS_DIR) { + const output = resolve(process.env.PAPERCLIP_RUNNER_PUBLIC_PACKS_DIR); + assert.equal(JSON.parse(readFileSync(join(repo, 'server/dist/build-info.json'))).commit, sourceRevision); + assert.ok(!existsSync(output), 'Never replace retained public packages'); + mkdirSync(output, { recursive: true }); + const { createHash } = await import('node:crypto'); + const archives = [...needed].map((name, index) => { + const file = basename(tarballs[index]); cpSync(tarballs[index], join(output, file)); + return { name, file, sha256: createHash('sha256').update(readFileSync(tarballs[index])).digest('hex') }; + }); + writeFileSync(join(output, 'manifest.json'), JSON.stringify({ + schema: 'paperclip.runner.public-packages/v1', sourceRevision, releaseVersion, + publicInstallationVerified: false, providerCalls: 0, archives, + }, null, 2) + '\n'); + } const assets = join(root, 'assets'); mkdirSync(assets, { mode: 0o755 }); const consumer = join(root, 'consumer'); mkdirSync(consumer); const cache = join(root, 'cache'); mkdirSync(cache); @@ -153,3 +252,4 @@ try { } finally { rmSync(root, { recursive: true, force: true }); } +} diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index ba6ba31ac2..4585683fe7 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -168,7 +168,10 @@ describe("adapter routes", () => { it.each([ [{ driver: "ssh", platform: "linux", architecture: "x64" }, "paperclip"], - [{ driver: "ssh", platform: "darwin", architecture: "arm64" }, "legacy"], + [{ driver: "ssh", platform: "darwin", architecture: "arm64" }, "paperclip"], + [{ driver: "ssh", platform: "darwin", architecture: "x64" }, "paperclip"], + [{ driver: "ssh", platform: "linux", architecture: "arm64" }, "legacy"], + [{ driver: "local", platform: "win32", architecture: "x64" }, "legacy"], ])("projects the selected environment once for the whole adapter inventory (%s)", async (target, defaultRunner) => { const resolveRunnerTarget = vi.fn(async () => target); const response = await request(createApp({}, { resolveRunnerTarget })).get("/api/adapters?companyId=company-1&environmentId=selected"); diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 324463f70a..1cfbbe3e93 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -1260,7 +1260,9 @@ describe("agent skill routes", () => { const codexRunnerCases = [ { adapterType: "codex_local", runner: "auto", platform: "linux", expectedType: "paperclip_runner" }, { adapterType: "codex_local", runner: "legacy", platform: "linux", expectedType: "codex_local" }, - { adapterType: "codex_local", runner: "auto", platform: "darwin", expectedType: "codex_local" }, + { adapterType: "codex_local", runner: "auto", platform: "darwin", expectedType: "paperclip_runner" }, + { adapterType: "codex_local", runner: "legacy", platform: "darwin", expectedType: "codex_local" }, + { adapterType: "codex_local", runner: "auto", platform: "win32", expectedType: "codex_local" }, ]; it.each([...codexRunnerCases, { adapterType: "claude_local", runner: "auto", platform: "linux", expectedType: "claude_local" }].flatMap((runnerCase) => [ ["agents", "paperclipai/paperclip/paperclip-create-agent"], diff --git a/server/src/__tests__/agents-pending-approval-config.test.ts b/server/src/__tests__/agents-pending-approval-config.test.ts index 6c71ec8e71..7096a63442 100644 --- a/server/src/__tests__/agents-pending-approval-config.test.ts +++ b/server/src/__tests__/agents-pending-approval-config.test.ts @@ -17,6 +17,7 @@ import { } from "./helpers/embedded-postgres.js"; import { agentService } from "../services/agents.ts"; import { approvalService } from "../services/approvals.ts"; +import { paperclipRunnerSupportsPlatform } from "@paperclipai/shared"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -71,9 +72,7 @@ describeEmbeddedPostgres("pending approval agent config integrity", () => { const native = await lifecycle.requestHire(companyId, { name: "New Codex", adapterType: "codex_local", adapterConfig: { modelReasoningEffort: "high" }, status: "pending_approval" }); const legacy = await lifecycle.requestHire(companyId, { name: "Legacy Codex", adapterType: "codex_local", runner: "legacy", adapterConfig: { extraArgs: ["--search"] }, status: "pending_approval" }); const reviewedLegacy = await lifecycle.requestHire(companyId, { name: "Historical approved hire", adapterType: "codex_local", adapterConfig: {}, status: "pending_approval" }, { runnerResolved: true }); - // Only public Linux x64 artifacts qualify an automatic native default; - // source-built macOS/Windows agents keep legacy until their packaging is qualified. - if (process.platform === "linux" && process.arch === "x64") { + if (paperclipRunnerSupportsPlatform("codex_local", process.platform, process.arch)) { expect(native).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", modelReasoningEffort: "high" } }); } else { expect(native).toMatchObject({ adapterType: "codex_local", adapterConfig: { modelReasoningEffort: "high" } }); diff --git a/server/src/__tests__/built-in-agents.test.ts b/server/src/__tests__/built-in-agents.test.ts index fe84bf30ff..20f411b6f3 100644 --- a/server/src/__tests__/built-in-agents.test.ts +++ b/server/src/__tests__/built-in-agents.test.ts @@ -267,7 +267,9 @@ describeEmbeddedPostgres("built-in agents", () => { ])).toThrow("defaultAdapterType must be allowed"); }); - it("lazily provisions one agent per company/key and updates the same row on setup", async () => { + it.each([{ platform: "linux", architecture: "x64" }, { platform: "darwin", architecture: "arm64" }, { platform: "darwin", architecture: "x64" }])("lazily provisions one agent per company/key and updates the same row on setup ($platform/$architecture)", async ({ platform, architecture }) => { + runnerTarget.platform = platform; + runnerTarget.architecture = architecture; const companyId = await seedCompany(); const svc = builtInAgentService(db); @@ -327,9 +329,9 @@ describeEmbeddedPostgres("built-in agents", () => { expect(switchedBack.agent).toMatchObject({ adapterType: "codex_local", adapterConfig: savedSettings }); const switchedAgain = await svc.ensure(companyId, "briefs", { runner: "paperclip" }); expect(switchedAgain.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", ...savedSettings } }); - // A saved native profile remains editable on source-built macOS installs. - runnerTarget.platform = "darwin"; - runnerTarget.architecture = "arm64"; + // Existing native execution survives editing on an unqualified target. + runnerTarget.platform = "win32"; + runnerTarget.architecture = "x64"; const edited = await svc.ensure(companyId, "briefs", { adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } }); expect(edited.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.4" } }); }); @@ -339,8 +341,8 @@ describeEmbeddedPostgres("built-in agents", () => { const result = await builtInAgentService(db).provision(companyId, "briefs", { adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } }, { requestedByUserId: "board-user" }); expect(result.approval?.payload).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.4" } }); // Restart and approval activate the reviewed config even if eligibility changed. - runnerTarget.platform = "darwin"; - runnerTarget.architecture = "arm64"; + runnerTarget.platform = "win32"; + runnerTarget.architecture = "x64"; await reconcileBuiltInAgentsOnStartup(db); await approvalService(db).approve(result.approval!.id, "board-user"); const approved = await builtInAgentService(db).get(companyId, "briefs"); diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 8b2d69b77c..2884ae55ab 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -6067,6 +6067,7 @@ describe("company portability", () => { it.each([ { runner: "legacy", platform: "linux" }, + { runner: "legacy", platform: "darwin" }, { runner: "paperclip", platform: "linux" }, { runner: "paperclip", platform: "darwin" }, ] as const)("exports the resolved Codex runner and preserves it on import ($runner/$platform)", async ({ runner, platform }) => { @@ -6099,7 +6100,9 @@ describe("company portability", () => { it.each([ { action: "create", platform: "linux", expectedType: "paperclip_runner" }, { action: "update", platform: "linux", expectedType: "codex_local" }, - { action: "create", platform: "darwin", expectedType: "codex_local" }, + { action: "create", platform: "darwin", expectedType: "paperclip_runner" }, + { action: "update", platform: "darwin", expectedType: "codex_local" }, + { action: "create", platform: "win32", expectedType: "codex_local" }, ])("applies qualified Codex defaults only to new imports without a runner ($platform/$action)", async ({ action, platform, expectedType }) => { runnerTarget.platform = platform; runnerTarget.architecture = platform === "darwin" ? "arm64" : "x64"; @@ -6118,6 +6121,7 @@ describe("company portability", () => { it.each([ { existingRunner: "legacy", existingHarness: "codex_local", expectedType: "codex_local", platform: "linux" }, + { existingRunner: "legacy", existingHarness: "codex_local", expectedType: "codex_local", platform: "darwin" }, { existingRunner: "paperclip", existingHarness: "codex_local", expectedType: "paperclip_runner", platform: "linux" }, { existingRunner: "paperclip", existingHarness: "codex_local", expectedType: "paperclip_runner", platform: "darwin" }, { existingRunner: "legacy", existingHarness: "claude_local", expectedType: "paperclip_runner", platform: "linux" }, diff --git a/server/src/__tests__/dev-runner-native-binary.test.ts b/server/src/__tests__/dev-runner-native-binary.test.ts index 705ea8cf76..b0f41862b7 100644 --- a/server/src/__tests__/dev-runner-native-binary.test.ts +++ b/server/src/__tests__/dev-runner-native-binary.test.ts @@ -110,6 +110,24 @@ describe("paperclip runner native dev prerequisite", () => { ).toBe(false); }); + it.each(["arm64", "x64"])("uses the staged Mac %s target before the flat build output", architecture => { + const checkout = createRunnerCheckout(); + const target = path.join(path.dirname(checkout.binary), `darwin-${architecture}`, "paperclip-runnerd"); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, "target-runnerd"); + const old = new Date(Date.now() - 2000), current = new Date(Date.now() + 2000); + fs.utimesSync(checkout.binary, old, old); + fs.utimesSync(target, current, current); + const input = { repoRoot: checkout.root, nativeRunnerRequired: true, platform: "darwin", architecture }; + expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(false); + fs.rmSync(checkout.binary); + expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(false); + fs.utimesSync(target, old, old); + expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(true); + fs.rmSync(target); + expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(true); + }); + it("does not build a workspace binary when an explicit binary is configured", () => { expect( paperclipRunnerBinaryNeedsBuild({ diff --git a/server/src/__tests__/plugin-managed-agents.test.ts b/server/src/__tests__/plugin-managed-agents.test.ts index dfb0228f44..c306e28afe 100644 --- a/server/src/__tests__/plugin-managed-agents.test.ts +++ b/server/src/__tests__/plugin-managed-agents.test.ts @@ -284,7 +284,9 @@ describeEmbeddedPostgres("plugin-managed agents", () => { }); }); - it("creates managed agents with the most-used compatible company adapter", async () => { + it.each([{ platform: "linux", architecture: "x64" }, { platform: "darwin", architecture: "arm64" }, { platform: "darwin", architecture: "x64" }])("creates managed agents with the most-used compatible company adapter ($platform/$architecture)", async ({ platform, architecture }) => { + runnerTarget.platform = platform; + runnerTarget.architecture = architecture; const pluginManifest = manifest(); pluginManifest.agents![0] = { ...pluginManifest.agents![0]!, @@ -336,8 +338,8 @@ describeEmbeddedPostgres("plugin-managed agents", () => { const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" }); expect(reconciled.agent?.adapterConfig).toEqual(created.agent?.adapterConfig); // Reset declaration defaults without requalifying an existing native agent. - runnerTarget.platform = "darwin"; - runnerTarget.architecture = "arm64"; + runnerTarget.platform = "win32"; + runnerTarget.architecture = "x64"; const reset = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" }); expect(reset.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } }); }); diff --git a/server/src/services/agent-runner-selection.test.ts b/server/src/services/agent-runner-selection.test.ts index bd564e7355..9d972ecebc 100644 --- a/server/src/services/agent-runner-selection.test.ts +++ b/server/src/services/agent-runner-selection.test.ts @@ -9,10 +9,11 @@ vi.mock("./environment-config.js", () => ({ resolveEnvironmentDriverConfigForRun vi.mock("@paperclipai/adapter-utils/ssh", () => ({ runSshCommand: state.ssh })); import { agentRunnerAvailability, resolveAgentRunnerTargetForCompany, resolveNewAgentRunner, resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js"; const target = { driver: "local", platform: "linux", architecture: "x64" }; +const qualifiedTargets = [target, { driver: "local", platform: "darwin", architecture: "arm64" }, { driver: "local", platform: "darwin", architecture: "x64" }]; describe("server-owned Codex runner selection", () => { beforeEach(() => { state.disabled = []; state.overridden.clear(); state.settings = {}; state.getEnvironment.mockReset(); state.bindings.mockReset().mockResolvedValue([]); state.managedEnvironment.mockReset(); state.ssh.mockReset().mockResolvedValue({ stdout: "Linux\nx86_64\n" }); state.resolveEnvironment.mockReset().mockResolvedValue({ driver: "ssh", config: {} }); }); - it("uses native Codex automatically with compatible configuration and is idempotent", () => { + it.each(qualifiedTargets)("uses native Codex automatically with compatible configuration and is idempotent on $platform/$architecture", target => { const input = { adapterType: "codex_local", target, adapterConfig: { model: "gpt-5.6-sol", modelReasoningEffort: "high", env: { OPENAI_API_KEY: { type: "secret_ref", secretId: "secret" } }, cwd: "/workspace", instructionsFilePath: "/workspace/AGENTS.md", workspaceStrategy: { type: "git_worktree" }, timeoutSec: 60, search: false, fastMode: false, dangerouslyBypassApprovalsAndSandbox: true } }; const resolved = resolveNewAgentRunner(input); expect(resolved).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", codexPermissionMode: "never", modelReasoningEffort: "high", env: input.adapterConfig.env, workspaceStrategy: input.adapterConfig.workspaceStrategy, timeoutSec: 60 } }); @@ -23,7 +24,7 @@ describe("server-owned Codex runner selection", () => { it.each(["claude_local", "opencode_local", "grok_local", "cursor", "pi_local", "process", "external"])("keeps %s default unchanged", adapterType => { expect(resolveNewAgentRunner({ adapterType, target, adapterConfig: { custom: "preserved" } })).toEqual({ adapterType, adapterConfig: { custom: "preserved" } }); }); - it("allows an explicit legacy runner and native to legacy round trip", () => { + it.each(qualifiedTargets)("allows an explicit legacy runner and native to legacy round trip on $platform/$architecture", target => { expect(resolveNewAgentRunner({ adapterType: "codex_local", target, runner: "legacy", adapterConfig: { extraArgs: ["--search"] } }).adapterType).toBe("codex_local"); expect(resolveNewAgentRunner({ adapterType: "paperclip_runner", runner: "legacy", adapterConfig: { provider: "codex", model: "gpt-5.6-sol", lifecycleMode: "per_turn", codexPermissionMode: "never", modelReasoningEffort: "high" } })).toEqual({ adapterType: "codex_local", adapterConfig: { model: "gpt-5.6-sol", modelReasoningEffort: "high", dangerouslyBypassApprovalsAndSandbox: true } }); }); @@ -33,23 +34,26 @@ describe("server-owned Codex runner selection", () => { expect(resolveNewAgentRunner({ adapterType: "paperclip_runner", runner: "legacy", adapterConfig: { provider: "codex", codexPermissionMode: "never", lifecycleMode: "per_turn", dangerouslyBypassApprovalsAndSandbox: false } })) .toEqual({ adapterType: "codex_local", adapterConfig: { dangerouslyBypassApprovalsAndSandbox: false } }); }); - it.each(["darwin", "win32"])("keeps unqualified %s targets legacy while preserving explicit native profiles", platform => { - const input = { adapterType: "codex_local", target: { driver: "local", platform, architecture: "x64" } }; + it.each([{ platform: "win32", architecture: "x64" }, { platform: "linux", architecture: "arm64" }, { platform: "darwin", architecture: "ia32" }])("keeps unqualified $platform/$architecture targets legacy while preserving explicit native profiles", ({ platform, architecture }) => { + const input = { adapterType: "codex_local", target: { driver: "local", platform, architecture } }; expect(resolveNewAgentRunner(input).adapterType).toBe("codex_local"); expect(() => resolveNewAgentRunner({ ...input, runner: "paperclip" })).toThrow("unavailable"); const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, target: input.target }; expect(resolveNewAgentRunner(saved)).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig }); expect(resolveNewAgentRunner({ ...saved, runner: "paperclip" })).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig }); }); - it("automatically keeps unsupported targets and active external overrides legacy", () => { + it.each(qualifiedTargets)("automatically keeps unsupported targets and active external overrides legacy on $platform/$architecture", target => { const input = { adapterType: "codex_local", target: { driver: "local", platform: "win32", architecture: "x64" } }; expect(resolveNewAgentRunner(input).adapterType).toBe("codex_local"); expect(() => resolveNewAgentRunner({ ...input, runner: "paperclip" })).toThrow("unavailable"); state.overridden.add("codex_local"); expect(agentRunnerAvailability("codex_local", target).defaultRunner).toBe("legacy"); expect(resolveNewAgentRunner({ adapterType: "codex_local", target }).adapterType).toBe("codex_local"); + state.overridden.clear(); + state.overridden.add("paperclip_runner"); + expect(resolveNewAgentRunner({ adapterType: "codex_local", target }).adapterType).toBe("codex_local"); }); - it("does not select a disabled runner", () => { + it.each(qualifiedTargets)("does not select a disabled runner on $platform/$architecture", target => { state.disabled = ["paperclip_runner"]; expect(resolveNewAgentRunner({ adapterType: "codex_local", target }).adapterType).toBe("codex_local"); expect(() => resolveNewAgentRunner({ adapterType: "codex_local", target, runner: "paperclip" })).toThrow("unavailable"); @@ -80,7 +84,9 @@ describe("server-owned Codex runner selection", () => { }); it.each([ ["Linux\nx86_64\n", "paperclip", "paperclip_runner"], - ["Darwin\narm64\n", "legacy", "codex_local"], + ["Darwin\narm64\n", "paperclip", "paperclip_runner"], + ["Darwin\nx86_64\n", "paperclip", "paperclip_runner"], + ["Linux\naarch64\n", "legacy", "codex_local"], ])("uses the same selected SSH target for discovery and saving (%s)", async (stdout, expectedRunner, expectedAdapter) => { state.getEnvironment.mockResolvedValue({ id: "selected", driver: "ssh", status: "active", config: {} }); state.ssh.mockResolvedValue({ stdout }); @@ -113,9 +119,10 @@ describe("Codex creation runner contract", () => { expect(agentRunner("codex_local")).toBe("legacy"); expect(agentRunner("paperclip_runner")).toBe("paperclip"); }); - it("qualifies only the daemon actually shipped in public server packages", () => { - expect(paperclipRunnerSupportsPlatform("codex_local", "linux", "x64")).toBe(true); - for (const [platform, architecture] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "arm64"], ["win32", "x64"], ["freebsd", "x64"]]) expect(paperclipRunnerSupportsPlatform("codex_local", platform, architecture)).toBe(false); + it("qualifies only the Codex daemon targets shipped in public server packages", () => { + for (const { platform, architecture } of qualifiedTargets) expect(paperclipRunnerSupportsPlatform("codex_local", platform, architecture)).toBe(true); + for (const [platform, architecture] of [["darwin", "ia32"], ["linux", "arm64"], ["win32", "x64"], ["win32", "arm64"], ["freebsd", "x64"]]) expect(paperclipRunnerSupportsPlatform("codex_local", platform, architecture)).toBe(false); + expect(paperclipRunnerSupportsPlatform("claude_local", "darwin", "arm64")).toBe(false); }); it("keeps auto out of saved update defaults and validates request intent", () => { expect(createAgentSchema.parse({ name: "Codex", adapterType: "codex_local" }).runner).toBeUndefined(); diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index b6bf07ccb6..fd59b0f4fe 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -2001,12 +2001,31 @@ Run the explicit `paperclipai runtime setup pi` for that installation first. This lane rejects candidate flags, local/remote daemon overrides, provider asset or pack overrides, and Node injection. It launches the installed JavaScript CLI without a TypeScript loader, rechecks its pins on controller restart, and records -`installed-cli-admission.json` in private attempt evidence. It supports only the -explicit Pi extended, native, controls and warm suites, which require none of the +`installed-cli-admission.json` in private attempt evidence. Pi uses the explicit +extended, native, controls and warm suites, which require none of the source-only response barriers. Cursor/Copilot qualification gates stay intact. Pi's historical `qualificationCandidate` fixture selector remains a roster key; it no longer grants an opt-in when the source Pi declaration admits normal use. +The same four installed-package pins also admit three existing local native +Codex cells: `first-task.runner-codex.local.task-card-accept`, +`everyday-workflows.runner-codex.local.build-revise`, and +`agent-chat.runner-codex.local.continuity-restart`. Select exact IDs; other +Codex cells, legacy profiles and remote environments remain outside this lane. +These reuse the onboarding, independently checked file/revision, and restart +graders. They require separately authorized provider access and are not executed +by the provider-free release smoke. + +The release consumer calls `probeInstalledCodexStartup` from `installed-cli.ts` +against its installed server, normal platform daemon and contained npm Codex. +It checks a real native read-only session and joined cleanup using empty homes, +file-only authentication storage and a closed environment. It sends no provider +turn, consults no account, records the actual CLI version without an equality +gate, and exposes no session IDs. Build/source, Mach-O/ELF and official npm +provenance checks precede this probe. Startup proof does not establish useful-file +delivery, authentication, reasoning effort or follow-up behavior; the existing +live journeys must prove those separately. + Installed Pi Daytona cells also require the published plugin fixture inputs `PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN` (canonical package root), `PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN_AUTHORITY` (canonical JSON file), diff --git a/tests/runner-e2e/installed-cli.test.ts b/tests/runner-e2e/installed-cli.test.ts index 4020bafe8c..868898c6f7 100644 --- a/tests/runner-e2e/installed-cli.test.ts +++ b/tests/runner-e2e/installed-cli.test.ts @@ -1,11 +1,11 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { chmod, mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, expect, it } from "vitest"; import { runnerMatrix } from "./catalog.js"; -import { assertInstalledCliSelection, installedCliKeys, verifyInstalledCli } from "./installed-cli.js"; +import { assertInstalledCliSelection, installedCliKeys, probeInstalledCodexStartup, verifyInstalledCli } from "./installed-cli.js"; import { buildPaperclipServerEnvironment, buildRunnerE2EProcessEnvironment } from "./harness-env.js"; const cells = runnerMatrix.filter(e => e.profile.id === "runner-acpx-pi"); const roots: string[] = []; @@ -25,6 +25,20 @@ it("admits all26 current Pi cases through pinned installed public CLI/server wit const { env, server } = await fixture(); expect(cells).toHaveLength(26); for (const cell of cells) expect(await verifyInstalledCli(env, [cell])).toMatchObject({ serverRoot: server, defaultRuntimeResolution: true, qualificationOverride: false }); }); +it("admits existing Codex onboarding, useful-file revision and restart journeys without broadening other cells", async () => { + const { env, server } = await fixture(); + for (const id of ["first-task.runner-codex.local.task-card-accept", "everyday-workflows.runner-codex.local.build-revise", "agent-chat.runner-codex.local.continuity-restart"]) { + const cell = runnerMatrix.find(e => e.id === id)!; + expect(cell).toBeDefined(); + expect(await verifyInstalledCli(env, [cell])).toMatchObject({ serverRoot: server, defaultRuntimeResolution: true, qualificationOverride: false }); + for (const key of ["PAPERCLIP_RUNNER_BINARY", "NODE_PATH"]) expect(() => assertInstalledCliSelection({ ...env, [key]: "foreign" }, [cell])).toThrow("forbids"); + } + for (const id of ["first-task.legacy-codex.local.task-card-accept", "first-task.runner-codex.local.interview-first-response", "everyday-workflows.runner-codex.daytona.build-revise"]) { + const cell = runnerMatrix.find(e => e.id === id)!; + expect(cell).toBeDefined(); + expect(() => assertInstalledCliSelection(env, [cell])).toThrow("explicit supported Pi or Codex"); + } +}); it("qualified providers drop ambient admission while pending Copilot retains its exact gate", () => { for (const cell of cells) expect(buildRunnerE2EProcessEnvironment({ PAPERCLIP_RUNNER_ACPX_QUALIFICATION: "ambient" }, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeUndefined(); for (const agent of ["copilot"]) { @@ -92,3 +106,60 @@ it("startup-only accepts only explicit uncredentialed local hello and zero retri expect(()=>assertInstalledStartupOnly({...f.env,KIMI_MODEL_API_KEY:"dummy-not-a-real-key"},[cell],options)).toThrow("credential inputs"); expect(()=>assertInstalledStartupOnly({},[cell],options)).toThrow("startup-only"); }); + +async function startupFixture(mode: "normal" | "wrong-model" | "ambient-codex" = "normal") { + const f = await fixture(); + const vendor = join(f.server, "dist/vendor/paperclip-runner"); + const daemon = join(vendor, "bin", `${process.platform}-${process.arch}`, "paperclip-runnerd"); + const codex = join(f.root, "node_modules/@openai/codex/bin/codex"); + const observations = join(f.root, "startup-observations.json"); + await mkdir(join(vendor, "bin", `${process.platform}-${process.arch}`), { recursive: true }); + await mkdir(join(f.server, "dist/services/native-runtime"), { recursive: true }); + await mkdir(join(f.root, "node_modules/@openai/codex/bin"), { recursive: true }); + await writeFile(daemon, "fixture-daemon"); + await writeFile(codex, "#!/bin/sh\nprintf 'codex-cli 0.149.5\\n'\n"); + await chmod(codex, 0o755); + await writeFile(join(f.server, "dist/services/native-runtime/native-codex-runner.js"), `export function resolvePaperclipRunnerBinary() { return ${JSON.stringify(daemon)}; }`); + await writeFile(join(vendor, "index.js"), ` + import {writeFileSync} from "node:fs"; + const state={calls:[],environmentKeys:[],fileAuthPinned:false};let retired=false; + const save=()=>writeFileSync(${JSON.stringify(observations)},JSON.stringify(state)); + export function resolveCodexCommand(){return ${JSON.stringify(mode === "ambient-codex" ? process.execPath : codex)};} + export function createIsolatedCodexAppServerArgs(){return ["app-server"];} + export function createRunnerdCodexTransport(options){ + state.environmentKeys=Object.keys(options.environment).sort(); + state.fileAuthPinned=options.codexArgs.includes('cli_auth_credentials_store="file"'); + save();return {transport:{async close(){retired=true;state.calls.push("transport-close");save();}}, + evidence:()=>({runnerPid:111,codexPid:222,providerDriver:"codex_app_server",runnerExited:retired})};} + export class CodexAppServerDriver { + constructor(options){this.options=options;} + async openSession(){state.calls.push("open");save();const model=${JSON.stringify(mode === "wrong-model" ? "wrong-model" : "gpt-5.6-sol")}; + return {ids:()=>({driverSessionId:"PRIVATE-DRIVER-ID",providerSessionId:"PRIVATE-PROVIDER-ID"}), + events:()=>({async *[Symbol.asyncIterator](){yield {eventType:"session.started",payload:{context:{model,sandbox:{permissionProfile:{id:"paperclip-runner-workspace-read-only"}}}}};}}), + startTurn(){state.calls.push("FORBIDDEN-TURN");save();throw new Error("No provider turn is permitted");}, + async close(){retired=true;state.calls.push("session-close");save();}};} + } + `); + return { ...f, observations, expectedRunnerSha256: hash("fixture-daemon") }; +} + +it.skipIf(process.platform === "win32")("installed native startup uses closed homes, file-only auth and no turn or version equality gate", async () => { + const f = await startupFixture(); + const receipt = await probeInstalledCodexStartup({ consumerRoot: f.root, serverRoot: f.server, expectedRunnerSha256: f.expectedRunnerSha256 }); + expect(receipt).toMatchObject({ nativeSessionStarted: true, cleanupConfirmed: true, providerTurns: 0, credentialsUsed: false, + codexVersion: "codex-cli 0.149.5", defaultRuntimeResolution: true, reasoningEffortExercised: false }); + expect(await readFile(f.observations, "utf8").then(JSON.parse)).toMatchObject({ calls: ["open", "session-close"], fileAuthPinned: true, + environmentKeys: ["CODEX_HOME", "HOME", "LC_ALL", "PAPERCLIP_RUNNER_NETWORK_ACCESS", "PATH", "TMPDIR", "TZ"] }); + expect(JSON.stringify(receipt)).not.toMatch(/PRIVATE-(?:DRIVER|PROVIDER)-ID/); +}); + +it.skipIf(process.platform === "win32")("installed startup rejects stale daemon/ambient Codex and cleans a mismatched native session", async () => { + const f = await startupFixture(); + await expect(probeInstalledCodexStartup({ consumerRoot: f.root, serverRoot: f.server, expectedRunnerSha256: "0".repeat(64) })).rejects.toThrow("reviewed pin"); + await expect(readFile(f.observations)).rejects.toThrow(); + const ambient = await startupFixture("ambient-codex"); + await expect(probeInstalledCodexStartup({ consumerRoot: ambient.root, serverRoot: ambient.server, expectedRunnerSha256: ambient.expectedRunnerSha256 })).rejects.toThrow("ambient Codex"); + const wrong = await startupFixture("wrong-model"); + await expect(probeInstalledCodexStartup({ consumerRoot: wrong.root, serverRoot: wrong.server, expectedRunnerSha256: wrong.expectedRunnerSha256 })).rejects.toThrow("matching native identity"); + expect(await readFile(wrong.observations, "utf8").then(JSON.parse)).toMatchObject({ calls: ["open", "session-close"] }); +}); diff --git a/tests/runner-e2e/installed-cli.ts b/tests/runner-e2e/installed-cli.ts index 97dc50a49f..7f764af687 100644 --- a/tests/runner-e2e/installed-cli.ts +++ b/tests/runner-e2e/installed-cli.ts @@ -1,21 +1,33 @@ /** Optional published-install lane. No production hook or admission override. */ import { constants } from "node:fs"; -import { lstat, open, realpath } from "node:fs/promises"; +import { lstat, mkdir, mkdtemp, open, realpath, rm } from "node:fs/promises"; import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; import { findPackageJSON } from "node:module"; -import { dirname, isAbsolute, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { tmpdir } from "node:os"; import { pathToFileURL } from "node:url"; import { CREDENTIAL_NAMES, type MatrixExecution } from "./types.js"; export const installedCliKeys = ["PAPERCLIP_RUNNER_E2E_INSTALLED_CLI", "PAPERCLIP_RUNNER_E2E_INSTALLED_CLI_SHA256", "PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_ROOT", "PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_SHA256"] as const; const overrideKeys = ["PAPERCLIP_RUNNER_BINARY", "PAPERCLIP_RUNNER_REMOTE_BINARY_PATH", "PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH", "PAPERCLIP_RUNNER_ACPX_QUALIFICATION", "PAPERCLIP_ACPX_BUILTIN_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST"] as const; const suites = new Set(["extended-harnesses", "pi-native", "pi-controls", "rich-acp-warm-continuity"]); +// Reuse the production-onboarding, independent file/revision, and restart +// journeys. Installed proof does not add cases or relax their existing graders. +const installedCodexCells = new Set([ + "first-task.runner-codex.local.task-card-accept", + "everyday-workflows.runner-codex.local.build-revise", + "agent-chat.runner-codex.local.continuity-restart", +]); export function usesInstalledCli(env: NodeJS.ProcessEnv): boolean { return installedCliKeys.some(key => env[key] !== undefined); } export function assertInstalledCliSelection(env: NodeJS.ProcessEnv, executions: readonly MatrixExecution[]): void { if (!usesInstalledCli(env)) return; - if (!installedCliKeys.every(key => Boolean(env[key])) || executions.length === 0 || executions.some(e => e.profile.id !== "runner-acpx-pi" || e.profile.qualificationCandidate !== "pi" || !e.suite.manualOnly || !suites.has(e.suite.id))) throw new Error("Installed CLI proof requires complete pins and explicit supported Pi cases"); + if (!installedCliKeys.every(key => Boolean(env[key])) || executions.length === 0 || executions.some(e => !( + (e.profile.id === "runner-acpx-pi" && e.profile.qualificationCandidate === "pi" && e.suite.manualOnly && suites.has(e.suite.id)) || + (e.profile.id === "runner-codex" && e.profile.provider === "codex" && e.environment.id === "local" && installedCodexCells.has(e.id)) + ))) throw new Error("Installed CLI proof requires complete pins and explicit supported Pi or Codex cases"); for (const key of overrideKeys) if (env[key] !== undefined) throw new Error(`Installed CLI proof forbids ${key}`); if (env.NODE_OPTIONS !== undefined || env.NODE_PATH !== undefined) throw new Error("Installed CLI proof forbids ambient Node injection"); } @@ -51,6 +63,89 @@ export async function verifyInstalledCli(env: NodeJS.ProcessEnv, executions: rea return { schema: "paperclip.e2e.installed-cli-admission/v1", entry, cliRoot, cliSha256, serverRoot, serverEntry, serverSha256, version: cli.version, defaultRuntimeResolution: true, qualificationOverride: false }; } +/** Clean-install protocol proof only: no credentials, task, or provider turn. */ +export async function probeInstalledCodexStartup(input: { + consumerRoot: string; + serverRoot: string; + expectedRunnerSha256: string; + timeoutMs?: number; +}) { + for (const key of [...overrideKeys, "NODE_OPTIONS", "NODE_PATH"]) { + if (process.env[key] !== undefined) throw new Error(`Installed Codex startup forbids ${key}`); + } + const inside = (root: string, filename: string) => { + const name = relative(root, filename); + return name !== "" && name !== ".." && !name.startsWith(`..${sep}`) && !isAbsolute(name); + }; + for (const filename of [input.consumerRoot, input.serverRoot]) { + if (!isAbsolute(filename) || resolve(filename) !== filename || await realpath(filename) !== filename) throw new Error("Installed Codex startup paths must be canonical and unlinked"); + } + if (!inside(join(input.consumerRoot, "node_modules"), input.serverRoot) || !/^[a-f0-9]{64}$/.test(input.expectedRunnerSha256)) throw new Error("Installed Codex startup requires a contained server and reviewed daemon digest"); + const manifest = JSON.parse((await readOwned(join(input.serverRoot, "package.json"), 65536)).toString()); + if (manifest.name !== "@paperclipai/server" || manifest.exports?.["."]?.import !== "./dist/index.js") throw new Error("Installed Codex startup requires the published server"); + const vendorRoot = join(input.serverRoot, "dist/vendor/paperclip-runner"); + const entries = [join(vendorRoot, "index.js"), join(input.serverRoot, "dist/services/native-runtime/native-codex-runner.js")]; + for (const filename of entries) if (await realpath(filename) !== filename) throw new Error("Installed Codex startup runtime escapes its package"); + const [runtime, production] = await Promise.all(entries.map(filename => import(pathToFileURL(filename).href))); + const runnerBinary = await realpath(production.resolvePaperclipRunnerBinary()); + if (runnerBinary !== join(vendorRoot, "bin", `${process.platform}-${process.arch}`, "paperclip-runnerd")) throw new Error("Installed Codex startup requires the normal platform daemon; no source or flat fallback"); + const digest = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex"); + const runnerSha256 = digest(await readOwned(runnerBinary, 512 * 1024 * 1024)); + if (runnerSha256 !== input.expectedRunnerSha256) throw new Error("Installed Codex startup daemon differs from its reviewed pin"); + const directory = await realpath(await mkdtemp(join(tmpdir(), "pc-codex-startup-"))); + const home = join(directory, "home"), codexHome = join(directory, "codex-home"), workspace = join(directory, "workspace"); + for (const filename of [home, codexHome, workspace]) await mkdir(filename, { mode: 0o700 }); + const environment = { PATH: `${dirname(process.execPath)}:/usr/bin:/bin:/usr/sbin`, HOME: home, CODEX_HOME: codexHome, + TMPDIR: directory, LC_ALL: "C", TZ: "UTC", PAPERCLIP_RUNNER_NETWORK_ACCESS: "disabled" }; + let session: any; + let bundle: any; + let cleanupConfirmed = false; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error("Installed Codex startup timed out")), Math.max(1, Math.min(input.timeoutMs ?? 45000, 120000))); + try { + const codexCommand = await realpath(runtime.resolveCodexCommand(undefined, environment, workspace)); + if (!inside(join(input.consumerRoot, "node_modules"), codexCommand)) throw new Error("Installed Codex startup resolved an ambient Codex executable"); + const codexSha256 = digest(await readOwned(codexCommand, 512 * 1024 * 1024)); + const codexVersion = execFileSync(codexCommand, ["--version"], { cwd: workspace, env: environment, encoding: "utf8", timeout: 10000, maxBuffer: 16384 }).trim(); + if (!codexVersion || codexVersion.length > 200) throw new Error("Installed Codex startup returned no bounded version identity"); + // Keep the production security arguments; pin file-only auth for this + // credential-free fixture so macOS keychain accounts cannot be consulted. + const args = runtime.createIsolatedCodexAppServerArgs(environment); + args.splice(args.indexOf("app-server"), 0, "-c", 'cli_auth_credentials_store="file"'); + bundle = runtime.createRunnerdCodexTransport({ provider: "codex", stateDirectory: directory, environment, + sourceCodexHome: "", runnerBinary, codexCommand, codexArgs: args, closeGraceMs: 10000, + lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }); + const driver = new runtime.CodexAppServerDriver({ + taskEnvelope: { schema: "paperclip.skillless_task.v1", objective: "Credential-free installed startup proof.", + completionContract: { revision: "installed-startup-v1", criteria: [] }, constraints: [], expectedResultSchema: "paperclip.run_result.v1" }, + model: "gpt-5.6-sol", conversationMode: "direct", requestedCollaborationMode: "plan", approvalPolicy: "never", + includeCollaborationModeInstructions: false, dynamicTools: [], environment, + transportFactory: () => bundle.transport, requireProviderSessionIdentity: true, + }); + session = await driver.openSession({ runId: "installed-startup", normalizedSessionId: "installed-startup", workingDirectory: workspace, signal: controller.signal }); + const event = await session.events()[Symbol.asyncIterator]().next(); + const identity = session.ids(); + const evidence = bundle.evidence(); + if (event.value?.eventType !== "session.started" || event.value.payload?.context?.model !== "gpt-5.6-sol" || + event.value.payload?.context?.sandbox?.permissionProfile?.id !== "paperclip-runner-workspace-read-only" || + !identity.driverSessionId || !identity.providerSessionId || evidence.providerDriver !== "codex_app_server" || + !(evidence.runnerPid > 0) || !(evidence.codexPid > 0)) throw new Error("Installed Codex startup returned no matching native identity or read-only model context"); + await session.close({ reason: "credential-free installed startup complete" }); + session = undefined; + if (!bundle.evidence().runnerExited) throw new Error("Installed Codex startup cleanup is incomplete"); + cleanupConfirmed = true; + return { schema: "paperclip.e2e.installed-codex-startup/v1", platform: `${process.platform}-${process.arch}`, + runnerSha256, codexSha256, codexVersion, providerDriver: "codex_app_server", model: "gpt-5.6-sol", + nativeSessionStarted: true, cleanupConfirmed: true, providerTurns: 0, credentialsUsed: false, + defaultRuntimeResolution: true, reasoningEffortExercised: false }; + } finally { + clearTimeout(timer); + if (session) await session.close({ reason: "installed startup cleanup" }); + else if (bundle && !cleanupConfirmed) await bundle.transport.close("installed startup cleanup"); + if (!bundle || bundle.evidence().runnerExited) await rm(directory, { recursive: true, force: true }); + } +} + /** This prep lane cannot create agents or read the normal local credential file. */ export function assertInstalledStartupOnly(env: NodeJS.ProcessEnv, executions: readonly MatrixExecution[], options: { ids: string[]; maxParallel: number; maxAutomaticRetries: number; headed: boolean; ui: boolean; debug: boolean; list: boolean; matrixJson: boolean }) { assertInstalledCliSelection(env, executions); diff --git a/ui/src/pages/NewAgent.test.tsx b/ui/src/pages/NewAgent.test.tsx index b33ae66b60..49cf8f2ac3 100644 --- a/ui/src/pages/NewAgent.test.tsx +++ b/ui/src/pages/NewAgent.test.tsx @@ -200,10 +200,10 @@ afterEach(async () => { container.remove(); }); describe("New agent setup", () => { - it("records an explicit legacy choice when it matches the Mac default, then preserves it on Linux", async () => { + it("records an explicit legacy choice when it matches the Windows default, then preserves it on Linux", async () => { settings.get.mockResolvedValue({ defaultEnvironmentId: "local-1" }); envApi.list.mockResolvedValue([ - { id: "local-1", name: "Mac", driver: "local", status: "active", config: {} }, + { id: "local-1", name: "Windows", driver: "local", status: "active", config: {} }, { id: "linux-ssh", name: "Linux", driver: "ssh", status: "active", config: {} }, ]); adapterApi.list.mockImplementation(async (context?: { environmentId?: string | null }) => state.adapters.map((adapter: any) => @@ -242,7 +242,7 @@ describe("New agent setup", () => { it("discovers the runner for the selected target and rechecks after changing environments", async () => { settings.get.mockResolvedValue({ defaultEnvironmentId: "linux-ssh" }); envApi.list.mockResolvedValue([ - { id: "local-1", name: "Mac", driver: "local", status: "active", config: {} }, + { id: "local-1", name: "Windows", driver: "local", status: "active", config: {} }, { id: "linux-ssh", name: "Linux", driver: "ssh", status: "active", config: {} }, ]); adapterApi.list.mockImplementation(async (context?: { companyId: string; environmentId?: string | null }) => state.adapters.map((adapter: any) => @@ -287,9 +287,13 @@ describe("New agent setup", () => { expect(api.testEnvironment.mock.calls[0][2]).toMatchObject({ runner: "legacy" }); expect(api.hire.mock.calls[0][1]).toMatchObject({ runner: "legacy" }); }); - it("uses automatic Codex resolution through setup and hire without the experimental gate", async () => { + it.each(["Linux x64", "Mac arm64", "Mac x64"])("uses automatic Codex resolution through setup and hire without the experimental gate (%s)", async name => { settings.getExperimental.mockResolvedValue({ enableNativeRunner: false }); + envApi.list.mockResolvedValue([{ id: "local-1", name, driver: "local", status: "active", config: {} }]); + state.adapters = (state.adapters as any[]).map(adapter => adapter.type === "codex_local" ? { ...adapter, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" } : adapter); await render("codex_local"); + await act(async () => (container.querySelector("summary") as HTMLElement).click()); + expect(container.querySelector('[aria-label="Runner"]')?.textContent).toBe("Paperclip Runner (default)"); await connect("OpenAI"); await click("Finish setup"); expect(api.hire.mock.calls[0][1].adapterType).toBe("codex_local");