From 45b67f1d930457fc8d27b7a7ea43c13bca7658ae Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:58:52 -0500 Subject: [PATCH] feat: default new Apple Silicon Claude agents to Paperclip Runner (#15993) ## Thinking Path > - Paperclip lets users choose an agent harness and account. > - The server resolves the runner for new agents. > - Claude still needs the same ordinary native-default journey as Codex. > - Existing agents must retain their runner, model and account. > - This PR defaults new Apple Silicon Claude agents to Paperclip Runner with Opus 5.5. > - Users can connect and work without a runner override. ## Linked Issues or Issue Description Refs #15683. This PR depends on #15994 and precedes #15997. Related scope: #15422, #13978 and #15224. ## What Changed - Resolve new Apple Silicon Claude agents to `paperclip_runner`, `provider: acpx`, `acpxAgent: claude`. Keep explicit Legacy, models, provider routing and saved execution. - Use `claude-opus-5-5` for ordinary Claude defaults and retain explicit overrides. - Reuse existing API-key/subscription connections, credential storage and checks. Generalize the shared Advanced runner control with Claude branding. - Carry configured effort into the shared v9 contract. Preserve compatible permissions, instructions, skills, timeouts and session behavior. Require Legacy for unsupported settings. - Qualify a new immutable managed Claude target at explicit Connect through the existing permission-gated probe. Use fresh scoped discovery for test and hire. Discard stale results and require an explicit Connect after settings change. - Retain merged Codex onboarding recovery, auth preparation, import/effort corrections and Grok Bot gates. - Check the Claude first-task wizard's saved default directly. Update configuration/runtime documentation and exact Opus 5.5 accounting. - Align hire fixtures with the merged host-environment restriction. Native agent-authenticated hires cannot inject host env settings; both creation endpoints verify that no extra agent is saved. ## Verification - Updated onto master `9179d8dcc3e908c217fde4f63c9d08ccf8132eeb` through #15994. Native input v9 carries effort while preserving v7 home modes and v8 Muse. Codex defaults remain unchanged. - Focused local verification passed 285 server tests, 323 UI tests, 124 setup/registry tests, and two v9 permission-pin transport cases. Direct server/UI TypeScript checks passed. An initial 30-second hook timeout was resolved by serial verification with a longer hook budget: all 122 credential/permission cases and 232 snapshot/environment cases pass. Direct server TypeScript checking also passes. - Ordinary web creation with the experiment disabled saved native Claude, Opus 5.5 and High effort without runner/model overrides. The actual product task and follow-up both succeeded on integrated source `b2c9fe43f9cd3bc5dd9d22552da0148845ba71a4` with the cloud-built daemon from `04e0202ca81387b17cbe7fd094a8338648356fe9`. The final master reconciliation also changes shared runtime source. Historical proof retains its source identity; a final-source cloud qualification is running before refreshing the preview daemon. - Both actual browser downloads were checked: v1 has three sections/six checkboxes (451 bytes); v2 preserves the original body and adds two Rollback checks (612 bytes). The original attachment stayed byte-identical. The follow-up records session resume and the same provider session. Total estimated API usage: $0.518432800, with no pending/unpriced usage in the successful fixture. - Fixed the live-discovered setup incompatibility: native API-key validation no longer depends on the global Legacy Claude CLI. The existing provider endpoint verifies the key without a paid prompt; subscription/custom routing probes remain intact. - Fixed security review's native-hire credential redirection: agent-supplied URLs, proxy/launch settings and other host environment controls are rejected before inheritance. Native credential references remain supported and partial edits use the saved harness. Added create/hire/update regression coverage. - Current-head cloud CI is green. All three stack PRs have fresh Greptile 5/5 reviews and zero unresolved review threads. Required typecheck, test and build gates ran in cloud CI; Docker work stayed remote. Earlier failed checks remain in their original runs. - Docker and native release builds ran in cloud infrastructure. An inadvertent local Rust prerequisite was stopped; no local Docker build was run. - The final master merge preserves task-loading, permission, skill-retention and OpenRouter changes. Pi uses a fresh combined profile 27, preserving both historical profile-24 declarations; Copilot remains profile 22. Focused protocol checks passed 155 cases (one skipped), the updated Pi attestation check passed 18 (one skipped), and 91 evaluation/catalog checks passed. - Latest-application manual continuation passed in 37.942 seconds on `695e5c005a85bddc8711d3fcddcaf6b7e1fb64d0`, with High effort, native Opus 5.5, Anthropic API attribution, the same resumed Claude session, an independently checked 635-byte file and persisted Done status. It uses the earlier verified Mac daemon; the preceding-source package qualification passed at `dca62c7aeb4b45f0dd91a8e18d386a16a2658ef8`. That package run predates the small TypeScript startup fix and is not relabeled as qualification for it. - Upgrade limitation: changed compiled sidecar identity rejected old durable launch state and recovery hit session-open bounds before eventually resuming. That attempt was operator-cancelled and retained; the successful manual continuation does not qualify seamless upgrades. - Local latency profile, Opus 5.5/High unchanged: three alternating real-ACP loopback samples per variant reduce median provider-ready time from 5.081s to 4.034s. The matched real API task startup improves from 5.533s to 4.348s, but total reply time changes from 17.933s to 20.221s because agent work varies. This establishes a startup saving of about one second, not a consistent total-reply speedup. A real candidate follow-up passed in 20.146s and resumed the same saved session. The loopback fixture makes no external model requests; failed initial probes remain retained. - The startup change passes 107 focused runtime/effort/lease tests, 53 profile checks (one skipped), TypeScript checking and the TypeScript-only runtime build. Native hire fixture checks pass 70 tests; their first local run retained a socket-hangup failure and stale OpenCode expectation, both accounted for before the passing rerun. Broader required checks run in cloud CI. ## Risks - Missing packages, authentication rejection, unsupported settings and unavailable models must fail explicitly. Existing agents retain saved execution; explicit runner changes use existing revision/session invalidation. - Subscription follow-up remains unverified; remaining live acceptance uses API keys per human steering. Historical accounting holds and the separate $50 ceiling remain intact. - The subscription acceptance limit remains explicit for review. No Claude database migration is introduced. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, repository tools, code execution and browser testing. The exact serving model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] Focused local tests and final cloud verification pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .github/workflows/docker-runner-check.yml | 7 +- doc/DEVELOPING.md | 20 +- doc/evals.md | 5 +- doc/plans/2026-10-12-claude-runner-default.md | 1633 +++++++++++++++++ maps/agent-access-map/agents.md | 6 + maps/agent-access-map/runs-adapters.md | 7 +- maps/feature-map/agents.md | 10 +- maps/feature-map/onboarding.md | 12 +- maps/feature-map/runs-adapters.md | 10 +- .../src/paperclip-runner-permissions.ts | 2 +- packages/adapter-utils/src/types.ts | 1 + .../adapters/claude-local/src/index.test.ts | 6 +- packages/adapters/claude-local/src/index.ts | 2 +- .../claude-local/src/server/acp.test.ts | 2 +- .../src/server/execute.acp-fallback.test.ts | 2 +- .../execute.max-turns-classification.test.ts | 12 +- .../src/server/execute.remote.test.ts | 28 +- .../src/server/test.probe.test.ts | 34 +- .../src/server/test.remote.test.ts | 4 +- .../claude-local/src/ui/build-config.test.ts | 14 + .../claude-local/src/ui/build-config.ts | 7 +- .../codex-local/src/ui/build-config.test.ts | 17 +- .../codex-local/src/ui/build-config.ts | 13 +- packages/shared/src/agent-runner.ts | 7 +- .../agent-adapter-validation-routes.test.ts | 8 +- .../agent-hire-ai-connections.test.ts | 29 +- ...agent-hire-auth-inheritance-routes.test.ts | 32 +- .../agent-permissions-routes.test.ts | 24 +- .../src/__tests__/agent-skills-routes.test.ts | 34 +- .../agents-claude-oauth-binding.test.ts | 22 +- .../__tests__/ai-connection-router.test.ts | 3 + .../claude-local-adapter-environment.test.ts | 3 + .../__tests__/claude-local-execute.test.ts | 14 +- .../src/__tests__/company-portability.test.ts | 6 +- server/src/__tests__/redaction.test.ts | 1 + ...teams-catalog-install-no-overrides.test.ts | 8 +- server/src/adapters/registry.test.ts | 31 +- server/src/adapters/registry.ts | 43 +- .../__tests__/lifecycle.test.ts | 2 +- server/src/redaction.ts | 1 + server/src/routes/agents.ts | 16 +- server/src/services/agent-credentials.ts | 3 +- .../services/agent-runner-selection.test.ts | 84 +- server/src/services/agent-runner-selection.ts | 80 +- server/src/services/ai-connection-router.ts | 3 +- server/src/services/anthropic-pricing.test.ts | 8 +- server/src/services/anthropic-pricing.ts | 20 +- server/src/services/built-in-agents.ts | 9 +- server/src/services/company-portability.ts | 4 +- .../heartbeat-runner-provider-config.test.ts | 10 + .../services/heartbeat/runtime-selection.ts | 3 +- .../native-runtime/acpx-qualification.test.ts | 2 +- .../native-execution-input.test.ts | 24 + .../native-runtime/native-execution-input.ts | 16 +- .../native-session-executor.test.ts | 14 + .../native-runtime/native-session-executor.ts | 13 +- .../native-runtime/provider-profile.ts | 35 +- .../native-runtime/runtime-mode.test.ts | 4 + .../services/native-runtime/runtime-mode.ts | 2 +- server/src/services/secrets.ts | 3 +- server/src/vendor/paperclip-runner/index.ts | 2 + skills/paperclip-create-agent/SKILL.md | 13 +- tests/e2e/acp-stop-continuation.spec.ts | 2 +- tests/e2e/legacy-failure-continuation.spec.ts | 2 +- tests/runner-e2e/README.md | 36 +- tests/runner-e2e/catalog.ts | 2 +- tests/runner-e2e/first-task-flow.ts | 35 +- tests/runner-e2e/installed-cli.test.ts | 13 +- tests/runner-e2e/runner.spec.ts | 3 + ui/src/adapters/adapter-display-registry.ts | 3 +- .../codex-local/config-fields.test.tsx | 7 +- ui/src/adapters/codex-local/config-fields.tsx | 2 +- .../AgentConfigForm.render.test.tsx | 123 +- ui/src/components/AgentConfigForm.tsx | 72 +- ...RunnerSelect.tsx => AgentRunnerSelect.tsx} | 2 +- .../components/ConfigureBuiltInAgentModal.tsx | 14 +- ui/src/components/NewAgentDialog.test.tsx | 2 +- .../components/OnboardingWizard.step.test.tsx | 4 +- ui/src/components/OnboardingWizard.test.tsx | 315 +++- ui/src/components/OnboardingWizard.tsx | 148 +- .../new-agent/AgentBasicsDialog.tsx | 2 - ui/src/components/new-agent/NewAgentSetup.tsx | 114 +- .../task-chat/ComposerRunSettingsPicker.tsx | 4 +- .../task-chat/composer-run-settings.ts | 6 +- ui/src/lib/qualify-claude-setup.test.ts | 67 + ui/src/lib/qualify-claude-setup.ts | 48 + ui/src/lib/test-agent-setup.test.ts | 8 + ui/src/lib/test-agent-setup.ts | 6 +- ui/src/pages/CompanyImport.test.tsx | 81 +- ui/src/pages/CompanyImport.tsx | 54 +- ui/src/pages/NewAgent.test-support.tsx | 205 +++ ui/src/pages/NewAgent.test.tsx | 415 ++--- ui/storybook/.storybook/preview-base.tsx | 20 +- 93 files changed, 3718 insertions(+), 587 deletions(-) create mode 100644 doc/plans/2026-10-12-claude-runner-default.md rename ui/src/components/{CodexRunnerSelect.tsx => AgentRunnerSelect.tsx} (97%) create mode 100644 ui/src/lib/qualify-claude-setup.test.ts create mode 100644 ui/src/lib/qualify-claude-setup.ts create mode 100644 ui/src/pages/NewAgent.test-support.tsx diff --git a/.github/workflows/docker-runner-check.yml b/.github/workflows/docker-runner-check.yml index d6a2115efb9..ae00c65df35 100644 --- a/.github/workflows/docker-runner-check.yml +++ b/.github/workflows/docker-runner-check.yml @@ -173,16 +173,19 @@ jobs: - name: Verify repository on hosted Linux if: inputs.verify_source # Leave time for artifact retention within the job deadline. - timeout-minutes: 38 + timeout-minutes: 80 run: | set -uo pipefail pnpm install --frozen-lockfile --ignore-scripts + git fetch --no-tags --depth=1 origin 2a8a99e4a5f69aa803b3f10b982f583e75a87042 16ef1a5744b23bb043a8b286e6288921da1de98f status=0 for check in 'pnpm -r typecheck' 'pnpm test:run' 'pnpm check:token-gates' 'pnpm test:e2e:runner:typecheck' 'pnpm test:e2e:runner:unit' 'pnpm build' 'if [ -f scripts/verify-grok-npm-install.mjs ]; then node scripts/verify-grok-npm-install.mjs; fi'; do label="$(echo "$check" | tr -cs 'a-zA-Z0-9' '-')" echo "Starting $check" check_status=0 - timeout --signal=TERM --kill-after=15s 15m bash -c "$check" > "remote-verification/$label.log" 2>&1 || check_status=$? + limit=15m + if [ "$check" = 'pnpm test:run' ]; then limit=60m; fi + timeout --signal=TERM --kill-after=15s "$limit" bash -c "$check" > "remote-verification/$label.log" 2>&1 || check_status=$? printf '%s\t%s\n' "$check_status" "$check" >> remote-verification/check-status.tsv if [ "$check_status" -ne 0 ]; then status=1; fi echo "Finished $check (exit $check_status)" diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index ab3093c2ce1..af54d1b28e0 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -1790,14 +1790,18 @@ agent conversion are outside this playbook. ## Paperclip Runner Adapter Conversion -The experimental Paperclip Runner offers native Codex, OpenCode, and **ACPX -Claude**. Converting an existing Claude, Codex, or OpenCode agent selects its +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 +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. Other adapters require an explicit provider choice. Legacy ACPX Codex agent settings normalize to native Codex on configuration updates and before fresh runs; immutable run descriptors remain readable. The **Paperclip Runner** -experimental setting and company access checks still apply. +experimental setting applies to other native providers. Native Codex and ACPX +Claude do not require it. Company access checks still apply. Agent configuration uses the same section layout across adapters: model and provider belong to **Adapter**, environment variables have their own section, @@ -1825,6 +1829,16 @@ retries stopped. Reinstall or rebuild Paperclip with its provider dependencies, restart the server, and run the agent's runtime test before retrying the task. Changing the model or signing in again cannot repair a missing runtime package. +ACPX Claude uses the existing API-key and subscription connection flows, +including built-in login and credential checks. Connection setup and a successful +native task are separate checks. Thinking effort (`low`, `medium`, `high`, +`xhigh`, `max`, where supported by the selected model) travels in native input v9. +The runner requires the provider to acknowledge it before prompting and on resume. +An ignored setting or unavailable model fails admission; no runner fallback occurs. +Chrome integration, custom commands/state paths, warm-process controls, +confinement settings and turn limits require an explicit Legacy choice. Native +forms omit Legacy-only defaults. Native permissions keep their saved policy. + ACPX Claude supports Linux x64 and macOS ARM64/x64 with pinned SDK executables. On macOS the launcher uses private verified module/executable snapshots instead of Linux `/proc` descriptors. Dependency isolation, process ownership, and diff --git a/doc/evals.md b/doc/evals.md index 4104338f0fd..1268b34a0af 100644 --- a/doc/evals.md +++ b/doc/evals.md @@ -411,8 +411,9 @@ Remaining native chat boundaries are in the explicit-only `agent-chat-qualification` suite: active task reassignment, user Retry after verified worker process loss, and multi-turn answers grounded in actual task records. See the [workflow and qualification limits](../tests/runner-e2e/README.md#remaining-native-agent-chat-qualification). -The 26 native `first-task` cells exercise onboarding before native selection -becomes the UI default. Live results and semantic answer reviews must accompany +The 26 native `first-task` cells inspect the production wizard's saved native +default on qualified targets, without an API runner switch. The 26 Legacy cells +select Legacy explicitly in Advanced. Live results and semantic answer reviews must accompany any qualification claim; catalog presence alone is not a pass. The explicit-only [native question/resume qualification](../tests/runner-e2e/README.md#task-continuation) diff --git a/doc/plans/2026-10-12-claude-runner-default.md b/doc/plans/2026-10-12-claude-runner-default.md new file mode 100644 index 00000000000..f37275435a5 --- /dev/null +++ b/doc/plans/2026-10-12-claude-runner-default.md @@ -0,0 +1,1633 @@ +# Claude default: refreshed plan — 2026-10-15 + +## Master refresh and local test drive — 2026-10-19 + +Current work starts from master `56e09cd19f1757f44e4000cada1f3f2f6b4892a4`, +including the merged startup transport fix. All three original PR tips are saved +under `archive/claude-*-pre-refresh-20261019` before integration. + +| Required outcome | Current evidence | Remaining gap / next action | +| --- | --- | --- | +| Protocol prerequisite #15994 | 369 focused tests plus 72 catalog tests; Runner TypeScript checks/build pass | Fresh cloud CI/review; retain v7 home modes and v8 Muse, carry Claude effort in v9 | +| Apple Silicon default #15993 | 285 server and 323 UI tests; UI typecheck pass | Fresh cloud CI/review; preserve all current-master behaviors | +| 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 | + +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 +remain in cloud infrastructure. Use the existing API key and preserve the $50 +aggregate qualification ceiling, unknown-charge reservations, and disabled paid retries. + + +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. + +## 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`. +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 + +- Codex default on macOS ARM64/x64 and Linux ARM64/x64 via #15683 (`adb69e8f09`). Keep its current platform/libc checks, including the glibc 2.39 minimum and sandbox target qualification. +- Existing native Claude ACPX profile: Claude ACP 0.73.0, SDK 0.3.286. Native Claude is not a new adapter implementation. +- Pinned Claude Linux ARM64 executable admission, including actual digest `0292fa22ac2fd43e16be9d0e511ddd8347280d6e0ebaca744ef5b27e05d8d0f8`, landed in #16372 (`854ab294cc`). Reuse this implementation. +- Network-safe libc detection and asynchronous startup probing (#16333/#16335), plus newer task recovery, repository admission, workspace retention, and snapshot isolation. These must survive integration. +- 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 + +| Work | Current state | Reuse / remaining gap | +| --- | --- | --- | +| Protocol prerequisite #15994 | Open, head dd82936c1d | Reconcile with master; retain only shared changes required for Claude and historical readers. | +| Claude behavior #15993 | Open, head 4ee0d1d444 | Existing selection, UI, auth, effort and resume work; previous CI and review passed, but integration needs fresh verification. | +| Platform qualification #15997 | Open, published bddff3c5e9; local 84426fef60 | ARM/package work and unpublished image artifact consumer. Preserve the work, remove duplicate landed ARM admission changes. | +| Packaged Mac API task/revision | Passed on frozen source 2646b94533, including controller restart and independent files | Historical proof. Repeat relevant acceptance on the master-integrated candidate. | +| Packaged ARM SSH API task/revision | Passed on the same frozen source | Historical proof. Revalidate final remote runtime and selection after integration. | +| Linux x64 API acceptance | Failed before model startup due to image/npm pack mismatch | Build an image from the exact package producer artifacts, then run a fresh task/revision. | +| Subscription | Existing flow retained; complete follow-up acceptance unverified | User redirected this work to API keys. Preserve flow and auth-routing controls; do not claim live subscription acceptance or request another CAPTCHA. | + +The two successful historical journeys remain at http://127.0.0.1:3115/CLAAAAAA/issues/CLAAAAAA-2 and /CLAAAAAA/issues/CLAAAAAA-3. They are not evidence for current master or a new candidate. Do not upgrade their data in place for the new verification run. + +## 1. Reconcile the existing implementation onto current master + +Use the existing PR stack; integrate master forward into its prerequisite, then cascade to behavior and platforms. The fresh checkout above provides the reference baseline. Preserve published history and the image fix. Do not wholesale replace current runtime files with old versions. + +An index-free merge rehearsal against the complete local stack found 25 overlapping changed files and six textual conflicts: + +- packages/paperclip-runner/README.md +- packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +- server/src/__tests__/agent-skills-routes.test.ts +- server/src/__tests__/issue-update-comment-wakeup-routes.test.ts +- ui/src/components/AgentConfigForm.render.test.tsx +- ui/src/components/AgentConfigForm.tsx + +Resolve executable admission using master's landed ARM64 support; preserve newer UI and consolidated test structures while adding Claude cases. Independently review clean merges in native-session-executor, runner transport, heartbeat selection, agents routes, onboarding, and imports: textually clean does not establish correct recovery behavior. + +Review prerequisite scope before carrying it forward: the old branch advances Pi profile 22 to 25 as part of shared config admission. Keep changes demonstrably required for Claude transport and historical compatibility; isolate unrelated provider changes instead of making their qualification a new Claude gate. Do not drop required historical readers merely to reduce the diff. + +Exit: focused selection/UI/protocol/resume checks pass with current Codex behavior preserved; each remaining diff has a clear reason tied to Claude delivery. + +## 2. Prove the local default first + +First implementation delivery enables Apple Silicon only. Other Claude targets retain their current default until their package/execution gates pass. + +- Extend existing centralized resolution to `claude_local` -> `paperclip_runner`, `provider: acpx`, `acpxAgent: claude`. Graduate this profile only from the experimental gate. +- Preserve request-level `runner: auto | paperclip | legacy`, discovery fields, and resolved stored configuration. No migration or bulk rewrite of saved agents. +- Reuse Claude connections, secret storage, selected account, subscription actions, and built-in setup probes. Use the API key from ~/.secrets through the existing connection flow without exposing it. +- Reuse the shared Advanced runner selector with Claude branding. Omit legacy-only defaults from native forms; incompatible custom execution settings require explicit Legacy with a useful error. +- Apply ordinary Opus 5.5 defaults consistently while preserving explicit models, ANTHROPIC_MODEL, and provider routing. +- Preserve effort admission before prompt/resume, permission semantics, instructions, skills, timeouts, workspace settings, cancellation and session continuity. + +Run the smallest faithful isolated local Product E2E case on the integrated candidate. Reuse a compatible verified daemon only if its protocol identity is compatible; any necessary Rust build runs in cloud and only the Mac target is needed initially. Do not begin a four-target packaging campaign to answer a local behavior question. + +Acceptance: ordinary New Agent/onboarding -> Claude Code -> existing API-key connection -> submit with no runner/model override. Inspect saved native/Opus 5.5 config directly, with enableNativeRunner=false. Produce a useful file, independently download it, restart the isolated controller, request a revision, download both versions and compare bytes, then reload task/agent and confirm the same session and persisted settings. The E2E fixture must not patch the runner after onboarding. + +Exit: two successful paid turns, independently verified artifacts, persisted settings, effort acknowledgment, exact controller/runtime identities, and a usable new preview. Failures are diagnosed locally before platform work proceeds. + +## 3. Close compatibility and creation-path gaps + +Use focused controls for: + +| Contract | Required evidence | +| --- | --- | +| All creation boundaries | UI, API/CLI, onboarding, hires/approval snapshots, built-ins, plugins and imports call the resolver. | +| Saved configuration | Ordinary edits, restart, activation, export/import preserve saved Legacy/native and explicit model/provider values. Explicit changes invalidate sessions through existing revisions. | +| Target/adapters | Disabled adapters, external overrides, unsupported OS/libc, qualified sandbox target, remote ARM setup, and unchanged Codex defaults. | +| Auth and isolation | Selected account, company boundaries, secret forwarding, API billing attribution; existing subscription actions still route to Claude. | +| Settings and failures | Effort before prompt/resume; permissions, instructions/skills, timeout, cancellation/cleanup; clear model/auth/package/incompatible-setting errors with no runner fallback. | +| Protocol compatibility | Historical input readers and existing provider controls; no silent effort loss on resume. | +| UI | Shared components, light/dark, narrow viewport and keyboard operation. | + +Update configuration docs, bundled agent-creation skill, feature maps and E2E documentation. Re-run only affected passing cases during iteration; final required checks remain mandatory. + +## 4. Qualify the remaining platforms + +After the local integrated candidate passes, freeze its runtime/package source and build in cloud. Reuse the prior four-target release mechanisms and the image artifact consumer where applicable. + +The observed x64 failure was real: its independently rebuilt image had a different lock/provider-pack manifest than npm. Preserve the rejection guard. The cloud image must consume the same exact-source, digest-verified daemon and provider pack as the public package, then verify both identities inside the resulting image. Do not rerun the old 2646 campaign merely because its command is available; qualify the newly integrated candidate. + +Before dispatch, verify current GitHub workflow publishing access. The previous OAuth token lacked workflow scope and SSH signing failed. If unchanged, this blocks publishing the prepared workflow only; local behavior/reconciliation can proceed. Inspect a successful push before dispatching the updated workflow. + +| Target | Required before enabling its automatic default | +| --- | --- | +| Apple Silicon | Local API pair above, then final packaged runtime verification. | +| Linux x64 | Clean install, matching immutable image and package authority, ordinary selection, API task/revision, independent downloads and continuity. | +| Linux ARM64 | Native cloud build, clean install, standard installed-host/SSH package selection, API task/revision and continuity. No ARM cloud-image production. | +| Intel Mac | Cloud build and clean install/package admission. Attended provider execution is not required. | + +Preserve existing Linux x64 pack behavior and other providers' qualification boundaries. Additional defaults are enabled only after the corresponding evidence passes. Live subscription task/follow-up qualification remains explicitly unverified under the user's API-key steering. + +## 5. Delivery and operating limits + +Keep the approved prerequisite + two delivery boundaries: protocol compatibility, Claude behavior/Apple Silicon, remaining platforms. Reuse existing PRs where their scope still fits. Refresh exact-head CI and review after reconciliation; historical green checks are not current approval. + +Required before PR-ready handoff: pnpm -r typecheck, pnpm test:run, pnpm build, pnpm check:token-gates, applicable cloud package checks, current review 5/5 and resolved findings. Keep expensive builds and all Docker work in cloud. No paid retries run automatically. + +Retain the separate $50 Claude ceiling. The last recorded known rate-card estimate was $3.054638600, excluding unknown charges, with a $1 setup reservation. Reconcile actual current policies/usage before any paid work; unknown charges and failed holds stay reserved. Use fresh bounded fixtures within existing available allocation, never clear old holds to make acceptance proceed. + +Update this record with the integrated source, package/image digests, preview, artifact comparisons, reports, costs and PR checks. Finish with usable previews and reviewable PRs. No merge or production deployment is included. + + +## 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. + +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. + +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. + +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. + +The first integrated local pair passed on `77936b2973`: runs `58291208-459e-4de4-8b3e-5eff25d4e98c` and `37e6164e-98e3-42fe-8847-2ee061629055`, independent 160/217-byte artifacts, high effort, unchanged task/provider/backend session across controller restart, estimate $0.336505200. Its report remains `/private/tmp/claude-master-local-proof-20261015/acceptance.json` and attached to CLA-1. This is preserved historical evidence for that runtime. + +After rebuilding the corrected TypeScript runtime, the new pair passed on `f1dfde5a09`: runs `6bfd4019-4d7e-4be7-948e-b56f56d8ee3e` and `c761ac19-3e5f-4301-bf13-22a1f9ec0d32`. Independently downloaded `claude-v7-checklist-v1.md` is 134 bytes, SHA256 `3161571692c5d08dd80e91ce83f7ff42fb69f70d8f349084114707e27b8b8b82`; v2 is 180 bytes, SHA256 `3fe1b7f05f1f320246691d007576d0d25a40a1e91fd649c2496a8e56d1ddd299`. V1 is preserved. Task/provider/backend identity survived another controller restart; both turns admitted high effort, billed as metered API, and settled without pending/unpriced usage. Estimate $0.351676000. Report `/private/tmp/claude-v7-local-proof-20261015/acceptance.json`, also attached to CLA-2 as `56c7acb8-7079-4b46-a73f-b0b49e21c0e3`; screenshot attachment `615e3de4-38dd-4c0f-be94-0bb05f7b6757`. Task and agent settings survive reload. A fresh read-only browser check also passes light/dark desktop, 390-pixel mobile, and keyboard open/Escape/focus restoration for the shared Runner control. No horizontal overflow or mutations occurred. Screenshots and receipt: `/private/tmp/claude-v7-local-ui-20261015`. + +The local daemon retains verified source `2646b94533bac1eae0d30b11d3774b104eeaf981` and its original manifest. Rust/protocol trees are unchanged; Darwin ARM64 daemon SHA256 is `094e5e809843efc3d16c796ee4271ff11fcb550afb927b3fa0e745d964bcba2d`. Current-source JS was rebuilt locally. No local Docker or Rust builds ran. Local source acceptance is passed; final installed-package proof remains separate. + +The first new cloud campaign [38734513491](https://github.com/paperclipai/paperclip/actions/runs/38734513491), source `64e721e5f5`, was cancelled after review identified the v7 session defect. Its ARM64 Linux, ARM64 Mac and x64 Linux daemon jobs succeeded; Intel and package assembly were cancelled. Those artifacts are preserved and are not final qualification. Following the corrected local pair, [campaign 38738177651](https://github.com/paperclipai/paperclip/actions/runs/38738177651) builds frozen source `bd74bf5059` in cloud workers. The exact-artifact image consumer and final installed Mac/Linux execution follow that successful producer. Live subscription acceptance remains unverified under the user's API-key direction. + +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. + +--- + +# Historical plan and evidence (retained) + +# Claude runner defaults + +Updated 2026-10-12. + +## Outcome and constraints + +Start from `058af8f1b69c5fa3d9bc974b9eab73c141b52823`; branch `codex/claude-runner-default`, checkout `/Users/dotta/.codex/worktrees/670b/paperclip`. New Claude agents eventually default to native ACPX Claude on Apple Silicon, Intel Mac, Linux x64 and Linux ARM64. Codex selection remains owned by the dependency branch. On 2026-10-12 the user explicitly approved preserving its latest Linux ARM64 default from `dcdf09a0f33536fa2b6daaf6e731d921d5711282` during reconciliation. Ordinary Claude defaults become Opus 5.5; explicit models and existing runner selections remain intact. + +Local Apple Silicon API-key task and follow-up are the first gate. Keep existing connection/login tools. Complete core settings and local subscription next, then package/qualify other targets. Two behavior/platform delivery boundaries plus the user-approved protocol prerequisite PR; no merge or deployment. Linux ARM64 covers installed hosts and SSH, not ARM cloud images. Separate $50 Claude ceiling including setup probes, unknown-charge holds and task turns. No automatic paid retry loops. Docker and expensive Rust builds run in cloud infrastructure; never build Docker images on this Mac. + +## Current state + +- Review boundaries: the user approved a separate shared-protocol prerequisite to keep each PR below the repository's 100-file limit. The behavior PR is [#15993](https://github.com/paperclipai/paperclip/pull/15993); the platform branch is `codex/claude-runner-platforms` and remains gated. Protocol prerequisite [#15994](https://github.com/paperclipai/paperclip/pull/15994), initial candidate `2e8d5e906e`, has 44 source files and preserves latest Codex startup changes from `a200495cc7`. Behavior candidate `12f30702d7` includes that prerequisite. +- Review corrections preserve legacy ACP permission aliases, retained native import settings and fixed Claude OAuth claims through native creation. Unknown inherited-property model names stay unpriced. Legacy onboarding cases now explicitly select Legacy; ordinary native Claude onboarding skips unqualified controller targets before provisioning. Focused import52, selection/pricing117, OAuth54, CLI fixture104, shared Pi92, Copilot28 and Pi evidence72 controls passed; final CI/review remains required. +- Shared source attestations are versioned rather than rewriting historical identities: Pi v23 and pending Copilot v18 bind the changed Claude effort sources. Prior declarations remain. Executable closures and platform qualification are unchanged; prior mismatched profiles require session reopening. These new generated bindings require a current-source daemon/package build. +- Local ordinary API-key onboarding, useful task, revision, cold resume with high effort, independent artifact downloads and persistence passed. The active preview is `http://127.0.0.1:3105/CLAA/issues/CLAA-1`. +- The user redirected the remaining live checks to API keys. Subscription login remains supported, but no further attended subscription qualification is required; subscription follow-up is not claimed. +- Shared runner controls passed light/dark, 390-pixel mobile and keyboard opening/closing checks. The walkthrough found native Command/Extra args controls; they are now hidden for native execution. The saved Claude preservation test covers this path. Agent form 111 tests, UI TypeScript and token gates passed. +- Four-target cloud producers and required source checks are running on 9788c05; clean-install consumers are still pending. The existing authorized SSH development host is Linux ARM64 with GNU libc 2.39; qualification will use an isolated directory and preserve its other services. +- Reviewed source chat “Make Paperclip Runner the default”; source worktree remains separate and active. +- Isolated feature branch starts at the exact requested SHA. +- Workspace dependencies installed with pinned pnpm 9.15.4 and lifecycle scripts disabled. +- Local key source exists; no credentials retained here. +- Verified baseline Apple Silicon daemon is available at `/private/tmp/pr2-d522-mac-remote-daemon/darwin-arm64/paperclip-runnerd`; use its receipt and digest for phase 1. +- Local default selection, narrow experimental-gate graduation, Opus defaults, and shared runner UI are being implemented. Claude effort transport and acknowledgment are being implemented; the live milestone used default effort on the unchanged baseline daemon. + +## Remaining gates + +1. Local API-key onboarding/task/follow-up passed; retain evidence while runtime components change. +2. Core settings propagation and preservation paths. The user redirected the remaining subscription acceptance to API-key qualification; subscription follow-up remains unverified. +3. Four-target package and remote proof; enable additional defaults only after qualification. +4. Required checks, final-source review, PRs, inspectable evidence and working preview. + +## Budget + +Ceiling: $50. Two successful API-key task turns and one successful built-in setup probe. Retain $8 for the initial unpriced receipt/setup uncertainty, plus an $8 additional billed-spend allowance with hard stops and no automatic paid retries; total conservative hold/allowance $16. The follow-up estimate consumes $0.269886 of that additional allowance. These are qualification estimates/reservations, not provider invoices. + +## Local API checkpoint + +Source base plus uncommitted feature changes; preview `http://127.0.0.1:3105`, isolated home `/private/tmp/claude-runner-default-qa`. Initial CLI seed and ordinary UI creation both saved native Claude / Opus 5.5. Native experiment was disabled before UI setup and task. Existing setup tools succeeded using the verified SDK CLI 2.1.286 in this instance-only PATH; global CLI 2.1.245 was insufficient for Opus 5.5 and was not modified. PostgreSQL install required local postinstall links/signature repair after lifecycle-disabled installation. + +UI agent `a0e75080-86de-427a-8ac3-3743c6d076ae`; task `CLA-1`, `6ddff45d-9974-4bd6-976a-f586393ed8f5`. First native run `42773dcb-cbb2-4deb-a495-ca5b3f451780` succeeded with `acpx_runtime` and observed model `claude-opus-5-5`. Independent attachment download: 125 bytes, sha256 `839680679537dfcc8700661347623f0fc1eef7a35e4593dd851a1389bbdc14fc`, three requested checklist items. + +The run exposed missing Opus 5.5 pricing. Its immutable receipt remains unpriced, with reported input 15611, cache-write 15599, cached-input 135871 and output 2194 tokens. Preserve an $8 hold. A narrow rate-card addition uses official standard rates 4 / 0.2 / 8 (one-hour cache-write upper bound) / 20 USD per million; first-run independent upper-rate estimate is $0.1958942, not an invoice. Both QA policies blocked unpriced usage and paused after completion. Before the follow-up, retain the $8 hold as explicit policy reservation, allow the known historical unpriced receipt only in these disposable QA policies, keep the $8 additional billed-spend hard stop and no automatic paid retries. Total conservative hold/allowance $16 of the $50 ceiling. No historical receipt is rewritten. + +Checks: 66 selection/model tests, 111 agent form tests, 78 other affected UI tests, UI/server noemit and token gates passed. Native eligibility regression passes; Opus pricing check passes after the provenance formatting correction. The mocked route suite subsequently passed 47 tests with localhost access enabled; the initial timeouts came from sandbox listener restrictions. + + +Follow-up run `60e7f9a2-aff3-40b3-8493-2ede6c59da72` succeeded after a controller restart. The task session was reused and the observed model remained Opus 5.5. Estimated Anthropic API usage: `$0.269886000`. Second attachment is 178 bytes, sha256 `ae6fad75dfa46c4c444ad203dff8029359721e19249c04ec1faba9eb6c3d3341`, preserving the first three items and adding the requested revision. Independently re-downloaded the original after the follow-up and confirmed it remained unchanged. Both runs reached Done; saved agent configuration and native experiment=false persisted across restart. + +Product evidence: [task and both downloadable artifacts](http://127.0.0.1:3105/CLA/issues/CLA-1), [first artifact](http://127.0.0.1:3105/api/attachments/3b1b6033-78b4-4a0b-99ae-de363d7f5071/content?download=1), [revision](http://127.0.0.1:3105/api/attachments/857add4a-ef8c-4146-b9c5-be9affc32565/content?download=1). Sanitized receipt: `/private/tmp/claude-local-api-acceptance.json`. + +Runtime: daemon source `d522822a5daeb72d124360b576b82a8bb52b43f4`, executable sha256 `d0656673f7b6dfa6f2554fac18f3e0b42e2eb10f444b06f6063255335d71aa14`; SDK CLI 2.1.286 sha256 `75e3016e9d2570767b08e43a7467d4817a4f149232c169ca295f2c95fef21433`. The planned effort transport changes require a new remotely built daemon and affected acceptance reruns; they are not covered by this baseline-runtime evidence. + + +## Behavior candidate and cloud checks + +Implementation commit `6fd56c445d` adds the Apple Silicon default, native-input v7 effort admission, compatible settings/preservation, existing authentication routing, and Opus API pricing. Historical v1-v6 readers and Codex/Pi admission remain covered. Focused runner tests passed (197 tests before the additional three admission/recovery tests; the new focused admission/recovery suite also passed 111 tests). Focused server selection/provider projection, UI/import, routing and token gates passed; two stale experimental-gate fixtures were corrected and the New Agent suite then passed 60 tests. Updated server/runner/UI TypeScript checks passed. Required broad checks and the updated daemon run in cloud CI. + +Reconcile the latest Codex dependency (`dcdf09a0f3`, direct descendant of the requested baseline), preserve its newly supplied four-target producer/consumer work, and keep Claude automatic selection restricted to Apple Silicon. No Docker build ran locally. The baseline local API proof remains tied to its original runtime; new effort acceptance requires the cloud-built candidate. + + +Frozen runtime candidate: `5f0b898e9ecef77afefe309a230e04d7fef66cde`. [Cloud daemon and clean-install qualification](https://github.com/paperclipai/paperclip/actions/runs/38273229107) uses the reconciled source's four-target release matrix. [Cloud required-source verification](https://github.com/paperclipai/paperclip/actions/runs/38273238920) resolves the branch to an immutable target under the trusted master workflow. Both are in progress; no passing claim yet. Image publication is disabled. All Docker/Rust compilation is remote. + +Subscription setup is prepared through the ordinary Claude Code wizard. The existing host CLI account is Paperclip Team (`claude.ai`, Team subscription), but the provider browser requires fresh reauthentication after selecting that organization. The user has been asked to complete that sign-in; no credential was extracted from the host or pasted into chat. The API-key preview/artifacts remain available while the subscription draft is pending. + +## Cold-resume diagnosis and corrective candidate + +The remotely built candidate daemon passed all four producer builds, but package assembly failed on a TypeScript error in Claude task-effort projection: runtime selection profiles expose a backend, not provider/agent properties. The projection now reads the persisted adapter configuration. No daemon Rust change is needed for this correction. + +Actual high-effort first tasks succeeded for API-key and subscription agents. Their cold follow-ups rejected session admission before a task prompt. A bounded existing-runtime diagnostic submitted zero prompts/tools and captured only static admission errors and effort options. It identified `session/resume`, newly used by the pinned ACP library, as missing from the configuration acknowledgment guard. The guard now correlates resume acknowledgments just as it does load acknowledgments. Bound effort also persists in the private Claude settings used by cold connections. The corrected diagnostic admitted high effort with `providerTurns: 0`. Focused Claude/Pi/sandbox tests passed 68 tests; focused Product E2E fixture checks passed 91 tests. Server TypeScript passed after the projection correction. + +Failed qualification records remain intact, including their pending-accounting/recovery fences. No historical receipt was overwritten or invented. Automatic approval review rejected a budget-resume action as potentially restarting paid work; the user then explicitly approved bounded resume plus one manual retry. The product refused resume while accounting remained pending, and no provider retry occurred. New disposable fixtures preserve those failures while proving the corrected execution path. This recovery/accounting condition remains a recorded acceptance limitation until resolved. + +Current aggregate company policy caps: original QA $17 (including an $8 unknown-charge reservation), old candidate $9 (retaining its failed-start reservation and known spend), API-effort acceptance $16, fresh subscription acceptance $8; total $50. Agent policies share their company allocations. Timer heartbeats are disabled, no automatic paid retry campaign is enabled, and only the explicit small acceptance turns are authorized. Reservations and estimates are not invoices. Known API estimates before the final pair: original follow-up $0.269886 and candidate first task $0.1906868; the original first receipt remains unpriced. + +The first subscription artifact was independently downloaded and matched the exact 153-byte oracle, sha256 `ef76358a83a31dcb5759ee4384b92fa7a0ce1801b093352e5e6a548e0fbb7512`. Its run used `acpx_runtime`, observed Opus 5.5, and recorded `subscription_included`. The new API wizard saved native Claude, Opus 5.5, and high effort without a runner override; its 137-byte first artifact matched sha256 `fe24122962056946d5d01f693342c86fd83e22dbb21d6d0bc20d35f220f7952c`. Updated-runtime follow-up acceptance is still in progress. + +Cloud source verification retained failures rather than claiming a pass: the projection TypeScript error; a 15-minute full-suite timeout; stale Pi catalog identities inherited from the dependency branch; installed-startup fixtures exposed to pnpm's NODE_PATH; and absent historical Git objects in the shallow checkout. The corrected fixture expectations preserve current Pi qualification boundaries and explicitly test rejection of ambient Node injection. The cloud source-check step now fetches only the two exact historical fixture trees and gives the full test command a bounded 60-minute slot inside an 80-minute check step. Required checks and clean-install consumers must be rerun on the corrected source before PR-ready handoff. Docker and Rust builds remain cloud-only. + +## API-key acceptance after the resume correction + +The user redirected remaining live execution checks to the API key in `~/.secrets`; the fresh subscription sign-in was closed and its unused company archived. Do not require more attended subscription sign-in for this delivery. Preserve existing connection tools and the prior successful subscription first-task evidence; subscription follow-up qualification is not claimed. + +Ordinary API-key wizard agent `688ff15d-eb48-4cb5-9f31-2d394ab3fbbb` saved `paperclip_runner` / `acpx` / `claude`, model `claude-opus-5-5`, effort `high`, and the existing responsible-user API-key connection. Preview: [successful API task and revision](http://127.0.0.1:3105/CLAA/issues/CLAA-1). Runs `271e0877-c60c-4ac7-aa1a-7b075481872b` and `eb95ed4d-c219-463e-b04a-1bc4ac83c012` both succeeded with `acpx_runtime`, acknowledged high effort, and metered Anthropic API attribution. Estimated costs: $0.194798400 and $0.342168800. The task, provider, and backend session identities all matched across the controller restart; private provider IDs are omitted from the report. Both runs finished accounting. Saved settings and `enableNativeRunner=false` persisted after reload. + +Independent downloads matched both byte oracles, and the original remained unchanged after the revision: v1 137 bytes, sha256 `fe24122962056946d5d01f693342c86fd83e22dbb21d6d0bc20d35f220f7952c`; v2 194 bytes, sha256 `0232a93ccc4da44fd156907b22a64b14487a981fdf12ab9cf73b4ef20d755816`. [First download](http://127.0.0.1:3105/api/attachments/fe58953d-7d1d-4b33-9548-c7611a2e82c6/content?download=1), [revision download](http://127.0.0.1:3105/api/attachments/3a0b5877-5afc-4088-b958-daa3c0b61f61/content?download=1). Sanitized report: `/private/tmp/claude-final-api-acceptance.json`. + +Executable identities: daemon source `5f0b898e9ecef77afefe309a230e04d7fef66cde`, sha256 `228c7814c4786614c8358b205da01cf43df822ca5e6f6b74d5a1e8304d03a6de`; sidecar sha256 `daabddfc1373e6b6d2a754cb60bc4c33d7a8b6d6d5c3564ce511ebfaeb8a463d`; Claude SDK 0.3.286 executable sha256 `75e3016e9d2570767b08e43a7467d4817a4f149232c169ca295f2c95fef21433`. Behavior source is `9788c05d0a9825d825c0461eab3191da6a6dcad7`. Reconciled source `966b3d5bc294442f71c1def9406ed2027f41d191` merges Codex dependency `123a18837478d8cd0e2aff23f7e7aa86c9107de3`, retaining its GNU libc 2.39 gate and Linux ARM64 selection. Claude admission/sandbox/input and daemon Rust sources did not change during that reconciliation; the rebuilt sidecar digest is identical. Updated runner TypeScript build, server TypeScript, 95 selection controls and 15 installed-startup controls passed after reconciliation. + +[Corrected cloud source checks](https://github.com/paperclipai/paperclip/actions/runs/38279356267) and [corrected four-target packaging](https://github.com/paperclipai/paperclip/actions/runs/38279359427) run on frozen behavior source 9788c05. Carry forward only unchanged evidence; final source and package consumers still require passing checks. Active company caps after archiving the unused subscription-only fixture total $42: original $17, failed candidate $9, API acceptance $16. Agent limits share these allocations, and unknown-charge holds remain in place. Known API estimates now total $0.9975406; this excludes the original unpriced receipt and is not a provider invoice. + + +## Approved prerequisite split and current CI + +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. + + +## Review and qualification update after the prerequisite split + +Review stack: [protocol #15994](https://github.com/paperclipai/paperclip/pull/15994), +[behavior #15993](https://github.com/paperclipai/paperclip/pull/15993), +[platform #15997](https://github.com/paperclipai/paperclip/pull/15997). +Before the latest dependency reconciliation, protocol `33c9216614` and behavior +`23d834fc0a` each received 5/5 with their findings resolved. Platform pack parity +was corrected in `4dc13aa32c`; its review still requires refresh. All remain +unmerged drafts pending checks and final qualification. + +Further fixes keep explicit Auto effort cleared through native imports and use +the resolved Claude harness for task model/effort overrides and compatibility +validation, including saved profiles without `acpxAgent`. Builder/provider88, +import52 and server noemit passed. Native v7 answered-question constraints now +point to the compact envelope; native contracts/runtime-context58 passed. The +clean-install Pi probe now binds both v23 and its exact digest. Install-sandbox +controls21 passed. + +Native Linux producer packs previously contained a daemon under `dist/bin` +that was absent from image packs. Both builders now exclude controller binaries +and remote-pack identities before hashing their provider tree. Parity/integrity13 +and image content9 controls passed. Actual rebuilt pack/image identity and paid +remote execution remain gates. No provider boundary was widened by this fix. + +Candidate campaigns `38284813521` (8d07b409) and `38285667342` (d0da37c2) +completed all four daemon producers and both Mac clean-install consumers. +Linux assembly retained package bytes but failed the stale Pi install-probe +assertion, now fixed. ARM host startup exposed the precise Codex prerequisite +error: stock Bubblewrap's network namespace was denied by the host AppArmor +policy. The latest dependency `665c155472` supplies verified stock distro +prerequisites and retains the host restriction values before and after setup. +The stack is reconciled with that branch, including its master fixes and cached +build preparation. Reconciled Pi evidence/installed controls87 passed. + +The cloud ARM pack from d0da37c2 passed GitHub ZIP digest validation, +bounded archive extraction, recorded artifact hashes, relocated Node/Claude +version launches and the shipped no-follow Claude admission probe. ZIP digest: +`9589de44e923b336e6a4b09bacbfd8ae66de1473577197731bc85e8eefd174a0`; +pack identity: `sha256:6e283f3069e38a2f00cd1b752f10d8147f633dc5be4b60e85ae15a1b762bcfd1`; +daemon: `sha256:0adbf14efb2b5b2826044aa0f55f50a0a779d0f5751d7a7a2526f494ffb9e1bc`. +The pack is installed at the authorized SSH host's standard runtime location; +an ownership receipt records this disposable qualification installation. No +existing pack was replaced. These software checks submitted zero prompts and +used no API key. Pack-parity changes require a new identity before final proof. + +The hash-verified 8d07b409 public packages were installed without lifecycle +scripts into `/private/tmp/claude-installed-8d07-preview/consumer` for an actual +installed preview. This is preparation, not a passing installed task claim. +The existing successful preview/data and all unknown-charge holds remain. +No additional paid turns occurred during this review/packaging work; aggregate +active company caps remain $42 of the $50 Claude ceiling. Remaining live checks +use API keys per the user's steering. No Docker or Rust build ran locally. + +## Installed API-key acceptance and latest review corrections + +The installed 8d07b409 public package preview is now usable at +[CLAA-2](http://127.0.0.1:3105/CLAA/issues/CLAA-2). Runs +`11155946-5239-4127-9c67-ff23acf961c2` and +`5f0e4191-9dd6-44ef-bc0a-0b66a910ec2c` succeeded using the saved API-key +connection, Opus 5.5, admitted high effort, and normal installed daemon +resolution with no runner override or ambient Claude credential. Accounting +completed: estimates $0.197602000 and $0.136576400. Known estimates now total +$1.331719; unknown-charge reservations and aggregate $42 caps remain intact. + +The controller restarted between turns. Task, provider and backend session +identities matched privately; IDs are redacted from the sanitized report +`/private/tmp/claude-installed-api-acceptance.json`. Agent settings and the +completed task persisted after reload with the native experiment disabled. +Independent downloads matched v1 (168 bytes, SHA256 +`858f0438f048937dcd2e357a77f77e058582968215d33d5857637cd37fcfdb94`) and +v2 (222 bytes, SHA256 +`4efd87a1f3a9d946d4278906bb779c4e3c0c83ec92faafdc29b9d6da28cada6d`); +v1 remained unchanged. [v1 download](http://127.0.0.1:3105/api/attachments/4fa30ccf-f6bd-47a5-83f3-564c1b16a138/content?download=1), +[v2 download](http://127.0.0.1:3105/api/attachments/af0f2c2e-a0f1-4138-980e-f93f5b48d2cf/content?download=1). +Installed daemon SHA256: +`36bbf4d878bfaa23e2fcec443e117cf04f46006ff8815c64855491b7bd3e440f`; +sidecar SHA256: +`ca6cad1f9b2fb2822d940d281a383360076c2ac03ea2ac875e64662f4719ae13`. +This source predates subsequent review fixes; final package qualification and +subscription follow-up are not claimed. + +Latest behavior corrections preserve plain environment model bindings, reject +unresolved default/model-routing bindings, retain native translation when an +import explicitly switches to Legacy, and clear every effort alias on an +incompatible model change. Selection/import/form controls: 271 passed; server +noemit and token gates passed. Current historical instruction admission: 6 +passed. Platform `33077bed73` pins producer Node to the image's 24.21.0 version; +its narrow policy exception and workflow controls passed (71 tests). + +[Four-target campaign](https://github.com/paperclipai/paperclip/actions/runs/38293566808) +and [cloud image build](https://github.com/paperclipai/paperclip/actions/runs/38293576503) +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. + +## Four-target packages and SSH launch repair — 2026-10-12 + +The frozen `557c3064a30779e86e1ae312eab3d1d42c3a8b8d` four-target +campaign above completed successfully, including all daemon producers, Linux +x64 public installation, both Mac consumers, and the native Linux ARM64 +consumer. Consumer reports record clean npm installation and zero provider +calls. The raw packaging manifest's `publicInstallationVerified: false` +precedes those consumer checks; it is not rewritten as execution evidence. + +The cloud image campaign succeeded with immutable image +`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:d9dbda3a9435e3e555f431bdeb0ba22e1241ae9103184e6c1646a200e26423e1`. +Independent native/image Linux x64 manifest comparison matched pack +`sha256:97f44524d1b35ed6295c922f6b52ac376960213be0a0e09a3635c281db26aecf`, +Node `sha256:7fde7b8afa198da66257f42ee2001d874c7355631e6d1579a5fb5ef1f246df4c`, +and distribution `sha256:83aafb7d6c951c1fa31c0d8bbbfd32c39b4d265b01607cd566c55c8d9b4a6143`. +The installed ARM pack is +`sha256:51fe734d79211b474ee13f53080ac60f07cdc9315ebd33b703ecf4a7dc0f8ce1`. +The new existing-consumer Claude SDK admission hook passed 22 controls and a +real installed Mac no-prompt probe. The earlier frozen campaign predates this +hook; its Intel report does not certify that new hook ran. + +The isolated preview now uses the real 557 public package with two recorded +controller overlays: the narrow existing Claude ARM setup allowlist and the +shared SSH argument repair. Normal installed daemon/package resolution stays +in use. The source is not misrepresented as an unmodified installed package. +The user-directed remaining acceptance uses the API key in `~/.secrets`; +subscription follow-up remains unverified. + +ARM attempts `5ec7bc81-8023-4b5c-84c0-1886224ac4fa` and +`ebb8a76b-60d8-4881-a6ad-e60b6604ca9c` failed before provider startup. +The first lacked a remote WSS ingress URL. Qualification now uses the existing +explicit direct-WSS transport through a private TLS relay and authenticated SSH +reverse forwarding; both endpoints bind loopback, certificate validation stays +on, and protocol authentication is unchanged. The second exposed the first +causal launch error, `sh: 7: 1: parameter not set`: the shared SSH wrapper had +flattened `sh -c`, dropping positional arguments. Failed records and unknown +accounting holds remain intact; neither failure is reported as a successful +provider turn or zero-cost accounting completion. + +Protocol `b833c609cf2da1aacb6ab29d062e23bb1865624c` preserves argv, applies +environment assignments after `exec`, and validates environment names. +Six regression controls fail before and pass after; 51 shared/Codex controls +and adapter-utils typecheck pass. The authorized cloud host passed both +execFile and stdin/spawn paths with exact literal quoted arguments and zero +provider calls. Private proof: `/private/tmp/claude-ssh-no-provider-proof.json`; +installed overlay: `/private/tmp/claude-installed-557-ssh-overlay.json`. +Module SHA256 changed from +`0b89740ef859923d3bb9cdddc09a836eab8ff9296b986eaee68700f766ea5bae` to +`a7f3ec5effa48d05a8388d77d130449dba52f3693afd8bed04c16378a7c764ae`. + +The stack incorporates latest Codex composed source +`d3916151f65ca08904bda309474a35e4c48c54fe`. The authorized source-chat owner +will lift the identical shared SSH fix into prerequisite +[PR #16028](https://github.com/paperclipai/paperclip/pull/16028) before Codex; +our retained commit will coalesce on later reconciliation. That owner coordinates +normal squash merge order. No dependent PR or production deployment is performed +by this qualification session. + +The failed fresh ARM fixture now retains its complete $1 unknown reservation +under a reduced $1 cap and remains held. A new repaired ARM fixture has a $3 +cap; original $17, candidate $9, and local acceptance $16 caps remain. +Aggregate allocated cap is $46, reserving $4 for Linux x64, within the separate +$50 ceiling. Timer heartbeats and automatic paid retries remain disabled. +Known estimates remain $1.331719, excluding unknown charges. + +[Required cloud source checks](https://github.com/paperclipai/paperclip/actions/runs/38302321706) +passed authorization and await a build worker. Current-source full checks, +latest reviews, useful remote task/follow-up pairs, additional platform defaults, +and final package qualification remain gates. No local Docker or Rust build. + +## SSH cold-controller acceptance — 2026-10-12 + +The first repaired SSH task `9a6c3417-b961-47fb-bd25-06847d82e2e6` +succeeded with a checked file, admitted high effort, and completed API accounting +($0.195194600 estimate). Its follow-up failed before provider startup with +`prior_owner_terminal_state_indeterminate`; accounting completed without a +pending charge. The successful remote turn had already persisted a suspended +verified backup, but two controller migration callers allowed that backup only +for sandbox transport. The existing quarantine and task records remain intact. + +Shared repair `e833e1d6c1cac6017f4effb59fbe0d22f0a9fcf2` allows selected +SSH remote targets to use the same integrity/full-scope/suspended-owner checks. +Two positive SSH regression cases fail before the repair; all 14 sandbox/SSH +backup scenarios pass after, including corrupt, conflicting and active state +rejection. Additional active-owner/full-scope controls bring focused coverage to +16 passing tests. Server typecheck passes. The source owner lifted both shared +repairs into prerequisite `00ac509b0a85336e4948a325dbde1cbf09378572`, composed +Codex `8de43be4cd7e71e7833ea268d9e6e59e77cde381`; our stack coalesces them. + +A fresh bounded [ARM task and revision](http://127.0.0.1:3105/CLAAAAAA/issues/CLAAAAAA-1) +now passed. Runs `3c297604-23b4-4a42-a0af-185af05aad01` and +`09bae36d-32a5-4634-a0b8-0349cf405dc9` succeeded with Opus 5.5, +requested/admitted high effort, metered Anthropic API billing and completed +accounting: estimates $0.219495400 and $0.186328600. The controller boot changed; +task, provider and backend session identities matched privately. Both exact +oracles passed independent downloads after the revision; v1 stayed unchanged. + +- [v1](http://127.0.0.1:3105/api/attachments/e94f7320-1197-4a4a-b27e-4447a9ecc58c/content?download=1): + 166 bytes, SHA256 `6263758fb2009dd6b70df03b1c3aa314a865ba1b2b81d5bda5cc6c079a17b7ab`. +- [v2](http://127.0.0.1:3105/api/attachments/a536415a-91c3-4e7f-8b45-46b09d5520bb/content?download=1): + 220 bytes, SHA256 `b7229581fb2b566485a023ed6b016e556e6bc66f3ab4d1bcf7b81c6abf61f9f4`. +- [Sanitized report](http://127.0.0.1:3105/api/attachments/5db4c71b-cb01-4a91-972d-49bcec527d7f/content?download=1) + and [screenshot](http://127.0.0.1:3105/api/attachments/998dac47-837f-46a9-b821-a77499809031/content?download=1) + are attached artifact work products. Uploading them left the run count at two. + +This proof uses public package source 557 with the recorded setup/argv/cold-resume +controller overlays and the verified standard installed ARM pack. It is not an +unmodified-final-package claim. The cold-resume compiled module changed from +`205e27e8a0dda494585600d87b98545d5601822e5bd77c79505101419719a1b7` to +`1c75aa00142f5657a416debba44717548ad5106a763821ed1f8cb0309167ab71`. + +The prior successful-but-failed-resume fixture is paused with a conservative $1 +allocation; the fresh ARM pair uses $2. Original $17/$9/$16 allocations and the +failed prelaunch ARM fixture's $1 unknown reservation remain. The Linux x64 +fixture has $4, making aggregate allocated caps $50. No automatic paid retries +or timer heartbeats are enabled; remaining acceptance uses API keys per the user. + +## Linux x64 task and cold-controller revision — 2026-10-12 + +The [Linux x64 acceptance task](http://127.0.0.1:3105/CLAAAAAAA/issues/CLAAAAAAA-1) +and follow-up succeeded on the immutable 557 cloud image through the existing +Daytona integration. Runs `ba8ff6f0-d88d-4369-b03e-a88cf2a3dafa` and +`f9675055-6f9f-4ec2-aa7c-31b3a784dffc` used Opus 5.5, acknowledged high +effort and the fixture's selected API-key connection. Accounting completed: +estimates $0.178204600 and $0.135639200. The controller restarted between +turns; private task/provider/backend session IDs matched. Both versions passed +independent exact byte/hash checks after the revision and persisted after reload. + +- [v1](http://127.0.0.1:3105/api/attachments/b7ebc13d-a09f-414f-84fc-a36d0dac9975/content?download=1): + 176 bytes, SHA256 `97d7ddbea0d3d1a1aad23673692ff3719fc83d8b8e7c66da4fc1f70af79d9672`. +- [v2](http://127.0.0.1:3105/api/attachments/e238108d-3a61-42c7-a305-b2199b97a291/content?download=1): + 230 bytes, SHA256 `426e41d0c76b037322aec212c60ece80d91b05ca9064aaab17a7e7950dc9416f`. +- [Sanitized report](http://127.0.0.1:3105/api/attachments/9f619e2f-6bca-4059-9a35-7e1651863395/content?download=1) + and [screenshot](http://127.0.0.1:3105/api/attachments/3eb53a63-f8fc-483e-8b53-18439b87185b/content?download=1) + are attached work products. + +The pinned official Daytona package `2026.1005.0` (manifest version `0.1.8`, +SDK `0.203.0`) is registered from the private qualification installation. +The first existing installer request used its shared package directory and +failed to resolve a version-suffixed package name; no global plugin record was +adopted for this fixture. Shared package files were left intact. Subsequent +installation disabled lifecycle scripts and uses the private directory. +Company-scoped Daytona and Anthropic secrets remain separate, with Anthropic +connection installation restricted to the qualification agent. + +This proof uses the exact 557 installed controller plus its recorded overlays +and image/pack identities; it does not qualify an unmodified final release. +Known Claude API estimates now total $2.2465808, excluding earlier unknown +charges. Aggregate allocated company caps remain $50; holds/reservations and +failed records are preserved. No automatic paid retries or local Docker/Rust +builds occurred. + +The source owner is correcting additional shared review findings and will +provide the next source freeze. Final packaging waits for that freeze. The +platform PR can now apply the additional target default changes using the +passing package/clean-install and API execution evidence, while final packaged +runtime, current-source required checks and exact-head reviews remain handoff +gates. Subscription follow-up remains unverified under the user's API-key +steering; Intel Mac requires clean installation, not attended provider turns. + + +## Current dependency and review reconciliation — 2026-10-12 + +Reconciled source-owner prerequisite `d16eccf109b524778ceb896f79ccb3881bc61a9b` +and Codex `eee0534154eaf5d0d3f2bbc97c1e24d86f34a5a5`. The latest Codex +Linux ARM64 default remains intact. Sandbox automatic selection uses the +server-owned probe bound to effective configuration and an immutable image; +unknown or mutable sandbox identity stays Legacy. Disabled Codex does not block +Claude's SSH platform probe. + +The behavior composition preserves the shared ACP-alias rejection and imports +saved native configuration by applying actual form changes. Explicit empty +model/effort and account-binding removal remain removals. Mounted Codex and +Claude import cases cover ordinary edits, model/effort clears, connection-binding +removal, Legacy selection and harness changes. Switching a Legacy Claude import +to Paperclip preserves deny-all/approve-reads through the native builder. +Current import/builder controls: 123 passed; mounted existing-agent form controls: +121 passed, including hydration and explicit Auto clearing for both harnesses +and all three effort aliases. Server/UI noemit, token gates and capability-map +checks passed. Profile source controls: 31 passed. The Pi install probe now +reports the exact profile version it actually validates. + +Platform review repairs retain explicit supported runner choices while keeping +Darwin SSH automatic Claude selection on Legacy until automatic Mac remote packs +exist. Local Mac defaults and GNU/Linux defaults remain qualified. Discovery +tries every standard preinstalled pack candidate through the full verifier; a +stale `/opt` pack cannot hide a matching user installation. Failed verification +still uses the existing upload/error path. These controls are separate from live +provider execution evidence. + +[Cloud required-source campaign](https://github.com/paperclipai/paperclip/actions/runs/38302321706) +completed on immutable source `b754869376e7bee831e87d879d5d89575044cb04`: +recursive typecheck, build, token gates, Runner E2E typecheck, 124 Runner E2E +unit files (2619 tests), and public installation verification passed. The full +`pnpm test:run` command exceeded its 60-minute bound (exit 124); this is incomplete, +not a passing full suite. Its artifact `11775656103` preserves source, logs and +statuses. Final exact-head checks must use the existing complete sharded test +coverage or finish the full command on cloud infrastructure. + +A read-only accounting audit records allocated caps of exactly $50 and known +ledger estimates of $2.246580800, excluding unknown charges. The original QA +policy retains its earlier approved `allow` setting and $8 unknown reservation +under a hard stop; other live qualification policies block unpriced work. The +historical candidate's $9 allocation and failed records remain reserved. Its +port has a listener but current API health did not respond, so its current policy +state is unconfirmed rather than asserted stopped or reverified. No accounting +hold was cleared by this audit. Timer heartbeats and automatic paid retries remain +disabled. No local Docker or Rust builds occurred. + + +## Complete CI fixture corrections — 2026-10-12 + +The first current-head matrix exposed stale fixture assumptions after graduation: +experimental harness-switch rejection still targeted Claude, Linux SSH creation +still expected Legacy, and Legacy instruction/skill cases relied on an implicit +runner. The gating case now uses Pi; all 56 validation-route controls pass. +Qualified SSH creation asserts native Claude/Opus 5.5; all 92 permission-route +controls pass. Legacy instruction/skill fixtures explicitly choose Legacy. +The native Claude creation/hire matrix now checks preserved skill-version pins +and CEO defaults, while omitting the legacy operational skill supplied by PRP. +All 111 skills-route controls pass. Builder/config controls retain the shared +package and mounted-form evidence rather than a duplicate UI pure-function test +that expected blank effort to restore saved values. The historical missing-form +fixture now deletes the property instead of assigning an invalid typed undefined; +Codex package noemit passes. These corrections make no provider calls and change +no runtime artifact identities. + + +## Final shared source freeze and corrective matrix — 2026-10-12 + +Composed published prerequisite `5a9ce11c49db31440863d633426ebe30bcc850ca` +and Codex `a583630e1c128c6d577b236b7ea9b73cf1be0bdd` without changing +Codex Linux ARM64 selection. The inherited network-admission guard rejects an +effective ACP no-network setting or a deferred policy reference with explicit +Legacy guidance; existing trusted native policy remains separate. The source +owner retains normal merge ownership and will send the actual master merge +identity before final reconciliation. + +Fresh reviews reached 5/5 with no open findings for protocol `66f20c4682`, +behavior `628754f6dc` and platform `0a8677d15c`. The subsequent corrective +composition includes reviewed shared changes and the test-fixture corrections; +its exact-head review/CI remains required. Packaging/assembly controls now pass +91/91 after the inert target JSON copy fix. Composed platform selection, pack, +import and mounted-form controls pass 341/341; server noemit, maps and token gates +pass. Native declarations match the release manifest without regeneration. + +Database-backed lifecycle, connection-pool and controlled catalog cases pass +121/121 on the behavior branch. Qualified platform onboarding/built-in/catalog +fixtures now assert native Claude directly, including preserved Haiku and role +skills. Their local database initialization hit hook timeouts; the existing fresh +cluster fallback skipped those suites. Neither result is an execution pass, and +full cloud execution must validate them. Custom ACP Stop/recovery browser fixtures +explicitly select Legacy for their custom commands/state paths; latest cloud +browser execution remains required. No provider turns or local native/Docker +builds were added by these corrections. + + +## Lifetime ownership and resolved Legacy limit — 2026-10-12 + +The fresh protocol review identified a real ownership violation: sandbox +preparation rewrote active Claude settings before a contender acquired the +provider lifetime lease. A fake-provider/real-lease regression reproduced losing +the active owner's saved high effort and marker when the second opener was +rejected. Repair `4cdb5ec4b38d792b432993a9a4b722090813a8ef` defers the +Claude settings write until after lifetime ownership is acquired and before +provider launch. Cancellation retains the lease until that filesystem write +settles. A later legitimate owner can initialize normally. Host, sandbox and +profile controls pass 104/104; runner noemit and unchanged declaration generation +pass. No provider process was launched by these controls. + +Fresh behavior review also found the Legacy form's default 1000-turn cap was +omitted for Auto even when the selected target resolved to Legacy. The form now +passes a transient resolution to the builder; unknown/Legacy Auto and explicit +Legacy retain the cap, while resolved native omits only that Legacy default. +Explicit custom limits still reach compatibility validation. The hint is absent +from execution config and API overrides. Onboarding uses its existing discovery; +import excludes hint-only updates from meaningful edits. The same builder carries +explicit native permission choices from the ordinary create form. Mounted forms, +builder, imports and onboarding pass 298/298; adapter-utils TS build, Claude/UI +noemit, maps and token gates pass. No local Rust or Docker build or new paid turn. + +The prior corrective platform source `a97a681f1caf90627505097cdcd368356811ffc1` +passed all required cloud checks and fresh 5/5 review. Behavior and protocol +required CI aggregates passed before these two findings. New repair heads require +fresh exact-head CI/review. Prerequisite #16028 is not merged: normal auto-merge +is queued for actual code-owner approval. Dependent branches have not been +retargeted as if master contains that prerequisite. Final packaged runtime and +source reconciliation remain gated; the source owner retains merge ownership. + +## Guided setup and source-attestation correction — 2026-10-12 + +Fresh review reproduced another ordinary creation gap: `NewAgentSetup` supplied +Auto without its discovered runner, causing native Claude to receive Legacy's +1000-turn default and fail compatibility validation. Repair `e2e5c60496` passes +the existing environment-scoped discovery into the transient builder hint. All +63 mounted setup controls pass, including native, Legacy and unknown Auto with +the native experimental flag disabled. UI noemit, maps and token gates pass. +The hint is absent from saved execution configuration and request runner overrides. + +Exact-head cloud runner controls also caught the ownership repair's changed Pi +shared-source closure. Declaration generation alone had not checked that closure. +Protocol repair `0ff31b2b76` publishes Pi profile 24 with command digest +`sha256:d25d674cda9a0853bdbf0521025099f1424c573d2f533630cb67d90e1bfcda3b`. +Immutable profile 23 is retained. Only the profile version and sandbox-source +attestation change; executable closures, models, effort and platform qualification +remain intact. Copilot stays at pending profile 18. TypeScript/Rust declarations, +current installed-Pi probe and historical rejection controls are synchronized. +Profile/factory controls pass 122 with one historical skip; runner noemit passes. + +Host/sandbox controls pass 78/78 on restored, uninstrumented source. Earlier local +expanded runs had one varying kernel-lease acquisition failure each; their logs +are retained, not relabeled as passes. This Mac's listener inventory contained +718 TCP rows. A temporary diagnostic group passed 78/78 and was fully reverted; +occupied-port contention is suspected but not independently proven. Sandboxed +loopback execution additionally failed and is excluded from valid lease proof. +Fresh cloud execution remains required to distinguish infrastructure contention +from a regression. Production ownership checks were not weakened and unrelated +listeners were not stopped. No local native/Docker build or paid provider turn. + +Current platform Darwin SSH guard keeps Auto on Legacy for both architectures; +explicit matching assets and saved native choices remain supported. Both focused +x64/arm64 controls pass. The general review comment cites historical `bfa3a966`, +which predates this guard. Final package qualification will use one frozen +candidate source through the existing provider-free cloud workflow, with tooling +and product identities recorded separately. No publication, deployment or paid +retry is part of that campaign. The actual foundation code-owner approval and +master reconciliation remain external prerequisites for normal merge sequencing. + +The first corrected candidate's cloud Docker-context check independently found +profile 24 excluded by the existing explicit attestation allowlist. The new +fixture now has its own exception alongside immutable prior identities; other +fixtures remain excluded. Cloud job 115053600515 retained the exact ENOENT +failure. No local Docker invocation was used. The next composed heads must pass +this check before candidate package qualification. + +## Completed-discovery onboarding guard — 2026-10-12 + +Fresh behavior review reproduced a separate onboarding race: saved-subscription +verification could start while runner discovery was pending, building an +unresolved Auto configuration with Legacy's 1000-turn cap. Both delayed native +and Legacy cases fail before treatment. Onboarding now enables discovery after +its environment/settings/policy queries succeed and gates manual, keyboard, +saved-account and completed-login advancement on the matching completed result. +The builder receives that resolved value. Target or runner changes invalidate +an in-flight attempt; metadata-only environment refreshes preserve it. Discovery +failure or a missing harness result stops setup and offers an explicit availability +retry, without testing or hiring. No connection/authentication mechanism changed. + +All 173 mounted onboarding/guided-setup/builder controls pass, including keyboard +submission while discovery is pending, both native and Legacy results, failed +availability/retry and obsolete-target verification rejection. UI noemit, token +and map checks pass. Earlier subscription/account/login controls remain intact; +the prior environment-refresh fixture now changes metadata rather than changing +its target ID, and a separate two-target control proves actual invalidation. + +Protocol source `3aaf1f8b1e1e525378f0f2330fd5453a3ac1c252` has 53 successful +current-head cloud checks, including Rust, static checks, both runner unit shards +and Docker-context integrity, with fresh 5/5 review. Earlier local varying lease +failures remain recorded. Behavior/platform need fresh gates after this UI guard; +no final candidate package campaign has been dispatched and no paid turn added. +The retained installed preview has been inspected in light/dark mode with the +original theme restored and agent Save disabled. The browser's requested mobile +override did not affect its actual 1150-pixel viewport, so that attempt is not +mobile proof and its override was reset. Final responsive qualification remains. + +## Warm metadata, dependency recovery and owned startup — 2026-10-12 + +Three warm-cache regressions reproduced configuration construction while required +metadata was unresolved. Onboarding now waits for current environment, settings +and policy reads as well as matching runner discovery, including refetch over +cached successful data. Each prerequisite failure is visible and Retry setup +refetches the required reads without testing or hiring early. This also closes +fresh review 4253377849. The neutral-role step fixture's empty discovery response +reproduced the exact cloud failure; it now supplies valid Legacy discovery while +retaining role/name assertions. All 193 wizard/step/guided-setup/builder controls, +UI noemit, maps and token gates pass. Exploratory guided-connection changes were +removed; its existing connection flow remains intact. + +Cloud platform job 115058187599 exposed ENOTEMPTY during late-bootstrap cleanup. +A deterministic held-writer test proves transport close returned before owned +credential preparation settled. A separate production-probe test proves the +helper called cleanup-confirmed during that unconfirmed preparation. These are +actual ownership defects, not a waived filesystem retry. Protocol repair +`463ebb62661d81ef1fbd79ae0a7b43ece68830f8` tracks startup, joins it within the +existing close budget and blocks later activation/launch. A drain timeout retains +state; late registration retires only its owned route. Primary setup timeout and +unconfirmed cleanup both remain in the failure. Six no-provider controls pass, +including writer drain, bounded retention, late release/error and the actual +probe callback fence. Runner noemit, declaration generation and maps pass; no +provider process, local Rust or Docker build was used. Historical Pi/Copilot +identities are unchanged by this transport-only correction. + +The repair is in this chat's protocol worktree, not the frozen upstream #16028. +Its placement must be reconciled with the source owner's normal merge sequence +before either default rollout merges. Automatic approval review rejected cross- +chat status messages because it did not accept the earlier approval retrieved +from the source chat as current-chat authorization; the direct coordination +question remains pending. Public PR/evidence updates continue within this task. +Fresh exact-head cloud runner/native execution is required, including the failing +late-bootstrap case. No package campaign or new paid task has been dispatched. + +## Current cloud ownership proof and durable test envelope — 2026-10-12 + +Behavior `be0e68872fd5e043bc6242e0634471b2c1794336` passed all 54 current +checks plus two conditional skips, required verify/E2E and fresh 5/5 review. +Protocol `463ebb62661d81ef1fbd79ae0a7b43ece68830f8` and platform +`66925e79c9d9e8f7e51fd9ee5a518a778d34acf8` passed the previously failing +real late-bootstrap case and the new ownership controls. Their remaining runner +lane failure was a different fake-transport durable-resume case: it reached the +outer 5000 ms test timeout (1599 passed, one failed, three skipped). There was no +ENOTEMPTY or primary semantic assertion failure in those runs. The exact cloud +latency stage was not recorded and is not labeled an infrastructure flake. + +That fixture deliberately includes a 500 ms killed turn, a resumed turn with its +own 5000 ms bound, and durable filesystem writes. A controlled 3800 ms resumed- +turn delay (within the existing turn bound) reproduces the outer 5000 ms timeout; +the identical treatment completes its semantic/usage/deduplication assertions +with a 15000 ms outer budget. The temporary delay was removed. Only the outer +fixture deadline and its rationale are committed; internal deadlines and all +assertions remain unchanged. The full fake-transport suite passes 72 with one +existing skip; runner noemit and maps pass. Fresh exact-head runner CI remains +required after this test-only correction. No package campaign, paid provider +call, local native build, Docker build or upstream prerequisite edit. + +## Guided creation with refreshed settings — 2026-10-12 + +Review 4253594222 on behavior head `4440df2e44e346080308e40ec3287c94c7f776a2` +identified stale cached environment selection during a background instance-settings +read. The mounted reproduction actually calls setup against cached SSH/Legacy +before the pending current settings select local/native, rather than merely +observing a loading label. Guided Claude/Codex setup now requires current completed +environment, settings, execution policy, experimental policy and matching Auto +runner discovery reads before testing or hiring. Existing connection controls stay +mounted inside a disabled fieldset while refreshing, preserving an API-key draft. +Both the cached SSH-to-local case and same-environment discovery refresh pass; +all 65 guided setup controls, UI noemit, token gates and structural maps pass. +Logs: `/private/tmp/claude-guided-current-settings-before.log`, +`/private/tmp/claude-guided-current-settings-draft-controls.log`, and +`/private/tmp/claude-guided-current-settings-final-{types,tokens,maps}.log`. +This repair is prepared locally while the source owner integrates the common +transport correction; published behavior head remains the literal 4440 revision. +No paid turns, local native builds or Docker builds were performed. + +Platform head `173a73ec95386d7b9eac50d87699a16ec27da3b3` failed a credential +fixture during its two-port reservation (EADDRINUSE before the production staging +call). The existing bounded three-port fixture reservation helper is extended to +hold two candidates, retrying fixture preparation only and never a production +staging call. A foreign-collision control verifies that foreign ownership survives, +the auth sentinel stays unchanged, and preparation stops within eight attempts. +All 42 credential controls and runner noemit pass locally after supplying the +worktree's declared tsx test loader. Logs: +`/private/tmp/claude-quorum-fixture-complete-controls.log` and +`/private/tmp/claude-quorum-fixture-final-types.log`. This test-only correction +is prepared in the protocol worktree and has not been published during the parent +integration hold. The independent serialized issue-route timeout remains under +phase diagnosis; a subsequent full mocked suite passes 36 controls but does not +waive the cloud failure or prove its latency cause. Final source composition, +required CI and the provider-free cloud packaging campaign remain pending. + +## Serialized route fixture initialization — 2026-10-12 + +The failing inherited-pause cloud case timed out at its 15000 ms outer test bound. +Temporary local phase instrumentation measured cold route imports at 9741 ms +(and 11920 ms in a separate diagnostic attempt), followed by a POST 409 response +in 11 ms and PATCH 409 in 4 ms. The subsequent full mocked suite passed 36 cases; +these measurements do not establish the exact unrecorded cloud latency. Route +module loading now runs in beforeEach after the same per-case module reset, +unmocking and mock initialization. It uses the existing 30000 ms setup-hook bound; +the 15000 ms request/test bound, application construction, policy assertions and +mutation/wakeup checks are unchanged. All 36 treatment controls pass. Temporary +phase instrumentation was removed. Evidence: +`/private/tmp/claude-pause-route-phases.txt`, +`/private/tmp/claude-pause-route-targeted-phases.log`, and +`/private/tmp/claude-pause-route-hook-controls.log`. Exact-head cloud verification +is still required; no timeout is waived and no provider or native build was run. + +## Mobile retained-preview inspection — 2026-10-12 + +The in-app browser's viewport override did not apply, including to a fresh tab, +so it supplies no mobile acceptance evidence. The existing Product E2E +`openConnectionBrowser` helper then opened a private temporary Chromium profile +against the retained preview. At an actual 390×844 viewport, light and dark +runner menus fit within the document's 390 px scroll width. Enter opens the +Runner listbox with native/Legacy choices; Escape preserves the saved native +choice. Screenshots fast-forward CSS color transitions. Mutation requests were +blocked by the test browser (none attempted); no provider prompts or setup probes +were sent. This is a bounded UI inspection, not a catalog workflow grade. + +Evidence remains tied to compiled source +`557c3064a30779e86e1ae312eab3d1d42c3a8b8d` with the recorded controller +overlays, and must be repeated against final installed packages. The older +preview's Linux Legacy default label is not evidence for the later platform Auto +change. Report, light screenshot and dark screenshot are attached to the completed +isolated x64 task as work products, with independently downloaded SHA-256 matches. +The task stays done and the agent stays idle. The Product E2E helper's local source +is `42e5d9bae436254bba6747e7d26be0cce13b3d0c`. +Report attachment: `e6206748-a785-4c02-9bbf-f4db8a3b22f9`, work product +`e7f6a023-e754-424b-bdc5-eb4087a79172`; light attachment +`7c324aa1-d1c0-45cd-b314-c3106cac302d`, work product +`e81b6b06-e64d-4ed5-aca7-c936a5235c81`; dark attachment +`2815f32b-86d5-48ed-a0f4-7e35554e54ca`, work product +`6fb9753e-19e0-496f-bf0a-f2de4d331895`. +Local receipts: `/private/tmp/claude-responsive-preview-evidence.json`, +`/private/tmp/claude-responsive-evidence-upload.json`, and +`/private/tmp/claude-responsive-evidence-independent-check.json`. + +## Canonical composition and current contribution rules — 2026-10-12 + +The source owner committed the stronger shared startup repair as +`ce23716503a2c9158b5a7ab74ab61571ff4ee7a3`. Local additive merges preserve +that parent and this chat's Claude effort/resume changes. Eight controls that do +not need native executables and runner noemit pass in the combined protocol. +The ninth selected readiness-cycle case timed out while waiting for readiness; +read-only resolution confirms both the default runner artifact and fake provider +fixture are absent, with no private runner override. That native case remains a +hosted CI gate, not a pass. No local native build was performed. Logs: +`/private/tmp/claude-canonical-startup-composition-controls.log`, +`/private/tmp/claude-canonical-startup-no-process-controls.log`, and +`/private/tmp/claude-canonical-startup-composition-types.log`. + +The source owner then reconciled current master in `9f59ff36cf`, adding the +Storybook catalog and contribution rules against new code comments and code files +crossing 1000 lines. The guided-creation fixture is extracted to +`ui/src/pages/NewAgent.test-support.tsx`; all 65 cases remain and pass, with UI +noemit and token gates passing. Fixture component rendering stays inside its +mock-owning module, including the saved-confirmation reload case. The active test +file no longer crosses the threshold. Our added explanatory comments are removed; +the source owner handles the common transport's comment cleanup. Semantic code +and internal deadlines stay unchanged. + +Removing this chat's added sandbox comments changes its raw source attestation to +`144d62b3415cfedf272b5b052e14af1e41fe50a644af84247fc9c20c7ef7edde`. +Immutable Pi profiles 23 and 24 remain retained. New Pi profile 25 binds only that +source revision plus its profile version, digest +`sha256:dd67387487dadb08da76534c057dc0aa059f7698d1779cbd5fc452813828167a`. +SDK/executable closures, platforms, model/effort contract and other providers' +qualification boundaries do not change. Copilot remains pending profile 18. +Generated TS/Rust descriptors, Rust current/prior fixtures, Docker context and +current documentation are synchronized. All 119 profile/factory/admission/effort +controls pass with one existing historical skip, including corrupt-byte rejection +before spawn/bridge; runner noemit and generation checks pass. The first restricted +attempt stopped at loopback lease admission and is retained, not counted as byte +validation. Native/Rust execution and final package qualification remain cloud gates. + +The maintain-storybook source review found concrete fixture incompatibilities: +the general-settings endpoint and scoped runner-discovery defaults were absent. +The shared preview now returns that settings contract and explicitly simulated +Legacy discovery for Claude/Codex, exposes the loaded native adapter, and includes +Opus 5.5 while retaining explicit older-model examples. These fixture defaults are +not rollout qualification. Catalog fingerprints/classification and affected story +rendering must be reconciled after the final parent is frozen; source-only review +is not an executed Storybook or provider pass. Published draft heads remain fixed +while the source owner finishes the canonical contribution/catalog cleanup. + +## Published canonical parent and later master blocker — 2026-10-12 + +The canonical prerequisite is published as +`64c074fb8da1b31f8d6042c4fef48dc4164e759b`; all 54 exact-head checks pass +with four conditional skips, fresh 5/5 and all review threads resolved. Its hosted +native transport suite passes 246/246, closing the fake-native-fixture limitation +for that literal source. Its four provider-free producers also pass, while clean +consumers remain in progress. Those checks are not relabeled as this chat's Claude +input/profile composition. Root retains sequential normal merge/staging ownership; +code-owner approval is still required and production is not promoted. + +Local composition preserves the source repair with no duplicate lifecycle delta: +the transport diff against 64 contains only Claude effort additions. The old Codex +base branch `1a0ca66242121e39d88455aa2b52a07acc4a07d9` still lacks the current- +master parent, so publishing the current protocol would expose 468 upstream files. +The root-owned dependency must be reconciled before the review slices can again +be verified under the 100-file limit. Cross-chat dispatch still needs direct +human authorization in this chat; a concrete dependency-coordination question is +pending. No frozen root branch was mutated. + +Master then advanced to task-workspace source +`f329b24ee12496e710ad17377ec7716e0eee22ef`, making the canonical prerequisite +conflicted. The source owner is reconciling its three conflicts and requested that +leaf CI/package campaigns wait for the next published parent. All local Claude +changes and earlier package/task evidence are preserved, and no new paid turn, +local Rust/Docker build or leaf package campaign is dispatched during that hold. + +The current Storybook catalog is reconciled with 224 story files and all 2,223 +pre-move IDs preserved. Fifty-three source-affected entries retain their prior +review classifications. Six bounded fixture plays and reset pass; exact cases, +source scope, simulated boundaries and omitted coverage are recorded in +`maps/storybook-map/REVIEW.md`. This does not qualify the final installed runtime. + +## Task-workspace composition and qualification hold — 2026-10-12 + +The source owner published canonical prerequisite +`16a53254c495f3e49a28cc911f5dcd60c41779fc` against task-workspace master +`f329b24ee12496e710ad17377ec7716e0eee22ef`. This chat composed it through +additive merges, retaining the Claude settings and immutable profile changes: + +| Review slice | Local code identity | Files against local dependency | +| --- | --- | --- | +| Protocol prerequisite, PR #15994 | `12113e431e37379901f2a39103816c8f2afec14a` | 678 against the older Codex branch; publication held | +| Claude behavior, PR #15993 | `cf1fa2e98285cf80d25b0681ab45761389b22a9d` | 88 | +| Platform qualification, PR #15997 | `4ba521823f8a92ba3fffcb5d6f578144466d848f` | 42 | + +The existing published PR heads remain unchanged. The root-owned Codex branch +still needs the same current-master dependency before the protocol can meet its +review file limit. Canonical 16a's fresh CI has two failing runner cases, which its +owner is investigating; earlier 64c074fb success is not carried forward as a +16a pass. The source owner's provider-free cloud qualification is separate from +the final Claude package campaign. + +Current composition checks pass: 189 guided-creation/form controls, 18 +task-workspace admission/runtime-context controls, 25 platform consumer controls, +and nine Storybook catalog controls. UI, server, runner and runner-surfaces noemit, +map checks, token gates and profile generation checks pass. The platform tests +retain both Claude corrupt-package admission and the root's bounded Intel install +diagnostics. No provider task, Docker build or Rust build ran for these checks. +Logs are `/private/tmp/claude-16a-creation-form-controls.log`, +`/private/tmp/claude-16a-workspace-context-controls.log`, +`/private/tmp/claude-16a-consumer-diagnostic-controls.log` and +`/private/tmp/claude-16a-catalog-controls.log`. + +Server noemit initially found a stale plugin SDK declaration where `projectId` +was still non-nullable. The source type was already correct. Refreshing ignored +shared/SDK outputs with the installed TypeScript compiler restored server noemit; +no product code or lockfile changed. The default pnpm wrapper's restricted-network +fetch failure is retained in `/private/tmp/claude-16a-sdk-refresh.log`; signature +checks were not bypassed. The successful compiler receipts are +`/private/tmp/claude-16a-shared-refresh.log`, +`/private/tmp/claude-16a-sdk-direct-refresh.log` and +`/private/tmp/claude-16a-server-types-refreshed.log`. + +The usable preview remains `http://127.0.0.1:3105` on recorded installed source +557c3064 with the previously declared controller overlays. Its successful API-key +task/revision pairs and downloaded artifacts remain valid for those literal +identities. They do not certify the current composition. Final gates remain a +frozen Codex dependency, fresh checks/review on all three published slices, +four-target cloud packages and clean consumers, and current installed-runtime +acceptance within the existing $50 ceiling. Unknown-charge holds remain intact. +Merging and production deployment remain separate actions. + +The retained preview stopped listening during the integration hold. Restarting +the same installed launcher restored health without changing its source/runtime +or isolated data. Read-only checks confirmed native Claude / Opus 5.5, the completed +x64 acceptance task, `enableNativeRunner=false`, and zero live runs in all six +checked QA companies. No task prompt was submitted. Receipt: +`/private/tmp/claude-retained-preview-restart-receipt.json`; launcher log: +`/private/tmp/claude-retained-preview-restart.log`. These checks confirm persistence +and preview availability, not qualification of the current source composition. + +The source owner identified the same durable-resume outer-deadline failure in +canonical 16a and requested reuse of correction +`c60bf4b6b9ee7a6052ae9c95bd10fef90f3418f7`. Its single semantic test change is +the 15000 ms outer budget; the old explanatory comments have since been removed +under the contribution rules. The controlled before/after receipts and full +72-pass/one-skip suite remain the evidence stated above. The separate callback +fixture failure is being diagnosed by the source owner and is not covered by +that correction. + +## Canonical dae808fe composition — 2026-10-12 + +Canonical prerequisite `dae808fe02694d13d322c535944910813b21f6d6` is published +against master `e168512f7bdbb6af6ea01954c6d7ed80b91e903e`. Additive composition +uses protocol `f34a1c81a93e755ed867ab71c2d016ed612baa07`, behavior +`81ce173e57` and platform `bfba2c6a5453a88c624077cec57000cfb3152d90` before +this evidence update. The durable-resume and callback tests have no remaining +diff against the canonical parent. Common production transport differences stay +limited to Claude effort. Two Storybook combined fingerprints were reconciled +after inspecting the activity/recovery map changes; classification, declared +sources and story IDs are retained. + +Current runner, surface, server and UI noemit pass. Maps, tokens, nine catalog +controls, four profile/protocol/capability generation checks and 25 clean-install +consumer controls pass. The fake-transport live-session suite passes 72 with one +existing skip. Receipts use `/private/tmp/claude-dae-*.log`; native transport +execution and required full checks remain cloud gates. The older Codex dependency +still makes the protocol review contain 703 files, so the three PR code heads +remain unchanged until that dependency is reconciled. Local behavior/platform +review slices remain 88/42 files. + +The source owner's provider-free campaign +[38338806076](https://github.com/paperclipai/paperclip/actions/runs/38338806076) +passed four native producers and all clean consumers, including Intel Mac, on +literal source 16a53254. That result is not relabeled as the Claude v7/Pi25 +composition. A separate immutable qualification ref will carry this composition +through the existing release-smoke producer/consumer workflow in the cloud, with +zero provider prompts and no package publication or deployment. This preserves +the existing PR boundaries while current package evidence is collected. Any +later source changes require an explicit evidence applicability check. + +## Frozen cloud campaign and remaining onboarding gap — 2026-10-12 + +Frozen qualification source is `ad5875fa4b78804e77911703f7ab2aacb9b32067` +on `codex/claude-runner-qualification-ad5875`. Its +[four-target package campaign](https://github.com/paperclipai/paperclip/actions/runs/38342761144) +has passed all native producers; package assembly and clean consumers remain +pending at this checkpoint. Independent no-follow reads verified all downloaded +daemon hashes, source bindings and executable architectures without executing +them locally. Receipt: `/private/tmp/claude-ad5875-daemon-identities.json`. + +| Target | Verified daemon SHA-256 | +| --- | --- | +| Apple Silicon | `bd55bd6f1868415fb6d0e3d402f469c310a35f34b96ca830216212e95fc77dd3` | +| Intel Mac | `37fe6575758600312ce13870d5471d464e6421ad9103db81f03b3451d0eabed6` | +| Linux ARM64 | `2b8f13c311cc33de809ba97718fd6f79e9f184658a2fe316dfc6c1f3251fdddc` | +| Linux x64 | `e55a45705095558110592fa38a7bf6e5ae52da5199c6051298e6db3c27ca3a78` | + +The [Linux x64 image build](https://github.com/paperclipai/paperclip/actions/runs/38342885632) +passed on the same source. Its recorded immutable image is +`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:d42fc84fda9af4cf37c52ba850b6d928c475e8ec72d3c9df0332120f247ea62d`. +Source/digest receipts are in `/private/tmp/claude-ad5875-image-evidence`; build +dispatch and fixed workflow identities are retained in +`/private/tmp/claude-ad5875-qualification-dispatch.json`. Neither campaign uses +provider prompts. No local Docker or Rust build ran. + +Shared parent correction `021df20762d96bc58d90a1dfa0fdb884393b1d5d` is +composed through all three review branches. The platform tree differs from +the frozen source only in the shared transport test and source-owner dated +record; runtime/UI/package/workflow inputs are unchanged. Runner noemit passes +for that composition. The ongoing package result retains literal source ad5875fa. + +The source owner separately found that fresh managed onboarding does not probe +its saved environment before Auto resolution; an immutable but unprobed new +company therefore stays on Legacy. Existing managed E2E fixtures preprobe that +environment and do not establish the ordinary first-use path. Claude's local +Mac proof and prior qualified selected-environment proofs retain their stated +scope. The source owner is repairing the shared/Codex setup flow and cloud seed +defaults. Claude must be checked against that repair before claiming fresh +managed onboarding or final additional-platform defaults. This gap is not +closed by the current package/image builds. + +## Installed-runtime checkpoint and lookup repair — 2026-10-13 + +Campaign 38342761144 completed with all four native producers and the Apple +Silicon, Intel Mac and Linux ARM64 clean consumers passing. Its Linux x64 public +installation failed at a stale verifier assertion, `25 !== 24`, before the Pi +admission probe. The 18 assembled archives were retained; independent no-follow +hash checks pass for all 18. The raw manifest's `publicInstallationVerified` +remains false. Protocol commit `35b811e09c` updates only the strict current Pi +version assertion to 25; the pinned command digest and other guards are unchanged. +This needs a corrected cloud clean-install run, not a relabeled prior pass. + +The same archives were installed in a fresh private Apple Silicon consumer. +Normal daemon resolution verifies source ad5875fa and daemon hash bd55bd6f; +sidecar SHA-256 is +`0ef83c19d993e8a4b3cf43c5418aa7b8f7b4ec82afa325d8c53781d145e9e2a0`. +After all six QA companies were confirmed idle, the old preview was stopped +gracefully and its 120 MB data copied to +`/private/tmp/claude-qa-before-ad5875-snapshot`. PostgreSQL's standard +`hydrate-symlinks.js` hook restored links omitted by the lifecycle-disabled +installation; no compilation or signature bypass was used. The preview at +port 3105 now runs the supplied ad5875fa packages without controller overlays. +Health, persisted native Claude/Opus 5.5, `enableNativeRunner=false`, disabled +heartbeat scheduling and zero live runs are verified. Receipts: +`/private/tmp/claude-ad5875-local-installed-identity.json` and +`/private/tmp/claude-ad5875-preview-health-receipt.json`. + +The actual creation UI selected Claude Code and displayed Paperclip Runner as +the default with the existing saved API-key account. Setup then failed before +authentication with `acpx_runtime_unavailable`: the default installed verifier +did not carry its owning server's package authority. The SDK and qualified +platform dependency are present. The earlier clean-consumer probe injected +that authority explicitly, so it did not prove ordinary discovery. + +Protocol repair `52486d6b8d` derives authority only from the canonical installed +server verifier layout. It checks bounded regular owner metadata, package name, +directory/file containment and no symlinks; existing package declarations, +qualified version/digest/architecture checks, external overrides and source-mode +resolution remain intact. Six filesystem controls, 15 selected installation +controls, runner noemit and profile generation pass. In a separate APFS-cloned +server treatment, the original installed probe fails with the recorded error; +the repaired normal probe verifies the real pinned SDK and closes its snapshot +lease without credentials, authority environment variables or model prompts. +The live preview was not modified by this comparison. Receipt: +`/private/tmp/claude-installed-authority-real-noprompt.json`. + +Platform commit `50659ac0e9` removes injected authority from the consumer probe +and rejects authority overrides before invoking it. All 26 consumer controls +pass. The runtime and strengthened consumer are composed locally, but require +new packaged-runtime verification and API task/revision acceptance before a +final claim. No new paid setup or task turn succeeded or was retried during +this checkpoint; the UI failure returned before the supplementary CLI probe. +Known estimates and all unknown-charge holds remain unchanged. + +Canonical parent `271d434c170e74bdedde65d0b13059a55419c943` is composed +without separate leaf diffs for the durable-resume, authorization and credential +quorum test repairs. The source owner reports a private explicit-Connect setup +repair and an unresolved named Cloud snapshot binding. Those changes are not +integrated or qualified here yet. The source-owned Codex dependency still needs +to advance before these PR code updates meet the review file limit. GitHub CLI +authentication expired during the source campaign; attended reauthentication is +owned there. No alternative credentials or conflicting auth changes are used. + +The incomplete checkpoint is attached to the existing isolated acceptance task +as work product `38b9e957-df5c-416f-b370-d51405a755ed`, attachment +`194e1903-5bd3-45b3-af94-af6821a6bf13`. Its independent download matches +6,911 bytes and SHA-256 +`398c24270b7bd1f2e00883ddf9a5132ad642cb38c7647d830c04ad2541845a81`. +[Download checkpoint](http://127.0.0.1:3105/api/attachments/194e1903-5bd3-45b3-af94-af6821a6bf13/content?download=1). +Upload receipt: `/private/tmp/claude-checkpoint-20261013-upload.json`. + + +## Claude managed Connect qualification — 2026-10-13 + +The root private patch 665a4bda8dd09ef82911e8b384004c29cbe72da9d534781c548a1bc223f159ef +is Codex-only. Claude now has a separate narrow counterpart in the shipped +onboarding wizard and agent setup. New Claude Code with Auto and an explicit +immutable sandbox image uses fresh company/environment discovery; an unqualified +eligible environment is probed once through the existing permission-gated API +at Connect. Discovery is refreshed afterward, and the measured runner controls +both the environment test and saved configuration. Native forms omit the Legacy +turn-limit default; a measured Legacy result retains it. No runner override is +submitted for Auto. + +Existing API-key storage and managed connection bindings remain in use. A new +API key is saved once before qualification; subsequent test/hire payloads carry +the managed binding rather than the raw key. Local execution, saved agents, +explicit runner choices, other harnesses, disabled/overridden adapters, mutable +images and named snapshots do not acquire this additional qualification path. +Named Cloud snapshot identity remains a separate unresolved acceptance boundary. + +The helper validates the existing probe's recorded qualification and lease ID. +Permission, connection, cleanup or missing-identity failures stop setup without +an automatic retry. Generation and selection checks discard held results after +company/environment/configuration changes. The wizard caches measured discovery +after its operation so its own new default does not invalidate the setup; agent +creation recognizes that same measured transition while retaining stale guards. + +Four mounted positive cases fail against the prior components and pass after +the change. Final focused verification passes all 197 tests: 109 wizard, +69 creation and 19 helper controls. The new cases include native and Legacy +measurements, API-key storage, denied probe access and held-result races. +UI noemit, token gates, map contracts, all nine catalog controls and the catalog +checker pass (224 files, 2,223 stable story IDs). The six affected catalog entries +were source-reviewed; no new rendering, play or live managed acceptance is +claimed. Logs: /private/tmp/claude-managed-cache-full-controls.log, +/private/tmp/claude-managed-cache-types.log, +/private/tmp/claude-managed-cache-catalog-controls.log and +/private/tmp/claude-managed-cache-token.log. The before comparison is retained +in /private/tmp/claude-managed-setup-before-controls.log. + +No provider prompts, paid setup retries or local Docker/Rust builds were used. +The live preview remains the unmodified ad5875fa installed package; the runtime +lookup repair and this UI change still need a new cloud-qualified package and +real API task/revision acceptance. Full repository checks and PR review must run +on the reconciled delivery heads before a PR-ready claim. + + +The managed setup checkpoint is attached to the same isolated acceptance task +as work product `894e1e67-ba80-40e0-80b3-e5e0723788dc`, attachment +`df4dde66-0c0a-4553-a3a2-170ded6663e9`. Independent download verifies +8,085 bytes and SHA-256 +`01783ff0aca0f4514df75aead60c511553cb8c407339dd047282a1e9248a4c85`. +[Download managed setup checkpoint](http://127.0.0.1:3105/api/attachments/df4dde66-0c0a-4553-a3a2-170ded6663e9/content?download=1). +It records behavior commit `6ea47e5480ca17e8e634c203964e99d5614db1dc` +and the platform composition at attachment time; it does not claim a final +packaged runtime or completed live managed acceptance. Receipt: +`/private/tmp/claude-managed-checkpoint-20261013-upload.json`. + + +## JavaScript-only local treatment and startup collision — 2026-10-13 + +Built only the UI JavaScript from behavior 9b068c0120 and copied the two verified +lookup repair files into a separate APFS-cloned ad5875fa consumer. The original +consumer is retained. The supplied daemon and sidecar are unchanged; the normal +Claude SDK probe passes without credentials or explicit package authority. No +Docker or Rust build ran locally. Treatment identity is recorded in +/private/tmp/claude-installed-9b068c-js-treatment-identity.json. After all six QA +companies were confirmed idle, the owned preview was stopped gracefully, its +complete data backed up to /private/tmp/claude-qa-before-js-treatment-snapshot, +and the private treatment started at 3105 with scheduling and ambient Claude +credentials disabled. This is explicitly a treatment, not a final package. + +The real creation UI displays Paperclip Runner (default) for Claude Code. Both +native readiness and the existing supplementary credential check pass with the +saved personal Anthropic API key. Three bounded setup walkthroughs were used: +the first browser harness waited on the wrong Configure label; the corrected +walkthrough reached Configure; the final walkthrough saved the agent but watched +/agents rather than the product's /agent-hires response. No duplicate agent was +created. Reload independently verifies agent dd26921e-fe79-4ef7-a83c-5755bebd8936 +as paperclip_runner/acpx/claude/claude-opus-5-5 with the existing managed API-key +binding. The runner control was inspected without changing its default. + +The first manual file task CLAAAAAAA-2 (6689bb7c-785e-4884-842f-13f6f5fddefa) +failed in session.open, run 04da7f23-3cff-4efb-87f5-7a757500bfc3, with +provider_lifetime_owned. No artifact or successful task execution is claimed. +The exact provider record identifies Claude SDK 0.3.286. Its deterministic +three-port lifetime fence is 56223/54772/53321. Read-only ownership inspection +finds unrelated node processes already listening on the first two ports: PID +13796 belongs to a separate runtime-service test directory; PID 42452's cwd is +not yet confirmed. The two-of-three guard refused admission. No guard was +weakened and neither foreign process was stopped. The failed run is preserved +and no retry was submitted. The post-failure budget read is pending, so no +accounting completeness or zero-charge claim is made. Existing company cap $4 +and new agent cap $2 remain inside the aggregate $50 company ceiling; retain +$1 run reservation and a conservative $1 reserve for unknown setup charges. + +Local checkpoint: /private/tmp/claude-local-startup-checkpoint-20261013.json. +Further paid work waits on the recorded accounting state and a safe startup +path; final cloud package and PR qualification also remain outstanding. + + +## Actual prerequisite merge reconciliation — 2026-10-13 + +The source owner confirmed prerequisite #16028 merged as actual master commit +`d67e3507b23041212fa26302ce6de078c5ebcc05`. That object is present locally. +Protocol composition `abcd8261d2` merges this actual commit; behavior composition +`3f442729a4f1253771e5693724142efb58f9c006` carries it forward. Five conflict +files retained only the reviewed newer Codex checkpoint history, Linux libc +compatibility prose, source/access-map scopes and native-platform test import; +master additions outside those hunks remain intact. Obsolete instructions to +start the already existing Codex slice were not restored. No source-owned branch +or new npm/staging qualification environment was changed. + +Focused reconciliation checks pass: 213 root controls (197 Claude setup and 16 +development native-binary controls), six package-local installed-authority +controls, UI noemit, generated ACPX profiles, map contracts, token gates and nine +catalog controls. The initial root invocation does not include runner package +tests; the six authority controls were separately run from that package's cwd. +The inherited Storybook delta has nine changed declared inputs across 61 +entries; source review retains classifications and all 2,223 IDs. Rendering, +play, final broad checks and live task/follow-up proof remain pending. Logs are +/private/tmp/claude-d67-reconcile-focused.log, +/private/tmp/claude-d67-authority-controls.log, +/private/tmp/claude-d67-reconcile-ui-types.log and +/private/tmp/claude-d67-catalog-after.log. + +The source owner's separate private Codex rehearsal is frozen at +`a758b226d898ef457f524427c19a9919ec3b5276`; its receipt independently matches +SHA-256 `568f927baed3e0864a31a6a4dbb44ab0ec0f7ccab2b93f7848c24387eb1e1c3a`. +It remains private and unqualified for live execution; it was not merged here. +The source-owned published Codex branch still needs final reconciliation before +our protocol publication can satisfy its review-size boundary. These source +checks do not relabel the live treatment preview as new compiled master. + +The human authorized the source owner a separate $25 npm-canary/fresh-staging +qualification lane, with $15 stack/sandbox, $5 setup/four Codex turns and $5 +cleanup reservations. It does not fund Claude and does not redistribute the old +$250 ledger or our $50 ceiling. No such environment or budget was allocated here. +Claude's budget-check/manual-retry question remains pending. The read-only budget +check failed to execute after approval-review timeout and then model-capacity +failure. No approval bypass, foreign listener cleanup, hold clearing, paid retry, +local Docker/Rust build or production promotion was performed. + + +The reconciliation report is attached to the existing completed QA task as +work product `ebb8457c-6a9c-40ee-bfaf-690538702feb`, attachment +`a42cc0c7-f253-4242-98ca-2d671e5cd5a9`. Independent download verifies +4,612 bytes and SHA-256 +`84322cef2fd1b3eb7074b7f946dce9b0466302fed8b0160ea7ef2fc527e9515b`. +[Download merged-master checkpoint](http://127.0.0.1:3105/api/attachments/a42cc0c7-f253-4242-98ca-2d671e5cd5a9/content?download=1). +Receipt: /private/tmp/claude-d67-reconciliation-checkpoint-upload.json. + +At attachment time the earlier preview process had expired and 3105 was free. +Restored the same recorded JavaScript treatment without rebuilding or changing +its runtime. Health at 2026-10-13T11:44:32.104Z confirms all six QA companies +have zero live runs, the experimental flag is false and the original completed +x64 task persists. Its agent is paused; no resume or budget read was performed. +The served controller is ad5875fa with the recorded 52486d6b lookup repair and +9b068c UI, not a newly compiled d67 package. Health receipt: +/private/tmp/claude-d67-source-treatment-preview-health.json. +Full workspace typecheck/tests/build and final packaged acceptance remain +unrun on the reconciled head; they belong to the still pending cloud/full-check +handoff after dependency and local acceptance gates are satisfied. + + +## Published prerequisite evidence remains separate — 2026-10-13 + +The source owner reports public canary `2026.1013.0-canary.6` on +`60bc0d5fee9a6e48db08fc016521fee3d9f57d67`, following merged prerequisite +d67e3507. The 60bc object is available locally and its own commit changes only +the release workflow and its verification tests (#16149). This checkpoint does +not merge that later master into the Claude branches or run a new workflow. +The source owner reports four native producers, publication and real onboarding +smoke passing, plus clean Apple Silicon installation and two native Codex task +runs through the supported API. Its fresh staging pair remains separately bound +to literal d67/cloud38392212140-1 and reuses its provider session. + +Those are prerequisite/Codex measurements, not qualification of our untested +Claude package. The source owner's published saved-account Connect UI still +uses a Legacy CLI probe that fails on its clean local PATH; its private Codex +#15683 fix remains separate. Its released gpt-5.6-luna costs are unpriced, and +an additional positive-budget control correctly fails closed. Restoring its +budget to default zero changed the configuration revision/session; local same +provider-session reuse is therefore not claimed. Four authorized Codex turns +were used, its QA agents are paused, and physical sandbox teardown/invoices +remain unconfirmed. None of its $25 allowance funds this Claude lane. + +Our public heads remain c60bf4b/4440df2/173a73e, with newer reconciled fixes +committed locally. Claude budget-check/retry approval is still pending; no new +paid check, retry, hold release, foreign process cleanup or borrowed environment +was performed. Current-source broad checks, final packages, local useful-task +and revision proof and fresh PR review remain gates. The old source merge timer +was canceled by its human; this chat has no authority to recreate it or promote +production defaults. + + +## Actual Codex merge composition — 2026-10-14 + +Codex #15683 merged as `adb69e8f09852c772d9901ae270e5b2d66e39c77`. +Fetched that exact object using the existing repository authentication. The +original /Users/dotta/.codex/worktrees/670b/paperclip checkout no longer exists, +but its committed behavior branch is intact at f4b24bf695. App-managed recovery +reported Not a git repository, so the same branch was recovered into the isolated +/private/tmp/claude-runner-default-recovery worktree. No source-owner branch, +preview or allowance was changed. + +Protocol composition `9ac6e674fa` retains the actual merged Codex implementation, +including shared bounded suspension reads and ingress shutdown. Its shared server +executor is byte-identical to adb69; transport differences are Claude effort +fields/admission only. The v7 input reader retains v1–v6, including Dot and Grok +Bot bindings. The merged Grok Bot gates remain intact in Claude behavior. +Copilot profile 18 already belongs to merged Grok schema evidence, so the combined +Claude source extension uses pending profile 19, preserving that immutable v18 +and the earlier private Claude v18 under a separate fixture name. Pi25 retains +its previous digest; no provider qualification boundary is promoted. + +Protocol checks pass 326 controls with one existing distribution-root fixture skip, +plus runner noemit, schema/profile/manifest generation and map contracts. These +are JavaScript/fake-provider checks, not new native package or live proof. +Cached dependencies were restored into isolated copies after the old absolute +workspace links became dangling. A frozen install stopped on the inherited +manifest/lock mismatch; a separate pnpm version query refused an unverifiable +registry fetch. No signature guard or patch policy was bypassed. The existing +installed dependency lock identity is retained in +/private/tmp/claude-adb69-dependency-recovery/receipt.json. Newly merged Speko and +OpenTelemetry packages were installed at their manifest-pinned versions into a +private prefix with npm lifecycle scripts disabled. Canonical shared/plugin SDK, +eval-kernel and runner JavaScript outputs were prepared without Docker or Rust. +Fresh full dependency/source checks still belong to current-head hosted CI. + +The defaults composition retains Codex onboarding draft recovery and its +explicit-Connect probe handling. A combined test exposed an inherited reset that +started another setup probe when measured discovery changed; restoring merged +navigation ownership fixes all 39 targeted managed Code/Claude controls. A +separate stale Claude target control now requires an explicit Connect action +before a second test: obsolete results cannot hire, and environment updates do +not automatically spend another setup turn. Native/Legacy configuration and +saved-account assertions remain intact. + +The actual merged Storybook uses source annotations and generated indexes, so +its deleted old maps/storybook-map files are not revived. The canonical Codex +story location and stable identity are retained. The current catalog generator +passes 283 files/2,727 stories; map contracts pass 35 areas/176 capabilities. +UI/server typechecks and token gates pass. Final focused settings/creation checks +cover 577 controls across their recorded runs, and all 31 native input-builder +controls pass. Logs use /private/tmp/claude-adb69-* prefixes. No new rendering, +paid model turn, budget read/resume, hold release or local native build ran. +The local preview remains its separately recorded older treatment; this source +composition is not a claim of current installed/runtime or task/revision proof. + + +### Hosted clean-install correction — 2026-10-14 + +The first reconciled hosted Build jobs (38605177149 and 38605182039) +failed in the credential-free Pi public-install probe: the generated profile25 +digest was dd673874…, while the probe retained d25d674c…. Protocol correction +519e8369a1 aligns that exact assertion with the immutable profile; all24 existing +public-install sandbox controls and profile generation checks pass locally. +The daemon, profile, admission checks and paid-provider behavior are unchanged. +The full clean consumer will rerun in hosted CI; no local Docker build ran. + +The earlier port3105 preview has been replaced by another test-drive +(source a6d81b67cb, separate github-review data directory). The report uploader +stopped on its404 target check before creating an attachment or work product. +That foreign instance is left untouched. Claude preview recovery must select +a separate port and the existing Claude data directory; the historical3105 +link is no longer a usable Claude preview. + + +### Fresh review and cancellation correction — 2026-10-14 + +Fresh reviews passed behavior 7069077274 and platforms 6b0cb979d4 at 5/5. +Protocol 519e8369a1 received two valid findings: stale profile 23 catalog +expectations and an owned Claude settings write that blocked startup abort. +Correction dd82936c1d updates the Pi 25 expectations/fingerprint and uses the +existing abort-aware admission helper, transferring the unfinished write and +lifetime lease to retained cleanup. Both successful and failing late writes +return cancellation promptly while rejecting a competing provider. All 55 +runtime-host tests, 72 Pi evidence controls, runner noemit and profile/manifest +checks pass. These are fake-provider controls, not paid execution evidence. + +Hosted jobs 38606914671 and 38606913944 received simultaneous runner shutdown +signals at 17:35:06 UTC. One failed-job prerequisite rerun was requested before +the new review findings. Credential-free package campaign 38607132186 was +canceled once the runtime fix invalidated its source 6b0cb979d4. Completed +producer outputs remain historical; no new package qualification is claimed. +A replacement package campaign waits for fresh review of the corrected runtime. + +The existing Product E2E browser helper exercised UI source 7069077274 against +the retained older backend at 3115 through New Agent, Claude Code and Advanced. +The runner shows Paperclip Runner (default); keyboard Legacy/native selection +passes. Stable light/dark screenshots at 1440 and 390 px show no horizontal page +overflow. The inherited Custom Gateway subtitle clips on 390 px; Claude API-key +controls remain visible. Every API mutation was blocked by the browser guard; +one local connection-attempt request was intercepted, no agent was created and +no setup probe or provider turn was submitted. This establishes UI rendering +and controls only, not saved-default persistence or final runtime execution. +UI receipt: /private/tmp/claude-current-ui-review-20261014.json. +Retained Claude preview: http://127.0.0.1:3115 (historical runtime treatment). +The prior reconciliation report was uploaded to its original completed QA task +and independently byte-checked: attachment aabe2e7e-a8a0-4408-bc7e-a013e0f7485b, +5916 bytes, SHA256 3bd9bcfa64db6a3a7968952122b1a5c2ae27e24f0e4ff336fd47ae374bae8ff1. +Paid acceptance and budget/hold changes remain stopped pending human approval. + + +### Frozen runtime and comment-only review cleanup — 2026-10-14 + +Protocol dd82936c1d has current 5/5 review and green CI 38609018629. +Platform 2646b94533 has current 5/5 review and green CI 38609191818. +Behavior 05a98b6cbf received one style finding for added code comments. +Removing its seven specified comment lines across three files produces identical +comment-free esbuild output for each file; the SHA256 comparison is retained in +/private/tmp/claude-comment-cleanup-equivalence-20261014.json. No executable +behavior changes, so package campaign 38610086180 and qualification-image +campaign 38610152781 retain their literal 2646b94533 runtime source identity. +They do not become proof of a later source revision by relabeling. + +The full Product E2E support checks pass 2733 Vitest and 128 node:test controls. +The behavior static lane observed a transient atomic .tmp record in an unchanged +build-stamp concurrency fixture; its exact local control passes. The original +failed attempt and one requested failed-job rerun remain recorded. +A read-only QA budget audit now succeeds on 3115: six confirmed policies have +zero live runs; four remain paused with pending accounting, and an older policy +is unavailable. Historical reservations remain conservative. No hold, limit or +paid run was changed. The two previously colliding foreign port listeners remain +present and untouched; retrying that exact old session is not execution proof. + +### Installed API-key acceptance — 2026-10-14 + +The full frozen package campaign [38610086180](https://github.com/paperclipai/paperclip/actions/runs/38610086180) +passed all four native producers and clean consumers, including default installed +Claude authority on Apple Silicon, Intel Mac and Linux ARM64. Linux x64 passed +its existing clean public-install controls; that lane does not separately run +the Claude authority probe. Runtime source remains +`2646b94533bac1eae0d30b11d3774b104eeaf981`. Behavior `06258a283c` and platforms +`bddff3c5e9` have green CI and 5/5 reviews, with executable comment equivalence +recorded above. Subsequent evidence or packaging-tooling edits do not relabel +these runtime bytes. + +The retained preview now runs that installed package at http://127.0.0.1:3115, +with no JavaScript overlays, daemon overrides, development provider pack or +ambient provider credentials. Native Runner experiment and timer heartbeats +are disabled. The compiled server identity is 2646b94533; the health page's Git +context reports checkout 06258a283c. Both identities are recorded. The old data +has a private cold backup. Normal embedded-Postgres symlink hydration and a +QA-local dangling Claude CLI link were repaired; the system CLI was unchanged. + +Ordinary browser onboarding selected Claude Code and its saved personal API-key +connection, left runner/model defaults intact, and saved native Claude/Opus 5.5. +One useful task and its revision succeeded after a controlled controller restart. +Independent downloads matched 160 and 214 byte oracles, and the original remained +available after reload. Task, provider and backend sessions were identical across +turns; High effort was requested and acknowledged in both. API usage settled at +$0.183214600 and $0.287600400 estimated rate-card cost, not invoice charges. +Report: `/private/tmp/claude-2646-mac-api-acceptance.json`. +Preview: http://127.0.0.1:3115/CLAAAAAA/issues/CLAAAAAA-2. + +Linux ARM64 also passed a fresh API-key task/revision on the installed SSH host, +using ordinary API creation with neither runner nor model overrides. The current +pack was discovered at its standard host location, not an explicit development +path; its source/digest and staged ARM daemon matched the frozen release. +Independent files matched 156 and 203 bytes, all three sessions persisted, and +High effort was acknowledged before both turns. Settled estimates are +$0.209004800 and $0.128238000. The old SSH port had reached an interactive +Tailscale login; the isolated fixture uses the authorized key on port 22222. +Report: `/private/tmp/claude-2646-arm-api-acceptance.json`. +Preview: http://127.0.0.1:3115/CLAAAAAA/issues/CLAAAAAA-3. + +These four turns used the existing healthy $2 company cap, serialized with timer +heartbeats disabled. After both Mac turns settled below 30 cents each, its +per-turn reservation changed from $1 to $0.50 while the $2 hard cap and unknown- +usage blocking remained intact. Historical failed records/holds and the separate +setup reservation remain preserved. No automatic paid retries were enabled. +The next x64 attempt was rejected before provider startup because its independently +rebuilt image pack differed from the npm release authority; its failed record +and pending accounting hold are retained. Cloud image alignment is the next gate. +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. + + +## Refreshed local API acceptance — 2026-10-19 + +The stack includes origin/master through `016e40d53286a34647fc564411b7a712c46a9594`, +including the merged cold-start fix #16493. Current delivery remains #15994 +(protocol), #15993 (Apple Silicon behavior), and #15997 (other platforms). +These three remain open for the requested local test drive. + +The fresh isolated preview is `http://127.0.0.1:3104/CLAA/issues/CLAA-1`, with +retained data in `/private/tmp/claude-default-test-drive-20261019`. No Docker +image was built locally. A server typecheck command unexpectedly invoked its +Rust prerequisite; it was stopped and replaced with direct TypeScript checks. +Subsequent native builds and package qualification ran in GitHub Actions. + +Ordinary web creation of `Claude Default QA` with `enableNativeRunner=false` +saved `paperclip_runner`, `provider: acpx`, `acpxAgent: claude`, +`model: claude-opus-5-5`, and `modelReasoningEffort: high`, without runner or +model overrides. The API-key connection initially applied a Legacy CLI version +check. The fix reuses the existing Anthropic key-validation endpoint for native +Claude secret references; subscriptions and custom destinations keep their +existing supplementary probes. Focused setup verification passed 124 tests, +and a further 81 UI tests and direct server/UI TypeScript checks passed. + +Review found a separate controller omission: v9 effort inputs did not pin +permissions at daemon startup. Both downstream constructors now include v9. +The isolated/preconfigured transport regression cases passed 2/2. Native input +v9 preserves master's explicit homeMode and historical v1–v8 readers. + +The first task attempt (`075e4543-e04f-4af2-bad3-c0d6c4987947`, original company +`1de13e1c-5957-4920-9332-f77396c976ec`) failed before provider launch because +the test-drive CLI intentionally removes inherited PAPERCLIP_* overrides. +The verified daemon was then staged in the package-owned target location. +That failed record and its full $2 pending reservation remain intact. + +A fresh $2-bounded fixture (`5423fac4-72c4-4f31-a933-db368682b4a8`) uses the +existing AI-connections API to validate and securely store the authorized key. +Its API-created `Claude Acceptance` agent also used ordinary runner/model +defaults, High effort, disabled timer heartbeats, and one concurrent run. +The fresh company's onboarding did not expose the original company's saved key; +engineering setup used the existing API rather than putting secrets in the +browser transcript. This is general product acceptance, not a blind user-only +journey. Task creation and the follow-up used ordinary browser controls. + +| Turn | Run | Outcome | Duration | API estimate | +| --- | --- | --- | --- | --- | +| Create checklist | `63fad59f-617b-4a0f-8548-a14582abed63` | succeeded | 53.925s | $0.209689200 | +| Add Rollback | `5861d089-7774-400d-a7c6-e3b65b70177f` | succeeded | 77.575s | $0.308743600 | + +The follow-up records `session.resumed` and reuses provider session +`d46b728c-1b32-44cb-a406-118baf287ab4` and normalized session +`a5dabc11-b797-4ce0-a8e4-1896e093ef11`. Both actual browser downloads were +independently checked. V1 is 451 bytes, three sections and six checkboxes, +SHA-256 `cab0eac549c9f7c4b7c35374e32cd645713935945377898bb21e2f0c5fcba706`. +V2 is 612 bytes, preserves the original body and adds two Rollback checks, +SHA-256 `94f5bb1979046df5e52e182c3fc0ccb8fe3f68f67c4542d99424263157a3d703`. +The original attachment remained byte-identical after revision. + +The two new successful turns total $0.518432800 in estimates, not invoices. +The original fixture's pending reservation and all earlier unknown-charge holds +remain separate. The aggregate Claude qualification ceiling stays $50. Full +remaining-budget reservations bound each manual acceptance turn; no automatic +paid retry campaign was authorized. Subscription execution remains unverified. + +The cloud-built Mac daemon used by this pair has source +`04e0202ca81387b17cbe7fd094a8338648356fe9` and SHA-256 +`0d7ef273fb9b7df2e73a4a6ef6b284a8b6cb59f949a8b9f78af5f391f8d296ef`. +Its header, receipt digest, regular-file status, and build metadata were checked. +[Exact-source four-target package qualification](https://github.com/paperclipai/paperclip/actions/runs/39267321383) +passed, including the four native builds, package assembly, and installed-host +consumers. Subsequent changes affect setup, controller admission, credential +configuration guards, and sandbox proof revocation; the runner/provider source +and pinned dependency bytes remain unchanged. Preserve these runtime identities +when carrying the result forward. + +Detailed mutable evidence, the chronological browser journal, downloads, and +run events are retained at `/private/tmp/claude-refresh-evidence-20261019`. +Latest-head CI, review status, final preview identity, and any remaining failures +are recorded in the final handoff there and in the PR bodies. The first latest- +master CI batch experienced simultaneous runner shutdowns; the independent +short UUID fixture-prefix collision and all failed job logs were preserved +before retrying. Security review also identified native hire credential routing; +the existing host-setting guard now covers native runners and retains the saved +harness for partial credential-reference edits. The first local route-test attempt hit a 30-second hook timeout. A resource- +pressure hypothesis was disproved by the system memory check. Serial reruns with +a longer hook budget passed all 122 credential/permission cases; another 232 +snapshot/environment cases and direct server TypeScript checking passed. +Cloud CI remains the final verification gate. + + +### Final master reconciliation — 2026-10-19 + +The stack now incorporates origin/master `9179d8dcc3e908c217fde4f63c9d08ccf8132eeb`, +including task-loading improvements, native progress polling, skill retention, +permission handling, and OpenRouter attribution. Both independently authored Pi +profile 24 declarations remain immutable. Combined source attestations use Pi +profile 27 (`sha256:b1b75a1ae3ac498eb07b95b96bebf4da86227b83d09276076dd5c222e3964dba`); +Copilot remains profile 22 and Claude's effort input remains v9. Focused protocol +verification passed 155 cases (one skipped), an additional 18 Pi attestation +cases passed (one skipped), and 91 evaluation/catalog checks passed. Rust format +checking ran locally; Rust compilation and Docker work remain in cloud CI. + +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. diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md index 26e162cf032..b6c747e1bb8 100644 --- a/maps/agent-access-map/agents.md +++ b/maps/agent-access-map/agents.md @@ -29,6 +29,12 @@ API: `present` — representative operation IDs: Agent access: `partial` — Native hire_agent; REST / CLI agent; runtime inheritance for 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 +and approval activation. This selection does not grant connection access or +bypass hiring authorization. Source: [runner resolver](../../server/src/services/agent-runner-selection.ts). + Policy: Agent creation/configuration uses action grants, resource policies and change consent; some controls require a board actor. diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index 3b404f2af54..f51a6bd5b15 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -44,6 +44,11 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. +Runner discovery exposes `supportedRunners` and `defaultRunner`; it describes +qualified selection, not successful authentication or task execution. Native +Claude retains existing account grants and company/responsible-user boundaries. +Source: [adapter discovery](../../server/src/adapters/registry.ts). + Gaps: Raw instance adapter listing requires board, while company model discovery exists for eligible agents. @@ -52,7 +57,7 @@ reports supported runners/effective default for the selected company/environment [selection policy](../../server/src/services/agent-runner-selection.ts) reads server-owned immutable sandbox facts without provisioning. New Codex defaults and explicit overrides follow the [shared runner contract](../../packages/shared/src/agent-runner.ts); -other harness defaults remain unchanged. The board inventory does not grant a +Claude follows its qualified rollout described above. The board inventory does not grant a native agent an equivalent discovery binding. See [probe restrictions](./execution-environments.md#probe) for acquiring qualification. An absent forced-Kubernetes environment is an unqualified sandbox target, never diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md index 44433068977..aae2f7863db 100644 --- a/maps/feature-map/agents.md +++ b/maps/feature-map/agents.md @@ -52,7 +52,7 @@ Manual: Hire an agent with a distinct name and role. Complete any required appro For a new Codex hire, choose the Codex harness. Automatic selection uses Paperclip Runner on a qualified selected target; Advanced offers an explicit Legacy runner -override. Other harness defaults are unchanged. Record the persisted runner and +override. Record the persisted runner and underlying harness, rather than treating the displayed Codex name as execution proof. See [runtime selection and validation](./runs-adapters.md#adapter-setup) and [target qualification](./execution-environments.md#environment-settings). @@ -69,6 +69,14 @@ 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 +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. + The current hiring wizard assigns the first hire CEO and subsequent hires General reporting to the CEO. Change manager/role afterward through supported configuration. Process/HTTP agents can be created through API/CLI but are excluded diff --git a/maps/feature-map/onboarding.md b/maps/feature-map/onboarding.md index 2c6668880e5..5428094bed0 100644 --- a/maps/feature-map/onboarding.md +++ b/maps/feature-map/onboarding.md @@ -54,7 +54,7 @@ company's onboarding route, and finish; drafts save automatically. Follow the created first task, verify the selected agent and company, and inspect its output or explicit authentication gate. Reload both company and task. -For Codex, choose the harness and leave runner selection automatic to prove the +For Codex or Claude, choose the harness and leave runner selection automatic to prove the first-agent default. On a qualified target it saves Paperclip Runner; Advanced also permits Legacy. Do not switch runners after onboarding when claiming proof of that default. Test the selected runtime and account, then complete a bounded @@ -68,6 +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 +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) +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 uses the existing saved environment probe when authoritative qualification is absent. The board actor needs the existing instance-environment administration diff --git a/maps/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md index ef0af37fba6..7acc1b3fffd 100644 --- a/maps/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -45,15 +45,19 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Reco Automated: [adapter validation routes](../../server/src/__tests__/agent-adapter-validation-routes.test.ts) and [model refresh](../../server/src/__tests__/adapter-model-refresh-routes.test.ts) cover server behavior. +Claude native setup uses the existing Anthropic API-key or subscription connection +flow. Record the selected account separately from successful execution; unavailable +models/authentication and unsupported Legacy-only options must fail visibly. + Manual: Select the intended adapter and account, refresh model choices, and run its environment test. Save, reload, then execute a small task. Repeat with a deliberately unavailable account and verify the failure remains visible. Choose Codex as the harness; the UI displays its name/icon for both runners. Advanced identifies the automatic default and offers Paperclip Runner or Legacy. -Only new Codex agents default to the native runner on supported macOS or GNU/Linux +New Codex agents default to the native runner on supported macOS or GNU/Linux targets; the [shared selection contract](../../packages/shared/src/agent-runner.ts) and [runtime prerequisites](../../packages/paperclip-runner/README.md) define the -platform/libc boundary. Windows and Alpine/musl remain Legacy; other harness -defaults are unchanged. Disabled adapters, external overrides, and selected-target +platform/libc boundary. Windows and Alpine/musl remain Legacy. Claude follows +its qualified rollout described above. Disabled adapters, external overrides, and selected-target qualification also affect availability. On a fresh forced-Kubernetes instance without an environment row, inspect discovery and create with automatic selection. Both must retain Legacy without provisioning; diff --git a/packages/adapter-utils/src/paperclip-runner-permissions.ts b/packages/adapter-utils/src/paperclip-runner-permissions.ts index cf084ba7e59..61f69366478 100644 --- a/packages/adapter-utils/src/paperclip-runner-permissions.ts +++ b/packages/adapter-utils/src/paperclip-runner-permissions.ts @@ -12,7 +12,7 @@ export const PAPERCLIP_RUNNER_IDLE_TIMEOUT_DEFAULT_MS = 300_000; export const PAPERCLIP_RUNNER_IDLE_TIMEOUT_MAX_MS = 86_400_000; export const PAPERCLIP_RUNNER_DEFAULT_MODELS = { codex: "gpt-5.6-sol", - acpx: "claude-sonnet-5", + acpx: "claude-opus-5-5", opencode: "openrouter/deepseek/deepseek-v4-flash-0731", } as const; diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 24b75ce1f75..a9a4fdc97b8 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -724,6 +724,7 @@ export interface CLIAdapterModule { export interface CreateConfigValues { runner?: "auto" | "paperclip" | "legacy"; + resolvedRunner?: "paperclip" | "legacy"; adapterType: string; codexPermissionMode?: "never" | "on-request" | "untrusted"; codexLegacySandboxBypass?: boolean; diff --git a/packages/adapters/claude-local/src/index.test.ts b/packages/adapters/claude-local/src/index.test.ts index ef67c309a55..1decc97a672 100644 --- a/packages/adapters/claude-local/src/index.test.ts +++ b/packages/adapters/claude-local/src/index.test.ts @@ -27,9 +27,9 @@ describe("Claude model defaults", () => { expect(claudeLocalReasoningEffortsForModel("custom-model")).toEqual(["low", "medium", "high"]); }); - it.each([undefined, null, "", " "])("uses Opus 5 for an unset model (%j)", (model) => { - expect(DEFAULT_CLAUDE_LOCAL_MODEL).toBe("claude-opus-5"); - expect(resolveClaudeModel(model)).toBe("claude-opus-5"); + it.each([undefined, null, "", " "])("uses Opus 5.5 for an unset model (%j)", (model) => { + expect(DEFAULT_CLAUDE_LOCAL_MODEL).toBe("claude-opus-5-5"); + expect(resolveClaudeModel(model)).toBe("claude-opus-5-5"); }); it("keeps explicit model IDs ahead of environment overrides", () => { diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index ea33b500210..814479afeaa 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -1,4 +1,4 @@ -export const DEFAULT_CLAUDE_LOCAL_MODEL = "claude-opus-5"; +export const DEFAULT_CLAUDE_LOCAL_MODEL = "claude-opus-5-5"; /** Resolve Paperclip's default without replacing an explicit provider model. */ export function resolveClaudeModel( diff --git a/packages/adapters/claude-local/src/server/acp.test.ts b/packages/adapters/claude-local/src/server/acp.test.ts index 8e16e2d31a9..304c2d6f852 100644 --- a/packages/adapters/claude-local/src/server/acp.test.ts +++ b/packages/adapters/claude-local/src/server/acp.test.ts @@ -278,7 +278,7 @@ describe("claude_local ACP lane", () => { onMeta: async (payload) => { meta.push(payload); }, })); expect(result.exitCode).toBe(0); - expect(meta[0]?.env?.ANTHROPIC_MODEL).toBe("claude-opus-5"); + expect(meta[0]?.env?.ANTHROPIC_MODEL).toBe("claude-opus-5-5"); }); it("keeps ACP model precedence consistent with CLI and provider overrides", () => { diff --git a/packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts b/packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts index 64d8f04b223..6d0dc802ad4 100644 --- a/packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts +++ b/packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts @@ -78,7 +78,7 @@ function buildContext(config: Record = {}) { sessionDisplayId: null, taskKey: null, }, - config, + config: { model: "claude-opus-5", ...config }, context: {}, onLog: vi.fn(async () => {}), }; diff --git a/packages/adapters/claude-local/src/server/execute.max-turns-classification.test.ts b/packages/adapters/claude-local/src/server/execute.max-turns-classification.test.ts index 2fb496918dd..dd65fdd0e2e 100644 --- a/packages/adapters/claude-local/src/server/execute.max-turns-classification.test.ts +++ b/packages/adapters/claude-local/src/server/execute.max-turns-classification.test.ts @@ -53,6 +53,14 @@ function buildProcResult(resultEvent: Record, extraStdout = "") }; } +function mockExecutionResult(result: ReturnType) { + runAdapterExecutionTargetProcess.mockImplementation(async (_runId: string, _target: unknown, _command: string, args: string[]) => + args.includes("--version") + ? { ...result, exitCode: 0, stdout: "2.1.284 (Claude Code)" } + : result, + ); +} + function buildContext() { return { runId: "run-1", @@ -76,7 +84,7 @@ describe("claude_local failure classification precedence", () => { }); it("keeps max_turns_exhausted when stdout mentions model_not_found", async () => { - runAdapterExecutionTargetProcess.mockResolvedValue( + mockExecutionResult( buildProcResult({ is_error: true, subtype: "error_max_turns" }, POISONED_TOOL_OUTPUT), ); @@ -86,7 +94,7 @@ describe("claude_local failure classification precedence", () => { }); it("still reports model_not_found when the provider returns it", async () => { - runAdapterExecutionTargetProcess.mockResolvedValue( + mockExecutionResult( buildProcResult({ is_error: true, subtype: "error", result: "API Error: 404 model not found" }), ); diff --git a/packages/adapters/claude-local/src/server/execute.remote.test.ts b/packages/adapters/claude-local/src/server/execute.remote.test.ts index cbb6ed55013..f8dc789a984 100644 --- a/packages/adapters/claude-local/src/server/execute.remote.test.ts +++ b/packages/adapters/claude-local/src/server/execute.remote.test.ts @@ -189,11 +189,11 @@ describe("claude remote execution", () => { remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/claude/mcp-config`, followSymlinks: true, })); - expect(runChildProcess).toHaveBeenCalledTimes(1); - const call = runChildProcess.mock.calls[0] as unknown as + expect(runChildProcess.mock.calls.filter(call => !call[2].includes("--version"))).toHaveLength(1); + const call = runChildProcess.mock.calls.find(call => !call[2].includes("--version")) as unknown as | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] | undefined; - expect(call?.[2]).toEqual(expect.arrayContaining(["--model", "claude-opus-5"])); + expect(call?.[2]).toEqual(expect.arrayContaining(["--model", "claude-opus-5-5"])); expect(call?.[2]).toContain("--dangerously-skip-permissions"); expect(call?.[2]).not.toContain("--allowedTools"); expect(call?.[2]).toContain("--append-system-prompt-file"); @@ -255,7 +255,7 @@ describe("claude remote execution", () => { sessionDisplayId: "12345678-1234-4abc-9def-123456789012", taskKey: null, }, - config: { + config: { model: "claude-opus-5", engine: "cli", command: "claude", }, @@ -280,8 +280,8 @@ describe("claude remote execution", () => { onLog: async () => {}, }); - expect(runChildProcess).toHaveBeenCalledTimes(1); - const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined; + expect(runChildProcess.mock.calls.filter(call => !call[2].includes("--version"))).toHaveLength(1); + const call = runChildProcess.mock.calls.find(call => !call[2].includes("--version")) as unknown as [string, string, string[]] | undefined; expect(call?.[2]).not.toContain("--resume"); }); @@ -303,7 +303,7 @@ describe("claude remote execution", () => { sessionDisplayId: null, taskKey: null, }, - config: { engine: "cli", command: "claude", cwd: rootDir, paperclipRuntimeSkills: [] }, + config: { model: "claude-opus-5", engine: "cli", command: "claude", cwd: rootDir, paperclipRuntimeSkills: [] }, context: {}, onLog, }); @@ -345,7 +345,7 @@ describe("claude remote execution", () => { sessionDisplayId: "12345678-1234-4abc-9def-123456789012", taskKey: null, }, - config: { + config: { model: "claude-opus-5", engine: "cli", command: "claude", }, @@ -370,8 +370,8 @@ describe("claude remote execution", () => { onLog: async () => {}, }); - expect(runChildProcess).toHaveBeenCalledTimes(1); - const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined; + expect(runChildProcess.mock.calls.filter(call => !call[2].includes("--version"))).toHaveLength(1); + const call = runChildProcess.mock.calls.find(call => !call[2].includes("--version")) as unknown as [string, string, string[]] | undefined; expect(call?.[2]).toContain("--resume"); expect(call?.[2]).toContain("12345678-1234-4abc-9def-123456789012"); }); @@ -413,7 +413,7 @@ describe("claude remote execution", () => { sessionDisplayId: null, taskKey: null, }, - config: { + config: { model: "claude-opus-5", engine: "cli", command: "claude", }, @@ -463,7 +463,7 @@ describe("claude remote execution", () => { sessionDisplayId: null, taskKey: null, }, - config: { + config: { model: "claude-opus-5", engine: "cli", command: "claude", ...config, @@ -619,7 +619,7 @@ describe("claude remote execution", () => { sessionDisplayId: "12345678-1234-4abc-9def-123456789012", taskKey: null, }, - config: { + config: { model: "claude-opus-5", engine: "cli", command: "claude", env: { ANTHROPIC_API_KEY: "fixture-anthropic-key" }, @@ -633,7 +633,7 @@ describe("claude remote execution", () => { expect(result.exitCode).toBe(0); expect(runChildProcess).toHaveBeenCalledTimes(2); - const first = (runChildProcess.mock.calls[0] as unknown as [string, string, string[], { stdin?: string }])[3]?.stdin ?? ""; + const first = (runChildProcess.mock.calls.find(call => !call[2].includes("--version")) as unknown as [string, string, string[], { stdin?: string }])[3]?.stdin ?? ""; const retry = (runChildProcess.mock.calls[1] as unknown as [string, string, string[], { stdin?: string }])[3]?.stdin ?? ""; expect(first).toContain("## Compact assignment"); expect(first).not.toContain("Explain the next step before starting work."); diff --git a/packages/adapters/claude-local/src/server/test.probe.test.ts b/packages/adapters/claude-local/src/server/test.probe.test.ts index 7273ca6adff..42a4fcfc22d 100644 --- a/packages/adapters/claude-local/src/server/test.probe.test.ts +++ b/packages/adapters/claude-local/src/server/test.probe.test.ts @@ -139,7 +139,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -170,7 +170,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -202,7 +202,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -239,7 +239,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -269,7 +269,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -296,7 +296,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -323,7 +323,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -361,7 +361,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -401,7 +401,7 @@ describe("claude sandbox hello probe diagnostics", () => { testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }), @@ -428,7 +428,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -451,7 +451,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -471,7 +471,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -494,7 +494,7 @@ describe("claude auth mode hints", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { + config: { model: "claude-opus-5", engine: "cli", command: "claude", env: { CLAUDE_CODE_OAUTH_TOKEN: "oauth-test-token" }, @@ -515,7 +515,7 @@ describe("claude auth mode hints", () => { it("reports an intentionally selected managed API account without a subscription warning", async () => { probeResult.value = { exitCode: 0, stdout: successStdout, stderr: "" }; const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude", managedAiConnection: { provider: "anthropic", method: "api_key" }, env: { ANTHROPIC_API_KEY: "api-test-key" } }, + config: { model: "claude-opus-5", engine: "cli", command: "claude", managedAiConnection: { provider: "anthropic", method: "api_key" }, env: { ANTHROPIC_API_KEY: "api-test-key" } }, executionTarget: sandboxTarget, environmentName: "Daytona", }); expect(result.checks.find(check => check.code === "claude_anthropic_api_key_overrides_subscription")).toMatchObject({ level: "info", message: "Using the selected Claude API connection." }); @@ -528,7 +528,7 @@ describe("claude auth mode hints", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { + config: { model: "claude-opus-5", engine: "cli", command: "claude", env: { @@ -606,7 +606,7 @@ describe("claude CLI local hello probe hardening", () => { await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { + config: { model: "claude-opus-5", engine: "cli", command: "/tmp/evil/claude", env: { @@ -683,7 +683,7 @@ describe("claude CLI local hello probe hardening", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: null, environmentName: null, }); diff --git a/packages/adapters/claude-local/src/server/test.remote.test.ts b/packages/adapters/claude-local/src/server/test.remote.test.ts index 80ecd74cccf..94c9633155a 100644 --- a/packages/adapters/claude-local/src/server/test.remote.test.ts +++ b/packages/adapters/claude-local/src/server/test.remote.test.ts @@ -109,7 +109,7 @@ describe("claude sandbox auth-missing check", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -130,7 +130,7 @@ describe("claude sandbox auth-missing check", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sshTarget, environmentName: "Remote host", }); diff --git a/packages/adapters/claude-local/src/ui/build-config.test.ts b/packages/adapters/claude-local/src/ui/build-config.test.ts index 30eb04c9d71..591970e6777 100644 --- a/packages/adapters/claude-local/src/ui/build-config.test.ts +++ b/packages/adapters/claude-local/src/ui/build-config.test.ts @@ -37,6 +37,20 @@ function makeValues(overrides: Partial = {}): CreateConfigVa } describe("buildClaudeLocalConfig", () => { + it.each([undefined, "legacy"] as const)("retains the default turn cap when Auto has resolved to %s", resolvedRunner => { + expect(buildClaudeLocalConfig(makeValues({ runner: "auto", resolvedRunner })).maxTurnsPerRun).toBe(1000); + }); + it.each(["auto", "paperclip"] as const)("omits the Legacy default only for native %s", runner => { + const config = buildClaudeLocalConfig(makeValues({ runner, resolvedRunner: "paperclip" })); + expect(config).not.toHaveProperty("maxTurnsPerRun"); + expect(buildClaudeLocalConfig(makeValues({ runner, resolvedRunner: "paperclip", maxTurnsPerRun: 3 })).maxTurnsPerRun).toBe(3); + }); + it("retains an explicit Legacy cap over a stale native form resolution", () => { + expect(buildClaudeLocalConfig(makeValues({ runner: "legacy", resolvedRunner: "paperclip" })).maxTurnsPerRun).toBe(1000); + }); + it.each(["approve-all", "approve-reads", "deny-all"])("carries the selected native %s permission to creation", acpxPermissionMode => { + expect(buildClaudeLocalConfig(makeValues({ resolvedRunner: "paperclip", adapterSchemaValues: { acpxPermissionMode } })).acpxPermissionMode).toBe(acpxPermissionMode); + }); it("omits engine for the auto default so runtime fallback remains available", () => { const config = buildClaudeLocalConfig(makeValues({ claudeEngine: "auto" })); diff --git a/packages/adapters/claude-local/src/ui/build-config.ts b/packages/adapters/claude-local/src/ui/build-config.ts index 9262984806c..c3d452a8e92 100644 --- a/packages/adapters/claude-local/src/ui/build-config.ts +++ b/packages/adapters/claude-local/src/ui/build-config.ts @@ -43,7 +43,12 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record 0) ac.env = env; - ac.maxTurnsPerRun = v.maxTurnsPerRun; + const nativeSelected = v.runner === "paperclip" + || ((v.runner === undefined || v.runner === "auto") && v.resolvedRunner === "paperclip"); + if (!nativeSelected || v.maxTurnsPerRun !== 1000) ac.maxTurnsPerRun = v.maxTurnsPerRun; + if (nativeSelected && v.adapterSchemaValues?.acpxPermissionMode !== undefined) { + ac.acpxPermissionMode = v.adapterSchemaValues.acpxPermissionMode; + } ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions; if (v.workspaceStrategyType === "git_worktree") { ac.workspaceStrategy = { diff --git a/packages/adapters/codex-local/src/ui/build-config.test.ts b/packages/adapters/codex-local/src/ui/build-config.test.ts index 019f635a35b..12df8af81d4 100644 --- a/packages/adapters/codex-local/src/ui/build-config.test.ts +++ b/packages/adapters/codex-local/src/ui/build-config.test.ts @@ -118,10 +118,15 @@ describe("buildCodexLocalConfig", () => { }); describe("buildPaperclipRunnerConfig", () => { - it.each(["", "max"])("preserves or overrides saved native Codex reasoning effort (%s)", thinkingEffort => { - const values = makeValues({ thinkingEffort, adapterSchemaValues: { provider: "codex", modelReasoningEffort: "high" } }); - expect(buildPaperclipRunnerConfig(values).modelReasoningEffort).toBe(thinkingEffort || "high"); - expect(buildPaperclipRunnerConfig({ ...values, adapterSchemaValues: { provider: "acpx", acpxAgent: "claude", modelReasoningEffort: "high" } })).not.toHaveProperty("modelReasoningEffort"); + it.each(["codex", "claude"])("preserves, overrides and explicitly clears saved %s reasoning effort", harness => { + const profile = harness === "codex" ? { provider: "codex" } : { provider: "acpx", acpxAgent: "claude" }; + const values = makeValues({ adapterSchemaValues: { ...profile, modelReasoningEffort: "high", reasoningEffort: "low", effort: "low" } }); + const unhydrated = { ...values }; + Reflect.deleteProperty(unhydrated, "thinkingEffort"); + expect(buildPaperclipRunnerConfig(unhydrated).modelReasoningEffort).toBe("high"); + expect(buildPaperclipRunnerConfig({ ...values, thinkingEffort: "max" }).modelReasoningEffort).toBe("max"); + const cleared = buildPaperclipRunnerConfig({ ...values, thinkingEffort: "" }); + for (const key of ["modelReasoningEffort", "reasoningEffort", "effort"]) expect(cleared).not.toHaveProperty(key); }); it.each([undefined, false, true])("preserves Dot attachment consent in create/import forms: %s", value => { const config = buildPaperclipRunnerConfig(makeValues({ adapterType: "paperclip_runner", adapterSchemaValues: { provider: "openai_dot", dotAttachmentAccess: value } })); @@ -225,6 +230,10 @@ describe("buildPaperclipRunnerConfig", () => { }); }); + it.each(["modelReasoningEffort", "reasoningEffort", "effort"])("keeps explicit Auto Claude effort cleared over saved %s", key => { + const config = buildPaperclipRunnerConfig(makeValues({ thinkingEffort: "", adapterSchemaValues: { provider: "acpx", acpxAgent: "claude", [key]: "high" } })); + for (const effortKey of ["modelReasoningEffort", "reasoningEffort", "effort"]) expect(config).not.toHaveProperty(effortKey); + }); it.each(["claude-opus-5", "my-custom-model"])("preserves the selected ACPX Claude model %s", (model) => { expect(buildPaperclipRunnerConfig(makeValues({ model, adapterSchemaValues: { provider: "acpx" } }))) .toMatchObject({ provider: "acpx", acpxAgent: "claude", model }); diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts index 060fbbfdf91..a83f0c19e9d 100644 --- a/packages/adapters/codex-local/src/ui/build-config.ts +++ b/packages/adapters/codex-local/src/ui/build-config.ts @@ -76,7 +76,9 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record { const config = buildCodexLocalConfig(v); const schemaValues = normalizeLegacyRunnerProvider({ ...(v.adapterSchemaValues ?? {}) }); - const codexReasoningEffort = config.modelReasoningEffort ?? schemaValues.modelReasoningEffort ?? schemaValues.reasoningEffort; + const configuredEffort = typeof v.thinkingEffort === "string" + ? v.thinkingEffort.trim() + : schemaValues.modelReasoningEffort || schemaValues.reasoningEffort || schemaValues.effort; for (const unsupportedKey of [ "engine", "agentCommand", @@ -232,13 +234,19 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record { expect(mockAgentService.create).not.toHaveBeenCalled(); }); - it("rechecks selection policy when a saved native adapter switches its underlying harness", async () => { + it("rechecks selection policy when a saved native adapter switches to an experimentally gated harness", async () => { mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } }); mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false }); const probe = vi.fn(); @@ -722,7 +722,7 @@ describe("agent routes adapter validation", () => { const app = await createApp(); const res = await requestApp(app, baseUrl => request(baseUrl) .post("/api/companies/company-1/adapters/paperclip_runner/test-environment") - .send({ agentId: "11111111-1111-4111-8111-111111111111", adapterConfig: { provider: "acpx", acpxAgent: "claude" } })); + .send({ agentId: "11111111-1111-4111-8111-111111111111", adapterConfig: { provider: "acpx", acpxAgent: "pi" } })); expect(res.status, JSON.stringify(res.body)).toBe(422); expect(res.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" }); expect(probe).not.toHaveBeenCalled(); @@ -762,9 +762,9 @@ describe("agent routes adapter validation", () => { expect(mockAgentService.create).not.toHaveBeenCalled(); }); - it("keeps non-Codex native provider selection behind its existing flag", async () => { + it("keeps other native provider selection behind its existing flag", async () => { const app = await createApp(); - const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Native Claude", adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" } })); + const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Native OpenCode", adapterType: "paperclip_runner", adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" } })); expect(response.status).toBe(422); expect(response.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" }); }); diff --git a/server/src/__tests__/agent-hire-ai-connections.test.ts b/server/src/__tests__/agent-hire-ai-connections.test.ts index c588b24b70c..c824821e214 100644 --- a/server/src/__tests__/agent-hire-ai-connections.test.ts +++ b/server/src/__tests__/agent-hire-ai-connections.test.ts @@ -134,7 +134,7 @@ describe("agent-created hires use managed AI connections", () => { const f = await fixture("openai"); const p = await poolFixture(f); await db.update(agents).set({ runtimeConfig: { aiConnection: p.binding } }).where(eq(agents.id, f.agentId)); - const response = await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Incompatible teammate", role: "engineer", adapterType: "claude_local" }); + const response = await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Incompatible teammate", role: "engineer", adapterType: "claude_local", runner: "legacy" }); expect(response.status, JSON.stringify(response.body)).toBe(422); // Fixed bindings contain identity only; compatibility is decided from // authoritative connection metadata when selecting each pool member. @@ -155,7 +155,7 @@ describe("agent-created hires use managed AI connections", () => { ["opencode_local", {}, "PAPERCLIP_OPENCODE_PROVIDERS"], ["opencode_local", { model: "anthropic/claude-sonnet-5" }, "OPENCODE_AUTH_JSON"], ["paperclip_runner", { provider: "opencode", model: "anthropic/claude-sonnet-5" }, "OPENCODE_CONFIG_CONTENT"], - ] as const)("%s hires with %j keep explicit %s auth outside the managed OpenRouter model catalog", async (adapterType, config, key) => { + ] as const)("%s hires with %j apply the host boundary to explicit %s auth", async (adapterType, config, key) => { const f = await fixture("openai"); const p = await poolFixture(f); await db.update(agents).set({ runtimeConfig: { aiConnection: p.binding } }).where(eq(agents.id, f.agentId)); @@ -163,9 +163,17 @@ describe("agent-created hires use managed AI connections", () => { const value = JSON.stringify(key === "PAPERCLIP_OPENCODE_PROVIDERS" ? { anthropic: provider } : key === "OPENCODE_CONFIG_CONTENT" ? { provider: { anthropic: provider } } : { anthropic: { type: "api", key: "fixture-explicit-key" } }); - const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ + const response = await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Independent OpenCode teammate", role: "engineer", adapterType, adapterConfig: { ...config, env: { [key]: value } }, - })); + }); + if (adapterType === "paperclip_runner") { + expect(response.status).toBe(403); + expect(response.body.error).toContain("host-executed local adapter settings"); + const savedAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, f.companyId)); + expect(savedAgents.map(agent => agent.id)).toEqual([f.agentId]); + return; + } + const agent = hired(response); expect(agent.runtimeConfig.aiConnection).toBeUndefined(); expect(agent.adapterConfig.env[key]).toEqual({ type: "plain", value }); expect(await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.targetId, agent.id))).toEqual([]); @@ -385,19 +393,16 @@ describe("agent-created hires use managed AI connections", () => { ["openai", "claude_local", {}, "OPENAI_API_KEY"], ["anthropic", "paperclip_runner", { provider: "codex" }, "ANTHROPIC_API_KEY"], ["openai", "paperclip_runner", { provider: "acpx", acpxAgent: "claude" }, "OPENAI_API_KEY"], - ] as const)(`${endpoint}: ignores the %s auth key for a different provider in %s`, async (provider, adapterType, config, key) => { + ] as const)(`${endpoint}: rejects host env settings for a %s caller hiring %s`, async (provider, adapterType, config, key) => { const f = await fixture(provider); const response = await request(f.app).post(`/api/companies/${f.companyId}/${endpoint}`).send({ name: "Cross-provider config", role: "engineer", adapterType, adapterConfig: { ...config, env: { [key]: "leftover-parent-setting" } }, }); - if (adapterType.endsWith("_local")) { - expect(response.status).toBe(403); - expect(response.body.error).toContain("host-executed local adapter settings"); - return; - } - const agent = hired(response); - expect(agent.runtimeConfig.aiConnection).toMatchObject({ provider: provider === "anthropic" ? "openai" : "anthropic", mode: "responsible_user" }); + expect(response.status).toBe(403); + expect(response.body.error).toContain("host-executed local adapter settings"); + const savedAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, f.companyId)); + expect(savedAgents.map(agent => agent.id)).toEqual([f.agentId]); }); } diff --git a/server/src/__tests__/agent-hire-auth-inheritance-routes.test.ts b/server/src/__tests__/agent-hire-auth-inheritance-routes.test.ts index 42db2f99c01..5159cb27d88 100644 --- a/server/src/__tests__/agent-hire-auth-inheritance-routes.test.ts +++ b/server/src/__tests__/agent-hire-auth-inheritance-routes.test.ts @@ -265,6 +265,34 @@ describeEmbeddedPostgres("hired agent provider credential inheritance", () => { expect(childEnvOf(res).XAI_API_KEY).toMatchObject({ type: "secret_ref", secretId: secret.id }); }); + it.each(["create", "hire"] as const)("blocks native Claude credential redirection before %s inheritance", async mode => { + const companyId = await seedCompany(); + const secret = await createCompanySecret(companyId, "fixture-parent-credential"); + const parent = await seedParentAgent(companyId, "paperclip_runner", { ANTHROPIC_API_KEY: secretRef(secret.id) }); + await db.update(agents).set({ adapterConfig: { provider: "acpx", acpxAgent: "claude", env: { ANTHROPIC_API_KEY: secretRef(secret.id) } } }).where(eq(agents.id, parent.id)); + const app = createApp(db, agentActor(companyId, parent.id)); + const res = await request(app).post(`/api/companies/${companyId}/${mode === "hire" ? "agent-hires" : "agents"}`).send({ + name: "Redirected child", role: "engineer", adapterType: "paperclip_runner", + adapterConfig: { provider: "acpx", acpxAgent: "claude", env: { ANTHROPIC_BASE_URL: "https://untrusted.example.test" } }, + }); + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toContain("host-executed local adapter settings"); + expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(1); + }); + + it("still inherits Claude credential references for an ordinary native hire", async () => { + const companyId = await seedCompany(); + const secret = await createCompanySecret(companyId, "fixture-parent-credential"); + const parent = await seedParentAgent(companyId, "claude_local", { ANTHROPIC_API_KEY: secretRef(secret.id) }); + const res = await hire(agentActor(companyId, parent.id), companyId, { + name: "Native child", role: "engineer", adapterType: "paperclip_runner", + adapterConfig: { provider: "acpx", acpxAgent: "claude" }, + }); + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(res.body.agent.adapterType).toBe("paperclip_runner"); + expect(childEnvOf(res).ANTHROPIC_API_KEY).toMatchObject({ type: "secret_ref", secretId: secret.id }); + }); + it("inherits a claude_local parent's ANTHROPIC_API_KEY reference", async () => { const companyId = await seedCompany(); const secret = await createCompanySecret(companyId, "ant-parent-key"); @@ -499,13 +527,13 @@ describeEmbeddedPostgres("hired agent provider credential inheritance", () => { const parent = await seedParentAgent(companyId, "paperclip_runner", {}); await db.update(agents).set({ adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5", acpxPermissionMode: "approve-all", - env: { ANTHROPIC_API_KEY: secretRef("parent-secret") }, cwd: "/private/parent", + env: { PRIVATE_PARENT_TOKEN: secretRef("parent-secret") }, cwd: "/private/parent", invocationLimits: { maxIterations: 3, credentials: "never-copy" }, } }).where(eq(agents.id, parent.id)); const res = await hire(agentActor(companyId, parent.id), companyId, { name: "Approval Snapshot Child", role: "engineer", adapterType: "paperclip_runner", inheritRuntimeFrom: "caller", }); - expect(res.status).toBe(201); + expect(res.status, JSON.stringify(res.body)).toBe(201); expect(res.body.agent.status).toBe("pending_approval"); expect(res.body.approval.payload.inheritRuntimeFrom).toBeUndefined(); expect(res.body.approval.payload.adapterConfig).toMatchObject({ provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all" }); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 446baf0cbb7..3cf6d2f3166 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1027,6 +1027,26 @@ describe("agent permission routes", () => { expect(mockAgentService.update).toHaveBeenCalledOnce(); }); + it.each(["ANTHROPIC_BASE_URL", "HTTP_PROXY", "NODE_OPTIONS"])("blocks agent-supplied %s on native Claude updates", async key => { + mockAgentService.getById.mockResolvedValue({ ...baseAgent, adapterType: "paperclip_runner", + adapterConfig: { provider: "acpx", acpxAgent: "claude" } }); + const app = await createApp({ type: "agent", agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId, source: "agent_key", runId: "run-1" }); + const res = await requestApp(app, baseUrl => request(baseUrl).patch(`/api/agents/${agentId}`) + .send({ adapterConfig: { env: { [key]: "untrusted-setting" } } })); + expect(res.status).toBe(403); + expect(mockAgentService.update).not.toHaveBeenCalled(); + }); + + it("allows native Claude credential references in partial updates using the saved harness", async () => { + mockAgentService.getById.mockResolvedValue({ ...baseAgent, adapterType: "paperclip_runner", + adapterConfig: { provider: "acpx", acpxAgent: "claude" } }); + const app = await createApp({ type: "agent", agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId, source: "agent_key", runId: "run-1" }); + const res = await requestApp(app, baseUrl => request(baseUrl).patch(`/api/agents/${agentId}`) + .send({ adapterConfig: { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "33333333-3333-4333-8333-333333333333" } } } })); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockAgentService.update).toHaveBeenCalledOnce(); + }); + it("blocks an agent from activating inherited host settings by switching onto a local adapter", async () => { mockAgentService.getById.mockResolvedValue({ ...baseAgent, @@ -1853,8 +1873,8 @@ describe("agent permission routes", () => { }), { runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); - if (adapterCase.adapterType === "codex_local") { - expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "codex" }); + if (["codex_local", "claude_local"].includes(adapterCase.adapterType)) { + if (adapterCase.adapterType === "codex_local") expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "codex" }); 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 f49dcd8ea56..fe24cc7fc36 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -909,6 +909,7 @@ describe("agent skill routes", () => { name: "QA Agent", role: "engineer", adapterType: "claude_local", + runner: "legacy", desiredSkills: ["paperclip"], adapterConfig: {}, })); @@ -942,6 +943,7 @@ describe("agent skill routes", () => { name: "QA Agent", role: "engineer", adapterType: "claude_local", + runner: "legacy", desiredSkills: [{ key: "paperclipai/paperclip/paperclip", versionId: "22222222-2222-4222-8222-222222222222", @@ -961,6 +963,7 @@ describe("agent skill routes", () => { name: "Security Engineer", role: "security", adapterType: "claude_local", + runner: "legacy", adapterConfig: {}, })); @@ -992,6 +995,7 @@ describe("agent skill routes", () => { name: "QA Agent", role: "engineer", adapterType: "claude_local", + runner: "legacy", adapterConfig: {}, instructionsBundle: { files: { @@ -1028,6 +1032,7 @@ describe("agent skill routes", () => { name: "QA Agent", role: "engineer", adapterType: "claude_local", + runner: "legacy", adapterConfig: { instructionsFilePath: "/tmp/existing/AGENTS.md", promptTemplate: "You are QA.", @@ -1054,6 +1059,7 @@ describe("agent skill routes", () => { name: "CEO", role: "ceo", adapterType, + ...(adapterType === "claude_local" ? { runner: "legacy" } : {}), adapterConfig: adapterType === "paperclip_runner" ? { provider: "codex" } : {}, })); @@ -1084,6 +1090,7 @@ describe("agent skill routes", () => { name: "Research Lead", role: "ceo", adapterType, + ...(adapterType === "claude_local" ? { runner: "legacy" } : {}), adapterConfig: adapterType === "paperclip_runner" ? { provider: "codex" } : {}, instructionsBundle: { entryFile: "AGENTS.md", files: customFiles }, })); @@ -1110,6 +1117,7 @@ describe("agent skill routes", () => { name: "Engineer", role: "engineer", adapterType: "claude_local", + runner: "legacy", adapterConfig: {}, })); @@ -1137,6 +1145,7 @@ describe("agent skill routes", () => { name: "Ada", role: "general", adapterType: "claude_local", + runner: "legacy", adapterConfig: {}, onboardingFirstAgent: true, })); @@ -1168,7 +1177,12 @@ describe("agent skill routes", () => { { adapterType: "codex_local", runner: "legacy", platform: "darwin", expectedType: "codex_local" }, { adapterType: "codex_local", runner: "auto", platform: "win32", expectedType: "codex_local" }, ]; - it.each([...codexRunnerCases, { adapterType: "claude_local", runner: "auto", platform: "linux", expectedType: "claude_local" }].flatMap((runnerCase) => [ + const creationRunnerCases = [...codexRunnerCases, + { adapterType: "claude_local", runner: "auto", 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" }, + ]; + it.each(creationRunnerCases.flatMap((runnerCase) => [ ["agents", "paperclipai/paperclip/paperclip-create-agent"], ["agent-hires", "paperclipai/paperclip/paperclip-create-agent"], ["agents", "paperclip"], @@ -1192,7 +1206,7 @@ describe("agent skill routes", () => { expect(input.role).toBe("general"); expect(input.adapterType).toBe(expectedType); const native = expectedType === "paperclip_runner"; - if (native) expect(input.adapterConfig.provider).toBe("codex"); + if (native) expect(input.adapterConfig).toMatchObject(adapterType === "claude_local" ? { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5" } : { provider: "codex" }); const canonicalKey = skill === "paperclip" ? "paperclipai/paperclip/paperclip" : skill; const expected = ["paperclip", "paperclip-board", "paperclip-converting-plans-to-tasks", "paperclip-create-agent", "para-memory-files", "first-task"] .filter((name) => !native || name !== "paperclip") @@ -1211,17 +1225,17 @@ describe("agent skill routes", () => { expect(mockAgentService.create.mock.calls[0][1].adapterConfig.paperclipSkillSync).toBeUndefined(); }); - it.each(codexRunnerCases.flatMap((runnerCase) => ["agents", "agent-hires"].map((route) => ({ ...runnerCase, route }))))("assigns core skills without first-task to ordinary CEOs via $route using $runner on $platform", async ({ route, runner, platform, expectedType }) => { + it.each(creationRunnerCases.flatMap((runnerCase) => ["agents", "agent-hires"].map((route) => ({ ...runnerCase, route }))))("assigns core skills without first-task to ordinary CEOs via $route using $runner on $platform", async ({ route, runner, platform, expectedType, adapterType }) => { runnerTarget.platform = platform; runnerTarget.architecture = platform === "darwin" ? "arm64" : "x64"; const res = await request(await createApp()) .post(`/api/companies/company-1/${route}`) - .send({ name: "CEO", role: "ceo", adapterType: "codex_local", ...(runner === "auto" ? {} : { runner }) }); + .send({ name: "CEO", role: "ceo", adapterType, ...(runner === "auto" ? {} : { runner }) }); expect(res.status, JSON.stringify(res.body)).toBe(201); const input = mockAgentService.create.mock.calls[0][1]; expect(input.adapterType).toBe(expectedType); const native = expectedType === "paperclip_runner"; - if (native) expect(input.adapterConfig.provider).toBe("codex"); + if (native) expect(input.adapterConfig).toMatchObject(adapterType === "claude_local" ? { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5" } : { provider: "codex" }); const desiredSkills = input.adapterConfig.paperclipSkillSync.desiredSkills; const expected = ["paperclip", "paperclip-board", "paperclip-converting-plans-to-tasks", "paperclip-create-agent", "para-memory-files"] .filter((name) => !native || name !== "paperclip") @@ -1237,7 +1251,8 @@ describe("agent skill routes", () => { type: "agent", agentId: "11111111-1111-4111-8111-111111111111", companyId: "company-1", })) .post("/api/companies/company-1/agent-hires") - .send({ name: "Biff", role: "general", adapterType: "claude_local", onboardingFirstAgent: true }); + .send({ name: "Biff", role: "general", adapterType: "claude_local", + runner: "legacy", onboardingFirstAgent: true }); expect(res.status, JSON.stringify(res.body)).toBe(201); expect(mockAgentService.create.mock.calls[0][1].adapterConfig.paperclipSkillSync).toBeUndefined(); await vi.waitFor(() => expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalled()); @@ -1272,6 +1287,7 @@ describe("agent skill routes", () => { name: "First Lead", role: "ceo", adapterType: "claude_local", + runner: "legacy", adapterConfig: {}, }); @@ -1329,6 +1345,7 @@ describe("agent skill routes", () => { name: "First Lead", role: "ceo", adapterType: "claude_local", + runner: "legacy", desiredSkills: ["paperclip"], adapterConfig: {}, }); @@ -1350,6 +1367,7 @@ describe("agent skill routes", () => { name: "QA Agent", role: "engineer", adapterType: "claude_local", + runner: "legacy", adapterConfig: {}, }); @@ -1367,6 +1385,7 @@ describe("agent skill routes", () => { name: "QA Agent", role: "engineer", adapterType: "claude_local", + runner: "legacy", desiredSkills: [{ key: "paperclipai/paperclip/paperclip", versionId: "22222222-2222-4222-8222-222222222222", @@ -1391,6 +1410,7 @@ describe("agent skill routes", () => { role: "engineer", icon: "crown", adapterType: "claude_local", + runner: "legacy", desiredSkills: ["paperclip"], adapterConfig: {}, sourceIssueId, @@ -1445,6 +1465,7 @@ describe("agent skill routes", () => { name: "QA Agent", role: "engineer", adapterType: "claude_local", + runner: "legacy", adapterConfig: {}, instructionsBundle: { files: { @@ -1481,6 +1502,7 @@ describe("agent skill routes", () => { name: "QA Agent", role: "engineer", adapterType: "claude_local", + runner: "legacy", adapterConfig: { instructionsFilePath: "/tmp/existing/AGENTS.md", promptTemplate: "You are QA.", diff --git a/server/src/__tests__/agents-claude-oauth-binding.test.ts b/server/src/__tests__/agents-claude-oauth-binding.test.ts index 4d645e6388e..e6d67135f03 100644 --- a/server/src/__tests__/agents-claude-oauth-binding.test.ts +++ b/server/src/__tests__/agents-claude-oauth-binding.test.ts @@ -48,6 +48,16 @@ const withEnv = (env: Record) => ({ env }); // --- The pure server-enforced binding invariant (no database) ---------------- describe("assertClaudeOAuthBindingInvariant", () => { + it.each([undefined, "claude"])("enforces the fixed binding for native Claude (agent: %s)", acpxAgent => { + const nextConfig = { provider: "acpx", ...(acpxAgent && { acpxAgent }), ...withEnv({ CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING }) }; + expect(assertClaudeOAuthBindingInvariant({ adapterType: "paperclip_runner", nextConfig })) + .toEqual({ introducesBinding: true, keepsBinding: false }); + expect(() => assertClaudeOAuthBindingInvariant({ adapterType: "paperclip_runner", nextConfig: { + ...nextConfig, env: { ...nextConfig.env, ANTHROPIC_API_KEY: "fixture-api-key" }, + } })).toThrow(CLAUDE_OAUTH_CREDENTIAL_CONFLICT); + expect(assertClaudeOAuthBindingInvariant({ adapterType: "paperclip_runner", nextConfig: { ...nextConfig, acpxAgent: "codex" } })) + .toEqual({ introducesBinding: false, keepsBinding: false }); + }); it("reports that a create introduces the fixed binding", () => { const decision = assertClaudeOAuthBindingInvariant({ adapterType: "claude_local", @@ -312,6 +322,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => { role: "engineer", status: "idle" as const, adapterType: "claude_local", + runner: "legacy" as const, defaultEnvironmentId: scope.environmentId, adapterConfig: { env: { CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_BINDING }, ...extraEnv } }, runtimeConfig: {}, @@ -359,11 +370,13 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => { return rows.length; } - it("inserts the fixed binding and consumes a valid claim exactly once", async () => { + it.each([false, true])("inserts the fixed binding and consumes a valid claim exactly once (native: %s)", async native => { const scope = await seedScope(); const sessionId = await seedStoredClaim(scope, Date.now() + 60_000); + const legacyInput = createInput(scope); + const input = native ? { ...legacyInput, runner: undefined, adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", ...legacyInput.adapterConfig } } : legacyInput; - const created = await createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), { + const created = await createAgentLifecycle(db).requestHire(scope.companyId, input, { claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId }, }); @@ -377,7 +390,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => { // A replay of the same claim inserts no second agent. await expect( - createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), { + createAgentLifecycle(db).requestHire(scope.companyId, input, { claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId }, }), ).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED }); @@ -616,6 +629,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => { role: "engineer", status: "idle", adapterType: "claude_local", + runner: "legacy", defaultEnvironmentId: scope.environmentId, adapterConfig: { env: {} }, runtimeConfig: {}, @@ -680,6 +694,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => { role: "engineer", status: "idle", adapterType: "claude_local", + runner: "legacy", defaultEnvironmentId: scope.environmentId, adapterConfig: { env: {} }, runtimeConfig: {}, @@ -755,6 +770,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => { role: "engineer", status: "idle", adapterType: "claude_local", + runner: "legacy", defaultEnvironmentId: scope.environmentId, adapterConfig: { env: {} }, runtimeConfig: {}, diff --git a/server/src/__tests__/ai-connection-router.test.ts b/server/src/__tests__/ai-connection-router.test.ts index 209bcb33df4..8e88d57b348 100644 --- a/server/src/__tests__/ai-connection-router.test.ts +++ b/server/src/__tests__/ai-connection-router.test.ts @@ -202,6 +202,9 @@ describe("durable, authorized connection routing", () => { expect(changed.binding).toEqual(first.binding); expect(changed.runtimeConfig).toMatchObject({ provider: "codex", model: "gpt-5.6-sol", modelReasoningEffort: "high" }); expect(changed.notes).toHaveLength(1); const claude = poolMemberRuntimeConfig(members[1]!, "paperclip_runner", { model: "gpt-5.6-sol", modelReasoningEffort: "ultra" }); expect(claude.config).toMatchObject({ provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" }); expect(claude.notes).toHaveLength(2); + const claudeEffort = poolMemberRuntimeConfig(members[1]!, "paperclip_runner", { modelReasoningEffort: "xhigh" }); + expect(claudeEffort.config).toMatchObject({ provider: "acpx", acpxAgent: "claude", modelReasoningEffort: "xhigh" }); + expect(claudeEffort.notes).toEqual([]); const overrides = applyAiConnectionRouterTaskSettings({ model: "claude-sonnet-5", effort: "invalid", cwd: "/fixture" }, changed); const native = resolvePaperclipRunnerNativeProviderInput({ backend: "codex_app_server", adapterConfig: projectPaperclipRunnerTaskConfig("codex_app_server", changed.runtimeConfig, overrides) }); expect(native).toMatchObject({ provider: "codex", model: "gpt-5.6-sol", codexReasoningEffort: "high" }); diff --git a/server/src/__tests__/claude-local-adapter-environment.test.ts b/server/src/__tests__/claude-local-adapter-environment.test.ts index 055e66d9ea4..69a321b05e7 100644 --- a/server/src/__tests__/claude-local-adapter-environment.test.ts +++ b/server/src/__tests__/claude-local-adapter-environment.test.ts @@ -48,6 +48,7 @@ afterEach(() => { async function writeHelpWithoutEffortClaudeCommand(commandPath: string): Promise { const script = `#!/usr/bin/env node +if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); } const argv = process.argv.slice(2); if (argv.includes("--help")) { process.stdout.write("Usage: claude [options]\\n --print\\n --model \\n"); @@ -306,6 +307,7 @@ describe("claude_local environment diagnostics", () => { adapterType: "claude_local", config: { engine: "cli", + model: "claude-opus-5", command: "claude", }, executionTarget: { @@ -379,6 +381,7 @@ describe("claude_local environment diagnostics", () => { await fs.writeFile(path.join(sourceConfigDir, "credentials.json"), JSON.stringify({ token: "local" }), "utf8"); await fs.writeFile(path.join(remoteHome, ".claude", ".credentials.json"), JSON.stringify({ token: "remote" }), "utf8"); await fs.writeFile(commandPath, `#!/usr/bin/env node +if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); } const fs = require("fs"); const path = require("path"); const configDir = process.env.CLAUDE_CONFIG_DIR || ""; diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts index adea792ca6e..0211abcb545 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -20,6 +20,7 @@ async function writeFailingClaudeCommand( const payload = JSON.stringify(options.resultEvent); const exit = options.exitCode ?? 1; const script = `#!/usr/bin/env node +if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); } console.log(${JSON.stringify(payload)}); process.exit(${exit}); `; @@ -33,6 +34,7 @@ async function writeTextFailingClaudeCommand( ): Promise { const exit = options.exitCode ?? 1; const script = `#!/usr/bin/env node +if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); } if (${JSON.stringify(options.stdout ?? "")}) { process.stdout.write(${JSON.stringify(options.stdout ?? "")}); } @@ -47,6 +49,7 @@ process.exit(${exit}); async function writeFakeClaudeCommand(commandPath: string): Promise { const script = `#!/usr/bin/env node +if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); } const fs = require("node:fs"); const path = require("node:path"); @@ -88,6 +91,7 @@ console.log(JSON.stringify({ type: "result", session_id: "11111111-1111-4111-811 async function writeHelpWithoutEffortClaudeCommand(commandPath: string): Promise { const script = `#!/usr/bin/env node +if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); } const fs = require("node:fs"); const path = require("node:path"); @@ -126,6 +130,7 @@ console.log(JSON.stringify({ type: "result", session_id: "33333333-3333-4333-833 async function writeHelpWithEffortClaudeCommand(commandPath: string): Promise { const script = `#!/usr/bin/env node +if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); } const fs = require("node:fs"); const path = require("node:path"); @@ -185,6 +190,7 @@ afterEach(() => { async function writePoisonedMessageIdClaudeCommand(commandPath: string): Promise { const script = `#!/usr/bin/env node +if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); } const fs = require("node:fs"); const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH; @@ -221,6 +227,7 @@ console.log(JSON.stringify({ type: "result", session_id: "bbbbbbbb-bbbb-4bbb-8bb async function writeAlwaysPoisonedMessageIdClaudeCommand(commandPath: string): Promise { const script = `#!/usr/bin/env node +if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); } const fs = require("node:fs"); const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH; @@ -251,6 +258,7 @@ process.exit(1); async function writeRetryThenSucceedClaudeCommand(commandPath: string): Promise { const script = `#!/usr/bin/env node +if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); } const fs = require("node:fs"); const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH; @@ -410,9 +418,9 @@ describe("claude execute", () => { }); it.each([ - [undefined, "claude-opus-5"], - ["", "claude-opus-5"], - [" ", "claude-opus-5"], + [undefined, "claude-opus-5-5"], + ["", "claude-opus-5-5"], + [" ", "claude-opus-5-5"], ["claude-sonnet-4-5", "claude-sonnet-4-5"], ])("passes the resolved model to the CLI for %j", async (model, expected) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-default-")); diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 74557b85033..76a5d0e9d41 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -6095,7 +6095,7 @@ describe("company portability", () => { await expect(portability.importBundle(input, "user-1")).rejects.toMatchObject({ status: 422, details: { code: "paperclip_runner_dot_disabled" } }); expect(agentSvc.create).not.toHaveBeenCalled(); instanceSettingsSvc.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true }); - await expect(portability.importBundle({ ...input, adapterOverrides: { claudecoder: { adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6" } } } }, "user-1")) + await expect(portability.importBundle({ ...input, adapterOverrides: { claudecoder: { adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "grok", model: "grok-build-1.0.13" } } } }, "user-1")) .rejects.toMatchObject({ status: 422, details: { code: "paperclip_runner_rollout_disabled" } }); expect(agentSvc.create).not.toHaveBeenCalled(); await portability.importBundle(input, "user-1"); @@ -6129,7 +6129,7 @@ describe("company portability", () => { adapterOverrides: { claudecoder: { adapterType: "paperclip_runner", - adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6" }, + adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" }, }, }, }; @@ -6161,7 +6161,7 @@ describe("company portability", () => { await portability.importBundle(request, "user-1"); expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ adapterType: "paperclip_runner", - adapterConfig: expect.objectContaining({ provider: "acpx", acpxAgent: "claude" }), + adapterConfig: expect.objectContaining({ provider: "opencode" }), }), { createdByUserId: "user-1" }); await portability.importBundle({ diff --git a/server/src/__tests__/redaction.test.ts b/server/src/__tests__/redaction.test.ts index 6910f9ed516..70de1b3371d 100644 --- a/server/src/__tests__/redaction.test.ts +++ b/server/src/__tests__/redaction.test.ts @@ -311,6 +311,7 @@ describe("redaction", () => { providerValue: input.nested.providerValue, }, }); + expect(sanitizeRecord({ schema: "paperclip.native-execution-input.v9" })).toEqual({ schema: "paperclip.native-execution-input.v9" }); expect(sanitizeRecord({ schema: "paperclip.native-execution-input.v7", runtimeSchema: "paperclip.native-execution-input.v8" })).toEqual({ schema: "paperclip.native-execution-input.v7", runtimeSchema: "paperclip.native-execution-input.v8", 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 562a2f39040..205cc105dc0 100644 --- a/server/src/__tests__/teams-catalog-install-no-overrides.test.ts +++ b/server/src/__tests__/teams-catalog-install-no-overrides.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { eq } from "drizzle-orm"; import { agents, companies, createDb } from "@paperclipai/db"; import { @@ -11,6 +11,12 @@ import { } from "./helpers/embedded-postgres.js"; import { teamsCatalogService } from "../services/teams-catalog.js"; +vi.mock("../services/agent-runner-selection.js", async importOriginal => { + const actual = await importOriginal(); + return { ...actual, resolveNewAgentRunnerForCompany: async (_db: unknown, _companyId: string, input: Parameters[2]) => + actual.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "linux", architecture: "x64", libc: "glibc", glibcVersion: "2.39" } }) }; +}); + const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; diff --git a/server/src/adapters/registry.test.ts b/server/src/adapters/registry.test.ts index b59f7ffc878..cd1c0d85a14 100644 --- a/server/src/adapters/registry.test.ts +++ b/server/src/adapters/registry.test.ts @@ -4,11 +4,13 @@ import { listServerAdapters, requireServerAdapter } from "./registry.js"; import * as executionTarget from "@paperclipai/adapter-utils/execution-target"; import { BUILTIN_ADAPTER_TYPES } from "./builtin-adapter-types.js"; -const { probeInstallation, probeGrokInstallation, probePiInstallation } = vi.hoisted(() => ({ +const { probeInstallation, probeGrokInstallation, probePiInstallation, verifyApiKey } = vi.hoisted(() => ({ + verifyApiKey: vi.fn(), probeInstallation: vi.fn(), probeGrokInstallation: vi.fn(), probePiInstallation: vi.fn(), })); +vi.mock("../services/ai-api-key-test.js", () => ({ validateAiApiKey: verifyApiKey })); vi.mock("../vendor/paperclip-runner/live/index.js", () => ({ probeAcpxClaudeInstallation: probeInstallation, probeAcpxGrokInstallation: probeGrokInstallation, @@ -96,6 +98,7 @@ describe("built-in runtime connection tool delivery", () => { describe("native ACPX environment checks", () => { beforeEach(() => { + verifyApiKey.mockReset().mockResolvedValue(undefined); probeInstallation.mockReset().mockResolvedValue(undefined); probeGrokInstallation.mockReset().mockResolvedValue(undefined); probePiInstallation.mockReset().mockResolvedValue(undefined); @@ -108,6 +111,32 @@ describe("native ACPX environment checks", () => { config: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" }, }; + it("verifies a native Claude key without the global CLI or a model prompt", async () => { + const result = await requireServerAdapter("paperclip_runner").testEnvironment!({ ...context, + config: { ...context.config, env: { ANTHROPIC_API_KEY: "test-key" } }, + }); + expect(result.status).toBe("pass"); + expect(verifyApiKey).toHaveBeenCalledExactlyOnceWith("anthropic", "test-key"); + expect(result.checks.map(check => check.code)).toEqual(["acpx_runtime_ready", "ai_connection_api_key_reverified"]); + }); + + it.each(["ai_connection_api_key_rejected", "ai_connection_verification_failed"])("fails setup on %s", async code => { + verifyApiKey.mockRejectedValue({ details: { code } }); + const result = await requireServerAdapter("paperclip_runner").testEnvironment!({ ...context, + config: { ...context.config, env: { ANTHROPIC_API_KEY: "test-key" } }, + }); + expect(result.status).toBe("fail"); + expect(result.checks.at(-1)).toMatchObject({ code, level: "error" }); + }); + + it.each(["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY"])("preserves the supplementary probe for custom routing via %s", async name => { + const result = await requireServerAdapter("paperclip_runner").testEnvironment!({ ...context, + config: { ...context.config, env: { ANTHROPIC_API_KEY: "test-key", [name]: "configured" } }, + }); + expect(verifyApiKey).not.toHaveBeenCalled(); + expect(result.checks.some(check => check.code === "ai_connection_api_key_reverified")).toBe(false); + }); + it("reports unsupported local platforms before a successful CLI login can mask them", async () => { probeInstallation.mockRejectedValue(new Error("ACPX Claude requires a supported runtime platform")); const result = await requireServerAdapter("paperclip_runner").testEnvironment!(context); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 9ba27985378..cc8ec03856e 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -1,5 +1,7 @@ import type { AdapterRuntimeCommandSpec, ServerAdapterModule } from "./types.js"; import { parseAdapterModelsEnv } from "../services/adapter-models-env.js"; +import { validateAiApiKey } from "../services/ai-api-key-test.js"; +import type { AdapterEnvironmentTestResult } from "@paperclipai/shared"; import { stampClaudeAgentIdHeader } from "./claude-agent-id-header.js"; import { buildSandboxNpmInstallCommand, @@ -143,6 +145,33 @@ import { resolvePaperclipRunnerProviderProfile, } from "../services/native-runtime/provider-profile.js"; +/** Reuse the existing provider key check without requiring a global Claude CLI. + * Readiness and account access are separate checks; model access is proven by a task. + */ +async function verifyNativeClaudeApiKey( + config: Record, + result: AdapterEnvironmentTestResult, +): Promise { + const env = config.env as Record | undefined; + if (config.acpxAgent !== "claude" || !env || typeof env.ANTHROPIC_API_KEY !== "string" + || !env.ANTHROPIC_API_KEY || config.managedAiRouting || config.managedAiConnection + || ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY"].some(key => env[key])) return result; + try { + await validateAiApiKey("anthropic", env.ANTHROPIC_API_KEY); + return { ...result, checks: [...result.checks, { + code: "ai_connection_api_key_reverified", level: "info", + message: "Anthropic verified this API key. Model access is checked when the agent runs.", + }] }; + } catch (error) { + const details = error && typeof error === "object" && "details" in error + ? error.details as { code?: string } | undefined : undefined; + return { ...result, status: "fail", checks: [...result.checks, { + code: details?.code === "ai_connection_api_key_rejected" ? details.code : "ai_connection_verification_failed", + level: "error", message: "Could not verify the Anthropic API key. Check the connection and try again.", + }] }; + } +} + function readConfiguredCommand(config: Record, fallback: string): string { const value = typeof config.command === "string" ? config.command.trim() : ""; return value.length > 0 ? value : fallback; @@ -436,18 +465,18 @@ const paperclipRunnerAdapter: ServerAdapterModule = { 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.`); } - return { + return verifyNativeClaudeApiKey(context.config, { adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(), checks: [{ code: "acpx_remote_runtime_unverified", level: "warn" as const, message: "The remote platform is supported. Runtime package integrity and readiness must still be verified by the remote runner before launch." }], - }; + }); } const { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxCursorInstallation, probeAcpxPiInstallation } = await import("../vendor/paperclip-runner/live/index.js"); await (profile.acpxAgent === "pi" ? probeAcpxPiInstallation : profile.acpxAgent === "grok" ? probeAcpxGrokInstallation : profile.acpxAgent === "cursor" ? probeAcpxCursorInstallation : probeAcpxClaudeInstallation)(profile.model); - return { + return verifyNativeClaudeApiKey(context.config, { adapterType: "paperclip_runner", status: "pass" as const, testedAt: new Date().toISOString(), checks: [{ code: "acpx_runtime_ready", level: "info" as const, message: `ACPX ${profile.acpxAgent} runtime is installed and verified. Model access is checked when it runs.` }], - }; + }); } catch (error) { return { adapterType: "paperclip_runner", status: "fail" as const, testedAt: new Date().toISOString(), @@ -505,19 +534,19 @@ const paperclipRunnerAdapter: ServerAdapterModule = { models: [ ...codexModels, { id: DEFAULT_OPENCODE_RUNNER_MODEL, label: "OpenRouter · DeepSeek V4 Flash 0731" }, - { id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Sonnet 5" }, + { id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Opus 5.5" }, { id: "global.anthropic.claude-sonnet-4-6", label: "Amazon Bedrock · Claude Sonnet 4.6 (global)" }, ], listModels: async () => [ ...await listCodexModels(), { id: DEFAULT_OPENCODE_RUNNER_MODEL, label: "OpenRouter · DeepSeek V4 Flash 0731" }, - { id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Sonnet 5" }, + { id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Opus 5.5" }, { id: "global.anthropic.claude-sonnet-4-6", label: "Amazon Bedrock · Claude Sonnet 4.6 (global)" }, ], refreshModels: async () => [ ...await refreshCodexModels(), { id: DEFAULT_OPENCODE_RUNNER_MODEL, label: "OpenRouter · DeepSeek V4 Flash 0731" }, - { id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Sonnet 5" }, + { id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Opus 5.5" }, { id: "global.anthropic.claude-sonnet-4-6", label: "Amazon Bedrock · Claude Sonnet 4.6 (global)" }, ], supportsLocalAgentJwt: false, diff --git a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts index 725a4adf1c3..8082f7594c2 100644 --- a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts +++ b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts @@ -136,7 +136,7 @@ const support = await getEmbeddedPostgresTestSupport(); const probe = vi.spyOn(adapter, "testEnvironment").mockResolvedValue({ adapterType, status: "warn", testedAt: new Date().toISOString(), checks: [{ code: `${adapterType.split("_")[0]}_hello_probe_auth_required`, level: "warn", message: "Login required" }] }); try { - const agent = await createAgentLifecycle(db).requestHire(companyId, { name: "Login test", adapterType }); + const agent = await createAgentLifecycle(db).requestHire(companyId, { name: "Login test", adapterType, runner: "legacy" }); const work = worker(createLifecycleDriver(db, {} as never).runHost); await work.process(agent.id); expect(probe).toHaveBeenCalled(); diff --git a/server/src/redaction.ts b/server/src/redaction.ts index aa3b50c21a7..f2fe6f6fa6b 100644 --- a/server/src/redaction.ts +++ b/server/src/redaction.ts @@ -92,6 +92,7 @@ export const PAPERCLIP_PUBLIC_SCHEMA_IDS = new Set([ "paperclip.native-execution-input.v5", "paperclip.native-execution-input.v7", "paperclip.native-execution-input.v8", + "paperclip.native-execution-input.v9", "paperclip.native-finalization.v1", "paperclip.native-finalization.v2", "paperclip.native-harness-backup-stamp.v1", diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index fc4ef03d94d..fc796b3b7b4 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1849,7 +1849,7 @@ export function agentRoutes( if (experimental.enableMuse !== true || experimental.enableNativeRunner !== true) throw unprocessable("Muse is experimental and disabled on this instance.", { code: "paperclip_runner_muse_disabled" }); } else if (asRecord(config)?.provider === "grok_bot" && (experimental.enableGrokBot !== true || experimental.enablePublicMcp !== true || experimental.enableNativeRunner !== true)) { throw unprocessable("Enable Grok Bot, Assistant connections (MCP), and Paperclip Runner before connecting this agent.", { code: "paperclip_runner_grok_disabled" }); - } else if (!([undefined, "codex"].includes(asRecord(config)?.provider as string | undefined)) && experimental.enableNativeRunner !== true) { + } else if (!([undefined, "codex"].includes(asRecord(config)?.provider as string | undefined)) && agentHarnessType("paperclip_runner", asRecord(config) ?? {}) !== "claude_local" && experimental.enableNativeRunner !== true) { throw unprocessable( "Paperclip Runner is experimental and disabled on this instance.", { code: "paperclip_runner_rollout_disabled" }, @@ -2255,12 +2255,12 @@ export function agentRoutes( const noInheritance = { adapterConfig, inheritedFixedClaudeOAuthBinding: false }; if (asRecord(runtimeConfig)?.aiConnection) return noInheritance; if (req.actor.type !== "agent" || !req.actor.agentId) return noInheritance; - const harness = agentHarnessType(adapterType ?? "", adapterConfig) === "codex_local" ? "codex_local" : adapterType ?? ""; + const harness = agentHarnessType(adapterType ?? "", adapterConfig); const credentialKeys = INHERITABLE_AGENT_CREDENTIAL_ENV_KEYS[harness]; if (!credentialKeys) return noInheritance; const parent = await svc.getById(req.actor.agentId); - if (!parent || parent.companyId !== companyId || (harness === "codex_local" ? agentHarnessType(parent.adapterType, parent.adapterConfig) !== harness : parent.adapterType !== adapterType)) return noInheritance; + if (!parent || parent.companyId !== companyId || agentHarnessType(parent.adapterType, parent.adapterConfig) !== harness) return noInheritance; // Managed parents must not pass stale legacy credential references to hires. if (aiRuntimeConnectionBindingSchema.safeParse(parent.runtimeConfig.aiConnection).success) return noInheritance; const parentEnv = asRecord(asRecord(parent.adapterConfig)?.env); @@ -2269,7 +2269,7 @@ export function agentRoutes( const existingEnv = asRecord(adapterConfig.env); const claudeCredentialKeys = INHERITABLE_AGENT_CREDENTIAL_ENV_KEYS.claude_local; const childHasClaudeCredential = - adapterType === "claude_local" && + harness === "claude_local" && existingEnv !== null && claudeCredentialKeys.some((key) => existingEnv[key] !== undefined); if (childHasClaudeCredential) return noInheritance; @@ -2630,11 +2630,13 @@ export function agentRoutes( adapterType: string, adapterConfig: Record, allowPlainCredentials = false, + harnessConfig = adapterConfig, ) { - if (req.actor.type !== "agent" || !adapterType.endsWith("_local")) return; + if (req.actor.type !== "agent" || (!adapterType.endsWith("_local") && adapterType !== "paperclip_runner")) return; + const harness = agentHarnessType(adapterType, harnessConfig); const changedKeys = LOCAL_ADAPTER_HOST_COMMAND_KEYS.filter((key) => adapterConfig[key] !== undefined && - (key !== "env" || !containsOnlyLocalAdapterCredentialRefs(adapterType, adapterConfig.env, allowPlainCredentials)), + (key !== "env" || !containsOnlyLocalAdapterCredentialRefs(harness, adapterConfig.env, allowPlainCredentials)), ); if (changedKeys.length === 0) return; throw forbidden( @@ -5329,6 +5331,8 @@ export function agentRoutes( req, requestedAdapterType, changingAdapterType ? rawEffectiveAdapterConfig : (requestedAdapterConfig ?? {}), + false, + rawEffectiveAdapterConfig, ); } const existingRunnerProvider = diff --git a/server/src/services/agent-credentials.ts b/server/src/services/agent-credentials.ts index 656fc18d3bf..503f68dc0c2 100644 --- a/server/src/services/agent-credentials.ts +++ b/server/src/services/agent-credentials.ts @@ -1,5 +1,6 @@ import { eq } from "drizzle-orm"; import { agents, type Db } from "@paperclipai/db"; +import { agentHarnessType } from "@paperclipai/shared"; import { collectSecretRefs, collectUserSecretRefs, syncAgentAdapterEnvBindings } from "./agent-secret-bindings.js"; import { logActivity } from "./activity-log.js"; import { claudeOAuthBindingsMatchExactly, claudeOAuthClaimRejectedError, @@ -166,7 +167,7 @@ export function agentCredentialService(db: Db) { if ( !parent || parent.companyId !== input.companyId || - parent.adapterType !== CLAUDE_LOCAL_ADAPTER_TYPE || + agentHarnessType(parent.adapterType, parent.adapterConfig as Record) !== CLAUDE_LOCAL_ADAPTER_TYPE || !claudeOAuthBindingsMatchExactly(parentBinding, childBinding) ) { throw claudeOAuthClaimRejectedError(); diff --git a/server/src/services/agent-runner-selection.test.ts b/server/src/services/agent-runner-selection.test.ts index 4c55b770f0c..1b8eff9ef5c 100644 --- a/server/src/services/agent-runner-selection.test.ts +++ b/server/src/services/agent-runner-selection.test.ts @@ -378,7 +378,8 @@ describe("server-owned Codex runner selection", () => { describe("Codex creation runner contract", () => { it("graduates only Codex and retains saved provider identity", () => { expect(paperclipRunnerProfileForHarness("codex_local")).toEqual({ provider: "codex" }); - for (const harness of ["claude_local", "opencode_local", "grok_local", "cursor", "pi_local", "process"]) expect(paperclipRunnerProfileForHarness(harness)).toBeUndefined(); + expect(paperclipRunnerProfileForHarness("claude_local")).toEqual({ provider: "acpx", acpxAgent: "claude" }); + for (const harness of ["opencode_local", "grok_local", "cursor", "pi_local", "process"]) expect(paperclipRunnerProfileForHarness(harness)).toBeUndefined(); expect(agentHarnessType("paperclip_runner", { provider: "codex" })).toBe("codex_local"); expect(agentHarnessType("paperclip_runner", { provider: "acpx", acpxAgent: "claude" })).toBe("claude_local"); expect(agentHarnessType("paperclip_runner", { provider: "unknown" })).toBe("unknown"); @@ -394,7 +395,7 @@ describe("Codex creation runner contract", () => { expect(paperclipRunnerSupportsPlatform("codex_local", "linux", architecture, "unknown")).toBe(false); } for (const [platform, architecture] of [["darwin", "ia32"], ["linux", "arm"], ["win32", "x64"], ["win32", "arm64"], ["freebsd", "x64"]]) expect(paperclipRunnerSupportsPlatform("codex_local", platform, architecture)).toBe(false); - expect(paperclipRunnerSupportsPlatform("claude_local", "darwin", "arm64")).toBe(false); + expect(paperclipRunnerSupportsPlatform("claude_local", "darwin", "arm64")).toBe(true); }); it("keeps auto out of saved update defaults and validates request intent", () => { expect(createAgentSchema.parse({ name: "Codex", adapterType: "codex_local" }).runner).toBeUndefined(); @@ -403,3 +404,82 @@ describe("Codex creation runner contract", () => { expect(createAgentSchema.safeParse({ name: "Codex", adapterType: "codex_local", runner: "other" }).success).toBe(false); }); }); + + +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 => { + 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 }; + expect(resolveNewAgentRunner({ adapterType: "claude_local", target, adapterConfig: config })).toEqual({ adapterType: "claude_local", adapterConfig: config }); + const assets = { runnerRemoteProviderPackPath: "/operator/mac-pack", runnerRemoteBinaryPath: "/operator/mac-daemon" }; + expect(resolveNewAgentRunner({ adapterType: "claude_local", target, runner: "paperclip", adapterConfig: assets })) + .toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { ...assets, provider: "acpx", acpxAgent: "claude" } }); + const saved = { adapterType: "paperclip_runner", adapterConfig: { ...assets, provider: "acpx", acpxAgent: "claude" }, target }; + expect(resolveNewAgentRunner(saved)).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig }); + }); + 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 }); + expect(result).toEqual({ adapterType: "paperclip_runner", adapterConfig: { env: config.env, instructionsFilePath: config.instructionsFilePath, timeoutSec: 180, provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", acpxPermissionMode: "approve-all", lifecycleMode: "per_turn" } }); + expect(resolveNewAgentRunner(result)).toEqual(result); + expect(config.dangerouslySkipPermissions).toBe(true); + }); + it("preserves explicit models and Legacy choices", () => { + const legacy = { adapterType: "claude_local", target: mac, runner: "legacy" as const, adapterConfig: { model: "custom", chrome: true } }; + expect(resolveNewAgentRunner(legacy)).toEqual({ adapterType: legacy.adapterType, adapterConfig: legacy.adapterConfig }); + expect(resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { model: "custom" } }).adapterConfig.model).toBe("custom"); + }); + it.each(["claude-sonnet-5-5", "custom-model"])("preserves the plain environment model binding %s", model => { + const env = { ANTHROPIC_MODEL: { type: "plain", value: model } }; + const native = resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { env } }); + expect(native.adapterConfig.model).toBe(model); + expect(native.adapterConfig.env).toEqual(env); + }); + it.each(["ANTHROPIC_MODEL", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "ANTHROPIC_BEDROCK_BASE_URL"])("rejects unresolved %s before choosing a default", key => { + const env = { [key]: { type: "secret_ref", secretId: "override" } }; + expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { env } })).toThrow(`env.${key}`); + expect(resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { env, model: "custom-model" } }).adapterConfig.model).toBe("custom-model"); + }); + it("does not pin the ordinary model over a plain provider-routing binding", () => { + expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, + adapterConfig: { env: { CLAUDE_CODE_USE_BEDROCK: { type: "plain", value: "1" } } } })).toThrow("Legacy runner"); + }); + it.each(["permissionMode", "acpPermissionMode"])("translates the legacy Claude %s without granting broader permissions", key => { + for (const mode of ["deny-all", "approve-reads", "approve-all"]) { + const native = resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { engine: "acp", [key]: mode } }); + expect(native.adapterConfig.acpxPermissionMode).toBe(mode); + expect(native.adapterConfig[key]).toBeUndefined(); + } + expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { [key]: "deny-all", acpxPermissionMode: "approve-all" } })).toThrow("cannot honor"); + expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { [key]: "unrecognized" } })).toThrow("Legacy runner"); + }); + it("retains saved native Claude and allows an explicit compatible Legacy switch", () => { + const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "custom", acpxPermissionMode: "approve-all", lifecycleMode: "per_turn" } }; + expect(resolveNewAgentRunner(saved)).toEqual(saved); + expect(resolveNewAgentRunner({ ...saved, runner: "legacy" })).toEqual({ adapterType: "claude_local", adapterConfig: { model: "custom", dangerouslySkipPermissions: true } }); + expect(() => resolveNewAgentRunner({ ...saved, runner: "legacy", adapterConfig: { ...saved.adapterConfig, acpxPermissionMode: "deny-all" } })).toThrow("cannot honor"); + }); + it.each(["chrome", "agentCommand", "maxTurnsPerRun", "networkScope"])("rejects unsupported %s without fallback", key => { + expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { [key]: "configured" } })).toThrow("Legacy runner"); + }); + it.each(["low", "medium", "high", "xhigh", "max"])("translates Claude effort %s and preserves it on an explicit Legacy switch", effort => { + const native = resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { effort } }); + expect(native.adapterConfig.modelReasoningEffort).toBe(effort); + expect(native.adapterConfig.effort).toBeUndefined(); + expect(resolveNewAgentRunner({ ...native, runner: "legacy" }).adapterConfig).toMatchObject({ effort }); + }); + it("rejects invalid effort and unknown platform defaults", () => { + expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { effort: "ultra" } })).toThrow("Claude effort"); + expect(agentRunnerAvailability("claude_local", { driver: "sandbox" }).defaultRunner).toBe("legacy"); + }); + it("keeps unqualified targets and overrides legacy", () => { + expect(agentRunnerAvailability("claude_local", target).defaultRunner).toBe("legacy"); + state.overridden.add("claude_local"); + expect(agentRunnerAvailability("claude_local", mac).defaultRunner).toBe("legacy"); + state.overridden.clear(); state.disabled = ["claude_local"]; + expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac })).toThrow("Claude is disabled"); + }); +}); diff --git a/server/src/services/agent-runner-selection.ts b/server/src/services/agent-runner-selection.ts index e47d433db48..8ab9694b158 100644 --- a/server/src/services/agent-runner-selection.ts +++ b/server/src/services/agent-runner-selection.ts @@ -1,9 +1,10 @@ import type { Db } from "@paperclipai/db"; import { agentHarnessType, isEnvironmentDriverSupportedForAdapter, paperclipRunnerSupportsPlatform, type AgentRunnerChoice, type AgentRunnerAvailability } from "@paperclipai/shared"; import { normalizeLegacyRunnerProvider, PAPERCLIP_RUNNER_DEFAULT_MODELS } from "@paperclipai/adapter-utils"; +import { resolveClaudeModel } from "@paperclipai/adapter-claude-local"; import { findActiveServerAdapter, hasActiveAdapterOverride, waitForExternalAdapters } from "../adapters/registry.js"; import { getDisabledAdapterTypes } from "./adapter-plugin-store.js"; -import { PaperclipRunnerProviderProfileError, resolvePaperclipRunnerNativeProviderInput } from "./native-runtime/provider-profile.js"; +import { claudeRunnerUnsupportedSettings, PaperclipRunnerProviderProfileError, resolvePaperclipRunnerNativeProviderInput } from "./native-runtime/provider-profile.js"; import { forbidden, unprocessable } from "../errors.js"; import { runSshCommand } from "@paperclipai/adapter-utils/ssh"; import { resolveEnvironmentDriverConfigForRuntime } from "./environment-config.js"; @@ -18,34 +19,84 @@ export interface AgentRunnerSelection { target?: RunnerTarget; } -export function agentRunnerAvailability(harness: string, target: RunnerTarget = localAgentRunnerTarget()): AgentRunnerAvailability { +function runnerAdaptersAvailable(harness: string, driver: string): boolean { const disabled = new Set(getDisabledAdapterTypes()); - const supported = harness === "codex_local" + return ["codex_local", "claude_local"].includes(harness) && !disabled.has(harness) && !disabled.has("paperclip_runner") && !!findActiveServerAdapter(harness) && !!findActiveServerAdapter("paperclip_runner") && !hasActiveAdapterOverride(harness) && !hasActiveAdapterOverride("paperclip_runner") - && isEnvironmentDriverSupportedForAdapter("paperclip_runner", target.driver) + && isEnvironmentDriverSupportedForAdapter("paperclip_runner", driver); +} + +export function agentRunnerAvailability(harness: string, target: RunnerTarget = localAgentRunnerTarget()): AgentRunnerAvailability { + const supported = runnerAdaptersAvailable(harness, target.driver) && (target.driver !== "sandbox" || Boolean(target.platform && target.architecture && (target.platform !== "linux" || (target.libc === "glibc" && target.glibcVersion)))) - && (!target.platform || paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "", target.libc, target.glibcVersion)); - return { supportedRunners: supported ? ["paperclip", "legacy"] : ["legacy"], defaultRunner: supported ? "paperclip" : "legacy" }; + && (harness === "claude_local" + ? !!target.platform && paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "", target.libc, target.glibcVersion) + : !target.platform || paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "", target.libc, target.glibcVersion)); + const automatic = supported && !(harness === "claude_local" && target.driver === "ssh" && target.platform === "darwin"); + return { supportedRunners: supported ? ["paperclip", "legacy"] : ["legacy"], defaultRunner: automatic ? "paperclip" : "legacy" }; } function incompatible(fields: string[]): never { throw unprocessable(`Paperclip Runner cannot honor these settings: ${fields.join(", ")}. Remove them or select Legacy runner in Advanced.`, { code: "agent_runner_config_incompatible", fields }); } +function resolveClaudeRunnerConfig(config: Record): Record { + const translated = { ...config }; + const legacyPermission = [config.permissionMode, config.acpPermissionMode].find(value => typeof value === "string" && value.trim()) as string | undefined; + if (legacyPermission !== undefined) { + const mode = legacyPermission.trim(); + if (!["approve-all", "approve-reads", "deny-all"].includes(mode) + || (config.acpxPermissionMode !== undefined && config.acpxPermissionMode !== mode)) incompatible(["permissionMode", "acpPermissionMode"]); + translated.acpxPermissionMode = mode; + } + for (const key of ["permissionMode", "acpPermissionMode"]) { + if (config[key] !== undefined && typeof config[key] !== "string") incompatible([key]); + delete translated[key]; + } + const unsupported = claudeRunnerUnsupportedSettings(translated); + const effort = config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort; + if (config.model !== undefined && typeof config.model !== "string") unsupported.push("model"); + if (unsupported.length) incompatible(unsupported); + for (const key of ["engine", "command", "mode", "acpMode", "nonInteractivePermissions", "acpNonInteractivePermissions", "dangerouslySkipPermissions", "chrome", "maxTurnsPerRun", "effort", "reasoningEffort"]) delete translated[key]; + const env = config.env && typeof config.env === "object" ? config.env as Record : {}; + const modelEnv = { ...env }; + if (!(typeof config.model === "string" && config.model.trim())) { + for (const key of ["ANTHROPIC_MODEL", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "ANTHROPIC_BEDROCK_BASE_URL"]) { + const binding = env[key]; + if (binding === undefined || typeof binding === "string") continue; + if (binding && typeof binding === "object" && "type" in binding && binding.type === "plain" + && "value" in binding && typeof binding.value === "string") modelEnv[key] = binding.value; + else incompatible([`env.${key}`]); + } + } + const model = resolveClaudeModel(config.model, modelEnv); + if (!model) incompatible(["model"]); + return { ...translated, ...(effort === undefined || effort === "" ? {} : { modelReasoningEffort: effort }), provider: "acpx", acpxAgent: "claude", model, acpxPermissionMode: translated.acpxPermissionMode ?? "approve-all", lifecycleMode: config.lifecycleMode ?? "per_turn" }; +} + export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterType: string; adapterConfig: Record } { const adapterType = input.adapterType ?? "process"; let config = { ...input.adapterConfig }; const harness = agentHarnessType(adapterType, config); const native = adapterType === "paperclip_runner"; - if (adapterType === "codex_local" && getDisabledAdapterTypes().includes(adapterType)) throw unprocessable("Codex is disabled on this instance. Enable its adapter or choose another harness.", { code: "agent_runner_unavailable" }); + if (["codex_local", "claude_local"].includes(adapterType) && getDisabledAdapterTypes().includes(adapterType)) throw unprocessable(`${adapterType === "claude_local" ? "Claude" : "Codex"} is disabled on this instance. Enable its adapter or choose another harness.`, { code: "agent_runner_unavailable" }); if (native && (input.runner === undefined || input.runner === "paperclip" || (harness !== "codex_local" && input.runner !== "legacy"))) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) }; const availability = agentRunnerAvailability(harness, input.target); const choice = input.runner ?? "auto"; const useNative = choice === "paperclip" || (choice === "auto" && availability.defaultRunner === "paperclip"); if (!useNative) { + if (harness === "claude_local") { + if (!native) return { adapterType, adapterConfig: config }; + if (config.acpxPermissionMode !== undefined && config.acpxPermissionMode !== "approve-all") incompatible(["acpxPermissionMode"]); + if (config.lifecycleMode !== undefined && config.lifecycleMode !== "per_turn") incompatible(["lifecycleMode"]); + const effort = config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort; + for (const key of ["provider", "acpxAgent", "acpxPermissionMode", "lifecycleMode", "idleTimeoutMs", "modelReasoningEffort", "reasoningEffort"]) delete config[key]; + if (effort !== undefined) config.effort = effort; + return { adapterType: "claude_local", adapterConfig: { ...config, dangerouslySkipPermissions: config.dangerouslySkipPermissions ?? true } }; + } if (!native && harness !== "codex_local") return { adapterType, adapterConfig: config }; if (!native && !["codexPermissionMode", "lifecycleMode", "idleTimeoutMs"].some(key => config[key] !== undefined)) return { adapterType, adapterConfig: config }; if (harness !== "codex_local") throw unprocessable("This native harness does not have a runner override in this release.", { code: "agent_runner_unavailable" }); @@ -55,10 +106,19 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp return { adapterType: "codex_local", adapterConfig: { ...config, dangerouslyBypassApprovalsAndSandbox: config.dangerouslyBypassApprovalsAndSandbox ?? true } }; } if (native && harness !== "codex_local" && choice === "paperclip") return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) }; - if (availability.defaultRunner !== "paperclip") throw unprocessable(input.target?.driver === "sandbox" + if (!availability.supportedRunners.includes("paperclip")) throw unprocessable(input.target?.driver === "sandbox" ? "Paperclip Runner is unavailable for this sandbox. Choose an immutable OCI image digest and run its environment connection probe to qualify the OS, architecture and libc, or select Legacy runner in Advanced." : "Paperclip Runner is unavailable for this harness and environment. Select Legacy runner in Advanced.", { code: "agent_runner_unavailable" }); if (native) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) }; + if (harness === "claude_local") { + config = resolveClaudeRunnerConfig(config); + try { resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: config }); } + catch (error) { + if (error instanceof PaperclipRunnerProviderProfileError) throw unprocessable(error.message, { code: error.code }); + throw error; + } + return { adapterType: "paperclip_runner", adapterConfig: config }; + } const aliasSources = new Map(); for (const [canonical, alias] of [ ["agentCommand", "acpAgentCommand"], ["stateDir", "acpStateDir"], @@ -114,7 +174,7 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp } export async function resolveNewAgentRunnerForCompany(db: Db, companyId: string, input: AgentRunnerSelection & { defaultEnvironmentId?: string | null }) { - if (agentHarnessType(input.adapterType ?? "process", input.adapterConfig) !== "codex_local" + if (!["codex_local", "claude_local"].includes(agentHarnessType(input.adapterType ?? "process", input.adapterConfig)) || input.runner === "legacy" || (input.adapterType === "paperclip_runner" && (input.runner === undefined || input.runner === "paperclip"))) return resolveNewAgentRunner(input); const target = await resolveAgentRunnerTargetForCompany(db, companyId, input.defaultEnvironmentId); return resolveNewAgentRunner({ ...input, target }); @@ -151,7 +211,7 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri const qualified = await readSandboxPlatformQualification(db, environment, companyId); if (qualified) target = { driver, ...qualified }; } - if (driver === "ssh" && environment && agentRunnerAvailability("codex_local", target).defaultRunner === "paperclip") { + if (driver === "ssh" && environment && ["codex_local", "claude_local"].some(harness => runnerAdaptersAvailable(harness, driver))) { const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment); if (parsed.driver !== "ssh") throw unprocessable("The selected SSH environment is unavailable."); let output: string; diff --git a/server/src/services/ai-connection-router.ts b/server/src/services/ai-connection-router.ts index 4208bcd775a..5bea58456d2 100644 --- a/server/src/services/ai-connection-router.ts +++ b/server/src/services/ai-connection-router.ts @@ -55,8 +55,7 @@ export function poolMemberRuntimeConfig(member: AiConnectionPoolMember, adapterT const efforts = provider === "openai" ? codexLocalReasoningEffortsForModel(model) : provider === "anthropic" ? claudeLocalReasoningEffortsForModel(model) : provider === "xai" ? grokLocalReasoningEffortsForModel(model) : []; - // ACPX currently has no qualified effort override; keep that host limitation explicit. - const effortKey = adapterType === "paperclip_runner" ? profile.provider === "codex" ? "modelReasoningEffort" : null + const effortKey = adapterType === "paperclip_runner" ? profile.provider === "codex" || (profile.provider === "acpx" && profile.acpxAgent === "claude") ? "modelReasoningEffort" : null : provider === "anthropic" ? "effort" : provider === "xai" ? "reasoningEffort" : provider === "openai" ? "modelReasoningEffort" : "variant"; const requested = overrides.modelReasoningEffort ?? overrides.reasoningEffort ?? overrides.effort ?? overrides.variant; const effort = typeof requested === "string" && efforts.includes(requested) && effortKey ? requested diff --git a/server/src/services/anthropic-pricing.test.ts b/server/src/services/anthropic-pricing.test.ts index 760b18d2eef..1a4ba592f20 100644 --- a/server/src/services/anthropic-pricing.test.ts +++ b/server/src/services/anthropic-pricing.test.ts @@ -6,6 +6,12 @@ const receipt: AdapterUsageCheckpoint = { complete: true, usageBasis: "per_run", billingType: "metered_api", model: "claude-sonnet-5", costUsd: null, costStatus: "unpriced", usage: { inputTokens: 22462, cachedInputTokens: 208218, cacheWriteTokens: 22450, outputTokens: 2450 } }; describe("complete Anthropic receipt pricing", () => { + it("prices Opus 5.5 cache writes at the one-hour upper rate", () => { + const priced = priceAnthropicReceipt({ ...receipt, model: "claude-opus-5-5" }); + expect(priced.costUsdExact).toBe("0.270291600"); + expect(priced.pricingProvenance).toMatchObject({ version: "anthropic-standard-2026-10-12", inputCentsPerMillion: "400", cachedInputCentsPerMillion: "20", cacheWriteCentsPerMillion: "800", outputCentsPerMillion: "2000" }); + expect(priced.costStatus).toBe("estimated"); + }); it("prices complete counters conservatively and labels their provenance as an estimate", () => { const priced = priceAnthropicReceipt(receipt); expect(priced.costUsdExact).toBe("0.155967600"); @@ -17,7 +23,7 @@ describe("complete Anthropic receipt pricing", () => { }); it.each([ { complete: false }, { billingType: "unknown" }, { billingType: "subscription_included" }, - { provider: "unknown" }, { biller: "aws_bedrock" }, { model: "claude-sonnet-latest" }, + { provider: "unknown" }, { biller: "aws_bedrock" }, { model: "claude-sonnet-latest" }, { model: "constructor" }, { model: "toString" }, { model: "__proto__" }, { usageBasis: "session_cumulative" }, { costUsd: 0.12 }, { costUsdExact: "0.12" }, { cacheAdjustedCostUsd: 0.12 }, { pricingContext: { serviceTier: "priority" } }, { usageByModel: [{ model: "other", usage: { inputTokens: 1, outputTokens: 1 }, costUsd: 1 }] }, { usage: undefined }, { usage: { inputTokens: 1, outputTokens: 1 } }, diff --git a/server/src/services/anthropic-pricing.ts b/server/src/services/anthropic-pricing.ts index 6a9d3cfdae3..1823a0adbc2 100644 --- a/server/src/services/anthropic-pricing.ts +++ b/server/src/services/anthropic-pricing.ts @@ -1,31 +1,35 @@ import { centsToUsd, unitsToCents, usdToUnits } from "@paperclipai/shared"; import type { AdapterUsageCheckpoint } from "@paperclipai/adapter-utils"; -// Direct Anthropic API list prices, verified 2026-10-07: -// https://platform.claude.com/docs/en/about-claude/pricing -// Keep this bounded to the qualified model; aliases and other billers stay unknown. -const RATES = ["2", "0.2", "4", "10"] as const; +const RATE_CARDS: Record = { + "claude-sonnet-5": { rates: ["2", "0.2", "4", "10"], version: "anthropic-standard-2026-10-07" }, + "claude-opus-5-5": { rates: ["4", "0.2", "8", "20"], version: "anthropic-standard-2026-10-12" }, +}; /** A complete token receipt can receive an explicit estimate, never an invoice * claim. ACP aggregates cache writes without TTL; conservatively price them at * the documented one-hour rate rather than silently assume five-minute writes. */ export function priceAnthropicReceipt(receipt: AdapterUsageCheckpoint): AdapterUsageCheckpoint { + const card = receipt.model && Object.hasOwn(RATE_CARDS, receipt.model) ? RATE_CARDS[receipt.model] : undefined; if (!receipt.complete || !receipt.usage || receipt.usageBasis !== "per_run" || receipt.provider !== "anthropic" || receipt.biller !== "anthropic" || !["api", "metered_api"].includes(receipt.billingType ?? "") - || receipt.model !== "claude-sonnet-5" || receipt.usageByModel?.length + || !card || receipt.usageByModel?.length || receipt.costUsd != null || receipt.costUsdExact != null || receipt.cacheAdjustedCostUsd != null || (receipt.pricingContext?.serviceTier && !["standard", "default"].includes(receipt.pricingContext.serviceTier))) return receipt; const usage = receipt.usage; if (usage.cacheWriteTokens === undefined || usage.cachedInputTokens === undefined) return receipt; const counts = [usage.inputTokens - usage.cacheWriteTokens, usage.cachedInputTokens, usage.cacheWriteTokens, usage.outputTokens]; if (counts.some(count => !Number.isSafeInteger(count) || count < 0)) return receipt; - const numerator = counts.reduce((total, count, index) => total + BigInt(count) * usdToUnits(RATES[index]), 0n); + const numerator = counts.reduce((total, count, index) => total + BigInt(count) * usdToUnits(card.rates[index]), 0n); return { ...receipt, costUsdExact: centsToUsd(unitsToCents((numerator + 500_000n) / 1_000_000n)), costStatus: "estimated", pricingProvenance: { - source: "rate_card", version: "anthropic-standard-2026-10-07", + source: "rate_card", version: card.version, evidence: "https://platform.claude.com/docs/en/about-claude/pricing; standard global API pricing assumed; cache-write TTL unavailable, one-hour upper rate used; not an invoice", - inputCentsPerMillion: "200", cachedInputCentsPerMillion: "20", cacheWriteCentsPerMillion: "400", outputCentsPerMillion: "1000", serviceTier: "standard", + inputCentsPerMillion: (usdToUnits(card.rates[0]) / 10_000_000n).toString(), + cachedInputCentsPerMillion: (usdToUnits(card.rates[1]) / 10_000_000n).toString(), + cacheWriteCentsPerMillion: (usdToUnits(card.rates[2]) / 10_000_000n).toString(), + outputCentsPerMillion: (usdToUnits(card.rates[3]) / 10_000_000n).toString(), serviceTier: "standard", }, }; } diff --git a/server/src/services/built-in-agents.ts b/server/src/services/built-in-agents.ts index 24664c9662a..20277a48fcd 100644 --- a/server/src/services/built-in-agents.ts +++ b/server/src/services/built-in-agents.ts @@ -1761,11 +1761,12 @@ export function builtInAgentService(db: Db) { const adapterType = resolvedInput.adapterType ?? existing.adapterType; const adapterConfig = resolvedInput.adapterConfig ?? existing.adapterConfig; assertAdapterAllowed(definition, adapterType, adapterConfig); - const sameCodexHarness = agentHarnessType(adapterType, adapterConfig) === "codex_local" - && agentHarnessType(existing.adapterType, existing.adapterConfig) === "codex_local"; - const runner = resolvedInput.runner ?? (sameCodexHarness && existing.metadata?.paperclipBuiltInSetupRequired !== true + const selectedHarness = agentHarnessType(adapterType, adapterConfig); + const sameQualifiedHarness = ["codex_local", "claude_local"].includes(selectedHarness) + && agentHarnessType(existing.adapterType, existing.adapterConfig) === selectedHarness; + const runner = resolvedInput.runner ?? (sameQualifiedHarness && existing.metadata?.paperclipBuiltInSetupRequired !== true ? agentRunner(existing.adapterType) : undefined); - const retainedNative = sameCodexHarness && runner === "paperclip" && existing.adapterType === "paperclip_runner"; + const retainedNative = sameQualifiedHarness && runner === "paperclip" && existing.adapterType === "paperclip_runner"; const resolvedAdapterConfig = { ...adapterConfig }; if (retainedNative) { for (const field of ["provider", "acpxAgent", "acpxMode"]) { diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 6bd07713dd6..a8496f36e4e 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -5307,7 +5307,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { "OpenAI Dot is experimental and disabled on this instance.", { code: "paperclip_runner_dot_disabled" }, ); - } else if (config.provider !== "codex" && config.provider !== undefined && experimental.enableNativeRunner !== true) { + } else if (config.provider !== "codex" && config.provider !== undefined && agentHarnessType("paperclip_runner", config) !== "claude_local" && experimental.enableNativeRunner !== true) { throw unprocessable( "Paperclip Runner is experimental and disabled on this instance.", { code: "paperclip_runner_rollout_disabled" }, @@ -5651,7 +5651,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { runner = agentRunner(existingImportAgent.adapterType); } } - const retainedNative = runner === "paperclip" && selectedHarness === "codex_local" + const retainedNative = runner === "paperclip" && ["codex_local", "claude_local"].includes(selectedHarness) ? [manifestAgent, existingImportAgent].find((agent) => agent?.adapterType === "paperclip_runner" && agentHarnessType(agent.adapterType, agent.adapterConfig) === selectedHarness) : undefined; diff --git a/server/src/services/heartbeat-runner-provider-config.test.ts b/server/src/services/heartbeat-runner-provider-config.test.ts index aad42c9b2a6..1707d22955d 100644 --- a/server/src/services/heartbeat-runner-provider-config.test.ts +++ b/server/src/services/heartbeat-runner-provider-config.test.ts @@ -117,6 +117,16 @@ describe("Paperclip Runner native provider configuration", () => { .toMatchObject({ provider: "codex", model: "gpt-6-astra", modelReasoningEffort: "high" }); }); + it.each([true, false])("carries Claude task effort while retaining provider and permissions (explicit agent: %s)", explicitAgent => { + const base = { provider: "acpx", ...(explicitAgent ? { acpxAgent: "claude" } : {}), model: "claude-opus-5-5", modelReasoningEffort: "medium", acpxPermissionMode: "deny-all" }; + const projected = projectPaperclipRunnerTaskConfig("acpx_runtime", base, { effort: "xhigh", provider: "codex", acpxAgent: "pi", acpxPermissionMode: "approve-all" }); + expect(projected).toEqual({ ...base, modelReasoningEffort: "xhigh" }); + expect(resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: projected })).toMatchObject({ provider: "acpx", acpxAgent: "claude", claudeEffort: "xhigh", acpxPermissionMode: "deny-all" }); + const changedModel = projectPaperclipRunnerTaskConfig("acpx_runtime", base, { model: "claude-sonnet-5" }); + expect(changedModel).not.toHaveProperty("modelReasoningEffort"); + expect(() => resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: { ...base, chrome: true } })).toThrow("Legacy runner"); + }); + it("uses an OpenCode task model without changing the Runner provider", () => { const projected = projectPaperclipRunnerTaskConfig( "opencode_server", diff --git a/server/src/services/heartbeat/runtime-selection.ts b/server/src/services/heartbeat/runtime-selection.ts index 44146bbf846..0344a78278f 100644 --- a/server/src/services/heartbeat/runtime-selection.ts +++ b/server/src/services/heartbeat/runtime-selection.ts @@ -21,7 +21,7 @@ import type { agentInstructionWorkingCopyService } from "../agent-instruction-wo import { createHash, randomUUID } from "node:crypto"; import { and, eq, inArray, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { CHAT_PROVIDERS } from "@paperclipai/shared"; +import { agentHarnessType, CHAT_PROVIDERS } from "@paperclipai/shared"; import { type agents, completionContracts, @@ -716,6 +716,7 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele }); getNativeFreshSessionHandoff = nativeReviewRequest ? undefined : getFreshSessionHandoff; const nativeProviderConfig = nativeRuntimeResolution.profile.backend === "codex_app_server" + || (nativeRuntimeResolution.profile.backend === "acpx_runtime" && agentHarnessType(agent.adapterType, parseObject(agent.adapterConfig)) === "claude_local") || nativeRuntimeResolution.profile.backend === "opencode_server" ? projectPaperclipRunnerTaskConfig( nativeRuntimeResolution.profile.backend, diff --git a/server/src/services/native-runtime/acpx-qualification.test.ts b/server/src/services/native-runtime/acpx-qualification.test.ts index 71ddf2c5130..f230c71c701 100644 --- a/server/src/services/native-runtime/acpx-qualification.test.ts +++ b/server/src/services/native-runtime/acpx-qualification.test.ts @@ -72,7 +72,7 @@ describe("host ACPX qualification admission", () => { } }); it("preserves product defaults separately from qualification examples", () => { - for (const [acpxAgent, model] of [["claude", "claude-sonnet-5"], ["grok", "grok-4.7"]]) { + for (const [acpxAgent, model] of [["claude", "claude-opus-5-5"], ["grok", "grok-4.7"]]) { expect(resolvePaperclipRunnerProviderProfile({ provider: "acpx", acpxAgent })) .toMatchObject({ acpxAgent, model }); } diff --git a/server/src/services/native-runtime/native-execution-input.test.ts b/server/src/services/native-runtime/native-execution-input.test.ts index 9a5875a4d76..f0cdc8aca17 100644 --- a/server/src/services/native-runtime/native-execution-input.test.ts +++ b/server/src/services/native-runtime/native-execution-input.test.ts @@ -664,3 +664,27 @@ describe("Pi thinking native execution projection", () => { expect(() => buildNativeExecutionInput({ ...fixture("low"), acpxAgent: "copilot" })).toThrow("only for Pi"); }); }); + + +describe("Claude effort and master home-mode compatibility", () => { + it.each([ + ["isolated", undefined, "v5"], ["preconfigured", undefined, "v7"], + ["isolated", "high", "v9"], ["preconfigured", "high", "v9"], + ] as const)("retains %s home mode with effort %s in %s", (homeMode, claudeEffort, version) => { + const value = buildNativeExecutionInput({ + companyId: "company", runId: "run", agentId: "agent", + issue: { id: "issue", identifier: "CLA-1", title: "Useful output", description: null, workMode: "standard" }, + taskPrompt: "Create the requested file.", + workspace: { id: "workspace", cwd: "/workspace", repoUrl: null, repoRef: null, branchName: null }, + normalizedSessionId: null, provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", + acpxPermissionMode: "approve-all", homeMode, claudeEffort, + completionContract: { id: "contract", sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1", + contract: { revision: "1", objective: "Create a file", criteria: [{ id: "output", requirement: "Create the file" }] } }, + runtimeContext: nativeRuntimeContextFixture(), + }); + expect(value.schema).toBe(`paperclip.native-execution-input.${version}`); + if (version !== "v5") expect(value).toHaveProperty("homeMode", homeMode); + if (claudeEffort) expect(value.provider).toHaveProperty("claudeEffort", claudeEffort); + expect(JSON.stringify(buildNativeModelEnvelope(value))).not.toContain("claudeEffort"); + }); +}); diff --git a/server/src/services/native-runtime/native-execution-input.ts b/server/src/services/native-runtime/native-execution-input.ts index 33fef7ba68b..8c62bb7bbbf 100644 --- a/server/src/services/native-runtime/native-execution-input.ts +++ b/server/src/services/native-runtime/native-execution-input.ts @@ -9,6 +9,7 @@ import type { NativeExecutionInputV5, NativeExecutionInputV6, NativeExecutionInputV8, + NativeExecutionInputV9, MuseBindingSnapshot, DotBindingSnapshot, NativeExecutionInputV7, @@ -85,6 +86,7 @@ export interface BuildNativeExecutionInput { acpxPermissionMode?: NativeAcpxPermissionMode; acpxSessionMode?: "agent" | "plan" | "ask"; piThinkingLevel?: "off" | "low" | "high" | "max"; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; model?: string | null; managedProfile?: Extract< NativeExecutionInputV5["provider"], @@ -115,9 +117,9 @@ export interface BuildNativeExecutionInput { } export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider: "muse"; museBinding: MuseBindingSnapshot }): NativeExecutionInputV8; export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider: "openai_dot" | "grok_bot"; dotBinding: DotBindingSnapshot }): NativeExecutionInputV6; -export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider?: Exclude }): NativeExecutionInputV5 | NativeExecutionInputV7; -export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8; -export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8 { +export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider?: Exclude }): NativeExecutionInputV5 | NativeExecutionInputV7 | NativeExecutionInputV9; +export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8 | NativeExecutionInputV9; +export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8 | NativeExecutionInputV9 { if (input.issue.workMode !== "standard" && input.issue.workMode !== "planning" && input.issue.workMode !== "ask") { throw new Error("native_execution_input_invalid: issue work mode must be standard, planning, or ask"); } @@ -217,8 +219,9 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat ? verifiedCompletionSources(input.turnContext, input.completionContract.sources ?? []) : []; const prepared = { - schema: input.homeMode === "preconfigured" ? "paperclip.native-execution-input.v7" : "paperclip.native-execution-input.v5", - ...(input.homeMode === "preconfigured" ? { homeMode: input.homeMode } : {}), + schema: input.claudeEffort !== undefined ? "paperclip.native-execution-input.v9" + : input.homeMode === "preconfigured" ? "paperclip.native-execution-input.v7" : "paperclip.native-execution-input.v5", + ...(input.claudeEffort !== undefined || input.homeMode === "preconfigured" ? { homeMode: input.homeMode ?? "isolated" } : {}), ...((input.initialCommunicationGuidance || input.freshSessionHandoff) ? { initialCommunicationGuidance: [input.initialCommunicationGuidance, input.freshSessionHandoff].filter(Boolean).join("\n\n"), } : {}), @@ -297,6 +300,7 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat permissionMode: input.acpxPermissionMode ?? "approve-all", ...(mode === undefined ? {} : { mode }), ...(piThinkingLevel === undefined ? {} : { piThinkingLevel }), + ...(input.claudeEffort === undefined ? {} : { claudeEffort: input.claudeEffort }), profile: { driverKind: acpxProfile!.driverKind, protocolVersion: acpxProfile!.protocolVersion, @@ -345,7 +349,7 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat schema: "paperclip.native-execution-input.v6", provider: { kind: input.provider, model: null, binding: input.dotBinding }, workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null }, session: { ...prepared.session, driverKind: input.provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp", lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } } - } : prepared) as NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8; + } : prepared) as NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8 | NativeExecutionInputV9; } 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 0c9a546214c..f2be0dd7514 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -10235,6 +10235,20 @@ describe("runnerd provider runtime wiring", () => { await expect(readFile(authPath, "utf8")).resolves.toBe(auth); }); + it.each(["isolated", "preconfigured"] as const)("pins Claude v9 permissions and effort at daemon startup in %s mode", async homeMode => { + const current = { ...execution, schema: "paperclip.native-execution-input.v9", homeMode, + provider: { kind: "acpx", agent: "claude", model: "claude-opus-5-5", permissionMode: "approve-all", claudeEffort: "high" }, + session: { ...execution.session, driverKind: "acpx_runtime" }, + } as unknown as NativeExecutionInput; + await createRunnerdBackend({ db: leaseDb(current), execution: current, + runnerInstanceId: `claude-v9-pinned-${homeMode}` }); + state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!(); + expect(state.createTransport.mock.calls.at(-1)![0]).toMatchObject({ + provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all", + acpxPermissionModePinned: true, claudeEffort: "high", homeMode, + }); + }); + it("copies back and removes Grok credentials from the exact ACPX launch home", async () => { const priorHome = process.env.PAPERCLIP_HOME; const privateRoot = await realpath(isolatedStateDirectory); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 254c8bc284c..33f86149cd9 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -5638,6 +5638,7 @@ export function providerSessionIdentityFromDurableProviderState(input: { identity.permissionMode !== input.execution.provider.permissionMode || !acpxRecoveryModeMatches(input.execution.provider, descriptor.mode, identity.mode) || !acpxRecoveryPiThinkingMatches(input.execution.provider, descriptor.piThinkingLevel, identity.piThinkingLevel) || + !acpxRecoveryClaudeEffortMatches(input.execution.provider, descriptor.claudeEffort, identity.claudeEffort) || !["approve-all", "approve-paperclip", "approve-reads", "deny-all"].includes( String(identity.permissionMode), ) || @@ -5745,6 +5746,13 @@ function acpxRecoveryModeMatches( && observedModes.every(mode => mode === expected); } +function acpxRecoveryClaudeEffortMatches(provider: unknown, projected: unknown, observed: unknown): boolean { + const config = record(provider); + const expected = config.claudeEffort; + if (config.kind !== "acpx" || config.agent !== "claude") return expected === undefined && projected === undefined && observed === undefined; + return expected === projected && expected === observed && (expected === undefined || ["low", "medium", "high", "xhigh", "max"].includes(String(expected))); +} + function acpxRecoveryPiThinkingMatches(provider: NativeExecutionInput["provider"], ...observed: unknown[]): boolean { const expected = record(provider).piThinkingLevel; if (provider.kind === "acpx" && provider.agent === "pi") return ["off", "low", "high", "max"].includes(String(expected)) && observed.every(value => value === expected); @@ -5762,6 +5770,7 @@ export function providerSessionIdentityTransitionIsAllowed(input: { record(record(input.current).providerSessionIdentity).mode, )) return false; if (!acpxRecoveryPiThinkingMatches(input.execution.provider, record(record(input.previous).providerSessionIdentity).piThinkingLevel, record(record(input.current).providerSessionIdentity).piThinkingLevel)) return false; + if (!acpxRecoveryClaudeEffortMatches(input.execution.provider, record(record(input.previous).providerSessionIdentity).claudeEffort, record(record(input.current).providerSessionIdentity).claudeEffort)) return false; if (canonicalJson(input.previous) === canonicalJson(input.current)) { return true; } @@ -13001,10 +13010,12 @@ async function createRunnerdBackendWithinSessionClaim( acpxPermissionMode: input.execution.provider.permissionMode, acpxMode: input.execution.provider.mode, piThinkingLevel: input.execution.provider.piThinkingLevel, + claudeEffort: "claudeEffort" in input.execution.provider ? input.execution.provider.claudeEffort : undefined, acpxPermissionModePinned: input.execution.schema === "paperclip.native-execution-input.v4" || input.execution.schema === "paperclip.native-execution-input.v5" || - input.execution.schema === "paperclip.native-execution-input.v7", + input.execution.schema === "paperclip.native-execution-input.v7" || + input.execution.schema === "paperclip.native-execution-input.v9", acpxRuntimeDirectory: remoteRunnerFilesystemRoot ? posix.join(remoteRunnerFilesystemRoot, "acpx") : resolve( diff --git a/server/src/services/native-runtime/provider-profile.ts b/server/src/services/native-runtime/provider-profile.ts index e3a56340f8a..9e384378a84 100644 --- a/server/src/services/native-runtime/provider-profile.ts +++ b/server/src/services/native-runtime/provider-profile.ts @@ -1,4 +1,5 @@ -import { resolveQualifiedAcpxProfile } from "../../vendor/paperclip-runner/index.js"; +import { agentHarnessType } from "@paperclipai/shared"; +import { resolveClaudeEffort, resolveQualifiedAcpxProfile } from "../../vendor/paperclip-runner/index.js"; import { isPaperclipRunnerProvider, PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES, @@ -26,7 +27,7 @@ export const CLAUDE_MANAGED_BETA_VERSION = "managed-agents-2026-04-01" as const; export const DEFAULT_ACPX_RUNNER_MODELS = { grok: "grok-4.7", - claude: "claude-sonnet-5", + claude: "claude-opus-5-5", // These profiles require explicit configuration. Admission belongs to the runner. codex: null, cursor: null, @@ -136,6 +137,7 @@ export type PaperclipRunnerNativeProviderInput = acpxPermissionMode: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; acpxSessionMode?: "agent" | "plan" | "ask"; piThinkingLevel?: "off" | "low" | "high" | "max"; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; }; export class PaperclipRunnerProviderProfileError extends Error { @@ -195,7 +197,7 @@ export function validatePaperclipRunnerGrokConfig(config: Record key in task) : undefined; - if (backend === "codex_app_server" && ((model !== null && model !== optionalString(base.model)) || effortKey !== undefined)) { + if (supportsEffort && ((model !== null && model !== optionalString(base.model)) || effortKey !== undefined)) { delete config.modelReasoningEffort; delete config.reasoningEffort; delete config.effort; @@ -380,6 +383,19 @@ export function assertAgentCoreProfileRecoveryBinding(input: { } } +export function claudeRunnerUnsupportedSettings(config: Record): string[] { + const unsupported = ["chrome", "agentCommand", "acpAgentCommand", "stateDir", "acpStateDir", "warmHandleIdleMs", "acpWarmHandleIdleMs", "extraArgs", "args", "filesystemScope", "filesystemExtraPaths", "filesystemSandboxCommand", "networkScope", "networkAllowlist", "maxTurnsPerRun", "permissionMode", "acpPermissionMode"] + .filter(key => config[key] !== undefined && config[key] !== false && config[key] !== "" && config[key] !== 0 && !(Array.isArray(config[key]) && config[key].length === 0)); + if (config.command && config.command !== "claude") unsupported.push("command"); + if (config.mode && config.mode !== "persistent") unsupported.push("mode"); + if (config.acpMode && config.acpMode !== "persistent") unsupported.push("acpMode"); + if (config.engine && !["auto", "cli", "acp"].includes(String(config.engine))) unsupported.push("engine"); + if (config.nonInteractivePermissions && config.nonInteractivePermissions !== "deny") unsupported.push("nonInteractivePermissions"); + if (config.acpNonInteractivePermissions && config.acpNonInteractivePermissions !== "deny") unsupported.push("acpNonInteractivePermissions"); + if (config.dangerouslySkipPermissions === false) unsupported.push("dangerouslySkipPermissions"); + return unsupported; +} + /** * Resolve the immutable provider identity used for a fresh Paperclip Runner * selection. The persisted adapterConfig is the authority; runtimeConfig is @@ -409,6 +425,11 @@ export function resolvePaperclipRunnerProviderProfile( const bindingId = validatePaperclipRunnerDotConfig(config)!; return { provider: "openai_dot", backend: "openai_dot_mcp", model: null, dotBindingId: bindingId }; } + if (candidate === "acpx" && agentHarnessType("paperclip_runner", config) === "claude_local") { + const unsupported = claudeRunnerUnsupportedSettings(config); + if (unsupported.length) throw new PaperclipRunnerProviderProfileError("agent_runner_config_incompatible", + `Paperclip Runner cannot honor these settings: ${unsupported.join(", ")}. Remove them or select Legacy runner in Advanced.`); + } assertPermissionMode(candidate, config); try { resolvePaperclipRunnerCursorMode(candidate, config.acpxAgent, config.acpxSessionMode); @@ -418,6 +439,9 @@ export function resolvePaperclipRunnerProviderProfile( error instanceof Error ? error.message : "Invalid Cursor session mode", ); } + try { if (candidate === "acpx" && agentHarnessType("paperclip_runner", config) === "claude_local") resolveClaudeEffort("claude", config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort); } catch (error) { + throw new PaperclipRunnerProviderProfileError("paperclip_runner_claude_effort_invalid", error instanceof Error ? error.message : "Invalid Claude effort"); + } try { resolvePaperclipRunnerPiThinkingLevel(candidate, config.acpxAgent, config.piThinkingLevel); } catch (error) { throw new PaperclipRunnerProviderProfileError("paperclip_runner_pi_thinking_invalid", error instanceof Error ? error.message : "Invalid Pi thinking level"); } @@ -630,6 +654,7 @@ export function resolvePaperclipRunnerNativeProviderInput(input: { provider: "acpx", model: profile.model, acpxAgent: profile.acpxAgent, + ...(profile.acpxAgent === "claude" ? { claudeEffort: resolveClaudeEffort("claude", config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort) } : {}), ...(profile.acpxAgent === "pi" ? { piThinkingLevel: resolvePaperclipRunnerPiThinkingLevel("acpx", "pi", config.piThinkingLevel) } : {}), ...(profile.acpxAgent === "cursor" ? { acpxSessionMode: resolvePaperclipRunnerCursorMode("acpx", "cursor", config.acpxSessionMode), diff --git a/server/src/services/native-runtime/runtime-mode.test.ts b/server/src/services/native-runtime/runtime-mode.test.ts index 94388cf91dd..6f70f9de393 100644 --- a/server/src/services/native-runtime/runtime-mode.test.ts +++ b/server/src/services/native-runtime/runtime-mode.test.ts @@ -501,6 +501,10 @@ describe("resolveHeartbeatRuntimeMode compatibility", () => { adapterConfig: { provider: "opencode", model: "opencode/gpt-5" }, })).toThrow(NativeRunnerSelectionError); }); + it("admits native Claude without enabling other ACPX providers", () => { + expect(resolveNativeRuntimeMode({ ...eligible, enabled: false, adapterConfig: { provider: "acpx", acpxAgent: "claude" } })).toMatchObject({ kind: "native", profile: { backend: "acpx_runtime" } }); + expect(() => resolveNativeRuntimeMode({ ...eligible, enabled: false, adapterConfig: { provider: "acpx", acpxAgent: "grok" } })).toThrow(expect.objectContaining({ code: "paperclip_runner_rollout_disabled" })); + }); }); diff --git a/server/src/services/native-runtime/runtime-mode.ts b/server/src/services/native-runtime/runtime-mode.ts index 7d5dfd08d5f..20510f4b79e 100644 --- a/server/src/services/native-runtime/runtime-mode.ts +++ b/server/src/services/native-runtime/runtime-mode.ts @@ -138,7 +138,7 @@ export function resolveNativeRuntimeMode(input: { if (input.dotEnabled !== true) throw ineligible("paperclip_runner_dot_disabled", "Enable OpenAI Dot and Assistant connections (MCP) in experimental settings before assigning new work."); } else if (runnerProfile.provider === "grok_bot" && (input.grokEnabled !== true || !input.enabled)) { throw ineligible("paperclip_runner_grok_disabled", "Enable Grok Bot, Paperclip Runner, and Assistant connections (MCP) before assigning new work."); - } else if (runnerProfile.provider !== "codex" && !input.enabled) { + } else if (runnerProfile.provider !== "codex" && !(runnerProfile.provider === "acpx" && runnerProfile.acpxAgent === "claude") && !input.enabled) { throw ineligible( "paperclip_runner_rollout_disabled", "Paperclip Runner is experimental and disabled on this instance.", diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 75164ed3102..8a6718bf1af 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -36,6 +36,7 @@ import type { SecretVersionSelector, } from "@paperclipai/shared"; import { + agentHarnessType, CLASS3_STATIC_LEASE_ALLOWLIST, createSecretProviderConfigSchema, deriveProjectUrlKey, @@ -279,7 +280,7 @@ export interface ClaudeOAuthBindingInvariantDecision { export function assertClaudeOAuthBindingInvariant( input: ClaudeOAuthBindingInvariantInput, ): ClaudeOAuthBindingInvariantDecision { - const isClaudeLocal = input.adapterType === CLAUDE_LOCAL_ADAPTER_TYPE; + const isClaudeLocal = agentHarnessType(input.adapterType ?? "", asRecord(input.nextConfig) ?? {}) === CLAUDE_LOCAL_ADAPTER_TYPE; const nextIsFixed = hasFixedClaudeOAuthBinding(input.nextConfig); const priorIsFixed = hasFixedClaudeOAuthBinding(input.priorConfig); diff --git a/server/src/vendor/paperclip-runner/index.ts b/server/src/vendor/paperclip-runner/index.ts index f89e440d53d..d98694ef465 100644 --- a/server/src/vendor/paperclip-runner/index.ts +++ b/server/src/vendor/paperclip-runner/index.ts @@ -37,6 +37,7 @@ export type { NativeExecutionInputV5, NativeExecutionInputV6, NativeExecutionInputV8, + NativeExecutionInputV9, MuseBindingSnapshot, DotBindingSnapshot, ExternalProviderPort, @@ -137,6 +138,7 @@ export const parsePaperclipQuestionSet = runner.parsePaperclipQuestionSet; export const parsePaperclipQuestionResponse = runner.parsePaperclipQuestionResponse; export const resolveQualifiedAcpxProfile = runner.resolveQualifiedAcpxProfile; +export const resolveClaudeEffort = runner.resolveClaudeEffort; export const QUALIFIED_ACPX_PROFILES = runner.QUALIFIED_ACPX_PROFILES; export const QUALIFIED_ACPX_VERSION = runner.QUALIFIED_ACPX_VERSION; export const CURSOR_DISTRIBUTION_PINS = runner.CURSOR_DISTRIBUTION_PINS; diff --git a/skills/paperclip-create-agent/SKILL.md b/skills/paperclip-create-agent/SKILL.md index 800c58fb6e7..c0a7fdf1e13 100644 --- a/skills/paperclip-create-agent/SKILL.md +++ b/skills/paperclip-create-agent/SKILL.md @@ -191,13 +191,18 @@ 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. Other harnesses retain their existing adapters. The saved -response may therefore have `adapterType: paperclip_runner` and `provider: codex`; +execution targets. New Claude Code agents use Paperclip Runner on Apple Silicon; +other Claude targets retain Legacy until qualified. 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; use that returned configuration for later reads and edits. The optional creation field `runner` accepts `auto`, `paperclip`, or `legacy`. -Use `legacy` when the user needs custom Codex CLI settings the native runtime -cannot honor. Setup and authentication errors never silently change runners. +Use `legacy` when the user needs CLI settings the native runtime cannot honor. +For Claude this includes Chrome, custom commands/state paths, warm-process +controls, confinement and turn limits. Native Claude uses existing API-key and +subscription connections. Its configured effort requires provider acknowledgment. Setup and authentication errors never silently change runners. Existing agents keep their saved execution after unrelated edits. An approved hire uses the configuration reviewed by the board. `inheritRuntimeFrom: caller` keeps its existing runtime inheritance and credential restrictions. diff --git a/tests/e2e/acp-stop-continuation.spec.ts b/tests/e2e/acp-stop-continuation.spec.ts index 8e03550841f..3a5735e76b0 100644 --- a/tests/e2e/acp-stop-continuation.spec.ts +++ b/tests/e2e/acp-stop-continuation.spec.ts @@ -18,7 +18,7 @@ for (const { unfinishedWrite, stopResponse } of [{ unfinishedWrite: false, stopR try { await json(await request.patch("/api/instance/settings/experimental", { data: { enableClassicTaskInterface: false } })); const owner = await json(await request.post(`/api/companies/${company.id}/agents`, { data: { - name: "ACP Stop fixture", role: "engineer", adapterType: "claude_local", + name: "ACP Stop fixture", role: "engineer", adapterType: "claude_local", runner: "legacy", adapterConfig: { engine: "acp", cwd: root, stateDir: path.join(root, "state"), agentCommand: `${JSON.stringify(process.execPath)} ${JSON.stringify(path.resolve("scripts/mcp-fixtures/servers/acp-stop-agent.mjs"))}`, // The ACP fixture has no provider login. Use a fixture key to keep setup offline. diff --git a/tests/e2e/legacy-failure-continuation.spec.ts b/tests/e2e/legacy-failure-continuation.spec.ts index 6645935cbab..216261cb878 100644 --- a/tests/e2e/legacy-failure-continuation.spec.ts +++ b/tests/e2e/legacy-failure-continuation.spec.ts @@ -24,7 +24,7 @@ for (const action of ["task_retry", "thread_retry", "inbox_retry", "message", "q try { await writeFile(path.join(root, "continued"), "ready"); const agent = await json(await request.post(`/api/companies/${company.id}/agents`, { data: { - name: "Recovery fixture", role: "engineer", adapterType: "claude_local", + name: "Recovery fixture", role: "engineer", adapterType: "claude_local", runner: "legacy", adapterConfig: { engine: "acp", cwd: root, stateDir: path.join(root, "state"), agentCommand: `${JSON.stringify(process.execPath)} ${JSON.stringify(path.resolve("scripts/mcp-fixtures/servers/acp-stop-agent.mjs"))}`, // The ACP fixture has no provider login. Use a fixture key to keep setup offline. diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index da3e4f63268..61867bbda68 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -1086,13 +1086,12 @@ model. Configured and provider-observed model identities are reported separately There are thirteen cases on `legacy-codex`, `legacy-claude`, `runner-codex`, and `runner-acpx-claude`, local only (52 cells). `runner-codex` selects OpenAI in the production wizard, omits the runner override, and verifies the saved native -configuration without changing runners afterward. `legacy-codex` selects -Legacy runner in Advanced before authentication and creation. Both are labeled -`production-wizard`. Claude's creation default is unchanged: `legacy-claude` -retains its wizard configuration, while `runner-acpx-claude` explicitly changes -runtime through the public API before its first task and is labeled -`post-onboarding-runtime-switch`. Persona, managed instructions, skills, seeded -question, and task invocation are preserved. The native path removes the legacy +configuration without changing runners afterward. `runner-acpx-claude` does the +same for Claude on its qualified default targets and inspects the saved ACPX +Claude profile directly. Both Legacy profiles select Legacy runner in Advanced +before authentication and creation. All four paths are labeled +`production-wizard`; there is no post-onboarding runner switch. Persona, +managed instructions, skills, seeded question, and task invocation are preserved. The native path removes the legacy Paperclip operational skill because Runner supplies that contract through its protocol; other assigned skills, including `/first-task`, are retained. Each isolated onboarding company has a $5 task budget. Setup probes have a @@ -1454,9 +1453,10 @@ prompts, onboarding defaults, or provider permissions are changed. For native onboarding qualification, select `first-task` with profiles `runner-codex,runner-acpx-claude` (26 cells). Codex verifies the production -creation default with the experimental flag disabled. Claude still uses the -existing public-API runtime switch before provider work; that case does not -qualify a native Claude creation default. +creation default with the experimental flag disabled. Claude also inspects its +wizard's saved native default with the experimental flag disabled; select that +profile on a qualified default target. Retained results from the former API-switch +definition do not establish that the current creation-default journey passed. Current proof and remaining decisions are recorded in [the 21 September qualification report](QUALIFICATION-2026-09-21.md). In particular, @@ -2222,6 +2222,22 @@ always preserves recovery state regardless of this optional diagnostic flag. The `file-edit-validate` fixture independently downloads the exact active artifact work product from the tested run. It requires the matching run-attributed attachment, filename, MIME type, recorded byte count and SHA-256, then compares the downloaded content to the expected bytes. A workspace file alone cannot satisfy this gate. Explicit failed-case diagnostic retention follows the final result after integrity, isolation and evidence checks, including incomplete publication. + +## Claude default rollout acceptance + +The Claude first-task onboarding case asserts the wizard's saved native profile +directly. It must not patch a Legacy agent into native execution after creation. +Ordinary native defaults are qualified by execution target: the first rollout +enables Apple Silicon only. API-key acceptance requires a useful downloadable +file, a follow-up revision, independent downloads of both versions, persisted +configuration and session continuity after reload/restart. Subscription setup +uses the existing connection tools and receives separate task/follow-up proof. + +Keep exact source, runtime executable digests and reports for each candidate. +Effort or runtime changes require affected acceptance reruns. No automatic paid +retry campaign is permitted under the separate $50 Claude qualification ceiling. +Docker and expensive Rust builds run in cloud CI, never on the local Mac. + ## Projectless task workspaces (explicit-only) `task-workspaces` contains exactly three cells: legacy OpenCode locally and native diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index fa4278a0bf7..d490d1c8e63 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: 5, runGrading: "evidenced-nonexecution-and-refusal", credentialPersistenceCheck: false, questionChoiceMinimum: 2, nativeSetup: "codex-production-default-claude-explicit-runtime-switch", productionInstructions: true, qualityGrading: "informational" }, + 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" }, }, { id: "agent-chat", label: "Persistent Agent Chat", diff --git a/tests/runner-e2e/first-task-flow.ts b/tests/runner-e2e/first-task-flow.ts index b190c824254..11c16a07921 100644 --- a/tests/runner-e2e/first-task-flow.ts +++ b/tests/runner-e2e/first-task-flow.ts @@ -8,7 +8,6 @@ import { captureFirstTaskAttachments } from "./first-task-attachments.js"; import { waitForFirstTaskReply } from "./first-task-replies.js"; import { observeCompletionUpdate } from "./completion-update-flow.js"; import { - firstTaskNativeRuntimePatch, provisionFirstTaskFixtures, isManagedFirstTask, startFirstTaskWithRuntimeBound, @@ -329,7 +328,7 @@ export async function setupFirstTaskFixtures(input: { exact: true, }); const savedKey = page.getByRole("combobox", { name: "Saved API key" }); - if (execution.profile.id === "legacy-codex") { + if (["legacy-codex", "legacy-claude"].includes(execution.profile.id)) { await page.getByText("Advanced", { exact: true }).click(); await page.getByRole("button", { name: "Runner", exact: true }).click(); await page.getByRole("option", { name: "Legacy runner", exact: true }).click(); @@ -359,7 +358,7 @@ export async function setupFirstTaskFixtures(input: { const wizardAdapter = execution.profile.credential === "OPENAI_API_KEY" ? execution.profile.id === "runner-codex" ? "paperclip_runner" : "codex_local" - : "claude_local"; + : execution.profile.id === "runner-acpx-claude" ? "paperclip_runner" : "claude_local"; expect(agents[0].adapterType).toBe(wizardAdapter); if (execution.profile.id === "runner-codex") { expect(agents[0].adapterConfig?.provider).toBe("codex"); @@ -371,33 +370,9 @@ export async function setupFirstTaskFixtures(input: { originalModel: agents[0].adapterConfig?.model ?? null, }; if (execution.profile.id === "runner-acpx-claude") { - const runtimePatch = firstTaskNativeRuntimePatch( - execution, - fixtures, - agents[0], - ); - const migrated = await api.patch( - `/api/agents/${agents[0].id}`, - runtimePatch, - ); - expect(migrated.adapterType).toBe("paperclip_runner"); - expect(migrated.adapterConfig?.provider).toBe(execution.profile.provider); - expect(migrated.adapterConfig?.instructionsFilePath).toBe( - agents[0].adapterConfig?.instructionsFilePath, - ); - expect(migrated.adapterConfig?.paperclipSkillSync?.desiredSkills).toEqual( - ( - runtimePatch.adapterConfig.paperclipSkillSync as { - desiredSkills?: unknown[]; - } - )?.desiredSkills, - ); - fixtures.onboardingRuntime = { - mode: "post-onboarding-runtime-switch", - originalAdapterType: wizardAdapter, - testedAdapterType: migrated.adapterType, - originalModel: agents[0].adapterConfig?.model ?? null, - }; + expect(agents[0].adapterConfig?.provider).toBe("acpx"); + expect(agents[0].adapterConfig?.acpxAgent).toBe("claude"); + expect(agents[0].adapterConfig?.model).toBe("claude-opus-5-5"); } fixtures.agent = { id: agents[0].id, diff --git a/tests/runner-e2e/installed-cli.test.ts b/tests/runner-e2e/installed-cli.test.ts index 64c4d5d9b3b..d14b65d8df0 100644 --- a/tests/runner-e2e/installed-cli.test.ts +++ b/tests/runner-e2e/installed-cli.test.ts @@ -3,13 +3,17 @@ import { createHash } from "node:crypto"; import { chmod, lstat, mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { afterEach, expect, it } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { runnerMatrix } from "./catalog.js"; import { assertInstalledCliSelection, installedCliKeys, probeInstalledCodexStartup, verifyInstalledCli } from "./installed-cli.js"; import { buildPaperclipServerEnvironment, buildRunnerE2EProcessEnvironment } from "./harness-env.js"; const cells = runnerMatrix.filter(e => e.profile.id === "runner-acpx-pi"); const roots: string[] = []; -afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); +beforeEach(() => { + vi.stubEnv("NODE_PATH", undefined); + vi.stubEnv("NODE_OPTIONS", undefined); +}); +afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); const hash = (value: string) => createHash("sha256").update(value).digest("hex"); async function fixture() { const root = await realpath(await mkdtemp(join(tmpdir(), "e2e-installed-cli-"))); roots.push(root); @@ -187,3 +191,8 @@ it.skipIf(process.platform === "win32")("installed startup rejects stale daemon/ await expect(probeInstalledCodexStartup({ consumerRoot: wrong.root, serverRoot: wrong.server, expectedRunnerSha256: wrong.expectedRunnerSha256 })).rejects.toThrow("matching native identity"); expect(await readFile(wrong.observations, "utf8").then(JSON.parse)).toMatchObject({ calls: ["open", "session-close"] }); }); + +it("installed startup rejects ambient Node injection before launching", async () => { + vi.stubEnv("NODE_PATH", "/untrusted/test-path"); + await expect(probeInstalledCodexStartup({ consumerRoot: "/unused", serverRoot: "/unused", expectedRunnerSha256: "0".repeat(64) })).rejects.toThrow("forbids NODE_PATH"); +}); diff --git a/tests/runner-e2e/runner.spec.ts b/tests/runner-e2e/runner.spec.ts index d286c4419c8..642f2bafc90 100644 --- a/tests/runner-e2e/runner.spec.ts +++ b/tests/runner-e2e/runner.spec.ts @@ -563,6 +563,9 @@ for (const execution of executions) { page, request, }, testInfo) => { + test.skip(execution.suite.id === "first-task" && execution.profile.id === "runner-acpx-claude" + && (process.platform !== "darwin" || process.arch !== "arm64"), + "Ordinary native Claude onboarding is qualified on Apple Silicon only in this delivery."); test.setTimeout( execution.task.flow === "warm_three_turn" ? deadlineMs diff --git a/ui/src/adapters/adapter-display-registry.ts b/ui/src/adapters/adapter-display-registry.ts index d56c6a92109..d77ed9bc96b 100644 --- a/ui/src/adapters/adapter-display-registry.ts +++ b/ui/src/adapters/adapter-display-registry.ts @@ -220,7 +220,8 @@ export function agentDisplayAdapterType(agent: { adapterType: string; adapterCon if (agent.adapterConfig.provider === "openai_dot" || agent.adapterConfig.provider === "grok_bot" || agent.adapterConfig.provider === "muse") { return agent.adapterConfig.provider; } - return agentHarnessType(agent.adapterType, agent.adapterConfig) === "codex_local" ? "codex_local" : agent.adapterType; + const harness = agentHarnessType(agent.adapterType, agent.adapterConfig); + return ["codex_local", "claude_local"].includes(harness) ? harness : agent.adapterType; } export function isKnownAdapterType(type: string): boolean { diff --git a/ui/src/adapters/codex-local/config-fields.test.tsx b/ui/src/adapters/codex-local/config-fields.test.tsx index 7075ec06792..28abea8201c 100644 --- a/ui/src/adapters/codex-local/config-fields.test.tsx +++ b/ui/src/adapters/codex-local/config-fields.test.tsx @@ -11,9 +11,9 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { CodexLocalConfigFields } from "./config-fields"; import { buildCodexRunnerConfig } from "./config-fields"; +import { buildPaperclipRunnerConfig } from "@paperclipai/adapter-codex-local/ui"; import { defaultCreateValues } from "@/components/agent-config-defaults"; import { resolveCodexLocalCliPolicy } from "@paperclipai/adapter-codex-local"; -import { buildPaperclipRunnerConfig } from "@paperclipai/adapter-codex-local/ui"; beforeAll(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); }); @@ -121,11 +121,6 @@ async function editFields( } describe("Paperclip Runner Codex configuration", () => { - it.each(["", "max"])("preserves or overrides saved native Codex reasoning effort (%s)", thinkingEffort => { - const values = { ...defaultCreateValues, thinkingEffort, adapterSchemaValues: { provider: "codex", modelReasoningEffort: "high" } }; - expect(buildPaperclipRunnerConfig(values).modelReasoningEffort).toBe(thinkingEffort || "high"); - expect(buildPaperclipRunnerConfig({ ...values, adapterSchemaValues: { provider: "acpx", acpxAgent: "claude", modelReasoningEffort: "high" } })).not.toHaveProperty("modelReasoningEffort"); - }); it.each(["", "--sandbox,read-only"])("shows actual automatic Legacy policy and preserves the explicit toggle (%s)", async (extraArgs) => { const container = document.createElement("div"); document.body.appendChild(container); diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index c2f624b3e8b..d691b30c5c2 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -40,7 +40,7 @@ const inputClass = const instructionsFileHint = "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime. Note: Codex may still auto-apply repo-scoped AGENTS.md files from the workspace."; const defaultOpenCodeRunnerModel = "openrouter/deepseek/deepseek-v4-flash-0731"; -const defaultAcpxClaudeModel = "claude-sonnet-5"; +const defaultAcpxClaudeModel = "claude-opus-5-5"; const defaultClaudeManagedModel = "claude-sonnet-5"; const defaultAwsAgentCoreModel = "global.anthropic.claude-sonnet-4-6"; const runnerHarnessOptions = [ diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index 0363a08af95..4dc46597202 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -18,6 +18,7 @@ import { ApiError } from "../api/client"; import { aiConnectionsApi } from "../api/ai-connections"; import { CodexLocalConfigFields } from "../adapters/codex-local/config-fields"; import type { AdapterConfigFieldsProps } from "../adapters/types"; +import { buildClaudeLocalConfig } from "@paperclipai/adapter-claude-local/ui"; import { DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local"; const mockAgentsApi = vi.hoisted(() => ({ @@ -79,9 +80,8 @@ vi.mock("../api/grokBot", () => ({ }, })); -vi.mock("../api/adapters", () => ({ - adaptersApi: { list: async () => [{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }] }, -})); +const mockAdaptersApi = vi.hoisted(() => ({ list: vi.fn(async () => [{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }]) })); +vi.mock("../api/adapters", () => ({ adaptersApi: mockAdaptersApi })); vi.mock("../api/environments", () => ({ environmentsApi: mockEnvironmentsApi, @@ -640,6 +640,37 @@ async function flushUntil(check: () => boolean, timeoutMs = 4000) { describe("AgentConfigForm environment selector", () => { let roots: Root[] = []; + it.each(["paperclip", "legacy"] as const)("uses the resolved %s Claude form to preserve the correct turn limit", async defaultRunner => { + mockAdaptersApi.list.mockResolvedValue([{ type: "claude_local", supportedRunners: defaultRunner === "paperclip" ? ["paperclip", "legacy"] : ["legacy"], defaultRunner }]); + try { + const values = { ...defaultCreateValues, adapterType: "claude_local", runner: "auto" as const }; + const result = await renderCreateForm([], values); + roots.push(result.root); + const resolved = result.onChange.mock.calls.reduce((current, [patch]) => ({ ...current, ...patch }), values); + expect(resolved.resolvedRunner).toBe(defaultRunner); + const config = buildClaudeLocalConfig(resolved); + if (defaultRunner === "legacy") expect(config.maxTurnsPerRun).toBe(1000); + else expect(config).not.toHaveProperty("maxTurnsPerRun"); + expect(config).not.toHaveProperty("resolvedRunner"); + } finally { mockAdaptersApi.list.mockResolvedValue([{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }]); } + }); + + + it("carries a selected native Claude permission through the ordinary creation builder", async () => { + mockAdaptersApi.list.mockResolvedValue([{ type: "claude_local", supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" }]); + try { + const values = { ...defaultCreateValues, adapterType: "claude_local", runner: "auto" as const }; + const result = await renderCreateForm([], values); + roots.push(result.root); + await clickElement(result.container.querySelector('[aria-label="Permission mode"]')); + const deny = [...document.querySelectorAll('[role="option"]')].find(option => option.textContent === "Deny all"); + expect(deny).toBeTruthy(); + await clickElement(deny); + const resolved = result.onChange.mock.calls.reduce((current, [patch]) => ({ ...current, ...patch }), values); + expect(buildClaudeLocalConfig(resolved)).toMatchObject({ acpxPermissionMode: "deny-all" }); + } finally { mockAdaptersApi.list.mockResolvedValue([{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }]); } + }); + it("keeps an explicit Codex draft policy until a harness reset clears it", async () => { mockEnvironmentsApi.list.mockResolvedValue([]); const container = document.createElement("div"); @@ -686,6 +717,21 @@ describe("AgentConfigForm environment selector", () => { expect(result.onSave).toHaveBeenCalledWith({ name: "Renamed Cody" }); }); + it.each(["codex", "claude"].flatMap(harness => ["modelReasoningEffort", "reasoningEffort", "effort"].map(key => ({ harness, key }))))("hydrates saved effort and clears explicit Auto for $harness ($key)", async ({ harness, key }) => { + const profile = harness === "codex" ? { provider: "codex", model: "gpt-5.6-sol" } : { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5" }; + const result = await renderForm([], { adapterType: "paperclip_runner", adapterConfig: { ...profile, [key]: "high" } }); + roots.push(result.root); + const effort = result.container.querySelector('[aria-label="Thinking effort"]'); + expect(effort?.textContent).toContain("High"); + await clickElement(effort); + await clickElement(document.querySelector('[role="option"][data-value=""]')); + await clickByText(result.container, "Save"); + const saved = result.onSave.mock.calls[0][0]; + expect(saved.replaceAdapterConfig).toBe(true); + expect(saved.adapterConfig).toMatchObject(profile); + for (const alias of ["modelReasoningEffort", "reasoningEffort", "effort"]) expect(saved.adapterConfig).not.toHaveProperty(alias); + }); + it.each(["codex_local", "paperclip_runner"])("changes and clears the environment without changing a saved %s runner or account", async adapterType => { const adapterConfig = { model: "gpt-5.6-sol", @@ -748,7 +794,23 @@ describe("AgentConfigForm environment selector", () => { expect(result.onSave.mock.calls[0][0].runner).toBeUndefined(); }); - it.each([{ provider: "opencode", model: "openrouter/anthropic/claude-sonnet-4.6" }, { provider: "acpx", acpxAgent: "claude" }, { provider: "unknown-provider" }])("preserves saved native $provider configuration with the experimental gate off", async adapterConfig => { + it("shows Claude's saved runner without legacy command controls when the experimental gate is off", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false }); + const result = await renderForm([], { adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", modelReasoningEffort: "high" } }, { showAdapterTypeField: true }); + roots.push(result.root); + expect(result.container.querySelector('[aria-label="Harness"]')?.textContent).toContain("Claude"); + await clickByText(result.container, "Advanced"); + expect(result.container.querySelector('[aria-label="Runner"]')?.textContent).toContain("Paperclip Runner"); + const labels = Array.from(result.container.querySelectorAll("label")).map(label => label.textContent?.trim()); + expect(labels).not.toContain("Command"); + expect(labels).not.toContain("Extra args (comma-separated)"); + await act(async () => setInputValue(result.container.querySelector('[placeholder="Agent name"]')!, "Renamed Claude")); + await flushReact(); + await clickByText(result.container, "Save"); + expect(result.onSave).toHaveBeenCalledWith({ name: "Renamed Claude" }); + }); + + it.each([{ provider: "opencode", model: "openrouter/anthropic/claude-sonnet-4.6" }, { provider: "unknown-provider" }])("preserves saved native $provider configuration with the experimental gate off", async adapterConfig => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false }); const result = await renderForm([], { adapterType: "paperclip_runner", adapterConfig }, { showAdapterTypeField: true }); roots.push(result.root); @@ -915,7 +977,7 @@ describe("AgentConfigForm environment selector", () => { it.each([ ["Codex", "codex", undefined, DEFAULT_CODEX_LOCAL_MODEL], - ["ACP agents", "acpx", "claude", "claude-sonnet-5"], + ["ACP agents", "acpx", "claude", "claude-opus-5-5"], ["Claude Managed", "claude_managed", undefined, "claude-sonnet-5"], ])("saves the %s harness default without the previous OpenCode model prefix", async (label, provider, acpxAgent, model) => { const accountList = vi.spyOn(aiConnectionsApi, "list").mockResolvedValue({ @@ -1058,14 +1120,61 @@ describe("AgentConfigForm environment selector", () => { }); }); + it.each(["modelReasoningEffort", "reasoningEffort", "effort"])("clears native Claude %s when the model does not support it", async key => { + mockAgentsApi.adapterModels.mockResolvedValue([ + { id: "claude-opus-5-5", label: "claude-opus-5-5" }, + { id: "claude-haiku-4-5", label: "claude-haiku-4-5" }, + ]); + const result = await renderForm( + [makeEnvironment({ id: "local-1", name: "Local", driver: "local" })], + { + adapterType: "paperclip_runner", + adapterConfig: { + provider: "acpx", acpxAgent: "claude", + model: "claude-opus-5-5", + [key]: "max", + }, + }, + ); + roots.push(result.root); + + const modelButton = Array.from(result.container.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "claude-opus-5-5"); + expect(modelButton).not.toBeUndefined(); + await act(async () => { + modelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + const astraOption = Array.from(document.body.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "claude-haiku-4-5"); + expect(astraOption).not.toBeUndefined(); + await act(async () => { + astraOption!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + const saveButton = Array.from(result.container.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Save"); + expect(saveButton).not.toBeUndefined(); + await act(async () => { + saveButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(result.onSave).toHaveBeenCalledWith({ + adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-haiku-4-5" }, + replaceAdapterConfig: true, + }); + }); + it("names the Claude default for new and existing agents without pinning it", async () => { const environments = [makeEnvironment({ id: "local-1", name: "Local", driver: "local" })]; const existing = await renderForm(environments, { adapterType: "claude_local", adapterConfig: {} }); roots.push(existing.root); const created = await renderCreateForm(environments, { adapterType: "claude_local", model: "" }); roots.push(created.root); - expect(existing.container.textContent).toContain("Default (claude-opus-5)"); - expect(created.container.textContent).toContain("Default (claude-opus-5)"); + expect(existing.container.textContent).toContain("Default (claude-opus-5-5)"); + expect(created.container.textContent).toContain("Default (claude-opus-5-5)"); expect(existing.onSave).not.toHaveBeenCalled(); }); diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 5d7caa4eb32..50f1ebb671b 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1,7 +1,7 @@ import { useConnectionModels } from "./ai-connections/useConnectionModels"; import { agentHarnessType, agentRunner, type AgentRunnerChoice } from "@paperclipai/shared"; import { adaptersApi } from "../api/adapters"; -import { CodexRunnerSelect } from "./CodexRunnerSelect"; +import { AgentRunnerSelect } from "./AgentRunnerSelect"; import { aiRoutingHarness } from "@paperclipai/shared"; import { AiConnectionField } from "./ai-connections/AiConnectionField"; import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema } from "@paperclipai/shared"; @@ -606,6 +606,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const effectiveConfig = isCreate ? props.values.adapterSchemaValues ?? {} : { ...config, ...overlay.adapterConfig }; const harnessType = agentHarnessType(adapterType, effectiveConfig); const isCodexHarness = harnessType === "codex_local"; + const isRunnerHarness = isCodexHarness || harnessType === "claude_local"; const rawCurrentDefaultEnvironmentId = isCreate ? props.values.defaultEnvironmentId ?? "" : eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? ""); @@ -619,13 +620,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) { queryFn: () => adaptersApi.list({ companyId: selectedCompanyId!, environmentId: rawCurrentDefaultEnvironmentId || null }), enabled: Boolean(selectedCompanyId), }); - const codexRunnerAvailability = runnerDiscovery.data?.find(item => item.type === "codex_local"); + const runnerAvailability = runnerDiscovery.data?.find(item => item.type === harnessType); const runner: AgentRunnerChoice = isCreate ? props.values.runner ?? "auto" : overlay.runner ?? agentRunner(adapterType); - const fieldsAdapterType = isCodexHarness - ? (runner === "paperclip" || (runner === "auto" && codexRunnerAvailability?.defaultRunner === "paperclip") ? "paperclip_runner" : "codex_local") + const fieldsAdapterType = isRunnerHarness + ? (runner === "paperclip" || (runner === "auto" && runnerAvailability?.defaultRunner === "paperclip") ? "paperclip_runner" : harnessType) : adapterType; + useEffect(() => { + if (props.mode !== "create" || harnessType !== "claude_local") return; + const resolvedRunner = runner === "auto" ? runnerAvailability?.defaultRunner : runner; + if (props.values.resolvedRunner !== resolvedRunner) props.onChange({ resolvedRunner }); + }, [props.mode, harnessType, runner, runnerAvailability?.defaultRunner, + isCreate ? props.values.resolvedRunner : undefined, isCreate ? props.onChange : undefined]); const getCapabilities = useAdapterCapabilities(); - const adapterCaps = getCapabilities(isCodexHarness ? harnessType : adapterType); + const adapterCaps = getCapabilities(isRunnerHarness ? harnessType : adapterType); const runnerProvider = adapterType === "paperclip_runner" ? String(isCreate ? props.values.adapterSchemaValues?.provider ?? "codex" : eff("adapterConfig", "provider", config.provider === "acpx" && config.acpxAgent === "codex" ? "codex" : config.provider ?? "codex")) @@ -921,7 +928,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ).data?.provider === "openrouter" ? "openrouter" : runnerProvider; const connectionModels = useConnectionModels(isExternalRunner ? null : selectedCompanyId, isCreate ? undefined : aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data, aiRoutingHarness(adapterType, runnerProvider, eff("adapterConfig", "acpxAgent", config.acpxAgent))); // Fetch adapter models for the effective provider, including unsaved changes. - const modelAdapterType = isCodexHarness ? harnessType : adapterType; + const modelAdapterType = isRunnerHarness ? harnessType : adapterType; const modelQueryKey = selectedCompanyId ? queryKeys.agents.adapterModels(selectedCompanyId, modelAdapterType, currentDefaultEnvironmentId || null, modelProvider) : ["agents", "none", "adapter-models", adapterType]; @@ -973,10 +980,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) { mode, isCreate, adapterType: fieldsAdapterType, - hideRunnerHarness: isCodexHarness, - values: isCreate ? props.values : null, + hideRunnerHarness: isRunnerHarness, + values: isCreate ? { ...props.values, ...(harnessType === "claude_local" && fieldsAdapterType === "paperclip_runner" ? { adapterSchemaValues: { ...props.values.adapterSchemaValues, provider: "acpx", acpxAgent: "claude" } } : {}) } : null, set: isCreate ? (patch: Partial) => props.onChange(patch) : null, - config, + config: harnessType === "claude_local" && fieldsAdapterType === "paperclip_runner" ? { ...config, provider: "acpx", acpxAgent: "claude" } : config, eff: eff as (group: "adapterConfig", field: string, original: T) => T, // Harness transitions supply the new harness's default model. Resolve // user-selected model IDs in ModelDropdown, not through the previous @@ -1108,9 +1115,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { : adapterConfig.provider === "acpx" && adapterConfig.acpxAgent === "claude" ? "claude_local" : adapterType : adapterType; - return testAgentSetup({ companyId: selectedCompanyId, adapterType, providerAdapter, adapterConfig, agentId, aiConnection, environmentId, ...(isCodexHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {}) }); + return testAgentSetup({ companyId: selectedCompanyId, adapterType, providerAdapter, adapterConfig, agentId, aiConnection, environmentId, ...(isRunnerHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {}) }); } - return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, aiConnection, environmentId, ...(isCodexHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {}) }); + return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, aiConnection, environmentId, ...(isRunnerHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {}) }); }, }); const [testActionPending, setTestActionPending] = useState(false); @@ -1157,7 +1164,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { resetTestEnvironmentRef.current(); setTestActionError(null); clearClaudeLoginClaimRef.current(); - }, [adapterType, runner, effectiveLoginEnvironmentId, runner === "auto" ? codexRunnerAvailability?.defaultRunner : undefined]); + }, [adapterType, runner, effectiveLoginEnvironmentId, runner === "auto" ? runnerAvailability?.defaultRunner : undefined]); // Show the login affordance only for a current sandbox adapter that declares a // login capability, and whose most recent Test result carries the canonical @@ -1168,7 +1175,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const adapterSupportsSandboxLogin = adapterCaps.login != null; const testResult = testEnvironment.data; const testResultSupportsSandboxLogin = - testResult != null && getCapabilities(isCodexHarness ? harnessType : testResult.adapterType).login != null; + testResult != null && getCapabilities(isRunnerHarness ? harnessType : testResult.adapterType).login != null; const authMissingCheck = testResult && testResultSupportsSandboxLogin ? testResult.checks.find((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE) ?? null @@ -1263,7 +1270,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { // environment id, so both are present here. login: showAdapterLogin && selectedCompanyId && effectiveLoginEnvironmentId - ? { companyId: selectedCompanyId, adapterType: isCodexHarness ? harnessType : adapterType, environmentId: effectiveLoginEnvironmentId } + ? { companyId: selectedCompanyId, adapterType: isRunnerHarness ? harnessType : adapterType, environmentId: effectiveLoginEnvironmentId } : null, }); return () => { @@ -1301,7 +1308,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } const thinkingEffortKey = - isCodexHarness + isCodexHarness || (adapterType === "paperclip_runner" && harnessType === "claude_local") ? "modelReasoningEffort" : adapterType === "cursor" ? "mode" @@ -1323,19 +1330,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? kimiThinkingEffortOptions : adapterType === "pi_local" ? [{ id: "", label: "Auto" }, ...["off", "minimal", "low", "medium", "high", "xhigh"].map(id => ({ id, label: id }))] - : adapterType === "claude_local" || adapterType === "grok_local" - ? [{ id: "", label: "Auto" }, ...setupEfforts(adapterType, currentModelId).map((id) => ({ + : harnessType === "claude_local" || adapterType === "grok_local" + ? [{ id: "", label: "Auto" }, ...setupEfforts(harnessType, currentModelId).map((id) => ({ id, label: id === "xhigh" ? "X-High" : id[0].toUpperCase() + id.slice(1), }))] : claudeThinkingEffortOptions; const currentThinkingEffort = isCreate ? val!.thinkingEffort - : isCodexHarness + : isCodexHarness || (adapterType === "paperclip_runner" && harnessType === "claude_local") ? eff( "adapterConfig", thinkingEffortKey, - String(config[thinkingEffortKey] ?? config.reasoningEffort ?? config.modelReasoningEffort ?? ""), + String(config[thinkingEffortKey] ?? config.reasoningEffort ?? config.modelReasoningEffort ?? config.effort ?? ""), ) : adapterType === "cursor" ? eff("adapterConfig", "mode", String(config.mode ?? "")) @@ -1344,7 +1351,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { : eff("adapterConfig", thinkingEffortKey, String(config[thinkingEffortKey] ?? "")); const showThinkingEffort = adapterType !== "gemini_local" && adapterType !== "cursor_cloud" - && (adapterType !== "paperclip_runner" || isCodexHarness); + && (adapterType !== "paperclip_runner" || isRunnerHarness); const codexSearchEnabled = adapterType === "codex_local" ? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search))) : false; @@ -1640,7 +1647,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { {showAdapterTypeField && !isGrokBotRunner && ( ({ ...prev, adapterType: t, - runner: t === "codex_local" ? "auto" : undefined, + runner: ["codex_local", "claude_local"].includes(t) ? "auto" : undefined, adapterConfig: { model: t === "gemini_local" @@ -1735,7 +1742,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { { - const supportedEfforts = setupEfforts(isCodexHarness ? harnessType : adapterType, v); - const clearUnsupportedEffort = ["codex_local", "claude_local", "grok_local"].includes(isCodexHarness ? harnessType : adapterType) + const supportedEfforts = setupEfforts(isRunnerHarness ? harnessType : adapterType, v); + const clearUnsupportedEffort = ["codex_local", "claude_local", "grok_local"].includes(isRunnerHarness ? harnessType : adapterType) && Boolean(currentThinkingEffort) && !supportedEfforts.includes(String(currentThinkingEffort)); if (isCreate) { @@ -1790,8 +1797,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } mark("adapterConfig", "model", connectionModels?.resolveModel(v) || v || undefined); if (clearUnsupportedEffort) { - mark("adapterConfig", thinkingEffortKey, undefined); - mark("adapterConfig", "reasoningEffort", undefined); + for (const key of ["modelReasoningEffort", "reasoningEffort", "effort"]) mark("adapterConfig", key, undefined); } }} open={modelOpen} @@ -1846,7 +1852,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? set!({ thinkingEffort: v }) : (() => { mark("adapterConfig", thinkingEffortKey, v || undefined); - if (isCodexHarness) mark("adapterConfig", thinkingEffortKey === "reasoningEffort" ? "modelReasoningEffort" : "reasoningEffort", undefined); + if (isRunnerHarness) { + for (const key of ["modelReasoningEffort", "reasoningEffort", "effort"]) if (key !== thinkingEffortKey) mark("adapterConfig", key, undefined); + } })() } open={thinkingEffortOpen} @@ -1908,12 +1916,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) { onToggle={() => setConfigurationAdvancedOpen(!configurationAdvancedOpen)} >
- {isCodexHarness && { void runnerDiscovery.refetch(); }} - defaultRunner={codexRunnerAvailability?.defaultRunner} - supportedRunners={codexRunnerAvailability?.supportedRunners} + defaultRunner={runnerAvailability?.defaultRunner} + supportedRunners={runnerAvailability?.supportedRunners} onChange={next => isCreate ? set!({ runner: next }) : setOverlay(prev => ({ ...prev, runner: next }))} />} - {isLocal && (<> {/* + {isLocal && (!isRunnerHarness || fieldsAdapterType !== "paperclip_runner") && (<> {/* The command names a binary on the execution host, so the managed-sandbox-only policy hides it: the platform-managed image owns the binary. Hiding is presentation only. A stored diff --git a/ui/src/components/CodexRunnerSelect.tsx b/ui/src/components/AgentRunnerSelect.tsx similarity index 97% rename from ui/src/components/CodexRunnerSelect.tsx rename to ui/src/components/AgentRunnerSelect.tsx index 6059f3e4ba3..f2f72e2d6ff 100644 --- a/ui/src/components/CodexRunnerSelect.tsx +++ b/ui/src/components/AgentRunnerSelect.tsx @@ -3,7 +3,7 @@ import { Field } from "./agent-config-primitives"; import { SelectPopover } from "./ui/select"; import { Button } from "./ui/button"; -export function CodexRunnerSelect({ +export function AgentRunnerSelect({ value, onChange, defaultRunner, diff --git a/ui/src/components/ConfigureBuiltInAgentModal.tsx b/ui/src/components/ConfigureBuiltInAgentModal.tsx index 8e6e63176bb..a4b39e8cb2c 100644 --- a/ui/src/components/ConfigureBuiltInAgentModal.tsx +++ b/ui/src/components/ConfigureBuiltInAgentModal.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from "react"; import { agentHarnessType, agentRunner, type AgentRunnerChoice } from "@paperclipai/shared"; import { adaptersApi } from "@/api/adapters"; -import { CodexRunnerSelect } from "./CodexRunnerSelect"; +import { AgentRunnerSelect } from "./AgentRunnerSelect"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -68,12 +68,12 @@ export function ConfigureBuiltInAgentModal({ const { definition } = state; const [adapterType, setAdapterType] = useState( - () => state.agent && agentHarnessType(state.agent.adapterType, state.agent.adapterConfig) === "codex_local" ? "codex_local" : state.agent?.adapterType ?? defaultAdapterType(state), + () => state.agent && ["codex_local", "claude_local"].includes(agentHarnessType(state.agent.adapterType, state.agent.adapterConfig)) ? agentHarnessType(state.agent.adapterType, state.agent.adapterConfig) : state.agent?.adapterType ?? defaultAdapterType(state), ); const [runner, setRunner] = useState(); const environmentId = state.agent?.defaultEnvironmentId ?? null; const adapters = useQuery({ queryKey: queryKeys.adapters.availability(companyId, environmentId), queryFn: () => adaptersApi.list({ companyId, environmentId }), enabled: open }); - const codexAvailability = adapters.data?.find(item => item.type === "codex_local"); + const runnerAvailability = adapters.data?.find(item => item.type === adapterType); const [model, setModel] = useState(() => { const config = state.agent?.adapterConfig; const configuredModel = typeof config === "object" && config !== null @@ -143,7 +143,7 @@ export function ConfigureBuiltInAgentModal({ : { adapterType, adapterConfig }; const result = await builtInAgentsApi.provision(companyId, definition.key, { ...adapterInput, - ...(adapterType === "codex_local" && runner !== undefined ? { runner } : {}), + ...(["codex_local", "claude_local"].includes(adapterType) && runner !== undefined ? { runner } : {}), ...(budgetMonthlyCents !== undefined ? { budgetMonthlyCents } : {}), }); return result; @@ -189,11 +189,11 @@ export function ConfigureBuiltInAgentModal({ /> - {adapterType === "codex_local" &&
+ {["codex_local", "claude_local"].includes(adapterType) &&
Advanced - { void adapters.refetch(); }} - defaultRunner={codexAvailability?.defaultRunner} supportedRunners={codexAvailability?.supportedRunners} + defaultRunner={runnerAvailability?.defaultRunner} supportedRunners={runnerAvailability?.supportedRunners} onChange={setRunner} />
} diff --git a/ui/src/components/NewAgentDialog.test.tsx b/ui/src/components/NewAgentDialog.test.tsx index 1734a9202f4..34ce7eee193 100644 --- a/ui/src/components/NewAgentDialog.test.tsx +++ b/ui/src/components/NewAgentDialog.test.tsx @@ -107,7 +107,7 @@ it("offers one Codex harness without a runner/provider tile", async () => { expect(document.querySelectorAll('input[value="codex_local"]')).toHaveLength(1); expect(document.querySelector("select")).toBeNull(); }); -it.each(["claude", "grok", "opencode"])("preserves explicit experimental %s creation in Advanced", async provider => { +it.each(["grok", "opencode"])("preserves explicit experimental %s creation in Advanced", async provider => { await name(); await act(async () => (document.querySelector("summary") as HTMLElement).click()); await act(async () => document.querySelector('[aria-label="Experimental harness"]')!.click()); diff --git a/ui/src/components/OnboardingWizard.step.test.tsx b/ui/src/components/OnboardingWizard.step.test.tsx index 9a8536aa867..41dc21c80ac 100644 --- a/ui/src/components/OnboardingWizard.step.test.tsx +++ b/ui/src/components/OnboardingWizard.step.test.tsx @@ -178,7 +178,9 @@ describe("OnboardingWizard — which step it lands on", () => { dialogState.onboardingOpen = false; dialogState.onboardingOptions = {}; dialogState.onboardingRouteDismissed = false; - mockAdaptersApi.list.mockResolvedValue([]); + mockAdaptersApi.list.mockResolvedValue(["claude_local", "codex_local"].map(type => ({ + type, loaded: true, disabled: false, defaultRunner: "legacy", supportedRunners: ["legacy"], + }))); mockGoalsApi.list.mockResolvedValue([]); mockAgentsApi.adapterModels.mockResolvedValue([]); // The hire step lists the company's agents first so it can adopt one that diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index b863f315b9b..cc5ecbc1637 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -1,6 +1,8 @@ // @vitest-environment jsdom import { act, StrictMode } from "react"; +import { buildClaudeLocalConfig } from "../../../packages/adapters/claude-local/src/ui/build-config"; +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; import type { LocalAiLoginStatus } from "@paperclipai/shared"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -15,6 +17,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mockAdaptersApi = vi.hoisted(() => ({ list: vi.fn(async (_context?: { companyId: string; environmentId?: string | null }) => [] as import("../api/adapters").AdapterInfo[]) })); vi.mock("../api/adapters", () => ({ adaptersApi: mockAdaptersApi })); +function setupAdapterInfo(input: Pick & Partial): import("../api/adapters").AdapterInfo { + return { label: input.type, source: "builtin", modelsCount: 0, loaded: true, disabled: false, + capabilities: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, + requiresMaterializedRuntimeSkills: false, supportsAcp: true }, ...input }; +} + const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn() })); const localHealth = vi.hoisted(() => ({ get: vi.fn() })); vi.mock("@/api/health", () => ({ healthApi: localHealth })); @@ -127,7 +135,7 @@ const mockAgentsApi = vi.hoisted(() => ({ // can shape the built adapter config (e.g. a configured ANTHROPIC_API_KEY) // without a real adapter package. const mockAdapterBuild = vi.hoisted(() => ({ - buildAdapterConfig: vi.fn(() => ({}) as Record), + buildAdapterConfig: vi.fn((_values?: CreateConfigValues) => ({}) as Record), })); // The Connect path loads environment settings before probing; without these // the probe dies on "Could not load environment settings" and the hire never @@ -368,6 +376,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( mockCompaniesApi.list.mockResolvedValue([]); mockAdapterRegistry.list = []; mockAdapterRegistry.disabled = new Set(); + mockAdaptersApi.list.mockReset().mockResolvedValue(["claude_local", "codex_local"].map(type => setupAdapterInfo({ type, loaded: true, disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "legacy" }))); mockAdapterBuild.buildAdapterConfig.mockReset(); mockAdapterBuild.buildAdapterConfig.mockReturnValue({}); // Default: no stored Claude login for the owner. The route returns a @@ -1911,6 +1920,8 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( beforeEach(() => { mockEnvironmentsApi.list.mockReset(); + mockEnvironmentsApi.probe.mockReset().mockResolvedValue({ ok: true, driver: "sandbox", summary: "Measured image", + details: { platformQualificationRecorded: true, leaseId: "owned-platform-lease" } }); mockEnvironmentsApi.list.mockResolvedValue([SANDBOX_ENVIRONMENT]); mockEnvironmentsApi.capabilities.mockReset(); mockEnvironmentsApi.capabilities.mockResolvedValue( @@ -1963,6 +1974,138 @@ 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 => { + mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!)); + mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config: { + provider: "daytona", 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] }), + setupAdapterInfo({ type: "paperclip_runner", loaded: true, disabled: false }), + ]; + let qualified = false; + mockAdaptersApi.list.mockImplementation(async () => discovery(qualified ? measured : "legacy")); + const handles = await openStep4({ adapterType: "claude_local", runner: "auto" }); + await act(async () => handles.queryClient.setQueryData(["ai-connections", "company-new"], { + currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }], + })); + const calls: string[] = []; + mockAdaptersApi.list.mockImplementation(async context => { + expect(context).toEqual({ companyId: "company-new", environmentId: "env-sandbox-1" }); + calls.push(qualified ? "discovery-after" : "discovery-before"); + return discovery(qualified ? measured : "legacy"); + }); + mockEnvironmentsApi.probe.mockImplementation(async () => { + calls.push("platform"); qualified = true; + return { ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "owned-platform-lease" } }; + }); + mockAgentsApi.testEnvironment.mockImplementation(async () => { + calls.push("adapter"); return { adapterType: measured === "paperclip" ? "paperclip_runner" : "claude_local", status: "pass", checks: [], testedAt: "2026-10-13T00:00:00Z" }; + }); + mockAgentsApi.hire.mockImplementation(async () => { calls.push("hire"); return { agent: { id: "agent-1" }, approval: null }; }); + try { + expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled(); + await pickSource(/Claude/); + expect(calls).toEqual(["discovery-before", "platform", "discovery-after", "adapter", "hire"]); + expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-sandbox-1", "company-new"); + const tested = (mockAgentsApi.testEnvironment.mock.calls[0] as unknown[])[2] as { adapterConfig: Record }; + const saved = (mockAgentsApi.hire.mock.calls[0] as unknown[])[1] as { adapterConfig: Record }; + expect(saved).not.toHaveProperty("runner"); + expect(saved.adapterConfig).toMatchObject(tested.adapterConfig); + if (measured === "paperclip") expect(tested.adapterConfig).not.toHaveProperty("maxTurnsPerRun"); + else expect(tested.adapterConfig.maxTurnsPerRun).toBe(1000); + } finally { await act(async () => handles.root.unmount()); } + }); + + it("stores a new API key once before managed qualification and hires with the same native configuration", async () => { + const key = "example-scoped-api-key"; + mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!)); + mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config: { + provider: "daytona", image: `registry.example.test/runtime@sha256:${"a".repeat(64)}`, + } }]); + let qualified = false; + mockAdaptersApi.list.mockImplementation(async () => [ + setupAdapterInfo({ type: "claude_local", defaultRunner: qualified ? "paperclip" : "legacy", supportedRunners: qualified ? ["paperclip"] : ["legacy"] }), + setupAdapterInfo({ type: "paperclip_runner" }), + ]); + mockEnvironmentsApi.probe.mockImplementation(async () => { + qualified = true; + return { ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "owned-platform-lease" } }; + }); + const handles = await openStep4({ adapterType: "claude_local", runner: "auto", credentialMode: "api" }); + try { + await pickSource(/Claude.*API/); + const field = document.body.querySelector('input[type="password"]') as HTMLInputElement; + await act(async () => setControlledValue(field, key)); + await flushReact(); + await pressArcPrimary(); + expect(managedApi.create).toHaveBeenCalledExactlyOnceWith("company-new", expect.objectContaining({ provider: "anthropic", method: "api_key", apiKey: key })); + expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-sandbox-1", "company-new"); + expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); + expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); + const tested = (mockAgentsApi.testEnvironment.mock.calls[0] as unknown[])[2] as { adapterConfig: object }; + const saved = (mockAgentsApi.hire.mock.calls[0] as unknown[])[1] as { adapterConfig: object; runtimeConfig: object }; + expect(saved.adapterConfig).toMatchObject(tested.adapterConfig); + expect(saved.adapterConfig).not.toHaveProperty("maxTurnsPerRun"); + expect(saved.runtimeConfig).toMatchObject({ aiConnection: { provider: "anthropic", method: "api_key", mode: "responsible_user" } }); + expect(JSON.stringify(saved)).not.toContain(key); + } finally { await act(async () => handles.root.unmount()); } + }); + + it.each(["back", "environment-config"])("abandons held managed Claude qualification after %s without another probe", async change => { + const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`; + mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config: { provider: "daytona", image } }]); + mockAdaptersApi.list.mockResolvedValue([ + setupAdapterInfo({ type: "claude_local", defaultRunner: "legacy", supportedRunners: ["legacy"] }), setupAdapterInfo({ type: "paperclip_runner" }), + ]); + const handles = await openStep4({ adapterType: "claude_local", runner: "auto" }); + await act(async () => handles.queryClient.setQueryData(["ai-connections", "company-new"], { + currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }], + })); + let finish!: (value: import("@paperclipai/shared").EnvironmentProbeResult) => void; + mockEnvironmentsApi.probe.mockReturnValue(new Promise(resolve => { finish = resolve; })); + try { + await pickSource(/Claude/); + expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1); + await act(async () => document.querySelector('[data-testid="onboarding-wizard"]')!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }))); + await flushReact(); + expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1); + if (change === "back") await act(async () => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim() === "Back")!.click()); + else { + await act(async () => handles.queryClient.setQueryData(queryKeys.environments.list("company-new"), [{ ...SANDBOX_ENVIRONMENT, config: { + provider: "daytona", image: `registry.example.test/runtime@sha256:${"b".repeat(64)}`, + } }])); + await flushReact(); + } + await act(async () => finish({ ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "owned-lease" } })); + for (let i = 0; i < 8; i++) await flushReact(); + expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + } finally { await act(async () => handles.root.unmount()); } + }); + + it("stops managed Claude onboarding at the existing platform-probe permission boundary", async () => { + mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config: { + provider: "daytona", image: `registry.example.test/runtime@sha256:${"a".repeat(64)}`, + } }]); + mockAdaptersApi.list.mockResolvedValue([ + setupAdapterInfo({ type: "claude_local", defaultRunner: "legacy", supportedRunners: ["legacy"] }), setupAdapterInfo({ type: "paperclip_runner" }), + ]); + const handles = await openStep4({ adapterType: "claude_local", runner: "auto" }); + await act(async () => handles.queryClient.setQueryData(["ai-connections", "company-new"], { + currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }], + })); + mockEnvironmentsApi.probe.mockRejectedValue(new ApiError("Instance admin access required", 403, null)); + try { + await pickSource(/Claude/); + expect(document.body.textContent).toContain("Instance admin access required"); + expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + } finally { await act(async () => handles.root.unmount()); } + }); + it.each([ ["claude_local", "anthropic", /Claude/, "claude-session-1", "claude-setup-token-status"], ["codex_local", "openai", /OpenAI/, "codex-session-1", "adapter-login-status"], @@ -2017,7 +2160,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( // then never read, so the row went on rendering whatever the registry // supplied: "Claude Code" and "Codex" in the app, and the bare type here, // since this suite's registry mock returns `label: type`. - mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }]; + mockAdapterRegistry.list = [setupAdapterInfo({ type: "claude_local" }), { type: "codex_local" }]; const { root } = await openStep4({ adapterType: "claude_local" }); const labels = [...document.body.querySelectorAll("button[aria-checked]")].map( @@ -2086,7 +2229,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( // in never shows it. Nothing is highlighted, the canvas is shut, and with // the gate on `sourcePicked` the CTA was live: one press hires against an // adapter the customer has not seen on this screen. - mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "opencode_local" }]; + mockAdapterRegistry.list = [setupAdapterInfo({ type: "claude_local" }), { type: "opencode_local" }]; const { root } = await openStep4({ adapterType: "opencode_local" }); const tiles = [...document.body.querySelectorAll("button[aria-checked]")]; @@ -2118,7 +2261,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( // tiles, neither chosen. A keystroke that gets through hires claude_local // — a real, offerable adapter that nobody on this screen selected, which // is what makes the bypass worth a test rather than a comment. - mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }]; + mockAdapterRegistry.list = [setupAdapterInfo({ type: "claude_local" }), { type: "codex_local" }]; const { root } = await openStep4({ adapterType: "some_retired_adapter" }); const tiles = [...document.body.querySelectorAll("button[aria-checked]")]; @@ -2996,7 +3139,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( }); it("reads the signal again after an adapter change", async () => { - mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }]; + mockAdapterRegistry.list = [setupAdapterInfo({ type: "claude_local" }), { type: "codex_local" }]; mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); const { root } = await openStep4({ adapterType: "claude_local" }); @@ -3231,6 +3374,166 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( expect(document.body.textContent).not.toContain("Start sign-in again"); } + it.each(["paperclip", "legacy"] as const)("waits for Claude runner discovery before saved subscription verification (%s)", async defaultRunner => { + let finish!: (value: import("../api/adapters").AdapterInfo[]) => void; + mockAdaptersApi.list.mockReturnValue(new Promise(resolve => { finish = resolve; })); + mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!)); + const { root, queryClient } = await openStep4({ adapterType: "claude_local" }); + try { + await act(async () => queryClient.setQueryData(["ai-connections", "company-new"], { + currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }], + })); + await pickSource(/Claude/); + await settle(); + expect(button("Connect").disabled).toBe(true); + await act(async () => button("Connect").dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }))); + await settle(); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + await act(async () => finish([setupAdapterInfo({ type: "claude_local", defaultRunner, supportedRunners: ["paperclip", "legacy"] })])); + await settle(); + expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); + expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); + const tested = mockAgentsApi.testEnvironment.mock.calls[0] as unknown as [string, string, { adapterConfig: Record }]; + const hired = mockAgentsApi.hire.mock.calls[0] as unknown as [string, { adapterConfig: Record }]; + expect(hired[1].adapterConfig).toEqual(tested[2].adapterConfig); + if (defaultRunner === "paperclip") expect(hired[1].adapterConfig).not.toHaveProperty("maxTurnsPerRun"); + else expect(hired[1].adapterConfig.maxTurnsPerRun).toBe(1000); + expect(hired[1].adapterConfig).not.toHaveProperty("resolvedRunner"); + } finally { await act(async () => root.unmount()); } + }); + + it.each(["environments", "settings", "policy"] as const)("does not use warm Claude discovery before %s resolves", async pending => { + let finish!: (value: any) => void; + const delayed = new Promise(resolve => { finish = resolve; }); + if (pending === "environments") mockEnvironmentsApi.list.mockReturnValue(delayed); + else if (pending === "settings") mockInstanceSettingsApi.get.mockReturnValue(delayed); + else mockInstanceSettingsApi.getExperimental.mockReturnValue(delayed); + mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!)); + const { root, queryClient } = await openStep4({ adapterType: "claude_local" }); + try { + await act(async () => { + if (pending === "environments") queryClient.setQueryData(queryKeys.environments.list("company-new"), [LOCAL_ENVIRONMENT]); + else if (pending === "settings") queryClient.setQueryData(queryKeys.instance.settings, { defaultEnvironmentId: null }); + else queryClient.setQueryData(queryKeys.instance.experimentalSettings, { enableManagedSandboxOnly: false }); + for (const environmentId of [null, LOCAL_ENVIRONMENT.id]) queryClient.setQueryData(queryKeys.adapters.availability("company-new", environmentId), [{ type: "claude_local", defaultRunner: "paperclip" }]); + queryClient.setQueryData(["ai-connections", "company-new"], { currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }] }); + }); + await pickSource(/Claude/); + await settle(); + expect(mockAdapterBuild.buildAdapterConfig).not.toHaveBeenCalled(); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + await act(async () => finish(pending === "environments" ? [LOCAL_ENVIRONMENT] : pending === "settings" ? { defaultEnvironmentId: null } : { enableManagedSandboxOnly: false })); + await settle(); + expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); + const hired = mockAgentsApi.hire.mock.calls[0] as unknown as [string, { adapterConfig: Record }]; + expect(hired[1].adapterConfig.maxTurnsPerRun).toBe(1000); + } finally { await act(async () => root.unmount()); } + }); + + it.each(["environments", "settings", "policy"] as const)("reports failed Claude %s resolution and retries without early verification", async failed => { + if (failed === "environments") mockEnvironmentsApi.list.mockRejectedValue(new Error("Unavailable")); + else if (failed === "settings") mockInstanceSettingsApi.get.mockRejectedValue(new Error("Unavailable")); + else mockInstanceSettingsApi.getExperimental.mockRejectedValue(new Error("Unavailable")); + const { root, queryClient } = await openStep4({ adapterType: "claude_local" }); + try { + await act(async () => queryClient.setQueryData(["ai-connections", "company-new"], { currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }] })); + await pickSource(/Claude/); + await settle(); + expect(document.body.textContent).toContain("Could not load execution environment settings"); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]); + mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null }); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false }); + await click("Retry setup"); + expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); + expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); + } finally { await act(async () => root.unmount()); } + }); + + it("does not hire from obsolete Claude discovery after the target changes during verification", async () => { + mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!)); + mockAdaptersApi.list.mockImplementation(async context => [setupAdapterInfo({ type: "claude_local", defaultRunner: context?.environmentId === "new-local-default" ? "paperclip" : "legacy" })]); + const finishes: Array<(value: typeof passed) => void> = []; + mockAgentsApi.testEnvironment.mockImplementation(() => new Promise(resolve => finishes.push(resolve))); + const { root, queryClient } = await openStep4({ adapterType: "claude_local" }); + try { + await pickSource(/Claude/); + await detected(); + expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); + await act(async () => queryClient.setQueryData(queryKeys.environments.list("company-new"), [{ ...LOCAL_ENVIRONMENT, id: "new-local-default" }])); + await settle(); + expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); + await act(async () => finishes[0](passed)); + await settle(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); + expect(document.body.textContent).toContain("Configuration changed during setup"); + await click("Connect"); + expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2); + await act(async () => finishes[1](passed)); + await settle(); + expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); + const tested = mockAgentsApi.testEnvironment.mock.calls as unknown as Array<[string, string, { environmentId: string; adapterConfig: Record }]>; + expect(tested[0][2].adapterConfig.maxTurnsPerRun).toBe(1000); + expect(tested[1][2].environmentId).toBe("new-local-default"); + expect(tested[1][2].adapterConfig).not.toHaveProperty("maxTurnsPerRun"); + const hired = mockAgentsApi.hire.mock.calls[0] as unknown as [string, { adapterConfig: Record }]; + expect(hired[1].adapterConfig).toEqual(tested[1][2].adapterConfig); + } finally { await act(async () => root.unmount()); } + }); + + it("unlocks Claude setup when automatic runner availability changes during verification", async () => { + mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!)); + mockAdaptersApi.list.mockResolvedValue([setupAdapterInfo({ type: "claude_local", defaultRunner: "paperclip" })]); + const finishes: Array<(value: typeof passed) => void> = []; + mockAgentsApi.testEnvironment.mockImplementation(() => new Promise(resolve => finishes.push(resolve))); + const { root, queryClient } = await openStep4({ adapterType: "claude_local" }); + try { + await pickSource(/Claude/); + await detected(); + expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); + expectTesting(); + mockAdaptersApi.list.mockResolvedValue([setupAdapterInfo({ type: "claude_local", defaultRunner: "legacy" })]); + await act(async () => queryClient.setQueryData(queryKeys.adapters.availability("company-new", LOCAL_ENVIRONMENT.id), [setupAdapterInfo({ type: "claude_local", defaultRunner: "legacy" })])); + await settle(); + expect(document.body.textContent).toContain("Configuration changed during setup"); + expect(button("Connect").disabled).toBe(false); + expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); + await act(async () => finishes[0](passed)); + await settle(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + await click("Connect"); + expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2); + await act(async () => finishes[1](passed)); + await settle(); + expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); + const hired = mockAgentsApi.hire.mock.calls[0] as unknown as [string, { adapterConfig: Record }]; + expect(hired[1].adapterConfig.maxTurnsPerRun).toBe(1000); + } finally { await act(async () => root.unmount()); } + }); + + it("fails closed on Claude discovery rejection and retries availability before verification", async () => { + mockAdaptersApi.list.mockRejectedValue(new Error("Discovery unavailable")); + const { root, queryClient } = await openStep4({ adapterType: "claude_local" }); + try { + await act(async () => queryClient.setQueryData(["ai-connections", "company-new"], { + currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }], + })); + await pickSource(/Claude/); + await settle(); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain("Could not load runner availability"); + mockAdaptersApi.list.mockResolvedValue([setupAdapterInfo({ type: "claude_local", defaultRunner: "paperclip" })]); + await click("Retry setup"); + expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); + expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); + } finally { await act(async () => root.unmount()); } + }); + it.each(providers)("automatically verifies detected %s once through connection refreshes and advances", async (adapterType, provider, label) => { let finish!: (result: typeof passed) => void; mockAgentsApi.testEnvironment.mockReturnValue(new Promise(resolve => { finish = resolve; })); @@ -3324,7 +3627,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( await detected(); expectTesting(); await act(async () => queryClient.setQueryData(queryKeys.environments.list("company-new"), [ - { ...LOCAL_ENVIRONMENT, id: "new-local-default" }, + { ...LOCAL_ENVIRONMENT, name: "Refreshed local environment" }, ])); await settle(); expectTesting(); diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index fd7dcb4ba0d..2c66724efd7 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -1,7 +1,8 @@ -import type { AgentRunnerChoice } from "@paperclipai/shared"; +import type { AgentRunner, AgentRunnerChoice } from "@paperclipai/shared"; +import { needsClaudeSetupQualification, qualifyClaudeSetup } from "../lib/qualify-claude-setup"; import { agentHarnessType } from "@paperclipai/shared"; -import { adaptersApi } from "../api/adapters"; -import { CodexRunnerSelect } from "./CodexRunnerSelect"; +import { adaptersApi, type AdapterInfo } from "../api/adapters"; +import { AgentRunnerSelect } from "./AgentRunnerSelect"; import { healthApi } from "@/api/health"; import { LocalProviderLoginInstructions } from "./AdapterLoginChrome"; import { useLocalAiLogin } from "./ai-connections/useLocalAiLogin"; @@ -599,7 +600,7 @@ function OnboardingWizardInner({ restoreOnboardingAdapterType(saved?.adapterType, saved?.runnerProvider), ); const [runner, setRunner] = useState(() => saved?.runner === "legacy" || saved?.runner === "paperclip" - ? saved.runner : saved?.adapterType === "paperclip_runner" && (saved?.runnerProvider === undefined || saved?.runnerProvider === "codex") ? "paperclip" : "auto"); + ? saved.runner : saved?.adapterType === "paperclip_runner" && (saved?.runnerProvider === undefined || ["codex", "claude", "acpx"].includes(String(saved?.runnerProvider))) ? "paperclip" : "auto"); /** * Whether a model source has been chosen, as opposed to which one * `adapterType` happens to hold. @@ -626,7 +627,7 @@ function OnboardingWizardInner({ const savedNativeRunnerDraft = saved?.adapterType === "paperclip_runner"; const [cwd, setCwd] = useState((saved?.cwd as string) ?? ""); const [model, setModel] = useState( - savedNativeRunnerDraft && saved?.runnerProvider !== undefined && saved.runnerProvider !== "codex" ? "" : (saved?.model as string) ?? "", + savedNativeRunnerDraft && saved?.runnerProvider !== undefined && !["codex", "claude", "acpx"].includes(String(saved.runnerProvider)) ? "" : (saved?.model as string) ?? "", ); const [command, setCommand] = useState( savedNativeRunnerDraft ? "" : (saved?.command as string) ?? "", @@ -949,14 +950,14 @@ function OnboardingWizardInner({ // managed-sandbox-only redirect (see AgentConfigForm.tsx:618-640). A render // must not throw, so a resolver error yields no login environment rather // than an error boundary. - const { data: loginEnvironmentList = [] } = useQuery({ + const { data: loginEnvironmentList = [], isSuccess: loginEnvironmentsReady, isFetching: loginEnvironmentsFetching, isError: loginEnvironmentsFailed, refetch: refetchLoginEnvironments } = useQuery({ queryKey: createdCompanyId ? queryKeys.environments.list(createdCompanyId) : ["environments", "none"], queryFn: () => environmentsApi.list(createdCompanyId!), enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4, }); - const { data: instanceSettingsForLogin } = useQuery({ + const { data: instanceSettingsForLogin, isSuccess: loginSettingsReady, isFetching: loginSettingsFetching, isError: loginSettingsFailed, refetch: refetchLoginSettings } = useQuery({ queryKey: queryKeys.instance.settings, queryFn: () => instanceSettingsApi.get(), enabled: effectiveOnboardingOpen && step === 4, @@ -964,7 +965,7 @@ function OnboardingWizardInner({ // Wanted across the whole arc, not just the connect step. The progress strip // reads it too — see `enteredFromCloud` — and a value fetched only on step 4 // would let the strip change length as the customer walked through it. - const { data: experimentalSettingsForLogin } = useQuery({ + const { data: experimentalSettingsForLogin, isSuccess: loginPolicyReady, isFetching: loginPolicyFetching, isError: loginPolicyFailed, refetch: refetchLoginPolicy } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), enabled: effectiveOnboardingOpen && step >= 3 && step <= 5, @@ -987,17 +988,62 @@ function OnboardingWizardInner({ loginEnvironmentList, experimentalSettingsForLogin?.enableManagedSandboxOnly, ]); + const loginEnvironmentSettingsReady = loginEnvironmentsReady && loginSettingsReady && loginPolicyReady + && !loginEnvironmentsFetching && !loginSettingsFetching && !loginPolicyFetching; const runnerDiscovery = useQuery({ queryKey: queryKeys.adapters.availability(createdCompanyId, resolvedLoginEnvironmentId), queryFn: () => adaptersApi.list({ companyId: createdCompanyId!, environmentId: resolvedLoginEnvironmentId }), - enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4, + enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4 + && loginEnvironmentSettingsReady, }); + const needsRunnerDiscovery = ["codex_local", "claude_local"].includes(adapterType) && runner === "auto"; + const resolvedRunner = runner === "auto" + ? runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner + : runner; + const runnerDiscoveryReady = !needsRunnerDiscovery + || (loginEnvironmentSettingsReady + && runnerDiscovery.isSuccess && !runnerDiscovery.isFetching && resolvedRunner !== undefined); + const runnerDiscoveryError = needsRunnerDiscovery && (loginEnvironmentsFailed || loginSettingsFailed || loginPolicyFailed) + ? "Could not load execution environment settings. Retry setup." + : needsRunnerDiscovery && runnerDiscovery.isError + ? "Could not load runner availability. Retry setup." + : needsRunnerDiscovery && runnerDiscovery.isSuccess && resolvedRunner === undefined + ? "Runner availability is missing for this harness. Retry setup." + : null; + async function retryRunnerAvailability() { + if (!loginEnvironmentSettingsReady) { + await Promise.all([refetchLoginEnvironments(), refetchLoginSettings(), refetchLoginPolicy()]); + } else { + await runnerDiscovery.refetch(); + } + } + const visibleAdapterEnvError = adapterEnvError ?? runnerDiscoveryError; + const runnerSelectionKey = JSON.stringify([createdCompanyId, resolvedLoginEnvironmentId, adapterType, runner, adapterType === "codex_local" && runner === "auto" ? undefined : resolvedRunner]); + const runnerSelectionRef = useRef(runnerSelectionKey); + runnerSelectionRef.current = runnerSelectionKey; const resolvedLoginEnvironment = useMemo( () => loginEnvironmentList.find((environment) => environment.id === resolvedLoginEnvironmentId) ?? null, [loginEnvironmentList, resolvedLoginEnvironmentId], ); + const claudeSetupSelectionKey = JSON.stringify([createdCompanyId, adapterType, runner, resolvedRunner, model, command, + args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.binding, savedSubscription?.binding, + resolvedLoginEnvironmentId, resolvedLoginEnvironment?.config]); + const claudeSetupSelectionRef = useRef(claudeSetupSelectionKey); + claudeSetupSelectionRef.current = claudeSetupSelectionKey; + const activeClaudeQualification = useRef<{ attempt: number; selection: string } | null>(null); + useEffect(() => { + const active = activeClaudeQualification.current; + if (!active || active.selection === claudeSetupSelectionKey) return; + activeClaudeQualification.current = null; + connectAttemptRef.current++; + hiringAgentRef.current = null; + autoConnectStartedRef.current = true; + setLoading(false); + setAdapterEnvLoading(false); + setError("Configuration changed during setup. Connect again to test the current settings."); + }, [claudeSetupSelectionKey]); const adapterSetupSelectionRef = useRef(""); const adapterEnvErrorSelectionRef = useRef(null); adapterSetupSelectionRef.current = adapterType === "codex_local" && runner === "auto" && resolvedLoginEnvironment?.driver === "sandbox" @@ -1039,9 +1085,6 @@ function OnboardingWizardInner({ }, effectiveOnboardingOpen && step === 4 && canUseLocalLogin && credentialMode !== "api" && Boolean(managedProvider) && !savedSubscription && !savedKeys.storedLogin.data && !managedBindingForStep(), ); - // A result from a previous selection must not hire or advance this wizard. - // Environment query updates are not user navigation: the test resolves its - // own environment, and those updates must not interrupt the pending attempt. useEffect(() => { autoConnectStartedRef.current = false; hiringAgentRef.current = null; @@ -1190,7 +1233,7 @@ function OnboardingWizardInner({ * Anything that gates this step belongs in here, so the next one is added * once rather than twice. */ - const connectStepReady = sourceSelected && !adapterEnvLoading && !savedKeys.loading; + const connectStepReady = sourceSelected && !adapterEnvLoading && !savedKeys.loading && runnerDiscoveryReady; /** * Whether this step has a sign-in to do before it can hire. @@ -1333,7 +1376,7 @@ function OnboardingWizardInner({ // Not before the login is stored. "Connecting" starts at the paste now, // ahead of the server confirming anything, so a hire from here would go // out against a source with no credential to run on. - if (!connectCredentialStored) return; + if (!connectCredentialStored || !runnerDiscoveryReady) return; // No success state: the step advances. The hold is so "Connecting" is // legible as a state rather than a flicker on the way out — a step that // left the instant a paste landed would read as the paste having gone @@ -1372,7 +1415,7 @@ function OnboardingWizardInner({ return () => clearTimeout(t); } return; - }, [step, connectPhase, credentialMode, connectStepNeedsLogin, connectCredentialStored]); + }, [step, connectPhase, credentialMode, connectStepNeedsLogin, connectCredentialStored, runnerDiscoveryReady]); /** * The button's four faces, and which of them can be pressed. @@ -1557,7 +1600,7 @@ function OnboardingWizardInner({ setAdapterEnvResult(null); adapterEnvResultAppliedStoredLoginRef.current = false; if (adapterEnvErrorSelectionRef.current !== adapterSetupSelectionRef.current) setAdapterEnvError(null); - }, [step, adapterType, runner, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id, createdCompanyId, resolvedLoginEnvironmentId, runner === "auto" ? runnerDiscovery.data?.find(item => item.type === "codex_local")?.defaultRunner : undefined]); + }, [step, adapterType, runner, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id, createdCompanyId, resolvedLoginEnvironmentId, runner === "auto" ? runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner : undefined]); /** * Leaving the step puts the row back to a question. @@ -1833,12 +1876,13 @@ function OnboardingWizardInner({ } } - function buildAdapterConfig(bindApiKey = false): Record { + function buildAdapterConfig(bindApiKey = false, setupRunner?: AgentRunner): Record { const adapter = getUIAdapter(adapterType); const config = adapter.buildAdapterConfig({ ...defaultCreateValues, adapterType, runner, + resolvedRunner: setupRunner ?? resolvedRunner, model: adapterType === "gemini_local" ? model || DEFAULT_GEMINI_LOCAL_MODEL @@ -1904,7 +1948,9 @@ function OnboardingWizardInner({ adapterConfigOverride?: Record, appliedStoredClaudeLoginBinding = false, isCurrent = () => true, + onQualifiedConfig?: (config: Record, adapters: AdapterInfo[], environmentId: string | null) => void, ): Promise { + if (!runnerDiscoveryReady) return null; if (!createdCompanyId) { setAdapterEnvError( "Create or select an organization before testing adapter environment." @@ -2005,12 +2051,29 @@ function OnboardingWizardInner({ } } } + if (!testIsCurrent()) return null; + const selection = { adapterType, runner, savedAgentId: createdAgentId, + environment: environmentList.find(environment => environment.id === environmentId) }; + const qualified = await qualifyClaudeSetup({ ...selection, + readAdapters: () => adaptersApi.list({ companyId: createdCompanyId, environmentId }), + probeEnvironment: () => environmentsApi.probe(environmentId!, createdCompanyId), + isCurrent: testIsCurrent, + }); + if (!testIsCurrent() || (needsClaudeSetupQualification(selection) && !qualified)) return null; + let testedConfig = adapterConfigOverride ?? buildAdapterConfig(); + if (qualified) { + testedConfig = buildAdapterConfig(true, qualified.runner); + if (appliedStoredClaudeLoginBinding) testedConfig.env = { + ...(isEnvRecord(testedConfig.env) ? testedConfig.env : {}), ...buildFixedClaudeOAuthBinding(), + }; + onQualifiedConfig?.(testedConfig, qualified.adapters, environmentId); + } const result = await agentsApi.testEnvironment( createdCompanyId, adapterType, { - adapterConfig: adapterConfigOverride ?? buildAdapterConfig(), - ...(adapterType === "codex_local" && runner !== "auto" ? { runner } : {}), + adapterConfig: testedConfig, + ...(["codex_local", "claude_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}), ...(managedBindingForStep() ? { aiConnection: managedBindingForStep() } : {}), environmentId, } @@ -2088,13 +2151,19 @@ function OnboardingWizardInner({ setStep(5); return; } - if (hiringAgentRef.current !== null) return; + if (!runnerDiscoveryReady || hiringAgentRef.current !== null) return; const attempt = ++connectAttemptRef.current; - const isCurrent = () => connectAttemptRef.current === attempt && stillTheSameCompany(createdCompanyId); + const guardClaudeSetup = adapterType === "claude_local" && runner === "auto"; + const setupSelection = claudeSetupSelectionRef.current; + activeClaudeQualification.current = guardClaudeSetup ? { attempt, selection: setupSelection } : null; + const isCurrent = () => connectAttemptRef.current === attempt && stillTheSameCompany(createdCompanyId) + && runnerSelectionRef.current === runnerSelectionKey + && (!guardClaudeSetup || claudeSetupSelectionRef.current === setupSelection); hiringAgentRef.current = attempt; autoConnectStartedRef.current = true; setLoading(true); setError(null); + let qualifiedDiscovery: { adapters: AdapterInfo[]; environmentId: string | null } | undefined; try { if (adapterType === "opencode_local") { const selectedModelId = model.trim(); @@ -2175,7 +2244,7 @@ function OnboardingWizardInner({ setClaudeOAuthStatus(storedClaudeLogin); } const shouldApplyStoredClaudeLogin = storedClaudeLogin !== null; - const hireAdapterConfig = shouldApplyStoredClaudeLogin + let hireAdapterConfig = shouldApplyStoredClaudeLogin ? { ...baseAdapterConfig, env: { @@ -2194,14 +2263,15 @@ function OnboardingWizardInner({ // stored login, would lock out a customer who has since fixed the // problem or signed in. const cachedUsable = - adapterEnvResult && + !needsClaudeSetupQualification({ adapterType, runner, savedAgentId: createdAgentId, environment: resolvedLoginEnvironment }) && adapterEnvResult && adapterEnvResultAppliedStoredLoginRef.current === shouldApplyStoredClaudeLogin && !blocksAgentCreate(adapterEnvResult) ? adapterEnvResult : null; const result = cachedUsable ?? - (await runAdapterEnvironmentTest(hireAdapterConfig, shouldApplyStoredClaudeLogin, isCurrent)); + (await runAdapterEnvironmentTest(hireAdapterConfig, shouldApplyStoredClaudeLogin, isCurrent, + (config, adapters, environmentId) => { hireAdapterConfig = config; qualifiedDiscovery = { adapters, environmentId }; })); if (!result || !isCurrent()) return; // Block the hire on a failed environment test. Also block it on a // pass or a warn result that reports missing authentication — the @@ -2261,7 +2331,7 @@ function OnboardingWizardInner({ role: agentRole, adapterType, adapterConfig: hireAdapterConfig, - ...(adapterType === "codex_local" && runner !== "auto" ? { runner } : {}), + ...(["codex_local", "claude_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}), ...(shouldApplyStoredClaudeLogin ? { applyStoredClaudeLogin: true } : {}), // The server owns what the first agent is told now: this marker seeds // the chief-of-staff persona over the agent's entry instruction file. @@ -2297,7 +2367,13 @@ function OnboardingWizardInner({ setError(err instanceof Error ? err.message : "Failed to create agent"); } finally { if (hiringAgentRef.current === attempt) hiringAgentRef.current = null; - if (isCurrent()) setLoading(false); + if (activeClaudeQualification.current?.attempt === attempt) activeClaudeQualification.current = null; + if (isCurrent()) { + if (qualifiedDiscovery) queryClient.setQueryData( + queryKeys.adapters.availability(createdCompanyId, qualifiedDiscovery.environmentId), qualifiedDiscovery.adapters, + ); + setLoading(false); + } // Authentication is already saved. A failed probe or hire must offer a // retry with that account, rather than keep the completed login busy. if (connectCredentialStored && isCurrent()) { @@ -2957,12 +3033,12 @@ function OnboardingWizardInner({ ) : null} - {sourceSelected && adapterType === "codex_local" &&
+ {sourceSelected && ["codex_local", "claude_local"].includes(adapterType) &&
Advanced - { void runnerDiscovery.refetch(); }} - defaultRunner={runnerDiscovery.data?.find(item => item.type === "codex_local")?.defaultRunner} - supportedRunners={runnerDiscovery.data?.find(item => item.type === "codex_local")?.supportedRunners} + defaultRunner={runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner} + supportedRunners={runnerDiscovery.data?.find(item => item.type === adapterType)?.supportedRunners} onChange={setRunner} />
} @@ -2987,14 +3063,20 @@ function OnboardingWizardInner({ the hire, because `handleGiveHeartbeat` clears it in its `finally` after the early return — so a genuine block still shows its checks, which is the whole reason this is here. */} - {isLocalAdapter && !loading && (adapterEnvError || (adapterEnvResult && adapterEnvResult.status !== "pass")) && ( + {isLocalAdapter && !loading && (visibleAdapterEnvError || (adapterEnvResult && adapterEnvResult.status !== "pass")) && (
- {adapterEnvError && ( + {visibleAdapterEnvError && (
- {adapterEnvError} + {visibleAdapterEnvError}
)} + {runnerDiscoveryError && ( + + )} + {adapterEnvResult && adapterEnvResult.status === "pass" ? (
diff --git a/ui/src/components/new-agent/AgentBasicsDialog.tsx b/ui/src/components/new-agent/AgentBasicsDialog.tsx index b84b2390958..414e4780ad5 100644 --- a/ui/src/components/new-agent/AgentBasicsDialog.tsx +++ b/ui/src/components/new-agent/AgentBasicsDialog.tsx @@ -225,8 +225,6 @@ export function AgentBasicsDialog({ placeholder="Choose a native harness…" onValueChange={provider => { setRunnerProvider(provider); setAdapterType("paperclip_runner"); }} options={[ - { value: "codex", label: "Codex (Paperclip Runner)" }, - { value: "claude", label: "Claude Code (Paperclip Runner)" }, { value: "grok", label: "Grok Build (Paperclip Runner)" }, { value: "opencode", label: "OpenCode (Paperclip Runner)" }, ]} /> diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx index 388602d59ad..1add2ff0a4d 100644 --- a/ui/src/components/new-agent/NewAgentSetup.tsx +++ b/ui/src/components/new-agent/NewAgentSetup.tsx @@ -4,8 +4,8 @@ import { AgentCharacter } from "../AgentCharacter"; import { useAgentAppearanceDraft } from "../../hooks/useAgentAppearanceDraft"; import { AiConnectionField, aiProviderForAdapter } from "../ai-connections/AiConnectionField"; import type { AiConnectionBinding } from "@paperclipai/shared"; -import type { AgentRunnerChoice } from "@paperclipai/shared"; -import { CodexRunnerSelect } from "../CodexRunnerSelect"; +import type { AgentRunner, AgentRunnerChoice } from "@paperclipai/shared"; +import { AgentRunnerSelect } from "../AgentRunnerSelect"; import { SelectPopover } from "../ui/select"; import { DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local"; import { @@ -15,6 +15,7 @@ import { setupProviderKeys, } from "@/lib/agent-setup-fields"; import { testAgentSetup } from "@/lib/test-agent-setup"; +import { needsClaudeSetupQualification, qualifyClaudeSetup } from "@/lib/qualify-claude-setup"; import { useCloudInstance } from "@/hooks/useCloudInstance"; import { isNewAgentAdapterAllowed } from "@/lib/new-agent-adapters"; import { useEffect, useRef, useState } from "react"; @@ -93,9 +94,9 @@ export function NewAgentSetup() { key={`${selectedCompanyId}:${params.get("name")}:${params.get("adapterType")}:${params.get("runnerProvider")}:${params.get("runner")}`} companyId={selectedCompanyId} name={params.get("name") ?? ""} - adapterType={params.get("adapterType") === "paperclip_runner" && (params.get("runnerProvider") ?? "codex") === "codex" ? "codex_local" : params.get("adapterType") ?? ""} - initialRunner={params.get("runner") ?? (params.get("adapterType") === "paperclip_runner" && (params.get("runnerProvider") ?? "codex") === "codex" ? "paperclip" : "auto")} - preserveNativeCodex={params.get("adapterType") === "paperclip_runner" && (params.get("runnerProvider") ?? "codex") === "codex"} + adapterType={params.get("adapterType") === "paperclip_runner" && ["codex", "claude"].includes(params.get("runnerProvider") ?? "codex") ? params.get("runnerProvider") === "claude" ? "claude_local" : "codex_local" : params.get("adapterType") ?? ""} + initialRunner={params.get("runner") ?? (params.get("adapterType") === "paperclip_runner" && ["codex", "claude"].includes(params.get("runnerProvider") ?? "codex") ? "paperclip" : "auto")} + preserveNativeHarness={params.get("adapterType") === "paperclip_runner" && ["codex", "claude"].includes(params.get("runnerProvider") ?? "codex")} runnerProvider={params.get("runnerProvider") ?? "codex"} createdAgentId={params.get("createdAgentId")} /> @@ -108,7 +109,7 @@ function Setup({ adapterType, runnerProvider, initialRunner, - preserveNativeCodex, + preserveNativeHarness, createdAgentId, }: { companyId: string; @@ -116,7 +117,7 @@ function Setup({ adapterType: string; runnerProvider: string; initialRunner: string; - preserveNativeCodex: boolean; + preserveNativeHarness: boolean; createdAgentId: string | null; }) { const navigate = useNavigate(); @@ -124,7 +125,7 @@ function Setup({ const { openNewIssue } = useDialogActions(); const appearanceDraft = useAgentAppearanceDraft(`${companyId}:new-agent`); const [runner, setRunner] = useState(initialRunner === "legacy" || initialRunner === "paperclip" ? initialRunner : "auto"); - const requestAdapterType = preserveNativeCodex && runner === "paperclip" ? "paperclip_runner" : adapterType; + const requestAdapterType = preserveNativeHarness && runner === "paperclip" ? "paperclip_runner" : adapterType; const isRunner = adapterType === "paperclip_runner"; const isDot = isRunner && runnerProvider === "openai_dot"; const brandType = isDot ? "openai_dot" : isRunner @@ -192,6 +193,7 @@ function Setup({ ); const [error, setError] = useState(null); const generation = useRef(0); + const qualificationTransition = useRef<{ run: number; companyId: string; environmentId: string | null; runner: AgentRunner } | null>(null); const savingRef = useRef(false); useEffect( () => () => { @@ -200,6 +202,7 @@ function Setup({ [], ); const resetTest = () => { + qualificationTransition.current = null; generation.current++; setResult(null); setTestState("idle"); @@ -286,12 +289,19 @@ function Setup({ : "Could not resolve the environment."; } const environment = envs.data?.find((env) => env.id === environmentId); + const setupEnvironmentKey = JSON.stringify([companyId, environmentId, environment?.config]); + const setupEnvironmentRef = useRef(""); + setupEnvironmentRef.current = setupEnvironmentKey; + useEffect(() => { resetTest(); }, [setupEnvironmentKey]); const adapters = useQuery({ queryKey: queryKeys.adapters.availability(companyId, environmentId), queryFn: () => adaptersApi.list({ companyId, environmentId }), }); - const defaultRunner = adapters.data?.find(adapter => adapter.type === "codex_local")?.defaultRunner; + const defaultRunner = adapters.data?.find(adapter => adapter.type === adapterType)?.defaultRunner; useEffect(() => { + const transition = qualificationTransition.current; + if (transition?.run === generation.current && transition.companyId === companyId + && transition.environmentId === environmentId && transition.runner === defaultRunner) return; resetTest(); }, [companyId, environmentId, runner === "auto" ? defaultRunner : undefined]); const sandboxProvider = @@ -343,9 +353,9 @@ function Setup({ !adapter.disabled && !getAdapterDisplay(adapterType).comingSoon, ) && (requestAdapterType !== "paperclip_runner" || adapterType === "paperclip_runner" || adapterInventory.data?.some(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled)); - const ready = Boolean( + const connectionSettingsReady = Boolean( available && - (adapterType !== "codex_local" || runner !== "auto" || Boolean(adapters.data)) && + (!["codex_local", "claude_local"].includes(adapterType) || runner !== "auto" || Boolean(adapters.data)) && !environmentError && !envs.isPending && !settings.isPending && @@ -361,16 +371,26 @@ function Setup({ !caps.error && (!(managedOnly || forced.forced) || environmentId), ); + const needsCurrentSetup = ["codex_local", "claude_local"].includes(adapterType); + const ready = connectionSettingsReady && (!needsCurrentSetup || ( + envs.isSuccess && !envs.isFetching + && settings.isSuccess && !settings.isFetching + && experimental.isSuccess && !experimental.isFetching + && general.isSuccess && !general.isFetching + && (runner !== "auto" || (adapters.isSuccess && !adapters.isFetching)) + )); const busy = testState === "running" || saving; function buildConfig( nextConnection = connection, binding = selectedBinding, + setupRunner?: AgentRunner, ): Record { const values = { ...defaultCreateValues, adapterType, runner, + resolvedRunner: runner === "auto" ? setupRunner ?? defaultRunner : runner, model: model || (brandType === "codex_local" ? DEFAULT_CODEX_LOCAL_MODEL : ""), thinkingEffort: effort, @@ -386,9 +406,9 @@ function Setup({ } : {}), }; - const config = getUIAdapter(adapterType).buildAdapterConfig(values); - if (preserveNativeCodex && runner === "paperclip") { - Object.assign(config, { provider: "codex", codexPermissionMode: "never", lifecycleMode: "per_turn" }); + const config = getUIAdapter(preserveNativeHarness && runner === "paperclip" ? "paperclip_runner" : adapterType).buildAdapterConfig(preserveNativeHarness && runner === "paperclip" ? { ...values, adapterSchemaValues: runnerProvider === "claude" ? { provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all" } : { provider: "codex" } } : values); + if (preserveNativeHarness && runner === "paperclip") { + Object.assign(config, runnerProvider === "claude" ? { provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all", lifecycleMode: "per_turn" } : { provider: "codex", codexPermissionMode: "never", lifecycleMode: "per_turn" }); } if (isRunner) Object.assign(config, { @@ -423,7 +443,7 @@ function Setup({ } return config; } - function preparedConfig(nextConnection = connection) { + function preparedConfig(nextConnection = connection, setupRunner?: AgentRunner) { if (isDot && !allowUnmeteredProvider) throw new Error("Acknowledge external provider billing before creating your Dot agent."); if (multiProvider && (!model.trim() || !model.includes("/"))) throw new Error("Choose or enter a model in provider/model format."); @@ -452,7 +472,7 @@ function Setup({ } if (usingKimiApi && !kimiModel.trim()) throw new Error("Enter the Kimi API model name."); - return buildConfig(nextConnection); + return buildConfig(nextConnection, selectedBinding, setupRunner); } function pendingCredentials(nextConnection = connection) { if (aiBinding || nextConnection?.aiConnection) return {}; @@ -464,34 +484,48 @@ function Setup({ }; } - async function runTest(nextConnection = connection): Promise { - if (!ready) return false; + async function runTest(nextConnection = connection): Promise | null> { + if (!ready) return null; const run = ++generation.current; + const setupEnvironment = setupEnvironmentRef.current; setTestState("running"); setResult(null); setError(null); try { - const config = await preparedConfig(nextConnection); + preparedConfig(nextConnection); + const selection = { adapterType, runner, savedAgentId: createdAgentId, environment }; + const qualified = await qualifyClaudeSetup({ ...selection, + readAdapters: () => adaptersApi.list({ companyId, environmentId }), + probeEnvironment: () => environmentsApi.probe(environmentId!, companyId), + isCurrent: () => run === generation.current && setupEnvironmentRef.current === setupEnvironment, + }); + if (run !== generation.current || setupEnvironmentRef.current !== setupEnvironment + || (needsClaudeSetupQualification(selection) && !qualified)) return null; + if (qualified) { + qualificationTransition.current = { run, companyId, environmentId, runner: qualified.runner }; + cache.setQueryData(queryKeys.adapters.availability(companyId, environmentId), qualified.adapters); + } + const config = preparedConfig(nextConnection, qualified?.runner); const tested = await testAgentSetup({ companyId, adapterType: requestAdapterType, providerAdapter: brandType, - ...(adapterType === "codex_local" && runner !== "auto" ? { runner } : {}), + ...(["codex_local", "claude_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}), adapterConfig: config, testCredentials: pendingCredentials(nextConnection), aiConnection: runtimeAiBinding ?? nextConnection?.aiConnection, environmentId, }); - if (run !== generation.current) return false; + if (run !== generation.current) return null; setResult(tested); setTestState(blocking(tested) ? "fail" : tested.status); return ( !blocking(tested) && (!connectionAdapter || tested.checks.some((check) => - check.code.endsWith("hello_probe_passed"), + check.code.endsWith("hello_probe_passed") || check.code === "ai_connection_api_key_reverified", )) - ); + ) ? config : null; } catch (cause) { if (run === generation.current) { setError( @@ -499,7 +533,7 @@ function Setup({ ); setTestState("fail"); } - return false; + return null; } } async function finish() { @@ -520,11 +554,11 @@ function Setup({ const staged: Array<{ remove: () => Promise }> = []; let hired = false; try { - if (connectionAdapter && testState === "idle" && !(await runTest())) return; - const config = preparedConfig(); const credentials = pendingCredentials(); - // Untested entered keys must pass a probe before they can be stored. - if (Object.keys(credentials).length && !(await runTest())) return; + const config = (connectionAdapter && testState === "idle") || Object.keys(credentials).length + ? await runTest() + : preparedConfig(); + if (!config) return; for (const [key, value] of Object.entries(credentials)) { const store = connectionAdapter ? storeProviderApiKey @@ -549,7 +583,7 @@ function Setup({ role: existing.length ? "general" : "ceo", ...(leader ? { reportsTo: leader.id } : {}), adapterType: requestAdapterType, - ...(adapterType === "codex_local" && runner !== "auto" ? { runner } : {}), + ...(["codex_local", "claude_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}), adapterConfig: config, defaultEnvironmentId: environmentOverride || @@ -764,7 +798,7 @@ function Setup({ center />
- {ready ? (await runTest(next)) !== null} testError={ error ?? ( @@ -816,13 +850,13 @@ function Setup({ setRuntimeAiBinding(undefined); setScreen("runtime"); }} - /> :

Loading connection settings…

} - {adapterType === "codex_local" &&
+ /> :

Loading connection settings…

} + {["codex_local", "claude_local"].includes(adapterType) &&
Advanced - { void adapters.refetch(); }} - defaultRunner={adapters.data?.find(item => item.type === "codex_local")?.defaultRunner} - supportedRunners={adapters.data?.find(item => item.type === "codex_local")?.supportedRunners} + defaultRunner={adapters.data?.find(item => item.type === adapterType)?.defaultRunner} + supportedRunners={adapters.data?.find(item => item.type === adapterType)?.supportedRunners} onChange={value => { setRunner(value); resetTest(); }} />
} @@ -1168,12 +1202,12 @@ function Setup({
)} - {adapterType === "codex_local" &&
+ {["codex_local", "claude_local"].includes(adapterType) &&
Advanced - { void adapters.refetch(); }} - defaultRunner={adapters.data?.find(item => item.type === "codex_local")?.defaultRunner} - supportedRunners={adapters.data?.find(item => item.type === "codex_local")?.supportedRunners} + defaultRunner={adapters.data?.find(item => item.type === adapterType)?.defaultRunner} + supportedRunners={adapters.data?.find(item => item.type === adapterType)?.supportedRunners} onChange={value => { setRunner(value); resetTest(); }} />
} diff --git a/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx b/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx index 1f95fc838d3..a8eeccf5971 100644 --- a/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx +++ b/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx @@ -1,7 +1,7 @@ import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type Ref } from "react"; import { useQuery } from "@tanstack/react-query"; import { ArrowLeft, Check, ChevronDown, Plus, RotateCcw, Search, X, Zap } from "lucide-react"; -import { aiRuntimeConnectionBindingSchema, type Agent, type IssueAssigneeAdapterOverrides } from "@paperclipai/shared"; +import { aiRuntimeConnectionBindingSchema, agentHarnessType, type Agent, type IssueAssigneeAdapterOverrides } from "@paperclipai/shared"; import { agentsApi, type AdapterModel } from "@/api/agents"; import { queryKeys } from "@/lib/queryKeys"; import { cn } from "@/lib/utils"; @@ -51,7 +51,7 @@ const HARNESS_LABELS: Record = { function harnessLabel(agent: Agent | undefined): string { if (!agent) return "Choose an agent"; - const harness = HARNESS_LABELS[agent.adapterType] ?? agent.adapterType; + const harness = HARNESS_LABELS[agentHarnessType(agent.adapterType, agent.adapterConfig)] ?? agent.adapterType; const provider = composerCatalogProvider(agent); const binding = aiRuntimeConnectionBindingSchema.safeParse(agent?.runtimeConfig?.aiConnection).data; const poolId = binding?.mode === "router" ? binding.connectionId : undefined; diff --git a/ui/src/components/task-chat/composer-run-settings.ts b/ui/src/components/task-chat/composer-run-settings.ts index 382676ad13a..a33b338a42a 100644 --- a/ui/src/components/task-chat/composer-run-settings.ts +++ b/ui/src/components/task-chat/composer-run-settings.ts @@ -3,7 +3,7 @@ import { claudeLocalReasoningEffortsForModel } from "@paperclipai/adapter-claude import { resolvePaperclipRunnerModel } from "@paperclipai/adapter-utils"; import { DEFAULT_GROK_LOCAL_MODEL, grokLocalReasoningEffortsForModel } from "@paperclipai/adapter-grok-local"; import { DEFAULT_KIMI_LOCAL_MODEL, modelSupportsEffort, KIMI_SUPPORTED_EFFORTS } from "@paperclipai/adapter-kimi-local"; -import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema, type Agent, type IssueAssigneeAdapterOverrides } from "@paperclipai/shared"; +import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema, agentHarnessType, type Agent, type IssueAssigneeAdapterOverrides } from "@paperclipai/shared"; export interface ComposerRunSettings { model: string | null; @@ -66,12 +66,12 @@ export function composerEfforts(agent: Agent | undefined, model: string, catalog : agent.adapterType === "kimi_local" ? DEFAULT_KIMI_LOCAL_MODEL : ""); if (!effectiveModel) return []; const pool = aiRuntimeConnectionBindingSchema.safeParse(agent.runtimeConfig?.aiConnection).data?.mode === "router"; - if (pool && agent.adapterType === "paperclip_runner") return isCodexLocalKnownModel(effectiveModel) ? codexLocalReasoningEffortsForModel(effectiveModel) : []; + if (pool && agent.adapterType === "paperclip_runner") return isCodexLocalKnownModel(effectiveModel) ? codexLocalReasoningEffortsForModel(effectiveModel) : /^claude-/.test(effectiveModel) ? claudeLocalReasoningEffortsForModel(effectiveModel) : []; if (agent.adapterType === "codex_local" || (agent.adapterType === "paperclip_runner" && composerCatalogProvider(agent) === "codex")) { return isCodexLocalKnownModel(effectiveModel) ? codexLocalReasoningEffortsForModel(effectiveModel) : []; } if (!catalogIds.includes(effectiveModel)) return []; - if (agent.adapterType === "claude_local") return claudeLocalReasoningEffortsForModel(effectiveModel); + if (agentHarnessType(agent.adapterType, agent.adapterConfig) === "claude_local") return claudeLocalReasoningEffortsForModel(effectiveModel); if (agent.adapterType === "grok_local") return grokLocalReasoningEffortsForModel(effectiveModel); if (agent.adapterType === "pi_local") return ["off", "minimal", "low", "medium", "high", "xhigh"]; if (agent.adapterType === "kimi_local" && typeof agent.adapterConfig.engine === "string" diff --git a/ui/src/lib/qualify-claude-setup.test.ts b/ui/src/lib/qualify-claude-setup.test.ts new file mode 100644 index 00000000000..7d4f467acc8 --- /dev/null +++ b/ui/src/lib/qualify-claude-setup.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AdapterInfo } from "../api/adapters"; +import { qualifyClaudeSetup } from "./qualify-claude-setup"; + +const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`; +const discovery = (runner: "paperclip" | "legacy", overrides = {}) => [ + { type: "claude_local", defaultRunner: runner, supportedRunners: [runner], ...overrides }, + { type: "paperclip_runner" }, +] as AdapterInfo[]; +function setup() { + return { + adapterType: "claude_local", runner: "auto" as const, + environment: { driver: "sandbox" as const, config: { provider: "daytona", image } }, + readAdapters: vi.fn().mockResolvedValueOnce(discovery("legacy")).mockResolvedValue(discovery("paperclip")), + probeEnvironment: vi.fn().mockResolvedValue({ ok: true, driver: "sandbox", summary: "Measured image", + details: { platformQualificationRecorded: true, leaseId: "owned-probe-lease" } }), + isCurrent: vi.fn(() => true), + }; +} + +describe("Claude explicit setup qualification", () => { + it.each(["paperclip", "legacy"] as const)("uses fresh discovery and honors measured %s selection", async runner => { + const s = setup(); + s.readAdapters.mockResolvedValueOnce(discovery(runner)); + const result = await qualifyClaudeSetup(s); + expect(result?.runner).toBe(runner); + expect(s.readAdapters).toHaveBeenCalledTimes(2); + expect(s.probeEnvironment).toHaveBeenCalledTimes(1); + expect(s.readAdapters.mock.invocationCallOrder[0]).toBeLessThan(s.probeEnvironment.mock.invocationCallOrder[0]); + expect(s.probeEnvironment.mock.invocationCallOrder[0]).toBeLessThan(s.readAdapters.mock.invocationCallOrder[1]); + }); + + it.each(["permission", "cleanup", "missing-lease", "connection", "discovery"])("stops before adapter setup after %s failure", async failure => { + const s = setup(); + if (failure === "permission") s.probeEnvironment.mockRejectedValue(new Error("Instance admin access required")); + else if (failure === "discovery") s.readAdapters.mockRejectedValue(new Error("Discovery unavailable")); + else s.probeEnvironment.mockResolvedValue({ ok: failure !== "connection", summary: "Qualification rejected", + details: { platformQualificationRecorded: failure !== "cleanup", ...(failure === "missing-lease" ? {} : { leaseId: "lease" }) } }); + await expect(qualifyClaudeSetup(s)).rejects.toThrow(failure === "permission" ? "Instance admin" : failure === "discovery" ? "Discovery" : "Qualification rejected"); + }); + + it.each(["legacy", "native", "local", "snapshot", "tag", "saved", "codex", "qualified", "disabled", "override", "missing-native"])("does not allocate an extra probe for %s", async mode => { + const s = setup(); + const input: Parameters[0] = s; + if (mode === "legacy" || mode === "native") input.runner = mode === "native" ? "paperclip" : "legacy"; + if (mode === "local") input.environment = { driver: "local", config: {} }; + if (mode === "snapshot") input.environment = { driver: "sandbox", config: { image, snapshot: "named-snapshot" } }; + if (mode === "tag") input.environment = { driver: "sandbox", config: { image: "registry.example.test/runtime:latest" } }; + if (mode === "saved") input.savedAgentId = "saved-agent"; + if (mode === "codex") input.adapterType = "codex_local"; + if (mode === "qualified") s.readAdapters.mockReset().mockResolvedValue(discovery("paperclip")); + if (mode === "disabled" || mode === "override") s.readAdapters.mockReset().mockResolvedValue(discovery("legacy", mode === "disabled" ? { disabled: true } : { overriddenBuiltin: true })); + if (mode === "missing-native") s.readAdapters.mockReset().mockResolvedValue(discovery("legacy").slice(0, 1)); + await qualifyClaudeSetup(input); + expect(s.probeEnvironment).not.toHaveBeenCalled(); + }); + + it("abandons a stale selection before probing and after probing", async () => { + const before = setup(); before.isCurrent.mockReturnValue(false); + expect(await qualifyClaudeSetup(before)).toBeUndefined(); + expect(before.probeEnvironment).not.toHaveBeenCalled(); + const after = setup(); after.isCurrent.mockReturnValueOnce(true).mockReturnValue(false); + expect(await qualifyClaudeSetup(after)).toBeUndefined(); + expect(after.probeEnvironment).toHaveBeenCalledTimes(1); + expect(after.readAdapters).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/src/lib/qualify-claude-setup.ts b/ui/src/lib/qualify-claude-setup.ts new file mode 100644 index 00000000000..cfa6a2c8731 --- /dev/null +++ b/ui/src/lib/qualify-claude-setup.ts @@ -0,0 +1,48 @@ +import type { AgentRunner, AgentRunnerChoice, Environment, EnvironmentProbeResult } from "@paperclipai/shared"; +import type { AdapterInfo } from "../api/adapters"; + +export function needsClaudeSetupQualification(input: { + adapterType: string; + runner: AgentRunnerChoice; + savedAgentId?: string | null; + environment?: Pick | null; +}): boolean { + const config = input.environment?.config; + 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; +} + +export async function qualifyClaudeSetup(input: Parameters[0] & { + readAdapters: () => Promise; + probeEnvironment: () => Promise; + isCurrent: () => boolean; +}): Promise<{ runner: AgentRunner; adapters: AdapterInfo[] } | undefined> { + if (!needsClaudeSetupQualification(input)) return undefined; + const validate = (adapters: AdapterInfo[]) => { + const claude = adapters.find(adapter => adapter.type === "claude_local"); + const runner = claude?.defaultRunner; + if (!claude || (runner !== "paperclip" && runner !== "legacy") || !claude.supportedRunners?.includes(runner)) { + throw new Error("Could not confirm Claude runner availability for this environment. Retry setup."); + } + return { runner, claude }; + }; + let adapters = await input.readAdapters(); + if (!input.isCurrent()) return undefined; + const selected = validate(adapters); + const native = adapters.find(adapter => adapter.type === "paperclip_runner"); + if (selected.runner === "paperclip" || selected.claude.disabled || selected.claude.overriddenBuiltin + || !native || native.disabled || native.overriddenBuiltin) return { runner: selected.runner, adapters }; + const probe = await input.probeEnvironment(); + if (!input.isCurrent()) return undefined; + if (probe.ok !== true || probe.details?.platformQualificationRecorded !== true + || typeof probe.details.leaseId !== "string" || !probe.details.leaseId) { + throw new Error(typeof probe.details?.platformQualificationDiagnostic === "string" + ? probe.details.platformQualificationDiagnostic + : probe.summary || "The environment's platform qualification could not be confirmed. Retry the connection probe or select Legacy runner in Advanced."); + } + adapters = await input.readAdapters(); + if (!input.isCurrent()) return undefined; + return { runner: validate(adapters).runner, adapters }; +} diff --git a/ui/src/lib/test-agent-setup.test.ts b/ui/src/lib/test-agent-setup.test.ts index 50af21a4f9e..7c944b52a09 100644 --- a/ui/src/lib/test-agent-setup.test.ts +++ b/ui/src/lib/test-agent-setup.test.ts @@ -83,6 +83,14 @@ it("probes native Grok credentials with the pinned prerequisite in the selected }); expect(result.checks.some((check) => check.code === "grok_hello_probe_passed")).toBe(true); }); +it("accepts verified native API authentication without requiring a Legacy CLI", async () => { + testEnvironment.mockResolvedValue({ ...ready, checks: [ + { code: "acpx_runtime_ready", level: "info", message: "Runtime verified" }, + { code: "ai_connection_api_key_reverified", level: "info", message: "Key verified" }, + ] }); + expect((await testAgentSetup(input)).status).toBe("pass"); + expect(testEnvironment).toHaveBeenCalledTimes(1); +}); it("does not repeat a completed model probe", async () => { testEnvironment.mockResolvedValue({ ...ready, diff --git a/ui/src/lib/test-agent-setup.ts b/ui/src/lib/test-agent-setup.ts index 26517f95b15..e9bc1769a60 100644 --- a/ui/src/lib/test-agent-setup.ts +++ b/ui/src/lib/test-agent-setup.ts @@ -2,8 +2,8 @@ import type { AdapterEnvironmentTestResult } from "@paperclipai/shared"; import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared"; import { agentsApi } from "../api/agents"; -/** ACP readiness checks do not authenticate a provider. Verify credentials with - * the adapter's existing read-only CLI hello probe before calling setup connected. */ +/** Readiness alone does not authenticate a provider. Reuse the provider's key + * verification when present, otherwise retain the existing CLI hello probe. */ export async function testAgentSetup(input: { companyId: string; agentId?: string; @@ -34,7 +34,7 @@ export async function testAgentSetup(input: { runtime.checks.some( (check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE, ) || - runtime.checks.some((check) => check.code.includes("hello_probe")) || + runtime.checks.some((check) => check.code.includes("hello_probe") || check.code === "ai_connection_api_key_reverified") || !["claude_local", "codex_local", "grok_local"].includes(input.providerAdapter) ) return runtime; diff --git a/ui/src/pages/CompanyImport.test.tsx b/ui/src/pages/CompanyImport.test.tsx index d8c9286963f..6c4ae12aee8 100644 --- a/ui/src/pages/CompanyImport.test.tsx +++ b/ui/src/pages/CompanyImport.test.tsx @@ -1260,10 +1260,75 @@ describe("CompanyImport", () => { } await clickButton(text => text.startsWith("Import 3 file")); await settle(); - expect(lastImportMeta().adapterOverrides).toEqual({ coder: { adapterType: "codex_local", runner } }); + expect(lastImportMeta().adapterOverrides).toEqual({ coder: { adapterType: sourceType === "paperclip_runner" ? "paperclip_runner" : "codex_local", runner } }); expect(preview.manifest.agents[0]!.adapterConfig).toEqual(savedSourceConfig); }); + it.each(["deny-all", "approve-reads"].flatMap(permission => ["paperclip_runner", "claude_local"].map(sourceType => ({ permission, sourceType }))))("saves the selected Claude permission from $sourceType ($permission)", async ({ permission, sourceType }) => { + mockOptionalCompany.mockReturnValue({ selectedCompanyId: "company-1" }); + mockAdaptersApi.list.mockResolvedValue([ + { type: "claude_local", disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" }, + { type: "paperclip_runner", disabled: false }, + ]); + const preview = buildPreviewResult(); + const source = { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", modelReasoningEffort: "high", acpxPermissionMode: permission, timeoutSec: 180, lifecycleMode: "per_turn" }; + preview.manifest.agents[0]!.adapterType = sourceType; + preview.manifest.agents[0]!.runner = sourceType === "paperclip_runner" ? "paperclip" : "legacy"; + preview.manifest.agents[0]!.adapterConfig = source; + mockCompaniesApi.importPreview.mockResolvedValue(preview); + await renderPage(); + await enterGithubUrl(); + await clickButton(text => text === "Preview import"); + await clickButton(text => text.includes("configure adapter")); + if (sourceType === "claude_local") { + await clickButton(text => text.includes("Advanced")); + await act(async () => container.querySelector('[aria-label="Runner"]')!.click()); + await act(async () => document.querySelector('[role="option"][data-value="paperclip"]')!.click()); + await flushReact(); + } + const select = container.querySelector('[aria-label="Permission mode"]'); + if (sourceType === "paperclip_runner") expect(select?.textContent).toContain(permission === "deny-all" ? "Deny all" : "Allow Paperclip reads"); + await act(async () => select!.click()); + const nextPermission = permission === "deny-all" ? "approve-reads" : "deny-all"; + const option = [...document.querySelectorAll('[role="option"]')].find(element => element.textContent === (nextPermission === "deny-all" ? "Deny all" : "Allow Paperclip reads")); + expect(option).toBeTruthy(); + await act(async () => option!.click()); + await flushReact(); + await clickButton(text => text.startsWith("Import 3 file")); + await settle(); + expect(lastImportMeta().adapterOverrides?.coder).toMatchObject({ + runner: "paperclip", + adapterConfig: sourceType === "paperclip_runner" ? { ...source, acpxPermissionMode: nextPermission } : { provider: "acpx", acpxAgent: "claude", acpxPermissionMode: nextPermission }, + }); + expect(preview.manifest.agents[0]!.adapterConfig).toEqual(source); + }); + + it("keeps native Claude configuration at the server translation boundary when switching an import to Legacy", async () => { + mockOptionalCompany.mockReturnValue({ selectedCompanyId: "company-1" }); + mockAdaptersApi.list.mockResolvedValue([ + { type: "claude_local", disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" }, + { type: "paperclip_runner", disabled: false }, + ]); + const preview = buildPreviewResult(); + const source = { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", modelReasoningEffort: "high", acpxPermissionMode: "approve-all", timeoutSec: 180, lifecycleMode: "per_turn" }; + preview.manifest.agents[0]!.adapterType = "paperclip_runner"; + preview.manifest.agents[0]!.runner = "paperclip"; + preview.manifest.agents[0]!.adapterConfig = source; + mockCompaniesApi.importPreview.mockResolvedValue(preview); + await renderPage(); + await enterGithubUrl(); + await clickButton(text => text === "Preview import"); + await clickButton(text => text.includes("configure adapter")); + await clickButton(text => text.includes("Advanced")); + await act(async () => container.querySelector('[aria-label="Runner"]')!.click()); + await act(async () => document.querySelector('[role="option"][data-value="legacy"]')!.click()); + await flushReact(); + await clickButton(text => text.startsWith("Import 3 file")); + await settle(); + expect(lastImportMeta().adapterOverrides?.coder).toMatchObject({ adapterType: "paperclip_runner", runner: "legacy" }); + expect(preview.manifest.agents[0]!.adapterConfig).toEqual(source); + }); + it.each([ { dangerouslyBypassApprovalsAndSandbox: false }, { dangerouslyBypassSandbox: false }, @@ -1296,22 +1361,22 @@ describe("CompanyImport", () => { expect(lastImportMeta().adapterOverrides?.coder?.adapterConfig).not.toHaveProperty("codexLegacySandboxBypass"); }); - it.each(["model-clear", "effort-clear", "env-remove", "legacy-model", "changed-harness"] as const)("preserves native Codex source configuration during an ordinary import edit (%s)", async edit => { + it.each(["codex_local", "claude_local"].flatMap(harness => ["model-clear", "effort-clear", "env-remove", "legacy-model", "changed-harness"].map(edit => ({ harness, edit }))))("preserves native source configuration during an ordinary import edit ($harness, $edit)", async ({ harness, edit }) => { mockOptionalCompany.mockReturnValue({ selectedCompanyId: "company-1" }); mockAdaptersApi.list.mockResolvedValue([ - { type: "claude_local", disabled: false }, + { type: "claude_local", disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" }, { type: "codex_local", disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" }, { type: "paperclip_runner", disabled: false }, ]); const preview = buildPreviewResult(); const sourceConfig = { - provider: "codex", model: "gpt-5.6-sol", modelReasoningEffort: "high", reasoningEffort: "low", + ...(harness === "codex_local" ? { provider: "codex", model: "gpt-5.6-sol" } : { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", acpxPermissionMode: "approve-all" }), modelReasoningEffort: "high", reasoningEffort: "low", env: { CODEX_HOME: { type: "secret_ref", secretId: "saved-account", version: "latest" }, OPENAI_API_KEY: { type: "user_secret_ref", key: "OPENAI_API_KEY", version: "latest", required: true } }, timeoutSec: 120, graceSec: 30, cwd: "/saved/workspace", instructionsFilePath: "/saved/AGENTS.md", workspaceStrategy: { type: "git_worktree", baseRef: "main", branchTemplate: "qa/{runId}", worktreeParentDir: "/saved/worktrees" }, workspaceRuntime: { services: [{ name: "app", command: "pnpm dev" }] }, - lifecycleMode: "warm", idleTimeoutMs: 45000, + lifecycleMode: "per_turn", idleTimeoutMs: 45000, }; preview.manifest.agents[0]!.adapterType = "paperclip_runner"; preview.manifest.agents[0]!.runner = "paperclip"; @@ -1323,7 +1388,7 @@ describe("CompanyImport", () => { if (edit === "changed-harness") { const harness = findAdapterSelects()[0]!; await act(async () => { - Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")!.set!.call(harness, "claude_local"); + Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")!.set!.call(harness, sourceConfig.provider === "codex" ? "claude_local" : "codex_local"); harness.dispatchEvent(new Event("change", { bubbles: true })); }); } @@ -1354,14 +1419,14 @@ describe("CompanyImport", () => { await clickButton(text => text.startsWith("Import 3 file")); await settle(); if (edit === "changed-harness") { - expect(lastImportMeta().adapterOverrides?.coder?.adapterType).toBe("claude_local"); + expect(lastImportMeta().adapterOverrides?.coder?.adapterType).toBe(harness === "codex_local" ? "claude_local" : "codex_local"); const config = lastImportMeta().adapterOverrides?.coder?.adapterConfig; for (const field of ["provider", "env", "workspaceStrategy", "workspaceRuntime", "lifecycleMode", "idleTimeoutMs", "modelReasoningEffort"]) expect(config).not.toHaveProperty(field); expect(config?.timeoutSec).toBe(0); return; } expect(lastImportMeta().adapterOverrides?.coder).toEqual({ - adapterType: "codex_local", runner: edit === "legacy-model" ? "legacy" : "paperclip", adapterConfig: expected, + adapterType: "paperclip_runner", runner: edit === "legacy-model" ? "legacy" : "paperclip", adapterConfig: expected, }); expect(preview.manifest.agents[0]!.adapterConfig).toEqual(sourceConfig); }); diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index 99a324ed698..4077f52052b 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -576,13 +576,14 @@ function importAdapterValues( sourceAdapterType?: string, runner?: AgentRunnerChoice, ): CreateConfigValues { - const nativeCodex = sourceAdapterType === "paperclip_runner" && choice === "codex_local"; + const nativeHarness = sourceAdapterType === "paperclip_runner" && ["codex_local", "claude_local"].includes(choice); + const nativeCodex = nativeHarness && choice === "codex_local"; const savedBypass = sourceConfig?.dangerouslyBypassApprovalsAndSandbox ?? (nativeCodex ? undefined : sourceConfig?.dangerouslyBypassSandbox); return { ...defaultCreateValues, - adapterType: choice === "openai_dot" || choice === "muse" ? "paperclip_runner" : choice, - ...(runner !== undefined ? { runner } : nativeCodex ? { runner: agentRunner("paperclip_runner") } : {}), + adapterType: (choice === "openai_dot" || choice === "muse") ? "paperclip_runner" : choice, + ...(runner !== undefined ? { runner } : nativeHarness ? { runner: agentRunner("paperclip_runner") } : {}), ...(sourceConfig && choice === "codex_local" ? { model: typeof sourceConfig.model === "string" ? sourceConfig.model : "", thinkingEffort: String(sourceConfig.modelReasoningEffort ?? sourceConfig.reasoningEffort ?? ""), @@ -592,7 +593,8 @@ function importAdapterValues( ? (sourceConfig.codexPermissionMode ?? PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.codex.defaultMode) === "never" : resolveCodexLocalCliPolicy(sourceConfig).bypass, } : {}), - ...(nativeCodex && sourceConfig ? { + ...(sourceConfig && choice === "claude_local" ? { model: typeof sourceConfig.model === "string" ? sourceConfig.model : "", thinkingEffort: String(sourceConfig.modelReasoningEffort ?? sourceConfig.reasoningEffort ?? sourceConfig.effort ?? "") } : {}), + ...(nativeHarness && sourceConfig ? { cwd: typeof sourceConfig.cwd === "string" ? sourceConfig.cwd : "", instructionsFilePath: typeof sourceConfig.instructionsFilePath === "string" ? sourceConfig.instructionsFilePath : "", envBindings: { ...asObject(sourceConfig.env) }, @@ -1621,7 +1623,7 @@ export function CompanyImport() { resetMutationState(); const agent = adapterAgents.find((a) => a.slug === slug); const currentType = agent ? effectiveAdapterType(agent) : adapterOverrides[slug] ?? "claude_local"; - if (Object.keys(patch).some(key => key !== "runner")) { + if (Object.keys(patch).some(key => key !== "runner" && key !== "resolvedRunner")) { setAdapterConfigEditedSlugs(prev => new Set([...prev, slug])); } setAdapterConfigValues((prev) => ({ @@ -1679,14 +1681,15 @@ export function CompanyImport() { return importPreview.manifest.agents.map((a) => { const nativeCodex = a.adapterType === "paperclip_runner" && (a.adapterConfig?.provider === "codex" || a.adapterConfig?.provider === undefined); + const nativeHarness = nativeCodex || (a.adapterType === "paperclip_runner" && agentHarnessType(a.adapterType, a.adapterConfig) === "claude_local"); const adapterChoice = a.adapterType === "paperclip_runner" && a.adapterConfig?.provider === "openai_dot" - ? "openai_dot" : a.adapterType === "paperclip_runner" && a.adapterConfig?.provider === "muse" ? "muse" : nativeCodex ? agentHarnessType(a.adapterType, a.adapterConfig) : a.adapterType; + ? "openai_dot" : a.adapterType === "paperclip_runner" && a.adapterConfig?.provider === "muse" ? "muse" : nativeHarness ? agentHarnessType(a.adapterType, a.adapterConfig) : a.adapterType; let fallbackAdapterType: string | null = null; let availabilityWarning: string | undefined; if (a.adapterType === "paperclip_runner") { - const available = nativeCodex ? runnerInstalled : adapterChoice === "openai_dot" ? dotAvailable : adapterChoice === "muse" ? museAvailable : nativeRunnerAvailable; - if (!available && (availableAdapterTypes || !nativeCodex)) { - availabilityWarning = nativeCodex + const available = nativeHarness ? runnerInstalled : adapterChoice === "openai_dot" ? dotAvailable : adapterChoice === "muse" ? museAvailable : nativeRunnerAvailable; + if (!available && (availableAdapterTypes || !nativeHarness)) { + availabilityWarning = nativeHarness ? "Paperclip Runner is unavailable here. Choose a runner or another harness explicitly." : `Paperclip Runner provider ${String(a.adapterConfig?.provider)} requires its experimental feature to be enabled. This import keeps its saved harness until you choose another one.`; } @@ -1729,8 +1732,11 @@ export function CompanyImport() { for (const agent of adapterAgents) { const selectedType = effectiveAdapterType(agent); const configVals = adapterConfigValues[agent.slug]; - if (selectedType === agent.adapterChoice && !configVals) continue; - const apiType = selectedType === "openai_dot" || selectedType === "muse" ? "paperclip_runner" : selectedType; + if (selectedType === agent.adapterChoice && !adapterConfigEditedSlugs.has(agent.slug) + && (!configVals || configVals.runner === importAdapterValues(selectedType, agent.adapterConfig, agent.adapterType, agent.runner).runner)) continue; + const retainsNativeHarness = agent.adapterType === "paperclip_runner" + && selectedType === agent.adapterChoice && ["codex_local", "claude_local"].includes(selectedType); + const apiType = selectedType === "openai_dot" || selectedType === "muse" || retainsNativeHarness ? "paperclip_runner" : selectedType; const override: CompanyPortabilityAdapterOverride = { adapterType: apiType }; if (configVals?.runner !== undefined) override.runner = configVals.runner; if (configVals && (selectedType !== agent.adapterChoice || adapterConfigEditedSlugs.has(agent.slug))) { @@ -1744,11 +1750,14 @@ export function CompanyImport() { if (selectedType === "muse" && configVals.adapterSchemaValues?.allowUnmeteredProvider !== true) { throw new Error("Muse requires acknowledgement of external billing. Configure Muse and enable Allow externally billed provider."); } - const build = getUIAdapter(configVals.adapterType).buildAdapterConfig; + const nativeClaudeForm = selectedType === "claude_local" && (retainsNativeHarness + || (configVals.runner !== "legacy" && configVals.adapterSchemaValues?.provider === "acpx" + && configVals.adapterSchemaValues.acpxAgent === "claude")); + const build = getUIAdapter(nativeClaudeForm ? "paperclip_runner" : configVals.adapterType).buildAdapterConfig; const edited = build(configVals); if (agent.adapterType === "paperclip_runner" && selectedType === agent.adapterChoice - && selectedType === "codex_local" && configVals.adapterType === "codex_local" - && agentHarnessType("paperclip_runner", configVals.adapterSchemaValues) === "codex_local") { + && ["codex_local", "claude_local"].includes(selectedType) && configVals.adapterType === selectedType + && agentHarnessType("paperclip_runner", configVals.adapterSchemaValues) === selectedType) { const original = importAdapterValues(selectedType, agent.adapterConfig, agent.adapterType, configVals.runner); const baseline = build(original); const retained = { ...agent.adapterConfig }; @@ -1761,7 +1770,22 @@ export function CompanyImport() { }; applyChanges(original.adapterSchemaValues ?? {}, configVals.adapterSchemaValues ?? {}); applyChanges(baseline, edited); - if (!overlayValuesEqual(baseline.modelReasoningEffort, edited.modelReasoningEffort)) delete retained.reasoningEffort; + if (selectedType === "claude_local") { + const commonBuild = getUIAdapter("claude_local").buildAdapterConfig; + const commonBefore = commonBuild(original); + const commonAfter = commonBuild(configVals); + for (const key of ["cwd", "instructionsFilePath", "env", "workspaceStrategy", "workspaceRuntime"]) { + if (overlayValuesEqual(commonBefore[key], commonAfter[key])) continue; + if (commonAfter[key] === undefined) delete retained[key]; + else retained[key] = commonAfter[key]; + } + } + if (original.model !== configVals.model && !configVals.model) delete retained.model; + if (original.thinkingEffort !== configVals.thinkingEffort) { + delete retained.reasoningEffort; + delete retained.effort; + if (!configVals.thinkingEffort) delete retained.modelReasoningEffort; + } override.adapterConfig = retained; } else { override.adapterConfig = edited; diff --git a/ui/src/pages/NewAgent.test-support.tsx b/ui/src/pages/NewAgent.test-support.tsx new file mode 100644 index 00000000000..beab1997d3e --- /dev/null +++ b/ui/src/pages/NewAgent.test-support.tsx @@ -0,0 +1,205 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, vi } from "vitest"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { queryKeys } from "@/lib/queryKeys"; +import { NewAgent } from "./NewAgent"; +import { ApiError } from "@/api/client"; + +const api = vi.hoisted(() => ({ + get: vi.fn(), + adapterModels: vi.fn(), + list: vi.fn(), + hire: vi.fn(), + testEnvironment: vi.fn(), + getAdapterAuthSignal: vi.fn(), + getClaudeOAuthTokenStatus: vi.fn(), +})); +const envApi = vi.hoisted(() => ({ list: vi.fn(), capabilities: vi.fn(), probe: vi.fn() })); +const settings = vi.hoisted(() => ({ + get: vi.fn(), + getExperimental: vi.fn(), + getGeneral: vi.fn(), +})); +const secrets = vi.hoisted(() => ({ + create: vi.fn(), + remove: vi.fn(), + list: vi.fn(), + listMyUserSecrets: vi.fn(), + createUserSecretDefinition: vi.fn(), + createMyUserSecret: vi.fn(), + rotateMyUserSecret: vi.fn(), + removeUserSecretDefinition: vi.fn(), +})); +const state = vi.hoisted(() => ({ + params: new URLSearchParams(), + adapters: [] as object[], + navigate: vi.fn(), + openNewIssue: vi.fn(), +})); +const managedApi = vi.hoisted(() => ({ + list: vi.fn(async () => ({ currentUserId: "user-1", canManageConnections: true, connections: [] })), + create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })), + setDefault: vi.fn(async () => ({})), +})); +vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: managedApi })); +vi.mock("@/api/agents", () => ({ agentsApi: api })); +vi.mock("@/api/environments", () => ({ environmentsApi: envApi })); +vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: settings })); +vi.mock("@/api/secrets", () => ({ secretsApi: secrets })); +const adapterApi = vi.hoisted(() => ({ list: vi.fn() })); +vi.mock("@/api/adapters", () => ({ adaptersApi: adapterApi })); +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompanyId: "company-1" }), +})); +vi.mock("../context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }), +})); +vi.mock("@/context/DialogContext", () => ({ + useDialogActions: () => ({ openNewIssue: state.openNewIssue }), +})); +vi.mock("@/lib/router", () => ({ + useNavigate: () => state.navigate, + useSearchParams: () => [state.params], +})); +vi.mock("@/components/AgentConfigForm", () => ({ + ModelDropdown: ({ + value, + onChange, + }: { + value: string; + onChange: (value: string) => void; + }) => ( + onChange(e.target.value)} + /> + ), + AdapterLoginPanel: ({ onConnected }: { onConnected: (id: string) => void }) => ( + + ), +})); +vi.mock("@/components/onboarding/PillGuy", () => ({ PillGuy: () => null })); +vi.mock("motion/react", () => ({ + AnimatePresence: ({ children }: any) => children, + MotionConfig: ({ children }: any) => children, + motion: { + span: ({ children }: any) => {children}, + div: ({ children, initial, animate, exit, transition, ...rest }: any) => ( +
{children}
+ ), + }, +})); +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +let root: Root; +let container: HTMLDivElement; +let cache: QueryClient; +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); +} +async function click(text: string) { + const button = [...container.querySelectorAll("button")].find( + (b) => b.textContent?.trim() === text, + ); + expect(button, `Missing button ${text}`).toBeTruthy(); + await act(async () => button!.click()); + await settle(); +} +async function fill(label: string, value: string) { + const input = container.querySelector( + `[aria-label="${label}"]`, + ) as HTMLInputElement; + expect(input).toBeTruthy(); + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} +async function render(adapter = "pi_local", runnerProvider = "codex") { + state.params = new URLSearchParams({ + name: "Atlas", + adapterType: adapter, + runnerProvider, + }); + await renderCurrentParams(); +} +async function renderCurrentParams() { + await act(async () => + root.render( + + + , + ), + ); + await settle(); +} +async function connect(provider: string) { + await click(provider + "Subscription"); + await click("Connect"); +} +const pass = { + adapterType: "pi_local", + status: "pass", + checks: [ + { code: "hello_probe_passed", level: "info", message: "Model replied" }, + ], + testedAt: "2026-09-07T00:00:00Z", +}; +beforeEach(() => { + vi.clearAllMocks(); + adapterApi.list.mockImplementation(async () => state.adapters); + managedApi.list.mockResolvedValue({ currentUserId: "user-1", canManageConnections: true, connections: [] }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + cache = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + state.adapters = [ + "claude_local", + "codex_local", + "opencode_local", + "pi_local", + "paperclip_runner", "cursor_cloud", "cursor", "gemini_local", "kimi_local", "grok_local", "hermes_local", "hermes_gateway", + ].map((type) => ({ type, loaded: true, disabled: false })); + api.adapterModels.mockResolvedValue([]); + api.list.mockResolvedValue([{ id: "ceo", role: "ceo", status: "idle" }]); + api.getAdapterAuthSignal.mockResolvedValue({ status: "present" }); + api.getClaudeOAuthTokenStatus.mockRejectedValue(new ApiError("Not found", 404, null)); + api.testEnvironment.mockResolvedValue(pass); + api.hire.mockImplementation(async (_company, input) => ({ + agent: { ...input, id: "new-agent", status: "idle", urlKey: "atlas" }, + })); + envApi.list.mockResolvedValue([ + { id: "local-1", name: "Local", driver: "local", config: {} }, + ]); + envApi.capabilities.mockResolvedValue({ sandboxProviders: {} }); + envApi.probe.mockReset().mockResolvedValue({ ok: true, driver: "sandbox", summary: "Measured image", + details: { platformQualificationRecorded: true, leaseId: "owned-platform-lease" } }); + settings.get.mockResolvedValue({ defaultEnvironmentId: "local-1" }); + settings.getExperimental.mockResolvedValue({ enableNativeRunner: true }); + settings.getGeneral.mockResolvedValue({ executionMode: "any" }); + secrets.list.mockResolvedValue([]); + secrets.create.mockResolvedValue({ id: "org-secret-1" }); + secrets.remove.mockResolvedValue({ ok: true }); + secrets.listMyUserSecrets.mockResolvedValue([]); + secrets.createUserSecretDefinition.mockResolvedValue({ id: "definition-1" }); + secrets.createMyUserSecret.mockResolvedValue({ id: "secret-1" }); + secrets.removeUserSecretDefinition.mockResolvedValue({ ok: true }); +}); +afterEach(async () => { + await act(async () => root.unmount()); + cache.clear(); + container.remove(); +}); + +export { api, envApi, settings, secrets, state, managedApi, adapterApi, root, container, cache, settle, click, fill, render, connect, pass, renderCurrentParams }; diff --git a/ui/src/pages/NewAgent.test.tsx b/ui/src/pages/NewAgent.test.tsx index 49cf8f2ac3e..f1025570a28 100644 --- a/ui/src/pages/NewAgent.test.tsx +++ b/ui/src/pages/NewAgent.test.tsx @@ -1,205 +1,203 @@ // @vitest-environment jsdom import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { TooltipProvider } from "@/components/ui/tooltip"; +import { describe, expect, it, vi } from "vitest"; import { queryKeys } from "@/lib/queryKeys"; -import { NewAgent } from "./NewAgent"; -import { ApiError } from "@/api/client"; +import { + api, envApi, settings, secrets, state, managedApi, adapterApi, + root, container, cache, settle, click, fill, render, connect, pass, + renderCurrentParams, +} from "./NewAgent.test-support"; -const api = vi.hoisted(() => ({ - get: vi.fn(), - adapterModels: vi.fn(), - list: vi.fn(), - hire: vi.fn(), - testEnvironment: vi.fn(), - getAdapterAuthSignal: vi.fn(), - getClaudeOAuthTokenStatus: vi.fn(), -})); -const envApi = vi.hoisted(() => ({ list: vi.fn(), capabilities: vi.fn() })); -const settings = vi.hoisted(() => ({ - get: vi.fn(), - getExperimental: vi.fn(), - getGeneral: vi.fn(), -})); -const secrets = vi.hoisted(() => ({ - create: vi.fn(), - remove: vi.fn(), - list: vi.fn(), - listMyUserSecrets: vi.fn(), - createUserSecretDefinition: vi.fn(), - createMyUserSecret: vi.fn(), - rotateMyUserSecret: vi.fn(), - removeUserSecretDefinition: vi.fn(), -})); -const state = vi.hoisted(() => ({ - params: new URLSearchParams(), - adapters: [] as object[], - navigate: vi.fn(), - openNewIssue: vi.fn(), -})); -const managedApi = vi.hoisted(() => ({ - list: vi.fn(async () => ({ currentUserId: "user-1", canManageConnections: true, connections: [] })), - create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })), - setDefault: vi.fn(async () => ({})), -})); -vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: managedApi })); -vi.mock("@/api/agents", () => ({ agentsApi: api })); -vi.mock("@/api/environments", () => ({ environmentsApi: envApi })); -vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: settings })); -vi.mock("@/api/secrets", () => ({ secretsApi: secrets })); -const adapterApi = vi.hoisted(() => ({ list: vi.fn() })); -vi.mock("@/api/adapters", () => ({ adaptersApi: adapterApi })); -vi.mock("@/context/CompanyContext", () => ({ - useCompany: () => ({ selectedCompanyId: "company-1" }), -})); -vi.mock("../context/BreadcrumbContext", () => ({ - useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }), -})); -vi.mock("@/context/DialogContext", () => ({ - useDialogActions: () => ({ openNewIssue: state.openNewIssue }), -})); -vi.mock("@/lib/router", () => ({ - useNavigate: () => state.navigate, - useSearchParams: () => [state.params], -})); -// Exercise API/persistence contracts with deterministic presentation primitives. -// The actual searchable dropdown and login panel are covered by their tests and browser verification. -vi.mock("@/components/AgentConfigForm", () => ({ - ModelDropdown: ({ - value, - onChange, - }: { - value: string; - onChange: (value: string) => void; - }) => ( - onChange(e.target.value)} - /> - ), - AdapterLoginPanel: ({ onConnected }: { onConnected: (id: string) => void }) => ( - - ), -})); -vi.mock("@/components/onboarding/PillGuy", () => ({ PillGuy: () => null })); -vi.mock("motion/react", () => ({ - AnimatePresence: ({ children }: any) => children, - MotionConfig: ({ children }: any) => children, - motion: { - span: ({ children }: any) => {children}, - div: ({ children, initial, animate, exit, transition, ...rest }: any) => ( -
{children}
- ), - }, -})); -(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -let root: Root; -let container: HTMLDivElement; -let cache: QueryClient; -async function settle() { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 20)); - }); -} -async function click(text: string) { - const button = [...container.querySelectorAll("button")].find( - (b) => b.textContent?.trim() === text, - ); - expect(button, `Missing button ${text}`).toBeTruthy(); - await act(async () => button!.click()); - await settle(); -} -async function fill(label: string, value: string) { - const input = container.querySelector( - `[aria-label="${label}"]`, - ) as HTMLInputElement; - expect(input).toBeTruthy(); - await act(async () => { - Object.getOwnPropertyDescriptor( - HTMLInputElement.prototype, - "value", - )!.set!.call(input, value); - input.dispatchEvent(new Event("input", { bubbles: true })); - }); -} -async function render(adapter = "pi_local", runnerProvider = "codex") { - state.params = new URLSearchParams({ - name: "Atlas", - adapterType: adapter, - runnerProvider, - }); - await act(async () => - root.render( - - - , - ), - ); - await settle(); -} -async function connect(provider: string) { - await click(provider + "Subscription"); - await click("Connect"); -} -const pass = { - adapterType: "pi_local", - status: "pass", - checks: [ - { code: "hello_probe_passed", level: "info", message: "Model replied" }, - ], - testedAt: "2026-09-07T00:00:00Z", -}; -beforeEach(() => { - vi.clearAllMocks(); - adapterApi.list.mockImplementation(async () => state.adapters); - managedApi.list.mockResolvedValue({ currentUserId: "user-1", canManageConnections: true, connections: [] }); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - cache = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - state.adapters = [ - "claude_local", - "codex_local", - "opencode_local", - "pi_local", - "paperclip_runner", "cursor_cloud", "cursor", "gemini_local", "kimi_local", "grok_local", "hermes_local", "hermes_gateway", - ].map((type) => ({ type, loaded: true, disabled: false })); - api.adapterModels.mockResolvedValue([]); - api.list.mockResolvedValue([{ id: "ceo", role: "ceo", status: "idle" }]); - api.getAdapterAuthSignal.mockResolvedValue({ status: "present" }); - api.getClaudeOAuthTokenStatus.mockRejectedValue(new ApiError("Not found", 404, null)); - api.testEnvironment.mockResolvedValue(pass); - api.hire.mockImplementation(async (_company, input) => ({ - agent: { ...input, id: "new-agent", status: "idle", urlKey: "atlas" }, - })); - envApi.list.mockResolvedValue([ - { id: "local-1", name: "Local", driver: "local", config: {} }, - ]); - envApi.capabilities.mockResolvedValue({ sandboxProviders: {} }); - settings.get.mockResolvedValue({ defaultEnvironmentId: "local-1" }); - settings.getExperimental.mockResolvedValue({ enableNativeRunner: true }); - settings.getGeneral.mockResolvedValue({ executionMode: "any" }); - secrets.list.mockResolvedValue([]); - secrets.create.mockResolvedValue({ id: "org-secret-1" }); - secrets.remove.mockResolvedValue({ ok: true }); - secrets.listMyUserSecrets.mockResolvedValue([]); - secrets.createUserSecretDefinition.mockResolvedValue({ id: "definition-1" }); - secrets.createMyUserSecret.mockResolvedValue({ id: "secret-1" }); - secrets.removeUserSecretDefinition.mockResolvedValue({ ok: true }); -}); -afterEach(async () => { - await act(async () => root.unmount()); - cache.clear(); - container.remove(); -}); 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) => { + const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`; + envApi.list.mockResolvedValue([{ id: "sandbox-1", driver: "sandbox", config: { provider: "daytona", image } }]); + settings.get.mockResolvedValue({ defaultEnvironmentId: "sandbox-1" }); + settings.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: true, enableNativeRunner: false }); + api.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "saved-oauth", latestVersion: 1 }); + state.adapters = state.adapters.map((adapter: any) => adapter.type === "claude_local" + ? { ...adapter, defaultRunner: "legacy", supportedRunners: ["legacy"] } : adapter); + let qualified = false; + const calls: string[] = []; + adapterApi.list.mockImplementation(async context => { + if (context?.companyId) calls.push(qualified ? "discovery-after" : "discovery-before"); + return state.adapters; + }); + envApi.probe.mockImplementation(async () => { + calls.push("platform"); qualified = true; + state.adapters = state.adapters.map((adapter: any) => adapter.type === "claude_local" + ? { ...adapter, defaultRunner: measured, supportedRunners: [measured] } : adapter); + return { ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "owned-platform-lease" } }; + }); + api.testEnvironment.mockImplementation(async () => { calls.push("adapter"); return pass; }); + await render("claude_local"); + expect(envApi.probe).not.toHaveBeenCalled(); + if (step === "Connect") { + await click("ClaudeSubscription"); + calls.length = 0; + await click("Use saved subscription"); + } else { + managedApi.list.mockResolvedValue({ currentUserId: "user-1", connections: [{ + id: "00000000-0000-4000-8000-000000000021", grantId: "00000000-0000-4000-8000-000000000022", companyId: "company-1", + provider: "openrouter", method: "api_key", name: "Company OpenRouter", ownership: "shared", isDefault: false, status: "connected", + routing: { kind: "openrouter", protocol: "messages", auth: "bearer", models: [] }, + }] } as any); + await click("AdvancedCustom Gateway"); + const picker = container.querySelector('[role="combobox"][aria-label="Connection"]')!; + await act(async () => picker.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }))); + await settle(); + const option = [...document.querySelectorAll('[role="option"]')].find(node => node.textContent?.includes("Company OpenRouter")) as HTMLElement; + await act(async () => option.click()); + await settle(); + await click("Use connection"); + expect(api.testEnvironment).not.toHaveBeenCalled(); + calls.length = 0; + } + await click("Finish setup"); + expect(calls).toEqual(["discovery-before", "platform", "discovery-after", "adapter"]); + expect(envApi.probe).toHaveBeenCalledExactlyOnceWith("sandbox-1", "company-1"); + expect(api.testEnvironment).toHaveBeenCalledTimes(1); + expect(api.hire).toHaveBeenCalledTimes(1); + const tested = api.testEnvironment.mock.calls[0][2].adapterConfig; + const saved = api.hire.mock.calls[0][1]; + expect(saved).not.toHaveProperty("runner"); + expect(saved.adapterConfig).toEqual(tested); + if (measured === "paperclip") expect(tested).not.toHaveProperty("maxTurnsPerRun"); + else expect(tested.maxTurnsPerRun).toBe(1000); + }); + + it("drops a held managed Claude probe when the same environment changes configuration", async () => { + const config = { provider: "daytona", image: `registry.example.test/runtime@sha256:${"a".repeat(64)}` }; + envApi.list.mockResolvedValue([{ id: "sandbox-1", driver: "sandbox", config }]); + settings.get.mockResolvedValue({ defaultEnvironmentId: "sandbox-1" }); + state.adapters = state.adapters.map((adapter: any) => adapter.type === "claude_local" + ? { ...adapter, defaultRunner: "legacy", supportedRunners: ["legacy"] } : adapter); + api.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "saved-oauth", latestVersion: 1 }); + let finish!: (value: object) => void; + envApi.probe.mockReturnValue(new Promise(resolve => { finish = resolve; })); + await render("claude_local"); + await click("ClaudeSubscription"); + await click("Use saved subscription"); + expect(envApi.probe).toHaveBeenCalledTimes(1); + await act(async () => cache.setQueryData(queryKeys.environments.list("company-1"), [{ id: "sandbox-1", driver: "sandbox", config: { + ...config, image: `registry.example.test/runtime@sha256:${"b".repeat(64)}`, + } }])); + await settle(); + await act(async () => finish({ ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "owned-lease" } })); + await settle(); + expect(envApi.probe).toHaveBeenCalledTimes(1); + expect(api.testEnvironment).not.toHaveBeenCalled(); + expect(api.hire).not.toHaveBeenCalled(); + }); + + it("retains the existing managed Claude platform-probe permission gate", async () => { + envApi.list.mockResolvedValue([{ id: "sandbox-1", driver: "sandbox", config: { + provider: "daytona", image: `registry.example.test/runtime@sha256:${"a".repeat(64)}`, + } }]); + settings.get.mockResolvedValue({ defaultEnvironmentId: "sandbox-1" }); + state.adapters = state.adapters.map((adapter: any) => adapter.type === "claude_local" + ? { ...adapter, defaultRunner: "legacy", supportedRunners: ["legacy"] } : adapter); + api.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "saved-oauth", latestVersion: 1 }); + envApi.probe.mockRejectedValue(new Error("Instance admin access required")); + await render("claude_local"); + await click("ClaudeSubscription"); + await click("Use saved subscription"); + expect(container.textContent).toContain("Instance admin access required"); + expect(envApi.probe).toHaveBeenCalledTimes(1); + expect(api.testEnvironment).not.toHaveBeenCalled(); + expect(api.hire).not.toHaveBeenCalled(); + }); + + it("waits for the current instance default instead of testing cached SSH discovery", async () => { + let finish!: (value: object) => void; + settings.get.mockReturnValue(new Promise(resolve => { finish = resolve; })); + const environments = [ + { id: "local-1", name: "Local", driver: "local", config: {} }, + { id: "linux-ssh", name: "SSH", driver: "ssh", config: {} }, + ]; + envApi.list.mockResolvedValue(environments); + cache.setQueryData(queryKeys.environments.list("company-1"), environments); + cache.setQueryData(queryKeys.instance.settings, { defaultEnvironmentId: "linux-ssh" }); + adapterApi.list.mockImplementation(async (context?: { environmentId?: string | null }) => state.adapters.map((adapter: any) => adapter.type === "claude_local" + ? { ...adapter, supportedRunners: ["paperclip", "legacy"], defaultRunner: context?.environmentId === "local-1" ? "paperclip" : "legacy" } : adapter)); + await render("claude_local"); + await connect("Claude"); + expect(api.testEnvironment).not.toHaveBeenCalled(); + expect(api.hire).not.toHaveBeenCalled(); + await act(async () => finish({ defaultEnvironmentId: "local-1" })); + await settle(); + await connect("Claude"); + await click("Finish setup"); + expect(api.testEnvironment).toHaveBeenCalledTimes(1); + expect(api.testEnvironment.mock.calls[0][2].environmentId).toBe("local-1"); + expect(api.hire.mock.calls[0][1].adapterConfig).not.toHaveProperty("maxTurnsPerRun"); + }); + + it("keeps the API key draft while waiting for refreshed discovery", async () => { + state.adapters = state.adapters.map((adapter: any) => adapter.type === "claude_local" + ? { ...adapter, defaultRunner: "paperclip", supportedRunners: ["paperclip", "legacy"] } : adapter); + await render("claude_local"); + await click("ClaudeAPI key"); + await fill("API key", "example-test-secret"); + const draft = container.querySelector('[aria-label="API key"]') as HTMLInputElement; + let finish!: (value: object[]) => void; + adapterApi.list.mockReturnValue(new Promise(resolve => { finish = resolve; })); + let refresh!: Promise; + await act(async () => { + refresh = cache.invalidateQueries({ queryKey: queryKeys.adapters.availability("company-1", "local-1") }); + }); + await settle(); + expect(container.querySelector('[aria-label="API key"]')).toBe(draft); + expect(draft.value).toBe("example-test-secret"); + await click("Connect"); + expect(managedApi.create).not.toHaveBeenCalled(); + expect(api.testEnvironment).not.toHaveBeenCalled(); + await act(async () => { finish(state.adapters); await refresh; }); + await settle(); + expect(container.querySelector('[aria-label="API key"]')).toBe(draft); + expect(draft.value).toBe("example-test-secret"); + await click("Connect"); + await click("Finish setup"); + expect(api.testEnvironment).toHaveBeenCalledTimes(1); + expect(api.hire).toHaveBeenCalledTimes(1); + expect(api.hire.mock.calls[0][1].adapterConfig).not.toHaveProperty("maxTurnsPerRun"); + }); + + it.each(["paperclip", "legacy", undefined] as const)("uses Claude's discovered %s default for Auto setup and hire", async (defaultRunner) => { + settings.getExperimental.mockResolvedValue({ enableNativeRunner: false }); + state.adapters = (state.adapters as any[]).map(adapter => adapter.type === "claude_local" + ? { ...adapter, supportedRunners: ["paperclip", "legacy"], defaultRunner } : adapter); + await render("claude_local"); + await connect("Claude"); + await click("Finish setup"); + expect(api.hire).toHaveBeenCalledTimes(1); + for (const config of [api.testEnvironment.mock.calls[0][2].adapterConfig, api.hire.mock.calls[0][1].adapterConfig]) { + if (defaultRunner === "paperclip") expect(config).not.toHaveProperty("maxTurnsPerRun"); + else expect(config.maxTurnsPerRun).toBe(1000); + expect(config).not.toHaveProperty("resolvedRunner"); + } + expect(api.hire.mock.calls[0][1]).not.toHaveProperty("runner"); + }); + + it("finishes ordinary native Claude API setup when the key is verified without a CLI hello", async () => { + settings.getExperimental.mockResolvedValue({ enableNativeRunner: false }); + state.adapters = (state.adapters as any[]).map(adapter => adapter.type === "claude_local" + ? { ...adapter, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" } : adapter); + api.testEnvironment.mockResolvedValue({ ...pass, adapterType: "paperclip_runner", checks: [ + { code: "acpx_runtime_ready", level: "info", message: "Runtime verified" }, + { code: "ai_connection_api_key_reverified", level: "info", message: "Key verified" }, + ] }); + await render("claude_local"); + await connect("Claude"); + await click("Finish setup"); + expect(api.testEnvironment).toHaveBeenCalledTimes(1); + expect(api.hire).toHaveBeenCalledTimes(1); + expect(api.hire.mock.calls[0][1]).not.toHaveProperty("runner"); + }); + it("records an explicit legacy choice when it matches the Windows default, then preserves it on Linux", async () => { settings.get.mockResolvedValue({ defaultEnvironmentId: "local-1" }); envApi.list.mockResolvedValue([ @@ -412,11 +410,21 @@ describe("New agent setup", () => { await click("Finish setup"); expect(api.hire.mock.calls[0][1].defaultEnvironmentId).toBe("grok-sandbox"); }); - it.each([false, true])("keeps non-Codex runner setup links gated when the experiment is disabled (cloud=%s)", async (cloud) => { + it.each([false, true])("keeps other native setup links gated when the experiment is disabled (cloud=%s)", async (cloud) => { + cache.setQueryData(queryKeys.health, { status: "ok", cloud: { managed: cloud } }); + settings.getExperimental.mockResolvedValue({ enableNativeRunner: false }); + await render("paperclip_runner", "grok"); + expect(container.textContent).toContain("This adapter is unavailable"); + expect(api.hire).not.toHaveBeenCalled(); + }); + it.each([false, true])("restores native Claude setup intent and its connection flow with experiment=false (cloud=%s)", async cloud => { cache.setQueryData(queryKeys.health, { status: "ok", cloud: { managed: cloud } }); settings.getExperimental.mockResolvedValue({ enableNativeRunner: false }); await render("paperclip_runner", "claude"); - expect(container.textContent).toContain("This adapter is unavailable"); + expect(container.textContent).not.toContain("This adapter is unavailable"); + expect(container.textContent).toContain("Claude Code"); + expect(container.textContent).toContain("Paperclip Runner"); + expect(container.textContent).toContain("Connect a model"); expect(api.hire).not.toHaveBeenCalled(); }); it("blocks direct setup links for unsupported Cloud adapters", async () => { @@ -582,14 +590,7 @@ describe("New agent setup", () => { adapterType: "pi_local", createdAgentId: "saved-agent", }); - await act(async () => - root.render( - - - , - ), - ); - await settle(); + await renderCurrentParams(); expect(container.textContent).toContain("Your agent is ready"); expect(container.textContent).not.toContain("Finish setup"); expect(api.hire).not.toHaveBeenCalled(); diff --git a/ui/storybook/.storybook/preview-base.tsx b/ui/storybook/.storybook/preview-base.tsx index ff1cadb297d..e59e6e82201 100644 --- a/ui/storybook/.storybook/preview-base.tsx +++ b/ui/storybook/.storybook/preview-base.tsx @@ -163,6 +163,9 @@ function installStorybookApiFixtures() { if (url.pathname === "/api/instance/settings/experimental") { return Response.json(experimentalSettings()); } + if (url.pathname === "/api/instance/settings/general") { + return Response.json(instanceGeneralSettingsSchema.parse({})); + } if (url.pathname === "/api/health") { return Response.json({ deploymentMode: "authenticated", localAiLoginSupported: onboardingFixtureState.environments === "local" }); @@ -483,9 +486,11 @@ function installStorybookApiFixtures() { type: "claude_local", label: "Claude Code", source: "builtin", - modelsCount: 2, + modelsCount: 4, loaded: true, disabled: false, + supportedRunners: ["paperclip", "legacy"], + defaultRunner: "legacy", capabilities: { supportsInstructionsBundle: true, supportsSkills: true, @@ -511,6 +516,8 @@ function installStorybookApiFixtures() { modelsCount: 3, loaded: true, disabled: false, + supportedRunners: ["paperclip", "legacy"], + defaultRunner: "legacy", capabilities: { supportsInstructionsBundle: true, supportsSkills: true, @@ -523,6 +530,16 @@ function installStorybookApiFixtures() { }, }, }, + { + type: "paperclip_runner", + label: "Paperclip Runner", + source: "builtin", + modelsCount: 0, + loaded: true, + disabled: false, + supportedRunners: ["paperclip"], + defaultRunner: "paperclip", + }, ]); } @@ -533,6 +550,7 @@ function installStorybookApiFixtures() { const [, , resource] = adapterModelsMatch; if (resource === "models") { return Response.json([ + { id: "claude-opus-5-5", label: "Claude Opus 5.5" }, { id: "claude-opus-4-7", label: "Claude Opus 4.7" }, { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, { id: "claude-haiku-4-5", label: "Claude Haiku 4.5" },