diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index e0aae6d1a7..6301a4d1c1 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 native 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,163 @@ 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_native: + 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: ${{ fromJSON(needs.qualification_daemons.outputs.native_consumer_matrix) }} + 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: Checkout immutable ARM qualification tooling + if: ${{ matrix.target == 'linux-arm64' }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + # job.workflow_* identifies this reusable workflow, not its caller. + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .qualification-tooling + sparse-checkout: scripts/prepare-linux-arm64-qualification.sh + sparse-checkout-cone-mode: false + persist-credentials: false + - name: Prepare Linux ARM sandbox prerequisites + if: ${{ matrix.target == 'linux-arm64' }} + timeout-minutes: 5 + env: + SOURCE_SHA: ${{ inputs.qualification_source_sha }} + TOOLING_SHA: ${{ job.workflow_sha }} + TOOLING_REPOSITORY: ${{ job.workflow_repository }} + TOOLING_WORKFLOW_REF: ${{ job.workflow_ref }} + TOOLING_WORKFLOW_PATH: ${{ job.workflow_file_path }} + run: | + set -euo pipefail + [[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]] || exit 1 + [[ "$TOOLING_SHA" =~ ^[a-f0-9]{40}$ ]] || exit 1 + [[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] || exit 1 + [[ "$(git -C .qualification-tooling rev-parse HEAD)" == "$TOOLING_SHA" ]] || exit 1 + node --input-type=module <<'NODE' + import { createHash } from 'node:crypto'; + import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; + import { join } from 'node:path'; + const directory = join(process.env.RUNNER_TEMP, 'paperclip-arm-bwrap-prerequisite'); + mkdirSync(directory, { recursive: true }); + const helper = '.qualification-tooling/scripts/prepare-linux-arm64-qualification.sh'; + writeFileSync(join(directory, 'qualification-identity.json'), JSON.stringify({ + productSourceSha: process.env.SOURCE_SHA, + toolingSourceSha: process.env.TOOLING_SHA, + toolingRepository: process.env.TOOLING_REPOSITORY, + toolingWorkflowRef: process.env.TOOLING_WORKFLOW_REF, + toolingWorkflowPath: process.env.TOOLING_WORKFLOW_PATH, + helperSha256: createHash('sha256').update(readFileSync(helper)).digest('hex'), + }, null, 2) + '\n'); + NODE + bash .qualification-tooling/scripts/prepare-linux-arm64-qualification.sh + - 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 + mode=--mac-consumer + if [[ "$TARGET" == linux-arm64 ]]; then mode=--linux-arm64-consumer; fi + node scripts/verify-grok-npm-install.mjs "$mode" \ + "$RUNNER_TEMP/public-runner-packs" "$SOURCE_SHA" "$RUNNER_TEMP/native-public-install.json" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: ${{ inputs.artifact_name }}-${{ matrix.target }} + path: | + ${{ runner.temp }}/native-public-install.json + ${{ runner.temp }}/paperclip-arm-bwrap-prerequisite/ + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index 986968201d..96402b6abe 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -7,18 +7,30 @@ 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 }} + native_consumer_matrix: + value: ${{ jobs.runner_binary_targets.outputs.native_consumers }} # 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 +109,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 @@ -208,6 +221,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 @@ -257,6 +271,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 @@ -287,6 +302,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 @@ -470,6 +486,7 @@ jobs: key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }} 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 @@ -498,3 +515,161 @@ jobs: - name: Build run: pnpm build + + runner_binary_targets: + if: inputs.runner_binaries_only + name: Read exact-source daemon targets + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + matrix: ${{ steps.targets.outputs.matrix }} + native_consumers: ${{ steps.targets.outputs.native_consumers }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + - name: Preserve the immutable source's platform contract + id: targets + env: + SOURCE_SHA: ${{ inputs.ref }} + run: | + set -euo pipefail + [[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]] || exit 1 + [[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] || exit 1 + node --input-type=module <<'NODE' + import assert from 'node:assert/strict'; + import { appendFileSync } from 'node:fs'; + import { RELEASE_RUNNER_TARGETS } from './packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs'; + const historical = ['darwin-arm64', 'darwin-x64', 'linux-x64']; + const current = [...historical, 'linux-arm64']; + const targets = [...RELEASE_RUNNER_TARGETS].sort(); + assert.ok(JSON.stringify(targets) === JSON.stringify(historical.sort()) || JSON.stringify(targets) === JSON.stringify(current.sort()), 'Unknown exact-source daemon platform contract'); + const hosts = { 'linux-x64': targets.length === current.length ? 'ubuntu-24.04' : 'ubuntu-latest', 'linux-arm64': 'ubuntu-24.04-arm', 'darwin-arm64': 'macos-15', 'darwin-x64': 'macos-15-intel' }; + const include = RELEASE_RUNNER_TARGETS.map(target => ({ target, host: hosts[target] })); + appendFileSync(process.env.GITHUB_OUTPUT, `matrix=${JSON.stringify({ include })}\nnative_consumers=${JSON.stringify({ include: include.filter(row => row.target !== 'linux-x64') })}\n`); + NODE + + runner_binaries: + if: inputs.runner_binaries_only + needs: runner_binary_targets + name: Release daemon (${{ matrix.target }}) + runs-on: ${{ matrix.host }} + timeout-minutes: 30 + permissions: + contents: read + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.runner_binary_targets.outputs.matrix) }} + 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: Pin native Cargo workspace and toolchain + id: native_rust_workspace + working-directory: packages/paperclip-runner + run: | + set -euo pipefail + pinned="$HOME/paperclip-runner-rust" + if [[ -e "$pinned" && ! -L "$pinned" ]]; then + echo '::error::Native Cargo cache path must not replace a real directory' + exit 1 + fi + rm -f "$pinned" + ln -s "$GITHUB_WORKSPACE/packages/paperclip-runner/runner" "$pinned" + echo "path=$pinned" >> "$GITHUB_OUTPUT" + rustup show + toolchain="$(rustup show active-toolchain | awk '{print $1}')" + echo "RUSTUP_TOOLCHAIN=$toolchain" >> "$GITHUB_ENV" + extra_toolchains="$(rustup toolchain list | awk '{print $1}' | grep -vx "$toolchain" || true)" + if [[ -n "$extra_toolchains" ]]; then + echo "$extra_toolchains" | xargs -n1 rustup toolchain uninstall \ + || echo '::notice title=Native Rust cache::could not remove an extra toolchain; the cache key may not match' + fi + rustup toolchain list + - name: Cache native Rust dependencies + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: ${{ steps.native_rust_workspace.outputs.path }} -> target + # Compiler/environment and Cargo lock hashes are added by the action. + # Also bind generated contracts outside the Rust workspace, while + # always recompiling workspace code and recording actual new bytes. + shared-key: release-native-v2-${{ matrix.host }}-${{ matrix.target }}-${{ hashFiles('packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/src/**', 'packages/paperclip-runner/*.json') }} + cache-workspace-crates: false + cache-bin: false + cache-on-failure: false + # A master workflow can verify an unmerged preview ref. Those jobs + # restore only; they never write master's trusted dependency cache. + save-if: ${{ github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }} + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + - name: Build TypeScript contract and host daemon concurrently + run: | + node --input-type=module <<'NODE' + import { mkdirSync, readFileSync } from 'node:fs'; + import { join } from 'node:path'; + import { startPrefetch, stopPrefetches } from './scripts/prefetch-child.mjs'; + const root = process.cwd(), logs = join(process.env.RUNNER_TEMP, 'native-build-preparation'); + mkdirSync(logs, { recursive: true }); + const builds = []; + let failure; + try { + for (const [name, args, cwd] of [ + ['typescript', ['--filter', '@paperclipai/paperclip-runner', 'build:typescript'], root], + ['native', ['build:binary'], join(root, 'packages/paperclip-runner')], + ]) { + builds.push(startPrefetch({ name, cmd: 'pnpm', args, cwd, env: process.env, logPath: join(logs, name + '.log') })); + } + await Promise.all(builds.map(build => build.done)); + } catch (error) { failure = error; } + finally { + await stopPrefetches(builds); + const results = await Promise.allSettled(builds.map(build => build.done)); + builds.forEach((build, index) => { + console.log('::group::' + build.name + ' build'); + process.stdout.write(readFileSync(join(logs, build.name + '.log'), 'utf8')); + console.log('::endgroup::'); + console.log(JSON.stringify({ build: build.name, status: results[index].status, + exitCode: build.child.exitCode, signal: build.child.signalCode, durationMs: build.durationMs })); + }); + } + if (failure) throw failure; + NODE + - 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 a2c51dbc74..a676cf55a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -340,9 +340,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 source-declared native 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 @@ -380,6 +425,17 @@ jobs: git checkout -B master "$SOURCE_SHA" test "$(git rev-parse HEAD)" = "$SOURCE_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: @@ -633,12 +689,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 @@ -664,6 +722,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: @@ -901,10 +970,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 @@ -930,6 +1001,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: @@ -1286,8 +1368,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: @@ -1300,6 +1382,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: @@ -1328,8 +1421,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 @@ -1347,6 +1440,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 d28e7960ea..0e00c147a0 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -1,6 +1,55 @@ # Runner defaults: staged delivery -Updated 2026-10-08. +Updated 2026-10-12. + +## Latest checkpoint: runtime prerequisite and authorized merges + +The user authorizes preparing and merging ready PRs in dependency order. +Production deployment and provisioning-default promotion remain separate actions. +The Codex-default candidate is preserved at +`bb7591dfd0c4c8569e2a0b4303ff06de8ca3e9e2`; its running test-drive remains at +[3125](http://127.0.0.1:3125/COD/agents/all). No existing agent changes runner. + +Its qualification work grew the review to 135 files. Extract the independent +runtime, installed-package and hosted-build changes onto +`codex/runner-native-platform-prerequisites`, based on master +`7d9b93aee2b79b2f035c5838741ce0b94168ec83`. This prerequisite contains 39 source +files and this record. Creation policy and experimental gates remain identical +to that master. README activation text and eight existing default-selection +controls stay in the dependent [Codex PR #15683](https://github.com/paperclipai/paperclip/pull/15683), +whose expected review is 98 files. Preserve all work and both branches. + +The isolated extraction passes 276 existing focused controls. Runner no-emit +passes. The standard server no-emit initially finds a linked dependency-output +skew: the new master's ACP constants are absent from the old checkout's compiled +adapter-utils. A source-aware diagnostic passes; canonical verification remains +pending until its matching JavaScript dependency is prepared. Full hosted CI, +native compilation and fresh review are still required for the extracted head. +Do not count the diagnostic as a passing standard command. + +Retained qualification keeps literal identities: four producers and Linux x64 +and both Mac consumers pass at `a200495`; installed ARM native startup passes +with scoped stock Ubuntu prerequisites in +[38291535914](https://github.com/paperclipai/paperclip/actions/runs/38291535914). +Those artifacts are not relabeled as the extracted source. The Linux baseline +is GNU libc 2.39+, on x64/AArch64. Windows and Alpine remain deferred. +Cargo caches and overlapping JavaScript/native preparation use existing hosted +workflows. No local Docker/Rust builds or bundled Codex payloads are introduced. + +Merge order is runtime prerequisite, Codex defaults, then the existing Claude +[protocol #15994](https://github.com/paperclipai/paperclip/pull/15994), +[default #15993](https://github.com/paperclipai/paperclip/pull/15993), and +[platform #15997](https://github.com/paperclipai/paperclip/pull/15997) PRs. +The authorized Claude chat owns its verification and fixes; this task coordinates +normal squash merges after applicable gates. The older Grok Cloud PR is an +independent lane, with its own missing snapshot-cleanup access and live proof. + +Next action: open the extracted prerequisite, compose the preserved default +branch on it, require current-source CI/review, and remotely prepare the exact +Codex staging image and DB/shared pair. The existing QA tenant remains pinned to +old `2dbe43a4`. Managed Codex retains its $2.50 setup/task envelope and $2 cleanup; +historical unknown holds remain within the original $250 ceiling. Claude has +its separately authorized $50 ceiling. Missing live proof remains unresolved. ## Outcome and ownership diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index a7f0647a39..09fce4eb82 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -66,8 +66,8 @@ are allowed. Relative PATH entries use the selected process working directory. Compatibility is established by the actual protocol or login attempt. Missing executables and real protocol failures still return actionable errors. Package identity and executable containment remain checked. Browser login keeps -its existing provider-specific credential home. Linux ARM64 retains its existing -legacy login path because native execution is not qualified there. +its provider-specific credential home and resolves the installed Codex CLI on +64-bit Linux and macOS independently of the daemon's libc requirements. Exact dependency pins remain release and ACPX artifact-qualification checks. They make published builds reproducible and verify sandbox artifacts; they do not @@ -168,7 +168,7 @@ are unavailable. Accepting a native plan ends the planning run successfully whil the task waits for the next instruction. See the [Cursor release report](../../doc/plans/2026-10-03-cursor-production-readiness.md). -A release includes all three platform daemons and the Linux provider-pack identity +A release includes all four platform daemons and the Linux provider-pack identity from its matching Daytona image. Run `stage:release-binaries` with a manifest that binds each daemon path and SHA-256, plus `remoteProviderPack: {path, sha256}` for the actual image's `provider-pack.json`. Assemble these assets after the normal @@ -183,6 +183,55 @@ Ordinary remote Cursor startup uses the packaged Linux daemon and verifies every image asset against that manifest. A mismatched image fails before the provider starts; install the matching package and image together. +### Native binary platform baseline + +Release packages contain Paperclip daemons for `linux-x64`, `linux-arm64`, +`darwin-arm64`, and `darwin-x64`. Codex native binaries are installed separately +through the official optional npm platform dependencies described above. + +The shipped Linux daemons target **GNU libc 2.39 or newer**, on x86-64 or AArch64. +Their ELF version requirements include `GLIBC_2.39`; both depend on `libc.so.6`, +`libm.so.6`, `libgcc_s.so.1`, and the matching GNU dynamic loader. The ARM64 +loader is `/lib/ld-linux-aarch64.so.1`; x64 uses +`/lib64/ld-linux-x86-64.so.2`. This is a binary compatibility baseline, not a +promise that every Linux distribution or kernel has been qualified. + +Hosted native producers use Ubuntu 24.04 for both Linux architectures. Clean npm +install checks use the pinned Debian 13 Node image; the cloud Dockerfile uses +Debian 13 (`node:24-trixie-slim`). macOS binaries build on the existing macOS 15 +Apple Silicon and Intel hosts. Actual task and follow-up acceptance is performed +on Apple Silicon; Intel qualification covers build, installation, and protocol +startup. + +On Linux, Codex's sandboxed filesystem modes also require the host to permit its +unprivileged namespaces. A no-privilege Docker container can prohibit nested +namespace creation even when installation and binary compatibility pass. +Qualification therefore retains the restricted Docker install/lifecycle checks +and exercises the same installed graph's read-only native startup on the actual +ARM host. This does not qualify arbitrary container or managed setup targets; +their selected runtime must pass setup in that environment. + +Ubuntu 24.04 also needs system `bubblewrap` and the stock +`bwrap-userns-restrict` AppArmor profile described in +[Codex's Linux prerequisites](https://learn.chatgpt.com/docs/sandboxing#prerequisites). +The qualified ARM64 host uses that distribution helper/profile while retaining +AppArmor enforcement and global namespace restrictions. Hosted qualification +materializes authenticated stock package files without running maintainer +scripts; it verifies native startup and package continuity separately from the +restricted Docker installation check. This does not prove a normal OS package +installation journey or a paid provider task on ARM64. + +Windows, 32-bit ARM, and Alpine/musl native qualification remain deferred. + +Native release jobs cache Cargo dependencies per platform and OS baseline, +including compiler, lockfile, and external generated-contract inputs. Workspace +crates and released binaries are rebuilt and verified every time. Only canonical +exact-source master pushes may save trusted caches; preview qualification can +restore them. JavaScript preparation and native compilation run concurrently, +and both must succeed before artifact metadata and source-bound hashes are +recorded. Run Docker and native qualification on hosted CI rather than a local +developer machine. + Pi cold provider admission has an absolute 60-second budget. Warm run attachment checkpoints the old ACPX sidecar and starts a new one, so it uses that same budget while retaining the existing Runner authority. Live adoption and ordinary diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs index b1ed069b09..8568cb65f2 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs @@ -1034,6 +1034,9 @@ fn run() -> Result<(), Box> { } "initialized" => {} "thread/start" => { + if let Some(delay) = argument(&args, "--thread-start-delay-ms") { + thread::sleep(Duration::from_millis(delay.parse()?)); + } if require_external_sandbox && (message.pointer("/params/sandbox") != Some(&json!("danger-full-access")) || message.pointer("/params/permissions").is_some()) @@ -1076,7 +1079,8 @@ fn run() -> Result<(), Box> { } send(json!({ "id": id, - "result": {"thread": {"id": state.thread_id, "sessionId": "codex-account-session"}} + "result": {"thread": {"id": state.thread_id, "sessionId": "codex-account-session"}, + "model": argument(&args, "--returned-model").map(Value::String).unwrap_or_else(|| message.pointer("/params/model").filter(|model| model.is_string()).cloned().unwrap_or(json!("gpt-test")))} }))?; if agent_created_goal { send(json!({ diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs index 61432b9dbe..cf211a6c97 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs @@ -367,6 +367,8 @@ pub struct CodexProviderConfig { pub approval_policy: String, #[serde(default)] pub externally_sandboxed: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub read_only: bool, // Older persisted configurations deliberately retain the provider default. #[serde(default, skip_serializing_if = "Option::is_none")] pub include_skill_instructions: Option, @@ -669,6 +671,7 @@ pub struct CodexProvider { next_request_id: u64, thread_id: String, provider_session_id: Option, + observed_model: Option, active_provider_turn_id: Option, pending_messages: VecDeque, deferred_ambiguous_messages: VecDeque, @@ -831,6 +834,10 @@ fn codex_permission_profile(provider: &str, external_sandbox: bool) -> &'static } } +fn is_false(value: &bool) -> bool { + !*value +} + impl CodexProvider { pub fn start( config: &CodexProviderConfig, @@ -904,11 +911,15 @@ impl CodexProvider { )); } let authorized_tools = authorized_tools.into_iter().collect::>(); - let permission_profile = codex_permission_profile( - &config.provider, - config.externally_sandboxed - || std::env::var("PAPERCLIP_RUNNER_EXTERNAL_SANDBOX").as_deref() == Ok("1"), - ); + let permission_profile = if config.read_only { + "paperclip-runner-workspace-read-only" + } else { + codex_permission_profile( + &config.provider, + config.externally_sandboxed + || std::env::var("PAPERCLIP_RUNNER_EXTERNAL_SANDBOX").as_deref() == Ok("1"), + ) + }; let (dynamic_tools, authorized_tool_ids) = codex_dynamic_tools(authorized_tools.iter().cloned())?; let common_environment_keys = [ @@ -1003,6 +1014,7 @@ impl CodexProvider { next_request_id: 1, thread_id: String::new(), provider_session_id: None, + observed_model: None, active_provider_turn_id: None, pending_messages: VecDeque::new(), deferred_ambiguous_messages: VecDeque::new(), @@ -1133,6 +1145,12 @@ impl CodexProvider { .and_then(Value::as_str) .filter(|value| !value.is_empty()) .map(str::to_owned); + provider.observed_model = opened + .get("model") + .or_else(|| opened.pointer("/thread/model")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty() && value.len() <= 240) + .map(str::to_owned); if resume_thread_id.is_some() { stage = ProviderStartupStage::ThreadRead; @@ -1175,6 +1193,10 @@ impl CodexProvider { self.provider_session_id.as_deref() } + pub fn observed_model(&self) -> Option<&str> { + self.observed_model.as_deref() + } + pub(crate) fn take_provider_trace_frame_id(&mut self) -> Option { self.last_trace_frame_id.take() } @@ -1685,6 +1707,16 @@ impl CodexProvider { message: &str, cwd: &str, options: CodexTurnOptions<'_>, + ) -> Result { + self.start_turn_with_options_and_effort(message, cwd, options, None) + } + + pub(crate) fn start_turn_with_options_and_effort( + &mut self, + message: &str, + cwd: &str, + options: CodexTurnOptions<'_>, + effort: Option<&Value>, ) -> Result { let CodexTurnOptions { skills, @@ -1705,6 +1737,17 @@ impl CodexProvider { for skill in skills { skill.validate()?; } + let effort = effort.filter(|value| !value.is_null()); + if effort.is_some_and(|value| { + !matches!( + value.as_str(), + Some("minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra") + ) + }) { + return Err(LocalRunnerError::invalid( + "unsupported native Codex reasoning effort", + )); + } if self.quarantined { return Err(LocalRunnerError::invalid( "Codex provider is quarantined after unsafe recovered work", @@ -1745,6 +1788,9 @@ impl CodexProvider { let turn_params_object = turn_params .as_object_mut() .expect("Codex turn parameters are an object"); + if let Some(effort) = effort { + turn_params_object.insert("effort".to_owned(), effort.clone()); + } if let Some(mode) = reasoning_mode { turn_params_object.insert("reasoningMode".to_owned(), json!(mode)); } @@ -4094,6 +4140,7 @@ done instructions: "Test only.".to_owned(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }; @@ -4481,6 +4528,7 @@ done instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }; @@ -4601,6 +4649,7 @@ done instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs index 329c3dc7bf..e6c2c977db 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs @@ -2608,7 +2608,14 @@ impl CodexCommandExecutor { .as_ref() .and_then(|state| state.thread_id.as_ref()) .is_some(); - let (thread_id, provider_session_id, process_id, active_provider_turn_id, goal_probe) = { + let ( + thread_id, + provider_session_id, + process_id, + active_provider_turn_id, + goal_probe, + observed_model, + ) = { let provider = self.ensure_provider()?; ( provider.thread_id().to_owned(), @@ -2616,6 +2623,7 @@ impl CodexCommandExecutor { provider.process_id(), provider.active_provider_turn_id().map(str::to_owned), provider.get_goal(), + provider.observed_model().map(str::to_owned), ) }; let (goal_capability, goal) = match goal_probe { @@ -2703,6 +2711,7 @@ impl CodexCommandExecutor { "providerSessionId": thread_id, "providerAccountSessionId": provider_session_id, "processId": process_id, + "providerDescriptor": { "driver": driver, "providerVersion": provider_version, "effectiveModel": observed_model }, }), ), ( @@ -3003,13 +3012,14 @@ impl CodexCommandExecutor { rejected_accepted_turn, ) = { let provider = self.ensure_provider()?; - let result = provider.start_turn_with_options( + let result = provider.start_turn_with_options_and_effort( text, &cwd, CodexTurnOptions { skills: &skills, reasoning_mode, }, + payload.get("effort"), ); ( result, @@ -4998,6 +5008,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5379,6 +5390,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5470,6 +5482,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5523,6 +5536,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5566,6 +5580,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5658,6 +5673,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5725,6 +5741,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5775,6 +5792,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5899,6 +5917,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -6011,6 +6030,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -6068,6 +6088,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -6210,6 +6231,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -6251,6 +6273,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -6289,6 +6312,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -6366,6 +6390,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs index b4bc2a316b..e586105d51 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs @@ -65,6 +65,7 @@ fn provider_config(directory: &Path, switches: &[&str]) -> CodexProviderConfig { instructions: "Stay inside the test workspace.".to_owned(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, } diff --git a/packages/paperclip-runner/scripts/release-provider-pack.test.mjs b/packages/paperclip-runner/scripts/release-provider-pack.test.mjs index 33d6de5874..5dded2a32f 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. @@ -55,10 +56,10 @@ test("release assembly rejects rehashed stale or missing packs before staging an mkdirSync(dirname(join(root, file)), { recursive: true }); cpSync(join(source, file), join(root, file)); } const platforms = {}; - for (const target of ["darwin-arm64", "darwin-x64", "linux-x64"]) { + for (const target of ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"]) { const bytes = Buffer.alloc(64); - if (target === "linux-x64") { - bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(62, 18); + if (target.startsWith("linux-")) { + bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(target === "linux-x64" ? 62 : 183, 18); } else { bytes.writeUInt32LE(0xfeedfacf, 0); bytes.writeUInt32LE(target === "darwin-arm64" ? 0x0100000c : 0x01000007, 4); } @@ -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..c36e79e2aa 100644 --- a/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs +++ b/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs @@ -1,55 +1,135 @@ -// Assemble the three independently built release daemons before npm packing. +// Assemble the four independently built release daemons before npm packing. // Usage: node packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs MANIFEST.json // 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", "linux-arm64"]); +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 four 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 four 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.mjs b/packages/paperclip-runner/scripts/stage-runner-binary.mjs index 48643ea708..581ee2867e 100644 --- a/packages/paperclip-runner/scripts/stage-runner-binary.mjs +++ b/packages/paperclip-runner/scripts/stage-runner-binary.mjs @@ -28,11 +28,16 @@ export async function stageRunnerBinary(source, destination) { } } +export async function stageBuiltRunnerBinary(packageRoot) { + const executable = process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd"; + const source = path.join(packageRoot, "runner", "target", "release", executable); + const binaryRoot = path.join(packageRoot, "dist", "bin"); + // Source builds are separate from the source-bound release targets and their + // manifest. The dev supervisor selects this fresh output for its server. + await stageRunnerBinary(source, path.join(binaryRoot, executable)); +} + if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); - const executable = process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd"; - await stageRunnerBinary( - path.join(packageRoot, "runner", "target", "release", executable), - path.join(packageRoot, "dist", "bin", executable), - ); + await stageBuiltRunnerBinary(packageRoot); } diff --git a/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs b/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs index 81696b8ba6..3efc5bcf7e 100644 --- a/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs +++ b/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs @@ -1,11 +1,75 @@ import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { once } from "node:events"; -import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, symlink, utimes, 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 { stageBuiltRunnerBinary, stageRunnerBinary } from "./stage-runner-binary.mjs"; +import { RELEASE_RUNNER_TARGETS, releaseDaemonManifest, verifyReleaseRunnerMetadata } from "./stage-release-runner-binaries.mjs"; +import { paperclipRunnerBinaryNeedsBuild, resolveNativeRunnerDevBinary } from "../../../scripts/dev-runner-native-binary.mjs"; +import { resolvePackagedRunnerBinary } from "../src/live/runner-binary.ts"; + +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 four 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.startsWith("linux-")) { + bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(target === "linux-x64" ? 62 : 183, 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); + const armBinary = path.join(root, "linux-arm64/paperclip-runnerd"), armBytes = await readFile(armBinary); + const armReceipt = JSON.parse(await readFile(receiptPath("linux-arm64"), "utf8")); + const wrongArm = await readFile(path.join(root, "linux-x64/paperclip-runnerd")); + await writeFile(armBinary, wrongArm); + await writeFile(receiptPath("linux-arm64"), JSON.stringify({ ...armReceipt, sha256: "sha256:" + createHash("sha256").update(wrongArm).digest("hex") })); + assert.throws(() => releaseDaemonManifest(root, source, verify), /architecture mismatch/); + await writeFile(armBinary, armBytes); await writeFile(receiptPath("linux-arm64"), JSON.stringify(armReceipt)); + await mkdir(path.join(root, "win32-x64")); + assert.throws(() => releaseDaemonManifest(root, source, verify), /exactly the four/); + await rm(path.join(root, "win32-x64"), { recursive: true }); + await rm(receiptPath("linux-arm64")); + 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 = []; @@ -53,6 +117,82 @@ test("atomically publishes while the previous executable is running", { skip: pr }); }); +test("a source rebuild selects fresh dev bytes while preserving release binaries and their manifest", { skip: process.platform === "win32", timeout: 15_000 }, async () => { + const root = await mkdtemp(path.join(tmpdir(), "runnerd-stage-rebuild-")); + const packageRoot = path.join(root, "packages", "paperclip-runner"); + const binaryRoot = path.join(packageRoot, "dist", "bin"); + const hostTarget = path.join(binaryRoot, `${process.platform}-${process.arch}`, "paperclip-runnerd"); + const source = path.join(packageRoot, "runner", "crates", "runner-core", "src", "main.rs"); + const built = path.join(packageRoot, "runner", "target", "release", "paperclip-runnerd"); + await withOwnedChildren(root, async start => { + const copyHostFixture = async (input, output) => { + await copyFile(input, output); + if (process.platform === "darwin") { + // System fixtures can be universal; Cargo release daemons are thin. + // Extract only this host slice before exercising the native header gate. + const slices = execFileSync("/usr/bin/lipo", ["-archs", input], { encoding: "utf8" }).trim().split(/\s+/); + const architecture = slices.find(value => process.arch === "arm64" ? value.startsWith("arm64") : value === "x86_64"); + assert.ok(architecture); + if (slices.length > 1) execFileSync("/usr/bin/lipo", [input, "-thin", architecture, "-output", output]); + } + }; + await mkdir(path.dirname(source), { recursive: true }); + await writeFile(source, "fn main() {}\n"); + await mkdir(path.dirname(built), { recursive: true }); + await copyHostFixture("/bin/echo", built); + const previousBuild = path.join(root, "previous-build"); + await copyHostFixture("/bin/sleep", previousBuild); + await stageRunnerBinary(previousBuild, hostTarget); + await stageRunnerBinary(built, path.join(binaryRoot, "paperclip-runnerd")); + const platforms = {}, releaseBytes = new Map(); + for (const target of RELEASE_RUNNER_TARGETS) { + const artifact = path.join(binaryRoot, target, "paperclip-runnerd"); + if (artifact !== hostTarget) { + const header = Buffer.alloc(64); + if (target.startsWith("linux-")) { + header.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); header.writeUInt16LE(target === "linux-x64" ? 62 : 183, 18); + } else { header.writeUInt32LE(0xfeedfacf); header.writeUInt32LE(target === "darwin-x64" ? 0x01000007 : 0x0100000c, 4); } + await mkdir(path.dirname(artifact), { recursive: true }); + await writeFile(artifact, header); + } + const bytes = await readFile(artifact); + releaseBytes.set(artifact, bytes); + platforms[target] = { path: `${target}/paperclip-runnerd`, sha256: "sha256:" + createHash("sha256").update(bytes).digest("hex") }; + } + const oldTime = new Date(Date.now() - 10_000); + await utimes(hostTarget, oldTime, oldTime); + await utimes(path.join(binaryRoot, "paperclip-runnerd"), oldTime, oldTime); + const oldArtifact = await readFile(hostTarget); + const oldIdentity = await stat(hostTarget); + const manifestPath = path.join(binaryRoot, "release-manifest.json"); + const manifest = JSON.stringify({ schema: "paperclip.runner.release-binaries.v1", sourceRevision: "a".repeat(40), platforms }); + await writeFile(manifestPath, manifest); + const old = start(hostTarget, ["30"]); + await old.spawned; + const requirement = { repoRoot: root, nativeRunnerRequired: true }; + assert.equal(paperclipRunnerBinaryNeedsBuild(requirement), true); + + await stageBuiltRunnerBinary(packageRoot); + + const selected = resolveNativeRunnerDevBinary(requirement); + assert.deepEqual(selected, { binary: path.join(binaryRoot, "paperclip-runnerd"), sourceBuild: true }); + const published = await stat(selected.binary); + assert.equal(published.mode & 0o777, 0o755); + assert.notDeepEqual(await readFile(selected.binary), oldArtifact); + assert.equal((await stat(hostTarget)).ino, oldIdentity.ino); + assert.deepEqual(await readFile(hostTarget), oldArtifact); + assert.equal(await readFile(manifestPath, "utf8"), manifest); + for (const [artifact, bytes] of releaseBytes) assert.deepEqual(await readFile(artifact), bytes); + assert.equal(resolvePackagedRunnerBinary(path.join(packageRoot, "dist")), hostTarget); + assert.equal(paperclipRunnerBinaryNeedsBuild(requirement), false); + assert.equal(old.child.exitCode, null); + assert.equal(old.child.signalCode, null); + const next = start(selected.binary, ["rebuilt"]); + await next.spawned; await next.closed; + assert.equal(next.child.exitCode, 0); + }); +}); + test("a second spawn error still retires the live old child and removes its fixture", { skip: process.platform === "win32", timeout: 15_000 }, async () => { const root = await mkdtemp(path.join(tmpdir(), "runnerd-stage-spawn-failure-")); let old; diff --git a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts index aad5b5c737..02d377d045 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts @@ -5,16 +5,66 @@ import { rmSync, writeFileSync, realpathSync, + statSync, symlinkSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { execFileSync } from "node:child_process"; -import { describe, it, expect } from "vitest"; +import { beforeEach, afterEach, describe, it, expect, vi } from "vitest"; import { parse } from "smol-toml"; import { trustCodexStartupRoot } from "./codex-startup-trust.js"; -describe("isolated Codex startup trust", () => { +const parserStyle = vi.hoisted(() => ({ nullPrototypeTables: false })); +vi.mock("smol-toml", async (importOriginal) => { + const actual = await importOriginal(); + const setTablePrototypes = (value: unknown): void => { + if (value === null || typeof value !== "object" || value instanceof Date) return; + if (Array.isArray(value)) { + value.forEach(setTablePrototypes); + return; + } + Object.values(value).forEach(setTablePrototypes); + Object.setPrototypeOf(value, parserStyle.nullPrototypeTables ? null : Object.prototype); + }; + return { + ...actual, + parse: (...args: Parameters) => { + const result = actual.parse(...args); + // Exercise both table representations independently of the installed + // parser version while retaining the real TOML values. + setTablePrototypes(result); + return result; + }, + }; +}); + +describe.each([ + { name: "ordinary tables", nullPrototypeTables: false }, + { name: "null-prototype tables", nullPrototypeTables: true }, +])("isolated Codex startup trust with $name", ({ nullPrototypeTables }) => { + beforeEach(() => { parserStyle.nullPrototypeTables = nullPrototypeTables; }); + afterEach(() => { parserStyle.nullPrototypeTables = false; }); + + it.each(["missing", "empty"])("initializes %s config in an isolated home", (kind) => { + const temp = realpathSync(mkdtempSync(join(tmpdir(), "codex-trust-empty-"))); + try { + const cwd = join(temp, "project"); + const home = join(temp, "home"); + mkdirSync(cwd); + if (kind === "empty") { + mkdirSync(home); + writeFileSync(join(home, "config.toml"), ""); + } + trustCodexStartupRoot(home, cwd); + const path = join(home, "config.toml"); + const updated = readFileSync(path, "utf8"); + expect(parse(updated)).toEqual({ projects: { [cwd]: { trust_level: "trusted" } } }); + expect(statSync(path).mode & 0o777).toBe(0o600); + trustCodexStartupRoot(home, cwd); + expect(readFileSync(path, "utf8")).toBe(updated); + } finally { rmSync(temp, { recursive: true, force: true }); } + }); it.each(["missing", "table", "empty-table", "inline", "empty-inline", "inline-projects"])( "preserves managed comments and unrelated content with %s project configuration", (kind) => { const temp = realpathSync(mkdtempSync(join(tmpdir(), "codex-trust-comments-"))); @@ -60,15 +110,17 @@ describe("isolated Codex startup trust", () => { mkdirSync(home); writeFileSync( join(home, "config.toml"), - 'model = "test"\n[mcp_servers.fixture]\nurl = "http://localhost/example"\n', + 'model = "test"\nstarted = 2026-10-12T12:00:00Z\nlimits = [inf, nan, 9007199254740991]\n[mcp_servers.fixture]\nurl = "http://localhost/example"\n', ); + const original = readFileSync(join(home, "config.toml"), "utf8"); + const unrelated = parse(original); trustCodexStartupRoot(home, cwd); trustCodexStartupRoot(home, cwd); expect(parse(readFileSync(join(home, "config.toml"), "utf8"))).toEqual({ - model: "test", - mcp_servers: { fixture: { url: "http://localhost/example" } }, + ...unrelated, projects: { [cwd]: { trust_level: "trusted" } }, }); + expect(readFileSync(join(home, "config.toml"), "utf8")).toContain(original); expect(readFileSync(join(home, "config.toml"), "utf8")).not.toContain( `[projects."${temp}"]`, ); diff --git a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts index d85f002552..488b025148 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts @@ -92,24 +92,25 @@ export function trustCodexStartupRoot(codexHome: string, cwd: string): void { const path = join(codexHome, "config.toml"); const source = existsSync(path) ? readFileSync(path, "utf8") : ""; const config = parse(source); - const projects = config.projects ?? {}; + // Retain the parser's table prototypes so strict candidate comparison also + // works with parsers that return null-prototype TOML tables. + const projects = config.projects ?? parse(""); if ( typeof projects !== "object" || Array.isArray(projects) || projects instanceof Date ) throw new Error("codex_startup_trust_invalid_projects"); - const project = projects[root] ?? {}; + const project = projects[root] ?? parse(""); if ( typeof project !== "object" || Array.isArray(project) || project instanceof Date ) throw new Error("codex_startup_trust_invalid_project"); - config.projects = { - ...projects, - [root]: { ...project, trust_level: "trusted" }, - }; + project.trust_level = "trusted"; + projects[root] = project; + config.projects = projects; const updated = editTrust(source, root, config); if (updated === source) return; const temporary = resolve(codexHome, `config.toml.${randomUUID()}.tmp`); diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index fe7e4c7097..32103f88d9 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -46,6 +46,7 @@ export type { DurableRecoveryIdentity } from "./control-plane/prp-transport-type export * from "./drivers/codex/app-server-transport.js"; export * from "./drivers/codex/codex-app-server-driver.js"; export { resolveCodexCommand, resolvePinnedCodexCommand } from "./drivers/codex/codex-command.js"; +export { runnerBinaryTarget } from "./live/runner-binary.js"; export * from "./drivers/opencode/opencode-server-driver.js"; export * from "./drivers/opencode/mcp-bridge.js"; export * from "./drivers/acpx/qualified-profiles.js"; @@ -59,6 +60,7 @@ export * from "./drivers/acpx/sidecar-protocol.js"; export * from "./drivers/runner-tool-bridge.js"; export { createRunnerdCodexTransport, + probeNativeRunnerEnvironment, runnerCodexDynamicToolsFit, defaultCapabilityRunnerdBinary, readRunnerdArtifactBinding, diff --git a/packages/paperclip-runner/src/live/runner-binary.test.ts b/packages/paperclip-runner/src/live/runner-binary.test.ts index 914f443998..6f27822fc6 100644 --- a/packages/paperclip-runner/src/live/runner-binary.test.ts +++ b/packages/paperclip-runner/src/live/runner-binary.test.ts @@ -17,9 +17,9 @@ function binary(root: string, target: string | null, bytes: Buffer) { const file = join(folder, "paperclip-runnerd"); writeFileSync(file, bytes); return file; } describe("packaged native daemon target", () => { - it.each(["darwin-arm64", "darwin-x64", "linux-x64"])("selects %s from a universal package rather than the build-host legacy binary", target => { + it.each(["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"])("selects %s from a universal package rather than the build-host legacy binary", target => { const root = fixture(); binary(root, null, header("darwin-arm64")); - for (const t of ["darwin-arm64", "darwin-x64", "linux-x64"]) binary(root, t, header(t)); + for (const t of ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"]) binary(root, t, header(t)); const [platform, architecture] = target.split("-"); expect(runnerBinaryTarget(header(target))).toBe(target); expect(resolvePackagedRunnerBinary(join(root, "dist"), platform as NodeJS.Platform, architecture as typeof process.arch)).toBe(join(root, "dist", "bin", target, "paperclip-runnerd")); @@ -32,6 +32,16 @@ describe("packaged native daemon target", () => { const root = fixture(); binary(root, null, header("linux-x64")); binary(root, "linux-x64", header("darwin-arm64")); expect(() => resolvePackagedRunnerBinary(join(root, "dist"), "linux", "x64")).toThrow("runner_runtime_incompatible"); }); + it.each([["linux-x64", "arm64"], ["linux-arm64", "x64"]] as const)("selects remote bytes beside a %s controller for linux-%s", (controller, remoteArch) => { + const root = fixture(); + binary(root, null, header(controller)); + const remote = binary(root, `linux-${remoteArch}`, header(`linux-${remoteArch}`)); + expect(resolvePackagedRunnerBinary(join(root, "dist"), "linux", remoteArch)).toBe(remote); + }); + it.each([["linux-x64", "arm64"], ["linux-arm64", "x64"]] as const)("rejects a missing remote target instead of falling back to the %s controller for linux-%s", (controller, remoteArch) => { + const root = fixture(); binary(root, null, header(controller)); + expect(() => resolvePackagedRunnerBinary(join(root, "dist"), "linux", remoteArch)).toThrow("runner_runtime_incompatible"); + }); it("retains a matching local legacy build and permits the source-only debug fallback", () => { const root = fixture(); expect(resolvePackagedRunnerBinary(join(root, "dist"), "linux", "x64")).toBeNull(); const legacy = binary(root, null, header("linux-x64")); expect(resolvePackagedRunnerBinary(join(root, "dist"), "linux", "x64")).toBe(legacy); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index a64c51c26e..d33ff858a0 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -72,6 +72,7 @@ import { releaseMaterializedNativeRuntimeSkills } from "../drivers/runtime-conte import { RUNNERD_CANONICAL_ITEM } from "../drivers/codex/codex-driver-values.js"; import { + probeNativeRunnerEnvironment, parseAcpxTurnControlCapabilities, authorizedToolSetForProvider, createCapabilityRunnerdCodexTransport, @@ -2723,6 +2724,105 @@ function fakeCodexArgs(stateDirectory: string, ...args: string[]): string[] { ]; } +it.each(["complete", "api-key", "default-model", "mismatched-model", "model-refresh-error", "model-cleanup-error", "missing-model", "missing-runtime", "missing-completion", "late-bootstrap", "remote-complete", "remote-mismatched-model", "late-remote-preparation", "late-remote-release-error"] as const)("native Codex setup requires its daemon/app-server hello (%s)", async mode => { + const root = await mkdtemp(join(tmpdir(), "runnerd-native-setup-")); + const runtimeDirectory = join(root, "native-state"); + await mkdir(runtimeDirectory, { mode: 0o700 }); + const calls = join(root, "calls.jsonl"); + const sourceHome = join(root, "bound-account"); + await mkdir(sourceHome, { mode: 0o700 }); + await writeFile(join(sourceHome, "auth.json"), JSON.stringify({ OPENAI_API_KEY: mode === "api-key" ? "unrelated-login" : "selected-account" }), { mode: 0o600 }); + if (mode === "api-key") await writeFile(join(sourceHome, "config.toml"), 'model = "ambient-model"\n[mcp_servers.ambient]\ncommand = "untrusted-ambient-command"\n', { mode: 0o600 }); + const remote = mode.startsWith("remote-") || mode.startsWith("late-remote-"); + const remoteRoot = join(root, "assigned-remote-filesystem"); + const remoteFilesystem = join(remoteRoot, "filesystem"); + const remoteWorkspace = join(remoteFilesystem, "workspace"); + let registrationReleased = false; + let refreshed = false; + let cleanupConfirmed = false; + try { + const probe = probeNativeRunnerEnvironment({ + runtimeDirectory, provider: "codex", model: mode === "default-model" ? null : "gpt-6.1-sol", reasoningEffort: "low", + environment: { PATH: process.env.PATH, OPENAI_API_KEY: mode === "api-key" ? "selected-account" : "", HOME: "/unrelated/home" }, + timeoutMs: mode.startsWith("late-remote-") ? 800 : mode === "late-bootstrap" ? 100 : mode === "missing-completion" ? 1_000 : 10_000, + ...(remote ? { workingDirectory: remoteWorkspace } : {}), + transportOptions: { + ...(remote ? { + runnerFilesystemRoot: remoteFilesystem, runnerStateDirectory: join(remoteRoot, "runner"), + readRunnerState: async () => JSON.parse(await readFile(join(remoteRoot, "runner", "runner-state.json"), "utf8")), + controlPlaneRegistration: async authority => { + await mkdir(remoteWorkspace, { recursive: true, mode: 0o700 }); + const remoteHome = join(remoteFilesystem, "codex-home"); + await mkdir(remoteHome, { mode: 0o700 }); + for (const name of ["auth.json", "config.toml"] as const) await cp(join(runtimeDirectory, "codex-home", name), join(remoteHome, name)); + if (mode.startsWith("late-remote-")) await new Promise(resolve => setTimeout(resolve, 1000)); + await authority.start(); + return { connectUrl: authority.connectUrl, release: async () => { registrationReleased = true; await authority.stop(); if (mode === "late-remote-release-error") throw new Error("PRP registration release failed"); } }; + }, + } : {}), + runnerBinary: mode === "missing-runtime" ? join(root, "missing-runner") : resolve("runner/target/debug/paperclip-runnerd"), + codexCommand: fakeCodex, codexArgs: fakeCodexArgs(root, "--request-log", calls, + ...(mode === "missing-completion" ? ["--hold-turn"] : []), + ...(mode === "mismatched-model" || mode === "remote-mismatched-model" || mode === "model-refresh-error" || mode === "model-cleanup-error" ? ["--returned-model", "another-model"] : []), + ...(mode === "missing-model" ? ["--returned-model", ""] : []), + ...(mode === "late-bootstrap" ? ["--thread-start-delay-ms", "1000"] : [])), + sourceCodexHome: sourceHome, + }, + onCodexCredentialRefresh: async filename => { + expect(filename).toBe(join(remote ? remoteFilesystem : runtimeDirectory, "codex-home", "auth.json")); + expect(JSON.parse(await readFile(filename, "utf8"))).toEqual({ OPENAI_API_KEY: "selected-account" }); + if (mode === "api-key") { + const config = await readFile(join(runtimeDirectory, "codex-home", "config.toml"), "utf8"); + expect(config).not.toContain("ambient-model"); expect(config).not.toContain("untrusted-ambient-command"); + expect((await stat(filename)).mode & 0o777).toBe(0o600); + } + refreshed = true; + if (mode === "model-refresh-error") throw new Error("credential refresh failed after the native model error"); + }, + onCleanupConfirmed: async () => { + cleanupConfirmed = true; + expect(refreshed || mode === "missing-runtime" || mode === "late-bootstrap" || mode.startsWith("late-remote-")).toBe(true); + if (mode === "model-cleanup-error") throw new Error("owned temporary state cleanup failed"); + await rm(runtimeDirectory, { recursive: true, force: true }); + }, + }); + if (mode === "complete" || mode === "api-key" || mode === "default-model" || mode === "remote-complete") { + const observedModel = mode === "default-model" ? "gpt-test" : "gpt-6.1-sol"; + await expect(probe).resolves.toEqual({ provider: "codex", providerDriver: "codex_app_server", effectiveModel: observedModel, helloProbePassed: true }); + expect(refreshed).toBe(true); + if (remote) expect(registrationReleased).toBe(true); + expect(cleanupConfirmed).toBe(true); + await expect(stat(runtimeDirectory)).rejects.toThrow(); + const requests = (await readFile(calls, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + expect(requests.find(value => value.method === "thread/start").params).toMatchObject({ ...(remote ? { cwd: remoteWorkspace } : {}), permissions: "paperclip-runner-workspace-read-only", dynamicTools: [] }); + expect(requests.find(value => value.method === "turn/start").params).toMatchObject({ effort: "low", permissions: "paperclip-runner-workspace-read-only" }); + } else { + await expect(probe).rejects.toThrow(mode === "missing-runtime" ? /ENOENT|unavailable|cleanup/i : mode === "mismatched-model" || mode === "remote-mismatched-model" || mode === "model-refresh-error" || mode === "model-cleanup-error" || mode === "missing-model" ? /observed model|mismatched provider model/i : /timed out|cleanup|suspension/i); + if (mode === "late-remote-release-error") { + expect(registrationReleased).toBe(true); expect(cleanupConfirmed).toBe(false); + expect((await stat(runtimeDirectory)).isDirectory()).toBe(true); + } + if (mode === "model-refresh-error") { + expect(refreshed).toBe(true); + expect(cleanupConfirmed).toBe(false); + expect((await stat(runtimeDirectory)).isDirectory()).toBe(true); + } + if (mode === "model-cleanup-error") { + expect(cleanupConfirmed).toBe(true); + expect((await stat(runtimeDirectory)).isDirectory()).toBe(true); + } + if (mode === "mismatched-model" || mode === "remote-mismatched-model" || mode === "missing-model") { + expect(cleanupConfirmed).toBe(true); + await expect(stat(runtimeDirectory)).rejects.toThrow(); + } + if (mode === "mismatched-model" || mode === "remote-mismatched-model" || mode === "model-refresh-error" || mode === "model-cleanup-error" || mode === "missing-model" || mode === "late-bootstrap" || mode.startsWith("late-remote-")) { + const requests = (await readFile(calls, "utf8").catch(() => "")).trim().split("\n").filter(Boolean).map(line => JSON.parse(line)); + expect(requests.some(value => value.method === "turn/start")).toBe(false); + } + } + } finally { await rm(root, { recursive: true, force: true }); } +}, 20_000); + function assignedRuntimeContext( skillRoot: string, instructionRoot: string, diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 4d7be0fbe6..ff7dc3e8d5 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -39,6 +39,7 @@ import type { } from "../drivers/codex/app-server-transport.js"; import { createSanitizedCodexEnvironment } from "../drivers/codex/app-server-transport.js"; import { + CodexAppServerDriver, codexSemanticToolSpecs, createIsolatedCodexAppServerArgs, } from "../drivers/codex/codex-app-server-driver.js"; @@ -50,6 +51,7 @@ import type { NativeRunIdentity, NativeTurnControlCapabilities } from "../contra import type { PrpEvent } from "../protocol/replay-contract.js"; import { NativeSessionCloseUnrecoverableError } from "../contracts/native-session-backend.js"; import type { + HarnessSession, HarnessRuntimeRequestResolution, PersistedHarnessProviderIdentity, } from "../contracts/harness-driver.js"; @@ -3548,6 +3550,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { #deferredTurnStartEvents: DurableRecoveryCommittedEvent[] = []; #recoveryTurnBindingPending = false; #threadId = ""; + #observedProviderModel: string | undefined; #sessionId: string | null = null; #providerIdentity: Record | null = null; #turnControls: NativeTurnControlCapabilities = { steering: false, queuedFollowUp: false }; @@ -5005,6 +5008,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { externallySandboxed: provider === "codex" && this.options.externallySandboxed === true, + ...(provider === "codex" && params.permissions === "paperclip-runner-workspace-read-only" ? { readOnly: true } : {}), instructions: provider === "codex" ? withCodexCollaborationRuntimeInstructions( @@ -5112,6 +5116,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { const provider = this.options.provider ?? "codex"; const acpxAgent = this.options.acpxAgent ?? "codex"; return { + ...(provider === "codex" ? { model: this.#observedProviderModel } : {}), thread: { id: this.#threadId, sessionId: this.#sessionId, @@ -5949,6 +5954,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { "turn.start", { text: message, + ...((this.options.provider ?? "codex") === "codex" && params.effort !== undefined ? { effort: params.effort } : {}), ...(reasoningMode === undefined ? {} : { reasoningMode }), ...(skills.length ? { skills } : {}), turnId: pendingTurnId, @@ -6579,6 +6585,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { const started = record(record(event.envelope.payload).payload); const runtimeIdentity = record(started.runtimeIdentity); const descriptor = record(started.providerDescriptor); + if (typeof descriptor.effectiveModel === "string" && descriptor.effectiveModel.trim()) this.#observedProviderModel = descriptor.effectiveModel; const { processId: pid, threadId, @@ -7029,13 +7036,16 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { } } -export function defaultCapabilityRunnerdBinary(): string { +export function defaultCapabilityRunnerdBinary(platform = process.platform, architecture = process.arch): string { // Standalone builds use dist/live; the server vendors that compiled tree // directly under vendor/paperclip-runner. Resolve beside our own live module. const outputRoot = resolve(fileURLToPath(new URL("../", import.meta.url))); - const staged = resolvePackagedRunnerBinary(outputRoot) - ?? resolvePackagedRunnerBinary(resolve(packageRoot, "dist")); + const staged = resolvePackagedRunnerBinary(outputRoot, platform, architecture) + ?? resolvePackagedRunnerBinary(resolve(packageRoot, "dist"), platform, architecture); if (staged) return staged; + if (platform !== process.platform || architecture !== process.arch) { + throw new Error(`runner_remote_artifact_unavailable: install the Paperclip release containing the ${platform}-${architecture} daemon or configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH`); + } return resolve( packageRoot, `runner/target/debug/paperclip-runnerd${executableSuffix}`, @@ -7057,6 +7067,176 @@ export function createCapabilityRunnerdCodexTransport( export const createRunnerdCodexTransport = createCapabilityRunnerdCodexTransport; +/** A setup turn uses the same daemon/provider boundary as ordinary native runs. */ +export async function probeNativeRunnerEnvironment(options: { + runtimeDirectory: string; + provider: "codex"; + model: string | null; + reasoningEffort?: string; + environment: NodeJS.ProcessEnv; + timeoutMs?: number; + /** The selected remote filesystem supplies this cwd; never inspect it on the controller. */ + workingDirectory?: string; + transportOptions?: CapabilityRunnerdCodexTransportOptions; + onCodexCredentialRefresh?: (filename: string) => Promise; + onCleanupConfirmed?: () => Promise; +}): Promise<{ + provider: "codex"; + providerDriver: string; + effectiveModel: string; + helloProbePassed: true; +}> { + const environment = { ...options.environment }; + const remoteFilesystemRoot = options.transportOptions?.runnerFilesystemRoot; + if (options.workingDirectory !== undefined && !remoteFilesystemRoot) throw new Error("Native setup remote working directory requires a remote runner filesystem."); + const workingDirectory = options.workingDirectory ?? resolve(options.runtimeDirectory, "workspace"); + if (!remoteFilesystemRoot) mkdirSync(workingDirectory, { mode: 0o700 }); + const controller = new AbortController(); + let registrationTeardownError: unknown; + let registrationOperation: Promise | undefined; + const sourceCodexHome = options.transportOptions?.sourceCodexHome ?? resolveSourceCodexHome(environment); + // Never let an unbound home or an external-sandbox flag admit host state or + // weaken this read-only probe. The normal materializer copies bound auth and + // the managed provider projection into a new daemon-owned Codex home. + delete environment.PAPERCLIP_RUNNER_EXTERNAL_SANDBOX; + environment.HOME = resolve(remoteFilesystemRoot ?? options.runtimeDirectory, "probe-home"); + environment.CODEX_HOME = resolve(remoteFilesystemRoot ?? options.runtimeDirectory, "codex-home"); + environment.PAPERCLIP_WORKSPACE_CWD = workingDirectory; + const bundle = createRunnerdCodexTransport({ + ...options.transportOptions, + ...(options.transportOptions?.controlPlaneRegistration ? { controlPlaneRegistration: (authority, identity) => { + const operation = (async () => { + controller.signal.throwIfAborted(); + const registration = await options.transportOptions!.controlPlaneRegistration!(authority, identity); + if (controller.signal.aborted) { + try { await registration.release(); } + catch (error) { registrationTeardownError = error; throw error; } + controller.signal.throwIfAborted(); + } + return registration; + })(); + registrationOperation = operation; + return operation; + } } : {}), + ...(options.transportOptions?.runnerProcessLauncher ? { runnerProcessLauncher: spec => { + controller.signal.throwIfAborted(); + return options.transportOptions!.runnerProcessLauncher!(spec); + } } : {}), + provider: options.provider, + sourceCodexHome: sourceCodexHome ?? "", + stateDirectory: options.runtimeDirectory, + environment, + externallySandboxed: false, + closeGraceMs: 10_000, + lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null }, + }); + const driver = new CodexAppServerDriver({ + taskEnvelope: { + schema: "paperclip.skillless_task.v1", objective: "Verify the selected native account and model.", + completionContract: { revision: "environment-probe-v1", criteria: [] }, + constraints: [], expectedResultSchema: "paperclip.run_result.v1", + }, + ...(options.model ? { model: options.model } : {}), + ...(options.reasoningEffort ? { reasoningEffort: options.reasoningEffort } : {}), + conversationMode: "direct", + requestedCollaborationMode: "plan", + approvalPolicy: "never", + includeCollaborationModeInstructions: false, + dynamicTools: [], + environment, + transportFactory: () => bundle.transport, + requireProviderSessionIdentity: true, + ...(remoteFilesystemRoot ? { workingDirectoryAuthority: "remote_runner" as const } : {}), + }); + let rejectAborted!: (error: unknown) => void; + const aborted = new Promise((_resolve, reject) => { rejectAborted = reject; }); + const timer = setTimeout(() => { + const error = new Error("Native provider hello probe timed out."); + controller.abort(error); + rejectAborted(error); + }, Math.max(1, Math.min(options.timeoutMs ?? 45_000, 120_000))); + let session: HarnessSession | undefined; + let completed = false; + let effectiveModel: string | undefined; + let failure: unknown; + const operation = (async () => { + session = await driver.openSession({ runId: "environment-probe", normalizedSessionId: "environment-probe", + workingDirectory, signal: controller.signal }); + controller.signal.throwIfAborted(); + const ids = session.ids(); + const expectedDriver = "codex_app_server"; + if (!ids.driverSessionId || !ids.providerSessionId || bundle.evidence().providerDriver !== expectedDriver) { + throw new Error(`The native environment probe returned no matching provider identity (${bundle.evidence().providerDriver ?? "missing driver"}).`); + } + const events = session.events()[Symbol.asyncIterator](); + const first = await events.next(); + const context = record(first.value?.payload.context); + if (first.value?.eventType !== "session.started" || !context.model || record(record(context.sandbox).permissionProfile).id !== "paperclip-runner-workspace-read-only") { + throw new Error("The native environment probe returned no observed model or read-only runtime identity."); + } + { + effectiveModel = typeof context.model === "string" && context.model.trim() && context.model !== "unknown" ? context.model : undefined; + if (!effectiveModel || (options.model !== null && effectiveModel !== options.model)) throw new Error("The selected native runtime returned a missing or mismatched provider model."); + } + controller.signal.throwIfAborted(); + const { turnId } = await session.startTurn({ message: { role: "user", text: "Respond only with hello. Do not use tools or inspect files." } }); + let responseObserved = false; + while (true) { + const next = await events.next(); + if (next.done) break; + const event = next.value; + if (event.turnId !== turnId) continue; + if (event.eventType === "item.completed" && event.payload.kind === "agentMessage" + && typeof event.payload.text === "string" && event.payload.text.trim()) { + responseObserved = true; + } + if (event.eventType === "turn.completed") { + if (!responseObserved || !effectiveModel) throw new Error("The native provider hello probe returned no response or observed model."); + completed = true; + return; + } + if (event.eventType === "turn.failed" || event.eventType === "turn.interrupted" || event.eventType === "turn.cancelled") { + const detail = record(event.payload.error).message; + throw new Error(typeof detail === "string" ? detail : "The native provider hello probe failed."); + } + } + throw new Error("The native provider hello probe ended without a successful turn."); + })(); + try { + await Promise.race([operation, aborted]); + } catch (error) { + failure = error; + } finally { + clearTimeout(timer); + // Preserve private recovery state if the production close cannot confirm + // teardown; callers remove it only after an authoritative receipt. + const teardownErrors: unknown[] = []; + try { + if (session) await session.close({ reason: "environment probe complete" }); + else await bundle.transport.close("environment probe complete"); + if (bundle.evidence().runnerPid !== null && !bundle.evidence().runnerExited) throw new Error("Native setup probe cleanup is incomplete."); + } catch (error) { teardownErrors.push(error); } + await operation.catch(() => undefined); + await registrationOperation?.catch(() => undefined); + if (registrationTeardownError !== undefined) teardownErrors.push(registrationTeardownError); + if (bundle.evidence().runnerPid !== null && bundle.evidence().runnerExited && options.provider === "codex" && options.onCodexCredentialRefresh) { + try { await options.onCodexCredentialRefresh(resolve(remoteFilesystemRoot ?? options.runtimeDirectory, "codex-home", "auth.json")); } + catch (error) { teardownErrors.push(error); } + } + if (!teardownErrors.length && options.onCleanupConfirmed) { + try { await options.onCleanupConfirmed(); } + catch (error) { teardownErrors.push(error); } + } + if (teardownErrors.length) { + const errors = [...(failure === undefined ? [] : [failure]), ...teardownErrors]; + throw new AggregateError(errors, errors.map(error => error instanceof Error ? error.message : "Native probe cleanup failed.").join("; "), { cause: failure ?? teardownErrors[0] }); + } + } + if (failure !== undefined) throw failure; + if (!completed) throw new Error("The native provider hello probe did not complete."); + return Object.freeze({ provider: options.provider, providerDriver: bundle.evidence().providerDriver!, effectiveModel: effectiveModel!, helloProbePassed: true }); +} + /** The transport provider identifies the biller, independently of model family. */ function openedThreadModelProvider( provider: CapabilityRunnerdCodexTransportOptions["provider"], diff --git a/scripts/__tests__/grok-public-install-sandbox.test.mjs b/scripts/__tests__/grok-public-install-sandbox.test.mjs index 71f6971d9b..5b731c17a3 100644 --- a/scripts/__tests__/grok-public-install-sandbox.test.mjs +++ b/scripts/__tests__/grok-public-install-sandbox.test.mjs @@ -1,16 +1,64 @@ import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; import { isWorkspacePackage, parseWorkspaceEntries } from '../build-standalone-public-packages.mjs'; -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, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource } from '../grok-public-install-sandbox.mjs'; +import { verifyPiPackagedDaemon } from '../pi-public-install-probe.mjs'; +import { runnerBinaryTarget } from '../../packages/paperclip-runner/src/live/runner-binary.ts'; 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]] : []); +test('native host startup preserves the consumer lock and both executable byte identities', () => { + const before = { lockSha256: 'a'.repeat(64), runnerSha256: 'b'.repeat(64), codexCommandSha256: 'c'.repeat(64), codexNativeExecutableSha256: 'e'.repeat(64) }; + assert.doesNotThrow(() => assertInstalledNativeConsumerContinuity(before, { ...before })); + for (const field of Object.keys(before)) { + assert.throws(() => assertInstalledNativeConsumerContinuity(before, { ...before, [field]: 'd'.repeat(64) }), new RegExp(field)); + assert.throws(() => assertInstalledNativeConsumerContinuity({ ...before, [field]: '' }, { ...before, [field]: '' }), new RegExp(field)); + } + const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8'); + const arm = source.split('async function verifyLinuxArmConsumer')[1].split("if (process.argv[2] === '--linux-arm64-consumer')")[0]; + assert.match(arm, /const hostEnv = \{ PATH: .*HOME: home, TMPDIR: hostTmp, LC_ALL: 'C', TZ: 'UTC' \}/); + assert.match(arm, /execFileSync\(process.execPath, \[file\], \{ cwd: consumer, env: hostEnv/); + assert.ok(arm.indexOf('isolated(GROK_PUBLIC_INSTALL_LIFECYCLE)') < arm.indexOf("hostProbe('host-startup-probe.mjs'")); + assert.ok(arm.indexOf('const before =') < arm.indexOf("hostProbe('host-startup-probe.mjs'")); + assert.ok(arm.indexOf("hostProbe('host-startup-probe.mjs'") < arm.indexOf('const after =')); + assert.match(arm, /assertInstalledNativeConsumerContinuity\(before, after\)/); + assert.doesNotMatch(arm, /externallySandboxed|externalSandbox|sudo|sysctl|--privileged|seccomp=unconfined|unshare/); +}); + +test('Mac offline lifecycle uses only the matching executing Node SDK', () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'mac-node-headers-test-'))); + try { + const prefix = join(root, 'sdk'), executable = join(prefix, 'bin/node'), include = join(prefix, 'include/node'); + mkdirSync(join(prefix, 'bin'), { recursive: true }); mkdirSync(include, { recursive: true }); + writeFileSync(executable, 'fixture Node executable'); + writeFileSync(join(include, 'node.h'), '#include "node_version.h"\n'); + const header = '#define NODE_MAJOR_VERSION 24\n#define NODE_MINOR_VERSION 20\n#define NODE_PATCH_VERSION 0\n'; + writeFileSync(join(include, 'node_version.h'), header); + assert.equal(macNodeHeadersDirectory(executable, '24.20.0'), prefix); + assert.throws(() => macNodeHeadersDirectory(executable, '24.19.0'), /must match the executing Node/); + assert.throws(() => macNodeHeadersDirectory(executable, '24.20.0-custom'), /exact release version/); + writeFileSync(join(include, 'node_version.h'), header + '#define NODE_PATCH_VERSION 0\n'); + assert.throws(() => macNodeHeadersDirectory(executable, '24.20.0'), /declare one patch version/); + writeFileSync(join(include, 'node_version.h'), header); + rmSync(join(include, 'node.h')); + assert.throws(() => macNodeHeadersDirectory(executable, '24.20.0'), /ENOENT/); + writeFileSync(join(root, 'outside-node.h'), '#include "node_version.h"\n'); + symlinkSync(join(root, 'outside-node.h'), join(include, 'node.h')); + assert.throws(() => macNodeHeadersDirectory(executable, '24.20.0'), /inside the executing Node prefix/); + const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8'); + assert.ok(source.includes('macNodeHeadersDirectory(process.execPath, process.versions.node)')); + assert.ok(source.includes('consumer, { ...env, npm_config_nodedir: nodeHeaders }'), 'SDK is bound only to the deferred lifecycle environment'); + assert.equal(source.split('npm_config_nodedir:').length, 2); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + test('lifecycle execution has no network, host credentials, checkout, or elevated privileges', () => { const args = grokConsumerDockerArgs({ ...paths, command: GROK_PUBLIC_INSTALL_LIFECYCLE }); assert.deepEqual(values(args, '--network'), ['none']); @@ -85,7 +133,9 @@ test('verification never elevates PR-controlled provisioning or cleanup on the h test('the image and Grok downloads start before staging and join without weakening the npm checks', () => { const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8'); - const position = fragment => { const index = source.indexOf(fragment); assert.notEqual(index, -1, `verifier must contain ${fragment}`); return index; }; + // The optional Mac consumer owns separate scratch and no Linux prefetches. + const linuxSource = source.slice(source.indexOf("assert.equal(process.platform, 'linux'")); + const position = fragment => { const index = linuxSource.indexOf(fragment); assert.notEqual(index, -1, `verifier must contain ${fragment}`); return index; }; // Exactly the pinned consumer image is pulled, for the platform the probes run on. const imagePull = position("prefetch('image', 'docker', ['pull', '--platform', 'linux/amd64', GROK_PUBLIC_INSTALL_IMAGE])"); // Grok is provisioned into the prefetch directory, never straight into `native/grok`. @@ -109,7 +159,7 @@ test('the image and Grok downloads start before staging and join without weakeni // cleanup waits for them before removing the directory they write into. assert.ok(source.includes("import { startPrefetch, stopPrefetches } from './prefetch-child.mjs'")); assert.ok(position('await stopPrefetches(prefetches)') < position('rmSync(root, { recursive: true, force: true })')); - assert.equal(source.match(/rmSync\(root/g).length, 1); + assert.equal(linuxSource.match(/rmSync\(root/g).length, 1); }); test('the bundled-dependency installs run together while the other packages are copied, and pack in order', () => { @@ -137,7 +187,8 @@ test('the bundled-dependency installs run together while the other packages are const checks = position("for (const tarball of tarballs) assertNoBundledCodexPayloads(run('tar', ['-tzf', tarball])"); const copied = position('for (const tarball of tarballs) {'); assert.ok(bundledSources < start && start < registered && registered < otherSources && otherSources < join && join < packs && packs < reordered && reordered < checks && checks < copied); - assert.equal(source.match(/run\('npm', \['pack'/g).length, 1, 'only the lifecycle sentinel is packed in-process'); + const packagingLane = source.slice(position("} else {\nassert.equal(process.platform, 'linux'")); + assert.equal(packagingLane.match(/run\('npm', \['pack'/g).length, 1, 'only the lifecycle sentinel is packed in-process in the packaging lane'); assert.ok(source.includes("destination: join(root, `pack-${index}`)"), 'each pack writes into its own directory'); // The timing line reports the split staging phase, each install, and the // pack step with what the packs would have cost one after another. @@ -201,6 +252,14 @@ test('the installed Codex probe exercises the public export and rejects incomple const platformPath = `node_modules/@openai/codex-${target}`; const platform = join(consumer, platformPath); mkdirSync(platform, { recursive: true }); const nativeVendor = join(platform, 'vendor'); mkdirSync(nativeVendor); + const triple = { 'linux-x64': 'x86_64-unknown-linux-musl', 'linux-arm64': 'aarch64-unknown-linux-musl', + 'darwin-x64': 'x86_64-apple-darwin', 'darwin-arm64': 'aarch64-apple-darwin', 'win32-x64': 'x86_64-pc-windows-msvc', 'win32-arm64': 'aarch64-pc-windows-msvc' }[target]; + const nativeBytes = Buffer.alloc(64); + if (process.platform === 'darwin') { nativeBytes.writeUInt32LE(0xfeedfacf); nativeBytes.writeUInt32LE(process.arch === 'arm64' ? 0x0100000c : 0x01000007, 4); } + else if (process.platform === 'win32') { nativeBytes.set([0x4d, 0x5a]); nativeBytes.writeUInt32LE(40, 60); nativeBytes.writeUInt32LE(0x00004550, 40); nativeBytes.writeUInt16LE(process.arch === 'arm64' ? 0xaa64 : 0x8664, 44); } + else { nativeBytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); nativeBytes.writeUInt16LE(process.arch === 'arm64' ? 183 : 62, 18); } + const nativeFile = join(nativeVendor, triple, 'bin', process.platform === 'win32' ? 'codex.exe' : 'codex'); + mkdirSync(join(nativeVendor, triple, 'bin'), { recursive: true }); writeFileSync(nativeFile, nativeBytes, { mode: 0o755 }); const manifest = { name: '@openai/codex', version: `0.160.0-${target}`, os: [process.platform], cpu: [process.arch] }; writeFileSync(join(platform, 'package.json'), JSON.stringify(manifest)); const entry = { name: manifest.name, version: manifest.version, @@ -209,6 +268,8 @@ test('the installed Codex probe exercises the public export and rejects incomple writeFileSync(join(consumer, 'package-lock.json'), JSON.stringify(lock)); const server = join(consumer, 'node_modules/@paperclipai/server'); const vendor = join(server, 'dist/vendor/paperclip-runner'), drivers = join(vendor, 'drivers/acpx'); mkdirSync(drivers, { recursive: true }); + mkdirSync(join(vendor, 'live')); + writeFileSync(join(vendor, 'live/runner-binary.js'), `export const runnerBinaryTarget = ${runnerBinaryTarget.toString()};`); writeFileSync(join(server, 'package.json'), JSON.stringify({ name: '@paperclipai/server', type: 'module' })); const integrity = join(drivers, 'installation-integrity.js'); const security = join(vendor, 'drivers/codex/codex-security-config.js'); mkdirSync(join(vendor, 'drivers/codex')); @@ -233,6 +294,24 @@ test('the installed Codex probe exercises the public export and rejects incomple assert.equal(JSON.parse(result.stdout).pinnedCodexCommandVerified, true); assert.equal(JSON.parse(result.stdout).codexPlatformPackageSource, 'official npm'); assert.equal(JSON.parse(result.stdout).codexConsumerPlatform, target); + assert.equal(JSON.parse(result.stdout).codexCommandSha256, createHash('sha256').update(readFileSync(command)).digest('hex')); + assert.equal(JSON.parse(result.stdout).codexNativeExecutableSha256, createHash('sha256').update(nativeBytes).digest('hex')); + const original = JSON.parse(result.stdout); + nativeBytes[63] = 1; writeFileSync(nativeFile, nativeBytes); + result = run(); assert.equal(result.status, 0, result.stderr); + const changed = JSON.parse(result.stdout); + assert.equal(changed.codexCommandSha256, original.codexCommandSha256, 'native mutation does not change the npm launcher'); + assert.notEqual(changed.codexNativeExecutableSha256, original.codexNativeExecutableSha256); + assert.throws(() => assertInstalledNativeConsumerContinuity({ lockSha256: 'a'.repeat(64), runnerSha256: 'b'.repeat(64), ...original }, + { lockSha256: 'a'.repeat(64), runnerSha256: 'b'.repeat(64), ...changed }), /codexNativeExecutableSha256/); + nativeBytes[63] = 0; writeFileSync(nativeFile, nativeBytes); + rmSync(nativeFile); + result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /one native executable/); + const foreignNative = join(root, 'foreign-codex'); writeFileSync(foreignNative, nativeBytes, { mode: 0o755 }); + symlinkSync(foreignNative, nativeFile); + result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /bounded regular file in its selected platform package/); + rmSync(nativeFile); writeFileSync(nativeFile, nativeBytes, { mode: 0o755 }); + result = run(); assert.equal(result.status, 0, result.stderr); assert.equal(JSON.parse(result.stdout).codexQualifiedInstallationVerified, true); assert.equal(JSON.parse(result.stdout).codexCommandLeaseVerified, true); assert.equal(JSON.parse(result.stdout).codexNativeSandboxResourcesVerified, true); @@ -253,6 +332,20 @@ 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')); + const legacyNative = join(legacy, 'vendor', triple, 'codex', process.platform === 'win32' ? 'codex.exe' : 'codex'); + mkdirSync(join(legacy, 'vendor', triple, 'codex'), { recursive: true }); writeFileSync(legacyNative, nativeBytes, { mode: 0o755 }); + 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)}; };`); @@ -308,6 +401,7 @@ test('actual tar member checks allow Codex JavaScript but reject nested platform assert.doesNotThrow(() => assertNoBundledCodexPayloads(javascript)); for (const payload of [ 'package/node_modules/@openai/codex-linux-x64/package.json', + 'package/node_modules/@openai/codex-linux-arm64/package.json', 'package/node_modules/bridge/node_modules/@openai/codex-darwin-arm64/package.json', 'package/node_modules/@openai/codex-win32-x64/package.json', 'package/node_modules/@openai/codex/vendor/x86_64-unknown-linux-musl/codex/codex', @@ -320,24 +414,129 @@ test('actual tar member checks allow Codex JavaScript but reject nested platform }); test('consumer provenance rejects bundled, foreign, inconsistent and non-official Codex payloads', () => { - const path = 'node_modules/@openai/codex-linux-x64'; - const manifest = { name: '@openai/codex', version: '0.160.0-linux-x64', os: ['linux'], cpu: ['x64'] }; + for (const target of ['linux-x64', 'linux-arm64']) { + const path = `node_modules/@openai/codex-${target}`; + const manifest = { name: '@openai/codex', version: `0.160.0-${target}`, os: ['linux'], cpu: [target.slice('linux-'.length)] }; const entry = { version: manifest.version, resolved: `https://registry.npmjs.org/@openai/codex/-/codex-${manifest.version}.tgz`, integrity: `sha512-${Buffer.alloc(64, 1).toString('base64')}` }; - const check = (value = entry, installed = [{ path, manifest }]) => assertInstalledCodexPlatformProvenance({ packages: { [path]: value } }, installed, 'linux-x64'); + const check = (value = entry, installed = [{ path, manifest }]) => assertInstalledCodexPlatformProvenance({ packages: { [path]: value } }, installed, target); assert.equal(check().codexConsumerHostOnly, true); - const legacyManifest = { ...manifest, version: '0.156.1-linux-x64' }; + const legacyManifest = { ...manifest, version: `0.156.1-${target}` }; assert.equal(check({ ...entry, version: legacyManifest.version, - resolved: 'https://registry.npmjs.org/@openai/codex/-/codex-0.156.1-linux-x64.tgz' }, [{ path, manifest: legacyManifest }]).codexConsumerHostOnly, true); - assert.throws(() => check(entry, [{ path, manifest: { ...manifest, version: '^0.160.0-linux-x64' } }]), /exact host version/); + resolved: `https://registry.npmjs.org/@openai/codex/-/codex-0.156.1-${target}.tgz` }, [{ path, manifest: legacyManifest }]).codexConsumerHostOnly, true); + assert.throws(() => check(entry, [{ path, manifest: { ...manifest, version: `^0.160.0-${target}` } }]), /exact host version/); for (const change of [ - { inBundle: true }, { link: true }, { version: '0.156.1-linux-x64' }, - { resolved: 'https://example.com/@openai/codex/-/codex-0.160.0-linux-x64.tgz' }, - { resolved: 'https://registry.npmjs.org/@openai/codex/-/codex-0.156.1-linux-x64.tgz' }, - { resolved: 'https://user@registry.npmjs.org/@openai/codex/-/codex-0.160.0-linux-x64.tgz' }, + { inBundle: true }, { link: true }, { version: `0.156.1-${target}` }, + { resolved: `https://example.com/@openai/codex/-/codex-0.160.0-${target}.tgz` }, + { resolved: `https://registry.npmjs.org/@openai/codex/-/codex-0.156.1-${target}.tgz` }, + { resolved: `https://user@registry.npmjs.org/@openai/codex/-/codex-0.160.0-${target}.tgz` }, { integrity: undefined }, { integrity: 'sha512-AA==' }, ]) assert.throws(() => check({ ...entry, ...change })); assert.throws(() => check(entry, []), /must install its host/); 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.match(source, /'linux-arm64'/); + assert.doesNotMatch(source, /PAPERCLIP_RUNNERD_BINARY|runner\/target\/debug|execFileSync\('paperclip-runnerd'/); +}); + +test('native ARM clean consumers keep the existing container and installed-runtime boundaries', () => { + const args = grokConsumerDockerArgs({ ...paths, platform: 'linux/arm64', command: GROK_PUBLIC_INSTALL_LIFECYCLE }); + assert.deepEqual(values(args, '--platform'), ['linux/arm64']); + assert.deepEqual(values(args, '--network'), ['none']); + assert.deepEqual(values(args, '--user'), ['1001:1001']); + assert.ok(args.includes('--read-only')); + assert.deepEqual(values(args, '--cap-drop'), ['ALL']); + assert.deepEqual(values(args, '--security-opt'), ['no-new-privileges']); + assert.ok(values(args, '--mount').some(value => value.endsWith('dst=/packages,readonly'))); + assert.throws(() => grokConsumerDockerArgs({ ...paths, platform: 'linux/arm/v7', command: [] }), /supported native Linux platform/); + const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8'); + const arm = source.split('async function verifyLinuxArmConsumer(')[1].split("if (process.argv[2] === '--linux-arm64-consumer')")[0]; + assert.match(arm, /process.platform \+ '-' \+ process.arch, 'linux-arm64'/); + assert.match(arm, /const platform = 'linux\/arm64'/); + assert.match(arm, /isolated\(GROK_PUBLIC_INSTALL_LIFECYCLE\)/); + assert.match(arm, /installedCodexProbeSource\(installed \+ '\/index.js', '\/consumer', undefined, \{ nativeCodex: true \}\)/); + assert.match(arm, /tests\/runner-e2e\/installed-cli.ts/); + assert.match(arm, /process.versions.node.split\('\.'\)\[0\], '24'/); + assert.match(arm, /macInstalledStartupProbeSource\(consumer, hostServer, daemon.sha256, join\(assets, 'installed-cli.ts'\)\)/); + assert.match(arm, /startupEnvironment: 'native host, same installed graph, isolated empty homes'/); + assert.doesNotMatch(arm, /stage-runner|runner\/target|PAPERCLIP_RUNNER_BINARY|prerequisite:/); +}); + + +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/); +}); + +test('Pi admission verifies assembled host daemon identity and preserves the flat source-package control', async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'pi-installed-daemon-'))), server = join(root, 'server'); + const output = join(server, 'dist/vendor/paperclip-runner'), target = 'linux-x64', source = 'a'.repeat(40); + const binary = join(output, 'bin', target, 'paperclip-runnerd'), manifestPath = join(output, 'bin/release-manifest.json'); + const bytes = Buffer.alloc(64); bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(62, 18); + const sha256 = value => `sha256:${createHash('sha256').update(value).digest('hex')}`; + const manifest = { schema: 'paperclip.runner.release-binaries.v1', sourceRevision: source, + platforms: { [target]: { path: `${target}/paperclip-runnerd`, sha256: sha256(bytes) } } }; + const check = () => verifyPiPackagedDaemon(server, binary, target, runnerBinaryTarget); + try { + mkdirSync(join(output, 'bin', target), { recursive: true }); + writeFileSync(binary, bytes); writeFileSync(join(server, 'dist/build-info.json'), JSON.stringify({ commit: source })); + writeFileSync(manifestPath, JSON.stringify(manifest)); + await check(); + for (const changed of [null, { ...manifest, schema: 'untrusted' }, { ...manifest, sourceRevision: 'b'.repeat(40) }, + { ...manifest, platforms: { [target]: { ...manifest.platforms[target], path: '../outside' } } }, + { ...manifest, platforms: { [target]: { ...manifest.platforms[target], sha256: 'sha256:' + '0'.repeat(64) } } }]) { + writeFileSync(manifestPath, JSON.stringify(changed)); await assert.rejects(check()); + } + const wrongHeader = Buffer.from(bytes); wrongHeader.writeUInt16LE(183, 18); + writeFileSync(binary, wrongHeader); + writeFileSync(manifestPath, JSON.stringify({ ...manifest, platforms: { [target]: { ...manifest.platforms[target], sha256: sha256(wrongHeader) } } })); + await assert.rejects(check(), /linux-arm64/); + writeFileSync(binary, bytes); writeFileSync(manifestPath, JSON.stringify(manifest)); + const outside = join(root, 'outside'); writeFileSync(outside, bytes); + rmSync(binary); symlinkSync(outside, binary); await assert.rejects(check()); + rmSync(binary); writeFileSync(binary, bytes); rmSync(manifestPath); + const flat = join(output, 'bin/paperclip-runnerd'); writeFileSync(flat, bytes); + await verifyPiPackagedDaemon(server, flat, target, runnerBinaryTarget); + await assert.rejects(check(), /strictly equal/); + writeFileSync(manifestPath, 'null'); + await assert.rejects(verifyPiPackagedDaemon(server, flat, target, runnerBinaryTarget), /manifest must be an object/); + } finally { rmSync(root, { recursive: true, force: true }); } }); diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs index 9d00a15b79..2aa415cd20 100644 --- a/scripts/__tests__/release-verify-workflow.test.mjs +++ b/scripts/__tests__/release-verify-workflow.test.mjs @@ -1,8 +1,10 @@ import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; +import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import test from "node:test"; import { createServer } from "node:http"; @@ -81,14 +83,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. @@ -742,6 +744,399 @@ test("direct protocol concurrency override only lowers the configured ceiling", } }); +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]; + const targets = workflow.split(" runner_binary_targets:\n")[1].split(" runner_binaries:\n")[0]; + for (const [target, host] of [["linux-arm64", "ubuntu-24.04-arm"], ["darwin-arm64", "macos-15"], ["darwin-x64", "macos-15-intel"]]) { + assert.ok(targets.includes(`'${target}': '${host}'`)); + } + assert.match(producer, /matrix: \$\{\{ fromJSON\(needs.runner_binary_targets.outputs.matrix\) \}\}/); + assert.match(targets, /Unknown exact-source daemon platform contract/); + assert.match(targets, /RELEASE_RUNNER_TARGETS/); + assert.doesNotMatch(targets, /secrets\.|id-token: write|contents: write|npm publish|environment:/); + 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, /\['native', \['build:binary'\]/); + const runner = JSON.parse(readFileSync(path.join(repoRoot, 'packages/paperclip-runner/package.json'), 'utf8')); + assert.match(runner.scripts['build:binary'], /cargo build --release --manifest-path runner\/Cargo.toml --locked/); + assert.match(runner.scripts['build:binary'], /&& node scripts\/stage-runner-binary.mjs$/); + 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/); +}); + +function nativeProducer() { + return readWorkflow('release-verify.yml').split(' runner_binaries:\n')[1]; +} + +test('native dependency caches are target-bound and unmerged master callers cannot save them', () => { + const producer = nativeProducer(); + const cache = producer.split(' - name: Cache native Rust dependencies\n')[1].split('\n - ')[0]; + assert.match(cache, /Swatinem\/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6/); + assert.match(cache, /workspaces: \$\{\{ steps.native_rust_workspace.outputs.path \}\} -> target/); + assert.match(cache, /shared-key: release-native-v2-\$\{\{ matrix.host \}\}-\$\{\{ matrix.target \}\}-\$\{\{ hashFiles\('packages\/paperclip-runner\/protocol\/\*\*', 'packages\/paperclip-runner\/src\/\*\*', 'packages\/paperclip-runner\/\*.json'\) \}\}/); + for (const option of ['cache-workspace-crates', 'cache-bin', 'cache-on-failure']) { + assert.match(cache, new RegExp(option + ': false')); + } + const expression = cache.match(/save-if: \$\{\{ (.+) \}\}/)?.[1]; + assert.ok(expression, 'cache writes need a separate source trust gate'); + const canSave = new Function('github', 'inputs', `return (${expression});`); + const github = { repository: 'paperclipai/paperclip', repository_id: '1170821064', event_name: 'push', ref: 'refs/heads/master', sha: 'a'.repeat(40) }; + assert.equal(canSave(github, { ref: github.sha }), true); + for (const [overrides, input] of [ + [{}, 'b'.repeat(40)], [{}, ''], + [{ event_name: 'workflow_dispatch' }, github.sha], + [{ event_name: 'workflow_dispatch' }, 'b'.repeat(40)], + [{ event_name: 'pull_request', ref: 'refs/pull/123/merge' }, github.sha], + [{ ref: 'refs/heads/topic' }, github.sha], + [{ repository: 'fork/paperclip' }, github.sha], + [{ repository_id: '1' }, github.sha], + ]) assert.equal(canSave({ ...github, ...overrides }, { ref: input }), false); + assert.match(producer, /pinned="\$HOME\/paperclip-runner-rust"/); + assert.match(producer, /ln -s "\$GITHUB_WORKSPACE\/packages\/paperclip-runner\/runner" "\$pinned"/); + assert.match(producer, /echo "RUSTUP_TOOLCHAIN=\$toolchain" >> "\$GITHUB_ENV"/); + assert.doesNotMatch(producer, /cache-hit|continue-on-error/); + assert.ok(producer.indexOf('Bind immutable producer source and target') < producer.indexOf('Cache native Rust dependencies')); + assert.ok(producer.indexOf('Build TypeScript contract and host daemon concurrently') < producer.indexOf('Record actual daemon metadata and immutable bytes')); +}); + +function nativePreparationScript() { + const step = nativeProducer().split(' - name: Build TypeScript contract and host daemon concurrently\n')[1].split('\n - ')[0]; + const script = step.match(/node --input-type=module <<'NODE'\n([\s\S]*?)\n NODE/)?.[1]; + assert.ok(script); + return script.replace(/^ {10}/gm, ''); +} + +const fixtureProcessGone = pid => { + try { process.kill(pid, 0); } catch (error) { return error.code === 'ESRCH'; } + try { return /^\d+ \(.*\) Z /.test(readFileSync(`/proc/${pid}/stat`, 'utf8')); } catch { return false; } +}; +const waitForFixture = async (predicate, label) => { + for (let attempt = 0; attempt < 200; attempt++) { + if (predicate()) return; + await new Promise(resolve => setTimeout(resolve, 25)); + } + assert.fail(label); +}; + +// Execute the workflow's actual coordinator with two tiny local Node commands. +// Both must cross the start barrier before either can finish; no pnpm, Cargo, +// provider, registry, or Docker build runs in these fixtures. +async function withNativePreparationFixture(mode, check) { + const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'native-preparation-'))); + let controller; + try { + for (const directory of ['scripts', 'tools', 'packages/paperclip-runner', 'logs']) { + mkdirSync(path.join(root, directory), { recursive: true }); + } + for (const helper of ['prefetch-child.mjs', 'prefetch-supervisor.mjs']) { + copyFileSync(path.join(repoRoot, 'scripts', helper), path.join(root, 'scripts', helper)); + } + writeFileSync(path.join(root, 'prepare.mjs'), nativePreparationScript()); + writeFileSync(path.join(root, 'tools/pnpm'), `#!${process.execPath} +const { spawn } = require('node:child_process'); +const { existsSync, writeFileSync } = require('node:fs'); +const { join } = require('node:path'); +const root = process.env.FIXTURE_ROOT; +const name = process.argv.includes('build:binary') ? 'native' : 'typescript'; +writeFileSync(join(root, name + '.started'), JSON.stringify({ pid: process.pid, cwd: process.cwd(), args: process.argv.slice(2) })); +const wait = setInterval(() => { + if (!existsSync(join(root, 'native.started')) || !existsSync(join(root, 'typescript.started'))) return; + clearInterval(wait); + if (process.env.FIXTURE_MODE === 'success') { + setTimeout(() => { writeFileSync(join(root, name + '.finished'), 'joined'); console.log(name + ' complete'); }, 150); + return; + } + const child = spawn(process.execPath, ['-e', \ + "const fs=require('node:fs'); fs.writeFileSync(process.argv[1], String(process.pid)); setInterval(() => fs.writeFileSync(process.argv[2], String(Date.now())), 25)",\ + join(root, name + '.descendant'), join(root, name + '.writing')], { stdio: 'inherit' }); + child.on('exit', () => process.exit(0)); + const barrier = setInterval(() => { + if (!existsSync(join(root, 'native.descendant')) || !existsSync(join(root, 'typescript.descendant'))) return; + clearInterval(barrier); + if (process.env.FIXTURE_MODE === name) { console.error(name + ' failed'); process.exit(23); } + }, 10); +}, 10); +`, { mode: 0o755 }); + controller = spawn(process.execPath, ['prepare.mjs'], { cwd: root, env: { + ...process.env, PATH: `${path.join(root, 'tools')}:${process.env.PATH}`, RUNNER_TEMP: path.join(root, 'logs'), FIXTURE_ROOT: root, FIXTURE_MODE: mode, + }, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = '', stderr = ''; + controller.stdout.on('data', chunk => { stdout += chunk; }); + controller.stderr.on('data', chunk => { stderr += chunk; }); + const completed = new Promise((resolve, reject) => { + controller.once('error', reject); + controller.once('close', (status, signal) => resolve({ status, signal, stdout, stderr })); + }); + await check({ root, controller, completed }); + } finally { + if (controller?.exitCode === null && controller.signalCode === null) controller.kill('SIGKILL'); + for (const name of ['native', 'typescript']) { + for (const filename of [name + '.started', name + '.descendant']) { + const file = path.join(root, filename); + if (!existsSync(file)) continue; + const data = readFileSync(file, 'utf8'); + const pid = filename.endsWith('.started') ? JSON.parse(data).pid : Number(data); + if (!fixtureProcessGone(pid)) { + try { process.kill(pid, 'SIGKILL'); } catch (error) { if (error.code !== 'ESRCH') throw error; } + } + } + } + rmSync(root, { recursive: true, force: true }); + } +} + +test('native preparation overlaps independent outputs and joins both before recording', async () => { + await withNativePreparationFixture('success', async ({ root, completed }) => { + const result = await completed; + assert.equal(result.status, 0, result.stderr); + for (const name of ['native', 'typescript']) assert.equal(readFileSync(path.join(root, name + '.finished'), 'utf8'), 'joined'); + assert.equal(JSON.parse(readFileSync(path.join(root, 'native.started'), 'utf8')).cwd, path.join(root, 'packages/paperclip-runner')); + assert.deepEqual(JSON.parse(readFileSync(path.join(root, 'native.started'), 'utf8')).args, ['build:binary']); + assert.deepEqual(JSON.parse(readFileSync(path.join(root, 'typescript.started'), 'utf8')).args, ['--filter', '@paperclipai/paperclip-runner', 'build:typescript']); + const results = result.stdout.split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line)); + assert.deepEqual(results.map(item => [item.build, item.status, item.exitCode]), [['typescript', 'fulfilled', 0], ['native', 'fulfilled', 0]]); + }); +}); + +for (const name of ['typescript', 'native']) { + test(`${name} preparation failure fails the join and retires both owned process trees`, async () => { + await withNativePreparationFixture(name, async ({ root, completed }) => { + const result = await completed; + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, new RegExp(name + ' prefetch failed \\(exit 23\\)')); + const results = result.stdout.split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line)); + assert.equal(results.length, 2); + assert.ok(results.every(item => item.status === 'rejected')); + for (const build of ['native', 'typescript']) { + const pid = Number(readFileSync(path.join(root, build + '.descendant'), 'utf8')); + await waitForFixture(() => fixtureProcessGone(pid), build + ' descendant outlived failed join'); + } + }); + }); +} + +test('canceling native preparation retires both supervisors and their descendants', { skip: process.platform === 'win32' }, async () => { + await withNativePreparationFixture('cancel', async ({ root, controller, completed }) => { + await waitForFixture(() => ['native', 'typescript'].every(name => existsSync(path.join(root, name + '.descendant'))), 'both native build trees must start'); + controller.kill('SIGTERM'); + const result = await completed; + assert.equal(result.signal, 'SIGTERM'); + for (const name of ['native', 'typescript']) { + const pid = Number(readFileSync(path.join(root, name + '.descendant'), 'utf8')); + await waitForFixture(() => fixtureProcessGone(pid), name + ' descendant outlived cancellation'); + } + }); +}); + +test("premerge native 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, /--linux-arm64-consumer/); + assert.match(qualification, /fromJSON\(needs.qualification_daemons.outputs.native_consumer_matrix\)/); + assert.match(qualification, /pnpm install --ignore-scripts --no-frozen-lockfile/); +}); + +test("native ARM qualification prepares its scoped distro sandbox before the existing consumer", () => { + const smoke = readWorkflow("release-smoke.yml"); + const native = smoke.split(" qualification_native:\n")[1]; + const preparation = native.split(" - name: Prepare Linux ARM sandbox prerequisites\n")[1].split("\n - ")[0]; + assert.match(preparation, /if: \$\{\{ matrix\.target == 'linux-arm64' \}\}/); + assert.match(preparation, /timeout-minutes: 5/); + assert.match(preparation, /bash \.qualification-tooling\/scripts\/prepare-linux-arm64-qualification\.sh/); + const tooling = native.split(" - name: Checkout immutable ARM qualification tooling\n")[1].split("\n - ")[0]; + assert.match(tooling, /if: \$\{\{ matrix\.target == 'linux-arm64' \}\}/); + assert.match(tooling, /repository: \$\{\{ job\.workflow_repository \}\}/); + assert.match(tooling, /ref: \$\{\{ job\.workflow_sha \}\}/); + assert.match(tooling, /sparse-checkout: scripts\/prepare-linux-arm64-qualification\.sh/); + assert.match(tooling, /sparse-checkout-cone-mode: false/); + assert.match(tooling, /persist-credentials: false/); + assert.doesNotMatch(tooling + preparation, /github\.workflow_sha|github\.sha|ref: (?:master|main)|ref: \$\{\{ inputs\.qualification_source_sha/); + assert.ok(native.indexOf("Checkout immutable ARM qualification tooling") < native.indexOf("Prepare Linux ARM sandbox prerequisites")); + assert.ok(native.indexOf("Prepare Linux ARM sandbox prerequisites") < native.indexOf("Verify host identity and actual clean npm consumer")); + assert.match(native, /path: \|\n\s+\$\{\{ runner\.temp \}\}\/native-public-install\.json\n\s+\$\{\{ runner\.temp \}\}\/paperclip-arm-bwrap-prerequisite\//); + assert.doesNotMatch(smoke.split(" qualification_daemons:\n")[0], /prepare-linux-arm64-qualification/, + "ordinary service and Docker smoke keep their existing setup"); +}); + +test("ARM qualification binds separate product and reusable-workflow revisions even when the product lacks the helper", { skip: process.platform === "win32" }, () => { + const native = readWorkflow("release-smoke.yml").split(" qualification_native:\n")[1]; + const step = native.split(" - name: Prepare Linux ARM sandbox prerequisites\n")[1].split("\n - ")[0]; + const script = step.split(" run: |\n")[1].split("\n").map(line => line.startsWith(" ") ? line.slice(10) : line).join("\n"); + const root = mkdtempSync(path.join(tmpdir(), "arm-separate-tooling-")); + try { + const tools = path.join(root, "tools"); mkdirSync(tools); + const tooling = path.join(root, ".qualification-tooling", "scripts"); mkdirSync(tooling, { recursive: true }); + const helper = 'printf "%s\\n" "$TOOLING_SHA" > "$FIXTURE_PREPARED"\n'; + writeFileSync(path.join(tooling, "prepare-linux-arm64-qualification.sh"), helper); + writeFileSync(path.join(tools, "git"), '#!/bin/bash\nif [[ "$1" == -C ]]; then printf "%s\\n" "$FIXTURE_TOOLING_HEAD"; else printf "%s\\n" "$FIXTURE_PRODUCT_HEAD"; fi\n', { mode: 0o755 }); + const productSha = "a".repeat(40), toolingSha = "b".repeat(40); + for (const [productHead, toolingHead, expectedStatus] of [[productSha, toolingSha, 0], ["c".repeat(40), toolingSha, 1], [productSha, "c".repeat(40), 1], [productSha, "invalid", 1]]) { + const report = path.join(root, "report"); mkdirSync(report); + const prepared = path.join(root, "prepared"); + const result = spawnSync("/bin/bash", ["-c", script], { cwd: root, encoding: "utf8", env: { + PATH: `${tools}:${path.dirname(process.execPath)}:/usr/bin:/bin`, RUNNER_TEMP: report, + SOURCE_SHA: productSha, TOOLING_SHA: toolingSha, TOOLING_REPOSITORY: "paperclipai/paperclip", + TOOLING_WORKFLOW_REF: "paperclipai/paperclip/.github/workflows/release-smoke.yml@refs/heads/qualification", + TOOLING_WORKFLOW_PATH: ".github/workflows/release-smoke.yml", + FIXTURE_PRODUCT_HEAD: productHead, FIXTURE_TOOLING_HEAD: toolingHead, FIXTURE_PREPARED: prepared, + // Reusable calls retain the caller context; it must not select tooling. + GITHUB_REPOSITORY: "different/caller", GITHUB_SHA: "d".repeat(40), + } }); + assert.equal(result.status, expectedStatus, result.stderr); + assert.equal(existsSync(path.join(root, "scripts", "prepare-linux-arm64-qualification.sh")), false); + assert.equal(existsSync(prepared), expectedStatus === 0); + if (expectedStatus === 0) { + assert.equal(readFileSync(prepared, "utf8").trim(), toolingSha); + const identity = JSON.parse(readFileSync(path.join(report, "paperclip-arm-bwrap-prerequisite", "qualification-identity.json"), "utf8")); + assert.equal(identity.productSourceSha, productSha); + assert.equal(identity.toolingSourceSha, toolingSha); + assert.equal(identity.toolingRepository, "paperclipai/paperclip"); + assert.equal(identity.toolingWorkflowPath, ".github/workflows/release-smoke.yml"); + assert.equal(identity.helperSha256, createHash("sha256").update(helper).digest("hex")); + } + rmSync(report, { recursive: true, force: true }); + rmSync(prepared, { force: true }); + } + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("Linux ARM prerequisite setup rejects other hosts before invoking tools and preserves scoped security", { skip: process.platform === "win32" }, () => { + const helper = path.join(repoRoot, "scripts/prepare-linux-arm64-qualification.sh"); + const source = readFileSync(helper, "utf8"); + const root = mkdtempSync(path.join(tmpdir(), "arm-prerequisite-guard-")); + try { + const tools = path.join(root, "tools"); mkdirSync(tools); + const sentinel = path.join(root, "invoked"); + for (const command of ["dpkg", "uname", "mkdir", "mktemp", "sysctl", "sudo"]) { + writeFileSync(path.join(tools, command), '#!/bin/bash\nprintf "%s\\n" "$0" >> "$PREREQUISITE_SENTINEL"\nexit 85\n', { mode: 0o755 }); + } + for (const [actions, runnerOs] of [["", "Linux"], ["false", "Linux"], ["true", "macOS"], ["true", "Windows"]]) { + const result = spawnSync("/bin/bash", [helper], { encoding: "utf8", env: { + PATH: tools, GITHUB_ACTIONS: actions, RUNNER_OS: runnerOs, RUNNER_TEMP: root, PREREQUISITE_SENTINEL: sentinel, + } }); + assert.equal(result.status, 1); + assert.match(result.stderr, /requires a GitHub Actions Linux runner/); + assert.equal(existsSync(sentinel), false, "host guard must precede every external tool"); + assert.equal(existsSync(path.join(root, "paperclip-arm-bwrap-prerequisite")), false); + } + assert.ok(source.indexOf('"${GITHUB_ACTIONS:-}" != true') < source.indexOf(". /etc/os-release")); + assert.match(source, /-o Acquire::AllowInsecureRepositories=false/); + assert.match(source, /download "\$package_name=\$package_version"/); + assert.match(source, /printf '%s %s\\n' "\$package_sha256" "\$package_file" \| sha256sum -c -/); + assert.match(source, /dpkg-deb --fsys-tarfile "\$package_file"/); + assert.match(source, /tar -xO \.\/usr\/bin\/bwrap/); + assert.match(source, /11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9/); + assert.ok(source.indexOf('test ! -L "$target"') < source.indexOf('sudo install -o root -g root -m 0644 "$stock_profile" "$target"')); + assert.match(source, /timeout 15s sudo "\$parser" -r "\$target"/); + for (const setting of ["userns", "apparmor", "clone"]) assert.ok(source.includes(`test "$after_${setting}" = "$before_${setting}"`)); + const aptCommands = source.replace(/\\\n\s*/g, " ").split("\n").filter(line => /\bapt-get\b/.test(line)); + assert.equal(aptCommands.length, 2, "only signed-index update and download may invoke APT"); + assert.match(aptCommands[0], /\bupdate\b/); + assert.match(aptCommands[1], /\bdownload\b/); + for (const command of aptCommands) assert.doesNotMatch(command, /\b(?:install|upgrade|remove|purge)\b/); + assert.doesNotMatch(source, /apt-get[^\n]* install|\bdpkg(?:-deb)?\s+(?:-i|--install|--unpack)|sysctl\s+(?:-w|--system)|\baa-disable\b|\bsetcap\b|systemctl|--no-sandbox/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("exact-source daemon matrices preserve historical three-target sources and add native ARM only for new sources", () => { + const workflow = readWorkflow("release-verify.yml"); + const step = workflow.split(' - name: Preserve the immutable source\'s platform contract')[1].split('\n runner_binaries:')[0]; + const script = step.split(' run: |\n')[1].split('\n').map(line => line.startsWith(' ') ? line.slice(10) : line).join('\n'); + const root = mkdtempSync(path.join(tmpdir(), 'release-target-contract-')); + try { + const tools = path.join(root, 'tools'); mkdirSync(tools); + writeFileSync(path.join(tools, 'git'), '#!/bin/sh\nprintf "%s\\n" "$FIXTURE_HEAD"\n', { mode: 0o755 }); + const source = path.join(root, 'packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs'); + mkdirSync(path.dirname(source), { recursive: true }); + const output = path.join(root, 'outputs'); + const historical = ['darwin-arm64', 'darwin-x64', 'linux-x64']; + for (const targets of [historical, [...historical, 'linux-arm64']]) { + writeFileSync(source, `export const RELEASE_RUNNER_TARGETS = ${JSON.stringify(targets)};`); + writeFileSync(output, ''); + const result = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { cwd: root, encoding: 'utf8', env: { + PATH: `${tools}:${path.dirname(process.execPath)}:/usr/bin:/bin`, SOURCE_SHA: 'a'.repeat(40), FIXTURE_HEAD: 'a'.repeat(40), GITHUB_OUTPUT: output, + } }); + assert.equal(result.status, 0, result.stderr); + const data = Object.fromEntries(readFileSync(output, 'utf8').trim().split('\n').map(line => { + const index = line.indexOf('='); return [line.slice(0, index), JSON.parse(line.slice(index + 1))]; + })); + assert.deepEqual(data.matrix.include.map(row => row.target), targets); + assert.deepEqual(data.native_consumers.include.map(row => row.target), targets.filter(target => target !== 'linux-x64')); + assert.ok(data.matrix.include.every(row => row.target !== 'linux-arm64' || row.host === 'ubuntu-24.04-arm')); + assert.equal(data.matrix.include.find(row => row.target === 'linux-x64').host, targets.length === 4 ? 'ubuntu-24.04' : 'ubuntu-latest', + 'new GNU libc baseline stays pinned while historical qualification keeps its producer mode'); + } + writeFileSync(source, 'export const RELEASE_RUNNER_TARGETS = ["linux-arm64"];'); writeFileSync(output, ''); + const result = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { cwd: root, encoding: 'utf8', env: { + PATH: `${tools}:${path.dirname(process.execPath)}:/usr/bin:/bin`, SOURCE_SHA: 'a'.repeat(40), FIXTURE_HEAD: 'a'.repeat(40), GITHUB_OUTPUT: output, + } }); + assert.equal(result.status, 1); assert.match(result.stderr, /Unknown exact-source daemon platform contract/); + assert.equal(readFileSync(output, 'utf8'), ''); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + + +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'/); + } +}); + test("canary startup refreshes an incomplete dependency publication, then onboards once", async () => { diff --git a/scripts/dev-runner-native-binary.mjs b/scripts/dev-runner-native-binary.mjs index ecf9cabede..d49eb01aae 100644 --- a/scripts/dev-runner-native-binary.mjs +++ b/scripts/dev-runner-native-binary.mjs @@ -1,5 +1,6 @@ -import { readdirSync, statSync } from "node:fs"; +import { readFileSync, readdirSync, statSync } from "node:fs"; import path from "node:path"; +import { runnerBinaryTarget } from "../packages/paperclip-runner/src/live/runner-binary.ts"; export function resolveNativeRunnerRequirement({ exitCode, stdout }) { if (exitCode !== 0) { @@ -38,20 +39,41 @@ function newestMtimeMs(target) { return newest; } +export function resolveNativeRunnerDevBinary({ + repoRoot, + platform = process.platform, + architecture = process.arch, +}) { + const executable = platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd"; + const packageRoot = path.join(repoRoot, "packages", "paperclip-runner"); + const target = path.join(packageRoot, "dist", "bin", `${platform}-${architecture}`, executable); + const flat = path.join(packageRoot, "dist", "bin", executable); + const targetStat = statSync(target, { throwIfNoEntry: false }); + const flatStat = statSync(flat, { throwIfNoEntry: false }); + // Release targets remain authoritative unless a newer source build matches + // this host. In particular, a build-host flat artifact cannot mask a remotely + // staged Mac target or force a local Cargo build. + if (flatStat?.isFile() && (!targetStat?.isFile() || flatStat.mtimeMs > targetStat.mtimeMs) + && runnerBinaryTarget(readFileSync(flat)) === `${platform}-${architecture}`) { + return { binary: flat, sourceBuild: true }; + } + return targetStat?.isFile() ? { binary: target, sourceBuild: false } : null; +} + export function paperclipRunnerBinaryNeedsBuild({ repoRoot, 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 selected = resolveNativeRunnerDevBinary({ repoRoot, platform, architecture }); + if (!selected) return true; + const binaryStat = statSync(selected.binary); 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; const runnerRoot = path.join(packageRoot, "runner"); const buildInputs = [ diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 6bd777f333..748df95ab7 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -8,6 +8,7 @@ import { stdin, stdout } from "node:process"; import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.ts"; import { paperclipRunnerBinaryNeedsBuild, + resolveNativeRunnerDevBinary, resolveNativeRunnerRequirement, } from "./dev-runner-native-binary.mjs"; import { applyDevRunnerOptions } from "./dev-runner-options.ts"; @@ -171,6 +172,9 @@ const env: NodeJS.ProcessEnv = { ...process.env, PAPERCLIP_UI_DEV_MIDDLEWARE: explicitUiDevMiddleware ?? (serveBuiltUiForManagedRuntime ? "false" : "true"), }; +// An automatic source-build override must not hide subsequent Rust edits from +// the watcher. Keep the operator's original choice separate from server env. +const configuredRunnerBinary = env.PAPERCLIP_RUNNER_BINARY; if (mode === "dev") { env.PAPERCLIP_DEV_SERVER_STATUS_FILE = devServerStatusFilePath; @@ -573,28 +577,32 @@ async function buildPaperclipRunner() { process.exit(typescriptResult.code); } + const nativeRunnerRequired = await getNativeRunnerRequired(); if ( - !paperclipRunnerBinaryNeedsBuild({ + paperclipRunnerBinaryNeedsBuild({ repoRoot, - nativeRunnerRequired: await getNativeRunnerRequired(), - configuredBinary: env.PAPERCLIP_RUNNER_BINARY, + nativeRunnerRequired, + configuredBinary: configuredRunnerBinary, }) ) { - return; + console.log("[paperclip] building paperclip runner native binary..."); + const binaryResult = await runPnpm( + ["--filter", "@paperclipai/paperclip-runner", "build:binary"], + { stdio: "inherit" }, + ); + if (binaryResult.signal) { + exitForSignal(binaryResult.signal); + return; + } + if (binaryResult.code !== 0) { + console.error("[paperclip] paperclip runner native binary build failed"); + process.exit(binaryResult.code); + } } - - console.log("[paperclip] building paperclip runner native binary..."); - const binaryResult = await runPnpm( - ["--filter", "@paperclipai/paperclip-runner", "build:binary"], - { stdio: "inherit" }, - ); - if (binaryResult.signal) { - exitForSignal(binaryResult.signal); - return; - } - if (binaryResult.code !== 0) { - console.error("[paperclip] paperclip runner native binary build failed"); - process.exit(binaryResult.code); + if (!configuredRunnerBinary?.trim()) { + const selected = nativeRunnerRequired ? resolveNativeRunnerDevBinary({ repoRoot }) : null; + if (selected?.sourceBuild) env.PAPERCLIP_RUNNER_BINARY = selected.binary; + else delete env.PAPERCLIP_RUNNER_BINARY; } } diff --git a/scripts/grok-public-install-sandbox.mjs b/scripts/grok-public-install-sandbox.mjs index 8ce7663810..3e674c1017 100644 --- a/scripts/grok-public-install-sandbox.mjs +++ b/scripts/grok-public-install-sandbox.mjs @@ -1,4 +1,6 @@ import assert from 'node:assert/strict'; +import { readFileSync, realpathSync, statSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; // Keep public-package lifecycle code off the verification host. Resolve and // cache the public npm graph without scripts, then execute it offline. @@ -9,6 +11,31 @@ export const GROK_PUBLIC_INSTALL_LIFECYCLE = [ 'npm', 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts', ]; +// setup-node provides its matching SDK alongside the executing Node binary. +// Deferred lifecycle scripts may read that SDK, but may never download headers. +export function macNodeHeadersDirectory(nodeExecutable, nodeVersion) { + assert.match(nodeVersion, /^\d+\.\d+\.\d+$/, 'Executing Node must have an exact release version'); + const executable = realpathSync(nodeExecutable); + assert.equal(basename(executable), 'node', 'Node SDK must belong to the executing Node binary'); + assert.ok(statSync(executable).isFile(), 'Executing Node must be a regular file'); + const prefix = realpathSync(join(dirname(executable), '..')); + const include = join(prefix, 'include/node'); + for (const name of ['node.h', 'node_version.h']) { + const header = join(include, name); + assert.equal(realpathSync(header), header, 'Node SDK headers must remain inside the executing Node prefix'); + assert.ok(statSync(header).isFile(), 'Node SDK headers must be regular files'); + } + assert.match(readFileSync(join(include, 'node.h'), 'utf8'), /#\s*include\s+"node_version\.h"/, 'Node SDK must include its version header'); + const versionHeader = readFileSync(join(include, 'node_version.h'), 'utf8'); + const versions = ['MAJOR', 'MINOR', 'PATCH'].map(part => { + const matches = [...versionHeader.matchAll(new RegExp('^#\\s*define\\s+NODE_' + part + '_VERSION\\s+(\\d+)\\s*$', 'gm'))]; + assert.equal(matches.length, 1, 'Node SDK must declare one ' + part.toLowerCase() + ' version'); + return matches[0][1]; + }); + assert.equal(versions.join('.'), nodeVersion, 'Node SDK version must match the executing Node'); + return prefix; +} + // Inspect the actual npm archive, independently of the staging helper. export function assertNoBundledCodexPayloads(entries) { assert.ok(Array.isArray(entries) && entries.length > 0, 'Packed package must have tar members'); @@ -60,10 +87,11 @@ 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'; + import { createHash } from 'node:crypto'; import { createRequire } from 'node:module'; import { accessSync, constants, existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; @@ -118,11 +146,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'); @@ -132,26 +160,116 @@ export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersi const platformRoot = realpathSync(dirname(selectedPlatformManifest)); const nativeVendor = realpathSync(join(platformRoot, 'vendor')); assert.ok(contained(nativeVendor) && nativeVendor.startsWith(platformRoot + sep), 'Codex vendor resources must remain inside their installed platform package'); + const triple = { 'linux-x64': 'x86_64-unknown-linux-musl', 'linux-arm64': 'aarch64-unknown-linux-musl', + 'darwin-x64': 'x86_64-apple-darwin', 'darwin-arm64': 'aarch64-apple-darwin', + 'win32-x64': 'x86_64-pc-windows-msvc', 'win32-arm64': 'aarch64-pc-windows-msvc' }[process.platform + '-' + process.arch]; + assert.ok(triple, 'Codex native payload requires a known host target'); + const nativeName = process.platform === 'win32' ? 'codex.exe' : 'codex'; + const nativeCandidates = ['bin', 'codex'].map(directory => join(nativeVendor, triple, directory, nativeName)).filter(existsSync); + assert.equal(nativeCandidates.length, 1, 'Selected official Codex platform must contain one native executable'); + const nativeExecutable = realpathSync(nativeCandidates[0]); + const nativeStat = lstatSync(nativeCandidates[0]); + assert.ok(nativeExecutable.startsWith(nativeVendor + sep) && nativeStat.isFile() && !nativeStat.isSymbolicLink() + && nativeStat.size >= 20 && nativeStat.size <= 512 * 1024 * 1024, 'Codex native executable must be a bounded regular file in its selected platform package'); + accessSync(nativeExecutable, constants.X_OK); + const nativeBytes = readFileSync(nativeExecutable); + const { runnerBinaryTarget } = await import(pathToFileURL(join(vendor, 'live/runner-binary.js')).href); + assert.equal(runnerBinaryTarget(nativeBytes), process.platform + '-' + process.arch, 'Codex native executable must match the actual host architecture'); const sandboxSource = { HOME: '/private-provider-home', PATH: '/usr/local/bin:/usr/bin:/bin' }; const readRoots = codexExecutableReadOnlyRoots(sandboxSource, command); assert.ok(readRoots.includes(nativeVendor), 'Selected native Codex vendor directory must be readable inside its sandbox'); 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, + codexCommandSha256: createHash('sha256').update(readFileSync(command)).digest('hex'), + codexNativeExecutableSha256: createHash('sha256').update(nativeBytes).digest('hex'), + codexNativeExecutablePath: relative(root, nativeExecutable).split(sep).join('/'), + ...provenance, codexSelectedPlatformPath: selectedPlatform.path, codexSelectedPlatformVersion: selectedMetadata.version, + codexNativeSandboxResourcesVerified: true, + ${nativeCodex ? '' : `pinnedCodexVersion: ${JSON.stringify(expectedVersion)}, codexQualifiedInstallationVerified: true, codexCommandLeaseVerified: true,`} + providerCalls: 0 })); `; } -export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, gid, download = false, prerequisite, temporarySizeMiB = 256, temporaryExecutable = false }) { +/** Installed bytes and consumer resolution must survive host protocol startup. */ +export function assertInstalledNativeConsumerContinuity(before, after) { + for (const field of ['lockSha256', 'runnerSha256', 'codexCommandSha256', 'codexNativeExecutableSha256']) { + if (!/^[a-f0-9]{64}$/.test(before[field]) || before[field] !== after[field]) { + throw new Error(`Installed native host startup changed ${field}`); + } + } +} + +/** 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', 'linux-arm64']; + 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, platform = 'linux/amd64' }) { + if (!['linux/amd64', 'linux/arm64'].includes(platform)) throw new Error('Public-install verification requires a supported native Linux platform'); if (!Number.isSafeInteger(uid) || uid <= 0 || !Number.isSafeInteger(gid) || gid <= 0) { throw new Error('Public-install verification requires an unprivileged host user'); } @@ -162,7 +280,7 @@ export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, throw new Error('Executable runtime snapshots require an offline verification sandbox'); } return [ - 'run', '--rm', '--platform', 'linux/amd64', + 'run', '--rm', '--platform', platform, '--user', `${uid}:${gid}`, '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--pids-limit', '256', '--memory', '3g', diff --git a/scripts/pi-public-install-probe.mjs b/scripts/pi-public-install-probe.mjs index 663754fcbc..5827f9ad00 100644 --- a/scripts/pi-public-install-probe.mjs +++ b/scripts/pi-public-install-probe.mjs @@ -2,10 +2,44 @@ // Run in a credential-free public npm consumer after `paperclipai runtime setup pi`. // Use the server's normal packaged daemon resolver; never submit a prompt. import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import { mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +export async function verifyPiPackagedDaemon(server, daemon, target, runnerBinaryTarget) { + assert.match(target, /^(?:linux|darwin)-(?:x64|arm64)$/); + const output = join(server, 'dist/vendor/paperclip-runner'), manifestPath = join(output, 'bin/release-manifest.json'); + let manifest; + try { manifest = JSON.parse(await readFile(manifestPath, 'utf8')); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + let expectedPath = join(output, 'bin/paperclip-runnerd'); + if (manifest !== undefined) { + assert.ok(manifest && typeof manifest === 'object' && !Array.isArray(manifest), 'Release manifest must be an object'); + assert.equal(await realpath(manifestPath), manifestPath, 'Release manifest must remain inside the installed package'); + assert.equal(manifest.schema, 'paperclip.runner.release-binaries.v1'); + assert.match(manifest.sourceRevision, /^[a-f0-9]{40}$/); + const build = JSON.parse(await readFile(join(server, 'dist/build-info.json'), 'utf8')); + assert.equal(manifest.sourceRevision, build.commit, 'Packaged daemon source must match the installed server build'); + const artifact = manifest.platforms?.[target]; + assert.equal(artifact?.path, `${target}/paperclip-runnerd`, 'Packaged daemon must use the known host target path'); + assert.match(artifact.sha256, /^sha256:[a-f0-9]{64}$/); + expectedPath = join(output, 'bin', artifact.path); + assert.equal(await realpath(daemon), expectedPath, 'Production must select the installed host daemon'); + assert.equal(await realpath(expectedPath), expectedPath, 'Packaged daemon cannot escape through a symlink'); + const bytes = await readFile(expectedPath); + assert.equal(`sha256:${createHash('sha256').update(bytes).digest('hex')}`, artifact.sha256); + assert.equal(runnerBinaryTarget(bytes), target); + } else { + // Existing host-only source packages have no multi-target release manifest. + // They must still use their exact normal flat daemon, never an override. + assert.equal(await realpath(daemon), expectedPath); + assert.equal(runnerBinaryTarget(await readFile(expectedPath)), target); + } +} + +async function main() { const [serverDirectory, ...extra] = process.argv.slice(2); assert.ok(serverDirectory && extra.length === 0, 'Usage: pi-public-install-probe.mjs INSTALLED_SERVER_ROOT'); @@ -25,7 +59,8 @@ assert.equal(QUALIFIED_ACPX_PROFILES.pi.commandDigest, 'sha256:e92078bee3c23bec4 assert.equal(Object.hasOwn(QUALIFIED_ACPX_PROFILES.pi, 'qualificationModel'), false); assert.equal(Object.hasOwn(QUALIFIED_ACPX_PROFILES.pi, 'reportedModelId'), false); const daemon = resolvePaperclipRunnerBinary(); -assert.equal(await realpath(daemon), join(server, 'dist/vendor/paperclip-runner/bin/paperclip-runnerd')); +const { runnerBinaryTarget } = await import(pathToFileURL(join(server, 'dist/vendor/paperclip-runner/live/runner-binary.js'))); +await verifyPiPackagedDaemon(server, daemon, `${process.platform}-${process.arch}`, runnerBinaryTarget); const root = await mkdtemp(join(tmpdir(), 'pi-public-install-probe-')); const evidence = []; const started = performance.now(); @@ -69,3 +104,5 @@ try { try { await bundle?.transport.close(); } finally { clearTimeout(watchdog); await rm(root, { recursive: true, force: true }); } } +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); diff --git a/scripts/prepare-linux-arm64-qualification.sh b/scripts/prepare-linux-arm64-qualification.sh new file mode 100755 index 0000000000..162e17db4e --- /dev/null +++ b/scripts/prepare-linux-arm64-qualification.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# Prepare the disposable GitHub ARM qualification host using stock distro files. +# Extraction skips package maintainer scripts and their global policy changes. +set -euo pipefail + +if [[ "${GITHUB_ACTIONS:-}" != true || "${RUNNER_OS:-}" != Linux ]]; then + printf 'Linux ARM qualification preparation requires a GitHub Actions Linux runner.\n' >&2 + exit 1 +fi + +. /etc/os-release +test "$ID" = ubuntu +test "$VERSION_ID" = 24.04 +test "$(dpkg --print-architecture)" = arm64 +test "$(uname -m)" = aarch64 +prerequisite_report_dir=${RUNNER_TEMP:?}/paperclip-arm-bwrap-prerequisite +mkdir -p "$prerequisite_report_dir" +prerequisite_download_dir=$(mktemp -d "${RUNNER_TEMP}/paperclip-arm-stock-packages.XXXXXX") +before_userns=$(sysctl -n kernel.apparmor_restrict_unprivileged_userns) +before_apparmor=$(cat /sys/module/apparmor/parameters/enabled) +before_clone=$(sysctl -n kernel.unprivileged_userns_clone) +test "$before_userns" = 1 +test "$before_apparmor" = Y +test "$before_clone" = 1 +printf 'restriction=%s apparmor=%s clone=%s\n' \ + "$before_userns" "$before_apparmor" "$before_clone" | \ + tee "$prerequisite_report_dir/before.txt" + +parser=$(readlink -f "$(command -v apparmor_parser)") +test "$parser" = /usr/sbin/apparmor_parser +test "$(stat -c '%u' "$parser")" = 0 +dpkg-query -W -f='${Package} ${Version} ${Architecture}\n' \ + apparmor libc6 libcap2 libselinux1 | \ + tee "$prerequisite_report_dir/existing-package-versions.txt" +dpkg --compare-versions "$(dpkg-query -W -f='${Version}' libc6)" ge 2.38 +dpkg --compare-versions "$(dpkg-query -W -f='${Version}' libcap2)" ge 1:2.10 +dpkg --compare-versions "$(dpkg-query -W -f='${Version}' libselinux1)" ge 3.1~ + +timeout 120s sudo apt-get \ + -o Acquire::AllowInsecureRepositories=false \ + -o APT::Get::AllowUnauthenticated=false update | \ + tee "$prerequisite_report_dir/apt-update.txt" +apt-cache policy bubblewrap apparmor-profiles | \ + tee "$prerequisite_report_dir/apt-policy.txt" +for package_name in bubblewrap apparmor-profiles; do + package_version=$(apt-cache policy "$package_name" | awk '/Candidate:/ {print $2}') + test -n "$package_version" + test "$package_version" != '(none)' + apt-cache show "$package_name=$package_version" > \ + "$prerequisite_report_dir/$package_name-index.txt" + package_sha256=$(awk '/^SHA256:/ {print $2}' \ + "$prerequisite_report_dir/$package_name-index.txt") + test "${#package_sha256}" = 64 + printf '%s\n' "$package_sha256" | grep -E '^[0-9a-f]{64}$' >/dev/null + ( + cd "$prerequisite_download_dir" + timeout 60s apt-get -o APT::Get::AllowUnauthenticated=false \ + download "$package_name=$package_version" + ) + package_files=("$prerequisite_download_dir"/"$package_name"_*.deb) + test "${#package_files[@]}" = 1 + package_file=${package_files[0]} + test -f "$package_file" + printf '%s %s\n' "$package_sha256" "$package_file" | sha256sum -c - + test "$(dpkg-deb -f "$package_file" Package)" = "$package_name" + test "$(dpkg-deb -f "$package_file" Version)" = "$package_version" + dpkg-deb -f "$package_file" Package Version Architecture | \ + tee "$prerequisite_report_dir/$package_name-downloaded-version.txt" + if test "$package_name" = bubblewrap; then + test "$(dpkg-deb -f "$package_file" Architecture)" = arm64 + dpkg-deb --fsys-tarfile "$package_file" | \ + tar -xO ./usr/bin/bwrap > "$prerequisite_download_dir/bwrap" + else + test "$(dpkg-deb -f "$package_file" Architecture)" = all + dpkg-deb --fsys-tarfile "$package_file" | \ + tar -xO ./usr/share/apparmor/extra-profiles/bwrap-userns-restrict > \ + "$prerequisite_download_dir/bwrap-userns-restrict" + fi +done + +stock_binary=$prerequisite_download_dir/bwrap +stock_profile=$prerequisite_download_dir/bwrap-userns-restrict +printf '11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9 %s\n' \ + "$stock_profile" | sha256sum -c - +cat "$stock_profile" | tee "$prerequisite_report_dir/stock-profile.txt" +test "$(od -An -tx1 -N4 "$stock_binary" | tr -d ' \n')" = 7f454c46 +readelf -h "$stock_binary" | tee "$prerequisite_report_dir/bwrap-elf-header.txt" +grep -E 'Machine:.*AArch64' "$prerequisite_report_dir/bwrap-elf-header.txt" + +# Reject every target symlink, including dangling links, before privileged copy. +test ! -L /usr/bin/bwrap +if test -e /usr/bin/bwrap; then + test -f /usr/bin/bwrap + cmp "$stock_binary" /usr/bin/bwrap +else + sudo install -o root -g root -m 0755 "$stock_binary" /usr/bin/bwrap +fi +test "$(readlink -f "$(command -v bwrap)")" = /usr/bin/bwrap +test "$(stat -c '%u:%g:%a' /usr/bin/bwrap)" = 0:0:755 +command -v getcap >/dev/null +test -z "$(getcap /usr/bin/bwrap)" +sha256sum "$stock_binary" /usr/bin/bwrap | \ + tee "$prerequisite_report_dir/bwrap-sha256.txt" +timeout 5s /usr/bin/bwrap --version | tee "$prerequisite_report_dir/bwrap-version.txt" +help=$(timeout 5s /usr/bin/bwrap --help) +printf '%s\n' "$help" > "$prerequisite_report_dir/bwrap-help.txt" +printf '%s\n' "$help" | grep -F -- --as-pid-1 >/dev/null +printf '%s\n' "$help" | grep -F -- --perms >/dev/null + +target=/etc/apparmor.d/bwrap-userns-restrict +test ! -L "$target" +test ! -L /etc/apparmor.d/local/bwrap-userns-restrict +test ! -L /etc/apparmor.d/local/unpriv_bwrap +test ! -s /etc/apparmor.d/local/bwrap-userns-restrict +test ! -s /etc/apparmor.d/local/unpriv_bwrap +if test -e "$target"; then + test -f "$target" + cmp "$stock_profile" "$target" +else + sudo install -o root -g root -m 0644 "$stock_profile" "$target" +fi +timeout 15s sudo "$parser" -r "$target" +sha256sum "$stock_profile" "$target" | \ + tee "$prerequisite_report_dir/profile-sha256.txt" +loaded_profiles=$(sudo cat /sys/kernel/security/apparmor/profiles) +for required_profile in 'bwrap (enforce)' 'unpriv_bwrap (enforce)'; do + printf '%s\n' "$loaded_profiles" | grep -Fx "$required_profile" | \ + tee -a "$prerequisite_report_dir/loaded-profiles.txt" +done +after_userns=$(sysctl -n kernel.apparmor_restrict_unprivileged_userns) +after_apparmor=$(cat /sys/module/apparmor/parameters/enabled) +after_clone=$(sysctl -n kernel.unprivileged_userns_clone) +test "$after_userns" = "$before_userns" +test "$after_apparmor" = "$before_apparmor" +test "$after_clone" = "$before_clone" +printf 'restriction=%s apparmor=%s clone=%s\n' \ + "$after_userns" "$after_apparmor" "$after_clone" | \ + tee "$prerequisite_report_dir/after.txt" +timeout 5s /usr/bin/bwrap --die-with-parent --unshare-user --unshare-net \ + --ro-bind / / --cap-drop ALL /bin/true +printf 'stock files materialized without dpkg registration or maintainer scripts; namespace probe passed; no provider processes or turns\n' | \ + tee "$prerequisite_report_dir/namespace-probe.txt" diff --git a/scripts/release.sh b/scripts/release.sh index 7238c4b293..a4a0073550 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 four 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 524b76b86d..6bd8754baf 100644 --- a/scripts/verify-grok-npm-install.mjs +++ b/scripts/verify-grok-npm-install.mjs @@ -4,17 +4,200 @@ // 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, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; +import { stripTypeScriptTypes } from 'node:module'; import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { packConcurrency, packStagedPackages, packTimings } from './pack-staged-packages.mjs'; import { materializePublishManifest } 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, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource } from './grok-public-install-sandbox.mjs'; import { startPrefetch, stopPrefetches } from './prefetch-child.mjs'; import { retainRunnerQualificationPackages } from './retain-runner-qualification-packages.mjs'; import { bundledInstallTimings, startBundledInstalls } from './stage-bundled-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, runEnv = env) => execFileSync(command, args, { cwd, env: runEnv, + 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)); } + const nodeHeaders = macNodeHeadersDirectory(process.execPath, process.versions.node); + run('/usr/bin/sandbox-exec', ['-f', profile, process.execPath, npmCli, 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts', '--foreground-scripts'], + consumer, { ...env, npm_config_nodedir: nodeHeaders }); + 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, lifecycleNodeHeadersVerified: true, lifecycleNodeVersion: process.versions.node, + 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 }); } +} +async function verifyLinuxArmConsumer(packageDirectory, sourceRevision, receiptPath) { + assert.equal(process.env.GITHUB_ACTIONS, 'true', 'Native clean consumers run only on disposable hosted CI'); + assert.equal(process.platform + '-' + process.arch, 'linux-arm64', 'ARM qualification requires a native ARM host'); + assert.equal(process.versions.node.split('.')[0], '24', 'ARM host startup requires the qualified Node24 runtime'); + 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-arm-public-install-'))); + try { + const assets = join(root, 'assets'), consumer = join(root, 'consumer'), cache = join(root, 'cache'); + for (const directory of [assets, consumer, cache]) mkdirSync(directory); + const run = (command, args, cwd = root) => execFileSync(command, args, { cwd, + env: { PATH: process.env.PATH, HOME: root, TMPDIR: root }, timeout: 10 * 60_000, + maxBuffer: 32 * 1024 * 1024, encoding: 'utf8' }); + const platform = 'linux/arm64'; + run('docker', ['pull', '--platform', platform, GROK_PUBLIC_INSTALL_IMAGE]); + const isolated = (command, options = {}) => run('docker', grokConsumerDockerArgs({ assets, consumer, cache, + uid: process.getuid(), gid: process.getgid(), platform, command, ...options })); + for (const entry of manifest.archives) { + assert.match(entry.file, /^[A-Za-z0-9._-]+\.tgz$/); assert.match(entry.sha256, /^[a-f0-9]{64}$/); + const tarball = join(packages, entry.file); + assert.equal(realpathSync(tarball), tarball); + assert.equal(createHash('sha256').update(readFileSync(tarball)).digest('hex'), entry.sha256); + assertNoBundledCodexPayloads(run('tar', ['-tzf', tarball]).trim().split('\n')); + cpSync(tarball, join(assets, entry.file)); chmodSync(join(assets, entry.file), 0o644); + } + 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('npm', ['pack', '--ignore-scripts', '--pack-destination', assets], sentinelSource); + isolated(['npm', 'install', '--ignore-scripts', '--omit=dev', ...readdirSync(assets).filter(file => file.endsWith('.tgz')).map(file => `/packages/${file}`)], { download: true }); + 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'); + isolated(GROK_PUBLIC_INSTALL_LIFECYCLE); + 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 = '/consumer/node_modules/@paperclipai/server', installed = server + '/dist/vendor/paperclip-runner'; + const probe = (name, source, options = {}) => { writeFileSync(join(assets, name), source); return JSON.parse(isolated(['node', '/packages/' + name], options).trim()); }; + const daemon = probe('daemon-probe.mjs', installedDaemonProbeSource(installed, '/consumer', sourceRevision)); + assert.equal(daemon.target, 'linux-arm64'); + const codex = probe('codex-probe.mjs', installedCodexProbeSource(installed + '/index.js', '/consumer', undefined, { nativeCodex: true })); + // Reuse the existing zero-turn hook as read-only verifier source. All + // production imports still resolve from the installed consumer graph. + cpSync(join(repo, 'tests/runner-e2e/installed-cli.ts'), join(assets, 'installed-cli.ts')); + writeFileSync(join(assets, 'types.js'), stripTypeScriptTypes(readFileSync(join(repo, 'tests/runner-e2e/types.ts'), 'utf8'))); + // The restricted install container deliberately forbids nested namespaces. + // Keep its lifecycle/no-network proof, then test the exact same bind-mounted + // graph on the native host using Codex's normal read-only namespace policy. + const home = join(root, 'host-home'), hostTmp = join(root, 'host-tmp'); + for (const directory of [home, hostTmp]) mkdirSync(directory, { mode: 0o700 }); + const hostEnv = { PATH: `${dirname(process.execPath)}:/usr/bin:/bin:/usr/sbin:/sbin`, HOME: home, TMPDIR: hostTmp, LC_ALL: 'C', TZ: 'UTC' }; + const hostProbe = (name, source) => { + const file = join(assets, name); writeFileSync(file, source); + return JSON.parse(execFileSync(process.execPath, [file], { cwd: consumer, env: hostEnv, + timeout: 120_000, maxBuffer: 1024 * 1024, encoding: 'utf8' }).trim()); + }; + const hostServer = join(consumer, 'node_modules/@paperclipai/server'), hostInstalled = join(hostServer, 'dist/vendor/paperclip-runner'); + const hostDaemon = () => hostProbe('host-daemon-probe.mjs', installedDaemonProbeSource(hostInstalled, consumer, sourceRevision)); + const hostCodex = () => hostProbe('host-codex-probe.mjs', installedCodexProbeSource(join(hostInstalled, 'index.js'), consumer, undefined, { nativeCodex: true })); + const digest = value => createHash('sha256').update(value).digest('hex'); + const beforeCodex = hostCodex(); + const before = { lockSha256: digest(lock), runnerSha256: hostDaemon().sha256.slice(7), codexCommandSha256: beforeCodex.codexCommandSha256, codexNativeExecutableSha256: beforeCodex.codexNativeExecutableSha256 }; + assert.equal(before.runnerSha256, daemon.sha256.slice(7), 'Docker and host must select the same packaged daemon bytes'); + assert.equal(before.codexCommandSha256, codex.codexCommandSha256, 'Docker and host must select the same official Codex command bytes'); + assert.equal(before.codexNativeExecutableSha256, codex.codexNativeExecutableSha256, 'Docker and host must select the same official native Codex payload bytes'); + const hostHeader = process.report.getReport().header; + assert.match(hostHeader.glibcVersionRuntime || '', /^\d+\.\d+$/, 'Native host startup must report its actual GNU libc ABI'); + const startup = hostProbe('host-startup-probe.mjs', macInstalledStartupProbeSource(consumer, hostServer, daemon.sha256, join(assets, 'installed-cli.ts'))); + const afterCodex = hostCodex(); + const after = { lockSha256: digest(readFileSync(join(consumer, 'package-lock.json'))), runnerSha256: hostDaemon().sha256.slice(7), codexCommandSha256: afterCodex.codexCommandSha256, codexNativeExecutableSha256: afterCodex.codexNativeExecutableSha256 }; + assertInstalledNativeConsumerContinuity(before, after); + assert.equal(startup.runnerSha256, before.runnerSha256); + assert.equal(startup.codexSha256, before.codexCommandSha256); + const receipt = { schema: 'paperclip.runner.linux-arm64-public-install.v1', sourceRevision, platform: process.platform, architecture: process.arch, + cleanNpmInstall: true, packageCount: manifest.archives.length, codexPackedPayloadsOmitted: true, + consumerImage: GROK_PUBLIC_INSTALL_IMAGE, consumerImagePlatform: platform, + consumerLockPreserved: true, lifecycleSentinelVerified: true, lifecycleNetwork: 'none', + startupEnvironment: 'native host, same installed graph, isolated empty homes', + startupHost: { nodeVersion: process.versions.node, nodeModulesAbi: process.versions.modules, glibcVersionRuntime: hostHeader.glibcVersionRuntime, kernelRelease: hostHeader.osRelease }, + installedIdentityBefore: before, installedIdentityAfter: after, + ...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] === '--linux-arm64-consumer') { + assert.equal(process.argv.length, 6, 'Usage: --linux-arm64-consumer PACKAGE_DIRECTORY SOURCE_SHA RECEIPT_PATH'); + await verifyLinuxArmConsumer(process.argv[3], process.argv[4], process.argv[5]); +} else 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'); @@ -146,6 +329,24 @@ try { timing.stage_pack_check_ms = Math.round(performance.now() - checksStarted); timing.stage_pack_ms = Math.round(performance.now() - packStarted); Object.assign(timing, packTimings(packs, concurrency)); + + // 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 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); @@ -259,3 +460,4 @@ try { await stopPrefetches(prefetches); rmSync(root, { recursive: true, force: true }); } +} diff --git a/server/src/__tests__/dev-runner-native-binary.test.ts b/server/src/__tests__/dev-runner-native-binary.test.ts index 705ea8cf76..0e1ac24a07 100644 --- a/server/src/__tests__/dev-runner-native-binary.test.ts +++ b/server/src/__tests__/dev-runner-native-binary.test.ts @@ -1,16 +1,22 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { paperclipRunnerBinaryNeedsBuild, + resolveNativeRunnerDevBinary, resolveNativeRunnerRequirement, } from "../../../scripts/dev-runner-native-binary.mjs"; const tempRoots = new Set(); +const initialPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; +const initialArchitecture = Object.getOwnPropertyDescriptor(process, "arch")!; afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(process, "platform", initialPlatform); + Object.defineProperty(process, "arch", initialArchitecture); for (const root of tempRoots) { fs.rmSync(root, { recursive: true, force: true }); } @@ -36,10 +42,24 @@ function createRunnerCheckout(): { root: string; source: string; binary: string fs.writeFileSync(path.join(runnerRoot, "Cargo.toml"), "[workspace]\n", "utf8"); fs.writeFileSync(path.join(runnerRoot, "Cargo.lock"), "", "utf8"); fs.writeFileSync(source, "fn main() {}\n", "utf8"); - fs.writeFileSync(binary, "runnerd", "utf8"); + fs.writeFileSync(binary, nativeHeader(process.platform, process.arch)); return { root, source, binary }; } +function nativeHeader(platform: string, architecture: string): Buffer { + const bytes = Buffer.alloc(128); + if (platform === "darwin") { + bytes.writeUInt32LE(0xfeedfacf); + bytes.writeUInt32LE(architecture === "x64" ? 0x01000007 : 0x0100000c, 4); + } else if (platform === "win32") { + bytes.set([0x4d, 0x5a]); bytes.writeUInt32LE(64, 60); + bytes.writeUInt32LE(0x00004550, 64); bytes.writeUInt16LE(architecture === "x64" ? 0x8664 : 0xaa64, 68); + } else { + bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(architecture === "x64" ? 62 : 183, 18); + } + return bytes; +} + describe("paperclip runner native dev prerequisite", () => { it("uses an explicit status response and fails safe when status is unknown", () => { expect( @@ -110,6 +130,50 @@ 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, nativeHeader("darwin", architecture)); + 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); + // A newer Linux build-host output cannot mask the remote Mac artifact. + fs.writeFileSync(checkout.binary, nativeHeader("linux", "x64")); + const wrongHostNewer = new Date(Date.now() + 4000); + fs.utimesSync(checkout.binary, wrongHostNewer, wrongHostNewer); + expect(resolveNativeRunnerDevBinary(input)).toEqual({ binary: target, sourceBuild: false }); + 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.each(["arm64", "x64"])("uses only a newer matching Mac %s source build without changing release selection", 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, nativeHeader("darwin", architecture)); + fs.writeFileSync(checkout.binary, nativeHeader("darwin", architecture)); + const old = new Date(Date.now() - 2000), fresh = new Date(Date.now() + 2000), changed = new Date(Date.now() + 4000); + fs.utimesSync(target, old, old); fs.utimesSync(checkout.binary, fresh, fresh); + const input = { repoRoot: checkout.root, nativeRunnerRequired: true, platform: "darwin", architecture }; + expect(resolveNativeRunnerDevBinary(input)).toEqual({ binary: checkout.binary, sourceBuild: true }); + expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(false); + fs.utimesSync(checkout.source, changed, changed); + expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(true); + fs.writeFileSync(checkout.binary, nativeHeader("darwin", architecture === "x64" ? "arm64" : "x64")); + expect(resolveNativeRunnerDevBinary(input)).toEqual({ binary: target, sourceBuild: false }); + fs.rmSync(target); + expect(resolveNativeRunnerDevBinary(input)).toBeNull(); + 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__/remove-test-home.test.ts b/server/src/__tests__/remove-test-home.test.ts index 4eb8cc182d..0b4a5baf82 100644 --- a/server/src/__tests__/remove-test-home.test.ts +++ b/server/src/__tests__/remove-test-home.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { removeTestHome } from "./remove-test-home.js"; @@ -49,7 +49,9 @@ describe("removeTestHome", () => { // The plain removal the setup file used to run is what fails in CI. if (process.platform !== "win32" && process.getuid?.() !== 0) { - expect(() => fs.rmSync(home, { recursive: true, force: true })).toThrow(/EACCES|EPERM/); + expect(() => fs.rmSync(home, { recursive: true, force: true })).toThrow( + process.platform === "darwin" ? /EACCES|EPERM|ENOTEMPTY/ : /EACCES|EPERM/, + ); expect(fs.existsSync(bundle)).toBe(true); } @@ -61,6 +63,18 @@ describe("removeTestHome", () => { const home = makeHome(); fs.mkdirSync(path.join(home, "instances", "default"), { recursive: true }); fs.writeFileSync(path.join(home, "instances", "default", "paperclip.json"), "{}\n"); + // An unrelated ENOTEMPTY must not cause permission changes or be swallowed. + const error = Object.assign(new Error("unrelated nonempty directory"), { code: "ENOTEMPTY" }); + const removal = vi.spyOn(fs, "rmSync").mockImplementation(() => { throw error; }); + const permissionChange = vi.spyOn(fs, "chmodSync"); + try { + expect(() => removeTestHome(home)).toThrow(error); + expect(removal).toHaveBeenCalledTimes(1); + expect(permissionChange).not.toHaveBeenCalled(); + } finally { + removal.mockRestore(); + permissionChange.mockRestore(); + } removeTestHome(home); expect(fs.existsSync(home)).toBe(false); expect(() => removeTestHome(home)).not.toThrow(); diff --git a/server/src/__tests__/remove-test-home.ts b/server/src/__tests__/remove-test-home.ts index 524b1e34f6..1f2260f9bb 100644 --- a/server/src/__tests__/remove-test-home.ts +++ b/server/src/__tests__/remove-test-home.ts @@ -9,7 +9,8 @@ import path from "node:path"; * in them read-only (`makeDirectoriesReadOnly` in * services/native-runtime/runtime-context.ts), and a run that is still * finishing when the worker exits can leave such a bundle under the worker's - * home. Removing a file inside a read-only directory fails with `EACCES`, + * home. Removing a file inside a read-only directory fails with `EACCES` + * or, on macOS, the recursive removal can report `ENOTEMPTY` instead. * Vitest reports it as an unhandled error, and the shard fails after every * test passed. So on a permission error every directory in the tree is made * writable first and the removal is tried once more. @@ -19,8 +20,12 @@ export function removeTestHome(home: string): void { try { fs.rmSync(home, options); } catch (error) { - if (!isPermissionError(error)) throw error; - makeDirectoriesWritable(home); + const permissionError = isPermissionError(error); + if (!permissionError && (error as NodeJS.ErrnoException | null)?.code !== "ENOTEMPTY") throw error; + const restored = makeDirectoriesWritable(home); + // ENOTEMPTY also has unrelated causes; retry only after restoring an + // actual read-only directory, and still surface a failed second removal. + if (!permissionError && !restored) throw error; fs.rmSync(home, options); } } @@ -30,28 +35,33 @@ function isPermissionError(error: unknown): boolean { return code === "EACCES" || code === "EPERM"; } -function makeDirectoriesWritable(directory: string): void { +function makeDirectoriesWritable(directory: string): boolean { let stat: fs.Stats; try { stat = fs.lstatSync(directory); } catch { - return; + return false; } - if (!stat.isDirectory()) return; + if (!stat.isDirectory()) return false; // Owner read, write and search: enough to list and unlink the children. // A symlink is not followed, so nothing outside the home is touched. - try { - fs.chmodSync(directory, stat.mode | 0o700); - } catch { - // A directory that cannot be changed still fails the removal below, loudly. + let restored = false; + if ((stat.mode & 0o700) !== 0o700) { + try { + fs.chmodSync(directory, stat.mode | 0o700); + restored = true; + } catch { + // A directory that cannot be changed still fails the removal below, loudly. + } } let entries: fs.Dirent[]; try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { - return; + return restored; } for (const entry of entries) { - if (entry.isDirectory()) makeDirectoriesWritable(path.join(directory, entry.name)); + if (entry.isDirectory() && makeDirectoriesWritable(path.join(directory, entry.name))) restored = true; } + return restored; } diff --git a/server/src/services/local-ai-browser-login.test.ts b/server/src/services/local-ai-browser-login.test.ts index 6bfc0d3636..505b0bee18 100644 --- a/server/src/services/local-ai-browser-login.test.ts +++ b/server/src/services/local-ai-browser-login.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { mkdtemp, mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { createRequire } from "node:module"; import { startLocalBrowserLogin } from "./local-ai-browser-login.js"; import { resolveCodexCommand } from "../vendor/paperclip-runner/index.js"; @@ -18,6 +20,7 @@ afterEach(async () => { Object.defineProperty(process, "arch", initialArchitecture); process.env.PATH = initialPath; vi.unstubAllEnvs(); + vi.restoreAllMocks(); vi.clearAllMocks(); if (root) await rm(root, { recursive: true, force: true }); root = undefined; @@ -35,14 +38,38 @@ async function fakeCli(name: string, source: string) { return realpath(home); } -function targetPlatform(platform: string, architecture: string) { +function targetPlatform(platform: string, architecture: string, glibc = true, glibcVersionRuntime = "2.39") { Object.defineProperty(process, "platform", { ...initialPlatform, value: platform }); Object.defineProperty(process, "arch", { ...initialArchitecture, value: architecture }); + if (platform === "linux") vi.spyOn(process.report, "getReport").mockReturnValue({ header: glibc ? { glibcVersionRuntime } : {} } as never); +} + +function resolveFixtureCodexCommand(home: string): string { + // Vitest's global dependency paths can supply a foreign bridge before the + // fixture wrapper. Reuse the existing Codex security test's closed Node seam. + const source = ` + import { resolveCodexCommand } from ${JSON.stringify(new URL("../../../packages/paperclip-runner/src/drivers/codex/codex-command.ts", import.meta.url).href)}; + try { console.log(JSON.stringify({ command: resolveCodexCommand(${JSON.stringify(path.join(root!, "issuer.js"))}, { PATH: "/missing-codex" }, ${JSON.stringify(home)}) })); } + catch (error) { console.log(JSON.stringify({ error: error.message })); } + `; + const result = JSON.parse(execFileSync(process.execPath, [ + "--no-global-search-paths", "--import", createRequire(import.meta.url).resolve("tsx"), + "--input-type=module", "--eval", source, + ], { cwd: root, env: { PATH: "/usr/bin:/bin", NODE_PATH: "", NODE_OPTIONS: "" }, + encoding: "utf8", timeout: 10000, maxBuffer: 16384 })); + if (typeof result.error === "string") throw new Error(result.error); + if (typeof result.command !== "string") throw new Error("Fixture resolver returned no executable"); + return result.command; } describe.skipIf(process.platform === "win32")("local browser subscription login", () => { - it.each([false, true])("uses the selected Codex command with its credential home and ambient HOME/cwd (legacy Linux ARM64: %s)", async (legacy) => { - if (legacy) targetPlatform("linux", "arm64"); + it.each(["native", "arm32", "musl", "glibc-2.38", "glibc-3.0", "unknown"])("uses the selected Codex command with its credential home and ambient HOME/cwd (%s)", async platform => { + const legacy = platform === "arm32"; + if (platform === "arm32") targetPlatform("linux", "arm"); + if (platform === "musl") targetPlatform("linux", "arm64", false); + if (platform === "glibc-2.38") targetPlatform("linux", "arm64", true, "2.38"); + if (platform === "glibc-3.0") targetPlatform("linux", "arm64", true, "3.0"); + if (platform === "unknown") targetPlatform("linux", "x64", true, "unknown"); const home = await fakeCli("codex", [ '[ "$1 $2" = "login --device-auth" ] || exit 11', '[ "$HOME" = "$CLAUDE_CONFIG_DIR" ] && [ "$HOME" != "$CODEX_HOME" ] && [ "$CODEX_HOME" = "$PAPERCLIP_TEST_LOGIN_CODEX_HOME" ] || exit 12', @@ -60,13 +87,60 @@ describe.skipIf(process.platform === "win32")("local browser subscription login" for (const key of ["OPENAI_API_KEY", "CODEX_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"]) vi.stubEnv(key, "unrelated-host-value"); const login = startLocalBrowserLogin("openai", home); - await vi.waitFor(() => expect(login.outcome).toBe("success"), { timeout: 5000 }); + await vi.waitFor(() => expect(login.outcome).toBeDefined(), { timeout: 5000 }); + expect(login.outcome).toBe("success"); if (legacy) expect(resolveCodexCommand).not.toHaveBeenCalled(); else expect(resolveCodexCommand).toHaveBeenCalledOnce(); expect(login.code).toBe("ABCD-EFGHJ"); }); - it.each([["linux", "x64"], ["darwin", "arm64"], ["darwin", "x64"]] as const)("reports an unavailable Codex CLI on %s/%s without revealing private details", async (platform, architecture) => { + it.each(["x64", "arm64"].flatMap(architecture => ["2.38", "musl", "unknown"].map(libc => ({ architecture, libc }))))("uses the installed Codex dependency for login on Linux $architecture/$libc without a global CLI", async ({ architecture, libc }) => { + targetPlatform("linux", architecture, libc !== "musl", libc); + const home = await fakeCli("codex", [ + 'echo owned > "$CODEX_HOME/owned-cli"', + 'printf "1. Open this link in your browser and sign in to your account\\nhttps://auth.openai.com/codex/device\\n2. Enter this one-time code (expires in 15 minutes)\\nABCD-EFGHJ\\n"', + ].join("\n")); + const dependency = path.join(root!, "node_modules/@openai/codex"); + await mkdir(path.join(dependency, "bin"), { recursive: true }); + await writeFile(path.join(dependency, "package.json"), JSON.stringify({ name: "@openai/codex", version: "0.1.0", bin: { codex: "bin/codex" } })); + await writeFile(path.join(dependency, "bin/codex"), await readFile(path.join(root!, "bin/codex")), { mode: 0o700 }); + const command = resolveFixtureCodexCommand(home); + expect(command).toBe(await realpath(path.join(dependency, "bin/codex"))); + vi.mocked(resolveCodexCommand).mockReturnValue(command); + process.env.PATH = "/usr/bin:/bin"; + const login = startLocalBrowserLogin("openai", home); + try { + await vi.waitFor(() => expect(login.outcome).toBeDefined(), { timeout: 5000 }); + expect(login.outcome).toBe("success"); + expect(await readFile(path.join(home, "owned-cli"), "utf8")).toBe("owned\n"); + expect(resolveCodexCommand).toHaveBeenCalledOnce(); + expect(process.report.getReport).not.toHaveBeenCalled(); + } finally { login.abort(); } + }); + + it.each(["2.38", "musl", "unknown"])("reports a real owned-dependency containment failure on Linux/%s without falling back to another CLI", async libc => { + targetPlatform("linux", "arm64", libc !== "musl", libc); + const home = await fakeCli("codex", 'echo invoked > "$CODEX_HOME/unreviewed-cli"'); + const dependency = path.join(root!, "node_modules/@openai/codex"); + await mkdir(dependency, { recursive: true }); + await writeFile(path.join(dependency, "package.json"), JSON.stringify({ name: "@openai/codex", version: "0.1.0", bin: { codex: "../outside-codex" } })); + let resolutionError: unknown; + try { resolveFixtureCodexCommand(home); } catch (error) { resolutionError = error; } + expect(resolutionError).toBeInstanceOf(Error); + expect((resolutionError as Error).message).toContain("executable escapes its package"); + vi.mocked(resolveCodexCommand).mockImplementation(() => { throw resolutionError; }); + const login = startLocalBrowserLogin("openai", home); + try { + await vi.waitFor(() => expect(login.outcome).toBeDefined(), { timeout: 5000 }); + expect(login.outcome).toBe("failure"); + expect(login.error).toMatch(/requires an installed Codex CLI.*Install Codex/); + expect(JSON.stringify(login)).not.toContain(root!); + await expect(readFile(path.join(home, "unreviewed-cli"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(resolveCodexCommand).toHaveBeenCalledOnce(); + } finally { login.abort(); } + }); + + it.each([["linux", "x64"], ["linux", "arm64"], ["darwin", "arm64"], ["darwin", "x64"]] as const)("reports an unavailable Codex CLI on %s/%s without revealing private details", async (platform, architecture) => { targetPlatform(platform, architecture); const home = await fakeCli("codex", 'echo invoked > "$CODEX_HOME/unreviewed-cli"'); const failure = new Error("private-installation-path-or-provider-output"); @@ -79,8 +153,8 @@ describe.skipIf(process.platform === "win32")("local browser subscription login" expect(login.authorizationUrl).toBeUndefined(); }); - it("reports a missing Linux ARM64 Codex CLI without revealing the host PATH", async () => { - targetPlatform("linux", "arm64"); + it("reports a missing Linux ARM32 Codex CLI without revealing the host PATH", async () => { + targetPlatform("linux", "arm"); const home = await fakeCli("codex", "exit 0"); process.env.PATH = "/usr/bin:/bin"; const login = startLocalBrowserLogin("openai", home); diff --git a/server/src/services/local-ai-browser-login.ts b/server/src/services/local-ai-browser-login.ts index ad176b8618..755a1e4157 100644 --- a/server/src/services/local-ai-browser-login.ts +++ b/server/src/services/local-ai-browser-login.ts @@ -56,10 +56,10 @@ export function startLocalBrowserLogin(provider: "anthropic" | "openai", home: s async start(_command: string, onData: (chunk: string) => void): Promise<{ exitCode: number | null }> { let executable = "claude"; if (provider === "openai") { - // Linux ARM64 retains its supported legacy CLI path; it is not a - // qualified native runner target. Other hosts prefer the installed - // dependency, then an existing CLI when the dependency is absent. - const legacyPlatform = process.platform === "linux" && process.arch === "arm64"; + // The installed Codex CLI is independent of the runner daemon's glibc floor. + // Supported CLI hosts use owned resolution; other platforms keep PATH lookup. + const legacyPlatform = !((process.platform === "linux" || process.platform === "darwin") + && (process.arch === "x64" || process.arch === "arm64")); try { if (legacyPlatform) { await ensureCommandResolvable("codex", home, process.env); diff --git a/server/src/services/native-runtime/native-codex-runner.test.ts b/server/src/services/native-runtime/native-codex-runner.test.ts index 1d1cc71020..f5341c8a73 100644 --- a/server/src/services/native-runtime/native-codex-runner.test.ts +++ b/server/src/services/native-runtime/native-codex-runner.test.ts @@ -9,9 +9,10 @@ import { buildNativeRunnerArguments, buildNativeRunnerPreparePayload, resolvePaperclipRunnerBinary, + resolveRemotePaperclipRunnerBinary, } from "./native-codex-runner.js"; -afterEach(() => vi.restoreAllMocks()); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); describe("installed runner selection", () => { it("uses the runner's platform-aware packaged selection", async () => { @@ -45,6 +46,121 @@ describe("installed runner selection", () => { }); }); +describe("selected remote daemon target", () => { + function header(target: string) { + const bytes = Buffer.alloc(64); + if (target.startsWith("darwin")) { + bytes.writeUInt32LE(0xfeedfacf); bytes.writeUInt32LE(target.endsWith("arm64") ? 0x0100000c : 0x01000007, 4); + } else { + bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(target.endsWith("arm64") ? 183 : 62, 18); + } + return bytes; + } + const remote = (stdout: string, overrides = {}) => ({ execute: vi.fn(async () => ({ exitCode: 0, signal: null, timedOut: false, stdout, stderr: "", pid: null, startedAt: null, ...overrides })) }); + it.each([ + ["Linux", "aarch64", "linux", "arm64"], ["Linux", "x86_64", "linux", "x64"], + ["Darwin", "arm64", "darwin", "arm64"], ["Darwin", "x86_64", "darwin", "x64"], + ] as const)("uses the packaged %s/%s daemon independent of the controller", async (os, arch, platform, architecture) => { + const root = await mkdtemp(join(tmpdir(), "paperclip-ssh-daemon-")); + const binary = join(root, "paperclip-runnerd"); + try { + await writeFile(binary, header(`${platform}-${architecture}`)); + const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary").mockReturnValue(binary); + const target = remote(`${os}\n${arch}\n`); + expect(await resolveRemotePaperclipRunnerBinary({ runner: target, remoteCwd: "/selected-workspace" })).toBe(binary); + expect(select).toHaveBeenCalledExactlyOnceWith(platform, architecture); + expect(target.execute).toHaveBeenCalledExactlyOnceWith({ command: "sh", args: ["-c", "uname -s; uname -m"], cwd: "/selected-workspace", bypassSession: true, timeoutMs: 10_000 }); + } finally { await rm(root, { recursive: true, force: true }); } + }); + it.each(["arm64", "x64"].flatMap(controllerArch => ["arm64", "x64"].map(targetArch => ({ controllerArch, targetArch }))))( + "selects the actual Linux $targetArch sandbox bytes on a Mac $controllerArch controller", async ({ controllerArch, targetArch }) => { + const root = await mkdtemp(join(tmpdir(), "paperclip-sandbox-daemon-")); + const binary = join(root, "paperclip-runnerd"); + try { + vi.stubGlobal("process", { ...process, platform: "darwin", arch: controllerArch }); + await writeFile(binary, header(`linux-${targetArch}`)); + const controllerBinary = join(root, "controller-runnerd"); + await writeFile(controllerBinary, header(`darwin-${controllerArch}`), { mode: 0o700 }); + vi.stubEnv("PAPERCLIP_RUNNER_BINARY", controllerBinary); + const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary").mockReturnValue(binary); + const target = remote(`Linux\n${targetArch === "arm64" ? "aarch64" : "x86_64"}\n`); + expect(await resolveRemotePaperclipRunnerBinary({ runner: target, remoteCwd: "/sandbox-workspace" })).toBe(binary); + expect(select).toHaveBeenCalledExactlyOnceWith("linux", targetArch); + expect(target.execute).toHaveBeenCalledExactlyOnceWith({ command: "sh", args: ["-c", "uname -s; uname -m"], cwd: "/sandbox-workspace", bypassSession: true, timeoutMs: 10_000 }); + } finally { await rm(root, { recursive: true, force: true }); } + }, + ); + it.each(["linux", "darwin"].flatMap(platform => ["arm64", "x64"].map(architecture => ({ platform, architecture }))))( + "prefers the supervisor's fresh $platform/$architecture override for the same-host target", async ({ platform, architecture }) => { + const root = await mkdtemp(join(tmpdir(), "paperclip-same-host-daemon-")); + try { + vi.stubGlobal("process", { ...process, platform, arch: architecture }); + const fresh = join(root, "fresh-runnerd"), stale = join(root, "stale-packaged-runnerd"); + await writeFile(fresh, Buffer.concat([header(`${platform}-${architecture}`), Buffer.from("fresh")]), { mode: 0o700 }); + await writeFile(stale, Buffer.concat([header(`${platform}-${architecture}`), Buffer.from("stale")]), { mode: 0o700 }); + vi.stubEnv("PAPERCLIP_RUNNER_BINARY", fresh); + const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary").mockReturnValue(stale); + const target = remote(`${platform === "linux" ? "Linux" : "Darwin"}\n${architecture === "arm64" ? "arm64" : "x86_64"}\n`); + expect(await resolveRemotePaperclipRunnerBinary({ runner: target, remoteCwd: "/workspace" })).toBe(fresh); + expect(select).not.toHaveBeenCalled(); + } finally { await rm(root, { recursive: true, force: true }); } + }, + ); + it.each(["missing", "relative", "malformed", "wrong-architecture"])("retains same-host override policy and target validation (%s)", async scenario => { + const root = await mkdtemp(join(tmpdir(), "paperclip-same-host-daemon-")); + try { + vi.stubGlobal("process", { ...process, platform: "linux", arch: "x64" }); + const override = join(root, "configured-runnerd"), fallback = join(root, "packaged-runnerd"); + if (scenario !== "missing") await writeFile(override, scenario === "malformed" ? Buffer.alloc(64) : header("linux-arm64"), { mode: 0o700 }); + await writeFile(fallback, header("linux-x64"), { mode: 0o700 }); + vi.stubEnv("PAPERCLIP_RUNNER_BINARY", scenario === "relative" ? "relative-runnerd" : override); + vi.spyOn(runner, "defaultCapabilityRunnerdBinary").mockReturnValue(fallback); + const request = resolveRemotePaperclipRunnerBinary({ runner: remote("Linux\nx86_64\n"), remoteCwd: "/workspace" }); + if (scenario === "missing") expect(await request).toBe(fallback); + else await expect(request).rejects.toThrow(scenario === "relative" ? "PAPERCLIP_RUNNER_BINARY must be an absolute path" : "runner_remote_artifact_platform_mismatch"); + } finally { await rm(root, { recursive: true, force: true }); } + }); + it("ignores a relative controller override when the actual target differs", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-cross-host-daemon-")); + try { + vi.stubGlobal("process", { ...process, platform: "darwin", arch: "arm64" }); + vi.stubEnv("PAPERCLIP_RUNNER_BINARY", "relative-controller-runnerd"); + const binary = join(root, "linux-runnerd"); + await writeFile(binary, header("linux-arm64")); + const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary").mockReturnValue(binary); + expect(await resolveRemotePaperclipRunnerBinary({ runner: remote("Linux\naarch64\n"), remoteCwd: "/workspace" })).toBe(binary); + expect(select).toHaveBeenCalledExactlyOnceWith("linux", "arm64"); + } finally { await rm(root, { recursive: true, force: true }); } + }); + it.each(["matching", "wrong-architecture", "malformed", "missing"])("verifies an explicit controller-owned remote artifact (%s)", async scenario => { + const root = await mkdtemp(join(tmpdir(), "paperclip-ssh-daemon-")); + const binary = join(root, "configured-runnerd"); + try { + vi.stubGlobal("process", { ...process, platform: "linux", arch: "arm64" }); + vi.stubEnv("PAPERCLIP_RUNNER_BINARY", "invalid-relative-controller-override"); + if (scenario !== "missing") await writeFile(binary, scenario === "malformed" ? Buffer.alloc(64) : header(scenario === "matching" ? "linux-arm64" : "linux-x64")); + const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary"); + const request = resolveRemotePaperclipRunnerBinary({ runner: remote("Linux\naarch64\n"), remoteCwd: "/workspace", configuredPath: binary }); + if (scenario === "matching") expect(await request).toBe(binary); + else await expect(request).rejects.toThrow(scenario === "missing" ? "runner_remote_artifact_unavailable" : "runner_remote_artifact_platform_mismatch"); + expect(select).not.toHaveBeenCalled(); + } finally { await rm(root, { recursive: true, force: true }); } + }); + it("keeps missing package targets actionable without a controller or legacy fallback", async () => { + const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary").mockImplementation(() => { throw new Error("runner_remote_artifact_unavailable: install the matching release"); }); + await expect(resolveRemotePaperclipRunnerBinary({ runner: remote("Linux\naarch64\n"), remoteCwd: "/workspace" })).rejects.toThrow("runner_remote_artifact_unavailable"); + expect(select).toHaveBeenCalledExactlyOnceWith("linux", "arm64"); + }); + it.each([ + ["Linux\naarch64\n", { exitCode: 1 }], ["Linux\naarch64\n", { timedOut: true }], + ["Linux\nriscv64\n", {}], ["FreeBSD\nx86_64\n", {}], ["", {}], + ])("fails a missing, failed or unsupported platform probe (%s)", async (stdout, overrides) => { + const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary"); + await expect(resolveRemotePaperclipRunnerBinary({ runner: remote(stdout as string, overrides), remoteCwd: "/workspace" })).rejects.toThrow("runner_remote_artifact_platform_mismatch"); + expect(select).not.toHaveBeenCalled(); + }); +}); + describe("buildNativeRunnerArguments", () => { it("binds every durable identity without exposing the bootstrap ticket", () => { const args = buildNativeRunnerArguments({ diff --git a/server/src/services/native-runtime/native-codex-runner.ts b/server/src/services/native-runtime/native-codex-runner.ts index 14d25ed9a4..c5cda8a994 100644 --- a/server/src/services/native-runtime/native-codex-runner.ts +++ b/server/src/services/native-runtime/native-codex-runner.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { and, eq } from "drizzle-orm"; import type { AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime"; import type { Db } from "@paperclipai/db"; import { agentSessionGoalActions, agentTaskSessions } from "@paperclipai/db"; @@ -14,6 +15,7 @@ import { failRunnerGoalAction } from "../runner-goals.js"; import { createPaperclipRunnerAuthorizedToolSet, defaultCapabilityRunnerdBinary, + runnerBinaryTarget, type PaperclipSemanticToolDefinition, } from "../../vendor/paperclip-runner/index.js"; import { runnerPrpCoordinator } from "./runner-prp-coordinator.js"; @@ -206,6 +208,38 @@ export function resolvePaperclipRunnerBinary( ); } +/** Select controller-owned bytes for the actual remote host, never its controller. */ +export async function resolveRemotePaperclipRunnerBinary(input: { + runner: CommandManagedRuntimeRunner; + remoteCwd: string; + configuredPath?: string | null; +}): Promise { + const probe = await input.runner.execute({ command: "sh", args: ["-c", "uname -s; uname -m"], + cwd: input.remoteCwd, bypassSession: true, timeoutMs: 10_000 }); + const [os, arch] = probe.stdout.trim().split(/\r?\n/); + const platform = os === "Linux" ? "linux" : os === "Darwin" ? "darwin" : null; + const architecture = arch === "x86_64" ? "x64" : arch === "aarch64" || arch === "arm64" ? "arm64" : null; + if (probe.exitCode !== 0 || probe.timedOut || !platform || !architecture) { + throw new Error("runner_remote_artifact_platform_mismatch: could not identify a supported remote OS and architecture; check the selected execution environment and retry"); + } + const configured = input.configuredPath?.trim(); + if (configured && !isAbsolute(configured)) throw new Error("PAPERCLIP_RUNNER_REMOTE_BINARY_PATH must be an absolute path"); + // A same-host sandbox can use the supervisor's fresh development artifact. + // Cross-platform targets must never inherit the controller's override. + const controllerOverride = platform === process.platform && architecture === process.arch + ? process.env.PAPERCLIP_RUNNER_BINARY : undefined; + const binary = configured || (controllerOverride + ? resolvePaperclipRunnerBinary(controllerOverride) + : defaultCapabilityRunnerdBinary(platform, architecture)); + let bytes: Buffer; + try { bytes = readFileSync(binary); } + catch (cause) { throw new Error(`runner_remote_artifact_unavailable: install the Paperclip release containing the ${platform}-${architecture} daemon or configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH`, { cause }); } + if (runnerBinaryTarget(bytes) !== `${platform}-${architecture}`) { + throw new Error(`runner_remote_artifact_platform_mismatch: the selected daemon does not match ${platform}-${architecture}; install the matching Paperclip release or configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH`); + } + return binary; +} + export function buildNativeRunnerArguments(input: { connectUrl: string; stateDirectory: string; diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 52844fcdd6..d49205ae65 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -206,6 +206,7 @@ const state = vi.hoisted(() => ({ bundledPackManifestPath: null as string | null, bundledRunnerBinary: null as string | null, resolveRunnerBinary: vi.fn(() => "/tmp/paperclip-runnerd"), + resolveRemoteRunnerBinary: vi.fn(async (input: { configuredPath?: string | null }) => input.configuredPath || "/tmp/paperclip-runnerd"), release: null as null | (() => void), })); @@ -300,6 +301,7 @@ vi.mock("../issue-recovery-actions.js", () => ({ vi.mock("./native-codex-runner.js", () => ({ resolvePaperclipRunnerBinary: state.resolveRunnerBinary, + resolveRemotePaperclipRunnerBinary: state.resolveRemoteRunnerBinary, })); import { @@ -373,6 +375,7 @@ beforeEach(() => { state.bundledRunnerBinary = null; state.createAssignedMcpTools.mockReset(); state.resolveRunnerBinary.mockReset().mockReturnValue("/tmp/paperclip-runnerd"); + state.resolveRemoteRunnerBinary.mockReset().mockImplementation(async (input: { configuredPath?: string | null }) => input.configuredPath || "/tmp/paperclip-runnerd"); state.resolveCurrentWakeCommentsBinding.mockReset().mockResolvedValue(null); state.assertCurrentWakeCommentsRead.mockReset().mockResolvedValue(undefined); }); @@ -12203,22 +12206,20 @@ describe("runnerd provider runtime wiring", () => { it.each([ ...["current", "preinstalled-exact", "preinstalled-mismatch", "preinstalled-error", "preinstalled-timeout", "stale", "missing", "retained", "retained-mismatch", "retained-error", "retained-timeout", "retained-explicit"] - .map((image) => ({ image, version: "0.160.0", compatible: true })), + .map((image) => ({ image, version: "0.160.0" })), ...["0.149.0", "0.149.1", "0.153.4", "0.156.0", "0.160.1"] - .map((version) => ({ image: "current", version, compatible: true })), + .map((version) => ({ image: "current", version })), ...["0.148.9", "0.161.0", "1.0.0", "0.160.0-alpha.1", "unknown"] - .map((version) => ({ image: "current", version, compatible: false })), - ])("uses shared Codex and the server-owned replacement artifact (image=$image, Codex=$version)", async ({ image, version, compatible }) => { + .map((version) => ({ image: "current", version })), + ])("uses shared Codex and the server-owned replacement artifact (image=$image, Codex=$version)", async ({ image, version }) => { const retained = image.startsWith("retained"); const exactRetained = image === "retained" || image === "retained-explicit"; const needsReplacement = image !== "current" && image !== "preinstalled-exact" && !exactRetained; // The mocked remote executes metadata probes; artifact staging only needs bytes. // Keep this regression independent of a locally compiled Rust runner binary. const controllerArtifact = join(isolatedStateDirectory, "paperclip-runnerd"); - if (needsReplacement || retained || image === "preinstalled-exact") { - await writeFile(controllerArtifact, "fixture runner artifact"); - state.resolveRunnerBinary.mockReturnValueOnce(controllerArtifact); - } + await writeFile(controllerArtifact, "fixture runner artifact"); + state.resolveRemoteRunnerBinary.mockResolvedValue(controllerArtifact); const onLog = vi.fn(async () => undefined); const syncIn = vi.fn(async () => undefined); const remoteExecute = vi.fn( @@ -12300,16 +12301,10 @@ describe("runnerd provider runtime wiring", () => { controlPlaneRegistration: (authority: unknown) => Promise; }; await expect(transport.controlPlaneRegistration({})).rejects.toThrow( - compatible ? "reached-preinstalled-codex-verification" : "runner_remote_provider_artifact_incompatible: supported Codex versions >=0.149.0 <0.161.0", + "reached-preinstalled-codex-verification", ); - if (!compatible) { - expect(syncIn).not.toHaveBeenCalled(); - expect(remoteExecute.mock.calls.some(([call]) => call.command === "npm" || call.args?.[1]?.includes("paperclip_codex_launcher_tmp"))).toBe(false); - expect(onLog).not.toHaveBeenCalledWith("stderr", expect.stringContaining("using compatible Codex")); - return; - } if (version !== "0.160.0") { - expect(onLog).toHaveBeenCalledWith("stderr", expect.stringContaining(`using compatible Codex ${version}`)); + expect(onLog).toHaveBeenCalledWith("stderr", expect.stringContaining(`using installed Codex ${version === "unknown" || version.includes("alpha") ? "unrecognized version" : version}`)); } if (needsReplacement) { expect(transport.runnerBinary).toBe(controllerArtifact); @@ -12355,12 +12350,9 @@ describe("runnerd provider runtime wiring", () => { ].map((scenario) => ({ ...scenario, recover: false })), { version: "0.156.0", model: "gpt-6.1-sol", floor: null, recover: true }, { version: "0.158.0", model: "gpt-6.1-sol", floor: null, recover: true }, - ])("verifies sandbox Codex $version against the prepared $model selection (floor=$floor, recover=$recover)", async ({ version, model, floor, recover }) => { - // A preinstalled Codex inside the compatibility window can still be too - // old for the configured model: the ChatGPT backend rejects every turn - // with "not supported when using Codex with a ChatGPT account". The - // verifier names the stale image instead of letting the run fail as an - // account problem. + ])("lets sandbox Codex $version reach actual model verification ($model, recover=$recover)", async ({ version, model, recover }) => { + // Version metadata is diagnostic. A working app-server reaches the model + // turn instead of being rejected or replaced by a static CLI version floor. const preparedModel = recover ? resolvePaperclipRunnerNativeProviderInput({ backend: "codex_app_server", adapterConfig: { provider: "codex", model }, codexCliVersion: version, }).model : model; @@ -12430,32 +12422,33 @@ describe("runnerd provider runtime wiring", () => { ); } await expect(transport.controlPlaneRegistration({})).rejects.toThrow( - floor - ? `runner_remote_provider_artifact_incompatible: ${model} requires Codex ${floor} or newer with ChatGPT sign-in, received ${version} from the sandbox image; promote a sandbox image with Codex 0.160.0 or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC=@openai/codex@0.160.0` - : "reached-preinstalled-codex-verification", + "reached-preinstalled-codex-verification", ); - if (floor) { - // No npm spec is configured, so there is no fallback install; the run - // fails before launch instead of running against the stale CLI. - expect(remoteExecute.mock.calls.some(([call]) => call.command === "npm")).toBe(false); - expect(remoteExecute.mock.calls.some(([call]) => call.args?.[1]?.includes("paperclip_codex_launcher_tmp"))).toBe(false); - } + expect(remoteExecute.mock.calls.some(([call]) => call.command === "npm")).toBe(false); + }); - it("binds a remote launch to the configured controller-owned runner artifact", async () => { + it.each(["ssh", "sandbox"].flatMap(transport => ["explicit", "linux-arm64", "linux-x64", "missing", "mismatch"].map(mode => ({ transport, mode })))) + ("binds a $transport launch to the selected controller-owned runner artifact ($mode)", async ({ transport, mode }) => { const remoteCwd = "/home/daytona/paperclip-workspace"; - const controllerArtifact = "/controller/artifacts/paperclip-runnerd"; + const controllerArtifact = `/controller/artifacts/${mode}/paperclip-runnerd`; + const explicit = mode === "explicit" || mode === "missing" || mode === "mismatch"; + const error = mode === "missing" ? "runner_remote_artifact_unavailable" : mode === "mismatch" ? "runner_remote_artifact_platform_mismatch" : null; + if (error) state.resolveRemoteRunnerBinary.mockRejectedValue(new Error(error)); + else state.resolveRemoteRunnerBinary.mockResolvedValue(controllerArtifact); const remoteExecution = { ...execution, binding: { ...execution.binding, runId: "run-remote-runner-artifact" }, workspace: { ...execution.workspace, cwd: "/host/paperclip-workspace" }, } as NativeExecutionInputV1; - await createRunnerdBackend({ + const backend = createRunnerdBackend({ db: leaseDb(remoteExecution), execution: remoteExecution, runnerInstanceId: "runner", - runnerExecutionTarget: { + runnerExecutionTarget: transport === "sandbox" ? { + kind: "remote", transport: "sandbox", providerKey: "test", remoteCwd, runner: { execute: vi.fn() }, + } : { kind: "remote", transport: "ssh", remoteCwd, @@ -12470,14 +12463,26 @@ describe("runnerd provider runtime wiring", () => { strictHostKeyChecking: true, }, }, - runnerRemoteBinaryPath: controllerArtifact, + ...(explicit ? { runnerRemoteBinaryPath: controllerArtifact } : {}), }); + if (error) { + await expect(backend).rejects.toThrow(error); + expect(state.resolveRemoteRunnerBinary).toHaveBeenCalledWith(expect.objectContaining({ remoteCwd, configuredPath: controllerArtifact })); + expect(state.resolveRunnerBinary).not.toHaveBeenCalled(); + expect(state.createBackend).not.toHaveBeenCalled(); + expect(state.createTransport).not.toHaveBeenCalled(); + return; + } + await backend; + state.createTransport.mockClear(); state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!(); expect(state.createTransport).toHaveBeenCalledWith( expect.objectContaining({ runnerBinary: controllerArtifact }), ); + expect(state.resolveRemoteRunnerBinary).toHaveBeenCalledWith(expect.objectContaining({ remoteCwd, configuredPath: explicit ? controllerArtifact : undefined })); + expect(state.resolveRunnerBinary).not.toHaveBeenCalled(); }); it.each([ diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 92501cfa94..b0fb8b56c9 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -18,11 +18,7 @@ import { copyBackGrokAuth } from "@paperclipai/adapter-grok-local/server"; import { inferOpenAiCompatibleBiller, type AdapterUsageCheckpoint } from "@paperclipai/adapter-utils"; import { createAgentIdentityRedactor } from "../agent-identity-redaction.js"; -import { - isSupportedRemoteCodexVersion, - parseCodexCliVersion, - REMOTE_CODEX_SUPPORTED_RANGE, -} from "./codex-runtime-compatibility.js"; +import { parseCodexCliVersion } from "./codex-runtime-compatibility.js"; import { createNativeToolTrace, type NativeToolTrace } from "./native-tool-trace.js"; import { createNativeProviderFailureObservation } from "./native-provider-failure.js"; import { createNativeGitHubAccess, type NativeGitHubAccess } from "./native-github-access.js"; @@ -31,11 +27,6 @@ import { bindManagedNativeCredentialTurn, completeManagedNativeCredentialTurn } import { createLocalNativeQuestionBridge } from "./local-native-question-bridge.js"; import { readVerifiedRemoteWorkspaceFile } from "./remote-deliverable-file.js"; import { copyBackCodexAuth } from "@paperclipai/adapter-codex-local/server"; -import { - codexCliVersionAtLeast, - minimumCodexCliVersionForModel, - normalizeCodexModel, -} from "@paperclipai/adapter-codex-local"; import { nativeCompletionFeedback } from "./native-completion-feedback.js"; import { hasAcknowledgedNativeReassignmentStopIntent, hasAcknowledgedNativeStopIntent } from "../acknowledged-native-stop.js"; import { stoppedCodexTurnIsTextOnly } from "./stopped-codex-turn.js"; @@ -181,7 +172,7 @@ import { } from "./status-arbiter.js"; import { conflict, HttpError } from "../../errors.js"; import { redactSensitiveText } from "../../redaction.js"; -import { resolvePaperclipRunnerBinary } from "./native-codex-runner.js"; +import { resolvePaperclipRunnerBinary, resolveRemotePaperclipRunnerBinary } from "./native-codex-runner.js"; import { createNativeRunTrace, isNativeRunRootHistoricalSpan, @@ -10193,7 +10184,7 @@ export function assertRemoteRunnerBuildMetadata( } } -async function stageRemoteRunnerFile(input: { +export async function stageRemoteRunnerFile(input: { target: Extract; runner: CommandManagedRuntimeRunner; sourcePath: string; @@ -10585,7 +10576,7 @@ export async function syncRemoteRunnerDirectoryOut(input: { } } -async function readRemoteRunnerState(input: { +export async function readRemoteRunnerState(input: { runner: CommandManagedRuntimeRunner; stateDirectory: string; }): Promise> { @@ -11560,7 +11551,10 @@ async function createRunnerdBackendWithinSessionClaim( // filesystem; the remote launcher separately owns the sandbox command path. // When an explicit remote artifact is configured, prepareRemoteRunner stages // these exact bytes at remoteBinary before launch. - const controllerRunnerBinary = remoteTarget + const targetMatchedCodexArtifact = remoteTarget !== null && input.execution.provider.kind === "codex"; + const controllerRunnerBinary = targetMatchedCodexArtifact + ? await resolveRemotePaperclipRunnerBinary({ runner: remoteCommandRunner!, remoteCwd: remoteTarget.remoteCwd, configuredPath: input.runnerRemoteBinaryPath }) + : remoteTarget ? input.runnerRemoteBinaryPath?.trim() || remotePiCompanion?.runnerBinary || (useBundledRemoteImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary()) : resolvePaperclipRunnerBinary(); const explicitRemoteCodex = input.runnerRemoteCodexPath?.trim() || null; @@ -11651,76 +11645,6 @@ async function createRunnerdBackendWithinSessionClaim( }) : Promise.resolve(); - const verifyRemoteRunner = async ( - requiredMode: "dial_wss" | "listen_ws", - executable = remoteBinary, - ) => { - if (!remoteTarget || !remoteCommandRunner || !executable) return; - const metadataResult = await remoteCommandRunner.execute({ - command: executable, - args: ["--build-metadata"], - cwd: remoteTarget.remoteCwd, - bypassSession: true, - timeoutMs: 30_000, - }); - if (metadataResult.exitCode !== 0 || metadataResult.timedOut) { - throw new Error("runner_remote_artifact_verification_failed"); - } - let metadata: Record; - try { - metadata = JSON.parse(metadataResult.stdout) as Record; - } catch (error) { - throw new Error("runner_remote_artifact_metadata_invalid", { - cause: error, - }); - } - assertRemoteRunnerBuildMetadata(metadata, requiredMode); - }; - - const reportedCodexVersions = new Set(); - const verifyRemoteCodex = async (executable = remoteCodexBinary) => { - if (!remoteTarget || !remoteCommandRunner || !executable) return; - const versionResult = await remoteCommandRunner.execute({ - command: executable, - args: ["--version"], - cwd: remoteTarget.remoteCwd, - bypassSession: true, - timeoutMs: 30_000, - }); - if (versionResult.exitCode !== 0 || versionResult.timedOut) { - throw new Error("runner_remote_codex_artifact_verification_failed"); - } - const versionOutput = `${versionResult.stdout}\n${versionResult.stderr}`; - const version = parseCodexCliVersion(versionOutput); - if (!version || !isSupportedRemoteCodexVersion(version)) { - throw new Error( - `runner_remote_provider_artifact_incompatible: supported Codex versions ${REMOTE_CODEX_SUPPORTED_RANGE}, received ${version ?? "an unrecognized or prerelease version"}; install a supported stable Codex release or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC=@openai/codex@${REMOTE_PROVIDER_PACK_PINS.codex}`, - ); - } - // A Codex inside the compatibility window can still be too old for the - // configured model: the ChatGPT backend rejects a model from clients - // below the model's floor on every turn. Fail before launch with the - // exact gap, so a stale sandbox image is not reported as an account - // problem. When a preinstalled Codex fails here and an npm spec is - // configured, the caller falls back to installing the pinned release. - const configuredModel = input.execution.provider.kind === "codex" - ? input.execution.provider.model - : null; - const modelMinimum = minimumCodexCliVersionForModel(configuredModel); - if (modelMinimum && !codexCliVersionAtLeast(version, modelMinimum)) { - throw new Error( - `runner_remote_provider_artifact_incompatible: ${normalizeCodexModel(configuredModel)} requires Codex ${modelMinimum} or newer with ChatGPT sign-in, received ${version} from the sandbox image; promote a sandbox image with Codex ${REMOTE_PROVIDER_PACK_PINS.codex} or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC=@openai/codex@${REMOTE_PROVIDER_PACK_PINS.codex}`, - ); - } - if (version !== REMOTE_PROVIDER_PACK_PINS.codex && !reportedCodexVersions.has(version)) { - reportedCodexVersions.add(version); - await input.onLog?.( - "stderr", - `[paperclip-runner] using compatible Codex ${version} (supported ${REMOTE_CODEX_SUPPORTED_RANGE}; install pin ${REMOTE_PROVIDER_PACK_PINS.codex})\n`, - ); - } - }; - const verifyRemoteProviderPack = async (packRoot: string) => { if (!remoteTarget || !remoteCommandRunner || !expectedProviderPackManifest) return; @@ -11805,41 +11729,20 @@ async function createRunnerdBackendWithinSessionClaim( return parseRemoteExecutableCandidate(result.stdout); }; - // Image policy: keep one latest stable CLI installation shared by native and - // local adapters. Preferred bin entries must point to that same installation; - // never bake an older global CLI alongside a private runner-only version. - const discoverPreinstalledExecutable = async ( - name: "paperclip-runnerd" | "codex", - ) => { - if (!remoteTarget || !remoteCommandRunner) return null; - return discoverRemoteExecutable(remoteCommandRunner, remoteTarget.remoteCwd, name); - }; - - const linkPreinstalledExecutable = async ( - sourcePath: string, - targetPath: string, - ) => { - if (!remoteTarget || !remoteCommandRunner) return; - const escapedSource = sourcePath.replaceAll("'", "'\\''"); - const escapedTarget = targetPath.replaceAll("'", "'\\''"); - const escapedDirectory = posix.dirname(targetPath).replaceAll("'", "'\\''"); - const result = await remoteCommandRunner.execute({ - command: "sh", - args: [ - "-c", - targetPath === remoteCodexBinary - ? buildRemoteCodexLauncherCommand(sourcePath, targetPath) - : `umask 077; mkdir -p '${escapedDirectory}' && ` + - `ln -sfn '${escapedSource}' '${escapedTarget}'`, - ], - cwd: remoteTarget.remoteCwd, - bypassSession: true, - timeoutMs: 10_000, - }); - if (result.exitCode !== 0 || result.timedOut) { - throw new Error("runner_remote_preinstalled_link_failed"); - } + const remoteNativeArtifacts = remoteTarget && remoteCommandRunner && remoteBinary && remoteRuntimeRoot + ? createRemoteNativeArtifactPreparation({ + target: remoteTarget, runner: remoteCommandRunner, remoteBinary, controllerRunnerBinary, + remoteRuntimeRoot, remoteCodexBinary, runnerRemoteBinaryPath: input.runnerRemoteBinaryPath, + runnerRemoteCodexPath: input.runnerRemoteCodexPath, runnerRemoteCodexNpmSpec: input.runnerRemoteCodexNpmSpec, + model: input.execution.provider.kind === "codex" ? input.execution.provider.model : null, + reuseRetainedRunner: sandboxLeaseAcquisition?.outcome === "resumed", targetPlatformArtifactVerified: remotePiCompanion !== null || useBundledRemoteImageAssets || targetMatchedCodexArtifact, + trace: input.trace, onLog: input.onLog, + }) + : null; + const verifyRemoteRunner = async (mode: "dial_wss" | "listen_ws", executable?: string | null) => { + if (remoteNativeArtifacts) await remoteNativeArtifacts.verifyRemoteRunner(mode, executable ?? undefined); }; + const verifyRemoteCodex = async () => { await remoteNativeArtifacts?.verifyRemoteCodex(); }; const prepareRemoteRunner = async ( requiredMode: "dial_wss" | "listen_ws", @@ -11861,234 +11764,7 @@ async function createRunnerdBackendWithinSessionClaim( remotePrepared = true; return; } - // Compatibility metadata does not prove artifact identity. Reuse only the - // exact controller-owned bytes when the controller artifact is available. - const matchesControllerRunnerArtifact = async (executable: string): Promise => { - const expected = createHash("sha256") - .update(readFileSync(controllerRunnerBinary)) - .digest("hex"); - try { - const probe = await remoteCommandRunner.execute({ - command: "sh", - args: [ - "-c", - 'test -x "$1" || exit 1; if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1"; else shasum -a 256 "$1"; fi', - "paperclip-runner-artifact", - executable, - ], - cwd: remoteTarget.remoteCwd, - bypassSession: true, - timeoutMs: 10_000, - }); - return probe.exitCode === 0 && !probe.timedOut && - /^[a-f0-9]{64}\s/.test(probe.stdout) && - probe.stdout.trim().split(/\s+/)[0] === expected; - } catch { - // An unavailable checksum uses the verified staging path. - return false; - } - }; - let runnerArtifactPrepared = false; - if ( - sandboxLeaseAcquisition?.outcome === "resumed" && - existsSync(controllerRunnerBinary) - ) { - runnerArtifactPrepared = await measureNativeRunnerSpan( - input.trace, - "runner.artifact.verify_retained", - () => matchesControllerRunnerArtifact(remoteBinary), - ); - } - const explicitRemoteBinary = input.runnerRemoteBinaryPath?.trim() || null; - if (!runnerArtifactPrepared && mayUsePreinstalledRunnerArtifact(explicitRemoteBinary)) { - const preinstalledRunner = await measureNativeRunnerSpan( - input.trace, - "runner.artifact.discover", - () => discoverPreinstalledExecutable("paperclip-runnerd"), - ); - if (preinstalledRunner) { - try { - await measureNativeRunnerSpan( - input.trace, - "runner.artifact.verify_preinstalled", - () => verifyRemoteRunner(requiredMode, preinstalledRunner), - ); - if (existsSync(controllerRunnerBinary) && - !await matchesControllerRunnerArtifact(preinstalledRunner)) { - throw new Error("runner_remote_preinstalled_artifact_mismatch"); - } - await measureNativeRunnerSpan( - input.trace, - "runner.artifact.link", - () => linkPreinstalledExecutable(preinstalledRunner, remoteBinary), - ); - runnerArtifactPrepared = true; - await input.onLog?.( - "stderr", - "[paperclip-runner] using preinstalled runnerd from the sandbox image\n", - ); - } catch { - runnerArtifactPrepared = false; - } - } - } - if (!runnerArtifactPrepared) { - // Upload the same artifact used for the controller identity. The server - // vendors the runner under vendor/paperclip-runner/bin, so the package - // development fallback cannot locate it in a deployed server. - const sourceBinary = controllerRunnerBinary; - if (!existsSync(sourceBinary)) { - throw new Error("runner_remote_artifact_unavailable"); - } - if (!explicitRemoteBinary && !remotePiCompanion && !useBundledRemoteImageAssets) { - const platform = await remoteCommandRunner.execute({ - command: "sh", - args: ["-c", "uname -s; uname -m"], - cwd: remoteTarget.remoteCwd, - bypassSession: true, - timeoutMs: 10_000, - }); - const [remoteOs = "", remoteArch = ""] = platform.stdout - .trim() - .split(/\r?\n/); - const localOs = - process.platform === "darwin" - ? "Darwin" - : process.platform === "linux" - ? "Linux" - : process.platform; - const localArch = - process.arch === "x64" - ? "x86_64" - : process.arch === "arm64" - ? "aarch64" - : process.arch; - const archMatches = - remoteArch === localArch || - (localArch === "aarch64" && remoteArch === "arm64"); - if ( - platform.exitCode !== 0 || - platform.timedOut || - remoteOs !== localOs || - !archMatches - ) { - throw new Error( - "runner_remote_artifact_platform_mismatch: configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH for the remote OS and architecture", - ); - } - } - await stageRemoteRunnerFile({ - target: remoteTarget, - runner: remoteCommandRunner, - sourcePath: sourceBinary, - targetPath: remoteBinary, - mode: 0o700, - }); - } - await measureNativeRunnerSpan(input.trace, "runner.artifact.verify", () => - verifyRemoteRunner(requiredMode), - ); - if (remoteCodexBinary && explicitRemoteCodex) { - if (!existsSync(explicitRemoteCodex)) { - throw new Error("runner_remote_codex_artifact_unavailable"); - } - await stageRemoteRunnerFile({ - target: remoteTarget, - runner: remoteCommandRunner, - sourcePath: explicitRemoteCodex, - targetPath: remoteCodexBinary, - mode: 0o700, - }); - await verifyRemoteCodex(); - } - if (remoteCodexBinary && remoteCodexNpmSpec) { - let usedPreinstalledCodex = false; - const preinstalledCodex = await measureNativeRunnerSpan( - input.trace, - "harness.artifact.discover", - () => discoverPreinstalledExecutable("codex"), - ); - if (preinstalledCodex) { - try { - await measureNativeRunnerSpan( - input.trace, - "harness.artifact.verify_preinstalled", - () => verifyRemoteCodex(preinstalledCodex), - ); - await measureNativeRunnerSpan( - input.trace, - "harness.artifact.link", - () => - linkPreinstalledExecutable(preinstalledCodex, remoteCodexBinary), - ); - usedPreinstalledCodex = true; - await input.onLog?.( - "stderr", - "[paperclip-runner] using preinstalled Codex from the sandbox image\n", - ); - } catch { - usedPreinstalledCodex = false; - } - } - if (!usedPreinstalledCodex) { - const installRoot = posix.join( - remoteRuntimeRoot!, - "harnesses", - "codex", - ); - const installResult = await remoteCommandRunner.execute({ - command: "npm", - args: [ - "install", - "--prefix", - installRoot, - "--no-audit", - "--no-fund", - remoteCodexNpmSpec, - ], - cwd: remoteTarget.remoteCwd, - bypassSession: true, - timeoutMs: 180_000, - }); - if (installResult.exitCode !== 0 || installResult.timedOut) { - throw new Error("runner_remote_codex_install_failed"); - } - } - await measureNativeRunnerSpan( - input.trace, - "harness.artifact.verify", - () => verifyRemoteCodex(), - ); - } - if (remoteCodexBinary && !explicitRemoteCodex && !remoteCodexNpmSpec) { - const preinstalledCodex = await measureNativeRunnerSpan( - input.trace, - "harness.artifact.discover", - () => discoverPreinstalledExecutable("codex"), - ); - if (!preinstalledCodex) { - throw new Error( - "runner_remote_codex_artifact_unavailable: install codex in the sandbox image or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC", - ); - } - await measureNativeRunnerSpan( - input.trace, - "harness.artifact.verify_preinstalled", - () => verifyRemoteCodex(preinstalledCodex), - ); - await measureNativeRunnerSpan(input.trace, "harness.artifact.link", () => - linkPreinstalledExecutable(preinstalledCodex, remoteCodexBinary), - ); - await measureNativeRunnerSpan( - input.trace, - "harness.artifact.verify", - () => verifyRemoteCodex(), - ); - await input.onLog?.( - "stderr", - "[paperclip-runner] using preinstalled Codex from the sandbox image\n", - ); - } + await remoteNativeArtifacts?.prepare(requiredMode); if ( requiresRemoteProviderPack && expectedProviderPackManifest && @@ -13712,3 +13388,350 @@ async function createRunnerdBackendWithinSessionClaim( }, } satisfies NativeSessionBackend & { bindManagedSession(session: NativeSession): NativeSession }; } + +/** Shared task/setup preparation; setup exercises the same remote artifact contract. */ +export function createRemoteNativeArtifactPreparation(input: { + target: Extract; + runner: CommandManagedRuntimeRunner; + remoteBinary: string; + controllerRunnerBinary: string; + remoteRuntimeRoot: string; + remoteCodexBinary?: string | null; + runnerRemoteBinaryPath?: string | null; + runnerRemoteCodexPath?: string | null; + runnerRemoteCodexNpmSpec?: string | null; + model?: string | null; + reuseRetainedRunner?: boolean; + /** The selected Pi companion or bundled provider image verifies its target-platform artifact. */ + targetPlatformArtifactVerified?: boolean; + trace?: NativeRunTrace; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; +}) { + const remoteTarget = input.target; + const remoteCommandRunner = input.runner; + const { remoteBinary, controllerRunnerBinary, remoteRuntimeRoot } = input; + const remoteCodexBinary = input.remoteCodexBinary ?? null; + const explicitRemoteCodex = input.runnerRemoteCodexPath?.trim() || null; + const remoteCodexNpmSpec = input.runnerRemoteCodexNpmSpec?.trim() || null; + if (explicitRemoteCodex && remoteCodexNpmSpec) throw new Error("runner_remote_codex_source_conflict"); + const verifyRemoteRunner = async ( + requiredMode: "dial_wss" | "listen_ws", + executable = remoteBinary, + ) => { + if (!remoteTarget || !remoteCommandRunner || !executable) return; + const metadataResult = await remoteCommandRunner.execute({ + command: executable, + args: ["--build-metadata"], + cwd: remoteTarget.remoteCwd, + bypassSession: true, + timeoutMs: 30_000, + }); + if (metadataResult.exitCode !== 0 || metadataResult.timedOut) { + throw new Error("runner_remote_artifact_verification_failed"); + } + let metadata: Record; + try { + metadata = JSON.parse(metadataResult.stdout) as Record; + } catch (error) { + throw new Error("runner_remote_artifact_metadata_invalid", { + cause: error, + }); + } + assertRemoteRunnerBuildMetadata(metadata, requiredMode); + }; + + const reportedCodexVersions = new Set(); + const verifyRemoteCodex = async (executable = remoteCodexBinary) => { + if (!remoteTarget || !remoteCommandRunner || !executable) return; + const versionResult = await remoteCommandRunner.execute({ + command: executable, + args: ["--version"], + cwd: remoteTarget.remoteCwd, + bypassSession: true, + timeoutMs: 30_000, + }); + if (versionResult.exitCode !== 0 || versionResult.timedOut) { + throw new Error("runner_remote_codex_artifact_verification_failed"); + } + const versionOutput = `${versionResult.stdout}\n${versionResult.stderr}`; + const version = parseCodexCliVersion(versionOutput); + // The install pin describes our tested image, not a CLI admission rule. + // Ordinary installations reach the real app-server/model probe; a version + // difference alone must neither fail setup nor replace a working CLI. + if (version !== REMOTE_PROVIDER_PACK_PINS.codex && !reportedCodexVersions.has(version)) { + reportedCodexVersions.add(version); + await input.onLog?.( + "stderr", + `[paperclip-runner] using installed Codex ${version ?? "unrecognized version"} (image install pin ${REMOTE_PROVIDER_PACK_PINS.codex}); compatibility is checked by the native app-server\n`, + ); + } + }; + + // Image policy: keep one latest stable CLI installation shared by native and + // local adapters. Preferred bin entries must point to that same installation; + // never bake an older global CLI alongside a private runner-only version. + const discoverPreinstalledExecutable = async ( + name: "paperclip-runnerd" | "codex", + ) => { + if (!remoteTarget || !remoteCommandRunner) return null; + return discoverRemoteExecutable(remoteCommandRunner, remoteTarget.remoteCwd, name); + }; + + const linkPreinstalledExecutable = async ( + sourcePath: string, + targetPath: string, + ) => { + if (!remoteTarget || !remoteCommandRunner) return; + const escapedSource = sourcePath.replaceAll("'", "'\\''"); + const escapedTarget = targetPath.replaceAll("'", "'\\''"); + const escapedDirectory = posix.dirname(targetPath).replaceAll("'", "'\\''"); + const result = await remoteCommandRunner.execute({ + command: "sh", + args: [ + "-c", + targetPath === remoteCodexBinary + ? buildRemoteCodexLauncherCommand(sourcePath, targetPath) + : `umask 077; mkdir -p '${escapedDirectory}' && ` + + `ln -sfn '${escapedSource}' '${escapedTarget}'`, + ], + cwd: remoteTarget.remoteCwd, + bypassSession: true, + timeoutMs: 10_000, + }); + if (result.exitCode !== 0 || result.timedOut) { + throw new Error("runner_remote_preinstalled_link_failed"); + } + }; + + const prepare = async (requiredMode: "dial_wss" | "listen_ws") => { + // Compatibility metadata does not prove artifact identity. Reuse only the + // exact controller-owned bytes when the controller artifact is available. + const matchesControllerRunnerArtifact = async (executable: string): Promise => { + const expected = createHash("sha256") + .update(readFileSync(controllerRunnerBinary)) + .digest("hex"); + try { + const probe = await remoteCommandRunner.execute({ + command: "sh", + args: [ + "-c", + 'test -x "$1" || exit 1; if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1"; else shasum -a 256 "$1"; fi', + "paperclip-runner-artifact", + executable, + ], + cwd: remoteTarget.remoteCwd, + bypassSession: true, + timeoutMs: 10_000, + }); + return probe.exitCode === 0 && !probe.timedOut && + /^[a-f0-9]{64}\s/.test(probe.stdout) && + probe.stdout.trim().split(/\s+/)[0] === expected; + } catch { + // An unavailable checksum uses the verified staging path. + return false; + } + }; + let runnerArtifactPrepared = false; + if ( + input.reuseRetainedRunner === true && + existsSync(controllerRunnerBinary) + ) { + runnerArtifactPrepared = await measureNativeRunnerSpan( + input.trace, + "runner.artifact.verify_retained", + () => matchesControllerRunnerArtifact(remoteBinary), + ); + } + const explicitRemoteBinary = input.runnerRemoteBinaryPath?.trim() || null; + if (!runnerArtifactPrepared && mayUsePreinstalledRunnerArtifact(explicitRemoteBinary)) { + const preinstalledRunner = await measureNativeRunnerSpan( + input.trace, + "runner.artifact.discover", + () => discoverPreinstalledExecutable("paperclip-runnerd"), + ); + if (preinstalledRunner) { + try { + await measureNativeRunnerSpan( + input.trace, + "runner.artifact.verify_preinstalled", + () => verifyRemoteRunner(requiredMode, preinstalledRunner), + ); + if (existsSync(controllerRunnerBinary) && + !await matchesControllerRunnerArtifact(preinstalledRunner)) { + throw new Error("runner_remote_preinstalled_artifact_mismatch"); + } + await measureNativeRunnerSpan( + input.trace, + "runner.artifact.link", + () => linkPreinstalledExecutable(preinstalledRunner, remoteBinary), + ); + runnerArtifactPrepared = true; + await input.onLog?.( + "stderr", + "[paperclip-runner] using preinstalled runnerd from the sandbox image\n", + ); + } catch { + runnerArtifactPrepared = false; + } + } + } + if (!runnerArtifactPrepared) { + // Upload the same artifact used for the controller identity. The server + // vendors the runner under vendor/paperclip-runner/bin, so the package + // development fallback cannot locate it in a deployed server. + const sourceBinary = controllerRunnerBinary; + if (!existsSync(sourceBinary)) { + throw new Error("runner_remote_artifact_unavailable"); + } + if (!explicitRemoteBinary && !input.targetPlatformArtifactVerified) { + const platform = await remoteCommandRunner.execute({ + command: "sh", + args: ["-c", "uname -s; uname -m"], + cwd: remoteTarget.remoteCwd, + bypassSession: true, + timeoutMs: 10_000, + }); + const [remoteOs = "", remoteArch = ""] = platform.stdout + .trim() + .split(/\r?\n/); + const localOs = + process.platform === "darwin" + ? "Darwin" + : process.platform === "linux" + ? "Linux" + : process.platform; + const localArch = + process.arch === "x64" + ? "x86_64" + : process.arch === "arm64" + ? "aarch64" + : process.arch; + const archMatches = + remoteArch === localArch || + (localArch === "aarch64" && remoteArch === "arm64"); + if ( + platform.exitCode !== 0 || + platform.timedOut || + remoteOs !== localOs || + !archMatches + ) { + throw new Error( + "runner_remote_artifact_platform_mismatch: configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH for the remote OS and architecture", + ); + } + } + await stageRemoteRunnerFile({ + target: remoteTarget, + runner: remoteCommandRunner, + sourcePath: sourceBinary, + targetPath: remoteBinary, + mode: 0o700, + }); + } + await measureNativeRunnerSpan(input.trace, "runner.artifact.verify", () => + verifyRemoteRunner(requiredMode), + ); + if (remoteCodexBinary && explicitRemoteCodex) { + if (!existsSync(explicitRemoteCodex)) { + throw new Error("runner_remote_codex_artifact_unavailable"); + } + await stageRemoteRunnerFile({ + target: remoteTarget, + runner: remoteCommandRunner, + sourcePath: explicitRemoteCodex, + targetPath: remoteCodexBinary, + mode: 0o700, + }); + await verifyRemoteCodex(); + } + if (remoteCodexBinary && remoteCodexNpmSpec) { + let usedPreinstalledCodex = false; + const preinstalledCodex = await measureNativeRunnerSpan( + input.trace, + "harness.artifact.discover", + () => discoverPreinstalledExecutable("codex"), + ); + if (preinstalledCodex) { + try { + await measureNativeRunnerSpan( + input.trace, + "harness.artifact.verify_preinstalled", + () => verifyRemoteCodex(preinstalledCodex), + ); + await measureNativeRunnerSpan( + input.trace, + "harness.artifact.link", + () => + linkPreinstalledExecutable(preinstalledCodex, remoteCodexBinary), + ); + usedPreinstalledCodex = true; + await input.onLog?.( + "stderr", + "[paperclip-runner] using preinstalled Codex from the sandbox image\n", + ); + } catch { + usedPreinstalledCodex = false; + } + } + if (!usedPreinstalledCodex) { + const installRoot = posix.join( + remoteRuntimeRoot!, + "harnesses", + "codex", + ); + const installResult = await remoteCommandRunner.execute({ + command: "npm", + args: [ + "install", + "--prefix", + installRoot, + "--no-audit", + "--no-fund", + remoteCodexNpmSpec, + ], + cwd: remoteTarget.remoteCwd, + bypassSession: true, + timeoutMs: 180_000, + }); + if (installResult.exitCode !== 0 || installResult.timedOut) { + throw new Error("runner_remote_codex_install_failed"); + } + } + await measureNativeRunnerSpan( + input.trace, + "harness.artifact.verify", + () => verifyRemoteCodex(), + ); + } + if (remoteCodexBinary && !explicitRemoteCodex && !remoteCodexNpmSpec) { + const preinstalledCodex = await measureNativeRunnerSpan( + input.trace, + "harness.artifact.discover", + () => discoverPreinstalledExecutable("codex"), + ); + if (!preinstalledCodex) { + throw new Error( + "runner_remote_codex_artifact_unavailable: install codex in the sandbox image or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC", + ); + } + await measureNativeRunnerSpan( + input.trace, + "harness.artifact.verify_preinstalled", + () => verifyRemoteCodex(preinstalledCodex), + ); + await measureNativeRunnerSpan(input.trace, "harness.artifact.link", () => + linkPreinstalledExecutable(preinstalledCodex, remoteCodexBinary), + ); + await measureNativeRunnerSpan( + input.trace, + "harness.artifact.verify", + () => verifyRemoteCodex(), + ); + await input.onLog?.( + "stderr", + "[paperclip-runner] using preinstalled Codex from the sandbox image\n", + ); + } + }; + return { prepare, verifyRemoteRunner, verifyRemoteCodex }; +} diff --git a/server/src/vendor/paperclip-runner/index.ts b/server/src/vendor/paperclip-runner/index.ts index f4a9a81072..dd40545d7e 100644 --- a/server/src/vendor/paperclip-runner/index.ts +++ b/server/src/vendor/paperclip-runner/index.ts @@ -110,6 +110,7 @@ export const createRunnerdCodexTransport: ( runner.createRunnerdCodexTransport; export const defaultCapabilityRunnerdBinary = runner.defaultCapabilityRunnerdBinary; +export const runnerBinaryTarget = runner.runnerBinaryTarget; export const executeNativeSession = runner.executeNativeSession; export const applyNativeSessionGoalControl = runner.applyNativeSessionGoalControl; @@ -159,3 +160,5 @@ export const CONFIGURED_ENVIRONMENT_KEYS = runner.CONFIGURED_ENVIRONMENT_KEYS; export const GENERATED_RUNTIME_ENVIRONMENT_KEYS = runner.GENERATED_RUNTIME_ENVIRONMENT_KEYS; export const configuredEnvironmentProjection = runner.configuredEnvironmentProjection; export const configuredEnvironment = runner.configuredEnvironment; + +export const probeNativeRunnerEnvironment = runner.probeNativeRunnerEnvironment; diff --git a/tests/runner-e2e/installed-cli.test.ts b/tests/runner-e2e/installed-cli.test.ts index 4020bafe8c..64c4d5d9b3 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, lstat, 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,84 @@ 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" | "startup-and-cleanup-failed" | "cleanup-failed" = "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"'); + state.privateStateDirectory=options.stateDirectory; + save();return {transport:{async close(){retired=true;state.calls.push("transport-close");save();${mode === "startup-and-cleanup-failed" ? 'throw new Error("fixture secondary transport cleanup failure");' : ''}}}, + evidence:()=>({runnerPid:111,codexPid:222,providerDriver:"codex_app_server",runnerExited:retired,diagnostics:["fixture provider bootstrap failed"],providerSessionId:"PRIVATE-PROVIDER-ID"})};} + export class CodexAppServerDriver { + constructor(options){this.options=options;} + async openSession(){state.calls.push("open");save(); + ${mode === "startup-and-cleanup-failed" ? 'throw new Error("fixture original provider startup failure");' : ''} + 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();${mode === "cleanup-failed" ? 'throw new Error("fixture session cleanup failure");' : ''}}};} + } + `); + 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 preserves the primary cause, records bounded diagnostics and attempts independent cleanup", async () => { + const f = await startupFixture("startup-and-cleanup-failed"); + const error = await probeInstalledCodexStartup({ consumerRoot: f.root, serverRoot: f.server, expectedRunnerSha256: f.expectedRunnerSha256 }).catch(error => error); + expect(error).toBeInstanceOf(AggregateError); + expect(error.message).toContain("fixture original provider startup failure"); + expect(error.message).toContain("fixture secondary transport cleanup failure"); + expect(error.message).toContain("fixture provider bootstrap failed"); + expect(error.cause.message).toBe("fixture original provider startup failure"); + expect(error.errors.map((error: Error) => error.message)).toEqual(["fixture original provider startup failure", "fixture secondary transport cleanup failure"]); + expect(error.message).not.toContain("PRIVATE-PROVIDER-ID"); + const observations = JSON.parse(await readFile(f.observations, "utf8")); + expect(observations.calls).toEqual(["open", "transport-close"]); + await expect(lstat(observations.privateStateDirectory)).rejects.toThrow(); +}); + +it.skipIf(process.platform === "win32")("installed startup cannot qualify when session cleanup fails after successful open", async () => { + const f = await startupFixture("cleanup-failed"); + await expect(probeInstalledCodexStartup({ consumerRoot: f.root, serverRoot: f.server, expectedRunnerSha256: f.expectedRunnerSha256 })).rejects.toThrow("fixture session cleanup failure"); + expect(JSON.parse(await readFile(f.observations, "utf8")).calls).toEqual(["open", "session-close", "session-close"]); +}); + +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..6bcfc6f005 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,115 @@ 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; + let startupFailed = false; + let startupFailure: unknown; + 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 }; + } catch (error) { + startupFailed = true; + startupFailure = error; + throw error; + } finally { + clearTimeout(timer); + const cleanupFailures: unknown[] = []; + try { + if (session) await session.close({ reason: "installed startup cleanup" }); + else if (bundle && !cleanupConfirmed) await bundle.transport.close("installed startup cleanup"); + } catch (error) { cleanupFailures.push(error); } + try { + if (!bundle || bundle.evidence().runnerExited) await rm(directory, { recursive: true, force: true }); + } catch (error) { cleanupFailures.push(error); } + if (startupFailed) { + const evidence = bundle?.evidence(); + // This zero-turn fixture has empty homes and no provider credentials. + // Report only bounded failure text and lifecycle state, never identities + // or raw durable state. A close failure cannot hide the startup cause. + const bounded = (value: unknown) => String(value).replaceAll(directory, "[private-startup]").slice(0, 2000); + const primaryMessage = bounded(startupFailure instanceof Error ? startupFailure.message : startupFailure); + const diagnostic = { schema: "paperclip.e2e.installed-codex-startup-failure/v1", platform: `${process.platform}-${process.arch}`, + runnerExited: evidence?.runnerExited ?? null, runnerExitCode: evidence?.runnerExitCode ?? null, + runnerSignal: evidence?.runnerSignal ?? null, + diagnostics: (Array.isArray(evidence?.diagnostics) ? evidence.diagnostics : []).slice(-16).map(bounded), + cleanupFailures: cleanupFailures.map(error => bounded(error instanceof Error ? error.message : error)) }; + throw new AggregateError([startupFailure, ...cleanupFailures], `Installed Codex startup failed: ${primaryMessage}\n${JSON.stringify(diagnostic)}`, { cause: startupFailure }); + } + if (cleanupFailures.length) throw new AggregateError(cleanupFailures, "Installed Codex startup cleanup failed", { cause: cleanupFailures[0] }); + } +} + /** 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);