diff --git a/.github/workflows/docker-runner-check.yml b/.github/workflows/docker-runner-check.yml index ae00c65df35..60328d402e4 100644 --- a/.github/workflows/docker-runner-check.yml +++ b/.github/workflows/docker-runner-check.yml @@ -4,13 +4,21 @@ on: workflow_dispatch: inputs: target_branch: - description: "Branch in this repository to build; resolved to one immutable commit before checkout" + description: "Branch or full commit SHA in this repository to build" type: string required: false publish_eval_image: description: "Publish an immutable Daytona qualification image on hosted Linux (no provider credentials)" type: boolean default: false + release_run_id: + description: "Consume a successful exact-source Release Smoke pack, daemon and public-package artifact set" + type: string + required: false + release_base_image: + description: "Immutable Paperclip Daytona base image for the release-artifact consumer" + type: string + required: false verify_source: description: "Run broad source checks on hosted Linux" type: boolean @@ -84,6 +92,11 @@ jobs: TRIGGERING_ACTOR: ${{ github.triggering_actor }} ALLOWED_IDS: ${{ vars.AWS_CI_TRUSTED_USER_IDS }} TARGET_BRANCH: ${{ inputs.target_branch || github.ref_name }} + RELEASE_RUN_ID: ${{ inputs.release_run_id }} + PUBLISH_EVAL_IMAGE: ${{ inputs.publish_eval_image }} + VERIFY_SOURCE: ${{ inputs.verify_source }} + VERIFY_PUBLIC_INSTALL: ${{ inputs.verify_public_install }} + BUILD_EVAL_VIEWER: ${{ inputs.build_eval_viewer }} run: | set -euo pipefail test "$REPOSITORY" = paperclipai/paperclip @@ -93,7 +106,18 @@ jobs: for id in "$ACTOR_ID" "$triggering_id"; do jq -e --argjson id "$id" 'index($id) != null' <<< "$ALLOWED_IDS" >/dev/null done - target_sha="$(gh api "repos/$REPOSITORY/git/ref/heads/$TARGET_BRANCH" --jq '.object.sha')" + if [ -n "$RELEASE_RUN_ID" ]; then + [[ "$RELEASE_RUN_ID" =~ ^[1-9][0-9]*$ ]] + test "$PUBLISH_EVAL_IMAGE" = true + test "$VERIFY_SOURCE" != true + test "$VERIFY_PUBLIC_INSTALL" != true + test "$BUILD_EVAL_VIEWER" != true + fi + if [[ "$TARGET_BRANCH" =~ ^[a-f0-9]{40}$ ]]; then + target_sha="$(gh api "repos/$REPOSITORY/commits/$TARGET_BRANCH" --jq .sha)" + else + target_sha="$(gh api "repos/$REPOSITORY/git/ref/heads/$TARGET_BRANCH" --jq '.object.sha')" + fi [[ "$target_sha" =~ ^[0-9a-f]{40}$ ]] echo "target_sha=$target_sha" >> "$GITHUB_OUTPUT" @@ -105,6 +129,7 @@ jobs: timeout-minutes: 90 permissions: contents: read + actions: read packages: write steps: - name: Authorize an explicit maintainer image build @@ -126,7 +151,7 @@ jobs: done - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ needs.authorize_manual.outputs.target_sha }} + ref: ${{ inputs.release_run_id != '' && github.sha || needs.authorize_manual.outputs.target_sha }} persist-credentials: false - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 with: @@ -136,6 +161,7 @@ jobs: node-version: 24 package-manager-cache: false - name: Resolve source dependencies without lifecycle scripts + if: inputs.release_run_id == '' run: | pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile mkdir -p remote-verification @@ -149,7 +175,7 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build checksum-verified Grok provider image - if: inputs.publish_eval_image + if: inputs.publish_eval_image && inputs.release_run_id == '' env: SOURCE_SHA: ${{ needs.authorize_manual.outputs.target_sha }} run: | @@ -170,6 +196,43 @@ jobs: docker buildx imagetools inspect "$immutable" >/dev/null echo "$immutable" > remote-verification/image.txt echo "Image: $immutable" >> "$GITHUB_STEP_SUMMARY" + - name: Consume exact release artifacts into the qualification image + if: inputs.publish_eval_image && inputs.release_run_id != '' + env: + GH_TOKEN: ${{ github.token }} + SOURCE_SHA: ${{ needs.authorize_manual.outputs.target_sha }} + RELEASE_RUN_ID: ${{ inputs.release_run_id }} + RELEASE_BASE_IMAGE: ${{ inputs.release_base_image }} + run: | + set -euo pipefail + test '${{ inputs.verify_source }}' != true + test '${{ inputs.verify_public_install }}' != true + test '${{ inputs.build_eval_viewer }}' != true + mkdir -p remote-verification + git rev-parse HEAD > remote-verification/tooling-source.txt + printf '%s\n' "$SOURCE_SHA" > remote-verification/source.txt + node scripts/prepare-runner-release-image.mjs remote-verification/release-context + image="ghcr.io/paperclipai/paperclip-daytona-runner:qualification-${SOURCE_SHA}-${GITHUB_RUN_ID}" + docker buildx build --platform linux/amd64 \ + --build-arg "BASE_IMAGE=$RELEASE_BASE_IMAGE" \ + --build-arg "SOURCE_REVISION=$SOURCE_SHA" \ + --tag "$image" --metadata-file remote-verification/image.json --push \ + remote-verification/release-context + digest="$(jq -r '."containerimage.digest"' remote-verification/image.json)" + [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] + immutable="${image%:*}@$digest" + docker run --rm --entrypoint cat "$immutable" /opt/paperclip-runner/provider-pack/provider-pack.json \ + > remote-verification/image-provider-pack.json + cmp remote-verification/release-context/provider-pack.json remote-verification/image-provider-pack.json + test "$(docker run --rm --entrypoint id "$immutable" -un)" = daytona + docker run --rm --entrypoint sha256sum "$immutable" /usr/local/bin/paperclip-runnerd \ + > remote-verification/image-daemon.sha256 + expected="$(jq -r .daemonSha256 remote-verification/release-image-inputs.json)" + test "$(cut -d ' ' -f 1 remote-verification/image-daemon.sha256)" = "$expected" + printf '%s\n' "$immutable" > remote-verification/image.txt + rm -rf remote-verification/release-context + docker logout ghcr.io + echo "Image: $immutable" >> "$GITHUB_STEP_SUMMARY" - name: Verify repository on hosted Linux if: inputs.verify_source # Leave time for artifact retention within the job deadline. @@ -215,5 +278,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: grok-remote-verification-${{ github.run_id }} - path: remote-verification/ + path: | + remote-verification/ + !remote-verification/release-context/** retention-days: 7 diff --git a/.github/workflows/pr-trusted.yml b/.github/workflows/pr-trusted.yml index c69f097c3ba..6b6b2a1a7a6 100644 --- a/.github/workflows/pr-trusted.yml +++ b/.github/workflows/pr-trusted.yml @@ -117,7 +117,7 @@ jobs: run: node "$CI_NODE_TEST" ./scripts/__tests__/e2e-shard.test.mjs - name: Test release verify workflow wiring - run: node "$CI_NODE_TEST" ./scripts/__tests__/release-verify-workflow.test.mjs ./scripts/cloud-source-verification.test.mjs ./scripts/standard-image-contract.test.mjs + run: node "$CI_NODE_TEST" ./scripts/__tests__/release-verify-workflow.test.mjs ./scripts/__tests__/prepare-runner-release-image.test.mjs ./scripts/cloud-source-verification.test.mjs ./scripts/standard-image-contract.test.mjs - name: Test standalone package build concurrency run: node "$CI_NODE_TEST" ./scripts/__tests__/build-standalone-concurrency.test.mjs diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index 40fa1753466..05ef1de1e56 100644 --- a/.github/workflows/release-smoke.yml +++ b/.github/workflows/release-smoke.yml @@ -242,6 +242,13 @@ jobs: pattern: ${{ needs.qualification_daemons.outputs.runner_artifact_prefix }}-* path: ${{ runner.temp }}/release-runner-inputs merge-multiple: true + - name: Download target-owned Linux provider-pack identities + if: needs.qualification_daemons.outputs.provider_pack_artifact_prefix != '' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: ${{ needs.qualification_daemons.outputs.provider_pack_artifact_prefix }}-* + path: ${{ runner.temp }}/release-provider-pack-inputs + merge-multiple: true - name: Bind the immutable Linux producer source env: SOURCE_SHA: ${{ inputs.qualification_source_sha }} @@ -259,9 +266,14 @@ jobs: env: SOURCE_SHA: ${{ inputs.qualification_source_sha }} run: | + pack_args=() + if [[ -d "$RUNNER_TEMP/release-provider-pack-inputs" ]]; then pack_args=("$RUNNER_TEMP/release-provider-pack-inputs"); fi node packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs \ - assemble-daemons "$RUNNER_TEMP/release-runner-inputs" "$SOURCE_SHA" + assemble-daemons "$RUNNER_TEMP/release-runner-inputs" "$SOURCE_SHA" "${pack_args[@]}" cp -R packages/paperclip-runner/dist/bin/. server/dist/vendor/paperclip-runner/bin/ + if [[ -d packages/paperclip-runner/dist/remote-provider-packs ]]; then + cp -R packages/paperclip-runner/dist/remote-provider-packs server/dist/vendor/paperclip-runner/ + fi - name: Verify clean public installation and retain the same package bytes env: PAPERCLIP_RUNNER_PUBLIC_PACKS_DIR: ${{ runner.temp }}/public-runner-packs diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index cf333aaa618..fb5ed668f8a 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -15,6 +15,8 @@ on: outputs: runner_artifact_prefix: value: ${{ jobs.runner_binaries.outputs.artifact_prefix }} + provider_pack_artifact_prefix: + value: ${{ jobs.runner_binaries.outputs.provider_pack_artifact_prefix }} native_consumer_matrix: value: ${{ jobs.runner_binary_targets.outputs.native_consumers }} @@ -600,6 +602,7 @@ jobs: matrix: ${{ fromJSON(needs.runner_binary_targets.outputs.matrix) }} outputs: artifact_prefix: ${{ steps.identity.outputs.artifact_prefix }} + provider_pack_artifact_prefix: ${{ steps.identity.outputs.provider_pack_artifact_prefix }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: @@ -610,7 +613,7 @@ jobs: version: 9.15.4 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with: - node-version: 24 + node-version: 24.21.0 - name: Bind immutable producer source and target id: identity env: @@ -622,6 +625,9 @@ jobs: [[ "$(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" + if node --input-type=module -e 'const m = await import("./packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs"); process.exit(m.RELEASE_PROVIDER_PACK_TARGETS ? 0 : 1)'; then + echo "provider_pack_artifact_prefix=runner-provider-packs-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" + fi - name: Pin native Cargo workspace and toolchain id: native_rust_workspace working-directory: packages/paperclip-runner @@ -700,3 +706,29 @@ jobs: path: ${{ runner.temp }}/runner-release/ if-no-files-found: error retention-days: 14 + + - name: Build target-owned Linux provider pack + id: provider_pack + if: startsWith(matrix.target, 'linux-') && steps.identity.outputs.provider_pack_artifact_prefix != '' + env: + SOURCE_SHA: ${{ inputs.ref }} + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + pack="$RUNNER_TEMP/provider-pack" + PAPERCLIP_RUNNER_SOURCE_REVISION="$SOURCE_SHA" node packages/paperclip-runner/scripts/build-provider-pack.mjs "$pack" + node --input-type=module - "$pack/provider-pack.json" <<'NODE' + import { readFileSync } from 'node:fs'; + import { verifyReleaseProviderPack } from './packages/paperclip-runner/scripts/release-provider-pack.mjs'; + verifyReleaseProviderPack(JSON.parse(readFileSync(process.argv[2], 'utf8')), process.env.SOURCE_SHA, process.env.TARGET); + NODE + mkdir -p "$RUNNER_TEMP/release-provider-packs/$TARGET" + cp "$pack/provider-pack.json" "$RUNNER_TEMP/release-provider-packs/$TARGET/provider-pack.json" + tar -C "$pack" -czf "$RUNNER_TEMP/release-provider-packs/$TARGET/provider-pack.tar.gz" . + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: startsWith(matrix.target, 'linux-') && steps.identity.outputs.provider_pack_artifact_prefix != '' + with: + name: ${{ steps.identity.outputs.provider_pack_artifact_prefix }}-${{ matrix.target }} + path: ${{ runner.temp }}/release-provider-packs/ + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a02b40609f4..1a398e30dae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -430,6 +430,16 @@ jobs: pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* path: ${{ runner.temp }}/release-runner-inputs merge-multiple: true + - name: Download target-owned Linux provider-pack identities + if: needs.release_runner_source.outputs.required == 'true' && needs.release_runner_binaries.outputs.provider_pack_artifact_prefix != '' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: ${{ needs.release_runner_binaries.outputs.provider_pack_artifact_prefix }}-* + path: ${{ runner.temp }}/release-provider-pack-inputs + merge-multiple: true + - name: Bind provider-pack inputs for release assembly + if: needs.release_runner_source.outputs.required == 'true' && needs.release_runner_binaries.outputs.provider_pack_artifact_prefix != '' + run: echo "PAPERCLIP_RELEASE_PROVIDER_PACK_ARTIFACTS_DIR=$RUNNER_TEMP/release-provider-pack-inputs" >> "$GITHUB_ENV" - 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" @@ -727,6 +737,16 @@ jobs: pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* path: ${{ runner.temp }}/release-runner-inputs merge-multiple: true + - name: Download target-owned Linux provider-pack identities + if: needs.release_runner_source.outputs.required == 'true' && needs.release_runner_binaries.outputs.provider_pack_artifact_prefix != '' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: ${{ needs.release_runner_binaries.outputs.provider_pack_artifact_prefix }}-* + path: ${{ runner.temp }}/release-provider-pack-inputs + merge-multiple: true + - name: Bind provider-pack inputs for release assembly + if: needs.release_runner_source.outputs.required == 'true' && needs.release_runner_binaries.outputs.provider_pack_artifact_prefix != '' + run: echo "PAPERCLIP_RELEASE_PROVIDER_PACK_ARTIFACTS_DIR=$RUNNER_TEMP/release-provider-pack-inputs" >> "$GITHUB_ENV" - 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" @@ -1006,6 +1026,16 @@ jobs: pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* path: ${{ runner.temp }}/release-runner-inputs merge-multiple: true + - name: Download target-owned Linux provider-pack identities + if: needs.release_runner_source.outputs.required == 'true' && needs.release_runner_binaries.outputs.provider_pack_artifact_prefix != '' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: ${{ needs.release_runner_binaries.outputs.provider_pack_artifact_prefix }}-* + path: ${{ runner.temp }}/release-provider-pack-inputs + merge-multiple: true + - name: Bind provider-pack inputs for release assembly + if: needs.release_runner_source.outputs.required == 'true' && needs.release_runner_binaries.outputs.provider_pack_artifact_prefix != '' + run: echo "PAPERCLIP_RELEASE_PROVIDER_PACK_ARTIFACTS_DIR=$RUNNER_TEMP/release-provider-pack-inputs" >> "$GITHUB_ENV" - 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" @@ -1387,6 +1417,16 @@ jobs: pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* path: ${{ runner.temp }}/release-runner-inputs merge-multiple: true + - name: Download target-owned Linux provider-pack identities + if: needs.release_runner_source.outputs.required == 'true' && needs.release_runner_binaries.outputs.provider_pack_artifact_prefix != '' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: ${{ needs.release_runner_binaries.outputs.provider_pack_artifact_prefix }}-* + path: ${{ runner.temp }}/release-provider-pack-inputs + merge-multiple: true + - name: Bind provider-pack inputs for release assembly + if: needs.release_runner_source.outputs.required == 'true' && needs.release_runner_binaries.outputs.provider_pack_artifact_prefix != '' + run: echo "PAPERCLIP_RELEASE_PROVIDER_PACK_ARTIFACTS_DIR=$RUNNER_TEMP/release-provider-pack-inputs" >> "$GITHUB_ENV" - 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" @@ -1445,6 +1485,16 @@ jobs: pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* path: ${{ runner.temp }}/release-runner-inputs merge-multiple: true + - name: Download target-owned Linux provider-pack identities + if: needs.release_runner_source.outputs.required == 'true' && needs.release_runner_binaries.outputs.provider_pack_artifact_prefix != '' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: ${{ needs.release_runner_binaries.outputs.provider_pack_artifact_prefix }}-* + path: ${{ runner.temp }}/release-provider-pack-inputs + merge-multiple: true + - name: Bind provider-pack inputs for release assembly + if: needs.release_runner_source.outputs.required == 'true' && needs.release_runner_binaries.outputs.provider_pack_artifact_prefix != '' + run: echo "PAPERCLIP_RELEASE_PROVIDER_PACK_ARTIFACTS_DIR=$RUNNER_TEMP/release-provider-pack-inputs" >> "$GITHUB_ENV" - 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" diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index af54d1b28e0..292463b7451 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -806,6 +806,15 @@ Use the matching Paperclip Daytona image: the controller verifies the image's pack manifest, executable hashes, bridge tree, and pinned versions before launch. The controller's operating system does not determine the remote daemon target. +Native Claude remote execution also selects the Linux x64 or ARM64 daemon and +pack identity for its actual selected host. For SSH, install that exact release's +pack at `$HOME/.local/share/paperclip-runner/provider-pack` or +`/opt/paperclip-runner/provider-pack`; the runtime discovers and verifies it. +The package's identities and the native producer archives share one source. +No development pack override is needed for this installed-host path. Other +provider qualification boundaries and the current Linux x64 image path remain. +ARM64 cloud-image production is deferred. + A plain Daytona sandbox image does not contain this provider pack. Custom images and source-development launchers must supply a verified Linux provider pack and daemon through the existing remote artifact overrides; see @@ -1791,9 +1800,10 @@ agent conversion are outside this playbook. ## Paperclip Runner Adapter Conversion Paperclip Runner offers native Codex, OpenCode, and **ACPX Claude**. New -Claude Code agents default to ACPX Claude on Apple Silicon; other Claude targets -retain Legacy until platform qualification is complete. The runner override is -in Advanced. Ordinary Claude models default to `claude-opus-5-5`; explicit models +Claude Code agents default to ACPX Claude on Apple Silicon, Intel Mac, Linux x64 +and Linux ARM64 with supported glibc. Unsupported or unqualified targets retain +Legacy. Darwin SSH keeps an automatic Legacy default; explicit native execution +requires matching remote assets. The runner override is in Advanced. Ordinary Claude models default to `claude-opus-5-5`; explicit models and provider routing remain authoritative. Existing agents keep their saved runner. Converting an existing Claude, Codex, or OpenCode agent selects its corresponding provider, preserves compatible models, credentials, workspace, and instructions, and resets execution sessions while retaining run history. diff --git a/doc/plans/2026-10-12-claude-runner-default.md b/doc/plans/2026-10-12-claude-runner-default.md index f37275435a5..4e5584a6851 100644 --- a/doc/plans/2026-10-12-claude-runner-default.md +++ b/doc/plans/2026-10-12-claude-runner-default.md @@ -13,6 +13,16 @@ under `archive/claude-*-pre-refresh-20261019` before integration. | Remaining defaults #15997 | Historical qualified builds and task pairs remain source-bound below | Integrate the updated prerequisite/default branches; rerun affected controls | | Local preview for the user | Existing installed Mac daemon and API-key access are available | Start an isolated current-source test drive, verify a native Claude task and revision, and leave its URL running | +The integrated branch passes 243 focused UI cases, UI/server TypeScript checks, +and token gates. The workflow-security regression was reproduced and all 12 +checks pass after asserting the existing trusted release-artifact checkout. +The first local DB-backed startup attempt could not load embedded PostgreSQL's +unhydrated library links after script-free dependency installation. Running the +package's own symlink hydration made its executable work; its full startup suite +is being rerun. The legacy CLI classification fixture now answers version probes +separately; both focused classification cases pass. Fresh cloud checks cover each +updated PR. The local preview will use a current-source Mac daemon built in cloud. + Pi's combined source attestation is profile 26; Copilot remains pending and moves to profile 22. Historical published identities are retained. New source evidence will not relabel earlier platform or subscription results. All Docker/Rust builds @@ -22,11 +32,44 @@ aggregate qualification ceiling, unknown-charge reservations, and disabled paid This section supersedes the sequencing and baseline in the historical record below. Implementation is underway on the existing three-PR stack. The progress section below records current evidence; older entries are historical. +## Historical October 15 delivery evidence + +Installed-target implementation and API-key qualification are complete on the existing three-PR stack. The subsequent hosted test below found a Cloud default-selection gap. Hosted acceptance is failed and must not be inferred from the installed-target evidence. Earlier attempts remain preserved below. + +## Hosted user acceptance — 2026-10-15 + +A dedicated test organization served exact candidate `49f99e130aff317cb270c367523755910f20d080` after a successful single-organization deployment. The supported Core-only branch preview excluded private Cloud UI plugins; it does not qualify the full private distribution. [Cloud image build](https://github.com/paperclipai/paperclip/actions/runs/38778601920) completed remotely. No local Docker or Rust build ran. + +The strict user-only journey started at the supplied management entry, entered the fresh organization, created Claude Staging Writer, connected the authorized API key through the normal masked field, and completed onboarding without any runner/model override. The saved runtime form displayed `Default (claude-opus-5-5)` but **`Legacy runner (default)`** in Advanced. Acceptance failed at that point. No task or follow-up was submitted, so native execution, downloaded artifacts and session continuity remain unverified on this hosted deployment. The failure is preserved in 33 chronological screenshots and the structured journal; later engineering diagnosis is separate. + +The managed test fleet uses a named sandbox snapshot. Candidate `needsClaudeSetupQualification` only probes explicit immutable OCI image digests, and `readSandboxPlatformQualification` rejects named snapshots. The same target gate therefore keeps this Cloud path Legacy. A correct follow-up must establish an immutable provider snapshot identity, bind measured OS/architecture/libc facts to the selected environment and company, and invalidate stale/replaced bindings. It must then repeat fresh ordinary onboarding and the useful-task/revision acceptance. Forcing the agent to native, changing the fleet default image, or treating a CLI version probe as native task proof would not resolve this failure. + +The QA agent was paused with no runs or sessions. An unused $2 allowance was reserved within the existing $50 ceiling by lowering an inactive local fixture cap; its unknown-charge hold and blocking policy remain intact. Report: `/private/tmp/claude-test-user-20261015/report.html`; structured log: `/private/tmp/claude-test-user-20261015/journey.json`; deployment and diagnosis receipt: `/private/tmp/claude-test-deployment-20261015/deployment.json`. The offline renderer validated all 33 screenshots. Host file preview was queued; report-gallery rendering was not visually verified. Individual product screenshots were inspected during the journey. + +Experience findings: the Connectors announcement covered part of the first-task screen until dismissed; budget navigation required several detours and did not expose a clear policy-creation action. These do not cancel the successful API-key connection, and connection success does not establish native execution. + +## Delivery checkpoint — 2026-10-15 + +New Claude agents use Paperclip Runner and ordinary Opus 5.5 defaults on the four qualified installed targets. Saved Legacy/native choices, explicit models, provider routing, and the latest Codex defaults remain intact. Runtime source is `0670b85392a0be51745f1ba7ca84f998bf122c07`, integrated with master through `7420af26fc9a80d6252ababd879e2fc991eebd07`. Required source CI passed, with exact-source Greptile 5/5 and zero unresolved threads. The final record changes evidence only; the linked PRs show current-head checks. + +| Target | Completed evidence | +| --- | --- | +| Apple Silicon | Ordinary UI defaults, API task/revision, controller restart, independent files and session continuity on packaged `bd74bf5059`; final `0670b85392` clean consumer passes and its daemon/runner payload remains unchanged. The later execution fix affects remote directory staging. | +| Linux ARM64 | Native build, clean installed consumer, standard provider-pack discovery on an unprivileged SSH host, final-source API task/revision, exact files, effort/permission acknowledgment, session continuity and released leases. | +| Linux x64 | Clean installed consumer, exact-artifact immutable cloud image, final-source API task/revision across fresh Daytona sandboxes, exact files, effort/permission acknowledgment, session continuity and successful cleanup. | +| Intel Mac | Native cloud build and clean installed consumer pass. Attended provider execution was not required. | + +Delivery order: [protocol prerequisite #15994](https://github.com/paperclipai/paperclip/pull/15994), [Apple Silicon behavior #15993](https://github.com/paperclipai/paperclip/pull/15993), then [remaining platforms #15997](https://github.com/paperclipai/paperclip/pull/15997). None is merged or deployed to production. + +Reviewable previews: [Mac task and revision](http://127.0.0.1:3105/CLA/issues/CLA-3), [ARM task and revision](http://127.0.0.1:3105/CLAAA/issues/CLAAA-1), and [x64 task and revision](http://127.0.0.1:3105/CLAAA/issues/CLAAA-2). Acceptance reports and screenshots are attached to those tasks and independently checked after upload. The earlier 3115 preview is retained separately. + +Known rate-card estimates total **$4.810616600**, excluding unknown charges. Aggregate qualification caps remain **$50**, with all prior holds and the separate $1 setup reserve retained. There are no active qualification runs. No automatic paid retry ran. All Docker and Rust builds ran in cloud infrastructure. Live subscription execution remains unverified under the user's API-key direction; built-in connection/authentication flows are preserved. ARM64 cloud-image production remains deferred. + ## Finish line and baseline New Claude agents created through ordinary product entry points select Paperclip Runner automatically, use `claude-opus-5-5` unless an explicit model/provider override exists, and complete a useful task plus revision. Saved Legacy/native choices remain stable. Deliver tested, reviewable PRs; merge and production rollout remain separate. -Fetched origin/master: `7fc2dc8b3236e1149ae32756b6153a503623404b`. +Planning baseline fetched from origin/master: `7fc2dc8b3236e1149ae32756b6153a503623404b`. Implementation later integrated master through `7420af26fc9a80d6252ababd879e2fc991eebd07`; see the current evidence below. Clean baseline checkout: `/private/tmp/claude-default-master-plan`, branch `codex/claude-default-master-plan`. Existing three PR branches and installed preview are preserved. This planning checkout is current master plus this record; it does not yet contain the Claude implementation. ## What current master already supplies @@ -38,7 +81,7 @@ Clean baseline checkout: `/private/tmp/claude-default-master-plan`, branch `code - Claude still has no automatic runner mapping: `paperclipRunnerProfileForHarness` and `agentRunnerAvailability` select Codex only. Legacy Claude defaults to Opus 5; generic ACPX defaults to Sonnet 5. Native Claude remains subject to the experimental gate. - Native setup's SSH architecture whitelist still excludes Linux ARM64. Executable admission alone does not prove onboarding/remote selection works. -## Existing work and evidence +## Existing work and evidence at the planning checkpoint | Work | Current state | Reuse / remaining gap | | --- | --- | --- | @@ -134,11 +177,11 @@ Update this record with the integrated source, package/image digests, preview, a ## Integrated implementation progress — 2026-10-15 -Current checkpoint: all three integrated heads received 5/5 review with zero unresolved threads. Required source CI passed (the platform aggregate gate was finishing). The two latest master commits change retained-reader authorization batching and CI profile export; runner/Claude execution objects are unchanged from frozen package source bd74bf5059. The carry-forward receipt is `/private/tmp/claude-v7-runtime-carry-forward-20261015.json`; 267 authorization/selection controls and 15 CI profile tests pass. The existing Darwin SSH automatic-Legacy safeguard is also included in the behavior boundary, so Apple Silicon SSH creation is safe before the platform PR; its 142 selection tests pass. This moves an already-qualified guard earlier without changing the final platform implementation. +Current source checkpoint: master through `7420af26fc9a80d6252ababd879e2fc991eebd07` is integrated through the stack. Protocol is `bffac456ddb2ea6c340a8f598af10c6b98135625`, Apple Silicon behavior is `f0ed0bdefaeb363e5ab2d4018d9cd13656df7137`, and frozen platform runtime source is `0670b85392a0be51745f1ba7ca84f998bf122c07`. Each has passing required source CI, exact-head Greptile 5/5, and zero unresolved review threads. The protocol and behavior PRs are ready; platform acceptance remains gated below. GitHub workflow access is restored and the image consumer is published. -Master through `7420af26fc9a80d6252ababd879e2fc991eebd07` is merged through the three PRs. Protocol is `bffac456ddb2ea6c340a8f598af10c6b98135625`; the corresponding reviewed behavior/platform heads are `9e3dac873bbf672d6f84eb5ba388cf837412767c` and `75f5cb820e4fd63d1cbcbbd6f67bdc1972465564`. Frozen platform package source remains `bd74bf50596d099e138bc6824cf0230f1026bfe1`. Existing Codex behavior and master's landed ARM SDK admission remain intact. GitHub workflow scope is verified; the cloud image consumer is published. +Master's retained-reader authorization batching and CI profile export are preserved. Their 267 authorization/selection controls and 15 CI profile tests pass. The Darwin SSH automatic-Legacy safeguard is included in the behavior boundary, so Apple Silicon SSH creation is safe before the platform PR; its 142 selection tests pass. Existing Codex behavior and master's landed ARM SDK admission remain intact. -Review corrections are committed and covered by failing-before/passing-after controls. Discovery changes invalidate stale onboarding probes. Finish setup now saves the exact configuration admitted during qualification rather than rebuilding Legacy defaults from an older render. V7 native input retains prepared transport, constraints, and completion-only second turns. The latter regression reproduces `session cannot start another turn` before the fix. All 160 focused protocol/session tests, all 71 New Agent tests, 148 onboarding tests, UI typecheck, and runner TypeScript build pass. Earlier focused verification also passed 343 selection/configuration checks, 849 skills/comment/native-session checks, 320 latest-master lifecycle/selection/snapshot checks, 148 platform-selection controls, 114 release controls, 16 image-consumer controls and token gates. Master's subsequently landed snapshot-worker test fix replaces the earlier local timeout change. All three heads have fresh 5/5 reviews with zero unresolved findings and passing required CI. One platform shard hit an ephemeral HTTP/2 EADDRINUSE test-port collision in unchanged code; a single provider-free rerun passed, preserving the original failure log at `/private/tmp/claude-v7-platform-workspaces-b-failure-20261015.log`. Final installed-package and remote execution gates remain pending. +Review corrections are committed and covered by failing-before/passing-after controls. Discovery changes invalidate stale onboarding probes. Finish setup now saves the exact configuration admitted during qualification rather than rebuilding Legacy defaults from an older render. V7 native input retains prepared transport, constraints, and completion-only second turns. The latter regression reproduces `session cannot start another turn` before the fix. All 160 focused protocol/session tests, all 71 New Agent tests, 148 onboarding tests, UI typecheck, and runner TypeScript build pass. Earlier focused verification also passed 343 selection/configuration checks, 849 skills/comment/native-session checks, 320 latest-master lifecycle/selection/snapshot checks, 148 platform-selection controls, 114 release controls, 16 image-consumer controls and token gates. Master's subsequently landed snapshot-worker test fix replaces the earlier local timeout change. All three heads have fresh 5/5 reviews with zero unresolved findings and passing required CI. One earlier platform shard hit an ephemeral HTTP/2 EADDRINUSE test-port collision in unchanged code; a single provider-free rerun passed, preserving the original failure log at `/private/tmp/claude-v7-platform-workspaces-b-failure-20261015.log`. The final packaged Mac pair subsequently passed; current remote execution gates are recorded below. Preview: http://127.0.0.1:3105/CLA/issues/CLA-2, data `/private/tmp/claude-master-local-20261015`. Historical preview 3115 is separate. Ordinary New Agent -> Claude Code -> saved Anthropic API key -> Model Default -> high effort -> Finish setup originally saved agent `5339638d-b41d-4a6f-a041-4e5d1698a8de` as `paperclip_runner`/`acpx`/`claude`/`claude-opus-5-5`. No runner/model was patched after creation. Only timeout (180 seconds) and concurrency (one) were bounded. Experimental native runner remains false. The isolated PATH uses the already-qualified Claude 2.1.286 executable, preserving the user's system CLI. @@ -152,6 +195,47 @@ The first new cloud campaign [38734513491](https://github.com/paperclipai/paperc Budget remains $50 total with historical holds intact. The fresh fixture cap is now $2.30: $0.80 from the original safe reallocation, then $1.50 from unused allowance in held fixture `16cc3ada` while retaining its $1 pending reservation and $1 setup reserve. Donor remains hard-stopped and its agents paused. Receipts: `/private/tmp/claude-master-local-allocation-20261015.json` and `/private/tmp/claude-master-platform-allocation-20261015.json`. Fresh fixture observed usage is $0.688181200, pending/unpriced zero, leaving $1.611818800. Historical known rate-card estimate plus these runs is $3.742819800, excluding unknown charges. Per-run reservation remains $0.50; automatic paid retries and timer heartbeats remain disabled. + +## Packaged acceptance and remote staging repair — 2026-10-15 + +Cloud producer [38738177651](https://github.com/paperclipai/paperclip/actions/runs/38738177651) passed all four native daemon builds and clean package consumers on source `bd74bf50596d099e138bc6824cf0230f1026bfe1`. Public package artifact 11976635617 has ZIP SHA256 `079a0e5277ce978ccbe8464e4413ddb7f9a537a76fd71da931e3ee355bbe4198`; all 18 package archives were independently verified and installed into `/private/tmp/claude-installed-bd74bf50-preview/consumer`. The [cloud image consumer](https://github.com/paperclipai/paperclip/actions/runs/38744991246) also passed. Image `ghcr.io/paperclipai/paperclip-daytona-runner@sha256:41cde6894c649ff76a06ddb925f1be1945d5efc901b13a7e43d371427a205235` contains the exact producer's Linux x64 daemon and provider pack. No Docker or Rust build ran locally. + +The installed Mac task/revision passed in ordinary New Agent UI with saved API key, Default model, high effort, and no runner override. Agent `766dddf5-1178-4cdf-81ee-ec542cfe0673` saved native ACPX Claude/Opus 5.5 with the experimental gate disabled. Preview http://127.0.0.1:3105/CLA/issues/CLA-3. Runs `ae8386b6-6a41-493c-a35c-a34bcd5feb7c` and `f79a151e-2267-4acd-ad81-6a34be25e1ff` succeeded across a controller restart with the same task/provider/backend session and high-effort acknowledgment. Independently downloaded v1 (114 bytes) SHA256 `f1da81caf11f90e80c3fe008adf3c1d6e180d38188f929dc8244edd64e4ff41a`; v2 (157 bytes) SHA256 `cae638f5f239806842b9ef1fa9694097e142035f803a89ff743696e558f5b3b2`. V1 remained unchanged. Settled API estimate $0.347345000. Report `/private/tmp/claude-v7-installed-mac-proof-20261015/acceptance.json` retains the state at acceptance; later company holds do not invalidate those settled runs. + +The two subsequent ARM attempts failed before provider startup and remain pending accounting: `c2d8f460-381c-4764-ae04-4974b30739eb` (test-drive stripped inherited callback configuration) and `a6979058-b3d0-49ed-8c6d-159f507e3443` (read-only directory staging). Each retains a $0.50 hold. Callback configuration is now persisted through the instance's normal environment file. ARM uses a dedicated unprivileged account and private SSH listener, with the verified standard provider-pack installation; the system CLI and existing root listener are untouched. Root execution was rejected by Claude's ordinary permission check, reproduced without credentials or prompts. + +Credential-free reproduction proved that BSD tar emits non-contiguous directory entries. GNU tar restored read-only directory permissions before later children arrived. The staging fix delays GNU directory restoration, omits macOS metadata, and verifies existing immutable context bytes and modes before reuse on follow-up. It keeps native provider sync behavior and durable checkpoint handling. Corrupt or symlinked targets do not become accepted cache hits. The regression failed before the fix; all 15 focused controls now pass with both GNU and BSD tar, including repeat staging and corrupt/symlink rejection. The actual patched function also passes twice on the unprivileged ARM host with zero provider calls; receipt `/private/tmp/claude-v7-arm-staging-fixed-20261015.json`. Remote live acceptance remains incomplete; no paid retry runs automatically. + +Earlier allocation receipt `/private/tmp/claude-v7-remote-allocation-20261015.json` preserves the $50 aggregate ceiling and all historical holds. Known rate-card estimates total $4.090164800, excluding pending/unpriced charges and setup reservations. The current remote fixture has a $1.22 cap with its new $0.50 pending hold. Live subscription acceptance remains explicitly unverified under the user's API-key direction. + + +The staging repair is frozen at `0670b85392a0be51745f1ba7ca84f998bf122c07`. Its source CI passed; Greptile reviewed this exact commit at 5/5 with zero unresolved threads. [Package campaign 38751544488](https://github.com/paperclipai/paperclip/actions/runs/38751544488) passed the Linux and Apple Silicon producers, then was cancelled after the Intel worker stalled in checkout for 17 minutes. Its artifacts and checkout log remain preserved. A single infrastructure retry, [38756070238](https://github.com/paperclipai/paperclip/actions/runs/38756070238), uses the same frozen source and one consistent producer attempt. Its Intel checkout completed normally; all three other producers passed. All four producers subsequently passed; package assembly is queued for a hosted Linux worker, followed by clean consumers. No provider retry occurred. The new ARM pack differs from the previous payload only in `runnerSourceRevision`; its new manifest digest is `sha256:9b4dfddca028ac8befcc84b9cbe724553531d991386f80329b4bdcab3890874d`. Daemon bytes remain SHA256 `4720c6a23784d3b4535329ecf852d38af8c14ae920447a662a00c4657711387a`, and the pinned Claude executable remains SHA256 `0292fa22ac2fd43e16be9d0e511ddd8347280d6e0ebaca744ef5b27e05d8d0f8`. The verified new pack is installed in the dedicated QA user's standard location; receipt `/private/tmp/claude-staging-arm-activation-20261015.json`. The previous user pack and the system/root installations remain preserved. + +The prerequisite [#15994](https://github.com/paperclipai/paperclip/pull/15994) and Apple Silicon default [#15993](https://github.com/paperclipai/paperclip/pull/15993) are ready for review, with final packaged Mac evidence in their bodies. Neither is merged. The platform PR [#15997](https://github.com/paperclipai/paperclip/pull/15997) remains draft until its final remote task/revision acceptance passes. The installed Mac acceptance report and screenshot are attached to the completed preview task as `beec99dd-a9b3-40bb-84f5-6cae7c867562` and `fa8f2a00-d18b-444b-802e-11ad25166055`; both were downloaded again and verified after upload. + +The fresh remote fixture `99824208-435b-4c44-8f63-829fc79faad9` (CLAAA) has a $1.40 shared cap. It receives $0.72 of unused allowance from held fixture `4389d6ab` (retaining its $0.50 hold) and $0.68 from original QA `f1dd89ef` (retaining its known spend plus two conservative $8 unknown-charge reserves). Aggregate caps remain $50; every prior hold and the separate $1 setup reserve remain. Receipt `/private/tmp/claude-staging-fixture-allocation-20261015.json` verifies paused donors before and after the reduction. No hold was released and no task retry was submitted. Ordinary UI creation on the unchanged `bd74bf50` UI saved native Claude/Opus 5.5/high effort for ARM agent `7233d811-41f0-4059-9084-5eeea09da7dd` and x64 agent `3ba4cef6-bca9-4341-b9f2-88696e892dba`. Both existing CLI API-key checks succeeded in the selected environments; this is connection evidence, not native execution acceptance. Both agents are bounded to 180 seconds and one concurrent run. UI journal `/private/tmp/claude-staging-ui-20261015`; no runner/model switch occurred after onboarding. + +The isolated database is backed up before package replacement at `/private/tmp/claude-master-local-20261015/instances/default/data/backups/claude-before-staging-0670b853-20261015-094815.sql.gz`, SHA256 `8620dc98360032f48767c0d273d98c4926f355bb7bde6ff802a82451f2fe80cc`. The first backup attempt used the configured default port and timed out; the successful backup binds the actual instance data directory and allocated port 54334. No existing instance or backup was replaced. + + +### Final installed ARM acceptance — 2026-10-15 + +The replacement campaign's public package assembly passed. Artifact `11985673906` is 109691616 bytes, ZIP SHA256 `de6810dca086f2320a52bc4dc3d06e6e07374a0e9246add568f017b56b1bef89`. All 18 archive digests were verified before installing into `/private/tmp/claude-installed-0670b853-preview/consumer`. The controller preview now reports frozen source `0670b85392a0be51745f1ba7ca84f998bf122c07`, with the native experiment disabled. All four installed daemon hashes match the release manifest and are unchanged from the prior packaged Mac proof. The installed ARM provider-pack authority exactly matches the dedicated host's standard installation. All four native builds and all clean consumers have now passed in [campaign 38756070238](https://github.com/paperclipai/paperclip/actions/runs/38756070238). The matching x64 image is building in cloud via [38764436597](https://github.com/paperclipai/paperclip/actions/runs/38764436597); no local Docker build runs. + +The ARM task and revision both succeeded on the final installed source: `47cf1e62-2023-4198-ba04-65564b56ecf8` and `3f07e836-acc0-4141-9daa-bd74013317ac`. Preview http://127.0.0.1:3105/CLAAA/issues/CLAAA-1. Independent downloads match the 114/157-byte oracles and SHA256 values recorded for the packaged Mac pair; the original remains unchanged. Task/provider/backend session identity is the same across turns, and high effort is requested and acknowledged on both. API accounting settled at $0.216398000 and $0.163966600 ($0.380364600 combined), with no pending or unpriced usage in this fresh company. Report `/private/tmp/claude-staging-installed-arm-proof-20261015/acceptance.json`; runtime identities `/private/tmp/claude-0670b853-installed-runtime-identity.json`. The earlier read-only staging failure is preserved and is not counted as passing evidence. The report and reloaded completed-task screenshot were attached and independently downloaded again (`13602319-1f90-4176-94c6-4789df9c630a`, `10c4c7fe-d745-4034-b356-b15565304a8e`). Reloading the agent also retained Claude/Opus 5.5/High settings; UI journal step 51. No runner or provider processes remained in the dedicated ARM account after both turns. + + +The [cloud image consumer](https://github.com/paperclipai/paperclip/actions/runs/38764436597) passed. Immutable image `ghcr.io/paperclipai/paperclip-daytona-runner@sha256:d241ab13d48297a0fe85b04acf70980a427a5fbcd4a95ca2284c7022cd29d03c` contains the exact installed Linux x64 pack `sha256:6adcfdf1a90d922fb65d647e13936eafff7d9cc5f0142ebdfbc11d9470b00c55` and daemon SHA256 `cd46aa8e809f4f3a4f705bfdc93b17b1466c010235f37b0b2f71572f927b5f8f`. Evidence artifact `11989266220` ZIP SHA256 `f6878e2c8e97d0f9d0dc8bfc8af9ea4b255d1f5b92ea6323cd5a983c6e003f34` was independently verified, as were source and inner-image identities. The dedicated x64 environment now uses this image with its saved credential reference preserved. No local Docker or Rust build ran. + + +### Final installed x64 acceptance and accounting — 2026-10-15 + +The final x64 pair succeeded: `65f04a06-c2a4-4c3c-af4a-2f6026563623` and `9c22a214-c9f3-436c-a509-0ddf763c66e6`, using native Claude/Opus 5.5/high effort with the experimental gate off and no runner/model override. Preview http://127.0.0.1:3105/CLAAA/issues/CLAAA-2. Independent downloads match 114/157 bytes and the exact SHA256 values above; the original is unchanged. Task/provider/backend identity persists across two distinct Daytona sandboxes. Both runtime records confirm provider ingress, the selected environment, approve-all permissions, and high-effort acknowledgment. Both leases bind the verified immutable image and report successful cleanup. API accounting settled at $0.212391400 and $0.127695800 ($0.340087200 combined). + +Report `/private/tmp/claude-staging-installed-x64-proof-20261015/acceptance.json` includes actual environment leases, immutable image identity, and cleanup evidence. The report and reloaded completed-task screenshot are attached as `7276f35e-aad9-4be0-8e51-330cd9ad0904` and `20b33105-1229-47ec-b50f-ce88745b3fcf`; both passed independent download comparison. Reloading agent settings retains Claude/Opus 5.5/High; UI journal step 61. The ARM transport addendum `/private/tmp/claude-staging-installed-arm-proof-20261015/execution-targets.json` verifies both actual SSH leases, selected account, standard pack reuse, and permissions from retained runtime records; both leases are released and no provider/runner processes remain in that account. + +Final read-only accounting receipt `/private/tmp/claude-staging-accounting-current-20261015.json` records $4.810616600 known rate-card estimates, $50 aggregate caps, zero active runs, retained unknown charges and setup reserve, and no released holds. The fresh remote company has $0.720451800 observed usage, no pending/unpriced usage, and a $1.40 cap. One older retired fixture retains its prior policy evidence because its current instance is unavailable; its entire $9 allowance remains reserved. The original failed records, unknown-charge holds, previous package attempts and source identities remain intact. No additional paid turns are required for this handoff. + --- # Historical plan and evidence (retained) @@ -254,6 +338,32 @@ Executable identities: daemon source `5f0b898e9ecef77afefe309a230e04d7fef66cde`, The behavior PR now targets the protocol branch and has 64 changed files. Protocol #15994 targets the latest Codex dependency and adds the shared v7 effort/resume contract with historical readers. Cloud Docker-context verification found that `.dockerignore` omitted the new Pi v23 and Copilot v18 identity inputs; explicit exceptions retain them without broadening the fixture context. The old 9788c05 package campaign passed all four daemon producers and Apple Silicon clean install, but the Linux ARM64 consumer failed durable-suspend settlement. Retain that failure; current-source package qualification and ARM diagnosis remain required. No local Docker or Rust compilation occurred. +## Platform candidate implementation + +The isolated platform checkout is `/private/tmp/claude-runner-platform-pr` on +`codex/claude-runner-platforms`. It preserves behavior candidate `8d07b40907`. +The pinned Linux ARM64 Claude SDK 0.3.286 archive passed registry SHA512 +integrity, ELF aarch64 inspection and an actual no-prompt version launch on +the authorized cloud ARM host. Its executable SHA256 is +`0292fa22ac2fd43e16be9d0e511ddd8347280d6e0ebaca744ef5b27e05d8d0f8`. +Native admission retains descriptor/no-follow, package and architecture checks. + +Release producers now retain target-owned Linux x64 and ARM64 provider-pack +identities and relocatable archives separately from daemon receipts. Assembly +binds both target identities to its daemon source and vendors the manifests. +ARM inventories cannot claim additional Pi, Cursor or Copilot qualification. +Mac controllers probe the selected Claude remote target and select its packaged +identity without a development override. Installed SSH hosts use the standard +pack locations. Additional automatic defaults remain gated pending evidence. + +Focused platform checks: pack integrity11, release workflow67, native remote +manifest/selection12, executable/profile/path86 passed (six platform skips). +The first new ARM selection test failed from shared durable fixture identity; +a unique workspace/session per case fixed the fixture. No product guard was +removed. Platform TypeScript build passed. Cloud package and actual Linux task +pairs remain required. Current behavior package campaign: +https://github.com/paperclipai/paperclip/actions/runs/38284813521 . + ## Review and qualification update after the prerequisite split Review stack: [protocol #15994](https://github.com/paperclipai/paperclip/pull/15994), @@ -349,6 +459,40 @@ retain source `557c3064a30779e86e1ae312eab3d1d42c3a8b8d`. Actual pack/image identity agreement remains required. Additional Claude defaults remain gated pending clean-install and execution evidence. No local Docker or Rust build. +### Independent cloud pack comparison and explicit-asset compatibility + +The cloud image build `38293576503` passed. Its immutable image is +`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:d9dbda3a9435e3e555f431bdeb0ba22e1241ae9103184e6c1646a200e26423e1`. +The image manifest, copied from a stopped disposable container on the cloud +host, exactly matches the independently downloaded Linux x64 producer manifest: +pack digest `sha256:97f44524d1b35ed6295c922f6b52ac376960213be0a0e09a3635c281db26aecf`; +Node digest `sha256:7fde7b8afa198da66257f42ee2001d874c7355631e6d1579a5fb5ef1f246df4c`; +provider-tree digest `sha256:83aafb7d6c951c1fa31c0d8bbbfd32c39b4d265b01607cd566c55c8d9b4a6143`. +Both bind source 557c3064a3. This is actual byte-identity proof, not task execution. + +The independently downloaded ARM producer archive has storage SHA256 +`7c219420b02149cdc3f44c6c5073bed40631d26c03d06f0b109a9ff36962c9c7`; +its pack digest is `sha256:51fe734d79211b474ee13f53080ac60f07cdc9315ebd33b703ecf4a7dc0f8ce1`. +Bounded extraction checked 7,244 members, all recorded artifact hashes and +absence of controller binaries. The shipped Claude admission probe passed on +the native cloud ARM host with zero provider turns and no credentials. +The prior installed pack remains available separately. + +Latest platform review found the automatic Linux restriction also affected +explicit Mac SSH assets. The restriction now applies only when selecting a +bundled pack; explicit assets retain their existing manifest integrity checks. +Eight targeted controls passed, including both Mac architectures and unchanged +automatic Linux selection. Final broad source checks run in the cloud on +reconciled `06c4f5e5a0`: [campaign 38296110502](https://github.com/paperclipai/paperclip/actions/runs/38296110502). +The behavior head `2874232d25` received 5/5 after its three settings fixes. + +Installed Mac proof is available as a Paperclip +[acceptance report](http://127.0.0.1:3105/api/attachments/61653e0a-3ae0-459c-b1d1-20b5d1db73d6/content?download=1) +and [screenshot](http://127.0.0.1:3105/api/attachments/bce37c56-3984-45b9-a995-b6a23cc9a85d/content?download=1), +with artifact work products on CLAA-2. The actual native SDK is the server's +nested 0.3.286 package, executable SHA256 +`75e3016e9d2570767b08e43a7467d4817a4f149232c169ca295f2c95fef21433`; +the top-level Legacy SDK is a separate dependency and was not this runtime. ## Four-target packages and SSH launch repair — 2026-10-12 The frozen `557c3064a30779e86e1ae312eab3d1d42c3a8b8d` four-target @@ -1526,6 +1670,187 @@ Subscription follow-up remains unverified; remaining live acceptance uses API keys per human steering. No local Docker or Rust build ran. Merge and production deployment remain separate. +### Release image artifact consumer — 2026-10-14 + +The first current-image x64 run failed before Claude startup. The independently +built image used a separately resolved lock and omitted the producer pack's Pi +assets, so it could not equal the pack manifest shipped in npm. The runtime +rejected it; its pending accounting hold stays preserved. + +The manual cloud image workflow now optionally consumes one successful exact- +source Release Smoke artifact set. It validates repository/workflow/source, +artifact archive digests, canonical paths, package authority, daemon architecture +and daemon bytes before constructing the image. The release pack must equal the +manifest inside the public server tarball. This mode copies the producer's pack +and daemon over an immutable Paperclip base image without rebuilding either or +resolving dependencies. The resulting image is read back to compare pack and +daemon identities. Existing source-build mode remains available. Fifteen focused +negative/positive controls and 71 existing release workflow controls pass; full +current-head checks and fresh review run in hosted CI. The repaired image and +x64 task/revision are still pending. No local Docker or Rust build ran. + +### Review checkpoint and GitHub access gate — 2026-10-14 + +The Mac/ARM reports and completed-task screenshots are attached to their original +completed preview tasks, registered as artifacts, and independently downloaded +again with identical bytes. Mac report attachment: +`fb6e5d1b-09a6-4c0b-980a-cde24e4c25bb`; ARM report: +`15804e48-3338-4e8c-8f5f-0dbe36204e8a`. +Upload receipt: `/private/tmp/claude-2646-acceptance-upload-receipt.json`. +ARM's actual SDK executable SHA256 is +`0292fa22ac2fd43e16be9d0e511ddd8347280d6e0ebaca744ef5b27e05d8d0f8`; +its daemon is `4720c6a23784d3b4535329ecf852d38af8c14ae920447a662a00c4657711387a` +and its automatically discovered pack is +`sha256:fe4b772a2568fc9d9febac5ef37f4cb402a2f2b2448f85983afb2c54a7c367fa`. + +The four current successful turns add $0.808057800 of settled rate-card estimates. +Read-only reconciliation gives $3.054638600 known estimated ledger usage, excluding +unknown charges; the separate $1 setup reservation remains. Aggregate company +caps remain $50. All old holds and the new pre-start x64 pending hold remain +intact. The current company's agents are paused by its budget hold, timers are +disabled, and there are zero live runs. The owned x64 leases report cleanup +success. The unused 3116 Vite process was stopped; installed preview 3115 remains +available for reviewing/downloading the finished tasks. +Accounting: `/private/tmp/claude-qualification-accounting-20261014.json`. + +The cloud image consumer fix is committed locally (38cd5c95e5, d8e4c98cd5 and +502f404794), with 15 focused controls, 71 release controls and YAML syntax checked. +It preserves the non-root Daytona user and binds image labels to the consumed +runtime source. GitHub rejected its push because the OAuth credential lacks +workflow scope. Both existing SSH signing attempts failed through the configured +1Password agent. The attempted dispatch rejected the unknown inputs on the old +published workflow; no corrected image build or model retry started. + +Human intervention requested: approve GitHub SSH access or refresh the GitHub CLI +with workflow scope. Once available, push the platform branch, dispatch the exact +artifact consumer against producer 38610086180/runtime 2646b94533 and immutable +base b774f547…, verify the resulting image, then run the x64 API pair. Keep the +failed current company held. An existing $1 company allowance has zero pending or +unpriced usage and $0.804805400 remaining; its old agent stays paused. A fresh +x64 fixture there can retain the original aggregate $50 cap without releasing any +hold. Do not create a new allocation or silently retry the held task. + +Protocol #15994 remains at dd82936c1d. Behavior #15993 now publishes the evidence- +only 4ee0d1d444; its current-head hosted CI/review is running. Platform #15997's +published head remains bddff3c5e9, with prior green CI and 5/5 review; the prepared +image consumer is not published or reviewed yet. PR bodies distinguish these +states. All remain draft. No merge or production deployment occurred. + +Behavior head 4ee0d1d444 subsequently completed all 54 checks successfully, with +fresh Greptile 5/5 and zero unresolved review threads. Exact hosted source CI: +[38622133620](https://github.com/paperclipai/paperclip/actions/runs/38622133620); +Docker context/native compilation control: +[38622133915](https://github.com/paperclipai/paperclip/actions/runs/38622133915). +The behavior PR checklist now reflects those completed gates. This does not +qualify or publish the still-local platform image consumer. + + +### Hosted named-snapshot repair — 2026-10-15 + +The failed UI journey is retained unchanged. The correction extends explicit +Claude Connect qualification to Daytona snapshots: the plugin resolves a stable +provider ID and creates by that ID, the server measures the actual sandbox and +records v2 company/config/account/template-bound proof after owned cleanup, and +native acquisition pins that same ID. Existing image v1 readers remain intact; +ordinary Legacy acquisition and Codex setup eligibility are unchanged. Discovery +performs no new provider calls. Lookup timeout cannot continue into allocation. + +A no-prompt live check with the pinned Daytona SDK 0.203.0 created an owned sandbox +from the managed snapshot ID, measured Linux x86_64 / glibc 2.41, and deleted it. +Receipt: `/private/tmp/claude-test-deployment-20261015/snapshot-id-live-probe.json`. +This is provider identity/architecture evidence, not native Claude task proof. +The new candidate still needs cloud checks, deployment, and a fresh ordinary UI +journey through creation, useful task, revision and downloaded artifacts. + + +### Warm snapshot review correction — 2026-10-15 + +Review reproduced a valid warm-run gap: run-only `reuseLease: true` changed the +configuration fingerprint before snapshot proof was checked. The orchestrator +now carries the original configured environment into acquisition. The driver +resolves templates and checks proof against that configuration, then applies the +run-only retention setting. It rejects other run/configuration differences. +The regression failed before the fix; 19 focused database/runtime/orchestration +checks pass afterward, including omitted and false saved retention values. + +Candidate 7ff0038175 passed its source CI but received Greptile 4/5 for this gap. +Its task-owned preview build 38792291081 was cancelled before deployment. The +previous served 49f99e130a and failed UI report remain unchanged. A replacement +cloud build and fresh hosted acceptance are pending for the corrected source. +The disposable acceptance project has a UI-configured $2 lifetime cap, a $1 +per-run reservation, and unpriced usage blocking; all survive reload. This uses +the previously reserved allowance within the $50 ceiling. + + +### Hosted API-key acceptance passed — 2026-10-15 + +The corrected candidate `15e37379e94535fd9692a4f0c4d3fadd2595b188` is deployed +to the dedicated test organization. The deployment receipt confirms +`sha-15e37379e94535fd9692a4f0c4d3fadd2595b188-cloud`, status succeeded, and +verification verified. Cloud build: +[38793774125](https://github.com/paperclipai/paperclip/actions/runs/38793774125). +Deployment campaign: `fleet-campaign-1791979001533`. This is a Core-only preview; +private Cloud UI plugins are excluded. All image builds ran in cloud infrastructure. + +A fresh strict browser journey started at `https://test.paperclip.app`, selected +the test organization, created Claude Hosted Writer through New Agent, reused +the saved personal Claude API connection, and submitted default model/runner +settings. The saved Runtime form showed Paperclip Runner (default), Claude +Opus 5.5, effort Auto, and Paperclip Computer. No runner or model override was +submitted. The native experimental flag remained at its disabled app default. +Prior interface familiarity and the preconfigured $2 project are disclosed. + +The useful task and same-task follow-up both succeeded: + +| Turn | Run | Result | Duration | Displayed API estimate | +| --- | --- | --- | --- | --- | +| Create checklist | `000105db-4244-48f2-8b7e-a2bd207b2311` | succeeded | 3m 07s | $0.2083 | +| Add rollback section | `c68e858f-09f2-4aea-9c21-fbc57a1585a2` | succeeded | 3m 11s | $0.1468 | + +Independent checks read browser-downloaded files after the strict journey ended. +The 655-byte v1 has three headings and six checkboxes; the 906-byte v2 preserves +those sections and adds Rollback with two checks. Downloading v1 again after the +revision produced identical bytes. SHA-256 values: + +- v1: `0a279d7048f3e1d949aaaac6fdb467ec8d18489e7e7a6818497083e327bd1365` +- v2: `0b78e9f0226613892f47892a29f2e2aba7f28c7cd78707b6800e194603b8e485` + +Both rendered run logs identify provider session +`b1b07c68-7557-4a8d-b109-6619e10300ec` and normalized session +`163815a9-25e5-438b-bfc0-a84c6fadd479`. The follow-up records session resume +and durable-session attachment to its new run authority. Workspace finalization +succeeded on both runs. Saved settings, both responses, both attachments, and +Done status persisted across revisit/reload. The QA agent was then paused. +The displayed task estimates total $0.3551; this is additional to the previous +$4.810616600 known estimates. Unknown charges and the setup reservation remain +reserved, and aggregate qualification allowances remain within $50. + +The 78-step report includes an actual screenshot for every step. Evidence lives +in `/private/tmp/claude-test-user-20261015-r2/`, including `journey.json`, +`artifact-verification.json`, `session-verification.json`, and the portable HTML. +The report and structured log were uploaded to the completed acceptance task: +report attachment `43b8698f-d264-4d0f-86c3-9d0b8adabd35`; log attachment +`b5313baa-4a1c-408e-886b-400ac876083c`. The original failed journey remains intact. + +Functional acceptance passes. Experience needs improvement: Connect shows +“Connection needs attention” despite successful credential/hello checks, and +cold provider-pack uploads took 122.1s and 125.8s while the task mainly said +“Thinking.” The first download-text click had no visible feedback; file-card +preview and its Download control worked. This is desktop dark-theme evidence, +not mobile/accessibility qualification. The report renderer validated all images; +host preview was queued, so HTML gallery interaction was not visually verified. +Subscription execution and private Cloud plugins remain unqualified. + +Exact deployed-source required CI passed, including typecheck, tests, build, +Docker context/native compilation, and the aggregate verification gates. +Greptile is 5/5 with zero unresolved threads. The optional supply-chain scan +remains neutral/inconclusive. The evidence-only follow-up does not change runtime +bytes or invalidate these serving-source results. PRs remain +[#15994](https://github.com/paperclipai/paperclip/pull/15994), +[#15993](https://github.com/paperclipai/paperclip/pull/15993), and +[#15997](https://github.com/paperclipai/paperclip/pull/15997). +No merge or production rollout occurred. + ## Refreshed local API acceptance — 2026-10-19 @@ -1631,3 +1956,45 @@ The earlier API pair and four-target package results retain their exact source identities above. They are not relabeled as results for this final merge. A new cloud package qualification covers the changed shared runtime, with the final preview/runtime/check identities recorded in the PR bodies and dated handoff. + + +The final platform review found that revoking a successful snapshot proof could +fall back to its mutable name. Failed probes and target edits now preserve a +company-scoped invalidation marker. Native lease acquisition requires renewed +proof before contacting the provider; explicit Legacy and never-qualified +Codex environments retain their previous behavior. DB-backed snapshot/environment +verification passed all 232 cases, including the final metadata-preservation +implementation. Cloud CI remains the final complete-suite gate. This remote-only change does not +replace the source identity of the local API task pair above. + + +## Local reply latency profile — 2026-10-19 + +The user's fresh Opus 5.5/High greeting took 32.717 seconds: approximately +1.2 seconds preparation, 15.5 startup, 13.6 agent work and 2.3 settlement. +Local run-log spans and SDK timings establish this split; the local instance +had no matching exported Honeycomb trace. The MCP connection itself took 23ms. + +Protocol commit `20d75b6f3e` removes an unused verified command snapshot after +effort acknowledgment on a retained fresh provider. Cold control connections +still refresh their consumed command; integrity, lifetime and effort guards stay. +107 runtime/effort/lease tests pass, as do 53 profile checks (one skipped) and +the TypeScript-only check/build. No local Docker/Rust build was used. + +Three alternating real-ACP loopback fixture samples per variant improve median +ready time from 5.081s to 4.034s. The fixture sends no external model requests. +A matched real API-key task improves startup from 5.533s to 4.348s, but total +response time changes from 17.933s to 20.221s as agent time varies. This is a +measured startup improvement, not a demonstrated total-reply speedup. +The candidate follow-up succeeds in 20.146s and resumes the same saved session. +No-prompt cold-resume fixture failures remain recorded; the actual product +follow-up supplies the resume proof. Model, High effort and permissions stay. + +The next candidate is selective upfront loading of mandatory title/completion +MCP tools, supported by the pinned Claude SDK. It is not implemented or timed +here; shared tool metadata requires separate source-profile qualification. +The canonical local evidence record is +`/private/tmp/claude-refresh-evidence-20261019/2026-10-19-claude-default-refresh.md`. +Three paid profiling turns add $0.415121200 estimated, leaving $0.380149800 +under this local company's unchanged $2 cap, with zero pending/unpriced runs. +All prior aggregate qualification holds and the $50 ceiling remain in force. diff --git a/docker/daytona-runner/Dockerfile.release b/docker/daytona-runner/Dockerfile.release new file mode 100644 index 00000000000..d146a17572b --- /dev/null +++ b/docker/daytona-runner/Dockerfile.release @@ -0,0 +1,16 @@ +# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e +ARG BASE_IMAGE +FROM ${BASE_IMAGE} +ARG SOURCE_REVISION +USER root +RUN rm -rf /opt/paperclip-runner/provider-pack +ADD provider-pack.tar.gz /opt/paperclip-runner/provider-pack/ +COPY --chmod=0755 paperclip-runnerd /usr/local/bin/paperclip-runnerd +RUN chmod -R a+rX /opt/paperclip-runner/provider-pack +USER daytona +RUN test -r /opt/paperclip-runner/provider-pack/provider-pack.json \ + && test "$(claude --version)" = "2.1.286 (Claude Code)" \ + && test "$(codex --version)" = "codex-cli 0.160.0" \ + && test "$(acpx --version)" = "0.13.1" +LABEL io.paperclip.runner.content-id="release-${SOURCE_REVISION}" \ + org.opencontainers.image.revision="${SOURCE_REVISION}" diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md index b6c747e1bb8..ecebbcc54f2 100644 --- a/maps/agent-access-map/agents.md +++ b/maps/agent-access-map/agents.md @@ -31,7 +31,9 @@ native hires. Creation requests can use `runner: auto | paperclip | legacy`; the server resolves and persists execution configuration. Claude Auto selects the native ACPX profile -on qualified Apple Silicon; saved runner/model/provider overrides survive edits +on qualified macOS and GNU/Linux x64/ARM64 targets; unknown environment +qualification remains Legacy. Darwin SSH defaults to Legacy while preserving +explicit native assets and saved execution. Saved runner/model/provider overrides survive edits and approval activation. This selection does not grant connection access or bypass hiring authorization. Source: [runner resolver](../../server/src/services/agent-runner-selection.ts). diff --git a/maps/agent-access-map/execution-environments.md b/maps/agent-access-map/execution-environments.md index 5c2b2583167..1a5e97fc0a2 100644 --- a/maps/agent-access-map/execution-environments.md +++ b/maps/agent-access-map/execution-environments.md @@ -95,7 +95,8 @@ Both probe endpoints require a board actor that is local-implicit or an instance administrator; ordinary agent identity and company membership alone are denied. The saved sandbox probe also resolves company/environment and secret access before allocating a fresh lease. Its measurement is company-bound to an immutable -image digest, effective provider configuration and active custom-image identity; +image digest or verified Daytona snapshot ID, effective provider configuration +and active custom-image identity; only confirmed cleanup permits recording it. A draft probe does not record saved qualification. Reading stored qualification does not lease/provision a target and does not grant an agent permission to probe or manage that target. @@ -117,7 +118,8 @@ at the scoped commit. A connection-only success or a fixture cannot certify native compatibility, account authentication or agent access. Scoped Verification: `not-run` — on one authorized owned target, compare pinned -image proof with mutable/snapshot and changed-config controls; require independent +image and provider snapshot-ID proof with mutable-image, unverified-snapshot +and changed-config/account controls; require independent cleanup evidence before recording proof. Assert agent/non-admin board denial, secret-context denial where the actor lacks access and forged-proof rejection. Verify API reads hide the private record and metadata-only updates cannot overwrite it. Then use diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md index aae2f7863db..76088a25141 100644 --- a/maps/feature-map/agents.md +++ b/maps/feature-map/agents.md @@ -69,13 +69,16 @@ preserves its execution. For a native Codex import, edit a model or account bind and verify unchanged runtime settings survive; an explicit clear must stay cleared. Use the [portability recipe](./companies.md) for package preparation and boundaries. -For Claude Code on Apple Silicon, submit without a runner override and inspect +For Claude Code on qualified macOS or GNU/Linux x64/ARM64, submit without a +runner override and inspect saved configuration: `paperclip_runner`, `provider: acpx`, `acpxAgent: claude`, and ordinary model `claude-opus-5-5`. The Advanced runner selector can explicitly choose Legacy. Keep explicit model/account choices and saved runners on edits. Verify useful output and a follow-up after a controller restart; setup success -alone does not establish session continuity. Additional target defaults remain -subject to their separate qualification gates. +alone does not establish session continuity. Unsupported architectures/libc and unknown environment qualification retain +Legacy. Darwin SSH also defaults to Legacy until automatic matching assets are +qualified; explicit matching native assets and saved native configurations remain +supported. Linux ARM64 cloud-image production remains deferred. The current hiring wizard assigns the first hire CEO and subsequent hires General reporting to the CEO. Change manager/role afterward through supported diff --git a/maps/feature-map/execution-environments.md b/maps/feature-map/execution-environments.md index 6beb90d4a26..a4b2e7fbad9 100644 --- a/maps/feature-map/execution-environments.md +++ b/maps/feature-map/execution-environments.md @@ -42,8 +42,8 @@ Automated: [environment UI](../../ui/src/pages/CompanyEnvironments.test.tsx), [d Manual: Save a disposable target, reload it, and run its available probe. Verify both success and missing-credential/reachability errors identify the target. Confirm unavailable providers are not offered as working run targets. -For an immutable sandbox image, pin the OCI image with `@sha256` and explicitly -probe the saved environment in the intended company. The existing connection +For an immutable sandbox image, pin the OCI image with `@sha256`, or select +a Daytona snapshot whose provider ID can be verified, and explicitly probe the saved environment in the intended company. The existing connection probe allocates a fresh lease and can measure its OS, architecture and libc. Qualification is recorded only when the selected image/configuration matches the live lease and cleanup is confirmed. Inspect `platformQualificationRecorded` @@ -52,7 +52,10 @@ the image has native runtime qualification. The measured record is server-owned, company-bound and tied to the effective configuration and active custom-image identity. Mutable tags, provider defaults -and name-only snapshots do not qualify. Changing the target or image invalidates +and snapshots without verified provider identities do not qualify. Daytona +qualification creates by provider ID and binds native execution to that same ID; +a deleted ID fails instead of resolving a replacement name. Changing the target, +account, image or active captured template invalidates the record; normal metadata edits cannot forge or overwrite it, and API reads omit the private metadata. Reading this evidence for discovery or creation does not allocate a sandbox. The draft `probe-config` checks are not a saved-image diff --git a/maps/feature-map/onboarding.md b/maps/feature-map/onboarding.md index 5428094bed0..1c15e75dfbc 100644 --- a/maps/feature-map/onboarding.md +++ b/maps/feature-map/onboarding.md @@ -68,14 +68,16 @@ Use [runner validation](./runs-adapters.md#adapter-setup) and [environment qualification](./execution-environments.md#environment-settings) for the prerequisites; a successful login alone is insufficient. -For a new Claude agent on an explicit immutable sandbox image, use Connect to +For a new Claude agent on an explicit immutable sandbox image or a Daytona +named snapshot, use Connect to qualify the selected environment through the existing permission-gated probe. Do not preprobe outside onboarding when claiming this first-use path. Fresh scoped discovery determines the runner for both the account test and hire; the measured Legacy result remains valid. An instance-admin denial, incomplete cleanup, missing lease or changed configuration stops setup before the adapter -test or hire. Named snapshots and mutable image tags do not gain qualification -through this flow. [Helper controls](../../ui/src/lib/qualify-claude-setup.test.ts) +test or hire. Daytona resolves the selected snapshot to its immutable provider +ID before creating the measured lease; native execution pins that same ID. +Mutable image tags and other providers’ named templates remain unqualified. [Helper controls](../../ui/src/lib/qualify-claude-setup.test.ts) and mounted wizard/new-agent tests use mocks; live managed acceptance remains a separate gate. For a new Codex agent with Auto on a saved immutable OCI sandbox, Connect first diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 4ac35777143..e0b63d19f0a 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -202,7 +202,7 @@ the task waits for the next instruction. See the 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 +binds each daemon path and SHA-256, plus `remoteProviderPacks: {"linux-x64": {path, sha256}, "linux-arm64": {path, sha256}}` for the actual image's `provider-pack.json`. Assemble these assets after the normal build and include them in the server's vendored Runner output before npm packing. Assembly requires the provider pack's source revision to match `sourceRevision` @@ -241,10 +241,26 @@ for an image pinned with `@sha256`, after confirmed lease cleanup. The record is server-owned and bound to the company, effective provider/image configuration and active custom-image identity. Creation and discovery read this record without allocating a sandbox. Configuration or image changes invalidate it. Mutable tags, -provider defaults and name-only snapshots do not establish compatibility; they -keep automatic Legacy selection until there is verifiable immutable image evidence. +provider defaults and unverified snapshot names do not establish compatibility; +they keep automatic Legacy selection until there is verifiable immutable image +or Daytona snapshot-ID evidence. See managed snapshot qualification below. A connection-only success does not establish native readiness. +### Managed Daytona snapshot qualification + +Claude's automatic Connect flow can qualify a named Daytona snapshot through the +existing administrator-only saved environment probe. The bundled Daytona plugin +resolves the name to its provider snapshot ID, creates the probe sandbox by ID, +and returns its account and creation identity. OS, architecture and libc facts +are recorded only after confirmed cleanup. Native execution uses that same +snapshot ID; discovery does not allocate or perform provider lookups. A deleted +snapshot fails at the provider rather than silently selecting a replacement with +the same name. Configuration, resolved credentials and active captured-template +changes require another probe. Other named provider templates and mutable OCI +tags remain unqualified. A provider plugin without snapshot identity support +returns an actionable setup failure; it cannot certify a native default. + + 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. @@ -740,6 +756,21 @@ identifies the current console iteration. `create_task` accepts an optional initial `status` of `backlog` or `todo`. Use `backlog` when the user wants a saved task or plan without execution: assignment and the initial plan are committed without scheduling a wake, even when dependencies are already complete. Omitting status preserves immediate delegation (`todo`, or `blocked` for unresolved dependencies). + +Linux provider-pack producers run on the corresponding native Linux target and +retain `provider-pack.json` with a relocatable `provider-pack.tar.gz`. Release +assembly binds both identities to the same source as its four daemons. The +historical `remoteProviderPack` field remains the Linux x64 identity. Linux +ARM64 packs do not qualify Pi, Cursor or Copilot on that target. + +For an installed SSH host, install the same release producer's pack at +`$HOME/.local/share/paperclip-runner/provider-pack` or +`/opt/paperclip-runner/provider-pack`. A Mac controller selects its packaged +Linux target identity after probing the selected host. The runtime verifies the +installed pack before prompting. No development pack override is needed. A +missing or mismatched pack fails clearly. Existing explicit overrides remain +authoritative. ARM64 cloud-image production remains deferred. + ### Oversized OpenCode events The OpenCode driver keeps SSE frames up to 1,048,576 characters exact. It compacts longer diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index 738c06ba3e0..6728de3da4e 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -81,7 +81,7 @@ "typecheck:browser": "tsc -p tsconfig.browser.json --noEmit", "test": "pnpm run test:typescript && pnpm run test:rust", "test:typescript": "pnpm run test:typescript:prep && vitest run", - "test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node ../../scripts/ci-profile/node-test.mjs test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-message-boundaries-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/release-provider-pack.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs scripts/installed-package-pack.test.mjs scripts/runner-runtime-dependency-roots.test.mjs scripts/runner-package-executable.test.mjs scripts/stage-runner-binary.test.mjs scripts/build-inputs.test.mjs", + "test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node ../../scripts/ci-profile/node-test.mjs test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-message-boundaries-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/release-provider-pack.test.mjs scripts/provider-pack-runtime-tree.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs scripts/installed-package-pack.test.mjs scripts/runner-runtime-dependency-roots.test.mjs scripts/runner-package-executable.test.mjs scripts/stage-runner-binary.test.mjs scripts/build-inputs.test.mjs", "test:typescript:vitest": "node ./scripts/run-pr-vitest-lane.mjs", "test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace", "test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider", diff --git a/packages/paperclip-runner/scripts/build-provider-pack.mjs b/packages/paperclip-runner/scripts/build-provider-pack.mjs index a58e8cb3156..9925bd7ff67 100644 --- a/packages/paperclip-runner/scripts/build-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/build-provider-pack.mjs @@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url"; import { parseProviderPackArguments, materializeCandidateProviderPack, providerPackProviders, providerPackManifestFields } from "./candidate-provider-pack.mjs"; import { buildNodeStartupTimeout } from "./build-node-startup-timeout.mjs"; import { writePortableExecutableShim, writePortableCopilotShims } from "./provider-pack-executable-shims.mjs"; +import { removeControllerArtifactsFromProviderPack } from "./provider-pack-runtime-tree.mjs"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const workspaceRoot = resolve(packageRoot, "../.."); @@ -117,6 +118,7 @@ try { if (deployed.status !== 0) { throw new Error(`pnpm deploy failed with exit code ${deployed.status}`); } + removeControllerArtifactsFromProviderPack(temporaryRoot); // Fail the image build if a bridge silently brings back an older/private // provider CLI. A direct dependency alone does not deduplicate pnpm's graph. diff --git a/packages/paperclip-runner/scripts/provider-pack-runtime-tree.mjs b/packages/paperclip-runner/scripts/provider-pack-runtime-tree.mjs new file mode 100644 index 00000000000..5080335ef8f --- /dev/null +++ b/packages/paperclip-runner/scripts/provider-pack-runtime-tree.mjs @@ -0,0 +1,8 @@ +import { rmSync } from "node:fs"; +import { join } from "node:path"; + +export function removeControllerArtifactsFromProviderPack(packRoot) { + for (const directory of ["bin", "remote-provider-packs"]) { + rmSync(join(packRoot, "dist", directory), { recursive: true, force: true }); + } +} diff --git a/packages/paperclip-runner/scripts/provider-pack-runtime-tree.test.mjs b/packages/paperclip-runner/scripts/provider-pack-runtime-tree.test.mjs new file mode 100644 index 00000000000..83c64819f68 --- /dev/null +++ b/packages/paperclip-runner/scripts/provider-pack-runtime-tree.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { removeControllerArtifactsFromProviderPack } from "./provider-pack-runtime-tree.mjs"; + +test("image and native producer packs retain identical provider outputs despite different controller artifacts", () => { + const root = mkdtempSync(join(tmpdir(), "provider-pack-producers-")); + try { + const packs = [join(root, "image"), join(root, "native")]; + for (const pack of packs) { + mkdirSync(join(pack, "dist", "cli"), { recursive: true }); + writeFileSync(join(pack, "dist", "cli", "acpx-runtime-sidecar.cjs"), "identical provider bridge\n"); + } + mkdirSync(join(packs[1], "dist", "bin")); + writeFileSync(join(packs[1], "dist", "bin", "paperclip-runnerd"), "host-specific daemon"); + mkdirSync(join(packs[1], "dist", "remote-provider-packs")); + writeFileSync(join(packs[1], "dist", "remote-provider-packs", "prior-pack.json"), "earlier release identity"); + for (const pack of packs) { + removeControllerArtifactsFromProviderPack(pack); + assert.deepEqual(readdirSync(join(pack, "dist")), ["cli"]); + assert.equal(readFileSync(join(pack, "dist", "cli", "acpx-runtime-sidecar.cjs"), "utf8"), "identical provider bridge\n"); + } + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("removing an owned controller-artifact link preserves its external target", () => { + const root = mkdtempSync(join(tmpdir(), "provider-pack-controller-link-")); + try { + const pack = join(root, "pack"), outside = join(root, "outside"); + mkdirSync(join(pack, "dist"), { recursive: true }); mkdirSync(outside); + writeFileSync(join(outside, "preserved"), "original"); + symlinkSync(outside, join(pack, "dist", "bin")); + removeControllerArtifactsFromProviderPack(pack); + assert.equal(existsSync(join(pack, "dist", "bin")), false); + assert.equal(readFileSync(join(outside, "preserved"), "utf8"), "original"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/packages/paperclip-runner/scripts/release-provider-pack.mjs b/packages/paperclip-runner/scripts/release-provider-pack.mjs index cedbde6bbf2..4ac164dcc51 100644 --- a/packages/paperclip-runner/scripts/release-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/release-provider-pack.mjs @@ -7,22 +7,30 @@ const canonical = value => Array.isArray(value) ? `[${value.map(canonical).join( : JSON.stringify(value); /** Release assembly must reject a self-consistent pack built for another release. */ -export function verifyReleaseProviderPack(identity, sourceRevision) { +export function verifyReleaseProviderPack(identity, sourceRevision, target = "linux-x64", expected = { profiles, distributions }) { + const { profiles: releaseProfiles, distributions: releaseDistributions } = expected; const payload = identity?.payload; if (identity?.schema !== "paperclip-runner/remote-provider-pack/v1" || !payload - || payload.target?.platform !== "linux" || payload.target?.architecture !== "x64" - || payload.runnerSourceRevision !== sourceRevision) throw new Error("Remote provider pack must match the release source and Linux x64 target"); + || !["linux-x64", "linux-arm64"].includes(target) + || payload.target?.platform !== "linux" || `linux-${payload.target?.architecture}` !== target + || payload.runnerSourceRevision !== sourceRevision) throw new Error(`Remote provider pack must match the release source and ${target === "linux-x64" ? "Linux x64" : target} target`); if (identity.digest !== `sha256:${createHash("sha256").update(canonical(payload)).digest("hex")}`) { throw new Error("Remote provider-pack payload digest mismatch"); } - if (payload.pins?.acpx !== profiles.acpxVersion - || ["grok", "claude", "codex"].some(agent => payload.acpxProfileDigests?.[agent] !== profiles.profiles[agent].commandDigest)) { + if (payload.pins?.acpx !== releaseProfiles.acpxVersion + || ["grok", "claude", "codex"].some(agent => payload.acpxProfileDigests?.[agent] !== releaseProfiles.profiles[agent].commandDigest)) { throw new Error("Remote provider-pack ACPX profiles do not match this release"); } - const current = profiles.profiles.cursor; + if (target === "linux-arm64") { + if (Object.keys(payload.providers ?? {}).length || Object.keys(payload.candidateProviders ?? {}).length) { + throw new Error("Linux ARM64 pack must not expand other providers qualification"); + } + return identity; + } + const current = releaseProfiles.profiles.cursor; for (const cursor of [payload.providers?.cursor, ...(Object.hasOwn(payload.candidateProviders ?? {}, "cursor") ? [payload.candidateProviders.cursor] : [])]) { if (!cursor || cursor.version !== current.agentServerVersion || cursor.profileDigest !== current.commandDigest - || cursor.closureDigest !== `sha256:${distributions.platforms["linux-x64"].closureSha256}` + || cursor.closureDigest !== `sha256:${releaseDistributions.platforms["linux-x64"].closureSha256}` || cursor.qualification !== "qualified" || cursor.path !== "provider-assets/cursor/linux-x64" || !/^sha256:[a-f0-9]{64}$/.test(cursor.sha256 ?? "")) { throw new Error("Remote provider-pack Cursor identity does not match this release"); diff --git a/packages/paperclip-runner/scripts/release-provider-pack.test.mjs b/packages/paperclip-runner/scripts/release-provider-pack.test.mjs index 171edd46f06..84b6ac811fb 100644 --- a/packages/paperclip-runner/scripts/release-provider-pack.test.mjs +++ b/packages/paperclip-runner/scripts/release-provider-pack.test.mjs @@ -9,7 +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"; +import { existingReleaseProviderPack, releaseProviderPackManifest } from "./stage-release-runner-binaries.mjs"; const revision = "a".repeat(40); const digest = "sha256:" + "b".repeat(64); @@ -94,3 +94,56 @@ test("daemon-only assembly retains an existing same-source Linux provider-pack i assert.throws(() => existingReleaseProviderPack(root, revision), /Linux x64 target/); } finally { rmSync(root, { recursive: true, force: true }); } }); + +function armFixture() { + const payload = fixture(); + payload.target.architecture = "arm64"; + delete payload.providers; + return payload; +} +test("ARM pack binds its own target and preserves other providers qualification limits", () => { + const pack = seal(armFixture()); + assert.equal(verifyReleaseProviderPack(pack, revision, "linux-arm64"), pack); + assert.throws(() => verifyReleaseProviderPack(pack, revision), /Linux x64 target/); + assert.throws(() => verifyReleaseProviderPack(seal(fixture()), revision, "linux-arm64"), /linux-arm64 target/); + for (const inventory of ["providers", "candidateProviders"]) { + const payload = armFixture(); payload[inventory] = { pi: {} }; + assert.throws(() => verifyReleaseProviderPack(seal(payload), revision, "linux-arm64"), /must not expand/); + } +}); +test("both remote pack identities bind one source and remain separated during assembly", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "release-provider-targets-"))); + try { + for (const [target, payload] of [["linux-x64", fixture()], ["linux-arm64", armFixture()]]) { + const file = join(root, target, "provider-pack.json"); mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify(seal(payload))); + } + const entries = releaseProviderPackManifest(root, revision); + assert.deepEqual(Object.keys(entries), ["linux-x64", "linux-arm64"]); + assert.notEqual(entries["linux-x64"].sha256, entries["linux-arm64"].sha256); + assert.throws(() => releaseProviderPackManifest(root, "c".repeat(40)), /release source/); + writeFileSync(join(root, "linux-arm64", "provider-pack.json"), JSON.stringify(seal(fixture()))); + assert.throws(() => releaseProviderPackManifest(root, revision), /linux-arm64 target/); + rmSync(join(root, "linux-arm64"), { recursive: true }); + assert.throws(() => releaseProviderPackManifest(root, revision), /exactly both/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("frozen image validation uses release profiles rather than the newer workflow checkout", () => { + const frozenProfiles = structuredClone(profiles); + const frozenDistributions = structuredClone(distributions); + frozenProfiles.acpxVersion = "frozen-acpx"; + for (const agent of ["claude", "codex", "grok", "cursor"]) frozenProfiles.profiles[agent].commandDigest = digest; + frozenProfiles.profiles.cursor.agentServerVersion = "frozen-cursor"; + frozenDistributions.platforms["linux-x64"].closureSha256 = "c".repeat(64); + const payload = fixture(); + payload.pins.acpx = frozenProfiles.acpxVersion; + for (const agent of ["claude", "codex", "grok"]) payload.acpxProfileDigests[agent] = digest; + Object.assign(payload.providers.cursor, { version: "frozen-cursor", profileDigest: digest, closureDigest: "sha256:" + "c".repeat(64) }); + const pack = seal(payload); + const expected = { profiles: frozenProfiles, distributions: frozenDistributions }; + assert.throws(() => verifyReleaseProviderPack(pack, revision), /profiles/); + assert.equal(verifyReleaseProviderPack(pack, revision, "linux-x64", expected), pack); + payload.acpxProfileDigests.claude = "sha256:" + "d".repeat(64); + assert.throws(() => verifyReleaseProviderPack(seal(payload), revision, "linux-x64", expected), /profiles/); +}); diff --git a/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs b/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs index 5f82bd937f1..ab6153b7ce7 100644 --- a/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs +++ b/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs @@ -21,6 +21,7 @@ export function releaseRunnerTargets(contract) { export const RELEASE_RUNNER_TARGETS = releaseRunnerTargets(JSON.parse( readFileSync(new URL("../release-runner-targets.json", import.meta.url), "utf8"), )); +export const RELEASE_PROVIDER_PACK_TARGETS = Object.freeze(["linux-x64", "linux-arm64"]); const required = RELEASE_RUNNER_TARGETS; const digest = bytes => "sha256:" + createHash("sha256").update(bytes).digest("hex"); function regularFile(path, maximumSize) { @@ -64,10 +65,23 @@ export function releaseDaemonManifest(artifactDirectory, sourceRevision, verifyM return { sourceRevision, platforms }; } -export function existingReleaseProviderPack(packageRoot, sourceRevision) { - const file = join(packageRoot, "dist/remote-provider-packs/linux-x64/provider-pack.json"); +export function existingReleaseProviderPack(packageRoot, sourceRevision, target = "linux-x64") { + if (!RELEASE_PROVIDER_PACK_TARGETS.includes(target)) throw Error("Unsupported release provider pack target"); + const file = join(packageRoot, `dist/remote-provider-packs/${target}/provider-pack.json`); if (!existsSync(file)) return undefined; - return verifyReleaseProviderPack(JSON.parse(regularFile(file, 512 * 1024)), sourceRevision); + return verifyReleaseProviderPack(JSON.parse(regularFile(file, 512 * 1024)), sourceRevision, target); +} + +export function releaseProviderPackManifest(artifactDirectory, sourceRevision) { + if (readdirSync(artifactDirectory).sort().join() !== [...RELEASE_PROVIDER_PACK_TARGETS].sort().join()) { + throw Error("Release provider packs require exactly both Linux target directories"); + } + return Object.fromEntries(RELEASE_PROVIDER_PACK_TARGETS.map(target => { + const path = join(resolve(artifactDirectory), target, "provider-pack.json"); + const bytes = regularFile(path, 512 * 1024); + verifyReleaseProviderPack(JSON.parse(bytes), sourceRevision, target); + return [target, { path, sha256: digest(bytes) }]; + })); } async function main() { @@ -88,11 +102,12 @@ async function main() { 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"); + if (daemonOnly ? ![5, 6].includes(process.argv.length) : 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"); + if (daemonOnly && process.argv[5]) manifest.remoteProviderPacks = releaseProviderPackManifest(process.argv[5], manifest.sourceRevision); 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`); @@ -100,16 +115,26 @@ async function main() { 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) { - packIdentity = existingReleaseProviderPack(root, manifest.sourceRevision); + if (!manifest.remoteProviderPack && !manifest.remoteProviderPacks && !daemonOnly) throw Error("Release assembly requires the matching Linux remote provider pack"); + if (manifest.remoteProviderPacks && Object.keys(manifest.remoteProviderPacks).sort().join() !== [...RELEASE_PROVIDER_PACK_TARGETS].sort().join()) { + throw Error("Release provider packs require exactly both Linux targets"); + } + const packs = {}; + for (const target of RELEASE_PROVIDER_PACK_TARGETS) { + const entry = manifest.remoteProviderPacks?.[target] ?? (target === "linux-x64" ? manifest.remoteProviderPack : undefined); + if (entry) { + if (!isAbsolute(entry.path) || !/^sha256:[a-f0-9]{64}$/.test(entry.sha256)) throw Error("Invalid remote provider-pack release artifact"); + const bytes = regularFile(entry.path, 512 * 1024); + if (digest(bytes) !== entry.sha256) throw Error("Remote provider-pack release manifest digest mismatch"); + packs[target] = { bytes, identity: verifyReleaseProviderPack(JSON.parse(bytes), manifest.sourceRevision, target) }; + if (target === "linux-x64" && manifest.remoteProviderPack && manifest.remoteProviderPacks && + (manifest.remoteProviderPack.path !== entry.path || manifest.remoteProviderPack.sha256 !== entry.sha256)) { + throw Error("Conflicting legacy Linux x64 provider-pack identity"); + } + } else if (daemonOnly) { + const identity = existingReleaseProviderPack(root, manifest.sourceRevision, target); + if (identity) packs[target] = { identity }; + } } const platforms = {}; for (const artifact of verified) { @@ -121,19 +146,21 @@ async function main() { 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"); + for (const [target, pack] of Object.entries(packs)) { + if (!pack.bytes) continue; + const destination = join(root, "dist", "remote-provider-packs", target, "provider-pack.json"); mkdirSync(dirname(destination), { recursive: true }); const staged = `${destination}.staging-${process.pid}`; try { - writeFileSync(staged, packBytes, { mode: 0o444, flag: "wx" }); + writeFileSync(staged, pack.bytes, { 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"); + ...(packs["linux-x64"] ? { remoteProviderPack: { target: "linux-x64", digest: packs["linux-x64"].identity.digest, sourceRevision: packs["linux-x64"].identity.payload.runnerSourceRevision } } : {}), + ...(Object.keys(packs).length ? { remoteProviderPacks: Object.fromEntries(Object.entries(packs).map(([target, pack]) => [target, { target, digest: pack.identity.digest, sourceRevision: pack.identity.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/src/live/bundled-remote-provider-pack.test.ts b/packages/paperclip-runner/src/live/bundled-remote-provider-pack.test.ts index f63f8001e24..45471772e8a 100644 --- a/packages/paperclip-runner/src/live/bundled-remote-provider-pack.test.ts +++ b/packages/paperclip-runner/src/live/bundled-remote-provider-pack.test.ts @@ -7,13 +7,13 @@ import { afterEach, expect, it } from "vitest"; import { bundledRemoteProviderPackManifestPath, bundledRemoteRunnerBinary } from "./bundled-remote-provider-pack.js"; const roots: string[] = []; afterEach(() => roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true }))); -function release() { +function release(target: "linux-x64" | "linux-arm64" = "linux-x64") { const root = mkdtempSync(join(tmpdir(), "paperclip-remote-release-")); roots.push(root); - const output = join(root, "dist/vendor/paperclip-runner"), binary = join(output, "bin/linux-x64/paperclip-runnerd"); - mkdirSync(join(output, "bin/linux-x64"), { recursive: true }); - const bytes = Buffer.alloc(64); Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1]).copy(bytes); bytes.writeUInt16LE(62, 18); + const output = join(root, "dist/vendor/paperclip-runner"), binary = join(output, `bin/${target}/paperclip-runnerd`); + mkdirSync(join(output, `bin/${target}`), { recursive: true }); + const bytes = Buffer.alloc(64); Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1]).copy(bytes); bytes.writeUInt16LE(target === "linux-arm64" ? 183 : 62, 18); writeFileSync(binary, bytes); - const manifest = { schema: "paperclip.runner.release-binaries.v1", sourceRevision: "a".repeat(40), platforms: { "linux-x64": { path: "linux-x64/paperclip-runnerd", sha256: "sha256:" + createHash("sha256").update(bytes).digest("hex") } } }; + const manifest = { schema: "paperclip.runner.release-binaries.v1", sourceRevision: "a".repeat(40), platforms: { [target]: { path: `${target}/paperclip-runnerd`, sha256: "sha256:" + createHash("sha256").update(bytes).digest("hex") } } }; const save = () => writeFileSync(join(output, "bin/release-manifest.json"), JSON.stringify(manifest)); save(); return { output, binary, bytes, manifest, save, url: pathToFileURL(join(output, "live/bundled-remote-provider-pack.js")).href }; } @@ -22,6 +22,12 @@ it("selects the packaged Linux daemon even when the controller is macOS", () => expect(bundledRemoteRunnerBinary(r.url)).toBe(r.binary); expect(bundledRemoteProviderPackManifestPath(r.url)).toBe(join(r.output, "remote-provider-packs/linux-x64/provider-pack.json")); }); +it("selects ARM64 pack and daemon authority for the remote target", () => { + const r = release("linux-arm64"); + expect(bundledRemoteRunnerBinary(r.url, "linux-arm64")).toBe(r.binary); + expect(bundledRemoteProviderPackManifestPath(r.url, "linux-arm64")).toBe(join(r.output, "remote-provider-packs/linux-arm64/provider-pack.json")); + expect(() => bundledRemoteRunnerBinary(r.url)).toThrow(/runner_remote_artifact_unavailable/); +}); it.each(["bytes", "architecture", "digest", "path"])("rejects a mismatched packaged remote daemon: %s", reason => { const r = release(); if (reason === "bytes") writeFileSync(r.binary, Buffer.alloc(64)); diff --git a/packages/paperclip-runner/src/live/bundled-remote-provider-pack.ts b/packages/paperclip-runner/src/live/bundled-remote-provider-pack.ts index 29b4c9885db..1dd24d7588d 100644 --- a/packages/paperclip-runner/src/live/bundled-remote-provider-pack.ts +++ b/packages/paperclip-runner/src/live/bundled-remote-provider-pack.ts @@ -5,31 +5,34 @@ import { dirname, join, resolve } from "node:path"; import { runnerBinaryTarget } from "./runner-binary.js"; /** Release-owned identity for the pack already installed in the Daytona image. */ -export function bundledRemoteProviderPackManifestPath(moduleUrl = import.meta.url): string { +export type BundledRemoteProviderPackTarget = "linux-x64" | "linux-arm64"; + +export function bundledRemoteProviderPackManifestPath(moduleUrl = import.meta.url, target: BundledRemoteProviderPackTarget = "linux-x64"): string { + if (target !== "linux-x64" && target !== "linux-arm64") throw new Error("Bundled remote provider pack target is unsupported"); const url = new URL(moduleUrl); if (url.protocol !== "file:" || url.search || url.hash) throw new Error("Bundled remote provider pack requires a package file layout"); if (/\/src\/live\/bundled-remote-provider-pack\.ts$/.test(url.pathname)) { - return fileURLToPath(new URL("../../dist/remote-provider-packs/linux-x64/provider-pack.json", url)); + return fileURLToPath(new URL(`../../dist/remote-provider-packs/${target}/provider-pack.json`, url)); } if (/\/dist\/(?:vendor\/paperclip-runner\/)?live\/bundled-remote-provider-pack\.js$/.test(url.pathname)) { - return fileURLToPath(new URL("../remote-provider-packs/linux-x64/provider-pack.json", url)); + return fileURLToPath(new URL(`../remote-provider-packs/${target}/provider-pack.json`, url)); } throw new Error("Bundled remote provider pack requires a package file layout"); } /** A macOS controller must authorize Linux bytes for its Daytona daemon. */ -export function bundledRemoteRunnerBinary(moduleUrl = import.meta.url): string { - const manifestPath = bundledRemoteProviderPackManifestPath(moduleUrl); +export function bundledRemoteRunnerBinary(moduleUrl = import.meta.url, target: BundledRemoteProviderPackTarget = "linux-x64"): string { + const manifestPath = bundledRemoteProviderPackManifestPath(moduleUrl, target); const outputRoot = resolve(dirname(manifestPath), "../.."); const release = JSON.parse(readFileSync(join(outputRoot, "bin/release-manifest.json"), "utf8")); - const artifact = release?.platforms?.["linux-x64"]; + const artifact = release?.platforms?.[target]; if (release?.schema !== "paperclip.runner.release-binaries.v1" || !/^[a-f0-9]{40}$/.test(release.sourceRevision ?? "") || - artifact?.path !== "linux-x64/paperclip-runnerd" || !/^sha256:[a-f0-9]{64}$/.test(artifact.sha256 ?? "")) { + artifact?.path !== `${target}/paperclip-runnerd` || !/^sha256:[a-f0-9]{64}$/.test(artifact.sha256 ?? "")) { throw new Error("runner_remote_artifact_unavailable: install the complete Paperclip release with its Linux daemon"); } const binary = join(outputRoot, "bin", artifact.path); const bytes = readFileSync(binary); - if (runnerBinaryTarget(bytes) !== "linux-x64" || "sha256:" + createHash("sha256").update(bytes).digest("hex") !== artifact.sha256) { + if (runnerBinaryTarget(bytes) !== target || "sha256:" + createHash("sha256").update(bytes).digest("hex") !== artifact.sha256) { throw new Error("runner_remote_artifact_incompatible: packaged Linux daemon identity mismatch"); } return binary; diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index 0bbc3846755..d4e69cbba87 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -366,6 +366,50 @@ describe("Daytona sandbox provider plugin", () => { __resetDaytonaSandboxHandleCacheForTest(); }); + const snapshotRecord = { id: "a6b9e7d0-f886-471a-b461-640dd03e8d0f", name: "managed-snapshot", organizationId: "provider-account", createdAt: "2026-10-07T23:19:57.133Z" }; + const snapshotProbe = { driverKey: "daytona", companyId: "company-1", environmentId: "env-1", runId: "probe-1", + resolveSnapshotIdentity: true, config: { apiKey: "fixture-key", snapshot: "managed-snapshot", reuseLease: false } }; + + it("qualifies a named snapshot by creating from its immutable provider ID", async () => { + mockSnapshotGet.mockResolvedValue(snapshotRecord); + mockCreate.mockResolvedValue(createMockSandbox()); + const lease = await plugin.definition.onEnvironmentAcquireLease!(snapshotProbe); + expect(mockSnapshotGet).toHaveBeenCalledWith("managed-snapshot"); + expect(mockCreate.mock.calls[0][0].snapshot).toBe(snapshotRecord.id); + expect(lease.metadata).toMatchObject({ snapshot: "managed-snapshot", snapshotIdentity: { + ...snapshotRecord, provider: "daytona", requestedRef: "managed-snapshot", + } }); + }); + + it("does not resolve snapshot names during ordinary acquisition", async () => { + mockCreate.mockResolvedValue(createMockSandbox()); + await plugin.definition.onEnvironmentAcquireLease!({ ...snapshotProbe, resolveSnapshotIdentity: undefined }); + expect(mockSnapshotGet).not.toHaveBeenCalled(); + expect(mockCreate.mock.calls[0][0].snapshot).toBe("managed-snapshot"); + }); + + it.each([{ id: "mutable-name" }, { organizationId: "" }, { name: "different-name" }, { createdAt: "invalid" }])( + "rejects unverified snapshot identity %j before allocation", async invalid => { + mockSnapshotGet.mockResolvedValue({ ...snapshotRecord, ...invalid }); + await expect(plugin.definition.onEnvironmentAcquireLease!(snapshotProbe)).rejects.toThrow("immutable snapshot identity"); + expect(mockCreate).not.toHaveBeenCalled(); + }, + ); + + it("never allocates after a snapshot identity lookup times out", async () => { + vi.useFakeTimers(); + try { + let resolveLookup!: (value: typeof snapshotRecord) => void; + mockSnapshotGet.mockReturnValue(new Promise(resolve => { resolveLookup = resolve; })); + const result = plugin.definition.onEnvironmentAcquireLease!(snapshotProbe).catch(error => error); + await vi.advanceTimersByTimeAsync(10_001); + expect(await result).toBeInstanceOf(Error); + resolveLookup(snapshotRecord); + await vi.advanceTimersByTimeAsync(1); + expect(mockCreate).not.toHaveBeenCalled(); + } finally { vi.useRealTimers(); } + }); + it("declares environment lifecycle handlers", async () => { expect(await plugin.definition.onHealth?.()).toEqual({ status: "ok", diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index 652ece39f19..8dba1aaadf1 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -213,6 +213,8 @@ type DaytonaSnapshotService = { delete?: (snapshot: unknown) => Promise; }; +type SnapshotIdentity = { provider: "daytona"; requestedRef: string; id: string; name: string; organizationId: string; createdAt: string }; + const WORKSPACE_SENTINEL_RELATIVE_PATH = ".paperclip-runtime/reusable-sandbox-lease.json"; // Quota-safety defaults (minutes). Daytona counts *stopped* sandboxes against @@ -674,6 +676,7 @@ function leaseMetadata(input: { resumedFromState?: string | null; sandboxState?: string | null; workspaceSentinel?: WorkspaceSentinelResult; + snapshotIdentity?: SnapshotIdentity; }) { return { provider: "daytona", @@ -683,6 +686,7 @@ function leaseMetadata(input: { sandboxState: input.sandboxState ?? input.sandbox.state ?? null, image: input.config.image, snapshot: input.config.snapshot, + ...(input.snapshotIdentity ? { snapshotIdentity: input.snapshotIdentity } : {}), target: input.sandbox.target, timeoutMs: input.config.timeoutMs, reuseLease: input.config.reuseLease, @@ -958,6 +962,7 @@ async function createSandbox( purpose?: string; onCreateAttempt?: (cleanup: PluginEnvironmentCreationCleanup) => void; onCreateFailure?: () => PluginEnvironmentAcquisitionDiagnostic; + onSnapshotIdentity?: (identity: SnapshotIdentity) => void; } = {}, ): Promise { const resourceRequestError = validateRuntimeResourceRequest(config); @@ -965,6 +970,23 @@ async function createSandbox( throw new Error(resourceRequestError); } const client = createDaytonaClient(config); + let createConfig = config; + if ("resolveSnapshotIdentity" in params && params.resolveSnapshotIdentity && config.snapshot && !config.image) { + // This read happens only during explicit qualification. The SDK does not + // bound snapshot.get; a timed-out read must never continue into allocation. + const snapshot = await withLivenessTimeout("snapshot.identity", Math.min(config.timeoutMs || 10_000, 10_000), + () => client.snapshot.get(config.snapshot!)); + if (!/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(snapshot.id ?? "") + || !snapshot.name || !snapshot.organizationId || !snapshot.createdAt + || (config.snapshot !== snapshot.id && config.snapshot !== snapshot.name) + || !Number.isFinite(Date.parse(String(snapshot.createdAt)))) { + throw new Error("Daytona did not return an immutable snapshot identity. Retry the connection probe or select Legacy runner."); + } + const identity: SnapshotIdentity = { provider: "daytona", requestedRef: config.snapshot, + id: snapshot.id, name: snapshot.name, organizationId: snapshot.organizationId, createdAt: new Date(snapshot.createdAt).toISOString() }; + createConfig = { ...config, snapshot: identity.id }; + options.onSnapshotIdentity?.(identity); + } // The SDK can create a sandbox and then throw while waiting for it to start. // Keep an attempt-specific provider name so that failure cannot lose ownership. const attemptId = randomUUID(); @@ -979,7 +1001,7 @@ async function createSandbox( }), "paperclip-create-attempt": attemptId }; // The SDK mutates params.labels (for example, code-toolbox-language). // Preserve our immutable ownership snapshot for validation and retry. - const createParams = { ...buildCreateParams(config, { ...labels }), name }; + const createParams = { ...buildCreateParams(createConfig, { ...labels }), name }; const cleanup: PluginEnvironmentCreationCleanup = { providerLeaseId: name, companyId: params.companyId, environmentId: params.environmentId, ...("runId" in params ? { runId: params.runId } : {}), @@ -2589,6 +2611,7 @@ const plugin = definePlugin(trackDaytonaHooks({ params: PluginEnvironmentAcquireLeaseParams, ): Promise { const config = parseDriverConfig(params.config); + let snapshotIdentity: SnapshotIdentity | undefined; // One budget covers creation, setup, and inline cleanup. The host leaves // 30 seconds beyond this deadline to receive/journal the cleanup record. const budgetMs = config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_DAYTONA_OPERATION_TIMEOUT_MS; @@ -2632,8 +2655,9 @@ const plugin = definePlugin(trackDaytonaHooks({ }; const acquire = async (): Promise => { const sandbox = await createSandbox(params, config, { - onCreateAttempt: (attempt) => { cleanup = attempt; }, + onCreateAttempt: (attempt) => { assertActive(); cleanup = attempt; }, onCreateFailure: observeFailure, + onSnapshotIdentity: identity => { snapshotIdentity = identity; }, }); try { assertActive(); @@ -2693,6 +2717,7 @@ const plugin = definePlugin(trackDaytonaHooks({ remoteCwd, resumedLease: false, workspaceSentinel, + snapshotIdentity, }), }; } catch (error) { diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index c6db765ce6a..83e8d88b097 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -636,6 +636,11 @@ export interface PluginEnvironmentRunnerIngressEndpointParams export interface PluginEnvironmentAcquireLeaseParams extends PluginEnvironmentDriverBaseParams { runId: string; + /** Explicit platform probes may request a provider-owned immutable snapshot + * identity. Supporting providers create by that ID and return snapshotIdentity + * in lease metadata. Older providers may omit it; the host then cannot qualify + * a mutable snapshot name. Ordinary discovery must not allocate a sandbox. */ + resolveSnapshotIdentity?: boolean; workspaceMode?: string; requestedCwd?: string; agentId?: string; diff --git a/packages/shared/src/agent-runner.ts b/packages/shared/src/agent-runner.ts index 681c6999923..f957fad5c24 100644 --- a/packages/shared/src/agent-runner.ts +++ b/packages/shared/src/agent-runner.ts @@ -26,7 +26,6 @@ function supportsLinuxLibc(libc?: "glibc" | "musl" | "unknown", version?: string } export function paperclipRunnerSupportsPlatform(harness: string, platform: string, architecture: string, libc?: "glibc" | "musl" | "unknown", glibcVersion?: string): boolean { - if (harness === "claude_local") return platform === "darwin" && architecture === "arm64"; return !!paperclipRunnerProfileForHarness(harness) && ((platform === "linux" && supportsLinuxLibc(libc, glibcVersion) && (architecture === "x64" || architecture === "arm64")) || (platform === "darwin" && (architecture === "arm64" || architecture === "x64"))); diff --git a/scripts/__tests__/grok-public-install-sandbox.test.mjs b/scripts/__tests__/grok-public-install-sandbox.test.mjs index 66401392a44..ea06caa74f4 100644 --- a/scripts/__tests__/grok-public-install-sandbox.test.mjs +++ b/scripts/__tests__/grok-public-install-sandbox.test.mjs @@ -7,7 +7,7 @@ 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, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource, runMacInitialInstallWithDiagnostics } from '../grok-public-install-sandbox.mjs'; +import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledCodexPlatformProvenance, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedClaudeProbeSource, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource, runMacInitialInstallWithDiagnostics } from '../grok-public-install-sandbox.mjs'; import { verifyPiPackagedDaemon } from '../pi-public-install-probe.mjs'; import { publicInstallClosure } from '../public-install-closure.mjs'; import { runnerBinaryTarget } from '../../packages/paperclip-runner/src/live/runner-binary.ts'; @@ -15,6 +15,40 @@ import { runnerBinaryTarget } from '../../packages/paperclip-runner/src/live/run 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('installed Claude admission propagates failure without emitting a passing receipt', () => { + const root = mkdtempSync(join(tmpdir(), 'installed-claude-admission-')); + try { + const fixture = join(root, 'runner.mjs'), probe = join(root, 'probe.mjs'); + writeFileSync(probe, installedClaudeProbeSource(fixture)); + writeFileSync(fixture, "export async function probeAcpxClaudeInstallation(model) { if (model !== 'claude-opus-5-5') throw Error('wrong model'); }\n"); + const accepted = spawnSync(process.execPath, [probe], { encoding: 'utf8', timeout: 5000 }); + assert.equal(accepted.status, 0, accepted.stderr); + assert.equal(JSON.parse(accepted.stdout).packageIntegrityAndNoFollowLease, 'passed'); + assert.equal(JSON.parse(accepted.stdout).providerCalls, 0); + writeFileSync(fixture, "export async function probeAcpxClaudeInstallation() { throw Error('integrity rejected'); }\n"); + const rejected = spawnSync(process.execPath, [probe], { encoding: 'utf8', timeout: 5000 }); + assert.notEqual(rejected.status, 0); + assert.equal(rejected.stdout, ''); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('ordinary Claude consumer admission rejects authority overrides before invoking the verifier', () => { + const root = mkdtempSync(join(tmpdir(), 'installed-claude-authority-')); + try { + const fixture = join(root, 'runner.mjs'), probe = join(root, 'probe.mjs'); + writeFileSync(probe, installedClaudeProbeSource(fixture)); + writeFileSync(fixture, "export async function probeAcpxClaudeInstallation() { throw Error('provider verifier invoked'); }\n"); + for (const key of ['PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT', 'PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST']) { + const rejected = spawnSync(process.execPath, [probe], { encoding: 'utf8', timeout: 5000, + env: { PATH: process.env.PATH, [key]: root } }); + assert.notEqual(rejected.status, 0); + assert.equal(rejected.stdout, ''); + assert.match(rejected.stderr, /AssertionError/); + assert.doesNotMatch(rejected.stderr, /provider verifier invoked/); + } + } finally { rmSync(root, { recursive: true, force: true }); } +}); + test('Mac initial install failures retain bounded npm progress without secret or environment values', () => { const root = realpathSync(mkdtempSync(join(tmpdir(), 'mac-install-diagnostics-test-'))); try { diff --git a/scripts/__tests__/prepare-runner-release-image.test.mjs b/scripts/__tests__/prepare-runner-release-image.test.mjs new file mode 100644 index 00000000000..7dfa031c592 --- /dev/null +++ b/scripts/__tests__/prepare-runner-release-image.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createHash } from 'node:crypto'; +import { validateReleaseRun, selectReleaseArtifacts, validateReleaseImageBase, validateImageBindings, decodeReleaseJson } from '../prepare-runner-release-image.mjs'; + +const source = 'a'.repeat(40); +const run = { head_sha: source, conclusion: 'success', path: '.github/workflows/release-smoke.yml', repository: { full_name: 'paperclipai/paperclip' } }; +const artifacts = ['runner-provider-packs-123-2-linux-x64', 'runner-release-123-2-linux-x64', 'claude-qualification-public-packages'] + .map((name, index) => ({ id: index + 1, name, expired: false, digest: 'sha256:' + 'b'.repeat(64), size_in_bytes: 100 })); + +test('release image consumes one successful exact-source producer set', () => { + assert.doesNotThrow(() => validateReleaseRun(run, source)); + assert.deepEqual(selectReleaseArtifacts(artifacts, '123', 2), artifacts); +}); + +test('frozen runtime source can differ from workflow dispatch source', () => { + assert.doesNotThrow(() => validateReleaseRun({ ...run, head_sha: 'c'.repeat(40) }, source)); +}); + +for (const [name, patch] of [ + ['invalid workflow source', { head_sha: 'not-a-sha' }], + ['incomplete producer', { conclusion: null }], + ['failed producer', { conclusion: 'failure' }], + ['unrelated workflow', { path: '.github/workflows/ci.yml' }], + ['fork artifact', { repository: { full_name: 'fork/paperclip' } }], +]) test('rejects ' + name, () => assert.throws(() => validateReleaseRun({ ...run, ...patch }, source))); + +for (const [name, values] of [ + ['missing pack', artifacts.slice(1)], + ['duplicate authority', [...artifacts, artifacts[0]]], + ['expired artifact', artifacts.map((a, i) => i ? a : { ...a, expired: true })], + ['unbound digest', artifacts.map((a, i) => i ? a : { ...a, digest: null })], + ['oversized archive', artifacts.map((a, i) => i ? a : { ...a, size_in_bytes: 3 * 1024 ** 3 })], + ['wrong attempt', artifacts.map((a, i) => i ? a : { ...a, name: 'runner-provider-packs-123-1-linux-x64' })], +]) test('rejects ' + name, () => assert.throws(() => selectReleaseArtifacts(values, '123', 2))); + +test('base image must be immutable and repository-owned', () => { + assert.equal(validateReleaseImageBase('ghcr.io/paperclipai/paperclip-daytona-runner@sha256:' + source + 'b'.repeat(24)), 'ghcr.io/paperclipai/paperclip-daytona-runner@sha256:' + source + 'b'.repeat(24)); + for (const base of ['ghcr.io/paperclipai/paperclip-daytona-runner:latest', 'ghcr.io/fork/paperclip-daytona-runner@sha256:' + 'a'.repeat(64), 'ubuntu:latest']) { + assert.throws(() => validateReleaseImageBase(base)); + } +}); + +const daemonBytes = Buffer.alloc(64); +daemonBytes.write('7f454c46', 0, 'hex'); +daemonBytes[4] = 2; +daemonBytes[5] = 1; +daemonBytes.writeUInt16LE(62, 18); +const daemonDigest = 'sha256:' + createHash('sha256').update(daemonBytes).digest('hex'); +const binding = { source, pack: { digest: 'release', payload: { productionLock: 'original', candidateProviders: { pi: 'qualified' } } }, + receipt: { schema: 'paperclip.runner.release-artifact.v1', sourceRevision: source, target: 'linux-x64', sha256: daemonDigest }, + release: { sourceRevision: source, platforms: { 'linux-x64': { sha256: daemonDigest } } }, daemonBytes }; +binding.publicPack = structuredClone(binding.pack); + +test('image pack must equal the authority shipped in the public package', () => { + assert.equal(validateImageBindings(binding), daemonDigest.slice(7)); + for (const changed of [{ productionLock: 'regenerated' }, { candidateProviders: {} }]) { + const different = structuredClone(binding.pack); + Object.assign(different.payload, changed); + assert.throws(() => validateImageBindings({ ...binding, publicPack: different }), /public package authority/); + } +}); + +test('image daemon must match source, receipt, public package and actual architecture', () => { + for (const changed of [{ sourceRevision: 'c'.repeat(40) }, { sha256: 'sha256:' + 'c'.repeat(64) }, { target: 'linux-arm64' }]) { + assert.throws(() => validateImageBindings({ ...binding, receipt: { ...binding.receipt, ...changed } })); + } + assert.throws(() => validateImageBindings({ ...binding, release: { ...binding.release, sourceRevision: 'c'.repeat(40) } })); + const wrongArch = Buffer.from(daemonBytes); wrongArch.writeUInt16LE(183, 18); + const digest = 'sha256:' + createHash('sha256').update(wrongArch).digest('hex'); + assert.throws(() => validateImageBindings({ ...binding, daemonBytes: wrongArch, + receipt: { ...binding.receipt, sha256: digest }, release: { ...binding.release, platforms: { 'linux-x64': { sha256: digest } } } })); +}); + +test('frozen release JSON must be the bounded requested source file', () => { + const path = 'packages/paperclip-runner/acpx-profiles.json'; + const content = Buffer.from(JSON.stringify({ acpxVersion: 'frozen' })); + const file = { type: 'file', path, encoding: 'base64', size: content.length, content: content.toString('base64') }; + assert.deepEqual(decodeReleaseJson(file, path), { acpxVersion: 'frozen' }); + for (const patch of [{ type: 'symlink' }, { path: 'other.json' }, { size: content.length + 1 }, { size: 1024 * 1024 + 1 }, { encoding: 'none' }]) { + assert.throws(() => decodeReleaseJson({ ...file, ...patch }, path)); + } +}); diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs index 5c172222631..8ea57dadc77 100644 --- a/scripts/__tests__/release-verify-workflow.test.mjs +++ b/scripts/__tests__/release-verify-workflow.test.mjs @@ -826,6 +826,13 @@ function nativeProducer() { return readWorkflow('release-verify.yml').split(' runner_binaries:\n')[1]; } +test('native provider packs and the image use the same exact Node version', () => { + const version = nativeProducer().match(/node-version: ([0-9.]+)/)?.[1]; + assert.equal(version, '24.21.0'); + const image = readFileSync(path.join(repoRoot, 'docker/daytona-runner/Dockerfile'), 'utf8'); + assert.ok(image.includes(`FROM node:${version}-bookworm@sha256:`)); +}); + 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]; diff --git a/scripts/check-node-version-policy.mjs b/scripts/check-node-version-policy.mjs index 41f1dcce00b..06c513b63b7 100644 --- a/scripts/check-node-version-policy.mjs +++ b/scripts/check-node-version-policy.mjs @@ -40,7 +40,13 @@ const workflowRoot = path.join(repoRoot, ".github", "workflows"); walk(workflowRoot, (filePath) => { if (!/\.ya?ml$/.test(filePath)) return; const source = fs.readFileSync(filePath, "utf8"); + const packProducer = relative(filePath) === ".github/workflows/release-verify.yml" + ? /(?:^|\n) runner_binaries:\n[\s\S]*?(?=\n [a-z_]+:\n|$)/.exec(source) + : null; for (const match of source.matchAll(/node-version:\s*["']?([^\s"'#]+)/g)) { + if (match[1] === "24.21.0" && packProducer + && match.index >= packProducer.index + && match.index < packProducer.index + packProducer[0].length) continue; if (match[1] !== "24") failures.push(`${relative(filePath)}: node-version must be 24, found ${match[1]}`); } }); diff --git a/scripts/grok-public-install-sandbox.mjs b/scripts/grok-public-install-sandbox.mjs index 1d6279eb834..53a3e270577 100644 --- a/scripts/grok-public-install-sandbox.mjs +++ b/scripts/grok-public-install-sandbox.mjs @@ -158,6 +158,20 @@ 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 installedClaudeProbeSource(indexPath) { + return ` + import assert from 'node:assert/strict'; + import { pathToFileURL } from 'node:url'; + assert.equal(process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT, undefined); + assert.equal(process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST, undefined); + const { probeAcpxClaudeInstallation } = await import(pathToFileURL(${JSON.stringify(indexPath)}).href); + await probeAcpxClaudeInstallation('claude-opus-5-5'); + console.log(JSON.stringify({ schema: 'paperclip.claude-installed-admission.v1', + platform: process.platform, architecture: process.arch, model: 'claude-opus-5-5', + packageIntegrityAndNoFollowLease: 'passed', defaultPackageAuthority: true, providerCalls: 0 })); + `; +} + export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersion, { nativeCodex = false } = {}) { return ` import assert from 'node:assert/strict'; diff --git a/scripts/prepare-runner-release-image.mjs b/scripts/prepare-runner-release-image.mjs new file mode 100644 index 00000000000..f9774e190bc --- /dev/null +++ b/scripts/prepare-runner-release-image.mjs @@ -0,0 +1,166 @@ +import { createHash } from 'node:crypto'; +import { createReadStream, createWriteStream } from 'node:fs'; +import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; +import { pipeline } from 'node:stream/promises'; +import { Transform } from 'node:stream'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { isDeepStrictEqual } from 'node:util'; +import { verifyReleaseProviderPack } from '../packages/paperclip-runner/scripts/release-provider-pack.mjs'; + +export function validateReleaseRun(run, source) { + if (!/^[a-f0-9]{40}$/.test(source) || !/^[a-f0-9]{40}$/.test(run.head_sha ?? '') || run.conclusion !== 'success' + || run.path !== '.github/workflows/release-smoke.yml' || run.repository?.full_name !== 'paperclipai/paperclip') { + throw Error('Image inputs require a successful repository-owned Release Smoke run in paperclipai/paperclip'); + } +} + +export function selectReleaseArtifacts(artifacts, runId, attempt) { + const names = [ + `runner-provider-packs-${runId}-${attempt}-linux-x64`, + `runner-release-${runId}-${attempt}-linux-x64`, + ]; + const selected = names.map(name => artifacts.filter(a => a.name === name)); + selected.push(artifacts.filter(a => /^[a-zA-Z0-9._-]+-public-packages$/.test(a.name))); + if (selected.some(matches => matches.length !== 1)) throw Error('Expected one pack, daemon and public-package artifact'); + return selected.map(([a]) => { + if (a.expired || !Number.isSafeInteger(a.id) || a.id <= 0 || !/^sha256:[a-f0-9]{64}$/.test(a.digest) + || !Number.isSafeInteger(a.size_in_bytes) || a.size_in_bytes < 1 || a.size_in_bytes > 2 * 1024 ** 3) { + throw Error('Invalid or expired release artifact'); + } + return a; + }); +} + +export function validateReleaseImageBase(base) { + if (!/^ghcr\.io\/paperclipai\/paperclip-daytona-runner@sha256:[a-f0-9]{64}$/.test(base)) { + throw Error('Release image base must be an immutable Paperclip Daytona image'); + } + return base; +} + +export function validateImageBindings({ source, pack, publicPack, receipt, release, daemonBytes }) { + if (!isDeepStrictEqual(publicPack, pack)) throw Error('Image pack differs from public package authority'); + const sha256 = createHash('sha256').update(daemonBytes).digest('hex'); + if (receipt.schema !== 'paperclip.runner.release-artifact.v1' || receipt.sourceRevision !== source || receipt.target !== 'linux-x64' + || receipt.sha256 !== `sha256:${sha256}` || release.sourceRevision !== source || release.platforms?.['linux-x64']?.sha256 !== `sha256:${sha256}` + || daemonBytes.length < 64 || daemonBytes.subarray(0, 4).toString('hex') !== '7f454c46' + || daemonBytes[4] !== 2 || daemonBytes[5] !== 1 || daemonBytes.readUInt16LE(18) !== 62) { + throw Error('Daemon source, digest or architecture mismatch'); + } + return sha256; +} + +export function decodeReleaseJson(file, path) { + if (file?.type !== 'file' || file.path !== path || file.encoding !== 'base64' + || !Number.isSafeInteger(file.size) || file.size < 1 || file.size > 1024 * 1024 + || typeof file.content !== 'string') throw Error('Invalid frozen release JSON'); + const bytes = Buffer.from(file.content, 'base64'); + if (bytes.length !== file.size) throw Error('Frozen release JSON size mismatch'); + return JSON.parse(bytes.toString('utf8')); +} + +const digestFile = async file => { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(file)) hash.update(chunk); + return hash.digest('hex'); +}; + +async function main() { + const source = process.env.SOURCE_SHA, runId = process.env.RELEASE_RUN_ID, token = process.env.GH_TOKEN; + const output = resolve(process.argv[2] ?? 'remote-verification/release-context'); + validateReleaseImageBase(process.env.RELEASE_BASE_IMAGE); + if (!/^[1-9][0-9]*$/.test(runId ?? '') || !token) throw Error('Release run ID and GitHub token required'); + const api = async endpoint => { + const response = await fetch(`https://api.github.com/repos/paperclipai/paperclip/${endpoint}`, { + headers: { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json' }, redirect: 'error', + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) throw Error(`GitHub release metadata request failed: ${response.status}`); + return response.json(); + }; + const run = await api(`actions/runs/${runId}`); + validateReleaseRun(run, source); + const artifacts = []; + for (let page = 1; ; page++) { + const result = await api(`actions/runs/${runId}/artifacts?per_page=100&page=${page}`); + artifacts.push(...result.artifacts); + if (result.artifacts.length < 100) break; + if (page === 10) throw Error('Unexpected artifact count'); + } + const selected = selectReleaseArtifacts(artifacts, runId, run.run_attempt); + await mkdir(output, { recursive: false }); + const dirs = []; + for (const [index, artifact] of selected.entries()) { + const response = await fetch(`https://api.github.com/repos/paperclipai/paperclip/actions/artifacts/${artifact.id}/zip`, { + headers: { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json' }, redirect: 'manual', + signal: AbortSignal.timeout(30_000), + }); + const url = response.headers.get('location'); + if (response.status !== 302 || !url || new URL(url).protocol !== 'https:' + || !new URL(url).hostname.endsWith('.blob.core.windows.net')) throw Error('Unexpected artifact download redirect'); + const download = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(300_000) }); + if (!download.ok || !download.body) throw Error('Release artifact download failed'); + const archive = join(output, `artifact-${index}.zip`), hash = createHash('sha256'); + let size = 0; + await pipeline(download.body, new Transform({ transform(chunk, encoding, callback) { + size += chunk.length; + if (size > artifact.size_in_bytes) return callback(Error('Artifact exceeds its declared size')); + hash.update(chunk); callback(null, chunk); + } }), createWriteStream(archive, { flags: 'wx', mode: 0o600 })); + if (size !== artifact.size_in_bytes || `sha256:${hash.digest('hex')}` !== artifact.digest) throw Error('Artifact digest or size mismatch'); + const dir = join(output, `artifact-${index}`); + execFileSync('python3', ['-c', `import zipfile,sys,pathlib,stat +z=zipfile.ZipFile(sys.argv[1]); dest=pathlib.Path(sys.argv[2]); kind=int(sys.argv[3]); entries=z.infolist() +expected=[{'linux-x64/provider-pack.json','linux-x64/provider-pack.tar.gz'},{'linux-x64/paperclip-runnerd','linux-x64/receipt.json'}] +names=[e.filename for e in entries] +if len(names)!=len(set(names)): raise RuntimeError('Duplicate archive paths') +if kind<2: + if set(names)!=expected[kind]: raise RuntimeError('Unexpected release artifact entries') +else: + import re + if 'manifest.json' not in names or any(not re.fullmatch(r'(manifest.json|paperclipai-[a-z0-9.-]+\\.tgz)',n) for n in names): raise RuntimeError('Unexpected public package entries') +if sum(e.file_size for e in entries)>4*1024**3 or any(stat.S_ISLNK(e.external_attr>>16) for e in entries): raise RuntimeError('Unsafe artifact extraction') +z.extractall(dest) +`, archive, dir, String(index)], { stdio: 'pipe' }); + await rm(archive); + dirs.push(dir); + } + const pack = JSON.parse(await readFile(join(dirs[0], 'linux-x64/provider-pack.json'), 'utf8')); + // The workflow source can be newer than the immutable release being consumed. + // Read data from that release commit, never execute source from the artifact. + const [profiles, distributions] = await Promise.all(['acpx-profiles.json', 'cursor-distributions.json'].map(async name => { + const path = `packages/paperclip-runner/${name}`; + return decodeReleaseJson(await api(`contents/${path}?ref=${source}`), path); + })); + verifyReleaseProviderPack(pack, source, 'linux-x64', { profiles, distributions }); + const packages = JSON.parse(await readFile(join(dirs[2], 'manifest.json'), 'utf8')); + if (packages.schema !== 'paperclip.runner.public-packages/v1' || packages.sourceRevision !== source) throw Error('Wrong public-package source'); + const server = packages.archives?.filter(a => a.name === '@paperclipai/server'); + if (server?.length !== 1 || !/^paperclipai-server-[a-z0-9.-]+\.tgz$/.test(server[0].file) + || !/^[a-f0-9]{64}$/.test(server[0].sha256)) throw Error('Invalid server archive'); + const serverArchive = join(dirs[2], server[0].file); + if (await digestFile(serverArchive) !== server[0].sha256) throw Error('Server archive digest mismatch'); + const fromServer = name => JSON.parse(execFileSync('tar', ['-xOf', serverArchive, `package/dist/vendor/paperclip-runner/${name}`], { encoding: 'utf8', maxBuffer: 1024 * 1024 })); + const publicPack = fromServer('remote-provider-packs/linux-x64/provider-pack.json'); + const packArchive = join(dirs[0], 'linux-x64/provider-pack.tar.gz'); + if (!isDeepStrictEqual(JSON.parse(execFileSync('tar', ['-xOf', packArchive, './provider-pack.json'], { encoding: 'utf8', maxBuffer: 1024 * 1024 })), pack)) throw Error('Pack archive manifest mismatch'); + const receipt = JSON.parse(await readFile(join(dirs[1], 'linux-x64/receipt.json'), 'utf8')); + const daemon = join(dirs[1], 'linux-x64/paperclip-runnerd'), bytes = await readFile(daemon); + const release = fromServer('bin/release-manifest.json'); + const sha256 = validateImageBindings({ source, pack, publicPack, receipt, release, daemonBytes: bytes }); + await chmod(daemon, 0o700); + if (!isDeepStrictEqual(JSON.parse(execFileSync(daemon, ['--build-metadata'], { encoding: 'utf8', timeout: 15_000, maxBuffer: 65536 })), receipt.buildMetadata)) throw Error('Daemon metadata mismatch'); + await copyFile(packArchive, join(output, 'provider-pack.tar.gz')); + await copyFile(daemon, join(output, 'paperclip-runnerd')); + await copyFile(new URL('../docker/daytona-runner/Dockerfile.release', import.meta.url), join(output, 'Dockerfile')); + await copyFile(join(dirs[0], 'linux-x64/provider-pack.json'), join(output, 'provider-pack.json')); + for (const dir of dirs) await rm(dir, { recursive: true }); + const evidence = { sourceRevision: source, producerRunId: runId, producerWorkflowSource: run.head_sha, producerAttempt: run.run_attempt, providerPackDigest: pack.digest, + daemonSha256: sha256, baseImage: process.env.RELEASE_BASE_IMAGE, artifacts: selected.map(a => ({ id: a.id, name: a.name, digest: a.digest })), providerCalls: 0 }; + await writeFile(resolve(output, '../release-image-inputs.json'), JSON.stringify(evidence, null, 2) + '\n'); + console.log(JSON.stringify(evidence)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) await main(); diff --git a/scripts/release.sh b/scripts/release.sh index dc9815610b1..6b154634fd6 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -285,9 +285,16 @@ release_info "==> Step 2/7: Building workspace artifacts..." cd "$REPO_ROOT" pnpm build if [ -n "${PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR:-}" ]; then + pack_args=() + if [ -n "${PAPERCLIP_RELEASE_PROVIDER_PACK_ARTIFACTS_DIR:-}" ]; then + pack_args=("$PAPERCLIP_RELEASE_PROVIDER_PACK_ARTIFACTS_DIR") + fi node "$REPO_ROOT/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs" \ - assemble-daemons "$PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR" "$(git rev-parse HEAD)" + assemble-daemons "$PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR" "$(git rev-parse HEAD)" "${pack_args[@]}" cp -R "$REPO_ROOT/packages/paperclip-runner/dist/bin/." "$REPO_ROOT/server/dist/vendor/paperclip-runner/bin/" + if [ -d "$REPO_ROOT/packages/paperclip-runner/dist/remote-provider-packs" ]; then + cp -R "$REPO_ROOT/packages/paperclip-runner/dist/remote-provider-packs" "$REPO_ROOT/server/dist/vendor/paperclip-runner/" + fi elif [ "$dry_run" = false ]; then release_fail "Public releases require the four hosted runner daemon artifacts; use the supported Release workflow." fi diff --git a/scripts/verify-grok-npm-install.mjs b/scripts/verify-grok-npm-install.mjs index beb92bfa272..8efa4beef23 100644 --- a/scripts/verify-grok-npm-install.mjs +++ b/scripts/verify-grok-npm-install.mjs @@ -14,7 +14,7 @@ import { fileURLToPath } from 'node:url'; import { packConcurrency, packStagedPackages, packTimings } from './pack-staged-packages.mjs'; import { materializePublishManifest } from './prepare-bundled-package.mjs'; import { publicInstallClosure } from './public-install-closure.mjs'; -import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource, runMacInitialInstallWithDiagnostics } from './grok-public-install-sandbox.mjs'; +import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedClaudeProbeSource, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource, runMacInitialInstallWithDiagnostics } 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'; @@ -85,6 +85,7 @@ async function verifyMacConsumer(packageDirectory, sourceRevision, receiptPath) 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 claude = probe('claude-probe.mjs', installedClaudeProbeSource(join(installed, 'index.js'))); 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()); @@ -92,7 +93,7 @@ async function verifyMacConsumer(packageDirectory, sourceRevision, receiptPath) 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 }; + ...daemon, ...codex, claude, startup, providerCalls: 0 }; writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + '\n'); console.log(JSON.stringify(receipt)); } finally { rmSync(root, { recursive: true, force: true }); } @@ -161,6 +162,7 @@ async function verifyLinuxArmConsumer(packageDirectory, sourceRevision, receiptP 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 claude = hostProbe('host-claude-probe.mjs', installedClaudeProbeSource(join(hostInstalled, 'index.js'))); 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 }; @@ -182,7 +184,7 @@ async function verifyLinuxArmConsumer(packageDirectory, sourceRevision, receiptP 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 }; + ...daemon, ...codex, claude, startup, providerCalls: 0 }; writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + '\n'); console.log(JSON.stringify(receipt)); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 3cf6d2f3166..bb609d6890d 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1831,7 +1831,7 @@ describe("agent permission routes", () => { for (const adapterCase of sshCapableAdapterCases) { it(`allows creating a ${adapterCase.adapterType} agent with an SSH default environment`, async () => { - const expectedAdapterType = adapterCase.adapterType === "codex_local" ? "paperclip_runner" : adapterCase.adapterType; + const expectedAdapterType = ["codex_local", "claude_local"].includes(adapterCase.adapterType) ? "paperclip_runner" : adapterCase.adapterType; const environmentId = "33333333-3333-4333-8333-333333333333"; mockEnvironmentService.getById.mockResolvedValue({ id: environmentId, @@ -1875,6 +1875,7 @@ describe("agent permission routes", () => { ); if (["codex_local", "claude_local"].includes(adapterCase.adapterType)) { if (adapterCase.adapterType === "codex_local") expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "codex" }); + else expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5" }); const { AGENT_RUNNER_SSH_PLATFORM_COMMAND } = await import("../services/agent-runner-selection.js"); expect(mockRunSshCommand).toHaveBeenCalledWith(expect.objectContaining({ host: "qa.example.invalid" }), AGENT_RUNNER_SSH_PLATFORM_COMMAND, { timeoutMs: 10_000 }); } else { diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index fe24cc7fc36..98551e35f7e 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -1178,7 +1178,8 @@ describe("agent skill routes", () => { { adapterType: "codex_local", runner: "auto", platform: "win32", expectedType: "codex_local" }, ]; const creationRunnerCases = [...codexRunnerCases, - { adapterType: "claude_local", runner: "auto", platform: "linux", expectedType: "claude_local" }, + { adapterType: "claude_local", runner: "auto", platform: "linux", expectedType: "paperclip_runner" }, + { adapterType: "claude_local", runner: "legacy", platform: "linux", expectedType: "claude_local" }, { adapterType: "claude_local", runner: "auto", platform: "darwin", expectedType: "paperclip_runner" }, { adapterType: "claude_local", runner: "legacy", platform: "darwin", expectedType: "claude_local" }, ]; diff --git a/server/src/__tests__/built-in-agents.test.ts b/server/src/__tests__/built-in-agents.test.ts index a084001ad89..c43f911d6e7 100644 --- a/server/src/__tests__/built-in-agents.test.ts +++ b/server/src/__tests__/built-in-agents.test.ts @@ -1403,8 +1403,8 @@ describeEmbeddedPostgres("built-in agents", () => { icon: "sparkles", role: "general", reportsTo: root.id, - adapterType: "claude_local", - adapterConfig: { model: "claude-haiku-4-5" }, + adapterType: "paperclip_runner", + adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-haiku-4-5" }, budgetMonthlyCents: 0, }); expect(state.status).toBe("paused"); diff --git a/server/src/__tests__/environment-probe.test.ts b/server/src/__tests__/environment-probe.test.ts index 71f2c425557..94d334fddaf 100644 --- a/server/src/__tests__/environment-probe.test.ts +++ b/server/src/__tests__/environment-probe.test.ts @@ -50,6 +50,35 @@ describe("probeEnvironment", () => { mockEnvironmentRuntimeService.mockClear(); }); + it("qualifies a named Daytona snapshot only after an ID-pinned lease and owned cleanup", async () => { + const environment = { id: "env", driver: "sandbox", status: "active", config: { provider: "daytona", snapshot: "managed-snapshot" }, metadata: null } as any; + const identity = { provider: "daytona", requestedRef: "managed-snapshot", id: "a6b9e7d0-f886-471a-b461-640dd03e8d0f", name: "managed-snapshot", organizationId: "provider-account", createdAt: "2026-10-07T23:19:57.133Z" }; + vi.spyOn(configRuntime, "resolveEnvironmentDriverConfigForRuntime").mockResolvedValue({ driver: "sandbox", config: environment.config }); + mockRuntimeAcquireRunLease.mockResolvedValue({ lease: { id: "lease", provider: "daytona", metadata: { snapshot: "managed-snapshot", snapshotIdentity: identity } } }); + mockRuntimeExecute.mockResolvedValue({ exitCode: 0, stdout: "Linux\nx86_64\nglibc\nglibc 2.39\n" }); + const result = await probeEnvironment({} as any, environment, { companyId: "company", acquireSandboxRuntimeLease: true }); + expect(result.details?.platformQualificationRecorded).toBe(true); + expect(mockRuntimeAcquireRunLease).toHaveBeenCalledWith(expect.objectContaining({ resolveSnapshotIdentity: true })); + const proof = mockRecordPlatformProbe.mock.calls[1]![0].proof; + expect(proof).toMatchObject({ v: 2, snapshotIdentity: identity }); + environment.metadata = { [SANDBOX_PLATFORM_PROBE_METADATA_KEY]: { company: proof } }; + expect(await readSandboxPlatformQualification({} as any, environment, "company")).toMatchObject({ platform: "linux", architecture: "x64", glibcVersion: "glibc 2.39" }); + expect(await readSandboxPlatformQualification({} as any, environment, "foreign-company")).toBeNull(); + expect(mockRuntimeAcquireRunLease).toHaveBeenCalledTimes(1); + }); + + it.each(["missing", "wrong-reference", "missing-owner", "cleanup"])("does not qualify a named snapshot after %s", async failure => { + const environment = { id: "env", driver: "sandbox", status: "active", config: { provider: "daytona", snapshot: "managed-snapshot" } } as any; + const identity = { provider: "daytona", requestedRef: failure === "wrong-reference" ? "other" : "managed-snapshot", id: "a6b9e7d0-f886-471a-b461-640dd03e8d0f", name: "managed-snapshot", organizationId: failure === "missing-owner" ? "" : "provider-account", createdAt: "2026-10-07T23:19:57.133Z" }; + vi.spyOn(configRuntime, "resolveEnvironmentDriverConfigForRuntime").mockResolvedValue({ driver: "sandbox", config: environment.config }); + mockRuntimeAcquireRunLease.mockResolvedValue({ lease: { id: "lease", provider: "daytona", metadata: { snapshot: "managed-snapshot", ...(failure === "missing" ? {} : { snapshotIdentity: identity }) } } }); + mockRuntimeExecute.mockResolvedValue({ exitCode: 0, stdout: "Linux\nx86_64\nglibc\nglibc 2.39\n" }); + if (failure === "cleanup") mockRuntimeReleaseRunLease.mockResolvedValue({ id: "lease", companyId: "company", environmentId: "env", status: "released", cleanupStatus: "failed" }); + const result = await probeEnvironment({} as any, environment, { companyId: "company", acquireSandboxRuntimeLease: true }); + expect(result.details?.platformQualificationRecorded).toBe(false); + expect(mockRecordPlatformProbe).toHaveBeenCalledTimes(1); + }); + it.each(["2.38", "2.39", "musl"])("records actual immutable-image facts for %s and reads them without another lease", async libc => { const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`; const environment = { id: "env", driver: "sandbox", status: "active", config: { provider: "daytona", image, reuseLease: false }, metadata: null } as any; diff --git a/server/src/__tests__/environment-run-orchestrator.test.ts b/server/src/__tests__/environment-run-orchestrator.test.ts index df72db38439..4807b47d65f 100644 --- a/server/src/__tests__/environment-run-orchestrator.test.ts +++ b/server/src/__tests__/environment-run-orchestrator.test.ts @@ -770,6 +770,7 @@ describe("native runner lifecycle changes before lease acquisition", () => { expect(warm.lease.leasePolicy).toBe("reuse_by_environment"); expect(warm.environment.config).toEqual({ provider: "daytona", reuseLease: true }); expect(acquireRunLease.mock.calls[1][0].persistedExecutionWorkspace).toEqual(input.persistedExecutionWorkspace); + expect(acquireRunLease.mock.calls[1][0].configuredEnvironment).toBe(environment); // Run-scoped adaptation must not change the shared environment or other agents. expect(environment.config).toEqual({ provider: "daytona", reuseLease: false }); const coldAgain = await orchestrator.acquireForRun({ ...input, heartbeatRunId: "run-3", adapterConfig: { lifecycleMode: "per_turn" } }); diff --git a/server/src/__tests__/environment-runtime.test.ts b/server/src/__tests__/environment-runtime.test.ts index d85b6d8a19e..3149a3b5ba1 100644 --- a/server/src/__tests__/environment-runtime.test.ts +++ b/server/src/__tests__/environment-runtime.test.ts @@ -322,6 +322,64 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { } as unknown as PluginWorkerManager; } + it.each([["paperclip_runner", false, false], ["paperclip_runner", true, false], ["paperclip_runner", true, undefined], ["claude_local", false, false], [null, false, false]] as const)("pins qualified snapshots only for native execution (%s, warm=%s, savedReuse=%s)", async (adapterType, warm, savedReuse) => { + const pluginId = randomUUID(); + const { companyId, environment: baseEnvironment, runId } = await seedEnvironment(); + const config = { provider: "daytona", snapshot: "managed-snapshot", ...(savedReuse === undefined ? {} : { reuseLease: savedReuse }), timeoutMs: 1234 }; + await insertSandboxProviderPlugin(pluginId, { environmentDrivers: [{ driverKey: "daytona", kind: "sandbox_provider", displayName: "Daytona", configSchema: { type: "object" } }] }); + await environmentService(db).update(baseEnvironment.id, { driver: "sandbox", config }); + const environment = { ...baseEnvironment, driver: "sandbox", config }; + const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment); + const snapshotId = "a6b9e7d0-f886-471a-b461-640dd03e8d0f"; + environment.metadata = { paperclipSandboxPlatformProbes: { [companyId]: { + v: 2, companyId, snapshot: config.snapshot, template: null, + baseConfigFingerprint: createHash("sha256").update(stableStringify(config)).digest("hex"), + runtimeConfigFingerprint: createHash("sha256").update(stableStringify(parsed.config)).digest("hex"), + snapshotIdentity: { provider: "daytona", requestedRef: config.snapshot, id: snapshotId, name: config.snapshot, organizationId: "provider-account", createdAt: "2026-10-07T23:19:57.133Z" }, + } } }; + const worker = createFreshPluginLeaseWorker(pluginId); + vi.mocked(worker.call).mockImplementation(async (_id, method, params: any) => { + if (method !== "environmentAcquireLease") throw new Error(`Unexpected ${method}`); + return { providerLeaseId: "snapshot-lease", metadata: { provider: "daytona", snapshot: params.config.snapshot, remoteCwd: "/workspace" } }; + }); + const service = environmentRuntimeService(db, { pluginWorkerManager: worker }); + const runtimeEnvironment = warm ? resolveRunnerEnvironmentForRun(environment, "paperclip_runner", { lifecycleMode: "warm" }) : environment; + const acquired = await service.acquireRunLease({ companyId, environment: runtimeEnvironment, configuredEnvironment: environment, issueId: null, heartbeatRunId: runId, + persistedExecutionWorkspace: null, adapterType, ...(adapterType === null ? { resolveSnapshotIdentity: true } : {}) }); + const expectedSnapshot = adapterType === "paperclip_runner" ? snapshotId : config.snapshot; + expect(worker.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", expect.objectContaining({ + config: expect.objectContaining({ snapshot: expectedSnapshot }), + ...(adapterType === null ? { resolveSnapshotIdentity: true } : {}), + }), expect.any(Number)); + expect(acquired.lease.metadata?.snapshot).toBe(expectedSnapshot); + }); + + it.each(["failed-probe", "config-change", "config-and-metadata-change"] as const)("refuses a mutable snapshot after %s invalidates its proof", async invalidation => { + const { companyId, environment: baseEnvironment, runId } = await seedEnvironment(); + const config = { provider: "daytona", snapshot: "managed-snapshot", reuseLease: false }; + const svc = environmentService(db); + await svc.update(baseEnvironment.id, { driver: "sandbox", config }); + const proof = { v: 2, companyId, snapshot: config.snapshot, template: null, + snapshotIdentity: { provider: "daytona", requestedRef: config.snapshot, + id: "a6b9e7d0-f886-471a-b461-640dd03e8d0f", name: config.snapshot, + organizationId: "provider-account", createdAt: "2026-10-07T23:19:57.133Z" }, + }; + expect(await svc.recordSandboxPlatformProbe({ environmentId: baseEnvironment.id, companyId, config, proof })).toBe(true); + if (invalidation === "failed-probe") { + await svc.recordSandboxPlatformProbe({ environmentId: baseEnvironment.id, companyId, config, proof: null }); + } else { + await svc.update(baseEnvironment.id, { config: { ...config, snapshot: "replacement-name" }, + ...(invalidation === "config-and-metadata-change" ? { metadata: { userNote: "retained" } } : {}) }); + } + const environment = (await svc.getById(baseEnvironment.id))!; + expect((environment.metadata?.paperclipSandboxPlatformProbes as Record)[companyId]).toMatchObject({ v: 2, invalidated: true }); + const acquire = vi.fn(); + const runtime = environmentRuntimeService(db, { pluginWorkerManager: { call: acquire } as never }); + await expect(runtime.acquireRunLease({ companyId, environment, issueId: null, heartbeatRunId: runId, + persistedExecutionWorkspace: null, adapterType: "paperclip_runner" })).rejects.toThrow("sandbox_snapshot_qualification_required"); + expect(acquire).not.toHaveBeenCalled(); + }); + function insertReusableExecutionWorkspace(executionWorkspaceId: string, companyId: string, projectId: string) { return db.insert(executionWorkspaces).values({ id: executionWorkspaceId, diff --git a/server/src/__tests__/onboarding-seed-route.test.ts b/server/src/__tests__/onboarding-seed-route.test.ts index d8cc783913b..74274bbe816 100644 --- a/server/src/__tests__/onboarding-seed-route.test.ts +++ b/server/src/__tests__/onboarding-seed-route.test.ts @@ -93,13 +93,9 @@ describeEmbeddedPostgres("POST /api/companies/:companyId/onboarding-seed", () => // The seed's free-text role is a job title; the structural role stays `ceo`. expect(companyAgents[0]?.title).toBe("Chief of Staff"); expect(companyAgents[0]?.role).toBe("ceo"); - // A seeded CEO arrives with the core paperclip skills enabled. Skills only - // reach an agent's runtime through its own desired set, and the default - // CEO instructions assume this toolkit. expect(companyAgents[0]?.adapterConfig).toMatchObject({ paperclipSkillSync: { desiredSkills: expect.arrayContaining([ - "paperclipai/paperclip/paperclip", "paperclipai/paperclip/paperclip-board", "paperclipai/paperclip/paperclip-converting-plans-to-tasks", "paperclipai/paperclip/paperclip-create-agent", @@ -130,7 +126,7 @@ describeEmbeddedPostgres("POST /api/companies/:companyId/onboarding-seed", () => expect(record[0]?.issueId).toBe(companyIssues[0]?.id); }); - it("keeps server-seeded onboarding on a legacy adapter when native runner is requested", async () => { + it("uses the qualified Claude default for an unsupported seed adapter override", async () => { const previous = process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE; process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE = "paperclip_runner"; try { @@ -141,7 +137,7 @@ describeEmbeddedPostgres("POST /api/companies/:companyId/onboarding-seed", () => expect(response.status).toBe(200); const companyAgents = await ctx.db.select().from(agents).where(eq(agents.companyId, companyId)); expect(companyAgents).toHaveLength(1); - expect(companyAgents[0]?.adapterType).toBe("claude_local"); + expect(companyAgents[0]).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5" } }); } finally { if (previous === undefined) { delete process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE; @@ -151,7 +147,7 @@ describeEmbeddedPostgres("POST /api/companies/:companyId/onboarding-seed", () => } }); - it("uses the creation default for server-seeded Codex without changing the Claude fallback", async () => { + it("uses the creation default for server-seeded Codex", async () => { const previous = process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE; process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE = "codex_local"; try { diff --git a/server/src/__tests__/sandbox-snapshot-qualification.test.ts b/server/src/__tests__/sandbox-snapshot-qualification.test.ts new file mode 100644 index 00000000000..423d10e510f --- /dev/null +++ b/server/src/__tests__/sandbox-snapshot-qualification.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import type { Environment, SandboxEnvironmentConfig } from "@paperclipai/shared"; +import { fingerprintEnvironmentSandboxProviderConfig as fingerprint, SANDBOX_PLATFORM_PROBE_METADATA_KEY } from "../services/environment-custom-image-runtime.js"; +import { hasSandboxSnapshotQualification, qualifiedSandboxSnapshotId, type SandboxTemplateIdentity } from "../services/sandbox-snapshot-qualification.js"; + +function fixture() { + const config = { provider: "daytona", snapshot: "managed-snapshot", apiKey: "fixture-account" } as SandboxEnvironmentConfig; + const template: SandboxTemplateIdentity = { id: "template", capturedAt: "2026-10-15T00:00:00Z", templateKind: "snapshot", bindingField: "snapshot" }; + const identity = { provider: "daytona", requestedRef: config.snapshot, id: "a6b9e7d0-f886-471a-b461-640dd03e8d0f", name: config.snapshot, organizationId: "provider-account", createdAt: "2026-10-07T23:19:57.133Z" }; + const proof = { v: 2, companyId: "company", snapshot: config.snapshot, template, + baseConfigFingerprint: fingerprint(config), runtimeConfigFingerprint: fingerprint(config), snapshotIdentity: identity }; + const environment = { config, metadata: { [SANDBOX_PLATFORM_PROBE_METADATA_KEY]: { company: proof } } } as unknown as Environment; + return { config, template, identity, proof, environment }; +} + +describe("qualified snapshot execution binding", () => { + it("pins the measured provider ID without a network lookup", () => { + const f = fixture(); + expect(qualifiedSandboxSnapshotId(f.environment, "company", f.config, f.template)).toBe(f.identity.id); + }); + it("keeps a revoked proof as a required qualification without accepting it", () => { + const f = fixture(); + Object.assign(f.proof, { invalidated: true }); + expect(hasSandboxSnapshotQualification(f.environment, "company")).toBe(true); + expect(hasSandboxSnapshotQualification(f.environment, "foreign-company")).toBe(false); + expect(qualifiedSandboxSnapshotId(f.environment, "company", f.config, f.template)).toBeNull(); + }); + it.each(["company", "base-config", "account", "template", "snapshot", "image", "provider", "identity"])("rejects changed %s", change => { + const f = fixture(); + let company = "company"; + if (change === "company") company = "foreign-company"; + if (change === "base-config") f.environment.config = { ...f.config, target: "different" }; + if (change === "account") f.config = { ...f.config, apiKey: "different-account" }; + if (change === "template") f.template = { ...f.template!, capturedAt: "2026-10-16T00:00:00Z" }; + if (change === "snapshot") f.config = { ...f.config, snapshot: "new-snapshot" }; + if (change === "image") f.config = { ...f.config, image: "other-image" }; + if (change === "provider") f.config = { ...f.config, provider: "other-provider" }; + if (change === "identity") f.identity.id = "mutable-name"; + expect(qualifiedSandboxSnapshotId(f.environment, company, f.config, f.template)).toBeNull(); + }); +}); diff --git a/server/src/__tests__/teams-catalog-install-no-overrides.test.ts b/server/src/__tests__/teams-catalog-install-no-overrides.test.ts index 205cc105dc0..01810075f33 100644 --- a/server/src/__tests__/teams-catalog-install-no-overrides.test.ts +++ b/server/src/__tests__/teams-catalog-install-no-overrides.test.ts @@ -64,14 +64,18 @@ describeEmbeddedPostgres("teams catalog install with no caller adapter overrides name: agents.name, role: agents.role, adapterType: agents.adapterType, + adapterConfig: agents.adapterConfig, permissions: agents.permissions, }) .from(agents) .where(eq(agents.companyId, companyId)); + for (const row of rows.filter(row => row.adapterType === "paperclip_runner")) { + expect(row.adapterConfig).toMatchObject({ provider: "acpx", acpxAgent: "claude" }); + } return new Map(rows.map((row) => [row.name, row])); } - it("installs core-exec-team end-to-end with no caller overrides and creates 3 claude_local agents", async () => { + it("installs core-exec-team end-to-end with no caller overrides and creates 3 native Claude agents", async () => { const companyId = await seedEmptyCompany(); const svc = teamsCatalogService(db); @@ -84,12 +88,12 @@ describeEmbeddedPostgres("teams catalog install with no caller adapter overrides expect(byName.size).toBe(3); const adapterTypes = Array.from(byName.values()).map((row) => row.adapterType); - expect(adapterTypes).toEqual(["claude_local", "claude_local", "claude_local"]); + expect(adapterTypes).toEqual(["paperclip_runner", "paperclip_runner", "paperclip_runner"]); expect(adapterTypes).not.toContain("process"); expect(adapterTypes).not.toContain("http"); }); - it("installs product-design end-to-end with no caller overrides and uses claude_local", async () => { + it("installs product-design end-to-end with no caller overrides and uses native Claude", async () => { const companyId = await seedEmptyCompany(); const svc = teamsCatalogService(db); @@ -101,11 +105,11 @@ describeEmbeddedPostgres("teams catalog install with no caller adapter overrides const byName = await listAdapterTypesByName(companyId); expect(byName.size).toBe(1); const adapterTypes = Array.from(byName.values()).map((row) => row.adapterType); - expect(adapterTypes).toEqual(["claude_local"]); + expect(adapterTypes).toEqual(["paperclip_runner"]); expect(adapterTypes).not.toContain("process"); }); - it("installs product-engineering end-to-end with no caller overrides and uses claude_local for every agent", async () => { + it("installs product-engineering end-to-end with no caller overrides and uses native Claude for every agent", async () => { const companyId = await seedEmptyCompany(); const svc = teamsCatalogService(db); @@ -117,12 +121,12 @@ describeEmbeddedPostgres("teams catalog install with no caller adapter overrides const byName = await listAdapterTypesByName(companyId); expect(byName.size).toBe(3); const adapterTypes = Array.from(byName.values()).map((row) => row.adapterType); - expect(adapterTypes).toEqual(["claude_local", "claude_local", "claude_local"]); + expect(adapterTypes).toEqual(["paperclip_runner", "paperclip_runner", "paperclip_runner"]); expect(adapterTypes).not.toContain("process"); expect(byName.get("CTO")?.permissions).toMatchObject({ canCreateAgents: true }); }); - it("honors an explicit caller adapter override for a single slug while defaulting the rest to claude_local", async () => { + it("honors an explicit caller adapter override for a single slug while defaulting the rest to native Claude", async () => { const companyId = await seedEmptyCompany(); const svc = teamsCatalogService(db); @@ -141,6 +145,6 @@ describeEmbeddedPostgres("teams catalog install with no caller adapter overrides const otherAdapters = Array.from(byName.values()) .filter((row) => row !== ctoRow) .map((row) => row.adapterType); - expect(otherAdapters).toEqual(["claude_local", "claude_local"]); + expect(otherAdapters).toEqual(["paperclip_runner", "paperclip_runner"]); }); }); diff --git a/server/src/adapters/registry.test.ts b/server/src/adapters/registry.test.ts index cd1c0d85a14..3196611b6d6 100644 --- a/server/src/adapters/registry.test.ts +++ b/server/src/adapters/registry.test.ts @@ -147,6 +147,23 @@ describe("native ACPX environment checks", () => { })]); }); + it.each([ + ["claude", "claude-opus-5-5", "aarch64", "warn"], + ["claude", "claude-opus-5-5", "arm64", "warn"], + ["claude", "claude-opus-5-5", "armv7l", "fail"], + ["grok", "grok-4.7", "aarch64", "fail"], + ["pi", "openrouter/deepseek/deepseek-v4-flash-0731", "aarch64", "fail"], + ])("checks the selected remote %s architecture %s without widening other providers", async (agent, model, architecture, status) => { + const result = await requireServerAdapter("paperclip_runner").testEnvironment!({ ...context, + config: { provider: "acpx", acpxAgent: agent, model }, + executionTarget: { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", providerKey: "test", + runner: { execute: vi.fn().mockResolvedValue({ exitCode: 0, timedOut: false, stdout: `Linux\n${architecture}\n` }) } }, + }); + expect(result.status).toBe(status); + expect(result.checks[0].code).toBe(status === "warn" ? "acpx_remote_runtime_unverified" : "acpx_runtime_unavailable"); + expect(probeInstallation).not.toHaveBeenCalled(); + }); + it("identifies an incomplete installation and gives the operator a repair", async () => { probeInstallation.mockRejectedValue(Object.assign(new Error("The Claude ACP runtime package is missing. Reinstall or rebuild Paperclip."), { code: "ACPX_RUNTIME_PACKAGE_MISSING" })); const result = await requireServerAdapter("paperclip_runner").testEnvironment!(context); @@ -210,7 +227,9 @@ describe("native ACPX environment checks", () => { ["Linux\nx86_64\n", "warn"], ["Darwin\nx86_64\n", "warn"], ["Darwin\narm64\n", "warn"], - ["Linux\naarch64\n", "fail"], + ["Linux\naarch64\n", "warn"], + ["Linux\narm64\n", "warn"], + ["Linux\narmv7l\n", "fail"], ["", "fail"], ])("qualifies the SSH platform from its own uname output %j", async (stdout, status) => { vi.spyOn(process, "platform", "get").mockReturnValue("linux"); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index cc8ec03856e..0f2b023f111 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -462,8 +462,8 @@ const paperclipRunnerAdapter: ServerAdapterModule = { ); if (probe.timedOut || probe.exitCode !== 0) throw new Error("Could not verify the remote ACPX runner platform."); const [os, arch] = probe.stdout.trim().split(/\s+/); - if (!((os === "Linux" && arch === "x86_64") || (os === "Darwin" && (arch === "arm64" || (profile.acpxAgent !== "grok" && arch === "x86_64"))))) { - throw new Error(`ACPX ${profile.acpxAgent} requires a qualified Linux x64 or macOS architecture.`); + if (!((os === "Linux" && (arch === "x86_64" || (profile.acpxAgent === "claude" && ["aarch64", "arm64"].includes(arch)))) || (os === "Darwin" && (arch === "arm64" || (profile.acpxAgent !== "grok" && arch === "x86_64"))))) { + throw new Error(`ACPX ${profile.acpxAgent} requires a qualified Linux ${profile.acpxAgent === "claude" ? "x64/ARM64" : "x64"} or macOS architecture.`); } return verifyNativeClaudeApiKey(context.config, { adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(), diff --git a/server/src/services/agent-runner-selection.test.ts b/server/src/services/agent-runner-selection.test.ts index 1b8eff9ef5c..f4cc9021a5e 100644 --- a/server/src/services/agent-runner-selection.test.ts +++ b/server/src/services/agent-runner-selection.test.ts @@ -74,7 +74,7 @@ describe("server-owned Codex runner selection", () => { expect(resolveNewAgentRunner(resolved)).toEqual(resolved); expect(input.adapterConfig).toHaveProperty("search", false); }); - it.each(["claude_local", "opencode_local", "grok_local", "cursor", "pi_local", "process", "external"])("keeps %s default unchanged", adapterType => { + it.each(["opencode_local", "grok_local", "cursor", "pi_local", "process", "external"])("keeps %s default unchanged", adapterType => { expect(resolveNewAgentRunner({ adapterType, target, adapterConfig: { custom: "preserved" } })).toEqual({ adapterType, adapterConfig: { custom: "preserved" } }); }); it.each(qualifiedTargets)("allows an explicit legacy runner and native to legacy round trip on $platform/$architecture", target => { @@ -409,7 +409,15 @@ describe("Codex creation runner contract", () => { describe("Claude runner defaults", () => { const mac = { driver: "local", platform: "darwin", architecture: "arm64" }; beforeEach(() => { state.disabled = []; state.overridden.clear(); }); - it.each(["arm64"])("keeps Darwin SSH %s automatic selection Legacy while preserving explicit assets", architecture => { + it.each(qualifiedTargets)("defaults Claude to the qualified $platform $architecture profile", target => { + expect(agentRunnerAvailability("claude_local", target)).toEqual({ supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" }); + expect(resolveNewAgentRunner({ adapterType: "claude_local", target })).toEqual({ + adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", acpxPermissionMode: "approve-all", lifecycleMode: "per_turn" }, + }); + expect(resolveNewAgentRunner({ adapterType: "claude_local", target, runner: "legacy", adapterConfig: { model: "custom", dangerouslySkipPermissions: true } })) + .toEqual({ adapterType: "claude_local", adapterConfig: { model: "custom", dangerouslySkipPermissions: true } }); + }); + it.each(["x64", "arm64"])("keeps Darwin SSH %s automatic selection Legacy while preserving explicit assets", architecture => { const target = { driver: "ssh", platform: "darwin", architecture }; expect(agentRunnerAvailability("claude_local", target)).toEqual({ supportedRunners: ["paperclip", "legacy"], defaultRunner: "legacy" }); const config = { model: "custom-model", dangerouslySkipPermissions: false }; @@ -420,6 +428,12 @@ describe("Claude runner defaults", () => { const saved = { adapterType: "paperclip_runner", adapterConfig: { ...assets, provider: "acpx", acpxAgent: "claude" }, target }; expect(resolveNewAgentRunner(saved)).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig }); }); + it.each(["x64", "arm64"])("keeps incompatible Linux Claude %s targets legacy", architecture => { + for (const libc of ["musl", "unknown"] as const) { + expect(agentRunnerAvailability("claude_local", { driver: "local", platform: "linux", architecture, libc }).defaultRunner).toBe("legacy"); + } + expect(agentRunnerAvailability("claude_local", { driver: "local", platform: "linux", architecture, libc: "glibc", glibcVersion: "2.17" }).defaultRunner).toBe("legacy"); + }); it("saves native Claude with Opus 5.5, preserving credentials and shared settings", () => { const config = { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "key" } }, instructionsFilePath: "/workspace/AGENTS.md", timeoutSec: 180, dangerouslySkipPermissions: true }; const result = resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: config }); @@ -476,7 +490,7 @@ describe("Claude runner defaults", () => { expect(agentRunnerAvailability("claude_local", { driver: "sandbox" }).defaultRunner).toBe("legacy"); }); it("keeps unqualified targets and overrides legacy", () => { - expect(agentRunnerAvailability("claude_local", target).defaultRunner).toBe("legacy"); + expect(agentRunnerAvailability("claude_local", { driver: "local", platform: "win32", architecture: "x64" }).defaultRunner).toBe("legacy"); state.overridden.add("claude_local"); expect(agentRunnerAvailability("claude_local", mac).defaultRunner).toBe("legacy"); state.overridden.clear(); state.disabled = ["claude_local"]; diff --git a/server/src/services/environment-probe.ts b/server/src/services/environment-probe.ts index 1d6c8ccedc5..c743e0f2950 100644 --- a/server/src/services/environment-probe.ts +++ b/server/src/services/environment-probe.ts @@ -14,6 +14,7 @@ import { environmentRuntimeService } from "./environment-runtime.js"; import { environmentService } from "./environments.js"; import { fingerprintEnvironmentSandboxProviderConfig, SANDBOX_PLATFORM_PROBE_METADATA_KEY } from "./environment-custom-image-runtime.js"; import type { SandboxEnvironmentConfig } from "@paperclipai/shared"; +import { isQualifiableDaytonaSnapshot, qualifiedSandboxSnapshotId, readSnapshotIdentity, type SandboxTemplateIdentity } from "./sandbox-snapshot-qualification.js"; export const SANDBOX_PLATFORM_PROBE_COMMAND = "uname -s; uname -m; if version=$(getconf GNU_LIBC_VERSION 2>/dev/null) && [ -n \"$version\" ]; then printf 'glibc\\n%s\\n' \"$version\"; else case \"$(ldd --version 2>&1)\" in *musl*) printf 'musl\\n' ;; *) printf 'unknown\\n' ;; esac; fi"; type PlatformFacts = { platform: string; architecture: string; libc: "glibc" | "musl" | "unknown"; glibcVersion?: string }; @@ -34,7 +35,7 @@ export function parseSandboxPlatformFacts(output: string): PlatformFacts { ...(libc === "glibc" ? { glibcVersion: version?.trim() ?? "unknown" } : {}) }; } async function sandboxBinding(db: Db, environment: Environment, companyId: string) { - const selectedTemplate: { value: { id: string; capturedAt: string | null; templateKind: string; bindingField: string } | null } = { value: null }; + const selectedTemplate: { value: SandboxTemplateIdentity } = { value: null }; const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment, { applyCustomImageTemplate: true, readOnlyCustomImageTemplate: true, onResolvedCustomImageTemplate: identity => { selectedTemplate.value = identity; }, @@ -42,6 +43,10 @@ async function sandboxBinding(db: Db, environment: Environment, companyId: strin if (parsed.driver !== "sandbox") return null; const image = parsed.config.image; const template = selectedTemplate.value; + if (isQualifiableDaytonaSnapshot(parsed.config, template)) { + return { baseConfigFingerprint: fingerprint(environment.config), runtimeConfigFingerprint: fingerprint(parsed.config), + snapshot: parsed.config.snapshot, template, qualifiedSnapshotId: qualifiedSandboxSnapshotId(environment, companyId, parsed.config, template) }; + } if (!isImmutableImage(image) || ["snapshot", "template", "templateRef"].some(key => record(parsed.config)[key]) || (template && (template.templateKind !== "image" || template.bindingField !== "image"))) return null; return { baseConfigFingerprint: fingerprint(environment.config), runtimeConfigFingerprint: fingerprint(parsed.config as unknown as Record), image, template }; @@ -49,9 +54,11 @@ async function sandboxBinding(db: Db, environment: Environment, companyId: strin export async function readSandboxPlatformQualification(db: Db, environment: Environment, companyId: string): Promise { const proof = record(record(environment.metadata?.[SANDBOX_PLATFORM_PROBE_METADATA_KEY])[companyId]); - if (proof.v !== 1 || proof.companyId !== companyId || proof.baseConfigFingerprint !== fingerprint(environment.config)) return null; + if (![1, 2].includes(Number(proof.v)) || proof.companyId !== companyId || proof.baseConfigFingerprint !== fingerprint(environment.config)) return null; const binding = await sandboxBinding(db, environment, companyId); - if (!binding || proof.runtimeConfigFingerprint !== binding.runtimeConfigFingerprint || proof.image !== binding.image + if (!binding || (proof.v === 2 ? !("qualifiedSnapshotId" in binding && binding.qualifiedSnapshotId) + : !("image" in binding) || proof.image !== binding.image) + || proof.runtimeConfigFingerprint !== binding.runtimeConfigFingerprint || JSON.stringify(proof.template) !== JSON.stringify(binding.template)) return null; const facts = record(proof.facts); if (typeof facts.platform !== "string" || typeof facts.architecture !== "string" @@ -140,6 +147,7 @@ export async function probeEnvironment( heartbeatRunId: null, persistedExecutionWorkspace: null, adapterType: null, + resolveSnapshotIdentity: true, applyCustomImageTemplate: options.applyCustomImageTemplate === true, }); const metadata = leaseRecord.lease.metadata ?? {}; @@ -149,11 +157,13 @@ export async function probeEnvironment( : null; details = { provider, providerLeaseId: leaseRecord.lease.providerLeaseId, leaseId: leaseRecord.lease.id, leasePolicy: leaseRecord.lease.leasePolicy, metadata, platformQualificationRecorded: false, - platformQualificationDiagnostic: "An immutable OCI image digest is required; this connection probe does not qualify a mutable image or snapshot name." }; - if (isImmutableImage(metadata.image) && !metadata.snapshot) { + platformQualificationDiagnostic: "Platform qualification requires an immutable OCI image or a Daytona snapshot with a verified provider ID. Update the provider plugin and probe again, or select Legacy runner." }; + if ((isImmutableImage(metadata.image) && !metadata.snapshot) || metadata.snapshotIdentity) { try { const binding = await sandboxBinding(db, environment, resolvedCompanyId); - if (binding && metadata.image === binding.image) { + const snapshotIdentity = binding && "snapshot" in binding ? readSnapshotIdentity(metadata.snapshotIdentity, binding.snapshot) : null; + if (binding && (("image" in binding && metadata.image === binding.image && !metadata.snapshot) + || (snapshotIdentity && metadata.snapshot === binding.snapshot && provider === "daytona"))) { const platform = await runtime.execute({ environment: probeEnvironmentConfig, lease: leaseRecord.lease, command: "sh", args: ["-c", SANDBOX_PLATFORM_PROBE_COMMAND], bypassSession: true, timeoutMs: 10_000, commandTimeoutMs: 10_000 }); @@ -163,10 +173,12 @@ export async function probeEnvironment( if (facts.platform !== "unknown" && facts.architecture !== "unknown" && (facts.platform !== "linux" || facts.libc !== "unknown") && (facts.libc !== "glibc" || /^(?:glibc\s+)?\d+\.\d+(?:\.\d+)?$/.test(facts.glibcVersion ?? ""))) { - qualification = { v: 1, companyId: resolvedCompanyId, ...binding, facts, probedAt: new Date().toISOString() }; + const { qualifiedSnapshotId: _qualifiedSnapshotId, ...recordedBinding } = { qualifiedSnapshotId: null, ...binding }; + qualification = { v: snapshotIdentity ? 2 : 1, companyId: resolvedCompanyId, ...recordedBinding, + ...(snapshotIdentity ? { snapshotIdentity } : {}), facts, probedAt: new Date().toISOString() }; delete details.platformQualificationDiagnostic; - } else details.platformQualificationDiagnostic = "The image's OS, architecture or libc could not be identified. Legacy remains the creation default."; - } else details.platformQualificationDiagnostic = "The live lease does not match the selected immutable image configuration. Probe again."; + } else details.platformQualificationDiagnostic = "The sandbox OS, architecture or libc could not be identified. Legacy remains the creation default."; + } else details.platformQualificationDiagnostic = "The live lease does not match the selected image or snapshot identity. Probe again."; } catch { details.platformQualificationDiagnostic = "Platform qualification could not complete; connection readiness does not establish native compatibility. Check the image and probe again."; } diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts index 0bfcd69effe..8df0e41f2b9 100644 --- a/server/src/services/environment-run-orchestrator.ts +++ b/server/src/services/environment-run-orchestrator.ts @@ -205,6 +205,7 @@ export function environmentRunOrchestrator( async function acquireLease(input: { companyId: string; environment: Environment; + configuredEnvironment?: Environment; issueId: string | null; agentId: string; heartbeatRunId: string; @@ -291,6 +292,7 @@ export function environmentRunOrchestrator( const acquisitionInput = { companyId: input.companyId, environment, + configuredEnvironment: selectedEnvironment, issueId: input.issueId, agentId: input.agentId, heartbeatRunId: input.heartbeatRunId, diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index e737779a283..ef8b154d3a4 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -46,6 +46,7 @@ import { } from "@paperclipai/adapter-utils/acpx-engine/startup-timing"; import { environmentService } from "./environments.js"; import { instanceSettingsService } from "./instance-settings.js"; +import { hasSandboxSnapshotQualification, qualifiedSandboxSnapshotId, type SandboxTemplateIdentity } from "./sandbox-snapshot-qualification.js"; import { verifyNativeHarnessBackupStamp } from "./native-runtime/native-harness-backup-stamp.js"; import { collectEnvironmentSecretRefs, @@ -438,6 +439,8 @@ function stripSecretRefValuesFromPluginLeaseMetadata(input: { export interface EnvironmentDriverAcquireInput { companyId: string; environment: Environment; + /** Saved environment before run-only lease retention settings. */ + configuredEnvironment?: Environment; issueId: string | null; agentId: string | null; /** @@ -457,6 +460,8 @@ export interface EnvironmentDriverAcquireInput { * configured default adapter. */ adapterType: string | null; + /** Resolve an immutable provider snapshot identity for an explicit platform probe. */ + resolveSnapshotIdentity?: boolean; /** * Force applying the active custom-image template even when issueId and * heartbeatRunId are null. Operator-initiated `Test` probes set this so the @@ -1847,15 +1852,34 @@ function createSandboxEnvironmentDriver( driver: "sandbox", async acquireRunLease(input) { + const configuredEnvironment = input.configuredEnvironment ?? input.environment; + // A warm run may change retention, but must not replace the boot binding + // or account used for qualification and custom-template resolution. + if (configuredEnvironment.id !== input.environment.id + || configuredEnvironment.driver !== input.environment.driver + || stableStringify({ ...configuredEnvironment.config, reuseLease: undefined }) + !== stableStringify({ ...input.environment.config, reuseLease: undefined })) { + throw new Error("Run environment does not match its saved configuration."); + } const storedParsed = parseEnvironmentDriverConfig(input.environment); - const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.companyId, input.environment, { + const selectedTemplate: { value: SandboxTemplateIdentity } = { value: null }; + const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.companyId, configuredEnvironment, { issueId: input.issueId, heartbeatRunId: input.heartbeatRunId, applyCustomImageTemplate: input.applyCustomImageTemplate ?? false, + onResolvedCustomImageTemplate: identity => { selectedTemplate.value = identity; }, }); if (parsed.driver !== "sandbox" || storedParsed.driver !== "sandbox") { throw new Error(`Expected sandbox environment config for driver "${input.environment.driver}".`); } + const pinnedSnapshotId = input.adapterType === "paperclip_runner" + ? qualifiedSandboxSnapshotId(configuredEnvironment, input.companyId, parsed.config, selectedTemplate.value) : null; + if (input.adapterType === "paperclip_runner" && !pinnedSnapshotId + && hasSandboxSnapshotQualification(configuredEnvironment, input.companyId)) { + throw new Error("sandbox_snapshot_qualification_required: The snapshot qualification is missing or stale. Test this environment again before running Paperclip Runner."); + } + if (input.configuredEnvironment) parsed.config = { ...parsed.config, reuseLease: storedParsed.config.reuseLease }; + if (pinnedSnapshotId) parsed.config = { ...parsed.config, snapshot: pinnedSnapshotId }; // Check if this provider should be handled by a plugin. if (!isBuiltinSandboxProvider(parsed.config.provider)) { @@ -1886,7 +1910,8 @@ function createSandboxEnvironmentDriver( } const workerConfig = stripSandboxProviderEnvelope(parsed.config); - const storedConfig = storedParsed.config; + const storedConfig = pinnedSnapshotId ? { ...storedParsed.config, snapshot: pinnedSnapshotId } : storedParsed.config; + if (pinnedSnapshotId) delete storedConfig.image; const providerConfigForLease = sandboxConfigForLeaseMetadata(storedConfig); // Require the reusable-lease capability AND a worker that verifies the // reuse methods. The provider must first opt in through the declaration: @@ -2129,6 +2154,7 @@ function createSandboxEnvironmentDriver( // environment's default adapter image (a pi agent then runs in the // opencode image and the harness binary is missing at exec time). adapterType: input.adapterType ?? undefined, + ...(input.resolveSnapshotIdentity ? { resolveSnapshotIdentity: true } : {}), // Forward a caller deadline so the provider configures a provider-side // expiry at or before it and returns the real provider expiry. ...(requestedExpiresAtParam(input.requestedExpiresAt) !== undefined @@ -4028,6 +4054,8 @@ export function environmentRuntimeService( async acquireRunLease(input: { companyId: string; environment: Environment; + /** Saved environment before run-only lease retention settings. */ + configuredEnvironment?: Environment; issueId: string | null; agentId?: string | null; /** Null for ad-hoc invocations (e.g. operator-initiated `Test` probes). */ @@ -4036,6 +4064,7 @@ export function environmentRuntimeService( executionWorkspaceSettings?: IssueExecutionWorkspaceSettings | null; /** The agent's adapter type for this run (mixed-harness environments). */ adapterType?: string | null; + resolveSnapshotIdentity?: boolean; /** * Force applying the active custom-image template even for ad-hoc (no * issue/run) invocations. Operator `Test` probes set this so the runtime @@ -4066,6 +4095,7 @@ export function environmentRuntimeService( const lease = await driver.acquireRunLease({ companyId: input.companyId, environment: input.environment, + configuredEnvironment: input.configuredEnvironment, issueId: input.issueId, agentId: input.agentId ?? null, heartbeatRunId: input.heartbeatRunId, @@ -4073,6 +4103,7 @@ export function environmentRuntimeService( executionWorkspaceMode: leaseContext.executionWorkspaceMode, executionWorkspaceSettings: input.executionWorkspaceSettings ?? null, adapterType: input.adapterType ?? null, + resolveSnapshotIdentity: input.resolveSnapshotIdentity, applyCustomImageTemplate: input.applyCustomImageTemplate ?? false, requestedExpiresAt: input.requestedExpiresAt ?? null, assertCompanyBinding: input.assertCompanyBinding, diff --git a/server/src/services/environments.ts b/server/src/services/environments.ts index ab5e80764eb..97a04b7732a 100644 --- a/server/src/services/environments.ts +++ b/server/src/services/environments.ts @@ -322,6 +322,14 @@ function mergeManagedEnvironmentMetadata( return merged; } +function preserveInvalidatedSnapshotProofs(replacement: SQL): SQL { + const proofs = sql`(select coalesce(jsonb_object_agg(entry.key, entry.value || '{"invalidated":true}'::jsonb), '{}'::jsonb) + from jsonb_each(coalesce(${environments.metadata}->${SANDBOX_PLATFORM_PROBE_METADATA_KEY}, '{}'::jsonb)) as entry + where entry.value->>'v' = '2')`; + return sql`case when ${proofs} = '{}'::jsonb then ${replacement} + else coalesce(${replacement}, '{}'::jsonb) || jsonb_build_object(${SANDBOX_PLATFORM_PROBE_METADATA_KEY}::text, ${proofs}) end`; +} + export function environmentService(db: Db) { /** * Idempotently ensure THE Paperclip-managed sandbox environment for this @@ -869,8 +877,11 @@ export function environmentService(db: Db) { proof: Record | null; }): Promise => { const existing = sql`coalesce(${environments.metadata}->${SANDBOX_PLATFORM_PROBE_METADATA_KEY}, '{}'::jsonb)`; + const previous = sql`${existing}->${input.companyId}`; const probes = input.proof === null - ? sql`${existing} - ${input.companyId}` + ? sql`case when ${previous}->>'v' = '2' + then jsonb_set(${existing}, ARRAY[${input.companyId}]::text[], ${previous} || '{"invalidated":true}'::jsonb, true) + else ${existing} - ${input.companyId} end` : sql`${existing} || ${JSON.stringify({ [input.companyId]: input.proof })}::jsonb`; const rows = await db.update(environments).set({ metadata: sql`jsonb_set(coalesce(${environments.metadata}, '{}'::jsonb), ARRAY[${SANDBOX_PLATFORM_PROBE_METADATA_KEY}]::text[], ${probes}, true)`, @@ -1142,7 +1153,8 @@ export function environmentService(db: Db) { const userMetadata = withoutManagedEnvironmentArchiveToken(patch.metadata ?? null); if (userMetadata) delete userMetadata[SANDBOX_PLATFORM_PROBE_METADATA_KEY]; if (changesTarget) { - values.metadata = userMetadata; + const replacement = userMetadata === null ? sql`null::jsonb` : sql`${JSON.stringify(userMetadata)}::jsonb`; + metadataUpdate = preserveInvalidatedSnapshotProofs(replacement); } else { const replacement = userMetadata === null ? sql`null::jsonb` : sql`${JSON.stringify(userMetadata)}::jsonb`; metadataUpdate = sql`case when ${environments.metadata} ? ${SANDBOX_PLATFORM_PROBE_METADATA_KEY} @@ -1152,7 +1164,7 @@ export function environmentService(db: Db) { } } else if (changesTarget || patch.status !== undefined) { metadataUpdate = sql`${environments.metadata} - ${MANAGED_ENVIRONMENT_ARCHIVE_TOKEN_METADATA_KEY}`; - if (changesTarget) metadataUpdate = sql`${metadataUpdate} - ${SANDBOX_PLATFORM_PROBE_METADATA_KEY}`; + if (changesTarget) metadataUpdate = preserveInvalidatedSnapshotProofs(sql`${metadataUpdate} - ${SANDBOX_PLATFORM_PROBE_METADATA_KEY}`); } const row = await writeDb 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 f2be0dd7514..02a2211ee2a 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -22,7 +22,7 @@ import { truncate, writeFile, } from "node:fs/promises"; -import { execFile, execFileSync, spawn } from "node:child_process"; +import { execFile, execFileSync, spawn, spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { inspect } from "node:util"; import { join } from "node:path"; @@ -239,7 +239,7 @@ vi.mock("../../vendor/paperclip-runner/index.js", async (importOriginal) => { >(); return { ...original, - bundledRemoteProviderPackManifestPath: () => state.bundledPackManifestPath ?? original.bundledRemoteProviderPackManifestPath(), + bundledRemoteProviderPackManifestPath: vi.fn((moduleUrl?: string, target?: "linux-x64" | "linux-arm64") => state.bundledPackManifestPath ?? original.bundledRemoteProviderPackManifestPath(moduleUrl, target)), bundledRemoteRunnerBinary: () => state.bundledRunnerBinary ?? original.bundledRemoteRunnerBinary(), createNativeSessionBackend: state.createBackend, createRunnerdCodexTransport: state.createTransport, @@ -1355,6 +1355,33 @@ describe("native provider usage normalization", () => { }); describe("remote provider pack manifest", () => { + it.each(["arm64", "x64"].flatMap(architecture => [false, true].map(explicit => ({ architecture, explicit }))))( + "keeps explicit Mac SSH assets at their existing pack integrity gate ($architecture, explicit=$explicit)", async ({ architecture, explicit }) => { + const root = await mkdtemp(join(tmpdir(), "paperclip-explicit-mac-pack-")); + const daemonPath = join(root, "paperclip-runnerd"); + const daemon = Buffer.alloc(64); + daemon.writeUInt32LE(0xfeedfacf); + daemon.writeUInt32LE(architecture === "arm64" ? 0x0100000c : 0x01000007, 4); + await writeFile(daemonPath, daemon); + state.resolveRemoteRunnerBinary.mockResolvedValue(daemonPath); + state.createBackend.mockClear(); + const remoteExecution = { ...execution, + provider: { kind: "acpx", agent: "claude", model: "claude-opus-5-5" }, + workspace: { ...execution.workspace, cwd: root }, + session: { ...execution.session, normalizedSessionId: `explicit-mac-${randomUUID()}`, driverKind: "acpx_runtime" }, + } as unknown as NativeExecutionInputV1; + try { + await expect(createRunnerdBackend({ db: leaseDb(remoteExecution), execution: remoteExecution, runnerInstanceId: "runner", + runnerRemoteBinaryPath: daemonPath, + ...(explicit ? { runnerRemoteProviderPackPath: root } : {}), + runnerExecutionTarget: { kind: "remote", transport: "ssh", remoteCwd: "/workspace", + spec: { host: "runner.internal", port: 22, username: "runner", remoteWorkspacePath: "/workspace", remoteCwd: "/workspace", + privateKey: null, knownHosts: null, strictHostKeyChecking: true } }, + })).rejects.toThrow(explicit ? "provider-pack.json is unreadable (missing)" : "require a qualified Linux target"); + expect(state.createBackend).not.toHaveBeenCalled(); + } finally { await rm(root, { recursive: true, force: true }); } + }, + ); const canonical = (value: unknown): string => { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (value && typeof value === "object") { @@ -1367,7 +1394,9 @@ describe("remote provider pack manifest", () => { return JSON.stringify(value); }; - it.each(["opencode", "cursor"] as const)("uses the bundled Linux image authority for remote %s without controller overrides", async provider => { + it.each(["opencode", "cursor", "claude-x64", "claude-arm64"] as const)("uses the bundled Linux image authority for remote %s without controller overrides", async provider => { + const architecture = provider === "claude-arm64" ? "arm64" : "x64"; + const claude = provider.startsWith("claude-"); const root = await mkdtemp(join(tmpdir(), "paperclip-bundled-image-")); const digest = "sha256:" + "a".repeat(64); const artifacts = Object.fromEntries(Object.entries({ @@ -1377,7 +1406,7 @@ describe("remote provider pack manifest", () => { opencodeProxy: "dist/cli/opencode-app-server-proxy.cjs", acpxSidecar: "dist/cli/acpx-runtime-sidecar.cjs", }).map(([key, path]) => [key, { path, sha256: digest }])); const payload = { - target: { platform: "linux", architecture: "x64" }, runnerSourceRevision: "a".repeat(40), + target: { platform: "linux", architecture }, runnerSourceRevision: "a".repeat(40), pins: { nodeMinimum: "24.11.0", codex: QUALIFIED_ACPX_PROFILES.codex.agentRuntimeVersion, opencode: "1.18.34", acpx: "0.13.1", grok: QUALIFIED_ACPX_PROFILES.grok.agentRuntimeVersion, claudeAcp: QUALIFIED_ACPX_PROFILES.claude.agentServerVersion, codexAcp: QUALIFIED_ACPX_PROFILES.codex.agentServerVersion }, @@ -1391,10 +1420,14 @@ describe("remote provider pack manifest", () => { state.bundledPackManifestPath = join(root, "provider-pack.json"); state.bundledRunnerBinary = join(root, "linux-paperclip-runnerd"); await writeFile(state.bundledPackManifestPath, JSON.stringify(manifest)); - await writeFile(state.bundledRunnerBinary, "verified Linux daemon fixture"); + const daemon = Buffer.alloc(64); daemon.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); + daemon.writeUInt16LE(architecture === "arm64" ? 183 : 62, 18); + await writeFile(state.bundledRunnerBinary, daemon); + if (claude) state.resolveRemoteRunnerBinary.mockResolvedValue(state.bundledRunnerBinary); const remoteExecution = { ...execution, provider: provider === "opencode" - ? { kind: "opencode", model: null } : { kind: "acpx", agent: "cursor", model: null }, - session: { ...execution.session, driverKind: provider === "opencode" ? "opencode_server" : "acpx_runtime" }, + ? { kind: "opencode", model: null } : { kind: "acpx", agent: claude ? "claude" : "cursor", model: null }, + workspace: { ...execution.workspace, cwd: root }, + session: { ...execution.session, normalizedSessionId: `bundled-${provider}-${randomUUID()}`, driverKind: provider === "opencode" ? "opencode_server" : "acpx_runtime" }, } as unknown as NativeExecutionInputV1; const syncIn = vi.fn(async () => undefined); const execute = vi.fn(async (command: { command: string; args?: string[] }) => { @@ -1413,6 +1446,11 @@ describe("remote provider pack manifest", () => { controlPlaneRegistration: (authority: unknown) => Promise; }; expect(transport.runnerBinary).toBe(state.bundledRunnerBinary); + if (claude) { + expect(state.resolveRemoteRunnerBinary).toHaveBeenCalledWith(expect.objectContaining({ remoteCwd: "/workspace", configuredPath: undefined })); + const { bundledRemoteProviderPackManifestPath } = await import("../../vendor/paperclip-runner/index.js"); + expect(bundledRemoteProviderPackManifestPath).toHaveBeenCalledWith(undefined, `linux-${architecture}`); + } await expect(transport.controlPlaneRegistration({})).rejects.toThrow("reached-staged-Linux-daemon-verification"); expect(syncIn).toHaveBeenCalledWith([expect.objectContaining({ files: [expect.objectContaining({ sourcePath: state.bundledRunnerBinary, targetPath: "/workspace/.paperclip-runtime/paperclip-runner/bin/paperclip-runnerd", @@ -2664,6 +2702,98 @@ describe("remote provider checkpoint snapshots", () => { }); describe("remote provider checkpoint restores", () => { + it("stages and verifies read-only context with non-contiguous archive entries", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-readonly-restore-")); + const sourcePath = join(root, "source"); + const targetPath = join(root, "target with 'quotes'"); + try { + await mkdir(join(sourcePath, "references", "agents"), { recursive: true }); + await writeFile(join(sourcePath, "SKILL.md"), "skill"); + await writeFile(join(sourcePath, "references", "guide.md"), "guide"); + await writeFile(join(sourcePath, "references", "agents", "coder.md"), "coder"); + for (const path of [sourcePath, join(sourcePath, "references"), join(sourcePath, "references", "agents")]) { + await chmod(path, 0o555); + } + for (const path of [join(sourcePath, "SKILL.md"), join(sourcePath, "references", "guide.md"), join(sourcePath, "references", "agents", "coder.md")]) { + await chmod(path, 0o444); + } + const archive = execFileSync("tar", [ + "--no-recursion", "--no-xattrs", "-czf", "-", "-C", sourcePath, + ".", "./references", "./SKILL.md", "./references/agents", + "./references/guide.md", "./references/agents/coder.md", + ], { env: { ...process.env, COPYFILE_DISABLE: "1" } }); + const execute = vi.fn(async (command: { command: string; args: string[] }) => { + const result = spawnSync(command.command, command.args, { + input: archive.toString("base64"), + encoding: "utf8", + env: { ...process.env }, + }); + return { exitCode: result.status, timedOut: false, stdout: result.stdout, stderr: result.stderr }; + }); + for (let pass = 0; pass < 2; pass++) { + await stageRemoteRunnerDirectory({ + target: { kind: "remote", transport: "ssh", remoteCwd: root } as never, + runner: { execute } as never, + sourcePath, + targetPath, + mode: 0o555, + }); + } + expect(await readFile(join(targetPath, "references", "agents", "coder.md"), "utf8")).toBe("coder"); + expect(await readFile(join(targetPath, "references", "guide.md"), "utf8")).toBe("guide"); + expect(await readFile(join(targetPath, "SKILL.md"), "utf8")).toBe("skill"); + expect((await lstat(join(targetPath, "references"))).mode & 0o777).toBe(0o500); + expect((await lstat(targetPath)).mode & 0o777).toBe(0o555); + const corruptedFile = join(targetPath, "references", "agents", "coder.md"); + await chmod(corruptedFile, 0o600); + await writeFile(corruptedFile, "other"); + await chmod(corruptedFile, 0o400); + await expect(stageRemoteRunnerDirectory({ + target: { kind: "remote", transport: "ssh", remoteCwd: root } as never, + runner: { execute } as never, + sourcePath, + targetPath, + mode: 0o555, + })).rejects.toThrow("runner_remote_directory_staging_failed"); + expect(await readFile(corruptedFile, "utf8")).toBe("other"); + } finally { + for (const directory of [sourcePath, targetPath]) { + for (const path of [directory, join(directory, "references"), join(directory, "references", "agents")]) { + await chmod(path, 0o700).catch(() => undefined); + } + } + await rm(root, { recursive: true, force: true }); + } + }); + + it("rejects context staging through a symlink", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-context-symlink-")); + const sourcePath = join(root, "source"); + const preservedPath = join(root, "preserved"); + const targetPath = join(root, "target"); + try { + await mkdir(sourcePath); + await mkdir(preservedPath); + await writeFile(join(preservedPath, "sentinel"), "preserved"); + await symlink(preservedPath, targetPath); + const execute = vi.fn(async (command: { command: string; args: string[]; stdin?: string }) => { + const result = spawnSync(command.command, command.args, { input: command.stdin, encoding: "utf8" }); + return { exitCode: result.status, timedOut: false, stdout: result.stdout, stderr: result.stderr }; + }); + await expect(stageRemoteRunnerDirectory({ + target: { kind: "remote", transport: "ssh", remoteCwd: root } as never, + runner: { execute } as never, + sourcePath, + targetPath, + mode: 0o555, + })).rejects.toThrow("runner_remote_directory_staging_failed"); + expect(await readFile(join(preservedPath, "sentinel"), "utf8")).toBe("preserved"); + expect((await lstat(targetPath)).isSymbolicLink()).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("does not upload excluded Codex scratch trees or credentials", async () => { const sourcePath = await mkdtemp( join(tmpdir(), "paperclip-codex-restore-source-"), diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 33f86149cd9..b22a1d7c9ff 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -8,7 +8,7 @@ import { isCloudManagedInstance } from "../cloud-instance.js"; import { dotRunnerBroker } from "../dot-runner-broker.js"; import { CURSOR_DISTRIBUTION_PINS, QUALIFIED_ACPX_PROFILES, QUALIFIED_ACPX_VERSION } from "../../vendor/paperclip-runner/index.js"; import { isProviderMode } from "../../vendor/paperclip-runner/index.js"; -import { bundledRemoteProviderPackManifestPath, bundledRemoteRunnerBinary } from "../../vendor/paperclip-runner/index.js"; +import { bundledRemoteProviderPackManifestPath, bundledRemoteRunnerBinary, runnerBinaryTarget } from "../../vendor/paperclip-runner/index.js"; import { nativeRetryCancellationEligible, rethrowNativeCancellationLockConflict, assertCancellationRequest, cancellationIntentId as callerCancellationIntentId, cancellationRequestId } from "./native-cancellation-request.js"; import { readNativePlanWait } from "./native-plan-wait.js"; import { nativeProviderLifecycle } from "./provider-lifecycle.js"; @@ -33,7 +33,7 @@ import { copyBackCodexAuth } from "@paperclipai/adapter-codex-local/server"; import { nativeCompletionFeedback } from "./native-completion-feedback.js"; import { hasAcknowledgedNativeReassignmentStopIntent, hasAcknowledgedNativeStopIntent } from "../acknowledged-native-stop.js"; import { stoppedCodexTurnIsTextOnly } from "./stopped-codex-turn.js"; -import { prepareVerifiedRemoteProviderPack, runRemoteProviderVerification } from "./remote-provider-pack.js"; +import { prepareVerifiedRemoteProviderPack, useVerifiedPreinstalledRemoteProviderPack, runRemoteProviderVerification } from "./remote-provider-pack.js"; import { readProviderPackArchive, stageProviderPackArchive } from "./provider-pack-archive.js"; import { selectRemotePiCompanion } from "./remote-pi-companion.js"; import { readNativeLocalProcessStop, PROCESS_START_REQUESTED } from "../native-local-process-stop.js"; @@ -10130,10 +10130,10 @@ export function readRemoteProviderPackManifest(packRoot: string): RemoteProvider return readRemoteProviderPackIdentity(packRoot, true); } -export function readBundledRemoteProviderPackManifest(manifestPath = bundledRemoteProviderPackManifestPath()): RemoteProviderPackManifest { +export function readBundledRemoteProviderPackManifest(manifestPath = bundledRemoteProviderPackManifestPath(), target: "linux-x64" | "linux-arm64" = "linux-x64"): RemoteProviderPackManifest { const manifest = readRemoteProviderPackIdentity(dirname(manifestPath), false); - if (manifest.payload.target.platform !== "linux" || manifest.payload.target.architecture !== "x64") { - throw new Error("runner_remote_provider_artifact_incompatible: bundled image pack must target Linux x64"); + if (`${manifest.payload.target.platform}-${manifest.payload.target.architecture}` !== target) { + throw new Error(`runner_remote_provider_artifact_incompatible: bundled image pack must target ${target}`); } return manifest; } @@ -10407,8 +10407,12 @@ export async function stageRemoteRunnerDirectory(input: { stagingRoot = mkdtempSync(join(tmpdir(), "paperclip-runner-restore-")); const archive = execFileSync( "tar", - [...excludeArgs, "-czf", "-", "-C", input.sourcePath, "."], - { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 }, + [...excludeArgs, "--no-xattrs", "-czf", "-", "-C", input.sourcePath, "."], + { + encoding: "buffer", + maxBuffer: 64 * 1024 * 1024, + env: { ...process.env, COPYFILE_DISABLE: "1" }, + }, ); execFileSync("tar", ["-xzf", "-", "-C", stagingRoot], { input: archive, @@ -10436,14 +10440,28 @@ export async function stageRemoteRunnerDirectory(input: { } const archive = execFileSync( "tar", - [...excludeArgs, "-czf", "-", "-C", input.sourcePath, "."], - { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 }, + [...excludeArgs, "--no-xattrs", "-czf", "-", "-C", input.sourcePath, "."], + { + encoding: "buffer", + maxBuffer: 64 * 1024 * 1024, + env: { ...process.env, COPYFILE_DISABLE: "1" }, + }, ); const escapedTarget = input.targetPath.replaceAll("'", "'\\''"); + const compareImmutable = `const fs=require("node:fs"),p=require("node:path");function same(a,b){const x=fs.lstatSync(a),y=fs.lstatSync(b);if((x.mode&511)!==(y.mode&511))return false;if(x.isDirectory())return y.isDirectory()&&fs.readdirSync(a).every(n=>same(p.join(a,n),p.join(b,n)));return x.isFile()&&y.isFile()&&fs.readFileSync(a).equals(fs.readFileSync(b));}try{process.exit(same(process.argv[1],process.argv[2])?0:1)}catch{process.exit(1)}`; + const immutableStage = (input.mode & 0o222) === 0 + ? `staging=$(mktemp -d) && mkdir "$staging/compare" || exit 1; ` + + `trap 'find "$staging" -type d -exec chmod u+w {} +; rm -rf -- "$staging"' EXIT; ` + + `base64 -d > "$staging/archive" && ` + + `tar "$@" -xzf "$staging/archive" -C "$staging/compare" && ` + + `chmod ${input.mode.toString(8)} "$staging/compare" || exit 1; ` + + `if node -e '${compareImmutable}' "$staging/compare" '${escapedTarget}'; then exit 0; fi; ` + + `tar "$@" -xzf "$staging/archive" -C '${escapedTarget}'` + : `base64 -d | tar "$@" -xzf - -C '${escapedTarget}'`; const script = - `umask 077; mkdir -p '${escapedTarget}' && ` + - `base64 -d | tar -xzf - -C '${escapedTarget}' && ` + - `chmod ${input.mode.toString(8)} '${escapedTarget}'`; + `case "$(tar --version 2>/dev/null)" in *"GNU tar"*) set -- --delay-directory-restore ;; *) set -- ;; esac; ` + + `umask 077; test ! -L '${escapedTarget}' && mkdir -p '${escapedTarget}' && ` + + `{ ${immutableStage}; } && chmod ${input.mode.toString(8)} '${escapedTarget}'`; const result = await input.runner.execute({ command: "sh", args: ["-c", script], @@ -11668,6 +11686,19 @@ async function createRunnerdBackendWithinSessionClaim( remoteTarget !== null && (input.execution.provider.kind === "opencode" || input.execution.provider.kind === "acpx"); + const remoteClaude = remoteTarget !== null && input.execution.provider.kind === "acpx" && input.execution.provider.agent === "claude"; + const remoteClaudeRunnerBinary = remoteClaude + ? await resolveRemotePaperclipRunnerBinary({ runner: remoteCommandRunner!, remoteCwd: remoteTarget!.remoteCwd, configuredPath: input.runnerRemoteBinaryPath }) + : null; + const remoteClaudeTarget = remoteClaudeRunnerBinary ? runnerBinaryTarget(readFileSync(remoteClaudeRunnerBinary)) : null; + const useBundledClaudePack = remoteClaude && !input.runnerRemoteProviderPackPath?.trim(); + if (useBundledClaudePack && remoteClaudeTarget !== "linux-x64" && remoteClaudeTarget !== "linux-arm64") { + throw new Error("runner_remote_provider_artifact_incompatible: Claude remote provider packs require a qualified Linux target"); + } + const bundledClaudeManifest = useBundledClaudePack && (remoteClaudeTarget === "linux-x64" || remoteClaudeTarget === "linux-arm64") + ? bundledRemoteProviderPackManifestPath(undefined, remoteClaudeTarget) : null; + const bundledClaudeRoot = bundledClaudeManifest && existsSync(join(dirname(bundledClaudeManifest), "node_modules")) + ? dirname(bundledClaudeManifest) : null; // Pi's explicit operator import supplies the target-platform authority. Never // substitute a Mac controller daemon or trust an image's self-reported hashes. // Existing explicit overrides and every other provider keep their old path. @@ -11677,13 +11708,15 @@ async function createRunnerdBackendWithinSessionClaim( workspaceRoot: input.execution.workspace.cwd!, })); const configuredProviderPackRoot = - input.runnerRemoteProviderPackPath?.trim() || remotePiCompanion?.providerPack || null; + input.runnerRemoteProviderPackPath?.trim() || remotePiCompanion?.providerPack || bundledClaudeRoot || null; let expectedProviderPackManifest: RemoteProviderPackManifest | null = null; const useBundledRemoteImageAssets = requiresRemoteProviderPack && !configuredProviderPackRoot && (input.execution.provider.kind === "opencode" || - (input.execution.provider.kind === "acpx" && input.execution.provider.agent === "cursor")); + (input.execution.provider.kind === "acpx" && (input.execution.provider.agent === "cursor" || remoteClaude))); if (useBundledRemoteImageAssets) { - expectedProviderPackManifest = readBundledRemoteProviderPackManifest(); + expectedProviderPackManifest = bundledClaudeManifest && (remoteClaudeTarget === "linux-x64" || remoteClaudeTarget === "linux-arm64") + ? readBundledRemoteProviderPackManifest(bundledClaudeManifest, remoteClaudeTarget) + : readBundledRemoteProviderPackManifest(); } else if (requiresRemoteProviderPack) { if ( !configuredProviderPackRoot || @@ -11714,7 +11747,7 @@ async function createRunnerdBackendWithinSessionClaim( const controllerRunnerBinary = targetMatchedCodexArtifact ? await timeController("runner_binary", () => resolveRemotePaperclipRunnerBinary({ runner: remoteCommandRunner!, remoteCwd: remoteTarget.remoteCwd, configuredPath: input.runnerRemoteBinaryPath })) : remoteTarget - ? input.runnerRemoteBinaryPath?.trim() || remotePiCompanion?.runnerBinary || (useBundledRemoteImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary()) + ? remoteClaudeRunnerBinary || input.runnerRemoteBinaryPath?.trim() || remotePiCompanion?.runnerBinary || (useBundledRemoteImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary()) : resolvePaperclipRunnerBinary(); const explicitRemoteCodex = input.runnerRemoteCodexPath?.trim() || null; const remoteCodexNpmSpec = input.runnerRemoteCodexNpmSpec?.trim() || null; @@ -11867,19 +11900,20 @@ async function createRunnerdBackendWithinSessionClaim( }; const discoverPreinstalledProviderPack = async () => { - if (!remoteTarget || !remoteCommandRunner) return null; + if (!remoteTarget || !remoteCommandRunner) return []; const result = await remoteCommandRunner.execute({ command: "sh", args: [ "-c", - 'for candidate in /opt/paperclip-runner/provider-pack "$HOME/.local/share/paperclip-runner/provider-pack"; do if [ -f "$candidate/provider-pack.json" ]; then printf \'%s\\n\' "$candidate"; break; fi; done', + 'for candidate in /opt/paperclip-runner/provider-pack "$HOME/.local/share/paperclip-runner/provider-pack"; do if [ -f "$candidate/provider-pack.json" ]; then printf \'%s\\n\' "$candidate"; fi; done', ], cwd: remoteTarget.remoteCwd, bypassSession: true, timeoutMs: 10_000, }); - if (result.exitCode !== 0 || result.timedOut) return null; - return parseRemoteExecutableCandidate(result.stdout); + if (result.exitCode !== 0 || result.timedOut) return []; + return result.stdout.split(/\r?\n/).map(parseRemoteExecutableCandidate) + .filter((candidate): candidate is string => candidate !== null); }; const remoteNativeArtifacts = remoteTarget && remoteCommandRunner && remoteBinary && remoteRuntimeRoot @@ -11888,7 +11922,7 @@ async function createRunnerdBackendWithinSessionClaim( 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, + reuseRetainedRunner: sandboxLeaseAcquisition?.outcome === "resumed", targetPlatformArtifactVerified: remotePiCompanion !== null || useBundledRemoteImageAssets || targetMatchedCodexArtifact || remoteClaude, trace: input.trace, onLog: input.onLog, }) : null; @@ -11930,53 +11964,47 @@ async function createRunnerdBackendWithinSessionClaim( "provider_pack.verify", () => verifyRemoteProviderPack(stagedRemoteProviderPackRoot), ), - usePreinstalled: async () => { - let preinstalledProviderPack = await measureNativeRunnerSpan(input.trace, "provider_pack.discover", discoverPreinstalledProviderPack); - preinstalledProviderPackRoots = preinstalledProviderPack ? [preinstalledProviderPack] : []; - if (preinstalledProviderPack) { - try { - await measureNativeRunnerSpan( - input.trace, - "provider_pack.verify_preinstalled", - () => verifyRemoteProviderPack(preinstalledProviderPack!), + usePreinstalled: async () => useVerifiedPreinstalledRemoteProviderPack({ + candidates: preinstalledProviderPackRoots = await measureNativeRunnerSpan(input.trace, "provider_pack.discover", discoverPreinstalledProviderPack), + verifyAndLink: async (preinstalledProviderPack) => { + await measureNativeRunnerSpan( + input.trace, + "provider_pack.verify_preinstalled", + () => verifyRemoteProviderPack(preinstalledProviderPack), + ); + const escapedSource = preinstalledProviderPack.replaceAll( + "'", + "'\\''", + ); + const escapedTarget = stagedRemoteProviderPackRoot.replaceAll( + "'", + "'\\''", + ); + const escapedParent = posix + .dirname(stagedRemoteProviderPackRoot) + .replaceAll("'", "'\\''"); + const linked = await measureNativeRunnerSpan(input.trace, "provider_pack.link_preinstalled", () => remoteCommandRunner.execute({ + command: "sh", + args: [ + "-c", + `umask 077; mkdir -p '${escapedParent}' && rm -rf '${escapedTarget}' && ln -s '${escapedSource}' '${escapedTarget}'`, + ], + cwd: remoteTarget.remoteCwd, + bypassSession: true, + timeoutMs: 10_000, + })); + if (linked.exitCode !== 0 || linked.timedOut) { + throw new Error( + "runner_remote_provider_artifact_incompatible: preinstalled provider pack could not be linked", ); - const escapedSource = preinstalledProviderPack.replaceAll( - "'", - "'\\''", - ); - const escapedTarget = stagedRemoteProviderPackRoot.replaceAll( - "'", - "'\\''", - ); - const escapedParent = posix - .dirname(stagedRemoteProviderPackRoot) - .replaceAll("'", "'\\''"); - const linked = await measureNativeRunnerSpan(input.trace, "provider_pack.link_preinstalled", () => remoteCommandRunner.execute({ - command: "sh", - args: [ - "-c", - `umask 077; mkdir -p '${escapedParent}' && rm -rf '${escapedTarget}' && ln -s '${escapedSource}' '${escapedTarget}'`, - ], - cwd: remoteTarget.remoteCwd, - bypassSession: true, - timeoutMs: 10_000, - })); - if (linked.exitCode !== 0 || linked.timedOut) { - throw new Error( - "runner_remote_provider_artifact_incompatible: preinstalled provider pack could not be linked", - ); - } - activeRemoteProviderPackRoot = stagedRemoteProviderPackRoot; - await input.onLog?.( - "stderr", - "[paperclip-runner] using manifest-matched provider pack from the sandbox image\n", - ); - } catch { - preinstalledProviderPack = null; } - } - return preinstalledProviderPack !== null; - }, + activeRemoteProviderPackRoot = stagedRemoteProviderPackRoot; + await input.onLog?.( + "stderr", + "[paperclip-runner] using manifest-matched preinstalled provider pack\n", + ); + }, + }), stageAndVerify: async () => { if (!configuredProviderPackRoot) { throw new Error("runner_remote_provider_artifact_incompatible: install the matching Paperclip package and Daytona image; the image provider pack did not match the bundled release identity"); diff --git a/server/src/services/native-runtime/remote-provider-pack.test.ts b/server/src/services/native-runtime/remote-provider-pack.test.ts index 437e8254ed3..ca60e946d9f 100644 --- a/server/src/services/native-runtime/remote-provider-pack.test.ts +++ b/server/src/services/native-runtime/remote-provider-pack.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { prepareVerifiedRemoteProviderPack, runRemoteProviderVerification } from "./remote-provider-pack.js"; +import { prepareVerifiedRemoteProviderPack, useVerifiedPreinstalledRemoteProviderPack, runRemoteProviderVerification } from "./remote-provider-pack.js"; describe("remote provider verification", () => { const ok = { exitCode: 0, timedOut: false, stdout: "verified", stderr: "", signal: null }; @@ -89,3 +89,25 @@ describe("remote provider pack reuse", () => { await expect(prepareVerifiedRemoteProviderPack(input)).resolves.toBe("uploaded"); }); }); + + +describe("standard remote provider pack discovery", () => { + const roots = ["/opt/paperclip-runner/provider-pack", "/home/operator/.local/share/paperclip-runner/provider-pack"]; + it.each(["old manifest", "modified artifact", "failed link"])("tries the matching home pack after %s rejects /opt", async reason => { + const verifyAndLink = vi.fn(async (root: string) => { + if (root === roots[0]) throw new Error(reason); + }); + await expect(useVerifiedPreinstalledRemoteProviderPack({ candidates: roots, verifyAndLink })).resolves.toBe(true); + expect(verifyAndLink.mock.calls.map(([root]) => root)).toEqual(roots); + }); + it("keeps the first matching pack without consulting another installation", async () => { + const verifyAndLink = vi.fn(async () => {}); + await expect(useVerifiedPreinstalledRemoteProviderPack({ candidates: roots, verifyAndLink })).resolves.toBe(true); + expect(verifyAndLink).toHaveBeenCalledExactlyOnceWith(roots[0]); + }); + it("fails closed when every standard installation is incompatible", async () => { + const verifyAndLink = vi.fn().mockRejectedValue(new Error("manifest mismatch")); + await expect(useVerifiedPreinstalledRemoteProviderPack({ candidates: roots, verifyAndLink })).resolves.toBe(false); + expect(verifyAndLink).toHaveBeenCalledTimes(2); + }); +}); diff --git a/server/src/services/native-runtime/remote-provider-pack.ts b/server/src/services/native-runtime/remote-provider-pack.ts index b3aa570e7f9..a7a453433f0 100644 --- a/server/src/services/native-runtime/remote-provider-pack.ts +++ b/server/src/services/native-runtime/remote-provider-pack.ts @@ -68,3 +68,17 @@ export async function prepareVerifiedRemoteProviderPack(input: { if (scope) await input.trace!.end(scope, { outcome: failed ? "failed" : "ok" }); } } + +export async function useVerifiedPreinstalledRemoteProviderPack(input: { + candidates: readonly string[]; + verifyAndLink: (root: string) => Promise; +}): Promise { + for (const candidate of input.candidates) { + try { + await input.verifyAndLink(candidate); + return true; + } catch { + } + } + return false; +} diff --git a/server/src/services/sandbox-snapshot-qualification.ts b/server/src/services/sandbox-snapshot-qualification.ts new file mode 100644 index 00000000000..6ef439a7cda --- /dev/null +++ b/server/src/services/sandbox-snapshot-qualification.ts @@ -0,0 +1,64 @@ +import type { Environment, SandboxEnvironmentConfig } from "@paperclipai/shared"; +import { fingerprintEnvironmentSandboxProviderConfig, SANDBOX_PLATFORM_PROBE_METADATA_KEY } from "./environment-custom-image-runtime.js"; + +export type SandboxTemplateIdentity = { id: string; capturedAt: string | null; templateKind: string; bindingField: string } | null; +export type SandboxSnapshotIdentity = { + provider: "daytona"; + requestedRef: string; + id: string; + name: string; + organizationId: string; + createdAt: string; +}; + +export function readSnapshotIdentity(value: unknown, reference: unknown): SandboxSnapshotIdentity | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const identity = value as Record; + if (identity.provider !== "daytona" || identity.requestedRef !== reference + || typeof identity.id !== "string" || !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(identity.id) + || typeof identity.name !== "string" || !identity.name.trim() + || (reference !== identity.id && reference !== identity.name) + || typeof identity.organizationId !== "string" || !identity.organizationId.trim() + || typeof identity.createdAt !== "string" || !Number.isFinite(Date.parse(identity.createdAt))) return null; + return identity as SandboxSnapshotIdentity; +} + +export function isQualifiableDaytonaSnapshot( + input: SandboxEnvironmentConfig, template: SandboxTemplateIdentity, +): input is SandboxEnvironmentConfig & Record & { snapshot: string } { + const config = input as unknown as Record; + return config.provider === "daytona" && typeof config.snapshot === "string" && !!config.snapshot.trim() + && !config.image && !config.template && !config.templateRef + && (!template || (template.templateKind === "snapshot" && template.bindingField === "snapshot")); +} + +/** Keep an invalidated snapshot binding distinct from an environment that has + * never required an immutable qualification. A failed probe cannot unpin a run. + */ +export function hasSandboxSnapshotQualification(environment: Environment, companyId: string): boolean { + const proofs = environment.metadata?.[SANDBOX_PLATFORM_PROBE_METADATA_KEY]; + if (!proofs || typeof proofs !== "object" || Array.isArray(proofs)) return false; + const proof = (proofs as Record)[companyId]; + return !!proof && typeof proof === "object" && !Array.isArray(proof) + && (proof as Record).v === 2; +} + +/** Discovery reads only saved, company-scoped proof. Execution pins the same + * provider ID rather than resolving the mutable name again. A new config or + * captured template needs a new probe; deleting the pinned snapshot fails at + * the provider instead of silently selecting its replacement. */ +export function qualifiedSandboxSnapshotId( + environment: Environment, companyId: string, config: SandboxEnvironmentConfig, template: SandboxTemplateIdentity, +): string | null { + if (!isQualifiableDaytonaSnapshot(config, template)) return null; + const proofs = environment.metadata?.[SANDBOX_PLATFORM_PROBE_METADATA_KEY]; + if (!proofs || typeof proofs !== "object" || Array.isArray(proofs)) return null; + const proof = (proofs as Record)[companyId]; + if (!proof || typeof proof !== "object" || Array.isArray(proof)) return null; + const value = proof as Record; + if (value.v !== 2 || value.invalidated === true || value.companyId !== companyId + || value.baseConfigFingerprint !== fingerprintEnvironmentSandboxProviderConfig(environment.config as SandboxEnvironmentConfig) + || value.runtimeConfigFingerprint !== fingerprintEnvironmentSandboxProviderConfig(config) + || value.snapshot !== config.snapshot || JSON.stringify(value.template) !== JSON.stringify(template)) return null; + return readSnapshotIdentity(value.snapshotIdentity, config.snapshot)?.id ?? null; +} diff --git a/skills/paperclip-create-agent/SKILL.md b/skills/paperclip-create-agent/SKILL.md index c0a7fdf1e13..71fb6dfc645 100644 --- a/skills/paperclip-create-agent/SKILL.md +++ b/skills/paperclip-create-agent/SKILL.md @@ -191,8 +191,10 @@ For each linked issue, either: Choose the underlying harness in `adapterType`, such as `codex_local` or `claude_local`. New Codex agents use Paperclip Runner automatically on supported -execution targets. New Claude Code agents use Paperclip Runner on Apple Silicon; -other Claude targets retain Legacy until qualified. The native Claude profile is +execution targets. New Claude Code agents use Paperclip Runner on Apple Silicon, +Intel Mac, Linux x64 and Linux ARM64 with supported glibc. Unknown or unsupported +targets retain Legacy. Darwin SSH also defaults to Legacy; native execution +requires an explicit runner choice and matching remote assets. The native Claude profile is `provider: acpx`, `acpxAgent: claude`, with ordinary model `claude-opus-5-5`. Explicit models and existing saved runners remain intact. The saved response may therefore have `adapterType: paperclip_runner` and a resolved provider; diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index d490d1c8e63..741382aec62 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -1466,7 +1466,7 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [ groups: ["onboarding"], profiles: runnerProfiles.filter(profile => ["legacy-codex", "legacy-claude", "runner-codex", "runner-acpx-claude"].includes(profile.id)), environments: [localEnvironment], tasks: firstTaskTasks, expectedMatrixSize: 52, - definitionMetadata: { version: 7, runGrading: "evidenced-nonexecution-and-refusal", credentialPersistenceCheck: false, questionChoiceMinimum: 2, nativeSetup: "qualified-platform-wizard-saved-default", claudeDefaultTarget: "darwin-arm64", legacySetup: "explicit-advanced-runner-choice", productionInstructions: true, qualityGrading: "informational" }, + definitionMetadata: { version: 8, runGrading: "evidenced-nonexecution-and-refusal", credentialPersistenceCheck: false, questionChoiceMinimum: 2, nativeSetup: "qualified-platform-wizard-saved-default", claudeDefaultTargets: ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"], legacySetup: "explicit-advanced-runner-choice", productionInstructions: true, qualityGrading: "informational" }, }, { id: "agent-chat", label: "Persistent Agent Chat", diff --git a/tests/runner-e2e/daytona-image-content.ts b/tests/runner-e2e/daytona-image-content.ts index 4ccda56aa2a..ef526f14e84 100644 --- a/tests/runner-e2e/daytona-image-content.ts +++ b/tests/runner-e2e/daytona-image-content.ts @@ -45,6 +45,7 @@ export const DAYTONA_IMAGE_INPUT_PATHS = [ "packages/paperclip-runner/scripts/materialize-cursor-distribution.mjs", "packages/paperclip-runner/scripts/cursor-runtime-patch.mjs", "packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs", + "packages/paperclip-runner/scripts/provider-pack-runtime-tree.mjs", "packages/paperclip-runner/scripts/generate-acpx-sidecar-contract.mjs", "packages/paperclip-runner/scripts/generate-protocol-schema-module.mjs", "packages/paperclip-runner/scripts/materialize-pi-distribution.mjs", diff --git a/tests/runner-e2e/workflow-security.test.ts b/tests/runner-e2e/workflow-security.test.ts index 9588de3bf6d..b89cc7c3b19 100644 --- a/tests/runner-e2e/workflow-security.test.ts +++ b/tests/runner-e2e/workflow-security.test.ts @@ -22,7 +22,10 @@ describe("public repository paid workflow security", () => { expect(manual.match(/test "\$REPOSITORY_ID" = 1170821064/gu)).toHaveLength(2); expect(manual.slice(manual.indexOf(' manual_image:'))).toContain('runs-on: ubuntu-latest'); expect(manual).toContain('repos/$REPOSITORY/git/ref/heads/$TARGET_BRANCH'); - expect(manual).toContain('ref: ${{ needs.authorize_manual.outputs.target_sha }}'); + expect(manual).toContain("ref: ${{ inputs.release_run_id != '' && github.sha || needs.authorize_manual.outputs.target_sha }}"); + expect(manual).toContain('test "$PUBLISH_EVAL_IMAGE" = true'); + expect(manual).toContain('test "$VERIFY_SOURCE" != true'); + expect(manual).toContain('test "$VERIFY_PUBLIC_INSTALL" != true'); expect(manual).toContain('SOURCE_SHA: ${{ needs.authorize_manual.outputs.target_sha }}'); expect([...manual.matchAll(/secrets\.([A-Z_]+)/gu)].map(match => match[1])).toEqual(["GITHUB_TOKEN"]); expect(manual).toContain('pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile'); diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index cc5ecbc1637..667a98a3046 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -1974,10 +1974,10 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( return { root, queryClient }; } - it.each(["paperclip", "legacy"] as const)("qualifies managed Claude during Connect and tests/hire with measured %s configuration", async measured => { + it.each([["paperclip", "image"], ["legacy", "image"], ["paperclip", "snapshot"], ["legacy", "snapshot"]] as const)("qualifies managed Claude during Connect and tests/hire with measured %s configuration from %s", async (measured, source) => { mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!)); mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config: { - provider: "daytona", image: `registry.example.test/runtime@sha256:${"a".repeat(64)}`, + provider: "daytona", ...(source === "snapshot" ? { snapshot: "managed-snapshot" } : { image: `registry.example.test/runtime@sha256:${"a".repeat(64)}` }), } }]); const discovery = (runner: "paperclip" | "legacy") => [ setupAdapterInfo({ type: "claude_local", loaded: true, disabled: false, defaultRunner: runner, supportedRunners: [runner] }), diff --git a/ui/src/lib/qualify-claude-setup.test.ts b/ui/src/lib/qualify-claude-setup.test.ts index 7d4f467acc8..9f6a22276fc 100644 --- a/ui/src/lib/qualify-claude-setup.test.ts +++ b/ui/src/lib/qualify-claude-setup.test.ts @@ -19,6 +19,12 @@ function setup() { } describe("Claude explicit setup qualification", () => { + it("qualifies a named Daytona snapshot through the normal Connect action", async () => { + const s = setup(); + const input: Parameters[0] = { ...s, environment: { driver: "sandbox", config: { provider: "daytona", snapshot: "managed-snapshot" } } }; + expect((await qualifyClaudeSetup(input))?.runner).toBe("paperclip"); + expect(s.probeEnvironment).toHaveBeenCalledTimes(1); + }); it.each(["paperclip", "legacy"] as const)("uses fresh discovery and honors measured %s selection", async runner => { const s = setup(); s.readAdapters.mockResolvedValueOnce(discovery(runner)); diff --git a/ui/src/lib/qualify-claude-setup.ts b/ui/src/lib/qualify-claude-setup.ts index cfa6a2c8731..97a8e4760fe 100644 --- a/ui/src/lib/qualify-claude-setup.ts +++ b/ui/src/lib/qualify-claude-setup.ts @@ -8,10 +8,11 @@ export function needsClaudeSetupQualification(input: { environment?: Pick | null; }): boolean { const config = input.environment?.config; + const immutableImage = typeof config?.image === "string" && /^[^\s@]+@sha256:[a-f0-9]{64}$/.test(config.image) && !config.snapshot; + const daytonaSnapshot = config?.provider === "daytona" && typeof config.snapshot === "string" && !!config.snapshot.trim() && !config.image; return !input.savedAgentId && input.adapterType === "claude_local" && input.runner === "auto" - && input.environment?.driver === "sandbox" && typeof config?.image === "string" - && /^[^\s@]+@sha256:[a-f0-9]{64}$/.test(config.image) - && !config.snapshot && !config.template && !config.templateRef; + && input.environment?.driver === "sandbox" && (immutableImage || daytonaSnapshot) + && !config?.template && !config?.templateRef; } export async function qualifyClaudeSetup(input: Parameters[0] & { diff --git a/ui/src/pages/NewAgent.test.tsx b/ui/src/pages/NewAgent.test.tsx index f1025570a28..4f3949e91a1 100644 --- a/ui/src/pages/NewAgent.test.tsx +++ b/ui/src/pages/NewAgent.test.tsx @@ -10,11 +10,12 @@ import { describe("New agent setup", () => { it.each([ - ["paperclip", "Connect"], ["legacy", "Connect"], - ["paperclip", "Finish setup"], ["legacy", "Finish setup"], - ] as const)("qualifies new managed Claude and saves measured %s defaults through %s", async (measured, step) => { + ["paperclip", "Connect", "image"], ["legacy", "Connect", "image"], + ["paperclip", "Finish setup", "image"], ["legacy", "Finish setup", "image"], + ["paperclip", "Connect", "snapshot"], ["paperclip", "Finish setup", "snapshot"], + ] as const)("qualifies new managed Claude and saves measured %s defaults through %s from %s", async (measured, step, source) => { const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`; - envApi.list.mockResolvedValue([{ id: "sandbox-1", driver: "sandbox", config: { provider: "daytona", image } }]); + envApi.list.mockResolvedValue([{ id: "sandbox-1", driver: "sandbox", config: { provider: "daytona", ...(source === "snapshot" ? { snapshot: "managed-snapshot" } : { image }) } }]); settings.get.mockResolvedValue({ defaultEnvironmentId: "sandbox-1" }); settings.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: true, enableNativeRunner: false }); api.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "saved-oauth", latestVersion: 1 });