From adb69e8f09852c772d9901ae270e5b2d66e39c77 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:33:02 -0500 Subject: [PATCH] feat: default new Codex agents to Paperclip Runner on Linux and macOS (#15683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source control plane for agent companies. > - People choose a harness when creating an agent. > - The server should resolve that harness to a qualified runner. > - Codex is the first harness in the staged default rollout. > - Saved agents must retain their execution choices. > - New Codex agents therefore use Paperclip Runner on qualified macOS/Linux targets, with an explicit Legacy override. ## Linked Issues or Issue Description Refs #16028, #15555, #15673, #15422, #15631, #15926, #15932, #16149. The runtime/install prerequisite and release repair are merged. This slice owns Codex creation, setup, configuration and preservation. Other harness defaults remain unchanged. It adds no database migration; incoming master migrations, required maps and regression coverage are retained. ## What Changed - Resolve `runner: auto | paperclip | legacy` at the common creation boundary across onboarding, API/CLI, hires, built-ins, plugins and imports. Freeze pending hires and export resolved execution choices. - Preserve existing runners on upgrades, unrelated edits and import updates, including compatible accounts, workspace settings, Codex effort and intentional clears. - Use the underlying harness picker and branding; put the runner override in Advanced. Use the shared dropdown and update production-component stories and creation contracts. - Support qualified Apple Silicon, Intel Mac, Linux x64 and Linux ARM64. Linux requires GNU libc 2.39+. Disabled, external, unsupported and unqualified combinations retain their applicable Legacy execution. - Test the selected runtime/account/model/environment. Ordinary Connect probes eligible immutable managed targets and refreshes scoped discovery. Creation/discovery do not provision. Auth, dependency, incompatible-setting and cleanup failures remain actionable without changing runners. - Keep setup probes read-only, preserve local/SSH isolation and resolve packaged assets before staging credentials. Bound cleanup reads by the remaining absolute deadline; physically close every owned ingress socket before endpoint release. Unconfirmed cleanup retains recovery state. - Preserve validated completed-onboarding callbacks through startup recovery. First file-producing work belongs to the child task and uses the existing dependency/block handoff and task-scoped registration. - Reuse existing E2E fixtures, artifact checks, reporting and controls. Keep Docker/Cargo/native compilation remote. Paperclip tarballs contain no Codex payloads; official optional npm platform dependencies remain. ## Verification Current tested head: `1c03a8815672d82c264a9b08d4ea1984e8fae107`. **Merge-ready: required CI, fresh review, package qualification, Mac core acceptance and Test task/follow-up acceptance pass.** The existing attachment and native-continuity oracles independently verify both Test downloads, run attribution and unchanged native/provider/workspace identities. The maintainer authorized a code-owner-review exception for this PR only. The first Test setup failure and normal Board completion-review friction are retained below. Actual post-merge packages and private Cloud distribution remain separate qualification gates. - [Current CI](https://github.com/paperclipai/paperclip/actions/runs/38564241625): all 48 ordinary jobs and required verify/E2E pass. Attempt 1 retained six simultaneous host-shutdown cancellations. One failed-only retry passes those six lanes and two aggregates, preserving 40 successes. Actual integration checkout `53104c1` combines this head with master `c80`; it is not relabeled as the branch tree. Fresh exact-head Greptile 5/5, 30 resolved threads, no actionable findings. - [Exact-source package qualification](https://github.com/paperclipai/paperclip/actions/runs/38565287239): nine applicable jobs pass, nine conditional jobs skip. Four remote native producers; Linux x64 clean install; Intel Mac, Apple Silicon and Linux ARM64 native startup/physical cleanup controls. All 18 tarballs/four daemon source/hash/manifest bindings and 343 migrations through 0344 verify. No Codex payload is packed. These hosted tarballs are not a published npm canary. - Normal Apple Silicon installation and credential-free native startup/cleanup pass using the packaged daemon, with no source build or provider turn. Fresh installed DB initialization passes. Upgrade 651a→1c03 preserves all three complete execution configs, accounts, ten recorded run profiles, paused budgets, active incidents, cost summaries and two historical unpriced events. Historical recovery state is retained. - Cleanup regressions: four delayed-read baseline failures become 15 passing deadline/authority controls. No-acknowledgement and prior-error socket ownership regressions become 12 passing controls. Direct runtime/server types, maps: 35 areas / 176 IDs, diff checks and independent reviews pass. Permission/auth/reconnect policy and the 10-second setup budget remain intact. - Existing source coverage includes creation/selection/imports, pending approvals, recovery, account/lifecycle handling, mounted forms, dropdowns and E2E support. Full typechecks/tests/build/token and applicable Storybook checks pass in the recorded current CI; no duplicate suite or weakened grader. - Literal 9546 live Mac proof: ordinary Auto Codex onboarding, useful document, real completed-child callback/reply, seven successful native runs, manual follow-up and independently downloaded1,101-byte child file. Existing Legacy title edit/reload preserves execution; two real Legacy runs produce independently checked15/34-byte files. These remain historical source records. - Current literal 1c03 Mac core acceptance passes independent checks: ordinary Auto native Codex onboarding without runner override; only timeout 180 seconds/concurrency 1 saved; six successful native runs and six serialized turns (maximum overlap 1); both same-task continuity checks and five mechanical callback checks; actual rendered parent reply and exact child downloads. The useful handoff advances from revision 1 to 2. Both files are 48 bytes, SHA-256 `8b742bd4e8123a7c078f0a8d9f3449fbfa8a320d7d90be641a2818db3c53d92c` and `f42db9774fa06e804ab7d596fc8b922a2b638921c418b87d0111151d290a6afe`; exactly one byte changes and old history remains. The agent is paused with zero active runs/errors. Both artifact work products have `isPrimary=false`, and descriptive titles fail a stricter title-helper assumption; these retained results are scoped editorial follow-up, not new merge gates. Six source-bound screenshots are uploaded and verified in the private primary QA report. - [Literal 9546 managed proof](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-38485488621-1/index.html): original 19 assertions and completed judge pass, four native turns, child-bound240-byte file and session/workspace continuity. Checkout reports a verified generated target-lock mutation; full final porcelain was not retained. Signed execution-image identity remains separate from Core source. - Actual 651a Test deployment succeeds on one owned/pinned target. One ordinary Auto API-key Connect then fails native cleanup before hire. Controller turn/drain/suspend completion and service-recorded environment cleanup are observed; they do not certify physical deletion or native authority retirement. Failure/recovery/unknown holds are retained. No Legacy switch or unchanged paid retry. The two source repairs above require actual treatment. - [Current immutable Core preview](https://github.com/paperclipai/paperclip/actions/runs/38570365725) and matching DB/shared artifacts pass. One owned, pinned Test target serves exact `1c03`; identical migration history correctly skips the migrator. Ordinary Auto Codex Connect succeeds and hires native; saved native selection and QA bounds survive reload. The initial child preparation attempt fails before provider work, with actual stdout identifying `renewal_deadline`; its trigger remains unknown and the failure is retained. Normal Board **Try again** and **Resume** recover the child to Done, producing a browser-verified 44-byte file, SHA-256 `0a8458b1efcc2d7a0d815689458c4caab039a46c7c7a3e5805de6bc8b1a96ecc`. The actual parent callback reports the completed child document/file. The existing validator requires a parent-owned attachment, so recovery enters normal Review; after byte checking, normal **Approve completion** reaches parent Done. No forced status or duplicate parent file is used. The validator is unchanged from integrated and current master. Autonomous initial callback-to-Done is not proven. The same-child follow-up completes document revision 2 and publishes a second independently downloaded 44-byte file, SHA-256 `22022257300fae762784632d08d3bbe0bab61e2ce81f91864a14dcd7aa464591`; only the revision digit changes. The unchanged existing native-continuity and attachment oracles pass. All ten recorded runs are terminal: nine native successes and the retained pre-provider setup failure. The QA agent is paused with zero active runs. The pre-existing parent evidence scope mismatch is a separate follow-up, with ordinary completion review exercised rather than a forced status or relaxed validation. This unsigned Core preview remains distinct from post-merge private Cloud composition. ## Risks - Saved agents are not migrated. Explicit runner changes use normal configuration/session rules; historical runs retain their recorded execution. - Native readiness depends on verified assets and server-owned target qualification. A Legacy probe, HTTP200 or successful account save cannot prove native readiness. - Recovery exceptions remain restricted to validated completed-onboarding reports; process-loss, ownership and terminal-state safeguards remain active. Unexecuted live journeys stay open. - Linux ARM paid tasks, Windows, Alpine and 32-bit ARM remain deferred. Cold Cargo cache misses are recorded; no warm-cache speedup is claimed. - Historical invoices/physical cleanup remain unconfirmed and held. After merge, exact published npm canary, signed standard image/migrator, private composition and serving/live acceptance still require qualification. Production deployment/default promotion is not authorized. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, tools, code execution and parallel agents. Exact serving snapshot and context-window size are not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used with available 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 linked public issues or PRs above - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] I have run the stated focused tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation - [x] I have considered and documented risks - [x] All Paperclip CI gates are green on the current candidate - [x] Greptile is 5/5 with no open findings on the current candidate - [x] I will address all reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- cli/src/__tests__/agent-lifecycle.test.ts | 12 + cli/src/commands/client/agent.ts | 7 +- doc/plans/2026-10-06-default-agent-runner.md | 1668 ++++++++++++++++- maps/agent-access-map/agents.md | 41 + maps/agent-access-map/onboarding.md | 89 + maps/agent-access-map/recovery.md | 11 + maps/agent-access-map/runs-adapters.md | 82 + maps/feature-map/agents.md | 27 +- maps/feature-map/instance-operations.md | 12 +- maps/feature-map/onboarding.md | 74 + maps/feature-map/recovery.md | 11 + maps/feature-map/runs-adapters.md | 73 + packages/adapter-utils/src/types.ts | 3 + packages/adapters/codex-local/src/index.ts | 30 +- .../codex-local/src/server/codex-args.test.ts | 15 + .../codex-local/src/server/codex-args.ts | 25 +- packages/paperclip-runner/README.md | 5 +- .../src/live/live-session.test.ts | 50 +- .../src/live/runnerd-codex-transport.test.ts | 84 +- .../src/live/runnerd-codex-transport.ts | 54 +- packages/shared/src/agent-runner.ts | 45 + packages/shared/src/index.ts | 1 + .../shared/src/types/company-portability.ts | 3 + packages/shared/src/validators/agent.ts | 3 + .../src/validators/company-portability.ts | 2 + .../adapter-auth-signal-routes.test.ts | 14 +- server/src/__tests__/adapter-registry.test.ts | 26 + server/src/__tests__/adapter-routes.test.ts | 50 +- .../agent-adapter-validation-routes.test.ts | 112 +- .../agent-hire-ai-connections.test.ts | 4 +- .../agent-permissions-routes.test.ts | 74 +- .../src/__tests__/agent-skills-routes.test.ts | 63 +- .../agent-test-environment-routes.test.ts | 13 +- .../agents-pending-approval-config.test.ts | 27 +- server/src/__tests__/ai-connections.test.ts | 39 + .../src/__tests__/approvals-service.test.ts | 4 +- server/src/__tests__/built-in-agents.test.ts | 78 +- .../src/__tests__/company-portability.test.ts | 98 +- .../dev-runner-native-binary.test.ts | 9 + server/src/__tests__/dot-runner.test.ts | 2 +- .../native-status-arbiter-corpus.test.ts | 10 +- .../__tests__/onboarding-seed-route.test.ts | 29 +- .../__tests__/plugin-managed-agents.test.ts | 29 +- .../recovery-stale-issue-lock-sweep.test.ts | 89 + server/src/adapters/registry.ts | 14 +- server/src/app.ts | 2 + server/src/dev-native-runner-status.ts | 7 +- server/src/lib/agent-records.ts | 5 +- server/src/lib/agent-runner-platform.ts | 20 + .../agent-lifecycle/adapters/approvals.ts | 2 +- .../first-task/skills/first-task/SKILL.md | 7 +- .../src/realtime/runner-prp-outbound.test.ts | 225 +++ server/src/realtime/runner-prp-outbound.ts | 29 +- server/src/routes/adapters.ts | 33 +- server/src/routes/agents.ts | 84 +- server/src/services/agent-hiring.ts | 11 +- .../services/agent-runner-selection.test.ts | 406 ++++ server/src/services/agent-runner-selection.ts | 164 ++ server/src/services/built-in-agents.ts | 77 +- server/src/services/company-portability.ts | 50 +- .../explicit-native-continuation.test.ts | 2 +- .../native-runtime/runtime-mode.test.ts | 47 +- .../services/native-runtime/runtime-mode.ts | 3 +- .../native-runtime/setup-readiness.test.ts | 528 ++++++ .../native-runtime/setup-readiness.ts | 275 +++ .../onboarding-first-task-assets.test.ts | 29 + server/src/services/plugin-managed-agents.ts | 27 +- server/src/services/recovery/service.ts | 15 +- skills/paperclip-create-agent/SKILL.md | 18 + tests/e2e/chat-adapters-ui-messaging.spec.ts | 22 +- tests/e2e/new-task-picker-touch.spec.ts | 4 +- tests/e2e/onboarding.spec.ts | 88 +- tests/e2e/project-repositories.spec.ts | 1 + tests/e2e/signoff-policy.spec.ts | 8 +- tests/runner-e2e/FIXTURES.md | 13 + tests/runner-e2e/README.md | 92 +- tests/runner-e2e/SECURITY.md | 10 + tests/runner-e2e/catalog.test.ts | 23 +- tests/runner-e2e/catalog.ts | 19 +- tests/runner-e2e/completion-quality.test.ts | 8 +- tests/runner-e2e/confirmation-replies.test.ts | 16 +- tests/runner-e2e/first-task-fixtures.ts | 164 +- tests/runner-e2e/first-task-flow.ts | 307 ++- tests/runner-e2e/first-task.test.ts | 534 +++++- tests/runner-e2e/live-fixtures.test.ts | 30 +- tests/runner-e2e/live-fixtures.ts | 36 +- tests/runner-e2e/pi-controls-evidence.test.ts | 2 +- tests/runner-e2e/runner.spec.ts | 24 +- .../storybook-visual/storybook-visual.spec.ts | 16 +- .../adapters/adapter-display-registry.test.ts | 16 +- ui/src/adapters/adapter-display-registry.ts | 9 + .../codex-local/config-fields.test.tsx | 175 +- ui/src/adapters/codex-local/config-fields.tsx | 134 +- ui/src/adapters/codex-local/index.ts | 5 +- ui/src/adapters/types.ts | 1 + ui/src/api/adapters.ts | 11 +- ui/src/api/agents.ts | 1 + ui/src/api/builtInAgents.ts | 1 + .../AgentConfigForm.render.test.tsx | 222 ++- ui/src/components/AgentConfigForm.tsx | 292 ++- ui/src/components/AgentProperties.tsx | 4 +- ui/src/components/CodexRunnerSelect.tsx | 44 + .../ConfigureBuiltInAgentModal.test.tsx | 92 + .../components/ConfigureBuiltInAgentModal.tsx | 31 +- ui/src/components/NewAgentDialog.test.tsx | 85 +- .../OnboardingWizard.adapters.test.tsx | 10 +- ui/src/components/OnboardingWizard.test.tsx | 287 ++- ui/src/components/OnboardingWizard.tsx | 135 +- ui/src/components/agent-config-defaults.ts | 1 + .../new-agent/AgentBasicsDialog.tsx | 44 +- ui/src/components/new-agent/NewAgentSetup.tsx | 77 +- ui/src/components/ui/select.tsx | 104 + ui/src/lib/agent-config-patch.ts | 2 + ui/src/lib/new-agent-hire-payload.test.ts | 19 + ui/src/lib/new-agent-hire-payload.ts | 1 + ui/src/lib/queryKeys.ts | 2 + ui/src/lib/test-agent-setup.test.ts | 35 +- ui/src/lib/test-agent-setup.ts | 5 +- ui/src/pages/AgentDetail.tsx | 6 +- ui/src/pages/Agents.test.tsx | 3 +- ui/src/pages/Agents.tsx | 4 +- ui/src/pages/CompanyImport.test.tsx | 247 ++- ui/src/pages/CompanyImport.tsx | 130 +- ui/src/pages/NewAgent.test.tsx | 134 +- ui/src/pages/OrgChart.tsx | 4 +- .../agent-settings/AgentSettingsPreview.tsx | 38 +- .../agent-settings-redesign.stories.tsx | 12 + .../agents/codex-runner/creation.stories.tsx | 143 ++ .../onboarding-agent-arc.stories.tsx | 130 +- 129 files changed, 8500 insertions(+), 890 deletions(-) create mode 100644 packages/shared/src/agent-runner.ts create mode 100644 server/src/lib/agent-runner-platform.ts create mode 100644 server/src/services/agent-runner-selection.test.ts create mode 100644 server/src/services/agent-runner-selection.ts create mode 100644 server/src/services/native-runtime/setup-readiness.test.ts create mode 100644 server/src/services/native-runtime/setup-readiness.ts create mode 100644 ui/src/components/CodexRunnerSelect.tsx create mode 100644 ui/storybook/stories/agents/codex-runner/creation.stories.tsx diff --git a/cli/src/__tests__/agent-lifecycle.test.ts b/cli/src/__tests__/agent-lifecycle.test.ts index 41817823db9..73f602ab0ef 100644 --- a/cli/src/__tests__/agent-lifecycle.test.ts +++ b/cli/src/__tests__/agent-lifecycle.test.ts @@ -70,6 +70,18 @@ describe("agent lifecycle commands", () => { ]); }); + it("sends automatic selection when omitted and supports explicit runner overrides", async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + const payload = { name: "Builder", adapterType: "codex_local" }; + await run(["agent", "create", "--company-id", COMPANY_ID, "--payload-json", JSON.stringify(payload)]); + await run(["agent", "create", "--company-id", COMPANY_ID, "--payload-json", JSON.stringify({ ...payload, runner: "auto" }), "--runner", "legacy"]); + await run(["agent", "hire", "--company-id", COMPANY_ID, "--payload-json", JSON.stringify(payload), "--runner", "paperclip"]); + expect(JSON.parse(fetchMock.mock.calls[0]![1].body).runner).toBeUndefined(); + expect(JSON.parse(fetchMock.mock.calls[1]![1].body).runner).toBe("legacy"); + expect(JSON.parse(fetchMock.mock.calls[2]![1].body).runner).toBe("paperclip"); + }); + it("wraps configuration, runtime, skills, and instructions endpoints", async () => { const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); vi.stubGlobal("fetch", fetchMock); diff --git a/cli/src/commands/client/agent.ts b/cli/src/commands/client/agent.ts index 3f81da6c6b3..50e1a4388c6 100644 --- a/cli/src/commands/client/agent.ts +++ b/cli/src/commands/client/agent.ts @@ -57,6 +57,7 @@ interface AgentWakeOptions extends BaseClientOptions { } interface AgentJsonPayloadOptions extends BaseClientOptions { + runner?: "auto" | "paperclip" | "legacy"; companyId?: string; payloadJson: string; } @@ -348,10 +349,11 @@ export function registerAgentCommands(program: Command): void { .description("Create an agent from a JSON payload") .option("-C, --company-id ", "Company ID") .requiredOption("--payload-json ", "CreateAgent JSON payload") + .option("--runner ", "Runner: auto (default), paperclip, or legacy") .action(async (opts: AgentJsonPayloadOptions) => { try { const ctx = resolveCommandContext(opts, { requireCompany: true }); - const payload = createAgentSchema.parse(parseJson(opts.payloadJson)); + const payload = createAgentSchema.parse({ ...parseJsonObject(opts.payloadJson), ...(opts.runner ? { runner: opts.runner } : {}) }); const created = await ctx.api.post(apiPath`/api/companies/${ctx.companyId}/agents`, payload); printOutput(created, { json: ctx.json }); } catch (err) { @@ -367,10 +369,11 @@ export function registerAgentCommands(program: Command): void { .description("Create an agent hire request") .option("-C, --company-id ", "Company ID") .requiredOption("--payload-json ", "CreateAgentHire JSON payload") + .option("--runner ", "Runner: auto (default), paperclip, or legacy") .action(async (opts: AgentJsonPayloadOptions) => { try { const ctx = resolveCommandContext(opts, { requireCompany: true }); - const result = await ctx.api.post(apiPath`/api/companies/${ctx.companyId}/agent-hires`, parseJson(opts.payloadJson)); + const result = await ctx.api.post(apiPath`/api/companies/${ctx.companyId}/agent-hires`, { ...parseJsonObject(opts.payloadJson), ...(opts.runner ? { runner: opts.runner } : {}) }); printOutput(result, { json: ctx.json }); } catch (err) { handleCommandError(err); diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index 925dd7cca18..8575a3849b3 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -1,5 +1,411 @@ # Runner defaults: staged delivery +## Current qualification and repair checkpoint — 2026-10-13 + +Canonical branch: `codex/codex-runner-default`. Delivery: +[#15683](https://github.com/paperclipai/paperclip/pull/15683). +The published baseline `ea6c8eb5e16d61111968f633a66748b397b64a3e` has +53 successful hosted checks, two conditional Storybook skips, required verify +and E2E passes, fresh 5/5 review and 30 resolved threads. Its +[package qualification](https://github.com/paperclipai/paperclip/actions/runs/38478290686) +passes all four native producers and clean Apple Silicon, Intel Mac and Linux +ARM64 consumers. All 18 package source manifests and binary hashes match; no +Codex payload is bundled. The isolated Apple Silicon install and zero-turn native +startup pass, and fresh installed JavaScript applies all 342 migrations through +0343 with confirmed owned cleanup. These are literal baseline results, not proof +of the new repairs. Linux remains GNU libc 2.39 or newer on x64/AArch64; Windows, +Alpine and 32-bit ARM stay deferred. No warm-cache speedup or paid ARM task is +claimed. Docker, Cargo and native builds remain remote. + +The [single baseline managed attempt](https://github.com/paperclipai/paperclip/actions/runs/38478333712) +fails completion reporting and the physical child-file assertion; its +[existing report](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-38478333712-1/index.html) +remains red. Two runs execute provider turns. A third callback is falsely marked +successful during startup, before any provider turn, because its onboarding +parent is already Done. One unstarted child wake is cancelled. Recovery now +reuses the existing completed-onboarding handoff validator before applying the +issue-terminal backstop. Company, parent origin/status, assignee, completed child +and remaining children must match. Process-death and ownership checks remain. +Three reproduced baseline failures become 103 passing PostgreSQL controls across +the existing recovery suites, with canonical server typecheck passing. Owned +test clusters stop and release their slot. The native file tool correctly remains +bound to its current task: the original run saved a child document but attached +the file to the parent. First-task instructions now send file-producing work +through the child's own execution, dependency and block handoff, then verify and +link the child's result. Document-only and plan-only paths retain their scope. +Two reproduced instruction-contract failures become 15 passing existing asset +and greeting controls; these static checks do not prove model compliance. + +All seven baseline managed lease rows are released or expired with successful +cleanup records, and prior company environment settings are restored. A separate +owned-server supervisor reports incomplete descendant cleanup after server exit; +its missing nested cause remains unresolved. Lease records do not prove physical +deletion or settle unknown charges. Existing cost and cleanup holds remain. +Revised-source live acceptance must still check actual turn events, the child's +downloaded physical file, source-correlated completion, follow-up and continuity. +Do not rerun the known failing baseline or weaken its assertions. + +The normal staging preparation and exact baseline Core image build complete, but +the deployment check rejects `fleet_campaign_staging_requires_master`. Current +Cloud policy admits only actual master source ancestry, including preview DB +artifacts; a pinned, owned single target has no exception. No staging campaign +starts and no serving-candidate claim is made. A human sequencing decision is +pending: complete Mac and signed managed acceptance before a normal squash, +then qualify the actual merged revision on the owned staging stack, or use a +separate branch-friendly test environment before merge. Do not bypass this gate +or change merge method to preserve preview ancestry. The preview Core image has +verified content identity but no verified signature; the managed image signature +is separately verified. Actual private Cloud composition and fresh warm/cold +signup remain required production-readiness evidence after merge. + +Next action: independent matching-source review, affected map checks and publish +the two scoped repairs in one source commit. Freeze that head, require fresh full +CI and 5/5 review, then repeat the existing package/install and single managed +journeys on admitted artifacts. Upgrade the idle local app with full saved +configuration, account and recorded-run-profile comparisons; preserve its data. +Use a fresh ordinary installed instance for automatic first-agent task/follow-up. +The new $100 QA ceiling permits bounded messages with paid concurrency one and +180-second runs; prior unknown-charge and cleanup holds remain separate. +Existing runners never migrate. Normal protected merge is authorized once +applicable gates and the sequencing decision are satisfied. No production or +provisioning-default promotion, local heavy build, new timer or archive is +authorized. Earlier checkpoints below retain their historical source attribution. + +## Current Codex merge checkpoint — 2026-10-13 + +Current published `1173a859baa78af1d382058aa89c25920da25c3d` passes +the policy, selector and commitperclip gates and is mergeable; full checks and +fresh review remain incomplete. Two reviewed setup findings have treatments: +missing forced-Kubernetes environments stay unqualified sandbox targets, so +automatic Codex creation preserves Legacy without provisioning and explicit +native selection returns qualification guidance. Four baseline failures become +161 passing existing selection/environment controls. Local daemon resolution now +precedes credential staging; preparation errors remove only the owned pre-handoff +root, while unconfirmed probe retirement retains recovery state. Three original +filesystem failures become 96 passing setup controls. Fake credentials and service +controls do not qualify live Kubernetes or provider execution. Matching final +source review, maps and typecheck are required before publishing the fixes. + +Published `2902b72c330d37ddff5d59e07927716dc3e1517c` is blocked before +main CI by the repository's tracked-lock policy and another master conflict. +The generated dependency lock is retained as build evidence; the branch restores +master's exact tracked lock, using the existing scripts-disabled CI resolution +path instead of bypassing the gate. Normal reconciliation with +`e0f30a4c7186498b71b2637f253b3399997dac7d` preserves controller-loss +attribution, creator identity and migration 0343, voice qualification and Apps +connection controls. Both test conflicts retain the Codex runner checks and +incoming controls. All 45 built-in and 44 controller/run-state controls pass; +maps and token gates remain clean. This source requires fresh CI/review and +appropriate installed/live qualification. Cancellation is requested for the +obsolete 2902 managed workflow before a paid test job is observed; terminal cancellation and unknown +remote costs still need confirmation. No unchanged paid test is retried. + +Independent inspection verifies the literal 578 fresh Mac journey below: exactly +one child, one document revised from one to two, and the same workspace file +changed from Saturday to Sunday with both required markers. Five executed runs +succeed with no interval overlap; one terminal-child wake is cancelled before +starting. Parent session continuity and the separate child session in the same +workspace are recorded. Estimated run cost is $1.820721600; setup usage and +settlement remain unknown. Reload preserves the completed task and revision. +This manual evidence does not relabel the original managed failure or newer source. + +The reviewed fixture changes are published as +`8d65fd7fc31d8170082f30a314b87c4779f91e8a`. Before a main CI merge checkout +is created, master advances to `edcfcaf3f69abfda12fcbc63225f7346f53450aa`. +Normal reconciliation retains its repository-skill preparation, signed idle wake +deadlines, readiness errors, voice support and migrations 0341/0342. The sole +text conflict preserves master's cancelled-lock return semantics and the PR's +bounded error-code diagnostic. The dependency lock is completed for the existing +server test dependencies and incoming voice dependency, without changing prior +resolved versions or running install hooks. Frozen-lock validation, 29 existing +native-authority/runtime-skill controls, E2E no-emit, maps and token gates pass. +This combined server/UI/DB source requires new package and live qualification; +literal 578 results below remain attributed to their actual source. + +The separate fresh Mac UI journey on installed 578 selects automatic native Codex, +connects an API-key account, creates the first agent, and reaches the ordinary +opening card. Accepting its proposal creates exactly one child task with a saved +document and workspace file. A follow-up changes that existing document to +revision two and edits the same file. Independent content, recorded execution, +workspace, session-boundary and accounting inspection remains in progress. +The failed managed eval is retained separately; this manual journey does not +turn its failed oracle green. All paid work remains bounded by the new $100 +allowance, 180-second runs and single-provider concurrency, with prior holds kept. + +Canonical branch: `codex/codex-runner-default`. Delivery PR: +[#15683](https://github.com/paperclipai/paperclip/pull/15683). +The runtime/install prerequisite #16028 and release repair #16149 are merged. +The finish line remains ordinary native Codex onboarding, checked task/follow-up, +saved-agent preservation, exact-candidate installation and required checks, then +a normal protected merge. Production deployment and default promotion remain +outside this merge campaign. + +Reconciled head `d68f1facf0e174a4f8a771a2055e02c72a47c289` receives a fresh +5/5 review with all 28 threads resolved. Its required E2E check passes, but the +aggregate verification check fails two test shards. A retained company fixture +collides because it truncates random UUIDs to six characters; full-UUID seed +treatment passes 255 existing PostgreSQL controls with zero skips. The existing +Runner durable-progress case lacks the diagnostic evidence needed to identify +its failure. Bounded failure diagnostics retain the original error, all deadlines, +usage, recovery and deduplication assertions. One exact local case passes; +controlled delayed-save and early-admission failures show why that pass cannot +prove the original cause. Independent review approves these two test-only edits. +The next published head still requires its own CI and review. + +Literal 578 remote qualification now passes all four producers and the maintained +Linux, Apple Silicon, Intel Mac and Linux ARM64 install scopes. All 18 tarballs, +source manifests and binary hashes match; no Codex payload is bundled. Native +consumer reports cover zero-turn open/close and cleanup, not paid ARM execution. +Fresh local installation/native/CLI controls pass. Its fresh DB helper fails +before migration with missing bootstrap stderr; its original cause remains unknown. +The ordinary existing-data restart separately reports exhausted Mac shared-memory +IDs. Releasing exactly two dead, unattached task-owned fixture segments restores +the app without changing host settings or other services. Installed 578 applies +migration 0340, reaches recovery ready and preserves three full agent execution +configurations, eight recorded run identities and safe account summaries, with +zero provider work. This proves upgrade preservation, not fresh initialization. +A separate ordinary installed CLI entry then starts with a new isolated home +and empty data, applies all 339 packaged migrations including 0340 and reaches +recovery ready with zero companies and provider actions. This proves fresh +startup after the owned resource cleanup; the original test-helper failure and +its missing cause remain recorded. + +The original managed child-task failure remains red. Explicit first-task skill +invocation and delivery source are recorded, but the retained trace cannot prove +the model received the child-rule bytes or the complete dynamic-tool schema. +Two rejected human-input calls recover through the ordinary API; their rejected +argument shapes are not retained. Causality remains provisional. The fresh staging +image and protected check pass at literal 578; deployment is blocked by an already +active workspace deployment, with no campaign started by this QA request. Wait +for that operation before any explicit normal deployment retry. No production +or provisioning-default action is authorized. + +Historical literal candidate `57881c607fcea782ac30f601b768b2142fc2184b` +passes all required checks: 53 successful checks, two conditional Storybook skips, +fresh 5/5 automated review and 28 resolved review threads. The existing hosted Linux +login/reload case passes against its recorded CI merge. The previous failed +attempts below remain historical evidence. + +Normal reconciliation with master `7eea9d8214fec00dfde32bda7fcf9b20a428e330` +accepts its source-annotation Storybook/map migration. All 263 authored suites and +2503 story identities, exports and play counts remain intact. Retired aggregate +files and their review history remain in Git; current indexes regenerate as +ignored outputs. Durable creation/onboarding annotations describe fixture limits. The 28 +existing tooling controls, UI typecheck, token gates and 35-area/175-ID map check +pass. Independent inspection finds no change to native execution, defaults, +onboarding, credentials, workspace, DB, CLI or packaging source relative to 578. +The isolated GitHub review-heading change and Storybook build preparation retain +their incoming master source. Fresh published-head CI/review is still required. + +Literal 578 [remote package qualification](https://github.com/paperclipai/paperclip/actions/runs/38464237723) +has completed successfully. The [single managed onboarding attempt](https://github.com/paperclipai/paperclip/actions/runs/38464212481) +passes automatic selection, saved environment qualification, native authentication, +hire and reload, then completes two successful native runs with session continuity. +It fails the accepted-child assertion: the model puts its document/file on the +onboarding parent and finishes that parent despite the delivered first-task skill +requiring a child. Preserve this candidate failure; it is not a passing E2E cell +or complete task/follow-up proof. The owning verifier records six terminal lease +rows with successful cleanup, of which four appear in the selected report; +physical deletion and unpriced usage remain unsettled. + +The former staging QA stack is archived with a decommissioned database. Its +normal preview preparation rejects the lifecycle before deployment. A new owned +QA workspace has been provisioned through the ordinary signup path; it still +needs candidate deployment and live verification. Previous preview/runtime proof +is historical. Heavy builds remain remote, existing-agent data stays preserved, +and no production deployment, default promotion or timer is authorized here. + +Published `e301c68d645eabd9fa7ae3a9450a460f6d2d0439` passes required CI with +52 successes and two conditional skips. Its fresh review finds one linked-login +setup defect. Literal executable source `6c0eca871616a110954553d6ff971e7655c85f93` +passes [remote package qualification](https://github.com/paperclipai/paperclip/actions/runs/38445419339), +including all four producers and clean Apple Silicon, Intel Mac and Linux ARM64 +consumers. Fresh installed JavaScript initializes migration0339. The existing +Mac app upgrades while preserving three full agent configurations, eight +historical runs and saved account summaries, with no provider work during upgrade. + +The [managed6c attempt](https://github.com/paperclipai/paperclip/actions/runs/38448001365) +passes the saved environment probe, native account/model hello, native hire and +campaign bounds. A legitimate reload then returns onboarding to step one before +Get started. Both setup leases and fixture cleanup pass; no task/artifact/follow-up +is reached. Preserve this failed attempt. The production draft-restoration fix +passes192 existing/new controls and independent source review, including delayed +loading, explicit fresh starts, old runner choices and company/account boundaries. +The managed fixture keeps its reload assertion. The separately scoped linked-login +fix stages checked credentials only for isolated setup and keeps the general +no-follow guard, refresh target and bounded retirement contract. Its original +source passes both regular-file controls and fails both linked-file controls +after the real lower materializer omits authentication. The matching treatment +passes69 setup controls and12 unchanged lower controls, server no-emit and +independent source review. Private copies retire only after confirmed cleanup; +an uncertain close retains owned state and remains a failure. + +The reviewed fix commit is `12046f522bca29ded24425be71b856993b4997f7`. +The normal merge retains exact master `393d1c410862aa93561548e0a39feec3b89df6ff`, +including migration0340, shared/plugin lifecycle contracts and file recovery. +Independent source inspection confirms all four reviewed fix hashes and the +incoming executable changes. Storybook reconciliation retains263 suites and2503 +authored IDs, both review histories and all four incoming recovery states. Its +27 existing controls, maintained catalog check and35-area/175-ID map check pass. +Only JavaScript dependencies are prepared locally; native/image builds stay remote. + +The combined736 source passes368 focused checks across10 files with zero final +skips. The initial sandbox listener failures are preserved separately; unchanged +support probes and owned loopback execution reach the affected tests successfully. +Incoming master `c7f4b74111d16f0b57112fa6d0b6a59c2d63bfe8` then adds fresh-task +workspace source isolation. Its eleven server files remain exact master, and281 +affected source/trust/preparation/runtime controls pass without repeating the368. +The catalog retains263 suites/2503 IDs and both histories. + +An actual task bootstrap check finds a narrow compatibility mismatch: an explicitly +configured or operator `CODEX_HOME` with linked auth can pass setup while task +isolation omits that link. Ordinary new defaults create no such link; regular host +logins and server-selected managed account homes seed correctly. Keep the general +no-follow guard. Use existing server-owned managed credential provenance to reject +the unbound linked case with explicit Legacy/managed-account guidance, while +retaining checked setup seeding for resolved accounts. The existing fake bootstrap +stops before provider launch; it is not live account proof. The existing trusted +outer managed credential-home context closes this mismatch without a new schema +or permission field. Four linked cases reproduce false setup success before the +guard;81 final setup controls, server no-emit and independent source review pass. +Regular homes, API-key precedence and resolved-account setup aliases remain +supported. Raw configuration cannot provide the trusted provenance. + +Published `1d09569b054e0ad9f4bc9e4d92ad1212d4794f7c` retains that combined +source. Its exact-head review is4/5 with one additional selected-key precedence +finding: `CODEX_API_KEY` still enters subscription validation when the task +materializer would use that key directly. Six local/remote selected-key controls +reproduce the failure; six `OPENAI_API_KEY` controls retain their behavior. The +two-line correction skips unrelated subscription reads and refresh binding for +either selected key. Frozen treatment passes93 setup controls,12 unchanged lower +controls, server no-emit and independent source review. The first93-control run's +91 passes and two import-deadline/follow-on failures remain recorded; the unchanged +standalone run passes without extending a deadline or weakening an assertion. + +The [1d CI run](https://github.com/paperclipai/paperclip/actions/runs/38459704117) +passes build, typecheck and native verification but has two independent blockers. +The onboarding browser case expects the obsolete return to the Agent step. Its +captured screen correctly restores Connect, Ada and the original authorization +link. Update that existing case to verify the exact active session, company, +name/harness/Auto choice, one login start and no pending-login hire through both +route reopening and actual reload. Independent source review passes. Local +baseline reproduces the old assertion; the final local attempt cannot initialize +its fresh PostgreSQL cluster before browser execution. Preserve that failure and +require the existing hosted Linux E2E case to prove treatment. The chat shard +loses its hosted runner connection; no failing chat assertion or test log is +available. A new ordinary current-head CI run must replace that incomplete gate. +No new image, package or paid live campaign was dispatched during these fixes. + +Next action: publish the reviewed narrow corrections together. Require fresh +CI/review, affected installed-package and managed first-task proof before ready +or merge. Historical receipts keep their literal sources. The following +checkpoints are retained history, not the current merge state. + +## Production preparation checkpoint — 2026-10-13 + +The runtime prerequisite [#16028](https://github.com/paperclipai/paperclip/pull/16028) +is ready for review at `271d434c170e74bdedde65d0b13059a55419c943`. +[CI attempt 2](https://github.com/paperclipai/paperclip/actions/runs/38346601686) +passes 54 checks with four conditional skips; fresh Greptile is 5/5 and all four +threads are resolved. The first attempt's bounded Pi Node archive timeout is +retained. The unchanged-source retry passed without relaxing source or deadlines; +its underlying network/host cause remains unknown. The passing CI installation +records merge source `7a82a37438e6e448abc7cc5caa60dc2519a97be0`, distinct from the +PR head. The [final literal-source package campaign](https://github.com/paperclipai/paperclip/actions/runs/38347027535) +passes all four daemon producers, public Linux assembly and Apple Silicon, +Intel Mac and Linux ARM64 clean consumers. The Linux ACPX component probe uses +explicit installed authority; normal Mac/ARM native lookup rejects authority +overrides. Neither establishes final cloud onboarding/task acceptance. Small +report ZIP downloads did not complete in the browser and the CLI token is invalid; +no independent current-report rehash is claimed. + +GitHub's normal ready-PR gate still requires a code-owner review. On 2026-10-13, +normal protected squash auto-merge was enabled for exact `271d434c`; approval +remains pending and no slice was merged. The user authorizes normal protected sequential merges, +not admin bypasses, production deployment or provisioning-default promotion. +[Codex #15683](https://github.com/paperclipai/paperclip/pull/15683) remains clean +at `1a0ca66242121e39d88455aa2b52a07acc4a07d9`, based on the historical `5a9ce11`. +The parent has advanced and GitHub reports conflicts. Reconcile with the actual +merged parent before publishing prepared fixes or claiming final checks. The +Claude owner retains its branches, private installed-authority correction and +separate $50 qualification ceiling. Those fixes need package and live acceptance; +root does not rewrite the Claude branches. + +Fresh ordinary managed onboarding currently lacks the saved platform probe +before Auto resolution; an unqualified immutable OCI environment selects Legacy. +The prepared Codex-only UI correction reuses the existing permission-gated saved +probe on explicit Connect, refreshes scoped discovery and validates the selected +runtime. It retains actionable failures, explicit Legacy and current creation/ +discovery non-allocation. The existing explicit-only Product E2E cell gains fresh +wizard-owned qualification, two authoritative setup lease IDs, independently +checked physical-file output and follow-up continuity. Its fixture does not +create an agent or preprobe. Missing responses preserve unknown cost coverage. +No new suite, grader, automatic roster or local-prompt replacement is introduced. + +The privately composed source passes 44 targeted onboarding controls and 184 +existing Product E2E support controls, UI/E2E no-emit and guarded patch +applicability. The earlier full wizard controls pass 117 cases at their recorded +source. Independent review found an unanswered-setup accounting gap: four cases +fail before the fix; 117 first-task controls pass after. Comment cleanup preserves +353,839 composed executable tokens and semantic ASTs. Required onboarding maps +retain IDs, actual probe authority and source-only/not-run limitations; structural +checks pass 35 areas/171 IDs. Storybook metadata must be reviewed after actual +parent reconciliation; its catalog/script are absent from literal `1a0ca662`. +This private preparation is not integrated, published or live-qualified. + +A private reconciliation rehearsal composes exact Codex `1a0ca662` with exact +prerequisite `271d434c` without modifying either published branch. It retains +master's render helper, moved Storybook paths and additional catalog cases; the +managed onboarding cell is added to the existing catalog with 730 total cases. +The rehearsal is not an actual post-merge candidate and does not close hosted, +installed-package or live Cloud acceptance. Its narrow checks and source manifest +remain private until normal source reconciliation is authorized by the actual merge. + +The private root review reproduced four cache failures under production +`staleTime: 30_000`: setup omitted fresh availability reads, hid a discovery +error, and skipped qualification after an image change. Both managed Codex +setup reads now set `staleTime: 0`. The same existing controls cover cold and +production-cache defaults; 53 selected UI controls and matching-source UI +no-emit pass on Node 24.19.0. Earlier 44-case results remain tied to their +pre-correction source. This correction is private and requires ordinary +published-head review and full qualification after the protected parent merge. + +Cloud source `23531d729756ab0461a53f9358beb8df5c1781ed` uses named Daytona +snapshots without a verified final boot-image binding for fresh-company native +qualification. A read-only metadata request was rejected before execution because +automatic approval review requires explicit use of the particular API key. +Authorization is pending; no key was read or provider request sent. The expired +GitHub device code needs a new attended sign-in when the human is present. +Production Fleet Admin sign-in and a concrete current QA owner grant also remain +pending. The existing QA tenant remains pinned/user-managed and unqualified; +old auth probes and preview images cannot close the live gate. + +The original $250 ceiling and $248.179254104 held/consumed ledger remain intact; +unknown charges and cleanup holds are not certified invoice balance or available +funding. Codex retains its bounded $2.50 setup/two-task-turn reservation plus $2 +cleanup. The expanded manual first-task cell has no separate paid reservation. +No provider turn, paid environment allocation, local Docker/Cargo/native build, +rollout or default promotion occurred in this preparation. The preserved local +preview remains `http://127.0.0.1:3125/COD/agents/all`, literal behavior source +`fe1ecf1c3449c21d6e6f0c9d9772d1949b80ea82`; its data and recorded runners remain +untouched. Root does not upgrade existing agents. + +Next gate: obtain actual code-owner review and restored GitHub CLI authentication, +then perform the normal exact-head parent merge and reconcile Codex/Claude with +its actual master commit. Compose these prepared fixes, retain required maps and +fresh review/checks, resolve the Cloud boot-identity boundary through authorized +metadata, and qualify the final immutable managed candidate. Actual private +Cloud distribution, warm/cold signup, useful task/follow-up, existing-agent +preservation and production-image rollback/forward-recovery checks remain open. +The stack is not production-ready and production deployment remains unauthorized. + +Evidence: `/private/tmp/pr2-271d434-final-qualified-ready-receipt.json`, +`/private/tmp/pr2-managed-onboarding-accounting-root-review.json`, +`/private/tmp/ci-artifacts-271d434-installed-native-lookup-audit-receipt.json` and +private composed source manifests. Public PR bodies contain the exact current +checkpoint; historical receipt sources remain literal. + + Updated 2026-10-13 UTC. ## Fresh cloud onboarding remains unqualified @@ -283,6 +689,166 @@ remote package/immutable preview and dedicated staging qualification. Merge read slices normally in the agreed order; actual production readiness requires the private distribution and rollback/forward-recovery evidence afterward. +## CI fixture, network policy and map synchronization checkpoint + +Runtime prerequisite source is `5a9ce11c49db31440863d633426ebe30bcc850ca` +with 59 changed files. The hosted static check on literal Codex source `eee0534` +passed 133 cases and failed one: its isolated assembly fixture omitted the new +target JSON, so the helper failed before its existing Cursor-identity assertion. +The fixture now copies that exact inert JSON; all negative assertions remain. +Baseline provider-pack controls reproduced eight passes and one failure; +treatment passes nine, with 16 related provider/assembly controls passing. +No local Cargo or Docker build was used. + +Codex behavior source `fe1ecf1c3449c21d6e6f0c9d9772d1949b80ea82` fixes the +fresh no-network P1. Effective Legacy ACP restrictions, including plain or +unresolved secret bindings, now fail native selection with field-specific Legacy +guidance. The unused flag for an explicitly selected CLI, existing Legacy/native +execution, and trusted server-owned native network policy retain their behavior. +Five failures reproduced before correction; all 112 selection controls, canonical +server no-emit, and independent source review pass. No secret was read or native +network authority translated to an agent setting. + +The replacement user AGENTS.md requires synchronized feature/access maps in the +same change. Eight existing documents now describe the affected behavior and +access limits: the environment pair belongs to the prerequisite; agents, +onboarding and runs/adapters pairs belong to Codex. The Codex review is 105 files, +comprising its original 99 plus six required map documents. Preserve required +documentation and useful tests rather than satisfying the lower-priority former +100-file preparation guideline by dropping them. IDs, original baseline review +scope and indexes remain intact. Both structural checks pass 35 areas and 171 +capability IDs. Scoped source references are separate from live proof; all new +agent/provider map recipes remain explicitly source-only/not-run. Independent +map and fixture review is clear. + +The [local preview](http://127.0.0.1:3125/COD/agents/all) serves this behavior +source as `2026.1005.0+407.git.fe1ecf1c34`. Four complete execution configurations +and two historical native runs survive the controlled restart unchanged, recovery +is ready, active runs are zero, and the deprecated flag remains false. Receipts: +`/private/tmp/pr2-fe1e-preservation-{before,after}.json`. Staged daemon preflight +required no source build. Provider actions, Cargo builds and Docker builds are +zero. This record's subsequent commit changes documentation only. + +Current-source full CI and fresh review remain gates. Prior `eee0534` hosted +typecheck, build, Rust, Canary and eight E2E shards passed, while aggregates were +incomplete; those results are not relabeled as the new head. Prior prerequisite +`d16eccf` had fresh 5/5; the new source requires review again. The Claude owner +is carrying these shared changes and owns its separate fixture corrections and +verified explicit effort-clear semantics. No final image, merge or production +deployment has occurred. Managed onboarding/task/follow-up remain pending. + +## Codex configuration review closure checkpoint + +Runtime prerequisite [#16028](https://github.com/paperclipai/paperclip/pull/16028) +advances to `d16eccf109b524778ceb896f79ccb3881bc61a9b`, with 57 files. Its small +shared builder correction preserves saved or selected Codex reasoning effort +without adding Codex settings to other providers. All 52 existing builder tests +pass in the actual prerequisite checkout. The earlier `cae179` fresh 5/5 review +is retained historical evidence; this new source requires fresh checks/review. + +Codex behavior source is `b63b779fe141ffe8db6d200580335de84ee78651`, on +`codex/codex-runner-default`; [#15683](https://github.com/paperclipai/paperclip/pull/15683) +retains 99 changed files. Review reproduced eight selection failures and six +import/builder failures before correction. All six actual ACP aliases now follow +Legacy precedence before native compatibility checks. Unsupported settings name +the effective source field and offer explicit Legacy; Legacy itself is unchanged. +Native imports retain hidden runtime settings and account bindings through +ordinary edits. Explicit clears/removals and intentional harness changes remain +effective. Existing controls pass: 100 selection, 91 UI, and 52 builder; counts +can overlap. Canonical server/UI/adapter no-emit, token gates, and independent +source-matching reviews pass. No new suite, schema, or API was introduced. + +The [local test-drive](http://127.0.0.1:3125/COD/agents/all) restarted on this exact +behavior source and serves `2026.1005.0+403.git.b63b779fe1`. Startup recovery is +ready, active runs are zero, and all four complete legacy/native execution +configurations and two historical native run records match before/after. +The deprecated flag remains false. Prefix-specific receipts are +`/private/tmp/pr2-b63b-preservation-{before,after}.json`. The staged Mac daemon +passed preflight with `sourceBuild=false` and `needsBuild=false`; startup used +JavaScript preparation only. Provider actions, Docker builds, and Cargo builds +are zero. This checkpoint's subsequent commit changes only this dated record. + +Current-head full CI/review and managed onboarding/task/follow-up remain required. +No merge or production deployment has occurred. Keep expensive final previews +deferred until the prerequisite merges and Codex is reconciled with actual master. +The authorized Claude owner is coordinating its dependent PRs and existing +bounded evidence; root retains normal sequential merge ownership. + +## Metadata review closure checkpoint + +Runtime prerequisite source is `cae179c21412048d08aeb94e0fe8b0f7844119de`, +with 55 changed files; Codex remains 99 files. Two fresh metadata review P2s +are fixed: explicit null clears user metadata, and saves preserve only the +latest private platform proof through an atomic database update. Config/driver +updates invalidate proof in the same write; status cleanup retains its existing +archive-token behavior. Public callers cannot supply the proof. + +The three existing environment suites pass 83 controls. Seven focused tests +exercise real PostgreSQL writes between request preparation and save; there are +42 name-filter exclusions and zero host-guard skips. Both canonical server +no-emit commands pass. Source hashes match in both worktrees and independent +review is clear. A separate read-only audit confirms no actual company/team +import bypass or proof export: portability does not copy environment rows, and +environment API responses strip the private subtree. No new infrastructure, +provider turn, Docker/Rust build or schema/API change was introduced. + +Fresh exact-head full CI/review remain required. The verified Mac preview restart +at `64d0746f8a` preserved four execution configurations and two historical native +runs with the deprecated flag false and no provider actions. Refresh the idle +preview to the next recorded head; old measurements retain their literal source. +Merge the qualified prerequisite first, then reconcile Codex with actual master +before the final remote image and managed acceptance. No merge has occurred. + +## Fixture closure checkpoint + +The runtime prerequisite advances to `093b97aa12` with 54 files. The Codex +review remains 99 files. Existing managed fixtures now run the saved environment +probe before native-generation Codex creation and inspect server defaults only +for automatic requests. The owned setup probe lease joins the existing runtime +estimator, with unrelated leases excluded from task attribution. The fixture +contract records one setup lease per eligible cell, zero provider turns, zero +retries, a 330-second HTTP deadline and a 10-second platform command deadline. +Archive/release, expiry and failure-cost holds remain unchanged. + +Both worktrees pass 36 existing fixture/billing controls and canonical E2E +no-emit. Existing catalog/report controls pass 46 cases, and core compatibility +discovery succeeds. Affected adapter discovery/setup routes pass 69 controls. +Unknown sandbox facts now expect Legacy; measured GNU2.39 ARM64 expects native. +All checks use fixtures, not paid provider proof. Exact-head hosted CI/review and +real managed onboarding/task/follow-up remain pending. No merge has occurred. + +## Corrective source checkpoint: ready for fresh CI and review + +Runtime prerequisite source is `cf19e086ae4a0142c8b4314771c55f73b294805b` +on `codex/runner-native-platform-prerequisites`, with 50 changed files. The +Codex-default branch `codex/codex-runner-default` is composed on it and retains +99 changed files. Both reviews remain under 100 files. All four original review +findings and the relevant independent-review follow-ups are fixed. + +Existing controls pass: release workflow 70, assembly 7, registration 15, +measured environment/probe routes 32, UI permission/forms 207, and selection/import +140. Counts describe separate runs and can overlap. Canonical server, runner and +UI no-emit checks and token gates pass. Source-matching independent reviews +confirm cleanup, immutable-image binding and saved Legacy permission handling. +No local Docker/Rust build, new provider turn, or agent migration was used. + +Remaining gates are fresh exact-head full CI/review and managed Codex acceptance. +The existing managed eval catalog already requires OCI digests. Its image-backed +fixture helper must use the existing explicit environment probe before automatic +Codex creation and inspect the stored default; explicit-runner coverage remains +separate. First-task onboarding fixtures are local-only, so fresh managed +onboarding still requires live proof. The pinned fleet tenant currently uses a +name-only snapshot and cannot establish native-default compatibility. Use a +qualified immutable image-backed QA environment, preserve the tenant pin, and +verify the serving commit and independently checked task/follow-up artifacts. + +Merge the prerequisite after its applicable checks pass. Then reconcile the +Codex PR with actual master, verify its final source, and build the immutable +preview and DB/shared pair remotely once. All three cancelled previews remain +excluded. The original budget and cleanup holds remain unchanged. Claude owns +its separate bounded proof and downstream attestation reconciliation. No merge +or production deployment has occurred at this checkpoint. + ## Final review checkpoint: shared fixes and sandbox qualification The user authorizes normal sequential merges after the applicable gates pass. @@ -384,13 +950,586 @@ The authorized Claude chat owns its verification and fixes; this task coordinate normal squash merges after applicable gates. The older Grok Cloud PR is an independent lane, with its own missing snapshot-cleanup access and live proof. -Next action: open the extracted prerequisite, compose the preserved default -branch on it, require current-source CI/review, and remotely prepare the exact -Codex staging image and DB/shared pair. The existing QA tenant remains pinned to +The extracted [runtime prerequisite #16028](https://github.com/paperclipai/paperclip/pull/16028) +is open at `ddcd746eb6a01f2bbe9b4adeec238ba35bde5adb`. The default branch is +composed on it with all 134 owned implementation/test files identical to +`bb7591dfd0`; this record retains every historical checkpoint. +Next action: require current-source CI/review and remotely prepare the exact +composed Codex staging image and DB/shared pair. The existing QA tenant remains pinned to old `2dbe43a4`. Managed Codex retains its $2.50 setup/task envelope and $2 cleanup; historical unknown holds remain within the original $250 ceiling. Claude has its separately authorized $50 ceiling. Missing live proof remains unresolved. + +## Retained checkpoint: ARM qualification and master integration + +The Rust daemon, protocol, Codex driver, creation policy, and provider +requirements remain unchanged from qualified source +`a200495cc7488f4d59da570407e516cf702e5af8`. Its verified 18-package archive +retains that literal identity. All four hosted daemon producers, Linux x64 clean +installation, and both Mac installed startup/cleanup consumers pass. The original +unprepared ARM failure remains recorded; qualified ARM startup passes with stock +scoped OS prerequisites and no global restriction change. + +The [committed-helper consumer](https://github.com/paperclipai/paperclip/actions/runs/38290234153) +passes using tooling `0690801` and literal `a200495` packages. Receipt: +`/private/tmp/ci-final-0690801-integrated-arm-receipt.json`. +Fresh review at `a5b1683` is 5/5, with all 16 threads resolved. That correction +loads the helper from the immutable workflow definition rather than an older +frozen product checkout that does not contain it. A separate sparse checkout +uses `job.workflow_repository`/`job.workflow_sha`; both source identities and +the helper digest enter the existing report. No mutable branch or caller-source +fallback is used. The original product consumer and archive guards stay intact. + +The [actual final workflow wiring](https://github.com/paperclipai/paperclip/actions/runs/38291535914) +also passes: immutable helper checkout, stock prerequisite preparation, native +session, default daemon, read-only model context, and joined cleanup. Its three +critical steps are byte-preserved from the delivery workflow. Literal product +source is `a200495`, definition base is `a5b1683`, and actual tooling source is +`8eb3595a`. All 18 packages and lock/daemon/official-launcher/native-payload +hashes remain unchanged. Global restrictions stay 1/Y/1; scoped profiles stay +enforced. No overlay, direct-control substitute, Rust rebuild, credentials, or +provider turns were used. Receipt: +`/private/tmp/ci-final-a5b1683-final-wiring-receipt.json`. +Normal OS APT installation and paid ARM task/follow-up remain unproven. + +Master `82274853729f8c5b0f812da08595020f7eb64d66` requires one test-fixture +conflict resolution. Its shared test-home cleanup replaces the duplicate +traversal. Real Mac Node 24 tests expose `ENOTEMPTY` for read-only descendants; +the shared helper now retries only after restoring owned directory permissions. +Unrelated errors remain visible; symlinks and immutable file modes stay intact. +All 84 fixture/selection controls, server typecheck, 80 merged workflow/cache +controls, and token gates pass. Both master's general release-crate cache and +this branch's native producer dependency cache remain. Receipts: +`/private/tmp/codex-default-master-home-cleanup-fix-receipt.json` and +`/private/tmp/codex-default-master822748-readonly-audit.json`. + +Master's heartbeat extraction updates server orchestration. Historical frozen +package startup and useful-task evidence remain component proof with literal +revisions; they are not relabeled as complete current-server onboarding proof. +The last verified preview restart serves `2026.1005.0+352.git.0690801f13` at +[3125](http://127.0.0.1:3125/COD/agents/all), preserving all four agent +configurations and both historical native runs. The downloaded Apple Silicon +daemon requires no source build or binary override. + +Fresh review at `665c155` reports three valid findings. Remote sandbox setup and +execution now share the existing actual-target OS/architecture resolver rather +than selecting the Mac controller's daemon. Thirteen new baseline failures +reproduce the issue; 124 target/setup/backend and retained-artifact/version +controls pass after the fix, with server typecheck. Header, digest, preinstalled +artifact, and remote build-metadata verification stay strict. Foreign artifacts +are read/hashed on the controller and executed through the remote launcher. +Local and non-Codex paths remain unchanged. Receipt: +`/private/tmp/codex-default-sandbox-target-fix-receipt.json`. + +Unchanged saved setup choices now bypass only new-selection resolution, retaining +later secret, company/environment, authentication, and runtime validation. The +actual disabled legacy baseline returned 422 before its probe. Treatment passes +175 existing controls plus a native-provider switch negative, with server +typecheck. New, explicit, draft, and changed-harness choices keep the disabled +adapter policy. Receipt: `/private/tmp/pr2-disabled-saved-codex-owned-receipt.json`. + +The actual served Codex guide reproduces stale Mac/ARM legacy text at `665c155`. +It now uses the shared GNU libc floor, lists qualified Linux/Mac architectures, +distinguishes legacy fields, and preserves existing-runner and failure semantics. +Adapter typecheck passes; the updated served guide needs final-preview proof. +These corrections update server setup/execution and guide source. The qualified +`a200495` package startup and historical task evidence retain their literal +component boundaries; no new managed live proof or paid turn is claimed. + +Fresh review at `e79254b` verifies the preceding fixes and reports one valid +development override edge. The common remote resolver now preserves +`PAPERCLIP_RUNNER_BINARY` only when the measured target OS/CPU matches the +controller. Explicit remote overrides retain priority; every selected artifact +still requires a matching native header. Cross-target selection ignores the +controller override. Seven new baseline failures reproduce the defect; 133 +target/setup/backend/retained/version controls and server typecheck pass after +the correction. Receipt: +`/private/tmp/codex-default-same-host-override-fix-receipt.json`. + +The `e79254b` preview restart preserves all four configurations and both +historical native runs. Its actual served guide now reports the shared GNU floor +and qualified Mac/ARM targets. No native build, Docker build, or provider call +runs locally. Main CI's selector remains unallocated; other PRs share this queue. +No environment approval, owned concurrency conflict, or routing failure was +found. Org capacity versus GitHub allocation is unconfirmed. Receipt: +`/private/tmp/pr2-e792-selector-queue-investigation.json`. Required final CI +aggregates and fresh review remain pending, not passed. + +Next action: require fresh CI/review on the final corrected candidate, refresh the +matched preview, and publish final conclusions in the canonical PR's evidence +index. Human code-owner approval, managed staging onboarding, and actual +post-merge Cloud composition remain outstanding. No merge or production +deployment is authorized. Historical unknown costs and cleanup holds remain +reserved; all Dockerfile/native builds stay remote. + +## Current amendment: remote build performance and Linux baseline + +The user requires Cargo dependency caching and overlapping JavaScript/native +preparation in the existing hosted jobs. Dockerfile and native builds must stay +off this Mac. Reuse the current child supervisor for concurrent commands and +failure/cancellation cleanup; join both outputs before source, header, signature, +and hash recording. Cache dependency crates only, keyed by target, OS baseline, +compiler, lockfile, and external contract inputs. Only canonical exact-source +master pushes may save trusted caches. Preview jobs restore only. Seventy-nine +workflow/process controls pass; actual speed or cache hits remain unproven until +the final hosted run. Receipt: +`/private/tmp/pr2-native-cache-parallel-owned-receipt.json`. + +The exact `dcdf09a0f33536fa2b6daaf6e731d921d5711282` four-target qualifier +[38268767165](https://github.com/paperclipai/paperclip/actions/runs/38268767165) +has built all four actual daemons. Linux package installation passes, as does +the clean Apple Silicon consumer. ARM64 installs successfully but native session +startup fails; cleanup masked the original cause. Ordinary CI passes at that +source. Fresh review found a target-daemon +selection mismatch for cross-architecture SSH: an x64 controller could select +its own daemon for an ARM64 environment. The existing resolver/setup/execution +seams now select and verify the actual target header. One hundred four affected +controls and both typechecks pass. Setup and execution share the fix; missing or +wrong artifacts fail explicitly. Receipt: +`/private/tmp/pr2-native-ubuntu-ssh-owned-receipt.json`. + +The existing installed startup fixture now preserves the primary error and +separately records cleanup failure. Fourteen controls and E2E typecheck pass. +Docker isolation, no-network policy, and zero provider turns stay unchanged. +Use the exact failed package artifact `11757308120` from run `38268767165` in +one disposable hosted ARM diagnostic job. Keep corrected tooling provenance +separate from its literal `dcdf09a` product source. Verify registry ZIP digest, +bounded archive members, source, and every package hash before using the +existing consumer. This temporary diagnostic ref adds no permanent test suite +or workflow inputs. The primary cause and final ARM startup remain unresolved. +Receipts: `/private/tmp/pr2-installed-startup-error-preservation-receipt.json` +and `/private/tmp/ci-final-dcdf09a-arm-startup-failure-receipt.json`. + +Independent inert ELF inspection of both Linux artifacts at this revision +confirms required `GLIBC_2.39`, GNU loaders, `libc.so.6`, `libm.so.6`, and +`libgcc_s.so.1`. Neither binary was executed locally. The published Linux +baseline is therefore GNU libc >=2.39 on x64/AArch64. Pin current producers to +Ubuntu 24.04; clean consumers use the pinned Debian 13 Node image. Do not claim +older distribution/kernel qualification. Automatic new-agent selection rejects +measured older, musl, or unknown Linux libc; existing native configurations keep +their execution. Windows and Alpine/musl remain deferred. Receipts: +`/private/tmp/ci-final-dcdf09a-linux-arm64-data/elf-dependencies.json` and +`/private/tmp/ci-final-dcdf09a-linux-x64-data/elf-dependencies.json`. + +The libc guard passes 209 focused controls and both typechecks. Seven actual +POSIX probe/version controls pass. Forty-one built-in database cases did not +execute because embedded PostgreSQL timed out on this Mac; require Linux CI. +Token gates pass with the pinned pnpm launcher. Receipt: +`/private/tmp/codex-default-glibc-floor-receipt.json`. + +Next action: freeze corrected diagnostic tooling, identify and fix the actual +ARM startup cause, then require fresh hosted qualification, CI, and review. Keep +the current branch, PR, test-drive, budget holds, and no-Codex-bundling boundary. +No merge or production deployment is authorized. + +Checkpoint `695506ad3912fa63604ca784fda50416fba983d6` is pushed and passes +ordinary CI (53 successful checks, four conditional skips). The restarted +test-drive serves `2026.1005.0+348.git.695506ad39`; four complete agent execution +configurations and both historical native runs survive unchanged, with the old +experimental flag false. Normal staging selects the verified Mac target and +reports `needsBuild: false`; there is no binary override or local Cargo build. +Receipt: `/private/tmp/pr2-final-preservation-after.json`. + +The hosted [ARM diagnostic](https://github.com/paperclipai/paperclip/actions/runs/38273898742) +used tooling `695506ad` and the literal `dcdf09a` package archives. It recovered +the primary failure: Codex's bubblewrap cannot create a namespace inside the +restricted Docker fixture. Daemon/CLI installation and ABI were not the cause. +The existing consumer now retains that Docker install/lifecycle proof and +uses the same installed graph on the actual ARM host for unchanged read-only +startup. Lock, daemon, and official Codex command hashes must match across +Docker/host and before/after. Twenty-one controls and independent review pass. +No external-sandbox substitution, sudo, sysctl, namespace flags, or product +permission policy changes are used. Actual host capability still needs proof. +Receipt: `/private/tmp/pr2-arm-host-split-owned-receipt.json`. + +Fresh review also found that the daemon's new libc gate had accidentally sent +older Linux browser login to PATH-only lookup. A two-file fix restores owned +Codex CLI resolution independently of daemon eligibility. One hundred twenty-four +focused cases and server typecheck pass; nine regressions fail before the fix. +Creation's libc floor remains unchanged. Receipt: +`/private/tmp/codex-default-login-libc-decoupling-receipt.json`. + +Next action: freeze this corrected tooling and run one remote ARM host treatment +against the same verified archives. Require actual host startup before the final +four-target qualification; retain literal product/tool source identities. + +The [host treatment](https://github.com/paperclipai/paperclip/actions/runs/38276826878) +at tooling `123a18837478d8cd0e2aff23f7e7aa86c9107de3` also fails native startup. +The actual ARM host reaches the installed daemon and Codex, then bubblewrap +fails loopback `RTM_NEWADDR` with `Operation not permitted`. No successful +native session or after-start continuity is claimed. Docker/host installation +identity passed. Exact upstream Codex 0.160 source places namespace creation +before its own seccomp filter and always restricts networking for filesystem +helpers. A Paperclip network-mode change would not solve this. Prepare one +temporary same-policy direct Codex control plus read-only host namespace facts +to distinguish host enforcement from native mediation. Do not alter kernel, +Docker, or product permission policy. Receipts: +`/private/tmp/ci-final-arm-host-diagnostic-123-receipt.json` and +`/private/tmp/codex-default-arm-sandbox-source-diagnosis.json`. + +Fresh `123a188` review is 5/5 with one payload-continuity finding. The existing +consumer now hashes the actual official Codex native executable separately +from its launcher, checks containment/header, and compares both across Docker, +host, and before/after. Twenty-one controls pass, including native-only mutation +and an older supported package layout. Receipt: +`/private/tmp/pr2-native-codex-payload-and-control-preparation-receipt.json`. + +Ordinary `123a188` CI found two test gaps. Nine new login fixture controls were +shadowed by Vitest's global module bridge. The existing bounded plain-Node +resolver seam now proves private package selection and containment before only +fake login; all 27 login cases and typecheck pass. A repository test reloaded one +millisecond after Save, before any update request reached the server. It now +awaits the real saved-value render, then retains the persistence assertion after +reload. No product repository code changes. Receipts: +`/private/tmp/codex-default-login-resolver-isolation-receipt.json` and +`/private/tmp/ci-final-123a188-repository-e2e-analysis.json`. + +Next action: freeze these review/test fixes, run the bounded direct ARM control, +and require fresh Linux CI. Keep ARM startup sign-off unresolved until proven +on a compatible target; package installation and direct execution alone do not +qualify the native runner. All Docker/native builds remain remote. + +Checkpoint `598e190cb308f4d7f8f43c188c2e7d88c3fc90d1` passes +[ordinary CI](https://github.com/paperclipai/paperclip/actions/runs/38279960223): +both required gates, 54 successful checks/statuses, and four conditional skips. +Fresh review is 5/5; all 15 review threads are resolved. Human code-owner approval +is still absent. The test-drive serves `2026.1005.0+350.git.598e190cb3`; all four +execution configurations and both historical native runs survive restart. +The verified Apple Silicon daemon remains unchanged, `needsBuild: false`, with +no binary override, native compilation, Docker build, or provider turn locally. +Receipts: `/private/tmp/ci-final-598e190-ci-review-receipt.json` and +`/private/tmp/pr2-final-preservation-after.json`. + +The [direct/native ARM diagnostic](https://github.com/paperclipai/paperclip/actions/runs/38280515437) +records GNU libc 2.39, AppArmor enabled with restricted unprivileged user +namespaces, an unconfined current profile, zero effective capabilities, and no +seccomp filter. Native startup still fails at bubblewrap loopback `RTM_NEWADDR`. +The direct control stopped before Codex spawn at a TOML preservation guard, +so it does not establish the sandbox failure's cause. Receipt: +`/private/tmp/ci-final-arm-direct-diagnostic-598-receipt.json`. + +Pure, integrity-bound public `smol-toml` 1.9.1 reproduction identifies that guard +failure: its null-prototype tables differ from the writer's spread-created +ordinary objects. Preserve the parser's tables while changing only the intended +trust field; keep strict comparison, original comments/bytes, private writes, +and malformed-input rejection. No parser pin, semantic bypass, or permission +change. The existing suite passes 22 controls across both table representations; +real 1.9.1 treatment repairs five valid configurations and keeps two invalid +configurations unchanged. Runner/server typechecks pass. Receipt: +`/private/tmp/codex-default-startup-trust-fix-receipt.json`. + +The [four-target performance qualifier](https://github.com/paperclipai/paperclip/actions/runs/38281631416) +uses literal `598e190` product/workflow source. Observed Linux x64/ARM64 and Apple +Silicon builds pass with actual concurrent JavaScript/native preparation. +Cache restore is exercised; observed results are cold misses, not measured +speedups. The cache writer is reachable through the existing verified master +canary push, with exact source and repository guards; no write-gate expansion +is needed. Linux daemon bytes match the measured GNU 2.39 baseline. Remaining +producer/consumer results must be recorded before qualification claims. + +Next action: freeze the narrow trust correction, repeat the bounded direct +control with explicit corrected-tooling provenance, and retain native startup +as mandatory. A private namespace negative control and bounded read-only +AppArmor journal may help distinguish host denial. Do not modify host policy, +relax Codex's sandbox, or substitute direct execution for native qualification. +Require fresh CI/review after the correction. ARM startup and managed staging +remain unresolved; no merge or production recommendation is made. + +The frozen production checkpoint `a200495cc7488f4d59da570407e516cf702e5af8` +passes [CI](https://github.com/paperclipai/paperclip/actions/runs/38283122068) +(54 successful checks/statuses, four conditional skips) and fresh 5/5 review; +all 15 review threads are resolved. It serves the test-drive at +`2026.1005.0+351.git.a200495cc7`, with four complete agent configurations and two +historical native runs unchanged after restart. No local native/Docker build +or additional provider turn ran. + +Its [exact-source qualifier](https://github.com/paperclipai/paperclip/actions/runs/38283695248) +builds and verifies all four daemons. Linux clean installation and both actual +Mac native startup/cleanup consumers pass. Cache restores are real cold misses; +JavaScript/native preparation overlaps on all four producers. The unchanged +host's ARM startup fails at AppArmor's namespace capability restriction, so that +original run remains failed. Public artifact `11763536756` contains the verified +18-package graph. Receipts: `/private/tmp/ci-final-a200495-hosted-qualification-receipt.json` +and `/private/tmp/ci-final-a200495-cached-producer-observations.json`. + +The separate [stock-prerequisite ARM treatment](https://github.com/paperclipai/paperclip/actions/runs/38287918968) +passes actual native startup, default daemon resolution, model selection, +read-only context, and joined cleanup on the exact same packages. Lock, daemon, +official Codex launcher, and native payload hashes match before/after. GNU libc +is 2.39. Global AppArmor enforcement, namespace restriction, and clone setting +remain Y/1/1. Authenticated Ubuntu package files provide system bubblewrap and +its reviewed stock scoped profile. Only those files are materialized; no package +maintainer scripts, sysctl writes, or broad profile reload runs. This is a +qualified target prerequisite, not a product permission-policy change. +The user has no other QA ARM host. No new paid environment was provisioned. +Receipt: `/private/tmp/ci-final-a200495-arm-stock-prerequisite-receipt.json`. + +Keep the boundaries explicit: normal OS APT installation is unproven; the actual +Paperclip npm installation and native startup are proven. The separate direct +control hits a read-only temporary-home error and is excluded from native +qualification. No ARM paid task/follow-up is claimed. Actual Apple Silicon +useful-task/follow-up evidence remains tied to its recorded source and identical +verified daemon bytes. Existing agents keep their runners. + +The tested setup body is integrated as +`scripts/prepare-linux-arm64-qualification.sh`, with an early hosted-Linux guard. +The existing ARM-only qualification step invokes it before the existing consumer +and uploads its report through the same artifact pipeline. Four focused workflow +and guard controls pass; syntax and diff checks pass. Receipt: +`/private/tmp/codex-default-arm-prerequisite-ci-integration-receipt.json`. + +Next action: require latest-head CI/review and one hosted consumer against the +unchanged `a200495` production packages using the integrated helper. Production +code remains frozen there; subsequent edits are qualification tooling or +documentation and retain separate source identities when reusing those literal +package archives. Managed staging, human +code-owner approval, actual post-merge cloud composition, and production sign-off +remain outstanding. No merge or deployment is authorized. + +## Current addition: Linux ARM64 + +The user now requires ARM Linux support as well. Treat ARM Linux as ARM64 +(`aarch64`), alongside Linux x64 and both macOS architectures. The earlier +Linux ARM deferral below is superseded for ARM64; 32-bit ARM and Windows retain +legacy defaults. Existing agents keep their saved execution. + +Extend the same native Codex creation/discovery contract and existing selection, +login, built-in and plugin fixtures. Extend the current release assembler and +remote workflow matrix to the fourth target, with an actual ARM64 hosted build +and clean installation. Reuse the installed no-turn Codex startup check and +the same public npm tarballs. Read required targets from the selected source +so historical three-target promotions still work. Keep the Linux x64 managed +provider pack distinct from the local ARM64 daemon. + +No local Rust/Docker builds or new paid Linux campaign. Apple Silicon task and +follow-up acceptance remains valid at the explicitly recorded source; Linux +ARM64 build/install/startup must acquire its own evidence. The existing +[three-target qualifier](https://github.com/paperclipai/paperclip/actions/runs/38259364638) +completed successfully at `d522822`, including both clean Mac consumers. It does +not qualify ARM64. Candidate `058af8f1b69c5fa3d9bc974b9eab73c141b52823` passed +ordinary CI and fresh 5/5 review. Its remaining import display finding is fixed +by sharing the existing legacy argument-policy calculation with the UI without +changing execution permissions. Ninety-five focused controls pass; all 45 exact +argument cases also pass against the original builder. Four component cases fail +on the original page. A no-provider protected preview shows Legacy with bypass +off, matching `--sandbox read-only`; it does not apply another import. +Receipt: `/private/tmp/codex-default-dc-import-live/legacy-policy-p2-receipt.json`. + +ARM64 selection/discovery controls pass 46 and 29 cases. Fourteen isolated +browser-login tests pass after replacing the hardcoded ARM64 legacy login branch +with the shared platform contract. This uses native execution's owned Codex +resolver on ARM64 and retains PATH lookup for unqualified platforms. Shared +typecheck passes. Built-in and plugin fixtures now include ARM64; their database +checks remain in normal Linux CI. Receipt: +`/private/tmp/pr2-linux-arm64-selection-checkpoint.json`. + +The packaging patch passes 97 existing controls: 15 assembler/provider-pack, +20 consumer-boundary and 62 workflow tests. The first full workflow attempt was +blocked by sandbox localhost permissions; the same full suite passes with its +temporary listener access. Three workflow YAML files and MJS/shell syntax pass. +Independent review confirms historical source compatibility and the unprivileged +offline consumer boundaries. Official registry metadata confirms the pinned +Node image has a native ARM64 manifest and the installed Codex dependency has +an ARM64 package. No image or binary was pulled to this Mac. Receipt: +`/private/tmp/pr2-linux-arm-packaging-owned-receipt.json`. + +Actual ARM64 daemon build and clean npm/native startup remain unexecuted at this +source freeze. The test-drive stays on its existing verified Mac daemon; its +last restart at `058af8f` preserved both full execution digests, both recorded +native runs and both paused imports, with the deprecated flag false. Receipt: +`/private/tmp/codex-default-dc-import-live/final-restart-receipt.json`. + +Next action: integrate the narrow policy display and ARM64 source/package fixes, +run targeted controls, freeze/push one candidate, then run the existing hosted +qualification once with all source-declared targets and require fresh CI/review. +Keep the same branch, PR, preview and evidence index below. No merge or +production deployment is authorized. + +## Current amendment: include macOS in this PR + +The user requires macOS support in the same Codex-default PR. The earlier +Linux-only scope and macOS deferral below are superseded. Qualify Linux x64, +macOS arm64 and macOS x64 through the shipped package path. Windows and Linux +ARM keep their legacy defaults. Existing agents retain recorded execution. +The user explicitly invoked `dotta:paperclip-test-drive`. Real task/follow-up +acceptance is required on this Apple Silicon Mac. Intel builds must be attempted; +an attended Intel provider journey is not required. Reuse the healthy test drive +and the existing Product E2E helpers rather than create another test system. + +Keep the current PR and branch: [#15683](https://github.com/paperclipai/paperclip/pull/15683), +`codex/codex-runner-default`. The Mac qualification source is +`d522822a5daeb72d124360b576b82a8bb52b43f4`; its +[ordinary CI](https://github.com/paperclipai/paperclip/actions/runs/38259205327) +is green. Fresh automated review is 5/5. The development release-manifest +finding is fixed with focused tests and awaits fresh final-head review. +Earlier Linux-only source `6ca302426b768791b499d2d563bbca4276f2c908` had fresh +5/5 review and eight resolved threads. That historical review does not qualify +the macOS amendment. Master integration conflicts are resolved. + +The [test-drive server](http://127.0.0.1:3125/COD/agents/all) is running through +the normal dev supervisor at the Mac qualification source. Health and bootstrap +are ready; the actual listener belongs to this worktree. The preserved legacy +and new native Codex QA agents are idle, with unrelated title edits intact. Dependencies +and ignored configuration had been cleared, so startup restored them and the +normal CLI signing keys while retaining the database and encrypted account key. +The initial borrowed development daemon was removed before paid testing. +The production resolver selects the source-bound CI arm64 daemon. Both native +task runs and restart preservation use the verified runtime below. + +Implementation ownership: + +- Packaging worker: reuse the existing daemon assembler, release verification + and release-smoke workflows to produce source-bound Linux and both macOS + artifacts. Pack the normal npm graph, then test those same tarballs on actual + Mac hosts. Retain the Linux and provider-pack controls. Never bundle Codex + native payloads; use the installed official npm dependency and real protocol + compatibility. Compile Rust and Docker only on remote hosts. +- Selection worker: extend the shared availability contract and existing + creation, onboarding, discovery, approval, import and UI fixtures. Preserve + explicit legacy choices, external overrides and existing execution. +- Runtime worker: verify the existing Mac native launch, account isolation, + file delivery and continuity seams; fix only actual gaps. Keep the Linux-only + sandboxed workspace-command capability gated on unsupported platforms. +- Lead: integrate, run targeted and required checks, verify useful Mac native + task/follow-up artifacts and ordinary editing, refresh the test-drive on the + verified revision, and update the same PR and evidence record. + +Reuse existing evaluations and assertions. Provider-free packaging and protocol +checks do not substitute for authenticated task completion. Preserve the $250 +ceiling and historical unknown/cleanup holds; reconcile only documented +unstarted future reservations before any new provider turns. No additional +paid Linux campaign is authorized by this amendment. Embedded-browser-only +login, no automatic agent migration, and no merge or production deployment +remain constraints. + +The macOS source checkpoint is `7b8d29a856b54217234131474ea47ea1a38fb0aa`. +The frozen master integration target is `b189f586d2f01c078725193b8a5ce4372b4acb3d`. +Conflicts are being reconciled by file ownership, retaining the newer transport, +lifecycle and release verification fixes. Focused macOS selection, installed-hook, +file confinement and packaging controls pass. These are source/protocol controls, +not real provider execution. The existing saved OpenAI subscription is connected. +The ordinary production agent-config render suite passes 111 cases after its merge. + +Budget reconciliation assigns $3 of the documented unstarted $5.50 managed +reservation to Apple Silicon native test-drive acceptance, leaving $2.50 for the +managed journey. The completed legacy subscription journey's entire $2.50 hold +remains reserved as unpriced. No historical unknown or cleanup reserve is released; +the $250 ceiling and $248.18 combined cost/reservation total remain unchanged. +Limit the Mac journey to two task turns, 180 seconds each, no automatic retry, +and an independently checked useful file plus revision. + +### Mac acceptance checkpoint + +The real [Apple Silicon task and follow-up](http://127.0.0.1:3125/COD/issues/COD-2) +passed at runtime source `f4abdc2cfcd779d5ad9b9c0ea8b63114c1a18a9b`. +The ordinary picker submitted `codex_local` without a runner override and saved +`paperclip_runner` / `codex`. Saved subscription reuse and native setup passed. +Both task runs succeeded with driver `codex_app_server`, observed model +`gpt-5.6-sol`, and the same provider session. Independent attachment downloads +verified exact 61-byte and 102-byte Markdown files, including preservation of +the first attachment. Reload retained the native configuration. + +The actual owned daemon process loaded the source-bound CI bytes: +`sha256:d0656673f7b6dfa6f2554fac18f3e0b42e2eb10f444b06f6063255335d71aa14`. +Mach-O arm64, production contract, source receipt and code signature passed. +The borrowed development daemon override was removed. Startup at this revision +preserved the old legacy agent's full execution digest. Unrelated UI title edits +and reload preserved complete legacy/native execution digests; the shared runner +picker passed light/dark, mobile and keyboard checks. Screenshots and sanitized +receipts are under `/private/tmp/pr2-f4-mac-ui/`. + +The corrected qualifier's exact `d522822` Apple Silicon artifact independently +matches those same bytes and metadata. Artifact `11752770004` passed native +header, source receipt and strict ad-hoc signature checks. Rust tree +`6ca713d5f1f0a059c7f01a37d21f268817e0c93c` and runner TypeScript tree +`ba9744db7fe79301cb0ab442e9546de3282a4bf4` are identical to the paid-task source. +The receipt is `/private/tmp/pr2-d522-mac-remote-daemon/sanitized-verification-receipt.json`. +Restarting the test-drive at `d522822` preserved both complete execution digests, +titles and both native run records, with no additional provider calls. + +The deprecated `enableNativeRunner` field is now false on this QA instance. +The native export/import UI journey passes with that field disabled. Import +submission omitted adapter overrides, preserved native Codex/model identity, +and left the new agent paused with its import hold. The explicit Legacy round +trip also passes: the request sends `runner: legacy` without replacing source +configuration, and the saved agent is `codex_local` with the import hold intact. +Both survive reload; the source agent remains unchanged. These imports do not +run setup probes or provider turns. +Evidence is under `/private/tmp/codex-default-dc-import-live/`. + +The live import journey found one incorrect policy display. Native Codex's +preserved `never` policy translated to Legacy bypass true, while its checkbox +showed false. A narrow hydration fix uses the existing policy defaults and +canonical booleans; it does not change server permission translation. Against +the pre-fix page, three display cases fail and four safe controls pass. The +fixed page passes 42 component cases and 40 selection controls. The no-provider +preview check passes: the corrected control matches the already-saved paused +agent. Exactly two paused imports were submitted, with no third Apply. The +combined receipt is `/private/tmp/codex-default-dc-import-live/receipt.json`; +it separates `d522822` persistence proof from the corrected UI's content hashes. + +[Three-target qualification](https://github.com/paperclipai/paperclip/actions/runs/38252137381) +built all three daemons, including actual Intel Mac. Its clean Linux consumer +passed the 18-package installation and Codex ownership controls, then a Pi probe +failed because it expected the old flat daemon path. Both Mac consumers reached +deferred offline lifecycle scripts but Node-gyp tried a forbidden header download. +Narrow verifier corrections preserve the startup/network/lock/sentinel assertions +and use the host SDK. These failed attempts remain evidence; installed-package +qualification still requires a passing corrected run. + +Test-only head `dc7304d7c4db1c9c9ee2ed80a98d004371372771` is production-byte +identical to `f4abdc2`. Its [ordinary CI](https://github.com/paperclipai/paperclip/actions/runs/38253941991) +passes the required checks. Two fixture repairs enabled Dot only in its intended +experiment cases and restored owner directory permissions only inside each test +worker's scratch home for teardown. The actual asset producer/symlink control +passed. Fresh review identified import override/availability gaps and stale dev +host binaries. Their fixes now pass 38 real-component import cases (eight fail +on the old page) and 12 dev-stager/freshness controls. Unknown/gated providers +retain their identity. Rebuilding refreshes an existing host target atomically +and keeps a running old executable intact. Fresh final-head checks/review remain +required. The corrected Pi/Mac installation probes pass 19 focused controls +and independent source review; actual hosted consumers remain pending. + +The two native runs report included subscription use without a dollar amount. +The complete $3 setup/task hold remains reserved as unpriced. No historical +unknown or cleanup hold was released, no automatic retry ran, and no additional +paid Linux campaign started. The aggregate ceiling remains $250. + +The import, dev-stager and installation-probe fixes are pushed at `d522822`. +The [corrected three-target qualifier](https://github.com/paperclipai/paperclip/actions/runs/38259364638) +has passed all three daemon producers and the real Linux clean installation, +including the corrected Pi probe. Both Mac consumers are checking the same +18 tarballs. Ordinary CI reports 53 successful check runs, Snyk success and four +conditional skips. Fresh review reports 5/5, with one new development-only +manifest issue: rebuilding a host daemon must not corrupt released artifact +identity. Its fix separates local development selection from release assets; +it does not alter the Rust runner, production selector or release assembler. +Fourteen focused controls prove preserved release targets/hash manifest, running +old inodes, correct Mac host selection and future source freshness. Independent +review verifies that the atomic flat publisher and qualified release components +remain unchanged. The actual Mac still selects the verified release target and +does not require Cargo. Receipts are +`/private/tmp/pr2-dev-release-preservation-checkpoint.json` and +`/private/tmp/ci-final-d522-dev-equivalence-audit.json`. + +Next action: commit the verified development/import display fixes and this state +record together. Run fresh final-head CI/review in parallel with the remaining +clean package consumers, then record their exact conclusions in the PR evidence +index. Keep `d522822` as the qualification source; subsequent UI/development +checks have their own source identity. Keep exact sources in every receipt. +Do not repeat unchanged paid task turns or qualification components merely for +the development-only fix. + +This is the 2026-10-12 source-freeze snapshot. The PR verification section is +the linked evidence index for final CI, review and consumer conclusions. Current +qualification links above retain their literal tested source rather than claim +that an earlier workflow ran on a later development/UI commit. +Managed staging execution/access renewal, code-owner approval and actual +post-merge Cloud composition remain unverified. Prior staging preflight evidence +is retained at [37962635959](https://github.com/paperclipai/paperclip/actions/runs/37962635959) +and [37965658123](https://github.com/paperclipai/paperclip-cloud/actions/runs/37965658123). + ## Outcome and ownership Users choose a harness. New agents use Paperclip Runner where the harness and @@ -408,76 +1547,345 @@ The user approved splitting that work into four useful steps: 3. Claude defaults and authentication. 4. The remaining supported harness defaults and provider-specific fixes. -## Current slice +## Completed prerequisite -Canonical branch: `codex/runner-packaging-prerequisites`. -Frozen master base: `1881894973a2b25838d8abed9bd8aeebc3af4441`. -Canonical PR: [#15555](https://github.com/paperclipai/paperclip/pull/15555). -Its [checks](https://github.com/paperclipai/paperclip/pull/15555/checks) and -[commits](https://github.com/paperclipai/paperclip/pull/15555/commits) record the -final candidate. Hosted results and review disposition belong to that exact -candidate; earlier green checks do not qualify a later revision. +[PR #15555](https://github.com/paperclipai/paperclip/pull/15555) merged at +`6f9d0a56ba3c2f0059a723b7de434320eed7eb22`. Its final candidate `dde37d7` +passed 47 ordinary CI jobs and fresh 5/5 automated review. The actual clean Linux +npm consumer checked all 17 tarballs for absent Codex native payloads and official +npm host dependencies. Actual Codex 0.156.1 startup passed on macOS ARM64. +[Bot lockfile refresh #15673](https://github.com/paperclipai/paperclip/pull/15673) +is merged. These checks did not qualify onboarding or cloud rollout. -The user requested a smaller PR on 2026-10-08. This slice now covers Codex -executable resolution, native-free npm packaging, the installed JavaScript -dependency graph, and the sandbox resource lookup required by that npm layout. -It retains focused tests and extends the existing public npm consumer verifier. -It does not add a workflow, change Docker or Git installation, change shared -login HOME or working-directory behavior, or transfer runner release assets. -Agent defaults, saved runner choices, UI, schema, and provider qualification stay -unchanged. +Removed prerequisite work remains on `codex/runner-packaging-full-snapshot` at +`6f3060beaa842ffcee21af058370d3cab5f571e9`. Do not restore its release workflows, +Docker materialization, Git installer, or extra login isolation wholesale. -The broader prerequisite implementation is preserved on the pushed branch -`codex/runner-packaging-full-snapshot` at -`6f3060beaa842ffcee21af058370d3cab5f571e9`. Its Git staging, extra login isolation, -release pipeline, Docker materialization, and unrelated test-fixture changes -remain outside this PR. Release and platform packaging must be qualified in a -separate slice before enabling new-agent defaults. +## Current slice: Codex defaults -### Retained behavior +Authorized 2026-10-09. Canonical branch: `codex/codex-runner-default`. +Integration base: master `2dd9811e8be1dab59cccd86cdb25e8953b02fe07`. +Canonical PR: [draft #15683](https://github.com/paperclipai/paperclip/pull/15683). +Last pushed candidate: `ad5d8826ad18f72d672fcafbdd9fec24ef8c9fef`. +It integrates the current agent lifecycle boundary. The next narrow follow-up +restores the native Codex sign-in action and settles an existing chat test's +canonical lookup before interaction. The PR remains under 100 files, with no +workflow, lockfile, or database migration changes. Fresh final-head CI and +review are required after that follow-up. +Preview: [isolated Codex QA](http://127.0.0.1:3125), normal dev supervisor, +separate database. Hosted Linux live onboarding passed at `38199ea8`; it remains +historical proof after the lifecycle integration. UI has separate revision-bound proof. +Attended local subscription and managed staging journeys remain unverified. -Ordinary native Codex startup, direct evals, and browser login prefer the -installed dependency. They accept usable older or newer CLI versions. If the -dependency is absent, use the selected host's PATH. Explicit commands and recorded -sessions retain precedence. Real protocol/login failures remain actionable; -there is no silent runner fallback. Release pins and the separate ACPX artifact -qualification stay unchanged. Linux ARM64 keeps its existing legacy login path. +New Codex agents select Paperclip Runner on qualified Linux x64 targets. Users +choose Codex, with Paperclip Runner or Legacy runner inside Advanced. Other +harness defaults stay unchanged. Existing legacy/native agents and ordinary +edits retain recorded execution; explicit runner changes use revisions/session +invalidation. No database migration or permission-policy change. -Final review found a relative PATH regression in the retained resolver. Resolve -relative and empty PATH entries against the selected provider working directory; -retain installed-dependency preference. Extend the existing fresh-process fixture -and qualify the resulting commit separately from the green `cb5948e` candidate. +Resolve before validation/auth/instructions/skills at the common creation +boundary. Cover direct/API/CLI creation, onboarding seeds, hires, frozen +approvals, built-ins, plugins, and import/export. Unsupported platforms or active +external overrides retain legacy selection. Missing dependencies, invalid models, +authentication failures, and incompatible settings remain actionable errors, +without silently choosing another runner. Native Codex bypasses the experimental +gate; other native providers retain their existing gate. -Paperclip npm tarballs retain the patched JavaScript graph and strip Codex native -payloads. The published server declares official optional host packages. npm -installs them for the consumer's OS and architecture. This avoids collisions with -the legacy adapter's separate Codex version. The server owns the bridge dependency -used by its vendored runner. Sandbox reads remain confined to the selected native -vendor resources; package identities and path containment still apply. +Finish line: one narrow reviewable PR, affected regressions and required checks +passing, useful Codex tasks plus follow-up and independently checked artifacts, +onboarding and existing-agent preservation verified locally and on qualified +managed staging, and inspectable evidence. Human merge and production deployment +remain separate actions. Actual production composition remains a post-merge gate. -Browser login keeps master's provider-specific credential directories, ambient -HOME, and working directory. Only executable selection and safe errors change. +## Coverage and execution ownership -## Evidence and gates +Reuse Product E2E profiles, fixtures, graders, reports, and installation checks. +The `first-task` native Codex case now inspects the wizard's saved default directly +with the experimental flag disabled. The legacy control selects its override +before authentication. Each isolated onboarding company has a $5 task budget; +setup probes require their own reserved allowance. +Claude's explicit native fixture is unchanged until its later default PR. -| Gate | Status | Evidence | +| Area | Owner | Current state | | --- | --- | --- | -| Preserve original and broader work | Passed | Pushed branches and revisions above | -| Narrow scope against current master | Passed | Installed Codex/npm scope only; no workflow, Docker, Git installer, or unrelated fixture diff | -| Focused packaging and installed-consumer controls | Passed | 24 packaging tests with actual offline npm tarballs; 9 existing consumer assertion tests | -| Login controls after narrowing | Passed | 13 existing tests; HOME/config-directory/spawn semantics match frozen master | -| Codex selection, protocol, sandbox, and ACPX integrity | Passed locally | 21 selection/security/transport/eval tests; 4 npm layout integrity controls; runner TypeScript no-emit | -| Full checks and fresh review | Pending for relative PATH fix | `cb5948e` passed all 47 CI jobs and its clean Linux npm consumer; qualify the final fix with new CI and fresh 5/5 review | -| Previous broad candidate | Historical | 47 ordinary CI jobs passed at `6f3060b`; not proof for the narrowed candidate | -| Existing old CLI protocol proof | Historical | Official Codex 0.156.1 completed startup handshake; scope must be matched to retained sources | -| All-harness live onboarding and cloud qualification | Deferred | Later defaults slices; authentication probes and CI cannot close live journeys | +| Shared contract, server resolution, setup readiness | Core worker | Integrated lifecycle: 210 core cases, 109 connection cases and five affected refresh controls pass; server noemit passes; counts overlap | +| Production create/edit/onboarding UI and stories | UI worker | 171 fresh focused cases, 30 desktop/10 mobile captures and six keyboard checks; built-in follow-up passes 15 cases, UI typecheck and token gates | +| Built-ins, plugins, approvals, imports/exports, CLI | Creation-path worker | Integrated lifecycle: 105 portability, eight approval and three asset cases pass; 73 DB cases skipped on macOS; final Linux checks required | +| Execution/dev gate, E2E gaps, packaging, live journeys | Lead | Selected Linux onboarding case passes all 13 assertions, actual task/artifact/follow-up and cleanup; 149 public-projection/support cases pass | +| Full CI and final review | Lead | Integrated head: 49 checks pass, three fail, four skip; native auth action finding and existing chat readiness fixture fixed locally; fresh combined-head checks/review required | +| Local subscription task + follow-up | Lead | Embedded-browser Codex sign-in and first-agent creation completed; saved codex_local is expected on macOS; useful task/follow-up still pending | +| Managed staging API/subscription onboarding | Lead | Fleet Admin sign-in completed; dedicated tenant exists, active/awake and pinned to old release; scoped one-hour QA owner access approved; final serving revision/snapshot/runtime/cleanup qualification pending | +| Shipped npm/Docker/cloud artifacts | Lead | Integrated-head clean Linux npm consumer passes: 18 packages, no Codex payloads, host dependency from official npm; immutable staging preparation reports missing matching DB package; actual cloud composition is post-merge | -Use Node 24 and one worker for focused local checks. Keep heavy builds in hosted -CI. Do not build Docker images or Rust locally. Do not author a lockfile change. -No provider-backed runs or new disposable environments are needed for this -prerequisite slice. Incremental provider spend for narrowing is $0. The original -$250 ceiling and cleanup obligations remain for later live qualification. -Existing credentials and user previews remain untouched. +Keep CPU-heavy Rust/Docker builds in hosted CI. Use Node 24, bounded workers, +conservative provider turns/timeouts and existing QA accounts. Login interactions +use the embedded browser. Do not change user preview services or production. + +The original $250 ceiling remains. Historical receipts include unknown-charge +reserves; the latest retained ledger holds $248.18 in costs/reservations and +reports $1.82 unallocated, not a certified invoice balance. Reconcile unstarted +reservations and current resource/credential state before paid runs. Do not reset +the ceiling or treat missing charges as zero. The single new Linux campaign has +four ready rate-card estimates totaling $1.265460800; its entire $12 hold remains +reserved. Keep exact source/artifact/runtime identities and failed attempts in the +existing report pipeline; mocks and passing CI do not close live acceptance. + +Integration found and fixed an import preservation gap: same-harness model edits +must retain the package's explicit runner or the existing agent's runner. Native +setup reuses production remote artifact preparation and verifies the actual +app-server/model path; a version number alone does not reject installed Codex. +Known unsupported local/SSH platforms retain legacy defaults. Sandbox support +does not infer an image architecture; setup verifies its actual artifacts. + +Public package audit found flat Linux x64 daemons in current stable and canary +server tarballs. Normal release builds stage only the build host's daemon; the +multi-platform assembly helper has no release caller or macOS artifact producer. +Automatic macOS/Windows/Linux ARM defaults therefore remain legacy in this slice. +Existing explicit native agents remain usable with their installed runtime. +macOS runner artifact assembly is a separate prerequisite for its default. +Codex setup required three small Rust seams: read-only probes, observed model +reporting, and reasoning effort. Old cached daemon bytes do not verify these. + +Local startup hit the macOS shared-memory ID limit. One detached 56-byte segment +was released only after zero attachments and exited owners were verified. No +running server or database file was changed. The separate QA server is healthy. + +Next action: verify the final candidate through existing Linux CI/install checks, +fresh review, and bounded actual journeys. Preserve +failed attempts and costs. Unrelated findings and remaining harness qualification +belong to later slices. + +Post-rebase UI checks pass (126 cases, typecheck, token gates). Product E2E +catalog/onboarding support passes (122 cases), plus 47 confirmation fixture +checks and E2E noemit. Existing Pi companion/executor controls pass (26 and 34 +cases); core selection/setup passes 87 cases and transport passes six. +Production Storybook build and 26 desktop/six mobile captures were completed +before the Pi rebase; they are component evidence, not live execution proof. +Both final integration gaps are implemented: discovery resolves the selected +environment through the same helper as saving, and explicit native export/import +profiles remain valid on macOS while new automatic defaults stay legacy. +Scoped discovery/authorization passes 63 checks. UI context and recovery checks +pass 280 cases plus three final regressions. Missing discovery metadata shows +loading or the server error with Retry; explicit Legacy remains available. +Final portability checks pass 105 cases; approval, built-in asset and CLI unit +controls pass eight, three and three. The 72 skipped DB cases still need Linux. +Candidate `b7194275e` completed 38 CI checks but failed 14, including aggregate +checks. Its fresh review reported three P1 findings. The fixes preserve explicit +legacy sandbox policy, return refreshed unmanaged subscription credentials to +their authorized source account, and restore gated experimental native choices +for other providers under Advanced. Actor restrictions now run before harness +translation and again on the resolved configuration. Existing route fixtures +use the real resolver; legacy execution controls request Legacy explicitly. +Capability drift was fixed by preserving existing documentation heading anchors, +without changing generated contracts. The retired Codex flag control now uses +an experimental OpenCode profile; all 45 corpus regressions pass locally. +Core regression checks pass 394 cases, with 147 final guard/inheritance controls; +server noemit passes. UI checks pass 212 cases, typecheck and token gates. The +original touch/wheel journeys pass two cases against the isolated preview. +Final desktop/mobile/keyboard captures and the Advanced experimental story are +being checked against the frozen UI tree. Earlier 26 desktop/six mobile/three +keyboard checks passed on the previous UI revision. The built-in regression +checks nondefault settings through runner-only switches in both directions. + +Exactly $22 of documented unstarted staging reservations were reassigned to +Codex-only proof: $12 Linux onboarding (one existing campaign, at most two +attempts), $5.50 managed onboarding, $2.50 attended local proof, and $2 incremental +compute/cleanup. The attempted $5 staging setup envelope remains an unknown-cost +hold. Aggregate costs/reservations stay $248.179254104 under the original $250 +allocation; no historical unknown or cleanup hold was released. This is not an +invoice-certified balance. No new paid provider task has run in this slice. + +Attended local Codex login is unfinished in the embedded browser; its old device +code expired. Staging Fleet Admin requires fresh attended sign-in. The dedicated +QA stack `stack-pool-d06dca5eafe7` and its old immutable environment are identified +historically; current access, serving revision, retained snapshot and physical +cleanup must be verified before reuse. Old archived probe resources and missing +compute billing remain unresolved. Do not substitute old artifacts or passing CI +for these live gates. The next candidate must pass fresh CI and review before +merge sign-off; actual private cloud composition remains post-merge work. + + +### 2026-10-09 Codex-only candidate qualification update + +Frozen execution candidate `38199ea8e6a276eecd35084ad84d691322aba9f4` is rebased +onto master `835a022936ddc7a152b9feab0c0420d02249a6d1`. Its CI completed +48 passing, four failing and four skipped checks. The two substantive failures +are test fixtures: legacy skill expectations on Linux and a credential probe +that depended on a developer daemon. Their narrow corrections pass locally: +77 native/legacy/onboarding skill route cases and the selected-login refresh +regression. The earlier Node 26 skill run ended without a result; it is not proof. +Build, workspace typecheck, all browser E2E shards, policy checks, native +compilation, and the clean public npm consumer pass. The consumer checks all +18 packed packages contain no Codex payload and acquires host Codex from official +npm. The merge and generated-lock CI stamps are recorded separately from source. + +The existing Linux Product E2E campaign [37950165589](https://github.com/paperclipai/paperclip/actions/runs/37950165589) +passed its only selected case on attempt one, without a retry. The ordinary +onboarding picker saved `paperclip_runner` / `codex`. All 13 assertions passed, +including an independently checked nonce-bearing document and follow-up with +recorded native execution and the same workspace. Four runs succeeded with +observed model `gpt-5.6-sol`; cleanup passed. Target source is exactly `38199ea8`. +The workflow deliberately applies its verified resolved lock; the source receipt +is dirty and must not be described as a pristine checkout. The report selected +one of 52 catalog cases; it is not the entire catalog qualification. + +The final run ledger contains four ready rate-card estimates totaling +$1.265460800 (openai-standard-2026-09-30). Production pricing and accounting +settled. The existing E2E numeric-only cost summary omits decimal-string +costUsdExact and incorrectly reports unpriced/zero; this is a reporting follow-up, +not a production pricing failure. The full $12 campaign hold remains reserved for +setup and invoice uncertainty. Estimates are not provider invoices. No further +paid campaign was dispatched. +The existing public report includes private session identifiers in its result +summary. It is withheld from broad linking while an allowlisted public projection +is added at the existing result seam. Private artifacts and behavioral grading +remain intact. This is an evidence-publication correction, not new eval machinery. + +Fresh review scored 3/5, verified the previous three fixes, and found three UI +edge cases: preserve original native setup-link intent on unqualified hosts; +retain built-in settings on runner-only changes; and record an explicit choice +when clicking the displayed automatic default. These fixes pass 171 focused UI +checks, typecheck and token gates. A fresh static build passes; affected visual +proof is in progress. Existing UI verification passed 28 desktop/eight mobile +renders, six keyboard/footer cases and two touch/wheel journeys. Final CI/review +must run after this combined follow-up is frozen. + +The isolated preview uses [3125](http://127.0.0.1:3125/COD/dashboard). +Its supervisor uses the correct port and the prior backend restarted at +`2026-10-09T15:28:08.305Z`. No user preview or the separate OAuth proxy was stopped. +The owned supervisor/backend were gracefully paused during lifecycle integration; +restart and serving-source verification are required before another live journey. +The first-agent name draft survived reload. The cached local daemon is not proof +of the new native Rust seams; Linux hosted build/live execution supply that proof. +Attended subscription login and current access to the dedicated managed staging +stack remain unresolved. Canonical issue coordination is unavailable because the +original test-drive service is offline; no unrelated company was used for updates. + +Public-evidence projection is complete and passes 149 existing and new support +checks. The private behavioral grader and raw evidence remain intact. Replaying +the retained result removes all 12 known private identity values and preserves +the original revision and all 13 assertion verdicts. Publication needs a new +presentation identity and operator access; the existing protected workflow has +no report-only replay input. It cannot overwrite the prior immutable report. +The prior exposed report also needs operator removal/cache remediation. No +provider rerun is justified to publish repaired evidence. + +At `1be8502f`, fresh review confirms all six previous fixes and reports one more +built-in setup issue. A built-in that still needs setup must submit its displayed +configuration to complete setup even when the model is unchanged. Already +configured agents must still preserve the full saved configuration during a +runner-only edit. Both native and legacy regressions reproduced before the fix. +All 15 focused cases pass after the fix, including definition-model fallback and +ready/paused settings preservation. UI typecheck and token gates pass. Rendering +is unchanged, so existing revision-bound visual evidence remains applicable. +Native compilation passes; the main CI cleared its allocation delay and is +running, with no test failure observed yet. + +Next action: freeze the built-in setup follow-up, require green +exact-head CI and fresh 5/5 review, and publish sanitized retained evidence when +operator access is available. Complete the attended and managed staging journeys +when their remaining resources are ready. The PR stays +a draft and is neither merge-ready nor production-ready yet. Merge and production +deployment remain outside authorization. + +### 2026-10-09 Current-master lifecycle integration + +Master merged [#15631](https://github.com/paperclipai/paperclip/pull/15631) during +qualification. GitHub blocked the new candidate on nine real merge conflicts. +The integration preserves master's agent lifecycle ownership. Common runner +resolution now runs in `prepareAgentHire`; approval activation retains the frozen +reviewed runner, responsible-user identity and credential restrictions. Existing +agent updates keep their saved execution. Obsolete source edits were removed, +and redundant generic API prose was dropped while the agent-creation skill and +its reference retain the complete runner contract. The diff remains 99 files. + +Focused integration checks pass: 210 core, 109 connection, five affected refresh, +105 portability, eight approval and three built-in asset cases. Counts overlap. +Server noemit and the existing lifecycle-boundary scanner pass. Shared, DB and +plugin SDK JavaScript outputs were refreshed without migrations, Rust or Docker. +The 73 DB-heavy cases were skipped on macOS and require hosted Linux proof. +The full local route-fixture attempt was interrupted and is incomplete evidence; +loopback restrictions were identified before its bounded affected-case rerun. +That unrestricted rerun passes all 61 selected cases; 108 unrelated cases were +excluded and remain part of required full hosted CI. + +The human completed Fleet Admin sign-in. The dedicated QA stack exists and is +active, awake, healthy and user-managed, so ordinary fleet rollouts skip it. +It still serves historical Core `2dbe43a4`, not this candidate. The human approved +one hour of scoped owner access for this QA tenant through the audited admin flow. +No release change, provider task, new image or fleet-wide action has run here. + +Next action: freeze/push the integrated candidate, +require exact-head CI and fresh 5/5 review, restart the matched local preview and +qualify the dedicated managed target against that candidate. The previous Linux +run is retained evidence; it must not be relabeled as the new live measurement. + +### 2026-10-09 Integrated-head qualification and final corrections + +At `ad5d8826`, all checks completed: 49 pass, three fail and four skip. Required +verification, workspace typecheck, build, Linux server/DB suites, native +compilation and the real clean 18-package consumer pass. The consumer contains +no Codex payloads and selects its owned official-npm Codex dependency despite a +different outer version. CI merge and generated-lock identities remain separate +from the PR source. Applicable code-owner approval is still unproven. + +The only browser failure is the existing selected-run denial case, whose fixture +uses the process adapter. Its URL becomes canonical before its agent query +settles. The test now waits for the successful company-scoped canonical response +and verifies its complete agent identity before clicking. All nine existing +retry cases pass against the real isolated preview with their exact request and +denial assertions unchanged. A supporting query lifecycle control reproduces +lost mutation feedback during a remount; the CI cause remains an inference, +because the controlled early-click browser reproduction did not activate. + +Fresh review scored 4/5 and found one native Codex onboarding defect: an +authentication error lacked the shared check that displays the sign-in action. +The missing check reproduced before the fix. The correction retains the native +diagnostic, failed result and explicit runner choice. All 174 readiness, registry +and production-form render cases pass, including selected-environment login and +negative model/dependency controls; server/UI typechecks pass. The agent creation +skill retains the full API contract and CLI equivalents in one place, avoiding a +redundant reference edit and keeping the PR at 99 files. + +The attended embedded-browser Codex sign-in succeeded, and first-agent onboarding +created the isolated Mac agent as `codex_local`, as expected on this platform. +The ordinary first-task interaction is open; task, artifact and follow-up are +not yet proven. No task provider call was made during these corrections. + +The dedicated staging tenant's old release remains unchanged. Read-only +immutable preparation [37959487896](https://github.com/paperclipai/paperclip-cloud/actions/runs/37959487896) +matched the intended QA target and reported a matching DB package/migrations +artifact gap. Rollout and serving verification were skipped. Build the frozen +candidate's isolated preview image and packages through the existing trusted +remote workflow; never substitute an older image or claim private Cloud +composition from the Core-only preview. Keep the pin and provisioning default. + +Next action: push these narrow corrections, run fresh full CI and review, build +matching immutable preview artifacts in hosted CI, then qualify the one QA tenant +after those gates pass. Retain all historical cost and cleanup holds. The draft +remains neither merge-ready nor production-ready. + +At correction `f6a15200c`, fresh review passes 5/5 with all eight threads resolved, +and the previously failing retry E2E shard passes. One unchanged auth-signal +fixture exceeded its 15-second test deadline during a cold route import; its +previous exact version passed on the integrated head. Imports now run in the +existing setup hook after the same resets, keeping the 15-second assertion +deadline and all actor/response checks. All 127 affected auth/form/model cases +pass. A temporary 100ms assertion deadline also passes while setup takes about +six seconds, confirming the separation. Keyboard coverage moved intact into +the existing form suite; the PR stays 99 files. This correction changes only +tests and this record, with the production runtime byte-identical to `f6a15200c`. +Three hosted server shards were interrupted after passing progress and remain +incomplete evidence. The new test-only head requires fresh completed CI. + +The frozen `f6a15200c` preview image is building remotely; exact-source shared/DB +preview packages are published and their source metadata verified. The isolated +local preview restarted at `2026-10-09T16:46:58.859Z`, serving that version with +startup recovery ready and no pending migrations. Its existing worktree execution +preference was off; activating it allowed the queued user onboarding response +to run. The actual legacy Codex task completed and its independently read file +contained the exact two requested lines. The same-task follow-up is running. +No second task, agent hire, or automatic runner conversion was used. Next action: commit and push the narrowed candidate, rerun its checks and fresh review, then update the PR and existing evidence report. Start the Codex-default @@ -504,3 +1912,141 @@ zero provider calls or native builds. Comment-only removal preserves the tested executable structure; the suite was not repeated. Evidence is recorded in `/private/tmp/ci-final-021df2-shared-credential-quorum-receipt.json` and its comment-closure receipt. Fresh published-head CI and review remain required. + +### 2026-10-13 Published canary and fresh staging qualification + +The human merged prerequisite #16028 at actual Core master `d67e3507b23041212fa26302ce6de078c5ebcc05` and asked to cancel the existing timer, then test the published npm canary locally and on a new staging stack. The `finish-runner-default-stack-and-merge` heartbeat was deleted. Codex defaults #15683 remains separate and unmerged. No production or provisioning-default promotion is authorized. + +The human approved a separate $25 ceiling: $15 for one owned QA stack/sandbox including pool backfill, $5 for setup and at most four short task/follow-up turns, and $5 for cleanup. Zero automatic retries. The original $248.179254104 held/consumed and all original unknown and cleanup holds remain unchanged. Actual new resource cost is unknown and held within the new reservation. + +Ordinary authenticated staging signup created `stack-pool-7998e841ace7` at https://runner-canary-d67-qa-oct-13.staging.paperclip.app. It reached fresh first-agent onboarding with zero agents or provider turns. Its user-managed pin is verified. Initial distribution `cloud-38355190192-1` is old source and has observed schema drift; it is baseline only. Exact d67 signed standard image, source gate and signed migrator have passed. Matching private composition is building through the existing canonical workflow, without a duplicate dispatch. + +The public d67 release38389963483 ended successfully but skipped native production, publication and canary onboarding. Registry canary.5 provenance binds it to old4a641d, so local acceptance remains blocked. The focused repair [PR #16149](https://github.com/paperclipai/paperclip/pull/16149), exact `ab99d3f7604cb2ac8d8a45c81276797af834e325`, adds explicit cancellation/source-result guards to the existing producer and canary smoke. Its existing contract cases reproduce two missing guards before treatment. All70 release controls and20 source-verification neighbors pass after treatment; independent source review is clear. All required hosted gates passed with fresh5/5 and actual code-owner approval. PR #16149 merged at `60bc0d5fee9a6e48db08fc016521fee3d9f57d67`; its new master-push release38395574681 is running. Concurrent master changes include Storybook indexing and verification-skill docs; npm and staging source identities remain separate. Source predicate tests support the diagnosis but do not establish actual GitHub scheduling or publication. + +The native-runner QA override is applied and converged through one-target feature campaign1791869766576, with zero failed targets. Literal d67 private distribution `cloud:38392212140:1` is qualified. Supported one-target deployment request `f7cb6751-91f8-49a4-9ab8-6092ecb27e7c` passed exact-source/private-artifact preflight38395788220; deployment38395855695 succeeded with authenticated serving verification and `set_default=false`. The pin and current private distribution remain verified. + +Attended OpenAI device authorization completed in the embedded browser. Ordinary fresh onboarding created Legacy preservation QA (`f9e30b8e-7ceb-4f88-b373-1a2cba65859d`) with its Codex Legacy configuration and unopened first-task question. Its sandbox and connection settings were retained. Native Codex package QA (`861f95bc-c9fc-4e32-8abb-8829d3fe42de`) was created separately through the explicit experimental runner picker, using Codex, gpt-5.6-luna, the responsible user's saved subscription and Paperclip Computer. This tests the merged prerequisite; automatic Codex defaults remain outside this source. + +The native creation Test reported success, but its visible details and exact d67 route show a Legacy Codex ACP/CLI model hello, not native runtime execution. At least one real setup hello is held in the setup reservation separately from the four task-turn allowance; other unmeasured setup usage is not assumed zero. Setup lease `1f2b04ce-97c2-4f81-8f92-0ece0fb54feb` and provider resource `622b8bb6-8bec-40d2-83c2-fa5edddfa6e8` have no independent cleanup confirmation yet. Green setup is not native execution or physical cleanup proof. + +Native QA policy survives reload with timeout180 seconds, concurrency1, interval disabled and max-turn continuation disabled. Separate transient/connection retry policy still needs an existing supported control before launching the task. The nonce-bearing first file challenge is drafted, not submitted; task turns remain zero. Existing file-artifact and session-continuity assertions are being reused, with manual adaptations identified rather than relabeled as an unchanged E2E suite pass. + +The retry audit found no supported retry-only UI control and no certified provider-start bound: existing native recovery can launch additional continuation prompts. The human explicitly approved normal recovery within the same $25 ceiling, with at most four manual task/follow-up messages, 180-second run timeouts and no automatic test reruns. Production recovery policy is unchanged. One manual staging task was submitted (`9f706825-36db-4950-9179-659406db37fb`); its native execution, file bytes and continuity remain pending. Manual message counts and observed underlying provider/recovery events will be reported separately. + +Release38395574681 has now actually completed all four native producers, including Darwin x64 and Linux ARM64, and its canary publisher is active. This supplies hosted scheduling evidence for the release repair. Actual source-bound registry publication and published onboarding smoke remain pending; canary.5 is still excluded as old-source evidence. + +Next action: verify the actual staging serving revision, wait for the source-bound published registry package, install the real package in an isolated Apple Silicon consumer, and run real file task/follow-up journeys within the new budget. Preserve the old3125 test-drive and all receipts. Current evidence: `/private/tmp/runner-d67-published-canary-qa/evidence/` and `/private/tmp/ci-artifacts-d67e350-artifact-readiness-receipt.json`. Neither requested live runtime test has passed yet. + + +#### 2026-10-13 Final published-consumer and fresh-stack result + +Actual master release38395574681 completed all four native producers, npm publication and real canary onboarding smoke. Public `paperclipai@2026.1013.0-canary.6` binds to exact60bc, including narrow merged release repair #16149. Its clean isolated Apple Silicon install passed normal lifecycle, all18 no-bundled-Codex tar checks, packaged daemon/provider verification, native startup/open/close and CLI/doctor checks. Official optional npm Codex dependencies are retained; no local Cargo/Rust/Docker/runner-source build occurred. Literal d67 private staging composition and literal60bc npm remain separately attributed. + +Ordinary local onboarding completed with the explicitly authorized OpenAI API key saved in a managed connection, and created Legacy preservation QA. The local subscription device flow expired; no subscription was saved. No credential values were written into prompts/reports. Native saved-account setup exposed a real published UI defect: the selected native runtime delegates to Legacy readiness and can trigger a second forced CLI probe, failing `Command not found in PATH: "codex"` despite successful packaged native resolution. Current private #15683 already contains the native setup probe correction, but its artifact/UI has not been tested here. Configure navigation cannot adopt the failed connection; no developer PATH or global Codex workaround was used. + +The supported published API created a separate explicit-native local QA agent with the saved responsible-user OpenAI API-key binding. Real UI task [NPM-2](http://127.0.0.1:3136/NPM/issues/NPM-2) completed native runs88eeb636 and4990be3e in43/46seconds. Independent26/52-byte downloads, exact random challenges, negative newline controls, durable attachments and run-attributed work products pass. Same saved account/grant and execution workspace are recorded. A self-added200-cent monthly cap correctly paused after unpriced metered gpt-5.6-luna usage; spent0 is not proof of free use. Removing only that supplemental cap to ordinary budget0 and explicit resume caused a recorded adapter config-revision freshness reset. The second provider session is fresh, so strict same-provider-session continuity fails and is not waived. No pricing or production budget policy was changed. + +Fresh staging [RUN-2](https://runner-canary-d67-qa-oct-13.staging.paperclip.app/RUN/issues/RUN-2) completed native runs7d242e7b and a8e62d9c in93/73seconds at d67/cloud:38392212140:1. Independent26/52-byte downloads pass; recorded UI/PRP events show session.started then session.resumed with the same provider session and workspace. Source-bound UI proof is distinct from a direct stored-row/full-grader export, which was unavailable. Its setup green was a Legacy hello and remains excluded as native readiness proof. + +Existing local Legacy full execution settings survived native enablement, an unrelated title edit and reload byte-for-byte; that control made zero provider calls. Both native QA agents are paused after four total authorized manual task/follow-up messages, zero test reruns, with normal product recovery unchanged. Local live runs are0. The installed preview remains [3136](http://127.0.0.1:3136/NPM/issues/NPM-2). Historical3125 is stopped, with its original data intact; no current safe staged source-build preflight was available and it was not restarted. Staging remains pinned/user-managed and available for inspection, with no production/default promotion. + +The existing exact-source file/artifact graders were reused. Strict registration helper title checks fail because the manual prompts requested readable titles rather than filename-as-title; correct attachment/run attribution passes separately. No metadata was rewritten to manufacture a pass, and no full catalog-cell pass is claimed. Physical cleanup of setup lease1f2b04ce/provider resource622b8bb6 and provider invoices remain independently unconfirmed. The separate25-dollar reservation and original248.179254104 held/consumed ledger preserve all unknown and cleanup holds. Metered model pricing remains unknown/unpriced. Required next gates are corrected native setup artifact qualification, priced-model budget acceptance and authorized physical cleanup confirmation; this does not make the default rollout production-ready. + +Detailed receipts and sanitized report: `/private/tmp/runner-d67-published-canary-qa/evidence/`. Public package admission/source receiptc9eeaf…; native consumer receipt45b613…; paid-pair offline integrityb6bb4e…; exact account attribution is a separate six-field projection. These are literal qualification sources, not the unmerged root HEAD1a0ca662. Record-only changes are retained locally for the next actual reconciliation commit; no doc-only CI churn or dependent branch mutation was introduced. + +The sanitized report is uploaded and independently download-verified as attachment-backed artifact work product122676da on the unassigned completed operator [NPM-3](http://127.0.0.1:3136/NPM/issues/NPM-3). It does not wake an agent or close the feature gates. Native task artifacts are unchanged. The authorized Claude owner received the prerequisite source/qualification boundaries; no ownership or allowance was reassigned. + + +#### 2026-10-13 Authorized Codex defaults finish and merge + +The human requested that #15683 be finished and merged normally. A separate incremental $100 ceiling and as many bounded messages as needed are explicitly approved. The previous four-message/$25 campaign and original$250 ledger retain all unknown-charge and cleanup holds; they are not refunded or reassigned. Reuse local3136 and owned staging stack7998; preserve their data and receipts. Root owns actual-master reconciliation, exact-head checks/review, ordinary Mac/managed onboarding, checked task/follow-up and normal protected merge. Claude owner retains dependent branches and separate allowance. No production deployment or provisioning-default promotion is authorized. No timer is recreated. + +Current-master integration includes `833769ab557255b11dd8f650f43ed7333653f5e4`, then `e3029c632b26b68595adc1a704a387833b3cefcb`. The ordinary merge retains the published Codex history and prerequisite squash history. Its audited logical source delta preserves master fixes, the prepared managed Connect qualification, fresh scoped discovery, and existing first-task eval assertions. The remaining merge conflicts concern Storybook indexes and dated reviews, not execution code. Independent review of both source compositions found no concrete P1/P2 regression; source review does not establish live acceptance. + +On the integrated checkout, two focused Codex runs pass 261 tests each across onboarding, runner selection, setup, editing, imports and readiness. Server and UI direct TypeScript checks pass after JavaScript-only shared/DB/adapter-utils/SDK preparation. All token gates pass. New master recovery controls initially report a missing declared trace test dependency and a sandbox-denied PostgreSQL listener; the four unaffected suites pass 74 cases. Isolated installation of the exact declared trace dependencies and an authorized temporary listener allow the two affected suites to pass all 60 cases. No tests are disabled, no lockfile or production policy is changed, and no local native/Docker build occurs. Full canonical workspace checks remain assigned to fresh hosted CI. + +Fresh-source remote package qualification, ordinary Apple Silicon onboarding and immutable managed staging onboarding remain pending. The previous canary60bc and stagingd67 receipts stay attributed to those sources. The new budget record reserves resources60/provider30/cleanup10 dollars and retains all prior unknown holds. The supported private Cloud skill and CLI are read from exact private master `4f3a63ad6f3f84c87e0e129da35909d8ccc7ffb0`; a deliberate pre-merge Core preview is separate from actual private Cloud distribution. Freeze the final reviewed product source before building; deploy only the owned pinned QA stack, with `set_default=false`. + +Published intermediate448db859 passes the focused source controls and starts fresh hosted checks. A bounded managed-image review then identifies a concrete setup mismatch: real sandbox tasks select provider outer isolation, while `probeNativeRunnerEnvironment` overrides the verified transport choice with false. Correct this internal forwarding and derive its value from the server-resolved target; preserve local/SSH Codex isolation and removal of injected environment flags. The obsolete remote package qualification38423256993 is cancelled to limit costs and is excluded from final proof. Native/Docker work remains remote. Current source and new-head qualification must close the corrected boundary before acceptance or merge. + +The maintained Daytona execution image is distinct from a standard Core app image. A saved immutable OCI digest without snapshot/template fields uses normal environment probing, measured OS/architecture/libc, actual image binding and confirmed lease cleanup. The existing full-stack E2E workflow owns its content-addressed execution image and one explicit managed-onboarding cell; its image build and live cell are not yet dispatched. A Core app image cannot be assumed to have compatible Daytona boot behavior merely because it contains the daemon. No provider credential is read to work around this boundary. + +The isolation correction changes three production lines, with no API/configuration field or permission change. Inert launch baseline1/8 fails; matching treatment8/8 and23 inert/source neighbors pass. Selected-target server baseline12/12 fails; the full treatment suite51/51 passes, including contradictory configuration/environment flags. Direct server no-emit and independent source review pass. Read-only and approval policy, owner-bound credentials, deadlines and cleanup remain intact. A separately retained overbroad local-listener scaffold failure is not counted as native execution proof. + +Intermediate448 hosted CI completes successfully. Fresh review4/5 correctly identifies the new managed case entering a local-only confirmation fixture. Baseline reproduces two failures; scope the existing local fixture correctly, retain all twelve local cases and the thirteenth explicit managed case, and reuse the separate managed-fixture checks. The full existing E2E support run passes2694 controls and exposes two more stale roster assumptions. Update those assertions while retaining the completion judge for accepted managed work and every Pi admission/negative control. No parallel suite or replacement grader is added. Final source still needs fresh published checks/review and live qualification. + +Embedded-browser inspection of the built448 stories confirms the dark shared Runner popup and saved Legacy choice. It also exposes remaining OS-native environment/lifecycle/ACP dropdowns on the relevant runtime page. Their narrow shared-picker conversion preserves all values and callbacks and leaves unrelated provider-specific controls alone. These UI changes require a new JavaScript-only Storybook build and rendering checks; the old build remains literal448 evidence. No screenshot baseline is created or changed. + +The corrected source passes the full existing E2E support suite:125 files/2697 tests, plus all128 existing Node source/qualification contract controls and direct E2E no-emit. Relevant dropdown baseline145/145 and treatment152/152 pass, including exact save requests for selecting/clearing an environment while retaining Legacy/native execution and accounts. UI no-emit and token gates pass. Independent review of the managed-isolation and confirmation-fixture corrections is clear. These are local source checks; exact-head hosted CI/review, fresh remote packages, rendered stories and real Mac/managed onboarding still remain required. Next action is a single corrected candidate publication, then affected qualification using the existing workflows. + +Current published Codex head1a0ca662 is draft with green historical checks. Actual master833769ab is newer than merged prerequisite d67 and release repair60bc. Prepared private onboarding/Storybook work will be reconciled by source, without claiming its private/historical checks as final qualification. Native Connect correction is already in the Codex slice. Final managed defaults require the saved immutable OCI environment probe owned by ordinary Connect. Existing suites/fixtures/assertions/reporting are reused; no duplicate grader or silent Legacy fallback is added. All heavy builds stay remote, Codex payloads remain external official npm dependencies, and saved agents retain runners. + +Next action: preserve/commit current evidence, integrate actual master plus reviewed prepared corrections, run targeted checks, freeze/push candidate, require fresh fullCI/review, then qualify ordinary creation/onboarding on Mac and the one staging target before protected squash merge. + +The frozen30bc candidate passes all current-head required CI and fresh5/5 review. Its existing remote release-smoke qualification passes all four producers and clean platform consumers. The actual managed onboarding cell38428601763 fails before agent creation: scoped discovery selects native, but Codex0.160 attempts a nested filesystem sandbox while scanning AGENTS.md for the read-only setup session. Both measured setup leases release successfully; the separate fixture environment deletion fails because the fixture leaves it selected as instance default. Preserve this failed attempt, unavailable provider billing and cleanup holds. No task artifact or follow-up is reached. + +The correction skips project instruction discovery only in the new empty setup workspace through the supported trusted Codex configuration. It retains the generated security arguments, read-only permission profile, approval-never, no dynamic tools, existing bounded hello turn and teardown deadlines. Ordinary task instruction discovery and saved agent configurations remain unchanged. Pinned official Codex0.160 source confirms the zero instruction limit precedes filesystem probes. Eight inert baseline failures become23 passing source/cleanup neighbors; direct runner/server TypeScript and maps pass. This does not establish hosted treatment. + +The existing managed first-task fixture now snapshots and restores its prior instance default and managed-only setting before deletion, including lost setup responses. Unconfirmed default restoration blocks deletion and remains red; other restoration/deletion errors remain inspectable and retry only outstanding cleanup. Three reproduced baseline failures become129 passing first-task controls, with direct E2E TypeScript passing. Independent source review finds no actionable runtime/permission P1/P2; the required source-comment cleanup is applied. Final source needs fresh CI/review, package artifacts and successful ordinary Mac/managed journeys before normal merge. The one owned Core preview deployment for30bc completes through its existing exact-source check/deploy path; actual readiness is independently checked next. No fleet default is changed. + +Corrected product source ca31623d36a3d63204077e2e951ff9cdcf2664cf passes all48 ordinary hosted CI jobs, required verify/E2E and fresh5/5 review with26 resolved threads. Its hosted package artifact11834160973 has verified ZIP digest d5d60bf5056f5573461f8d4bd873da78a0204c7f7295d03d5aa7234e55d4af69. The second isolated Mac consumer verifies all18 tarballs, no bundled Codex, official npm Codex0.160, all four daemon bindings and zero-turn native startup/cleanup. Normal install hooks include third-party optional C++ addons; no Paperclip runner/Cargo/Rust/Docker source build occurs locally. The source-bound preview completes one-target deployment38438245490 to the existing pinned QA stack, without default promotion; independent result admission remains separate. + +Before paid cell38435063658 entered its matrix job, an admission audit found that ordinary onboarding timeout0 and agent concurrency20 did not satisfy this campaign's retained180-second/single-run bounds. Cancelled the campaign before its live cell started; image/build costs remain unknown and held. Extend the existing first-task fixture with a normal operator configuration PATCH and independent read-back before Get started. Only the timeout/concurrency bounds may change; runner/account/model/environment/permissions remain the wizard's original selection. This is a fixture correction, not a production default or API change. Its first iteration passes149 controls but independent review identifies the concurrency gap. The final combined bound reproduces three functional failures with the previous helper, then passes153 controls and direct E2E TypeScript. Independent review confirms that the timeout reaches the native turn deadline and the queue uses the saved single-run cap; exact source review closes before publication or further live eval admission. No failing or cancelled attempt is relabeled as passed. + +The owned local3136 server is updated from30bc to literal ca316 hosted packages after an idle snapshot and stopped-data/home backup. All existing full execution configurations and recorded run projections match before/after, with no provider tasks during upgrade. The fresh Mac company's saved draft resumes with its agent name retained. Ordinary OpenAI API-key Connect creates native Codex with gpt-5.6-sol without a runner override; only the existing QA timeout/concurrency settings are then bounded before Get started. Actual useful task/artifact/follow-up proof is still pending. The staging immutable QA environment is selected temporarily as the instance default, with the prior Paperclip Computer value recorded for restoration. Its normal server credential fallback has not yet been verified. No direct provider-key extraction or production rollout is attempted. + +The literal ca316 Mac journey now passes independent exact-file and registration checks for29/63 bytes, saved API-key account binding, native Codex provider session and workspace continuity across all five executed runs. One queued child run is cancelled before start and retained in evidence. Two manual messages and two confirmation responses produce five successful native runs; no process-loss retry occurs. Both task and child finish Done, and the QA agent is paused afterward. Rate-card estimates total$3.504199600, not invoice cost. The existing strict artifact/session helpers are reused offline; this is a real UI journey with independent evidence, not a full catalog-cell pass. Receipt7ef97c4705b085d465eeaf19f11d231e7b01e5a66c61b517cfcde0ec70a2db97 remains literalca316. + +Candidate0065 has fresh5/5 review and26 resolved threads but its required E2E gate fails: the unchanged signoff fixture receives a cancelled run while waiting for executor success on GitHub's combined master checkout. The fixture discarded errorCode, so the precise cause is inconclusive. Preserve the failed gate; add only a bounded diagnostic to the existing assertion without weakening ownership, authorization or terminal-state checks. Managed38440601585 fails during image preparation after the hosted runner reports shutdown; its provider pack/live cell are skipped. No named live cell or provider task is admitted. Unknown preparation costs remain held; no automatic retry is dispatched. + +Actual master252d9b5077f4eee98ec48225641ef1a02d824158 is integrated through an ordinary merge. Its only conflicts are the three Storybook catalog files. Semantic union preserves226 authored entries,2242 stories, stable IDs, both review histories and actual evidence limits; the existing generator,27 catalog/map controls and structural map checks pass. Runner/Rust/Codex selection/setup/onboarding/guard sources remain byte-identical. Master adds instance-secret storage and migration0339, including its account-home claim check; keep those changes and require fresh combined-source account/lifecycle controls, JS/DB installation, CI/review and current-source managed acceptance. Prior receipts retain their original revision. + +On the owned staging stack, ordinary environment Test connects to the immutable image but cannot confirm probe-lease cleanup. New-agent discovery remains Legacy; the supported onboarding entry surfaces the exact pending-cleanup error before hiring. This is a blocked managed acceptance, not a native pass or a silent runner conversion. The QA environment had autoDeleteInterval0; source ordering makes immediate deletion after stop before archival a plausible cause, not a proven provider diagnosis. Attempting to change its QA interval to60 is correctly rejected while cleanup is pending, so persisted configuration remains0. Preserve the failed lease, its holds and the guard; do not clear durable state or extract provider credentials to manufacture qualification. Next action is confirmed owned-resource cleanup, then qualification of corrected QA configuration and normal native setup/task/follow-up. + +Combined-source integration passes100 existing controls with the repository launcher's canonical isolated HOME/config/TMPDIR contract:41 device-login,12 real-PostgreSQL instance-secret and47 lifecycle tests, zero skips. Initial direct-invocation99/100 and alone identity-verdict failures remain recorded; no expectations or product code were changed. No on-disk worktree config write is observed, and3136 uses a separate preserved home/data. Direct server/E2E TypeScript pass. The existing signoff failure assertion now retains only a bounded verified errorCode; ownership, issue checks, deadlines, retry semantics and terminal failures stay intact. Actual-helper baseline12-pass/4-missing-diagnostic failures become16 passing inert controls, with no browser/provider work. This improves subsequent diagnosis and does not claim to repair the earlier cancellation. + + +The integrated6c candidate now passes all48 ordinary hosted jobs, required verify/E2E and fresh Greptile5/5 with26 resolved threads. The earlier signoff case passes on this combined source; its earlier0065 cancellation remains an inconclusive historical failure, not a proved flake. The remote four-platform/package qualification38445419339 binds literal6c. A single guarded managed onboarding cell38448001365 also resolves literal6c through the trusted master workflow and independently verified target lock; its exact selection and180-second/concurrency1 guard pass admission. No second paid cell or automatic test rerun is dispatched. + +Master advances once more to c8efdbfba43377d39f57e4822e92d796a8a044b6 through Storybook-only #16196. Its only conflicts are the same three catalog files. Semantic reconciliation preserves all226 candidate suites and37 new master suites,263 suites/2499 stories, stable IDs and both review histories. Existing27 catalog/generator controls and35-area/175-ID map checks pass. Independent virtual-merge and actual staged checks prove every production/native/package/build component remains byte-identical to6c. Keep the6c package/image/live receipts literal; this catalog-only merge requires fresh head CI/review and applicable Storybook checks, not another expensive native build or paid runtime campaign. + +The supported Cloud CLI is re-read from current private master8472ece87f8d9a9e17a6116b9f4631d98ce63486 and is byte-identical to the previously used maintained command. Its one-target preview request d227da49-fd73-44c0-9377-f25711e529bc builds literal6c remotely in38448052890, preserving the existing pinned/user-managed QA stack and leaving fleet/default promotion disabled. Build, deployment and actual serving-source verification remain pending. A separate QA environment with the same immutable execution image and autoDeleteInterval60 is saved through ordinary UI, with instance default unchanged and no probe yet. The prior failed environment, durable teardown record and cost holds remain intact. It cannot borrow the older environment's qualification facts; it needs its own new saved probe and confirmed matching cleanup before automatic native onboarding. + +Existing ordinary board lease APIs and CLI diagnostics are identified, but anonymous challenge creation and the installed CLI's existing board context both receive401 at this tenant's public edge. No browser/provider credential is extracted and no new inspection API/workflow is introduced. The plugin settings route correctly honors Cloud's hidden-settings gate. Existing Fleet Admin provider links reach the normal signed-in Railway log UI; two successful aggregate orphan-cleanup sweeps prove the controller operates, but neither receipt binds the exact outstanding probe lease. Preserve that uncertainty and its hold. Current-source installed JS/DB migration/account preservation, independent managed task/follow-up and normal protected merge remain the next gates. No timer or production deployment is started. + + +#### 2026-10-14 Codex qualification and new Test environment reconciliation + +Frozen product9546f41e964953c801ad8c2d8bdecadbd5cd3830 passes current-source required CI and fresh5/5 review with30 resolved threads. Its existing four-platform/package campaign38485482843 succeeds: all18 tarballs/four daemon manifests, normal isolated Apple Silicon install, native credential-free startup/open/close and fresh installed DB initialization through0343 are independently verified. This source-built qualification package is not a registry-published npm canary. No Codex payload is bundled, and no local Cargo/Rust/Docker/native source build occurs. + +The original managed onboarding case38485488621 passes its19 assertions and completed judge, without retry. Four actual native turns execute the child-bound240-byte file and real completion callback/reply with parent session/workspace continuity. Its checkout reports dirty state: the verified generated target lock replaces the committed lock, but full final porcelain is unavailable. Literal HEAD, runtime/assets and effective graph are verified separately. The reused signed execution image keeps its historical producer identity. Guarded environment removal and server shutdown pass; physical deletion and invoices are not certified. Earlier failed attempts and unconfirmed cleanup holds remain intact. Public evidence: https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-38485488621-1/index.html. + +Ordinary fresh9546 Mac onboarding persists native Codex without a runner override before any QA bounds. The first useful document task and actual Done-parent completion callback execute successfully. One human follow-up updates the same child document to revision2 and publishes a child-bound1,101-byte file. Independent API and real browser downloads match SHA256b758f6d23b33cfa9cfc597a4c3bc2abbf74dc56b7b5d2b30fc45f65c1a4d1905. All seven actual native runs succeed; parent sessions/workspace continue, child execution has its own task session, observed concurrency1, longest run under47seconds, reload preserved. Two recovered proposal-format errors remain a nonblocking quality follow-up. + +Existing installed Mac upgrade preserves all three execution configurations, safe account summaries and eight historical recorded run profiles. The existing Legacy agent's ordinary title edit/reload keeps execution settings, and its shared Advanced dropdown retains Legacy. Two actual Legacy/direct-adapter runs complete the same task and independently checked15-byte then34-byte files. Prior session state is passed and workspace continues; returned provider UUID changes, so stable provider-ID continuity is not claimed. After explicit human authorization, the root-created company QA guard is restored to its original0 and a500-cent hard stop is scoped to the selected Legacy agent. Historical unpriced costs/holds remain unchanged, normal service resolves only the new QA incident, other agents remain paused, and the company is paused again after all runs finish. Rate-card estimates: Mac3.4154336, managed1.170946, Legacy0.114767 dollars; none is an invoice. The separate100-dollar ceiling and all original250/25-dollar unknown/cleanup holds remain enforced. + +The human selected the actual new Test environment for pre-merge acceptance. Its deployed policy supports verified branch previews, unlike Staging's current master-ancestry restriction. Normal Test email-code sign-in has sent a code and awaits the human handoff; no OTP redaction workaround, session impersonation or credential extraction is used. No actual owned Test tenant, schema admission, deployment or live task is claimed yet. The maintained one-target CLI and deliberately core-only preview path are prepared, with existing snapshot/migration-role/health/lock protections and set_default=false. Private Cloud composition and production/default promotion remain separate post-merge gates. The human also authorized a code-owner-review exception for PR15683 only, after all current-head CI/review/live gates pass; no global repository rule is changed and no other PR's approval is waived. + +Remote preview38498802456 completes at exact9546, through trusted workflow definitionb8ffeb973a2a1d430efabffd755f15b6c7cf112a. The ready result matches its single request; immutable Core image digest4cba5c3546c3af3b948f0ee9c3532ada7c754491ce2d175a00164adc46698976 and exact9546 DB/shared registry archives match producer bytes/integrities. This Core preview has no standard signed-image attestation; npm SLSA structure is source/invocation-bound but cryptographic verification is not claimed. The signed execution image is a distinct artifact. No image layers or large transfer archive are downloaded locally. Terminal admission receipt: /private/tmp/current-9546-preview-preparation/TERMINAL-PREVIEW-PAIR-QUALIFICATION.json, SHA25692faae84c89e9e37e23129b6a5cd18580ed1c09517db91286afdf1445e3b5c6f. + +Before merge, actual master advances to875943ac83e730fd4cfbe1e1860b3f4d7bf0ac78 and GitHub reports five content conflicts. Ordinary reconciliation preserves the Codex shared exports, flag-free Codex runtime, unified harness picker/display and incoming Grok Bot exports, three-flag admission, dedicated connection and Execution/Usage summary. The shared display helper additionally preserves Grok Bot branding; it must not label that existing external provider as Paperclip Runner. Seven narrow controls extend the existing display, mounted configuration and runtime-selection suites. An initial new test expected a saved connection section from an unsaved fixture; it is corrected to the existing save-first guidance without changing production behavior. Required maps now align35areas/176 capability IDs and direct token gates pass. Incoming shared Rust/backend/protocol bytes and migration0344 are materially different:9546 artifacts/live proof remain literal, and final integrated native/package/DB qualification must be refreshed remotely. No evidence is relabeled as combined-source proof. + +The resolved composition passes226 cases across the five existing runtime/display/mounted/selection/heartbeat suites. Direct server and UI no-emit checks pass after JavaScript-only Runner SDK declaration preparation and isolated installation of the exact declared Speko0.0.13 dependency with lifecycle scripts disabled. Initial missing-dependency/stale-declaration diagnostics remain setup limitations; no production workaround or dependency/lockfile change is made. All three existing token checks and staged diff checks pass. Fresh hosted full checks/review and remote native/package qualification remain required. + +Current inspectable QA results remain on local3136/3137 with preserved data. The normal board context publishes the sanitized qualification checklist as an attachment-backed primary work product on NPM-3; Done/unassigned status remains and no agent is woken. Canonical PR: https://github.com/paperclipai/paperclip/pull/15683; branch codex/codex-runner-default. Test login/owned target, refreshed reconciled-head gates/artifacts and real Test onboarding/task/file/manual follow-up remain the next action. No timer, merge, production deployment or provisioning-default promotion is performed in this checkpoint. + +#### 2026-10-14 current Test deployment and native cleanup failure + +Exact published651a passes fresh current-head CI/review and the remote four-platform/package campaign38502151368. Its normal installed Apple Silicon startup, fresh343-migration initialization through0344 and upgrade preservation pass; earlier9546 live evidence remains literal. The immutable preview38503641696 and matching DB/shared bytes pass independent admission. No Codex payload is bundled and no local Docker/Cargo/Rust build occurs. + +The human completes customer and separate Fleet Admin sign-in. One fresh customer-owned Test workspace is cold-provisioned, pinned through normal Admin UI and verified after reload. The maintained one-target core-only prepare/deploy succeeds with exact651a app/schema and authenticated serving-source verification, preserving the pin and fleet default. This unsigned branch preview is not the post-merge private Cloud distribution. Its execution image retains the distinct historical signed producer ea6c. + +One ordinary Dashboard first-agent Auto Codex API-key Connect records account connection and environment probing on the saved immutable QA target. Native setup then fails before hire: runtime closing exhausts the existing10-second cleanup deadline and authority release remains unconfirmed. The failed payload is delivered after110573ms; HTTP200 is not readiness. No agent, task, follow-up, Legacy switch or unchanged retry occurs. Hello usage, physical cleanup and invoices remain unverified and held. The failure receipt is SHA2561bfce873af81f2424d9ca66dfd64404a21f19e386c3b51e2e0e08cb3f6e37be3. Actual Test acceptance blocks merge despite the scoped code-owner exception. + +Read-only forensics reproduces two relevant bounded-close defects through the existing suites: a remote durable-state read can exceed its remaining suspension deadline, and an authenticated WebSocket awaiting peer close acknowledgement can outlive the setup budget. Late suspension proof is already rejected, and authority is retained; the repairs must preserve those protections and require actual physical socket closure before release. These reproductions establish source defects, not the sole cause of the live failure. The retained controller projection and provider request logs contain only permitted metadata; credentials, prompts and provider keys are not extracted. + +Next action: complete narrow deadline/physical-close repairs and independent review, retain baseline failures, freeze a new candidate with fresh applicable gates/artifacts, recover only confirmed owned cleanup, and repeat changed-source Test automatic setup/task/checked file/manual follow-up. Then merge the exact ready head under the human's scoped exception and discover/wait for actual npm publication, signed standard image, migrator and private Cloud composition. Production deployment/default promotion remain outside authorization. The original timer stays cancelled and all prior unknown-charge/cleanup holds remain intact. + +The narrow repairs now pass source controls and independent review. Four delayed-state baseline failures become15 passing deadline/containment/authority controls, with direct runner runtime TypeScript passing. Ingress's original no-acknowledgement regression fails before repair; a second review reproduces an earlier-error socket escaping active ownership. Final12 controls pass after retaining every owned socket until its physical close, with direct server TypeScript passing. Missing physical-close evidence remains blocked, and the original10-second setup deadline is unchanged. Maps35areas/176IDs and diff checks pass. These JavaScript repairs change neither permission policy nor native Rust; new-head hosted gates and actual Test treatment remain required. + +The actual retained Test controller records turn.accepted/started/completed and completed drain/suspend commands. Their stored timestamps are command issuance times, not completion times, so they cannot identify which close stage exhausted the deadline. The internal setup identity differs from the public environment lease; their correlation remains contextual. No hello content, provider invoice or physical deletion is inferred. The local3136 evidence viewer is restored through normal installed651a startup after a current DB-only read-only idle/paused check. All three agent configurations/accounts and ten recorded run identities match its previous651a snapshot. The refreshed primary NPM-3 report is independently downloaded and hash-verified, preserving Done/unassigned status and historical cleanup state. Local3137 and all prior evidence stay unchanged. diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md index 750a741b2f2..5988d4eace8 100644 --- a/maps/agent-access-map/agents.md +++ b/maps/agent-access-map/agents.md @@ -11,6 +11,12 @@ Each capability needs operation-level review before its entire scope can be qualified. No live agent execution is claimed. Follow the [map contract](../README.md) for format, permission checks, evidence and maintenance. +Scoped runner-default source review: 2026-10-12 at +`eee0534154eaf5d0d3f2bbc97c1e24d86f34a5a5`, limited to creation/configuration, +approval activation, built-in provisioning and import preservation described +below. The original baseline commit above still governs unrelated entries; this +update does not claim operation-complete review or live agent verification. + ## `hire` Review: `baseline`. @@ -26,17 +32,45 @@ native hires. Policy: Agent creation/configuration uses action grants, resource policies and change consent; some controls require a board actor. +The creation boundary resolves the request-level runner before adapter validation +and credential normalization. Omitted/automatic new Codex selection uses the +qualified target's default; explicit Legacy is retained. Disabled adapters and +external overrides affect new selection, while missing credentials/runtime or +incompatible settings remain errors. Existing agents and reviewed pending hires +retain their recorded execution. Native `hire_agent` explicitly inherits its +caller's runtime and remains subject to permission and credential restrictions; +it is not an arbitrary host-configuration or login tool. REST/CLI creation and +hire runner options use the [shared request contract](../../packages/shared/src/validators/agent.ts). + Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L1395) at the reviewed commit above. +Scoped Evidence: `source-only` — [creation policy](../../server/src/services/agent-hiring.ts), +[runner resolution](../../server/src/services/agent-runner-selection.ts), +[native hire binding](../../server/src/services/native-runtime/paperclip-runner-tool-authority.ts), +[frozen approval activation](../../server/src/modules/agent-lifecycle/adapters/approvals.ts) +and [same-harness import preservation](../../server/src/services/company-portability.ts) +at the scoped commit. No permitted-agent journey was executed for this map update. + Verification: `not-run` — exercise a listed permitted operation with actual agent credentials/run context; inspect its result and applicable persistence/attribution. Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. +For this scope, compare automatic and explicit Legacy creation, assert foreign +company/environment and missing-grant denials, then compare pending approval +configuration across restart and activation. An unrelated edit/import update must +not change existing execution; a new package without a runner choice receives +creation defaults. Do not count a board call or component test as agent evidence. + Gaps: Identity/persona hiring works. Operator account login and sensitive adapter/environment configuration are separate guarded actions. +The runner request does not grant new account, environment-probe or native API +fallback authority; see [validation access](./runs-adapters.md#validation) and +[environment administration](./execution-environments.md#probe). Full access +qualification remains not run. + ## `instructions` Review: `baseline`. @@ -141,6 +175,13 @@ remaining operations before qualifying the whole capability. Gaps: Provisioning checks agents:create. Enabling/running its built-in routine is not the same permission path. +Scoped source review: [built-in provisioning](../../server/src/services/built-in-agents.ts) +recognizes the underlying Codex harness of a native company agent. New configured +provisioning resolves the runner, while unrelated reconciliation and same-harness +edits retain existing execution. Verify explicit runner changes preserve compatible +saved settings; the general creation/access policy above still applies. This is +source-only evidence at the scoped commit, not a live provisioning result. + ## `primary-agent` Review: `baseline`. diff --git a/maps/agent-access-map/onboarding.md b/maps/agent-access-map/onboarding.md index f4eb025f50c..780dc5b9334 100644 --- a/maps/agent-access-map/onboarding.md +++ b/maps/agent-access-map/onboarding.md @@ -9,6 +9,11 @@ Each capability needs operation-level review before its entire scope can be qualified. No live agent execution is claimed. Follow the [map contract](../README.md) for format, permission checks, evidence and maintenance. +Scoped runner-default source review: 2026-10-12 at +`eee0534154eaf5d0d3f2bbc97c1e24d86f34a5a5`, limited to first-agent selection, +selected-runtime validation and draft restoration. Unrelated baseline entries +retain the original source commit; no live agent verification is added. + ## `instance` Review: `baseline`. @@ -74,10 +79,58 @@ provider-account/environment setup. Policy: Instance setup is operator work; company creation is board; agent creation/task creation retain their individual permissions. +The wizard selects the Codex harness and lets the server resolve an automatic +new-agent runner; explicit Legacy remains available. Supported-platform and +immutable sandbox evidence follow [environment probe policy](./execution-environments.md#probe). +Creation/discovery do not provision a target to determine this default. Adapter +setup validation requires `agents:create`, plus saved-agent update access when +testing an existing agent; interactive account login additionally requires a +board user and owner-bound session. An agent's right to hire is not login or +instance-environment administration authority. + +For fresh managed Codex Auto setup, the wizard's explicit Connect path can call +`POST /api/environments/{environmentId}/probe?companyId={companyId}` before +selected-runtime testing, then refresh scoped adapter discovery. This reuses the +existing board instance-admin/local-implicit probe gate; `agents:create` alone +is insufficient. A 403, absent proof, changed configuration, missing response +identity or unconfirmed cleanup remains visible. No probe is added to creation +or discovery, no agent/company grant is broadened, and provider authentication +errors never cause an automatic runner switch. + +Native setup derives outer sandbox isolation from the server-resolved execution +target, as task execution does. This is an internal transport choice, not a +request/configuration permission. Local and SSH setup keep Codex isolation; +caller-supplied environment flags do not authorize an external sandbox. Existing +company, account, probe and registration-cleanup gates still apply. +The bounded hello probe retains read-only permissions and no dynamic tools. Its +trusted launch skips project-instruction discovery in the new, empty setup +workspace. This grants no filesystem or networking permission and does not +change instruction discovery for ordinary task execution. + +Scoped source review: 2026-10-13, exact baseline +`1a0ca66242121e39d88455aa2b52a07acc4a07d9` plus the privately composed onboarding +correction. [Wizard controls](../../ui/src/components/OnboardingWizard.test.tsx) +and [existing Product E2E observer controls](../../tests/runner-e2e/first-task.test.ts) +exercise mocked allowed/denied setup and sticky unknown cost coverage. They do +not prove live actor authorization or provider execution. The explicit manual +`confirmation-replies.runner-codex.daytona.task-card-accept` case requires an +eligible immutable OCI target and the existing probe authority, two identified +setup leases and separately authorized paid/cleanup reservations. Its fixture +neither prequalifies the environment nor creates the agent. Actual Cloud named +snapshot binding, warm/cold signup and live allowed/denied agent checks remain +`not-run`. + + Evidence: `source-only` — [implementation](../../ui/src/components/OnboardingWizard.tsx#L279) at the reviewed commit above. +Scoped Evidence: `source-only` — [wizard/default selection](../../ui/src/components/OnboardingWizard.tsx), +[creation resolver](../../server/src/services/agent-runner-selection.ts) and +[setup/login authorization](../../server/src/routes/agents.ts) at the scoped +commit. Existing agent/approval execution preservation follows +[hiring access](./agents.md#hire); no live onboarding proof is claimed here. + Verification: `not-run` — exercise a listed permitted operation with actual agent credentials/run context; inspect its result and applicable persistence/attribution. Assert the relevant denied actor, owner, company or mode from Policy. Review the @@ -86,6 +139,11 @@ remaining operations before qualifying the whole capability. Gaps: Account authorization, environment administration and model selection need separate checks. +Verify automatic onboarding without a post-hire runner change, explicit Legacy, +saved configuration after reload and the actual selected runtime's useful task +plus follow-up. Assert denied agent login/probe and foreign-company account or +environment access separately. These agent-context checks remain `not-run`. + ## `first-task` Review: `baseline`. @@ -110,6 +168,20 @@ remaining operations before qualifying the whole capability. Gaps: Task creation is agent-accessible where permitted; scheduling/readiness does not follow from successful creation. +The Codex first-task fixture's operator cost guard uses the existing board +agent-configuration PATCH before admitting work. This is not evidence of agent +permission to update its runtime; the listed agent-context checks remain not-run. + +Scoped source review: 2026-10-13, the first-task asset correction against +`ea6c8eb5e16d61111968f633a66748b397b64a3e`. The existing +[first-task instructions](../../server/src/onboarding-assets/first-task/skills/first-task/SKILL.md) +send file-producing work through the child's own execution and existing +dependency/block controls. Native `register_deliverable` retains its current-task +binding; this adds no cross-task file-registration authority or permission. +Document-only work may use the existing child-document API inline, and plan-only +approval does not authorize implementation. The existing asset tests check +wording only. The literal baseline live attempt failed child-file delivery and +completion reporting; revised live authorization and execution remain `not-run`. ## `resume` @@ -134,3 +206,20 @@ evidence. Gaps: No server onboarding-draft API for this browser-local state; classify explicitly or intentionally make it a cross-client product feature. + +Scoped source review of [draft restoration](../../ui/src/components/OnboardingWizard.tsx) +preserves harness/runner intent and invalidates obsolete setup-test results after +a selection change. Browser draft continuity is not API or live-agent evidence, +and opening a saved Legacy agent does not apply new-agent defaults. + +The initial matching route opening preserves the authorized saved wizard step +on bare and company-prefixed reloads. Explicit new-company or changed-company +openings retain their navigation override and clear foreign company resource +IDs. [Existing component controls](../../ui/src/components/OnboardingWizard.test.tsx) +exercise these presentation and account boundaries. This scoped correction adds +no server draft API, agent grant or proof of live actor authorization. The +managed first-task eval still reloads before Get started and remains a separate +live acceptance gate. The existing mocked-login browser case also checks the +same company-scoped active session and saved selection through route reopening +and a real reload, with one login start and no pending-login hire. These fixture +assertions do not prove server authorization or live credential use. diff --git a/maps/agent-access-map/recovery.md b/maps/agent-access-map/recovery.md index 2fa28a9abb3..686273869f1 100644 --- a/maps/agent-access-map/recovery.md +++ b/maps/agent-access-map/recovery.md @@ -62,6 +62,17 @@ Gaps: Eligible resolutions use native `call_api`. The route retains owner and management authority, active-run/source-trust checks, board-only outcomes and reconciliation controls. +Scoped source review: 2026-10-13, recovery correction against +`ea6c8eb5e16d61111968f633a66748b397b64a3e`. The +[recovery service](../../server/src/services/recovery/service.ts) reuses the +existing completed-onboarding handoff validator. It checks the source company, +onboarding origin, Done parent, assignee and Done child, with no unfinished +siblings, before exempting the report from the issue-terminal backstop. Other +terminal work and process-death/controller ownership checks remain enforced. +This internal wake handling adds no recovery-resolution or cross-company agent +authority. Real PostgreSQL regressions cover allowed and rejected bindings; +revised live agent authorization and callback delivery remain `not-run`. + ## `source-action` Review: `baseline`. diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index 9316af9c19c..7f8076b487b 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -11,6 +11,11 @@ Each capability needs operation-level review before its entire scope can be qualified. No live agent execution is claimed. Follow the [map contract](../README.md) for format, permission checks, evidence and maintenance. +Scoped runner-default source review: 2026-10-12 at +`eee0534154eaf5d0d3f2bbc97c1e24d86f34a5a5`, limited to runner discovery, +selected-runtime testing and saved execution/session preservation. The original +baseline source and review depth remain unchanged for unrelated operations. + Scoped native cancellation source review: 2026-10-12 at `ce23716503a2c9158b5a7ab74ab61571ff4ee7a3`, limited to owned startup and shutdown/readiness ordering. The original baseline and review depth remain for @@ -42,6 +47,20 @@ remaining operations before qualifying the whole capability. Gaps: Raw instance adapter listing requires board, while company model discovery exists for eligible agents. +Scoped source-only evidence: [adapter discovery](../../server/src/routes/adapters.ts) +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 +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 +a local target. Discovery and automatic creation keep the Legacy path without +provisioning it; explicit Paperclip selection returns qualification guidance. +The existing execution path owns lazy environment creation. Saved runners remain +unchanged. This is source/control evidence, not live Kubernetes qualification. + ## `validation` Review: `baseline`. @@ -67,6 +86,42 @@ remaining operations before qualifying the whole capability. Gaps: Environment test path checks creation/configuration policy; missing provider auth must use its owner setup flow. +Scoped source review: [test authorization and resolution](../../server/src/routes/agents.ts) +requires same-company `agents:create`, saved-agent update access when supplied, +allowed environment selection, and normal secret/responsible-user checks. An +unchanged saved runner avoids new-creation policy but still receives selected +runtime validation. [Native readiness](../../server/src/services/native-runtime/setup-readiness.ts) +tests native Codex startup/model/account and bounded teardown; an authentication +or Legacy CLI check cannot replace that runtime evidence. Missing dependencies, +credentials, invalid models and incompatible settings fail without silently +changing runners. Account login remains board/owner-bound. + +For a linked Codex subscription with server-resolved managed credential provenance, +setup checks the resolved owner and stages a private probe copy before local or +remote isolation. Without a selected API key, an unbound linked `CODEX_HOME` is +rejected with explicit Legacy +or managed-account guidance. Caller configuration cannot supply that provenance. Selected `CODEX_API_KEY` and +`OPENAI_API_KEY` authentication skips unrelated subscription reads and refresh +bindings, matching task materialization. The fake-credential controls verify key +precedence without changing the account source or exposing either credential. +The general native +home materializer retains its no-follow symlink guard. Refresh still targets the +checked account source, and confirmed bounded retirement removes its private +copy. Unconfirmed retirement retains the existing cleanup block and owned state. +Failures before probe handoff remove only the owned temporary preparation root; +local daemon resolution occurs before credential staging. Once a probe is handed +off, unconfirmed retirement still retains its recovery state. +[Existing fake-credential setup controls](../../server/src/services/native-runtime/setup-readiness.test.ts) +exercise linked/regular sources and failure cleanup. This adds no credential-read +API, agent login authority or cross-company account access. Its source-only +evidence does not qualify a live permitted/denied agent operation. + +Verification for this scope remains `not-run`: use an admitted agent REST/CLI +validation path, assert missing grants and cross-company targets/accounts are +denied, then prove the useful task/follow-up separately. `hire_agent` inherits its +caller's runtime; do not assume its binding accepts arbitrary runner or account +settings. See [creation access](./agents.md#hire). + ## `runs` Review: `baseline`. @@ -118,6 +173,23 @@ Gaps: Task-session listing and execution-context reset require board agent management at their HTTP routes. Native `call_api` preserves these denials; native continuation remains server-owned. +Scoped source-only evidence: [configuration changes](../../server/src/services/agent-configuration.ts) +and [native execution/recovery](../../server/src/services/native-runtime/native-session-executor.ts) +retain existing execution on unrelated edits and use recorded run configuration +for active/recovered runs. Explicit runner changes use revisions and session +invalidation. Verify saved configuration, current versus historical run identity, +restart and follow-up without granting session-reset authority to an agent. + +Scoped source review: 2026-10-13, the recovery correction against +`ea6c8eb5e16d61111968f633a66748b397b64a3e` reuses +[completed onboarding handoff validation](../../server/src/services/chat-completion-delivery.ts) +before applying the issue-terminal backstop. The company, parent origin/status, +assignee, completed child and remaining children must match. Only that backstop +is suppressed for the validated report; process-death and controller/native +ownership checks still apply. No agent gains session or recovery control. +PostgreSQL regressions cover valid native/Legacy callbacks and rejected bindings; +revised live execution and actor authorization remain `not-run`. + Scoped Evidence: `source-only` — the [native transport](../../packages/paperclip-runner/src/live/runnerd-codex-transport.ts) joins owned startup before certifying cleanup. Rejected duplicates cannot replace @@ -130,3 +202,13 @@ Scoped Verification: `not-run` — verify allowed/denied access using the actual actor independently from the credential-free ownership controls. This correction adds no account-setup, cleanup-override or session-reset authority. The real native readiness/recovery fixtures and installed provider acceptance remain separate gates. + +Scoped shutdown source review: 2026-10-14, against literal +`651a3de64fc943216cc414b64b1443809f9499da`. The suspension barrier bounds remote +state reads by its remaining deadline and rejects late proof. Intentional +[provider ingress shutdown](../../server/src/realtime/runner-prp-outbound.ts) +joins physical socket closure and retires the connection owner before endpoint +release. Unconfirmed termination stays blocked. Existing fake-peer and delayed +read controls are source evidence; this changes no actor, company, account or +session-reset permission. Operation-level verification remains `not-run` and +the failed Test setup does not qualify successful live cleanup. diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md index b80663503e1..621e271ed13 100644 --- a/maps/feature-map/agents.md +++ b/maps/feature-map/agents.md @@ -12,7 +12,7 @@ Agent access: [companion](../agent-access-map/agents.md). ## Sub-features -- `hire`: choose identity, adapter, model, and required account settings; edit +- `hire`: choose identity, harness, model, and required account settings; edit reporting relationships through the agent configuration afterward. - `grok-bot`: experimental existing-Bot agent setup through remote OAuth, routine credentials, and an authenticated two-way challenge; current contact, stop and explicit recovery. - `instructions`: edit agent configuration and instruction files with durable revisions. @@ -50,6 +50,25 @@ Automated: [new agent page](../../ui/src/pages/NewAgent.test.tsx) and [configura Manual: Hire an agent with a distinct name and role. Complete any required approval, reload the roster, and open its configuration. Confirm manager, adapter, and model persisted; run a small task to prove execution readiness. +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 +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). + +Repeat with an explicit Legacy Codex hire. If approval is required, compare its +reviewed execution configuration before and after restart and activation; approval +must retain the reviewed runner. Test an existing Legacy and native agent through +an unrelated edit, reload and restart: neither is automatically upgraded. + +Creation defaults also apply to API/CLI hires, built-ins, plugins and new team +imports. Exports preserve resolved execution choices; a package without a runner +choice uses the new-agent default, while updating an existing same-harness agent +preserves its execution. For a native Codex import, edit a model or account binding +and verify unchanged runtime settings survive; an explicit clear must stay cleared. +Use the [portability recipe](./companies.md) for package preparation and boundaries. + 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 @@ -61,6 +80,12 @@ Automated: [instruction service](../../server/src/__tests__/agent-instructions-s Manual: Save a harmless instruction edit, reload, and verify its revision and effect in the next eligible run. Pause and resume the disposable agent; confirm pause prevents new work and does not erase previous runs. +An explicit runner change is a configuration change, with normal revisions and +session invalidation. Incompatible settings must produce a field-specific error; +selecting another runner must not silently discard them or change an active run's +recorded execution. Keep runner choice in Advanced and verify the next eligible +run separately from historical transcripts. + ### `organization` Automated: [org chart](../../ui/src/pages/OrgChart.test.tsx) is component evidence. Built-in setup also has [configuration modal tests](../../ui/src/components/ConfigureBuiltInAgentModal.test.tsx). diff --git a/maps/feature-map/instance-operations.md b/maps/feature-map/instance-operations.md index d04297487d5..df83b1a8fde 100644 --- a/maps/feature-map/instance-operations.md +++ b/maps/feature-map/instance-operations.md @@ -18,12 +18,6 @@ Agent access: [companion](../agent-access-map/instance-operations.md). - `diagnostics`: use doctor/health output to distinguish readiness from a listening port. - `backup`: create and inspect backups and verify restore only into a separate disposable target. -Routine HTTP monitoring may use `GET /api/liveness` (or `HEAD`). It returns only -process startup status and the public build commit without SQL or authentication. -It remains reachable during an idle drain. Use `/api/health` for explicit database -readiness, signed bootstrap, and authorized diagnostics; liveness does not -establish database health or claim a workspace. - ## How to get to it (user POV) ### `operator-cli` @@ -44,6 +38,12 @@ Automated: [doctor](../../cli/src/__tests__/doctor.test.ts), [update command](.. Manual: Inspect the selected installation/channel and configuration, start the isolated instance, confirm health and browser access, then stop that instance. For update changes, use the dry-run/check path first and verify installed version and rollback state on an owned disposable installation. +Routine HTTP monitoring may use `GET /api/liveness` (or `HEAD`). It returns only +process startup status and the public build commit without SQL or authentication. +It remains reachable during an idle drain. Use `/api/health` for explicit database +readiness, signed bootstrap, and authorized diagnostics; liveness does not +establish database health or claim a workspace. + ### `settings-backup` Automated: [experimental settings](../../ui/src/pages/InstanceExperimentalSettings.test.tsx) and [database backup routes](../../server/src/__tests__/instance-database-backups-routes.test.ts) cover their UI/API contracts. diff --git a/maps/feature-map/onboarding.md b/maps/feature-map/onboarding.md index 6a79b77d484..2c6668880e5 100644 --- a/maps/feature-map/onboarding.md +++ b/maps/feature-map/onboarding.md @@ -54,6 +54,80 @@ 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 +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 +useful task and follow-up with an independently checked file artifact. Saved +execution configuration and recorded run identity must match after reload. +The Codex first-task eval applies operator QA bounds before Get started through +the existing agent configuration API. It verifies that only timeout and +concurrency change; automatic runner, account and environment choices remain. +These campaign bounds are separate from production creation defaults. +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 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 +permission. Refresh company/environment-scoped discovery before testing the +selected runtime. A denied probe, unconfirmed cleanup or setup failure must be +visible; selecting Legacy remains an explicit choice. Creation and discovery +themselves allocate no target. A valid measured unsupported target follows the +server's Legacy default. Named Cloud snapshots remain a separate unqualified +boot-identity and fresh-signup gap. + +Selected native setup derives its target isolation as task execution does. The +hello probe retains its read-only permission profile, approval-never policy and +bounded provider turn. It skips Codex's project-instruction discovery in its new, +empty private workspace; ordinary tasks still load their project instructions. +Local and SSH transports retain Codex isolation. An injected environment flag +cannot establish an outer sandbox identity. Verify these boundaries with the +existing native setup controls and a real managed task/follow-up; source checks +alone are insufficient. + +The explicit manual Product E2E cell +`confirmation-replies.runner-codex.daytona.task-card-accept` covers this fresh +managed wizard path without fixture agent creation or a fixture preprobe. Its +existing attachment oracle checks a downloaded physical file and follow-up +continuity. Setup evidence must correlate both lease IDs with the same company, +environment and provider; missing responses or unmatched rows keep cost coverage +unknown. Run this cell only on an eligible target with separately authorized +provider, runtime and cleanup reservations. Its source and mock controls are +prepared. The literal `ea6c8eb5e16d61111968f633a66748b397b64a3e` live attempt +failed: the child document existed, but the physical file was attached to the +parent, and recovery stopped the completion callback before a provider turn. +The failed report remains evidence; the revised source still needs live proof. +Local cases and automatic selection remain unchanged. + +For accepted file-producing work, verify exactly one self-assigned child with a +self-contained description. The onboarding run must yield through the existing +dependency/block disposition so the child's own execution can publish its file. +Native file registration remains bound to the executing task. On completion, +the original conversation must receive the actual child and downloadable file +links. Document-only child work may finish inline; a plan-only request must not +start implementation. Existing first-task asset controls check the instructions, +not model compliance. Inspect the child's execution, document, downloaded file +and source-correlated completion reply in the real journey. + +Leave and resume during harness/runner setup. Confirm the draft retains the +explicit choice and compatible account bindings, old native Codex setup links +recover the harness and runner, and a changed harness or runner invalidates stale +test results. Existing Legacy agents remain Legacy when their onboarding/setup +configuration is reopened; this journey does not migrate them. + +Reload both `/onboarding` and the company-prefixed route after the first agent is +saved but before Get started. The authorized draft must retain its current step +and that agent's execution choice. An explicit new-company opening still starts +fresh. Opening another company must clear the previous company's resource IDs; +an unauthorized account must not restore its draft. Existing component controls +cover these boundaries; they do not substitute for the managed first-task eval. +The existing mocked-login browser case reopens and reloads Connect directly. It +checks the saved company/name/harness/runner, exact active authorization session, +one login start and no hire while login is pending. This is login UI evidence, +not a provider or managed-environment qualification. + ### `cli-setup` Automated: [CLI onboarding](../../cli/src/__tests__/onboard.test.ts) and [isolated test drive](../../cli/src/__tests__/test-drive.test.ts) cover their setup contracts; they do not establish that every model account is usable. diff --git a/maps/feature-map/recovery.md b/maps/feature-map/recovery.md index 038de76e049..c5f40656630 100644 --- a/maps/feature-map/recovery.md +++ b/maps/feature-map/recovery.md @@ -102,6 +102,17 @@ cleanup. The [route regressions](../../server/src/__tests__/issue-recovery-actio and [heartbeat regression](../../server/src/__tests__/heartbeat-process-recovery.test.ts) cover this sequence without a live operator recovery claim. +An intentionally Done onboarding parent can still need its validated +child-completion report. Recovery must not mark that callback successful before +execution solely because the parent is Done. The existing +[stale-lock sweep suite](../../server/src/__tests__/recovery-stale-issue-lock-sweep.test.ts) +covers delayed native/Legacy startup, invalid parent/child/company/assignee +bindings and a dead owner. Process-death and ownership backstops remain active. +Repeat the real onboarding journey and correlate callback turn events with its +reply and downloadable child file; status alone does not prove delivery. The +literal `ea6c8eb5e16d61111968f633a66748b397b64a3e` live failure is retained, +and the revised behavior still needs live qualification. + ### `run-detail` Automated: [run workspace recovery](../../ui/src/components/RunWorkspaceRecoverySurface.test.tsx) diff --git a/maps/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md index bd050519935..e8639edd248 100644 --- a/maps/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -37,12 +37,75 @@ Automated: [adapter validation routes](../../server/src/__tests__/agent-adapter- 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 +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 +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; +an explicit Paperclip request must give target-qualification guidance. Begin the +ordinary task to exercise the existing lazy environment path. This live journey +remains separate from the resolver's source controls. + +For sandbox defaults, follow the [environment probe](./execution-environments.md#environment-settings) +recipe first. Discovery/creation read company-bound immutable-image evidence +without allocating a target. A mutable image or snapshot name does not establish +native compatibility. Then test the actual selected runtime with its selected +account/model: a legacy CLI or authentication probe alone cannot establish native +readiness. Missing dependencies, failed authentication, invalid models and +incompatible custom settings must remain actionable errors without silent runner +switching. Saved unchanged execution can still be tested without applying new +selection policy, but runtime/account validation must still run. + +Verify an automatic Codex hire and an explicit Legacy control through a useful +file artifact, follow-up and reload. Preserve Codex reasoning effort and account +isolation; retain explicit Legacy permission choices, including false. Reopen an +existing agent, make an unrelated edit, and confirm both its persisted runner and +next run stay unchanged. Use [hiring/import preservation](./agents.md#hire-agent) +for approval and portability controls. + +Test native Codex with a saved subscription whose setup auth file is a managed +link, as well as a regular file and an API-key account. Setup must seed the +isolated probe from the checked account without exposing credentials or changing +its saved runner. With subscription authentication, an unbound configured +`CODEX_HOME` with linked credentials must fail setup with managed-account or explicit Legacy guidance; it must not claim +readiness when task isolation would omit the file. Dangling or incorrectly owned +credentials must also fail clearly. A selected `CODEX_API_KEY` or +`OPENAI_API_KEY` takes precedence over an unrelated subscription file. Test valid, +dangling and invalid subscription links with that selected key; setup must test +the key without reading or refreshing the unrelated account. The private probe copy must be removed after confirmed retirement following success, +failure or cancellation. Unconfirmed shutdown remains a cleanup failure. +Exercise an unavailable local daemon and a preparation failure before probe +handoff. No owned temporary credential copy may remain, and original account +files must be unchanged. A probe whose shutdown is unconfirmed must retain its +recovery state; do not treat it as a preparation-only failure. +Existing setup controls use fake credentials; real account execution remains +a separate acceptance requirement. + ### `run-history` Automated: [live run routes](../../server/src/__tests__/agent-live-run-routes.test.ts) and [run event sequencing](../../server/src/__tests__/heartbeat-run-event-sequencing.test.ts) cover run data contracts. Manual: Start a disposable task, open its running transcript, and follow it to a terminal outcome. Reload and compare task/run IDs, status, output, and cost. Verify a failed run remains distinguishable from a completed run with useful output. +An explicit runner change starts a configuration revision and invalidates the +eligible session. Active runs, restart recovery and historical transcript parsing +retain their recorded execution configuration; do not infer their runner from a +later edit to the agent. Account/harness branding and execution identity are +different observations, both required in the evidence. + +For an onboarding child-completion callback, verify actual session/turn events +and the source-correlated reply. A `succeeded` row alone is insufficient: the +literal `ea6c8eb5e16d61111968f633a66748b397b64a3e` managed attempt was marked +successful during startup without a provider turn. The recovery correction +preserves the existing validated Done-parent handoff; process-death and ownership +checks remain active. Its PostgreSQL controls are separate from the required +revised-source live onboarding and follow-up proof. + ## Gotchas - Model accounts are managed in Apps and inline agent account controls. @@ -61,3 +124,13 @@ state. Readiness observes shutdown; resumed startup cannot reactivate ingress after close. These are lifecycle controls, separate from installed provider proof. The real daemon readiness and recovery controls require their existing native fixtures in hosted CI; passing credential-free controls does not qualify them. + +For remote setup shutdown, verify that each durable-state read respects the +remaining suspension deadline. A late result cannot establish suspension or +queue a replacement command after that deadline. An intentional ingress close +must observe physical socket closure before retiring its connection owner and +releasing the endpoint; an unresponsive peer must not extend the setup budget. +The existing transport and ingress suites exercise these conditions without a +provider. Repeat the actual managed setup and useful task journey separately; +the literal `651a3de64fc943216cc414b64b1443809f9499da` Test attempt failed +cleanup before hire and retains its unconfirmed recovery state. diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index d648b4fd3c1..24b75ce1f75 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -370,6 +370,7 @@ export interface AdapterEnvironmentTestContext { companyId: string; adapterType: string; config: Record; + managedAiCredentialHome?: string; /** * Optional execution target the adapter should run probes against. * @@ -722,8 +723,10 @@ export interface CLIAdapterModule { // --------------------------------------------------------------------------- export interface CreateConfigValues { + runner?: "auto" | "paperclip" | "legacy"; adapterType: string; codexPermissionMode?: "never" | "on-request" | "untrusted"; + codexLegacySandboxBypass?: boolean; paperclipRunnerLifecycleMode?: "per_turn" | "warm"; paperclipRunnerIdleTimeoutMs?: number; cwd: string; diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index 0ffe729b928..c06e881c729 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -1,6 +1,7 @@ import { PAPERCLIP_RUNNER_DEFAULT_MODELS, } from "@paperclipai/adapter-utils"; +import { PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION } from "@paperclipai/shared"; export const type = "codex_local"; export const label = "Codex"; @@ -12,6 +13,29 @@ export const SANDBOX_INSTALL_COMMAND = "npm install -g @openai/codex"; // Codex CLI warn ("Model metadata for `gpt-5.6` not found") and fall back to generic context limits. export const DEFAULT_CODEX_LOCAL_MODEL = PAPERCLIP_RUNNER_DEFAULT_MODELS.codex; export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true; + +export function resolveCodexLocalCliPolicy(config: unknown, options: { networkAccess?: boolean } = {}) { + const record = typeof config === "object" && config !== null && !Array.isArray(config) + ? config as Record : {}; + const stringArgs = (value: unknown): string[] => Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") : []; + const configuredExtraArgs = stringArgs(record.extraArgs); + const extraArgs = configuredExtraArgs.length > 0 ? configuredExtraArgs : stringArgs(record.args); + const explicitSandbox = extraArgs.some((arg) => + /^(--sandbox(?:=|$)|-s|--profile(?:=|$)|-p|--full-auto$|--yolo$|--dangerously-bypass-approvals-and-sandbox$)/.test(arg) + || /^(?:(?:--config=|-c=?)\s*)?(?:sandbox_mode|profile)\s*=/.test(arg), + ); + const explicitPermissionRestriction = extraArgs.some((arg) => + /^(?:(?:--config=|-c=?)\s*)?(?:approval_policy\s*=|sandbox_workspace_write\.network_access\s*=\s*false)/.test(arg), + ); + const defaultBypass = !explicitSandbox && !explicitPermissionRestriction + && options.networkAccess !== false && DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; + const bypass = typeof record.dangerouslyBypassApprovalsAndSandbox === "boolean" + ? record.dangerouslyBypassApprovalsAndSandbox + : typeof record.dangerouslyBypassSandbox === "boolean" ? record.dangerouslyBypassSandbox : defaultBypass; + return { extraArgs, explicitSandbox, bypass }; +} + export const CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS = [ "gpt-6-astra", "gpt-6.1-sol", @@ -188,6 +212,10 @@ export const models = [ export const agentConfigurationDoc = `# codex_local agent configuration +New agents select the Codex harness with adapterType: codex_local and an optional request-level runner: auto, paperclip, or legacy. Automatic selection uses Paperclip Runner on qualified Linux x64/ARM64 targets with GNU libc ${PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION} or newer and macOS arm64/x64 targets. It stores adapterType: paperclip_runner with provider: codex. Windows, 32-bit ARM, Alpine/musl, and older or unknown Linux libc retain automatic legacy selection. Qualification and artifact checks use the selected execution environment, including its architecture, rather than the controller's platform. Other harness defaults are unchanged. + +Set runner: legacy to retain this adapter's execution and custom settings. Explicit runner: paperclip requires a qualified target. Existing agents and reviewed pending hires retain their saved runner; authentication, model, dependency, and runtime errors never silently switch runners. The fields below describe legacy codex_local execution. Native creation translates supported settings and rejects unsupported settings with the explicit Legacy runner option. + Adapter: codex_local Core fields: @@ -229,7 +257,7 @@ Notes: - Paperclip injects desired local skills into the effective CODEX_HOME/skills/ directory at execution time so Codex can discover "$paperclip" and related skills without polluting the project working directory. For new and updated agents, Paperclip assigns an isolated managed home at ~/.paperclip/instances//companies//agents//codex-home/skills/; when CODEX_HOME is explicitly overridden in adapter config, that override is used instead. - New and updated codex_local agents persist an empty OPENAI_API_KEY override by default so a host-level OPENAI_API_KEY cannot leak into Codex runs through process inheritance. Explicit CODEX_HOME overrides must not point at the shared company codex-home, $CODEX_HOME, or ~/.codex. - Some model/tool combinations reject certain effort levels (for example minimal with web search enabled). -- With ChatGPT sign-in, the Codex backend accepts a model only from a recent enough Codex CLI: GPT-6.1 Sol needs 0.159.0 or newer, GPT-6 Sol and GPT-6 Luna need 0.157.0 or newer. An older CLI fails every turn with "The '' model is not supported when using Codex with a ChatGPT account." The environment Test and the remote runner compare the installed \`codex --version\` against these floors before running; in a managed sandbox, the fix is a sandbox image that ships the Codex version Paperclip pins. +- With ChatGPT sign-in, model availability depends on the installed Codex capabilities. Legacy setup reports known model/version mismatches. Native setup tests the actual app-server and selected model; a version number alone does not reject or replace an installed CLI. If the provider rejects the model, select an available model or update Codex in the execution environment. - Fast mode is supported on GPT-6 (astra/sol/luna), GPT-6.1 Sol, GPT-5.6 (sol/terra/luna), GPT-5.5, GPT-5.4 and manual model IDs. When enabled for those models, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`. - When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling. - The ACP engine keeps its workspace sandbox and enables network access on each turn. Explicit sandbox_workspace_write.network_access overrides in extraArgs (or env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS="false") disable it; execution-target network denial wins. The bundled ACP patch is needed because upstream mode presets override Codex config.toml on every turn. diff --git a/packages/adapters/codex-local/src/server/codex-args.test.ts b/packages/adapters/codex-local/src/server/codex-args.test.ts index e75a17ebee8..1157018a661 100644 --- a/packages/adapters/codex-local/src/server/codex-args.test.ts +++ b/packages/adapters/codex-local/src/server/codex-args.test.ts @@ -1,7 +1,22 @@ import { describe, expect, it } from "vitest"; import { buildCodexExecArgs } from "./codex-args.js"; +import { resolveCodexLocalCliPolicy } from "../index.js"; describe("buildCodexExecArgs", () => { + it.each([ + { config: { extraArgs: ["--sandbox", "read-only"] }, bypass: false, args: ["exec", "--json", "--sandbox", "read-only", "-"] }, + { config: { args: ["-c", "approval_policy=on-request"] }, bypass: false, args: ["exec", "--json", "-c", 'sandbox_mode="workspace-write"', "-c", "sandbox_workspace_write.network_access=true", "-c", "approval_policy=on-request", "-"] }, + { config: { extraArgs: [], args: ["--profile", "restricted"] }, bypass: false, args: ["exec", "--json", "--profile", "restricted", "-"] }, + { config: { extraArgs: [0, null], args: ["--sandbox", "read-only"] }, bypass: false, args: ["exec", "--json", "--sandbox", "read-only", "-"] }, + { config: { extraArgs: ["--color", "never"], args: ["--sandbox", "read-only"] }, bypass: true, args: ["exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "--color", "never", "-"] }, + { config: { dangerouslyBypassApprovalsAndSandbox: true, extraArgs: ["--sandbox", "read-only"] }, bypass: true, args: ["exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "--sandbox", "read-only", "-"] }, + { config: { dangerouslyBypassSandbox: true, args: ["--sandbox", "read-only"] }, bypass: true, args: ["exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "--sandbox", "read-only", "-"] }, + { config: { dangerouslyBypassApprovalsAndSandbox: false, dangerouslyBypassSandbox: true, extraArgs: ["--color", "never"] }, bypass: false, args: ["exec", "--json", "-c", 'sandbox_mode="workspace-write"', "-c", "sandbox_workspace_write.network_access=true", "--color", "never", "-"] }, + ])("keeps the browser policy and exact execution arguments consistent for $config", ({ config, bypass, args }) => { + expect(resolveCodexLocalCliPolicy(config).bypass).toBe(bypass); + expect(buildCodexExecArgs(config).args).toEqual(args); + }); + it.each([null, "existing-session"])("defaults direct and resumed launches to full bypass (%s)", (resumeSessionId) => { const { args } = buildCodexExecArgs({}, { resumeSessionId }); expect(args).toContain("--dangerously-bypass-approvals-and-sandbox"); diff --git a/packages/adapters/codex-local/src/server/codex-args.ts b/packages/adapters/codex-local/src/server/codex-args.ts index 6ebf8381b44..5cb6b217642 100644 --- a/packages/adapters/codex-local/src/server/codex-args.ts +++ b/packages/adapters/codex-local/src/server/codex-args.ts @@ -1,7 +1,7 @@ -import { asBoolean, asString, asStringArray } from "@paperclipai/adapter-utils/server-utils"; +import { asBoolean, asString } from "@paperclipai/adapter-utils/server-utils"; import { CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS, - DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, + resolveCodexLocalCliPolicy, isCodexLocalFastModeSupported, normalizeCodexModel, } from "../index.js"; @@ -16,12 +16,6 @@ export type BuildCodexExecArgsResult = { fastModeIgnoredReason: string | null; }; -function readExtraArgs(config: unknown): string[] { - const fromExtraArgs = asStringArray(asRecord(config).extraArgs); - if (fromExtraArgs.length > 0) return fromExtraArgs; - return asStringArray(asRecord(config).args); -} - function asRecord(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) @@ -49,20 +43,7 @@ export function buildCodexExecArgs( const search = asBoolean(record.search, false); const fastModeRequested = asBoolean(record.fastMode, false); const fastModeApplied = fastModeRequested && isCodexLocalFastModeSupported(model); - const extraArgs = readExtraArgs(record); - // Explicit CLI modes/profiles remain deliberate overrides. An omitted - // setting uses the same full-auto default as agent creation and onboarding. - const explicitSandbox = extraArgs.some((arg) => - /^(--sandbox(?:=|$)|-s|--profile(?:=|$)|-p|--full-auto$|--yolo$|--dangerously-bypass-approvals-and-sandbox$)/.test(arg) - || /^(?:(?:--config=|-c=?)\s*)?(?:sandbox_mode|profile)\s*=/.test(arg), - ); - const explicitPermissionRestriction = extraArgs.some((arg) => - /^(?:(?:--config=|-c=?)\s*)?(?:approval_policy\s*=|sandbox_workspace_write\.network_access\s*=\s*false)/.test(arg), - ); - const bypass = asBoolean( - record.dangerouslyBypassApprovalsAndSandbox, - asBoolean(record.dangerouslyBypassSandbox, !explicitSandbox && !explicitPermissionRestriction && options.networkAccess !== false && DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX), - ); + const { extraArgs, explicitSandbox, bypass } = resolveCodexLocalCliPolicy(record, options); const args = ["exec", "--json"]; if (!bypass && !explicitSandbox) { args.push("-c", 'sandbox_mode="workspace-write"'); diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 535be262927..953c126de55 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -231,7 +231,10 @@ scripts; it verifies native startup and package continuity separately from the restricted Docker installation check. This does not prove a normal OS package installation journey or a paid provider task on ARM64. -Windows, 32-bit ARM, and Alpine/musl native qualification remain deferred. +Automatic creation selects legacy execution when a Linux host has musl, unknown +libc, or a measured GNU libc version below the baseline. Existing agents retain +their stored runner. Windows, 32-bit ARM, and Alpine/musl native qualification +remain deferred. Native release jobs cache Cargo dependencies per platform and OS baseline, including compiler, lockfile, and external generated-contract inputs. Workspace diff --git a/packages/paperclip-runner/src/live/live-session.test.ts b/packages/paperclip-runner/src/live/live-session.test.ts index 9292aa316ab..3697afc87e5 100644 --- a/packages/paperclip-runner/src/live/live-session.test.ts +++ b/packages/paperclip-runner/src/live/live-session.test.ts @@ -1787,12 +1787,52 @@ describe("Capability live runnerd and Codex session", () => { turnTimeoutMs: 500, }); state.holdAfterTool = true; - const killedTurn = captureTurnRejection(first.sendMessage("Apply idempotent progress once.")); - await vi.waitFor(async () => { - expect((await firstStore.load(binding.sessionId))?.mockState).toContain( - "Progress persisted through the live Codex tool loop.", - ); + let turnSettled = false; + let turnFailure: unknown = null; + const killedTurn = captureTurnRejection(first.sendMessage("Apply idempotent progress once.")).then((error) => { + turnSettled = true; + turnFailure = error; + return error; }); + try { + await vi.waitFor(async () => { + expect((await firstStore.load(binding.sessionId))?.mockState).toContain( + "Progress persisted through the live Codex tool loop.", + ); + }); + } catch (error) { + try { + const summarize = (snapshot: CapabilityLiveSessionSnapshot | null) => { + if (snapshot === null) return null; + const mock = JSON.parse(snapshot.mockState); + return { + revision: snapshot.revision, + status: snapshot.status, + activeTurnId: snapshot.activeTurnId, + mockRevision: mock.revision, + comments: mock.comments.length, + evidenceKinds: snapshot.evidence.slice(-8).map((entry) => entry.kind), + }; + }; + const stored = await firstStore.load(binding.sessionId).catch(() => null); + const diagnostics = { + turnSettled, + turnFailure: turnFailure instanceof Error + ? { name: turnFailure.name, message: turnFailure.message.slice(0, 240) } + : typeof turnFailure, + requests: state.transports[0]?.requests.slice(-16).map((request) => request.method), + memory: summarize(first.snapshot()), + stored: summarize(stored), + }; + if (error instanceof Error) error.message += `\nDurable progress diagnostics: ${JSON.stringify(diagnostics)}`; + } catch (diagnosticError) { + const diagnosticUnavailable = diagnosticError instanceof Error + ? { name: diagnosticError.name, message: diagnosticError.message.slice(0, 240) } + : { name: typeof diagnosticError }; + if (error instanceof Error) error.message += `\nDurable progress diagnostics unavailable: ${JSON.stringify(diagnosticUnavailable)}`; + } + throw error; + } await expect(first.recordUsage({ receiptId: "provider-response-1", providerResponseId: "response-1", diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index b44f6b7c36a..a67cd97afd5 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -1404,6 +1404,61 @@ it("reserves a bounded suspension window after close preparation", () => { }); }); +it.each([ + { existingCommand: false, rejectLateRead: false }, + { existingCommand: false, rejectLateRead: true }, + { existingCommand: true, rejectLateRead: false }, + { existingCommand: true, rejectLateRead: true }, +])("returns unconfirmed suspension at its deadline while a remote state read is still pending ($existingCommand, $rejectLateRead)", async ({ existingCommand, rejectLateRead }) => { + vi.useFakeTimers(); + const commands: { commandId: string; type: string; status: string }[] = existingCommand + ? [{ commandId: "exact-suspend", type: "runner.suspend", status: "completed" }] + : []; + const readRunnerState = vi.fn(async () => { + await new Promise(resolveRead => setTimeout(resolveRead, 10_000)); + if (rejectLateRead) throw new Error("remote state unavailable after its own timeout"); + return { lifecycle: "suspended" }; + }); + const unhandled = vi.fn(); + process.on("unhandledRejection", unhandled); + const queueSuspend = vi.fn((commandId: string) => commands.push({ commandId, type: "runner.suspend", status: "completed" })); + const release = vi.fn(); + let settled = false; + let suspended: boolean | undefined; + const result = runnerdRecoveryInternals.awaitRunnerSuspensionBarrier({ + commands: () => commands, + queueSuspend, + readRunnerState, + runnerHasExited: async () => true, + pump: () => undefined, + deadline: Date.now() + 2_500, + pollIntervalMs: 0, + }).then(value => { + settled = true; + suspended = value; + if (value) release(); + return value; + }); + try { + await vi.advanceTimersByTimeAsync(2_500); + expect(readRunnerState).toHaveBeenCalledOnce(); + expect(settled).toBe(true); + expect(suspended).toBe(false); + expect(queueSuspend).toHaveBeenCalledTimes(existingCommand ? 0 : 1); + expect(release).not.toHaveBeenCalled(); + } finally { + try { + await vi.advanceTimersByTimeAsync(10_000); + await expect(result).resolves.toBe(false); + expect(release).not.toHaveBeenCalled(); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off("unhandledRejection", unhandled); + vi.useRealTimers(); + } + } +}); + it.each([ { provider: "acpx" as const, acpxAgent: "pi" as const, closeGraceMs: undefined, drained: true }, { provider: "codex" as const, acpxAgent: undefined, closeGraceMs: undefined, drained: false }, @@ -2140,6 +2195,7 @@ it("denies the isolated Codex home without denying a remote execution workspace" readOnlyRoots: ["/usr/local"], }); const serialized = args.join("\n"); + expect(args).not.toContain("project_doc_max_bytes=0"); expect(serialized).toContain( '"/workspaces/task/.paperclip-runtime/paperclip-runner/sessions/session/filesystem/codex-home"="none"', @@ -2830,12 +2886,14 @@ const fakeCodex = resolve( "../../runner/target/debug/fake-codex-app-server", ); -it.each(["stuck", "late-release", "late-release-error", "stuck-release", "cooperative-release", "launch-failure"] as const)( +it.each(["stuck", "late-release", "late-release-error", "stuck-release", "cooperative-release", "launch-failure", "launch-failure-sandbox", "launch-failure-unsandboxed"] as const)( "bounds setup registration teardown and retains unconfirmed state (%s)", async mode => { const root = await mkdtemp(join(tmpdir(), "runnerd-setup-registration-")); const runtimeDirectory = join(root, "native-state"); await mkdir(runtimeDirectory, { mode: 0o700 }); + const launchFailure = mode.startsWith("launch-failure"); + let template: Record | null = null; let registrationStarted!: () => void; const started = new Promise(resolve => { registrationStarted = resolve; }); let returnRegistration!: (registration: { connection: { mode: "listen"; listenAddress: string; listenPort: number; listenPath: string }; release: () => Promise }) => void; @@ -2853,17 +2911,22 @@ it.each(["stuck", "late-release", "late-release-error", "stuck-release", "cooper }; let registrationSignal: AbortSignal | undefined; const cleanup = vi.fn(async () => { await rm(runtimeDirectory, { recursive: true, force: true }); }); - const launch = vi.fn(() => { throw new Error("owned launcher failed before execution"); }); + const launch = vi.fn((spec: durableControlPlane.RunnerProcessLaunchSpec) => { + expect(spec.environment.PAPERCLIP_RUNNER_EXTERNAL_SANDBOX).toBeUndefined(); + throw new Error("owned launcher failed before execution"); + }); let settled = false; vi.useFakeTimers(); const result = probeNativeRunnerEnvironment({ runtimeDirectory, provider: "codex", model: "gpt-test", timeoutMs: 1_000, - environment: { PATH: "", OPENAI_API_KEY: "inert-test-key" }, + environment: { PATH: "", OPENAI_API_KEY: "inert-test-key", PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "true" }, transportOptions: { + ...(mode === "launch-failure-sandbox" ? { externallySandboxed: true } : mode === "launch-failure-unsandboxed" ? { externallySandboxed: false } : {}), runnerBinary: resolve(import.meta.dirname, "../../package.json"), codexCommand: resolve(import.meta.dirname, "../../package.json"), sourceCodexHome: "", runnerProcessLauncher: launch, controlPlaneRegistration: async (_authority, _identity, signal) => { + template = structuredClone(_authority.store.state.runAttachTemplate!); registrationSignal = signal; if (mode === "cooperative-release") signal?.addEventListener("abort", () => returnRegistration(registered), { once: true }); registrationStarted(); @@ -2874,16 +2937,23 @@ it.each(["stuck", "late-release", "late-release-error", "stuck-release", "cooper }).then(value => { settled = true; return value; }, (error: unknown) => { settled = true; return error; }); try { await started; - if (mode === "launch-failure") returnRegistration(registered); + if (launchFailure) returnRegistration(registered); await vi.advanceTimersByTimeAsync(1_000); if (mode === "late-release" || mode === "late-release-error" || mode === "stuck-release") returnRegistration(registered); await vi.advanceTimersByTimeAsync(10_000); - if (mode === "late-release" || mode === "cooperative-release" || mode === "launch-failure") await result; + if (mode === "late-release" || mode === "cooperative-release" || launchFailure) await result; expect(settled).toBe(true); expect(registrationSignal?.aborted).toBe(true); const failure = await result; + expect(template).toMatchObject({ provider: { externallySandboxed: mode === "launch-failure-sandbox", readOnly: true, approvalPolicy: "never", collaborationMode: "plan" } }); + const args = (template!.provider as Record).args as string[]; + expect(args).toContain("project_doc_max_bytes=0"); + expect(args[args.indexOf("project_doc_max_bytes=0") - 1]).toBe("-c"); + expect(args.indexOf("project_doc_max_bytes=0")).toBeLessThan(args.indexOf("app-server")); + expect(args).not.toContain("--dangerously-bypass-approvals-and-sandbox"); + expect(args).toContain('permissions.paperclip-runner-workspace-read-only.network.enabled=false'); expect(failure).toBeInstanceOf(Error); - expect((failure as Error).message).toContain(mode === "launch-failure" ? "owned launcher failed before execution" : "Native provider hello probe timed out."); + expect((failure as Error).message).toContain(launchFailure ? "owned launcher failed before execution" : "Native provider hello probe timed out."); if (mode === "stuck" || mode === "stuck-release" || mode === "late-release-error") { expect(failure).toBeInstanceOf(AggregateError); expect((failure as AggregateError).cause).toMatchObject({ message: "Native provider hello probe timed out." }); @@ -2900,7 +2970,7 @@ it.each(["stuck", "late-release", "late-release-error", "stuck-release", "cooper expect(cleanup).toHaveBeenCalledOnce(); await expect(stat(runtimeDirectory)).rejects.toThrow(); } - expect(launch).toHaveBeenCalledTimes(mode === "launch-failure" ? 1 : 0); + expect(launch).toHaveBeenCalledTimes(launchFailure ? 1 : 0); expect(vi.getTimerCount()).toBe(0); } finally { returnRegistration(registered); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 75a7543ff7e..d824236787c 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -781,6 +781,22 @@ async function awaitRunnerSuspensionBarrier(input: { deadline: number; pollIntervalMs?: number; }): Promise { + if (Date.now() >= input.deadline) return false; + const readRunnerState = async (): Promise | undefined> => { + const remainingMs = input.deadline - Date.now(); + if (remainingMs <= 0) return undefined; + let timer: ReturnType | undefined; + try { + return await Promise.race([ + input.readRunnerState(), + new Promise(resolveDeadline => { + timer = setTimeout(() => resolveDeadline(undefined), remainingMs); + }), + ]); + } finally { + clearTimeout(timer); + } + }; let existing = [...input.commands()] .reverse() .find( @@ -793,13 +809,16 @@ async function awaitRunnerSuspensionBarrier(input: { // Reuse its receipt only when the current exact durable state is already // suspended; otherwise issue a new command rather than waiting on history. try { - if ((await input.readRunnerState()).lifecycle === "suspended") + const state = await readRunnerState(); + if (state === undefined) return false; + if (state.lifecycle === "suspended") return Date.now() < input.deadline; } catch { // The normal bounded barrier below handles unavailable state. } existing = undefined; } + if (Date.now() >= input.deadline) return false; const commandId = existing?.commandId ?? `command_close_suspend_${randomUUID().replaceAll("-", "")}`; @@ -819,11 +838,14 @@ async function awaitRunnerSuspensionBarrier(input: { } let lifecycle: unknown; try { - lifecycle = (await input.readRunnerState()).lifecycle; + const state = await readRunnerState(); + if (state === undefined) return false; + lifecycle = state.lifecycle; } catch { // A remote filesystem can lag the command-result delivery by a small // amount. Keep the single close deadline as the fail-closed bound. } + if (Date.now() >= input.deadline) return false; if ( command?.status === "completed" && lifecycle === "suspended" && @@ -1261,6 +1283,7 @@ export interface CapabilityRunnerdCodexTransportOptions { runnerBinary?: string; codexCommand?: string; codexArgs?: string[]; + disableCodexProjectInstructions?: boolean; /** Controller-visible Codex home used only to seed the isolated runner home. */ sourceCodexHome?: string | null; opencodeCommand?: string; @@ -3462,20 +3485,24 @@ export function createRunnerdCodexAppServerArgs(input: { codexCommand?: string; readOnlyRoots?: string[]; instructionWorkingCopyRoot?: string; + disableProjectInstructions?: boolean; }): string[] { // The filesystem policy denies HOME and CODEX_HOME to keep credentials and // runner state outside provider reach. Always bind those names to the actual // isolated runner home; a stale controller environment must never cause the // execution workspace itself to become an explicit deny root. - return createIsolatedCodexAppServerArgs( - { - ...input.environment, - HOME: input.codexHome, - CODEX_HOME: input.codexHome, - }, - [...(input.readOnlyRoots ?? []), ...codexExecutableReadOnlyRoots(input.environment ?? {}, input.codexCommand)], - input.instructionWorkingCopyRoot, - ); + return [ + ...(input.disableProjectInstructions ? ["-c", "project_doc_max_bytes=0"] : []), + ...createIsolatedCodexAppServerArgs( + { + ...input.environment, + HOME: input.codexHome, + CODEX_HOME: input.codexHome, + }, + [...(input.readOnlyRoots ?? []), ...codexExecutableReadOnlyRoots(input.environment ?? {}, input.codexCommand)], + input.instructionWorkingCopyRoot, + ), + ]; } export function unwrapToolResponse(response: Record, preserveEnvelope = false): { @@ -5028,6 +5055,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { codexHome, codexCommand: effectiveCodexCommand, instructionWorkingCopyRoot: runtimeContext?.instructions.workingCopy?.rootPath, + disableProjectInstructions: this.options.disableCodexProjectInstructions, readOnlyRoots: [ ...trustedRuntimeReadOnlyRoots( this.options.environment, @@ -5523,6 +5551,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { codexHome, codexCommand: typeof recordedCommand === "string" ? recordedCommand : this.options.codexCommand, instructionWorkingCopyRoot: runtimeContext?.instructions.workingCopy?.rootPath, + disableProjectInstructions: this.options.disableCodexProjectInstructions, readOnlyRoots: [ ...trustedRuntimeReadOnlyRoots(this.options.environment), ...(runtimeContext @@ -7210,7 +7239,8 @@ export async function probeNativeRunnerEnvironment(options: { sourceCodexHome: sourceCodexHome ?? "", stateDirectory: options.runtimeDirectory, environment, - externallySandboxed: false, + externallySandboxed: options.transportOptions?.externallySandboxed === true, + disableCodexProjectInstructions: true, closeGraceMs: 10_000, lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null }, }); diff --git a/packages/shared/src/agent-runner.ts b/packages/shared/src/agent-runner.ts new file mode 100644 index 00000000000..cde48d47255 --- /dev/null +++ b/packages/shared/src/agent-runner.ts @@ -0,0 +1,45 @@ +export type AgentRunnerChoice = "auto" | "paperclip" | "legacy"; +export type AgentRunner = Exclude; + +export interface AgentRunnerAvailability { + supportedRunners: AgentRunner[]; + defaultRunner: AgentRunner; +} + +export function paperclipRunnerProfileForHarness(harness: string): { provider: "codex" } | undefined { + return harness === "codex_local" ? { provider: "codex" } : undefined; +} + +export const PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION = "2.39"; + +function supportsLinuxLibc(libc?: "glibc" | "musl" | "unknown", version?: string): boolean { + if (libc !== undefined && libc !== "glibc") return false; + if (version === undefined) return true; + const match = typeof version === "string" ? version.trim().match(/^(?:glibc\s+)?(\d+)\.(\d+)(?:\.\d+)?$/) : null; + if (!match) return false; + const major = Number(match[1]), minor = Number(match[2]); + if (!Number.isSafeInteger(major) || !Number.isSafeInteger(minor)) return false; + const [requiredMajor, requiredMinor] = PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION.split(".").map(Number); + return major > requiredMajor! || (major === requiredMajor && minor >= requiredMinor!); +} + +export function paperclipRunnerSupportsPlatform(harness: string, platform: string, architecture: string, libc?: "glibc" | "musl" | "unknown", glibcVersion?: string): boolean { + return !!paperclipRunnerProfileForHarness(harness) + && ((platform === "linux" && supportsLinuxLibc(libc, glibcVersion) && (architecture === "x64" || architecture === "arm64")) + || (platform === "darwin" && (architecture === "arm64" || architecture === "x64"))); +} + +export function agentHarnessType(adapterType: string, config: Record = {}): string { + if (adapterType !== "paperclip_runner") return adapterType; + if (config.provider === undefined || config.provider === "codex") return "codex_local"; + if (config.provider === "opencode") return "opencode_local"; + if (config.provider === "acpx") { + const agent = config.acpxAgent ?? "claude"; + return ({ codex: "codex_local", claude: "claude_local", grok: "grok_local", cursor: "cursor", pi: "pi_local" } as Record)[String(agent)] ?? `acpx:${String(agent)}`; + } + return String(config.provider); +} + +export function agentRunner(adapterType: string): AgentRunner { + return adapterType === "paperclip_runner" ? "paperclip" : "legacy"; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 966ca1458ea..04165b41781 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2870,6 +2870,7 @@ export { updatePrimaryAgentSchema, type UpdatePrimaryAgent, type PrimaryAgentPre export * from "./agent-avatar-upload.js"; export type { DotBinding, DotInvitation, DotConnection, DotPairing } from "./dot-invitations.js"; +export { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION, type AgentRunnerChoice, type AgentRunner, type AgentRunnerAvailability } from "./agent-runner.js"; export { EXTERNAL_AGENT_TOOL_GUIDANCE, DOT_AGENT_TOOL_GUIDANCE, GROK_BOT_AGENT_TOOL_GUIDANCE } from "./external-agent-guidance.js"; export { AGENT_LIFECYCLE_STATES, type AgentLifecycleState, type AgentLifecycleOperation } from "./types/agent-lifecycle.js"; diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index 7c3bc6cc752..76ce2cd19c0 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -1,4 +1,5 @@ import type { AgentAppearance } from "../agent-appearance.js"; +import type { AgentRunnerChoice } from "../agent-runner.js"; import type { AgentEnvConfig } from "./secrets.js"; import type { RoutineVariable } from "./routine.js"; import type { IssueCommentAuthorType, PermissionKey } from "../constants.js"; @@ -211,6 +212,7 @@ export interface CompanyPortabilityIssueManifestEntry { } export interface CompanyPortabilityAgentManifestEntry { + runner?: AgentRunnerChoice; slug: string; name: string; path: string; @@ -387,6 +389,7 @@ export interface CompanyPortabilityPreviewResult { } export interface CompanyPortabilityAdapterOverride { + runner?: AgentRunnerChoice; adapterType: string; adapterConfig?: Record; } diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 415bce05c1d..575bfc551c5 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -93,6 +93,7 @@ export const createAgentSchema = z.object({ capabilities: z.string().optional().nullable(), desiredSkills: z.array(agentDesiredSkillSelectionSchema).optional(), adapterType: agentAdapterTypeSchema, + runner: z.enum(["auto", "paperclip", "legacy"]).optional(), adapterConfig: adapterConfigSchema.optional().default({}), instructionsBundle: createAgentInstructionsBundleSchema.optional(), runtimeConfig: agentRuntimeConfigSchema.optional().default({}), @@ -121,6 +122,7 @@ export type CreateAgent = z.infer; export const builtInAgentProvisionSchema = z.object({ adapterType: agentAdapterTypeSchema.optional(), + runner: z.enum(["auto", "paperclip", "legacy"]).optional(), adapterConfig: adapterConfigSchema.optional(), budgetMonthlyCents: z.number().int().nonnegative().optional(), }).strict(); @@ -256,6 +258,7 @@ export const resetAgentSessionSchema = z.object({ export type ResetAgentSession = z.infer; export const testAdapterEnvironmentSchema = z.object({ + runner: z.enum(["auto", "paperclip", "legacy"]).optional(), aiConnection: aiConnectionBindingSchema.optional(), /** Saved agent whose redacted environment entries are restored for this probe. */ agentId: z.string().guid().optional(), diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index 8244c3d4ec3..6045d608a05 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -71,6 +71,7 @@ export const portabilityBlobManifestEntrySchema = z.object({ }); export const portabilityAgentManifestEntrySchema = z.object({ + runner: z.enum(["auto", "paperclip", "legacy"]).optional(), slug: z.string().min(1), name: z.string().min(1), path: z.string().min(1), @@ -324,6 +325,7 @@ export const companyPortabilityPreviewSchema = z.object({ export type CompanyPortabilityPreview = z.infer; export const portabilityAdapterOverrideSchema = z.object({ + runner: z.enum(["auto", "paperclip", "legacy"]).optional(), adapterType: z.string().min(1), adapterConfig: z.record(z.string(), z.unknown()).optional(), }); diff --git a/server/src/__tests__/adapter-auth-signal-routes.test.ts b/server/src/__tests__/adapter-auth-signal-routes.test.ts index 8360e3e133a..44d7f12d0f2 100644 --- a/server/src/__tests__/adapter-auth-signal-routes.test.ts +++ b/server/src/__tests__/adapter-auth-signal-routes.test.ts @@ -113,6 +113,8 @@ vi.mock("@paperclipai/adapter-codex-local/server", async (importOriginal) => { }); let currentActor: Record; +let agentRoutes: typeof import("../routes/agents.js").agentRoutes; +let errorHandler: typeof import("../middleware/index.js").errorHandler; function boardActor(userId: string, companyIds: string[] = [COMPANY_1, OTHER_COMPANY]): Record { return { @@ -125,10 +127,6 @@ function boardActor(userId: string, companyIds: string[] = [COMPANY_1, OTHER_COM } async function createApp() { - const [{ agentRoutes }, { errorHandler }] = await Promise.all([ - vi.importActual("../routes/agents.js"), - vi.importActual("../middleware/index.js"), - ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -144,7 +142,7 @@ const authSignalPath = (companyId: string, type: string, environmentId?: string) `/api/companies/${companyId}/adapters/${type}/auth-signal${environmentId ? `?environmentId=${environmentId}` : ""}`; describe("adapter auth-signal route", () => { - beforeEach(() => { + beforeEach(async () => { vi.resetModules(); vi.clearAllMocks(); currentActor = boardActor(OWNER_A); @@ -176,6 +174,12 @@ describe("adapter auth-signal route", () => { effectiveHome: "/tmp/codex-home", sharedSourceHome: "/tmp/codex-shared-home", }); + const [routes, middleware] = await Promise.all([ + vi.importActual("../routes/agents.js"), + vi.importActual("../middleware/index.js"), + ]); + agentRoutes = routes.agentRoutes; + errorHandler = middleware.errorHandler; }); afterEach(() => { diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 341079db147..8d01d884271 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -15,8 +15,14 @@ import { import { resolveExternalAdapterRegistration, setOverridePaused, + hasActiveAdapterOverride, } from "../adapters/registry.js"; +const nativeSetup = vi.hoisted(() => ({ authenticate: vi.fn() })); +vi.mock("../services/native-runtime/setup-readiness.js", () => ({ + testNativeRunnerAuthentication: nativeSetup.authenticate, +})); + vi.mock("../vendor/paperclip-runner/live/index.js", () => ({ probeAcpxClaudeInstallation: vi.fn(async () => undefined), probeAcpxGrokInstallation: vi.fn(async () => undefined), @@ -267,6 +273,26 @@ describe("server adapter registry", () => { }); }); + it.each(["pass", "fail"] as const)("uses the selected native Codex runtime for setup (%s)", async status => { + nativeSetup.authenticate.mockReset().mockResolvedValue({ adapterType: "paperclip_runner", status, testedAt: "", checks: [{ code: `codex_hello_probe_${status === "pass" ? "passed" : "auth_required"}`, level: status === "pass" ? "info" : "error", message: "Selected native account" }] }); + const context = { companyId: "company-1", adapterType: "paperclip_runner", config: { provider: "codex", model: "gpt-5.6-sol", modelReasoningEffort: "high", env: { OPENAI_API_KEY: "selected-key" } } }; + const result = await requireServerAdapter("paperclip_runner").testEnvironment(context); + expect(result.status).toBe(status); + expect(nativeSetup.authenticate).toHaveBeenCalledWith(context, "codex", "gpt-5.6-sol"); + }); + + it("identifies only active external overrides for automatic runner selection", () => { + try { + expect(hasActiveAdapterOverride("codex_local")).toBe(false); + registerServerAdapter({ ...externalAdapter, type: "codex_local" }); + expect(hasActiveAdapterOverride("codex_local")).toBe(true); + setOverridePaused("codex_local", true); + expect(hasActiveAdapterOverride("codex_local")).toBe(false); + setOverridePaused("codex_local", false); + expect(hasActiveAdapterOverride("codex_local")).toBe(true); + } finally { unregisterServerAdapter("codex_local"); } + }); + it.each([ ["claude_managed", { managedProfileId: "managed-primary", diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index c4a66a3ff72..9ccc123a859 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -147,11 +147,11 @@ describe("adapter routes", () => { } }); - it("keeps paperclip_runner hidden from selection unless the rollout flag is enabled", async () => { + it("keeps the shared runner implementation available for Codex without the experimental flag", async () => { const disabledResponse = await request(createApp()).get("/api/adapters"); expect(disabledResponse.status).toBe(200); expect(disabledResponse.body.find((adapter: any) => adapter.type === "paperclip_runner")) - .toMatchObject({ disabled: true }); + .toMatchObject({ disabled: false }); const enabledResponse = await request(createApp({}, { getNativeRunnerEnabled: async () => true, @@ -166,6 +166,52 @@ describe("adapter routes", () => { }); }); + it.each([ + [{ driver: "ssh", platform: "linux", architecture: "x64" }, "paperclip"], + [{ driver: "ssh", platform: "darwin", architecture: "arm64" }, "paperclip"], + [{ driver: "ssh", platform: "darwin", architecture: "x64" }, "paperclip"], + [{ driver: "ssh", platform: "linux", architecture: "arm64" }, "paperclip"], + [{ driver: "ssh", platform: "linux", architecture: "arm" }, "legacy"], + [{ driver: "local", platform: "win32", architecture: "x64" }, "legacy"], + [{ driver: "sandbox", platform: "linux", architecture: "arm64", libc: "glibc", glibcVersion: "2.39" }, "paperclip"], + ])("projects the selected environment once for the whole adapter inventory (%s)", async (target, defaultRunner) => { + const resolveRunnerTarget = vi.fn(async () => target); + const response = await request(createApp({}, { resolveRunnerTarget })).get("/api/adapters?companyId=company-1&environmentId=selected"); + expect(response.status).toBe(200); + expect(resolveRunnerTarget).toHaveBeenCalledExactlyOnceWith("company-1", "selected"); + expect(response.body.find((adapter: any) => adapter.type === "codex_local")).toMatchObject({ defaultRunner }); + expect(response.body.find((adapter: any) => adapter.type === "claude_local")).toMatchObject({ defaultRunner: "legacy" }); + }); + + it("uses the company's effective default environment when discovery omits an override", async () => { + const resolveRunnerTarget = vi.fn(async () => ({ driver: "sandbox" })); + const response = await request(createApp({}, { resolveRunnerTarget })).get("/api/adapters?companyId=company-1"); + expect(response.status).toBe(200); + expect(resolveRunnerTarget).toHaveBeenCalledExactlyOnceWith("company-1", undefined); + expect(response.body.find((adapter: any) => adapter.type === "codex_local")).toMatchObject({ defaultRunner: "legacy" }); + }); + + it("keeps unscoped inventory independent of company target discovery", async () => { + const resolveRunnerTarget = vi.fn(); + const response = await request(createApp({}, { resolveRunnerTarget })).get("/api/adapters"); + expect(response.status).toBe(200); + expect(resolveRunnerTarget).not.toHaveBeenCalled(); + }); + + it("rejects a foreign company before selecting or probing an environment", async () => { + const resolveRunnerTarget = vi.fn(); + const response = await request(createApp({ source: "session", companyIds: ["allowed"] }, { resolveRunnerTarget })).get("/api/adapters?companyId=foreign&environmentId=selected"); + expect(response.status).toBe(403); + expect(resolveRunnerTarget).not.toHaveBeenCalled(); + }); + + it.each(["environmentId=selected", "companyId=", "companyId=one&companyId=two", "companyId=one&environmentId="])("rejects malformed discovery scope before resolution (%s)", async query => { + const resolveRunnerTarget = vi.fn(); + const response = await request(createApp({}, { resolveRunnerTarget })).get(`/api/adapters?${query}`); + expect(response.status).toBe(400); + expect(resolveRunnerTarget).not.toHaveBeenCalled(); + }); + it("keeps the shared implementation available for Dot independently, while honoring adapter-admin disabling", async () => { const options = { getNativeRunnerEnabled: async () => false, getOpenAiDotEnabled: async () => true }; const enabled = await request(createApp({}, options)).get("/api/adapters"); diff --git a/server/src/__tests__/agent-adapter-validation-routes.test.ts b/server/src/__tests__/agent-adapter-validation-routes.test.ts index 335381d98b5..1efdbe6f9a5 100644 --- a/server/src/__tests__/agent-adapter-validation-routes.test.ts +++ b/server/src/__tests__/agent-adapter-validation-routes.test.ts @@ -657,6 +657,78 @@ describe("agent routes adapter validation", () => { expect(res.status, JSON.stringify(res.body)).toBe(200); }); + it.each(["pass", "fail"] as const)("tests saved legacy Codex on a disabled adapter and retains its %s runtime verdict", async (status) => { + const adapterConfig = { model: "gpt-5.4", extraArgs: ["--legacy-setting"], env: { CODEX_HOME: "/saved-codex-home" } }; + mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterConfig }); + mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue(["codex_local"]); + const probe = vi.fn(async () => ({ adapterType: "codex_local", status, testedAt: new Date(0).toISOString(), + checks: status === "fail" ? [{ code: "adapter_auth_missing", level: "error" as const, message: "Sign in to the saved Codex account." }] : [] })); + const { registerServerAdapter } = await import("../adapters/index.js"); + registerServerAdapter({ ...externalAdapter, type: "codex_local", testEnvironment: probe }); + try { + const app = await createApp(); + const res = await requestApp(app, baseUrl => request(baseUrl) + .post("/api/companies/company-1/adapters/codex_local/test-environment") + .send({ agentId: "11111111-1111-4111-8111-111111111111", adapterConfig: { + ...adapterConfig, env: { CODEX_HOME: { type: "plain", value: "***REDACTED***" } }, + } })); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toMatchObject({ adapterType: "codex_local", status }); + expect(probe).toHaveBeenCalledOnce(); + expect(probe).toHaveBeenCalledWith(expect.objectContaining({ adapterType: "codex_local", config: adapterConfig })); + expect(mockSecretService.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith("company-1", adapterConfig, expect.objectContaining({ adapterType: "codex_local" })); + expect(mockAgentService.create).not.toHaveBeenCalled(); + expect(mockAgentService.update).not.toHaveBeenCalled(); + } finally { await unregisterTestAdapter("codex_local"); } + }); + + it.each(["draft", "explicit legacy", "explicit auto", "explicit native", "changed harness"] as const)("rejects a disabled Codex setup selection for %s", async (mode) => { + mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue(["codex_local"]); + if (mode === "changed harness") mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "external_test" }); + const probe = vi.fn(); + const { registerServerAdapter } = await import("../adapters/index.js"); + registerServerAdapter({ ...externalAdapter, type: "codex_local", testEnvironment: probe }); + try { + const runner = mode === "explicit legacy" ? "legacy" : mode === "explicit auto" ? "auto" : mode === "explicit native" ? "paperclip" : undefined; + const app = await createApp(); + const res = await requestApp(app, baseUrl => request(baseUrl) + .post("/api/companies/company-1/adapters/codex_local/test-environment") + .send({ adapterConfig: {}, ...(mode === "draft" ? {} : { agentId: "11111111-1111-4111-8111-111111111111" }), ...(runner ? { runner } : {}) })); + expect(res.status, JSON.stringify(res.body)).toBe(422); + expect(res.body.details).toMatchObject({ code: "agent_runner_unavailable" }); + expect(probe).not.toHaveBeenCalled(); + expect(mockSecretService.normalizeAdapterConfigForPersistence).not.toHaveBeenCalled(); + } finally { await unregisterTestAdapter("codex_local"); } + }); + + it("still rejects new Codex creation when its adapter is disabled", async () => { + mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue(["codex_local"]); + const app = await createApp(); + const res = await requestApp(app, baseUrl => request(baseUrl) + .post("/api/companies/company-1/agents").send({ name: "New Codex", adapterType: "codex_local" })); + expect(res.status, JSON.stringify(res.body)).toBe(422); + expect(res.body.details).toMatchObject({ code: "agent_runner_unavailable" }); + expect(mockAgentService.create).not.toHaveBeenCalled(); + }); + + it("rechecks selection policy when a saved native adapter switches its underlying harness", async () => { + mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } }); + mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false }); + const probe = vi.fn(); + const { registerServerAdapter } = await import("../adapters/index.js"); + registerServerAdapter({ ...externalAdapter, type: "paperclip_runner", testEnvironment: probe }); + try { + 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" } })); + expect(res.status, JSON.stringify(res.body)).toBe(422); + expect(res.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" }); + expect(probe).not.toHaveBeenCalled(); + expect(mockSecretService.normalizeAdapterConfigForPersistence).not.toHaveBeenCalled(); + } finally { await unregisterTestAdapter("paperclip_runner"); } + }); + it("still creates an agent on an adapter that is registered and enabled", async () => { const { registerServerAdapter } = await import("../adapters/index.js"); registerServerAdapter(externalAdapter); @@ -672,20 +744,45 @@ describe("agent routes adapter validation", () => { expect(res.status, JSON.stringify(res.body)).toBe(201); }); - it("rejects a new paperclip_runner selection while the rollout flag is off", async () => { + it("defaults new Codex on a qualified Linux target while the experimental flag is off", async () => { + const selection = await import("../services/agent-runner-selection.js"); + vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) => + selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "linux", architecture: "x64" } })); mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true }); const app = await createApp(); const res = await requestApp(app, (baseUrl) => request(baseUrl) .post("/api/companies/company-1/agents") - .send({ name: "Native Codex", adapterType: "paperclip_runner" }), + .send({ name: "Native Codex", adapterType: "codex_local" }), ); - expect(res.status, JSON.stringify(res.body)).toBe(422); - expect(res.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" }); + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockAgentService.create.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } }); + expect(mockAgentService.create.mock.calls.at(-1)?.[2]).toMatchObject({ runnerResolved: true }); + }); + + it("retains explicit legacy Codex and rejects incompatible automatic settings before persistence", async () => { + const selection = await import("../services/agent-runner-selection.js"); + vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) => + selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "linux", architecture: "x64" } })); + const app = await createApp(); + const legacy = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Legacy", adapterType: "codex_local", runner: "legacy", adapterConfig: { extraArgs: ["--search"] } })); + expect(legacy.status, JSON.stringify(legacy.body)).toBe(201); + expect(mockAgentService.create.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: "codex_local", adapterConfig: { extraArgs: ["--search"] } }); + mockAgentService.create.mockClear(); + const incompatible = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Custom", adapterType: "codex_local", adapterConfig: { extraArgs: ["--search"] } })); + expect(incompatible.status).toBe(422); + expect(incompatible.body.details).toMatchObject({ code: "agent_runner_config_incompatible", fields: ["extraArgs"] }); expect(mockAgentService.create).not.toHaveBeenCalled(); }); + it("keeps non-Codex 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" } })); + expect(response.status).toBe(422); + expect(response.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" }); + }); + it("allows a new paperclip_runner selection while the rollout flag is on", async () => { mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true }); const app = await createApp(); @@ -768,15 +865,14 @@ describe("agent routes adapter validation", () => { expect(mockAgentService.update).not.toHaveBeenCalled(); }); - it("requires the general Runner rollout when changing an existing Dot to Codex", async () => { + it("allows an explicit Dot to Codex change without enabling other native providers", async () => { mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true }); mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "paperclip_runner", adapterConfig: { provider: "openai_dot", allowUnmeteredProvider: true } }); const app = await createApp(); const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111") .send({ adapterConfig: { provider: "codex" }, replaceAdapterConfig: true })); - expect(response.status, JSON.stringify(response.body)).toBe(422); - expect(response.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" }); - expect(mockAgentService.update).not.toHaveBeenCalled(); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(mockAgentService.update.mock.calls.at(-1)?.[1]).toMatchObject({ adapterConfig: { provider: "codex" } }); }); it("normalizes legacy skills and permissions when switching to paperclip_runner", async () => { diff --git a/server/src/__tests__/agent-hire-ai-connections.test.ts b/server/src/__tests__/agent-hire-ai-connections.test.ts index ec903524c72..c588b24b70c 100644 --- a/server/src/__tests__/agent-hire-ai-connections.test.ts +++ b/server/src/__tests__/agent-hire-ai-connections.test.ts @@ -495,7 +495,7 @@ describe("hired agents sharing a subscription", () => { } it("keeps a credential-lock timeout on automatic retry without blocking the task or starting a provider", async () => { const f = await fixture("openai", "subscription"); - const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Waiting teammate", role: "engineer", adapterType: f.adapterType, reportsTo: f.agentId, adapterConfig: { engine: "cli" }, runtimeConfig: { heartbeat: { enabled: false } } })); + const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Waiting teammate", role: "engineer", adapterType: f.adapterType, runner: "legacy", reportsTo: f.agentId, adapterConfig: { engine: "cli" }, runtimeConfig: { heartbeat: { enabled: false } } })); // Only an operator may set host execution paths after the agent is hired. await db.update(agents).set({ adapterConfig: { ...agent.adapterConfig, cwd: home } }).where(eq(agents.id, agent.id)); await finishSetup(agent.id); @@ -539,7 +539,7 @@ describe("hired agents sharing a subscription", () => { it.each(["openai", "anthropic"] as const)("runs the %s child alongside a live parent and inherits its connection", async (provider) => { const f = await fixture(provider, "subscription"); - const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Concurrent teammate", role: "engineer", adapterType: f.adapterType, reportsTo: f.agentId, adapterConfig: { engine: "cli" }, runtimeConfig: { heartbeat: { enabled: false } } })); + const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Concurrent teammate", role: "engineer", adapterType: f.adapterType, runner: "legacy", reportsTo: f.agentId, adapterConfig: { engine: "cli" }, runtimeConfig: { heartbeat: { enabled: false } } })); // Host working directories are configured by an operator, not an agent key. await db.update(agents).set({ adapterConfig: { ...agent.adapterConfig, cwd: home } }).where(eq(agents.id, agent.id)); await finishSetup(agent.id); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 0ad2e679095..446baf0cbb7 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -117,13 +117,22 @@ const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn()); const mockEnsureOpenCodeModelConfiguredAndAvailable = vi.hoisted(() => vi.fn()); const mockEnvironmentService = vi.hoisted(() => ({ getById: vi.fn(), + listBoundCompanyIds: vi.fn(), })); const mockInstanceSettingsService = vi.hoisted(() => ({ + get: vi.fn(), getGeneral: vi.fn(), + getExperimental: vi.fn(), })); +const mockRunSshCommand = vi.hoisted(() => vi.fn()); + function registerModuleMocks() { + vi.doMock("@paperclipai/adapter-utils/ssh", async (importOriginal) => ({ + ...(await importOriginal()), + runSshCommand: mockRunSshCommand, + })); vi.doMock("@paperclipai/adapter-opencode-local/server", async () => { const actual = await vi.importActual("@paperclipai/adapter-opencode-local/server"); return { @@ -336,7 +345,11 @@ describe("agent permission routes", () => { mockTrackAgentCreated.mockReset(); mockGetTelemetryClient.mockReset(); mockSyncInstructionsBundleConfigFromFilePath.mockReset(); + mockInstanceSettingsService.get.mockReset().mockResolvedValue({ defaultEnvironmentId: null, experimental: {}, general: {} }); + mockInstanceSettingsService.getExperimental.mockReset().mockResolvedValue({}); mockInstanceSettingsService.getGeneral.mockReset(); + mockEnvironmentService.listBoundCompanyIds.mockReset().mockResolvedValue([]); + mockRunSshCommand.mockReset().mockResolvedValue({ stdout: "Linux\nx86_64\n", stderr: "", code: 0 }); mockEnvironmentService.getById.mockReset(); mockEnsureOpenCodeModelConfiguredAndAvailable.mockReset(); mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config); @@ -1205,6 +1218,9 @@ describe("agent permission routes", () => { expect(res.status).toBe(403); expect(res.body.error).toContain("instructions path or bundle configuration"); + expect(mockInstanceSettingsService.get).not.toHaveBeenCalled(); + expect(mockEnvironmentService.getById).not.toHaveBeenCalled(); + expect(mockRunSshCommand).not.toHaveBeenCalled(); expect(mockAgentService.create).not.toHaveBeenCalled(); expect(mockLogActivity).not.toHaveBeenCalled(); }); @@ -1235,6 +1251,36 @@ describe("agent permission routes", () => { expect(mockLogActivity).not.toHaveBeenCalled(); }); + it.each(["agents", "agent-hires"])("rejects agent-supplied Codex host commands before resolving the runner via %s", async (route) => { + mockAccessService.hasPermission.mockResolvedValue(true); + mockInstanceSettingsService.get.mockRejectedValue(new Error("Denied requests must not inspect execution targets")); + const app = createApp({ type: "agent", agentId, companyId, source: "agent_key" }); + const res = await requestApp(app, (baseUrl) => request(baseUrl) + .post(`/api/companies/${companyId}/${route}`) + .send({ name: "Injected", role: "engineer", adapterType: "codex_local", adapterConfig: { command: "untrusted-codex" } })); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toContain("host-executed local adapter settings"); + expect(mockInstanceSettingsService.get).not.toHaveBeenCalled(); + expect(mockEnvironmentService.getById).not.toHaveBeenCalled(); + expect(mockRunSshCommand).not.toHaveBeenCalled(); + expect(mockAgentService.create).not.toHaveBeenCalled(); + }); + + it.each(["agents", "agent-hires"])("requires instance admin for external instructions before resolving the runner via %s", async (route) => { + mockInstanceSettingsService.get.mockRejectedValue(new Error("Denied requests must not inspect execution targets")); + const app = createApp({ type: "board", userId: "member-user", source: "session", companyIds: [companyId], isInstanceAdmin: false }); + const res = await requestApp(app, (baseUrl) => request(baseUrl) + .post(`/api/companies/${companyId}/${route}`) + .send({ name: "External", role: "engineer", adapterType: "codex_local", adapterConfig: { instructionsBundleMode: "external" } })); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(mockInstanceSettingsService.get).not.toHaveBeenCalled(); + expect(mockEnvironmentService.getById).not.toHaveBeenCalled(); + expect(mockRunSshCommand).not.toHaveBeenCalled(); + expect(mockAgentService.create).not.toHaveBeenCalled(); + }); + it("allows direct agent creation for authenticated board users with agent create permission when approval is not required", async () => { mockAccessService.canUser.mockResolvedValue(true); @@ -1261,7 +1307,7 @@ describe("agent permission routes", () => { expect.objectContaining({ status: "idle", }), - { createdByUserId: "agent-admin-user", responsibleUserId: "agent-admin-user", claudeLogin: { storedSessionId: null, ownerUserId: "agent-admin-user", applyExistingWithoutClaim: false } }, + { runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "agent-admin-user", responsibleUserId: "agent-admin-user", claudeLogin: { storedSessionId: null, ownerUserId: "agent-admin-user", applyExistingWithoutClaim: false } }, ); expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith( companyId, @@ -1391,7 +1437,7 @@ describe("agent permission routes", () => { }, }, }), - { createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, + { runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); }); @@ -1427,7 +1473,7 @@ describe("agent permission routes", () => { model: DEFAULT_OPENCODE_LOCAL_MODEL, }), }), - { createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, + { runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); }); @@ -1465,7 +1511,7 @@ describe("agent permission routes", () => { model: "anthropic/claude-sonnet-4-5", }), }), - { createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, + { runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); }); @@ -1505,6 +1551,8 @@ describe("agent permission routes", () => { }, }), { + runnerResolved: true, + aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, @@ -1716,7 +1764,7 @@ describe("agent permission routes", () => { expect.objectContaining({ defaultEnvironmentId: environmentId, }), - { createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, + { runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); }); @@ -1763,17 +1811,18 @@ describe("agent permission routes", () => { for (const adapterCase of sshCapableAdapterCases) { it(`allows creating a ${adapterCase.adapterType} agent with an SSH default environment`, async () => { + const expectedAdapterType = adapterCase.adapterType === "codex_local" ? "paperclip_runner" : adapterCase.adapterType; const environmentId = "33333333-3333-4333-8333-333333333333"; mockEnvironmentService.getById.mockResolvedValue({ id: environmentId, companyId, driver: "ssh", - config: {}, + config: { host: "qa.example.invalid", username: "qa", remoteWorkspacePath: "/workspace" }, }); mockAgentService.create.mockResolvedValue({ ...baseAgent, name: adapterCase.name, - adapterType: adapterCase.adapterType, + adapterType: expectedAdapterType, defaultEnvironmentId: environmentId, }); @@ -1799,11 +1848,18 @@ describe("agent permission routes", () => { expect(mockAgentService.create).toHaveBeenCalledWith( companyId, expect.objectContaining({ - adapterType: adapterCase.adapterType, + adapterType: expectedAdapterType, defaultEnvironmentId: environmentId, }), - { createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, + { 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" }); + 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 { + expect(mockRunSshCommand).not.toHaveBeenCalled(); + } }); } diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 0bcf3dbaaf3..ddb6803e643 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -3,6 +3,16 @@ import { readFile } from "node:fs/promises"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +const runnerTarget = vi.hoisted(() => ({ driver: "local", platform: "linux", architecture: "x64" })); +vi.mock("../services/agent-runner-selection.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveNewAgentRunnerForCompany: vi.fn(async (...[_db, _companyId, input]: Parameters) => + actual.resolveNewAgentRunner({ ...input, target: runnerTarget })), + }; +}); + const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), update: vi.fn(), @@ -50,6 +60,7 @@ const mockCompanySkillService = vi.hoisted(() => ({ })); const mockInstanceSettingsService = vi.hoisted(() => ({ + get: vi.fn(), getExperimental: vi.fn(), })); @@ -262,6 +273,8 @@ function skillSnapshot(adapterType = "claude_local", mode = "ephemeral") { describe("agent skill routes", () => { beforeEach(async () => { + runnerTarget.platform = "linux"; + runnerTarget.architecture = "x64"; vi.resetModules(); vi.doUnmock("../routes/agents.js"); vi.doUnmock("../routes/authz.js"); @@ -290,6 +303,7 @@ describe("agent skill routes", () => { agent: makeAgent("claude_local"), }); mockSecretService.resolveAdapterConfigForRuntime.mockResolvedValue({ config: { env: {} } }); + mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: null, experimental: {}, general: {} }); mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableBetaSkills: false }); mockSecretService.syncEnvBindingsForTarget.mockResolvedValue(undefined); mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([ @@ -931,7 +945,7 @@ describe("agent skill routes", () => { }), }), }), - { createdByUserId: "local-board", responsibleUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } }, + { runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "local-board", responsibleUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } }, ); expect(mockTrackAgentCreated).toHaveBeenCalledWith( expect.anything(), @@ -981,7 +995,7 @@ describe("agent skill routes", () => { expect.objectContaining({ role: "security", }), - { createdByUserId: "local-board", responsibleUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } }, + { runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "local-board", responsibleUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } }, ); expect(mockTrackAgentCreated).toHaveBeenCalledWith( expect.anything(), @@ -1168,31 +1182,46 @@ describe("agent skill routes", () => { .toContain("paperclipai/paperclip/first-task"); }); - it.each(["codex_local", "claude_local"].flatMap((adapterType) => [ + const codexRunnerCases = [ + { adapterType: "codex_local", runner: "auto", platform: "linux", expectedType: "paperclip_runner" }, + { adapterType: "codex_local", runner: "legacy", platform: "linux", expectedType: "codex_local" }, + { adapterType: "codex_local", runner: "auto", platform: "darwin", expectedType: "paperclip_runner" }, + { adapterType: "codex_local", runner: "legacy", platform: "darwin", expectedType: "codex_local" }, + { adapterType: "codex_local", runner: "auto", platform: "win32", expectedType: "codex_local" }, + ]; + it.each([...codexRunnerCases, { adapterType: "claude_local", runner: "auto", platform: "linux", expectedType: "claude_local" }].flatMap((runnerCase) => [ ["agents", "paperclipai/paperclip/paperclip-create-agent"], ["agent-hires", "paperclipai/paperclip/paperclip-create-agent"], ["agents", "paperclip"], ["agent-hires", "paperclip"], ["agents", "paperclipai/paperclip/first-task"], ["agent-hires", "paperclipai/paperclip/first-task"], - ].map(([route, skill]) => ({ adapterType, route, skill }))))("gives $adapterType onboarding agents core and first-task skills via $route, preserving $skill pins", async ({ adapterType, route, skill }) => { + ].map(([route, skill]) => ({ ...runnerCase, route, skill }))))("assigns onboarding skills via $route for $adapterType/$runner on $platform with $skill pins", async ({ adapterType, runner, platform, expectedType, route, skill }) => { + runnerTarget.platform = platform; + runnerTarget.architecture = platform === "darwin" ? "arm64" : "x64"; mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableBetaSkills: true }); const versionId = "22222222-2222-4222-8222-222222222222"; const res = await request(await createApp(createDb(route === "agent-hires"))) .post(`/api/companies/company-1/${route}`) .send({ - name: "Chiff", role: "general", adapterType, + name: "Chiff", role: "general", adapterType, ...(runner === "auto" ? {} : { runner }), onboardingFirstAgent: true, desiredSkills: [{ key: skill, versionId }], }); expect(res.status, JSON.stringify(res.body)).toBe(201); const input = mockAgentService.create.mock.calls[0][1]; expect(input.role).toBe("general"); + expect(input.adapterType).toBe(expectedType); + const native = expectedType === "paperclip_runner"; + if (native) expect(input.adapterConfig.provider).toBe("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") .map((name) => ({ key: `paperclipai/paperclip/${name}`, versionId: `paperclipai/paperclip/${name}` === canonicalKey ? versionId : null })); - expect(input.adapterConfig.paperclipSkillSync.desiredSkills).toEqual(expect.arrayContaining(expected)); - expect(input.adapterConfig.paperclipSkillSync.desiredSkills).toHaveLength(6); + const desiredSkills = input.adapterConfig.paperclipSkillSync.desiredSkills.map((entry: string | { key: string; versionId: string | null }) => + typeof entry === "string" ? { key: entry, versionId: null } : entry); + expect(desiredSkills).toEqual(expect.arrayContaining(expected)); + expect(desiredSkills).toHaveLength(expected.length); }); it.each(["agents", "agent-hires"])("leaves ordinary general agents' defaults unchanged via %s", async (route) => { @@ -1203,13 +1232,23 @@ describe("agent skill routes", () => { expect(mockAgentService.create.mock.calls[0][1].adapterConfig.paperclipSkillSync).toBeUndefined(); }); - it.each(["agents", "agent-hires"])("does not assign first-task to ordinary CEOs via %s", async (route) => { + 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 }) => { + 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" }); + .send({ name: "CEO", role: "ceo", adapterType: "codex_local", ...(runner === "auto" ? {} : { runner }) }); expect(res.status, JSON.stringify(res.body)).toBe(201); - const desiredSkills = mockAgentService.create.mock.calls[0][1].adapterConfig.paperclipSkillSync.desiredSkills; - expect(desiredSkills).toContain("paperclipai/paperclip/paperclip"); + 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"); + 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") + .map((name) => `paperclipai/paperclip/${name}`); + expect(desiredSkills).toEqual(expect.arrayContaining(expected)); + expect(desiredSkills).toHaveLength(expected.length); expect(desiredSkills).not.toContain("paperclipai/paperclip/first-task"); }); @@ -1417,6 +1456,8 @@ describe("agent skill routes", () => { }), }), { + runnerResolved: true, + aiConnectionInstall: undefined, createdByUserId: "local-board", responsibleUserId: "local-board", claudeLogin: { storedSessionId: null, diff --git a/server/src/__tests__/agent-test-environment-routes.test.ts b/server/src/__tests__/agent-test-environment-routes.test.ts index 62fbd70b57d..4bba678c432 100644 --- a/server/src/__tests__/agent-test-environment-routes.test.ts +++ b/server/src/__tests__/agent-test-environment-routes.test.ts @@ -47,7 +47,10 @@ const mockResolveEnvironmentExecutionTarget = vi.hoisted(() => vi.fn()); const mockInstanceSettingsService = vi.hoisted(() => ({ get: vi.fn(async () => ({ defaultEnvironmentId: null as string | null })), getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })), - getExperimental: vi.fn(async () => ({ enableManagedSandboxOnly: false })), + getExperimental: vi.fn(async (): Promise<{ + enableManagedSandboxOnly: boolean; + enableOpenAiDot?: boolean; + }> => ({ enableManagedSandboxOnly: false })), })); vi.mock("../services/index.js", () => ({ @@ -255,6 +258,10 @@ describe("agent test-environment route", () => { it("accepts a managed Dot environment without allocating an idle sandbox", async () => { vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example"); + mockInstanceSettingsService.getExperimental.mockResolvedValue({ + enableManagedSandboxOnly: false, + enableOpenAiDot: true, + }); mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" }); const app = await createApp(); try { @@ -270,6 +277,10 @@ describe("agent test-environment route", () => { it("fails the Cloud Dot check without a managed sandbox instead of probing the host", async () => { vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example"); vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "test-cloud"); + mockInstanceSettingsService.getExperimental.mockResolvedValue({ + enableManagedSandboxOnly: false, + enableOpenAiDot: true, + }); const app = await createApp(); try { const res = await request(app).post("/api/companies/company-1/adapters/paperclip_runner/test-environment") diff --git a/server/src/__tests__/agents-pending-approval-config.test.ts b/server/src/__tests__/agents-pending-approval-config.test.ts index 28d27e6e66c..ea7c38d97ad 100644 --- a/server/src/__tests__/agents-pending-approval-config.test.ts +++ b/server/src/__tests__/agents-pending-approval-config.test.ts @@ -17,6 +17,7 @@ import { } from "./helpers/embedded-postgres.js"; import { agentService } from "../services/agents.ts"; import { approvalService } from "../services/approvals.ts"; +import { paperclipRunnerSupportsPlatform } from "@paperclipai/shared"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -64,10 +65,34 @@ describeEmbeddedPostgres("pending approval agent config integrity", () => { return companyId; } + it("resolves internal Codex creation once and preserves legacy agents through ordinary edits", async () => { + const companyId = await seedCompany(); + const service = agentService(db); + const lifecycle = createAgentLifecycle(db); + const native = await lifecycle.requestHire(companyId, { name: "New Codex", adapterType: "codex_local", adapterConfig: { modelReasoningEffort: "high" }, status: "pending_approval" }); + const legacy = await lifecycle.requestHire(companyId, { name: "Legacy Codex", adapterType: "codex_local", runner: "legacy", adapterConfig: { extraArgs: ["--search"] }, status: "pending_approval" }); + const reviewedLegacy = await lifecycle.requestHire(companyId, { name: "Historical approved hire", adapterType: "codex_local", adapterConfig: {}, status: "pending_approval" }, { runnerResolved: true }); + if (paperclipRunnerSupportsPlatform("codex_local", process.platform, process.arch)) { + expect(native).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", modelReasoningEffort: "high" } }); + } else { + expect(native).toMatchObject({ adapterType: "codex_local", adapterConfig: { modelReasoningEffort: "high" } }); + } + expect(legacy.adapterType).toBe("codex_local"); + expect(reviewedLegacy.adapterType).toBe("codex_local"); + for (const agent of [native, legacy, reviewedLegacy]) { + expect(agent).not.toHaveProperty("runner"); + await lifecycle.approveHire(agent.id); + await service.update(agent.id, { title: "Updated title" }); + const saved = await service.getById(agent.id); + expect(saved?.adapterType).toBe(agent.adapterType); + expect(saved?.adapterConfig).toEqual(agent.adapterConfig); + } + }); + it("freezes generic pending hire config and reapplies the approval snapshot on activation", async () => { const companyId = await seedCompany(); const agentSvc = agentService(db); - const agentSvcLifecycle = createAgentLifecycle(db); + const agentSvcLifecycle = createAgentLifecycle(db); const approvalSvc = approvalService(db); const pending = await agentSvcLifecycle.requestHire(companyId, { name: "Pending Coder", diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index 6d3c5f1004f..5856fa52b36 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -25,6 +25,9 @@ import { syncConnectionCredentialBindings } from "../services/connection-credent import * as codexAdapter from "@paperclipai/adapter-codex-local/server"; import { WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE } from "@paperclipai/adapter-utils/workspace-restore-merge"; import * as executionTarget from "@paperclipai/adapter-utils/execution-target"; +import * as runnerProbe from "../vendor/paperclip-runner/index.js"; +import * as nativeCodexRunner from "../services/native-runtime/native-codex-runner.js"; +import { testNativeRunnerAuthentication } from "../services/native-runtime/setup-readiness.js"; import { prepareManagedAiRuntime, withManagedAiProbe, assertManagedAiProjectAuth, isAiConnectionBusy } from "../services/ai-connection-runtime.js"; import { execute as executeGemini, testEnvironment as testGeminiEnvironment } from "@paperclipai/adapter-gemini-local/server"; import { toolAccessService } from "../services/tool-access.js"; @@ -174,6 +177,42 @@ describe("managed AI connections", () => { } finally { release.resolve(); await Promise.allSettled([probing, refreshing]); lock?.mockRestore(); request.mockRestore(); } }); + it("persists native Codex setup's refreshed selected login without changing existing agents", async () => { + const owner = "native-codex-setup-refresh"; + await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" }); + const auth = (hour: string) => JSON.stringify({ tokens: { account_id: owner, id_token: "identity", access_token: `access-${hour}`, refresh_token: `refresh-${hour}` }, last_refresh: `2026-09-10T${hour}:00:00Z` }); + await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, auth("10")); + const [row] = await service.quotaAccounts(companyId, owner); + const [before] = await db.select({ adapterType: agents.adapterType, adapterConfig: agents.adapterConfig }).from(agents).where(eq(agents.id, agentId)); + const runnerBinary = path.join(home, "fixture-runnerd"); + const resolveBinary = vi.spyOn(nativeCodexRunner, "resolvePaperclipRunnerBinary").mockReturnValue(runnerBinary); + const probe = vi.spyOn(runnerProbe, "probeNativeRunnerEnvironment").mockImplementation(async input => { + expect(input.transportOptions?.runnerBinary).toBe(runnerBinary); + expect(input.transportOptions?.sourceCodexHome).toContain("/provider"); + expect(await readFile(path.join(input.transportOptions!.sourceCodexHome!, "auth.json"), "utf8")).toBe(auth("10")); + const providerHome = path.join(input.runtimeDirectory, "codex-home"); + await fs.mkdir(providerHome, { mode: 0o700 }); + const refreshedFile = path.join(providerHome, "auth.json"); + await writeFile(refreshedFile, auth("11"), { mode: 0o600 }); + await input.onCodexCredentialRefresh!(refreshedFile); + await input.onCleanupConfirmed!(); + return { provider: "codex", providerDriver: "codex_app_server", effectiveModel: "gpt-5.6-sol", helloProbePassed: true }; + }); + let managedHome: string | undefined; + try { + const result = await withManagedAiProbe(db, { companyId, agentId, responsibleUserId: owner, adapterType: "paperclip_runner", + binding: { provider: "openai", method: "subscription", mode: "responsible_user" }, config: { provider: "codex" } }, async runtime => { + managedHome = runtime.home; + return testNativeRunnerAuthentication({ companyId, adapterType: "paperclip_runner", config: runtime.config, managedAiCredentialHome: path.join(runtime.home!, "provider") }, "codex", "gpt-5.6-sol"); + }); + expect(result.status, JSON.stringify(result)).toBe("pass"); expect(probe).toHaveBeenCalledOnce(); + expect(await service.credential(row)).toBe(auth("11")); + await expect(access(managedHome!)).rejects.toMatchObject({ code: "ENOENT" }); + const [after] = await db.select({ adapterType: agents.adapterType, adapterConfig: agents.adapterConfig }).from(agents).where(eq(agents.id, agentId)); + expect(after).toEqual(before); + } finally { probe.mockRestore(); resolveBinary.mockRestore(); } + }); + it("rejects preparation when reconnect replaces the selected credential reference", async () => { const owner = "replaced-ref-owner"; await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" }); diff --git a/server/src/__tests__/approvals-service.test.ts b/server/src/__tests__/approvals-service.test.ts index 1a4cbfe22d5..31755c1f65c 100644 --- a/server/src/__tests__/approvals-service.test.ts +++ b/server/src/__tests__/approvals-service.test.ts @@ -141,6 +141,7 @@ describe("approvalService resolution idempotency", () => { requestedByUserId, payload: { name: "New Agent", + adapterType: "codex_local", adapterConfig: { env: { API_KEY: { @@ -161,9 +162,10 @@ describe("approvalService resolution idempotency", () => { expect(mockAgentService.create).toHaveBeenCalledWith( "company-1", expect.objectContaining({ + adapterType: "codex_local", adapterConfig: approved.payload.adapterConfig, }), - { createdByUserId: expectedCreator, responsibleUserId: requestedByUserId }, + { runnerResolved: true, createdByUserId: expectedCreator, responsibleUserId: requestedByUserId }, ); }); }); diff --git a/server/src/__tests__/built-in-agents.test.ts b/server/src/__tests__/built-in-agents.test.ts index 3e0d4c9079e..a084001ad89 100644 --- a/server/src/__tests__/built-in-agents.test.ts +++ b/server/src/__tests__/built-in-agents.test.ts @@ -6,7 +6,7 @@ import { randomUUID } from "node:crypto"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { and, eq, sql } from "drizzle-orm"; import { activityLog, @@ -140,6 +140,8 @@ describeEmbeddedPostgres("built-in agents", () => { }; } let tempDb: Awaited> | null = null; + let runnerSelectionSpy: { mockRestore(): void } | undefined; + const runnerTarget = { driver: "local", platform: "linux", architecture: "x64" }; const instructionOperator = { type: "board", userId: "local-board", source: "local_implicit" } as const; async function writeInstructionEntry(agent: { id: string; companyId: string }, entryFile: string, content: string) { const service = agentInstructionRevisionService(db); @@ -157,7 +159,16 @@ describeEmbeddedPostgres("built-in agents", () => { lifecycleWorker = configureAgentLifecycle(db, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost: async () => "complete" }); }, 20_000); + beforeEach(async () => { + runnerTarget.platform = "linux"; + runnerTarget.architecture = "x64"; + const selection = await import("../services/agent-runner-selection.js"); + runnerSelectionSpy = vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) => + selection.resolveNewAgentRunner({ ...input, target: runnerTarget })); + }); + afterEach(async () => { + runnerSelectionSpy?.mockRestore(); await lifecycleWorker.sweep(); await db.delete(routineTriggers); await db.delete(routines); @@ -255,7 +266,9 @@ describeEmbeddedPostgres("built-in agents", () => { ])).toThrow("defaultAdapterType must be allowed"); }); - it("lazily provisions one agent per company/key and updates the same row on setup", async () => { + it.each([{ platform: "linux", architecture: "x64" }, { platform: "linux", architecture: "arm64" }, { platform: "darwin", architecture: "arm64" }, { platform: "darwin", architecture: "x64" }])("lazily provisions one agent per company/key and updates the same row on setup ($platform/$architecture)", async ({ platform, architecture }) => { + runnerTarget.platform = platform; + runnerTarget.architecture = architecture; const companyId = await seedCompany(); const svc = builtInAgentService(db); @@ -280,21 +293,60 @@ describeEmbeddedPostgres("built-in agents", () => { expect(configured.status).toBe("ready"); expect(configured.agentId).toBe(created.agentId); expect(configured.agent).toMatchObject({ - adapterType: "codex_local", - adapterConfig: { model: "gpt-5.4" }, + adapterType: "paperclip_runner", + adapterConfig: { provider: "codex", model: "gpt-5.4" }, }); const reconciled = await svc.ensure(companyId, "briefs"); expect(reconciled.status).toBe("ready"); expect(reconciled.agent).toMatchObject({ - adapterType: "codex_local", - adapterConfig: { model: "gpt-5.4" }, + adapterType: "paperclip_runner", + adapterConfig: { provider: "codex", model: "gpt-5.4" }, }); const rows = await db.select().from(agents).where(eq(agents.companyId, companyId)); expect(rows).toHaveLength(1); }); + it("keeps existing legacy and native built-in runners through unrelated edits and startup", async () => { + const companyId = await seedCompany({ requireApproval: false }); + const svc = builtInAgentService(db); + const savedSettings = { model: "gpt-5.5", modelReasoningEffort: "high", timeoutSec: 123 }; + const created = await svc.ensure(companyId, "briefs", { adapterType: "codex_local", adapterConfig: savedSettings, runner: "legacy" }); + expect(created.agent?.adapterType).toBe("codex_local"); + await svc.ensure(companyId, "briefs", { budgetMonthlyCents: 100 }); + await reconcileBuiltInAgentsOnStartup(db); + const legacy = await svc.get(companyId, "briefs"); + expect(legacy.agent?.adapterType).toBe("codex_local"); + expect(legacy.agent?.adapterConfig).toEqual(created.agent?.adapterConfig); + const switched = await svc.ensure(companyId, "briefs", { runner: "paperclip" }); + expect(switched.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", ...savedSettings } }); + await svc.ensure(companyId, "briefs", { budgetMonthlyCents: 200 }); + await reconcileBuiltInAgentsOnStartup(db); + expect((await svc.get(companyId, "briefs")).agent?.adapterConfig).toEqual(switched.agent?.adapterConfig); + const switchedBack = await svc.ensure(companyId, "briefs", { runner: "legacy" }); + expect(switchedBack.agent).toMatchObject({ adapterType: "codex_local", adapterConfig: savedSettings }); + const switchedAgain = await svc.ensure(companyId, "briefs", { runner: "paperclip" }); + expect(switchedAgain.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", ...savedSettings } }); + runnerTarget.platform = "win32"; + runnerTarget.architecture = "x64"; + const edited = await svc.ensure(companyId, "briefs", { adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } }); + expect(edited.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.4" } }); + }); + + it("freezes Codex built-in execution in the reviewed hire through service restart", async () => { + const companyId = await seedCompany({ requireApproval: true }); + const result = await builtInAgentService(db).provision(companyId, "briefs", { adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } }, { requestedByUserId: "board-user" }); + expect(result.approval?.payload).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.4" } }); + runnerTarget.platform = "win32"; + runnerTarget.architecture = "x64"; + await reconcileBuiltInAgentsOnStartup(db); + await approvalService(db).approve(result.approval!.id, "board-user"); + const approved = await builtInAgentService(db).get(companyId, "briefs"); + expect(approved.agent?.adapterType).toBe(result.approval!.payload.adapterType); + expect(approved.agent?.adapterConfig).toEqual(result.approval!.payload.adapterConfig); + }); + it("records the human who enabled a built-in agent and leaves automatic creation unattributed", async () => { const enabledCompanyId = await seedCompany({ requireApproval: false }); const automaticCompanyId = await seedCompany({ requireApproval: false }); @@ -418,7 +470,7 @@ describeEmbeddedPostgres("built-in agents", () => { await expect(builtIns.get(companyId, "briefs")).resolves.toMatchObject({ status: "ready", agentId: ready.agentId, - agent: { adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } }, + agent: { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.4" } }, }); }); @@ -445,8 +497,8 @@ describeEmbeddedPostgres("built-in agents", () => { agentId: seeded.agentId, agent: { status: "idle", - adapterType: "codex_local", - adapterConfig: { model: "gpt-5.4" }, + adapterType: "paperclip_runner", + adapterConfig: { provider: "codex", model: "gpt-5.4" }, budgetMonthlyCents: 2500, }, }); @@ -612,8 +664,8 @@ describeEmbeddedPostgres("built-in agents", () => { role: "general", title: null, capabilities: "Prepares concise operational briefs for the board and agent company.", - adapterType: "codex_local", - adapterConfig: { model: "gpt-5.4" }, + adapterType: "paperclip_runner", + adapterConfig: { provider: "codex", model: "gpt-5.4" }, }, }); }); @@ -659,7 +711,7 @@ describeEmbeddedPostgres("built-in agents", () => { role: "general", title: "Reflection Coach", icon: "eye", - adapterType: "codex_local", + adapterType: "paperclip_runner", permissions: { canCreateAgents: false, canCreateSkills: false, @@ -1275,7 +1327,7 @@ describeEmbeddedPostgres("built-in agents", () => { title: "Reflection Coach", icon: "eye", reportsTo: root.id, - adapterType: "codex_local", + adapterType: "paperclip_runner", budgetMonthlyCents: 0, }); expect(state.status).toBe("paused"); diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index bfd3cc30543..74557b85033 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -8,6 +8,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CompanyPortabilityFileEntry } from "@paperclipai/shared"; import { runnerApiResponseReservations } from "@paperclipai/db"; +const runnerTarget = vi.hoisted(() => ({ driver: "local", platform: "linux", architecture: "x64" })); + +vi.mock("../services/agent-runner-selection.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveNewAgentRunnerForCompany: vi.fn(async (...[_db, _companyId, input]: Parameters) => + actual.resolveNewAgentRunner({ ...input, target: runnerTarget })) }; +}); + const companySvc = { getById: vi.fn(), // Async-empty default (not a bare vi.fn()): every new-company import reads @@ -20,6 +28,7 @@ const companySvc = { const agentSvc = { list: vi.fn(), + getById: vi.fn(), create: vi.fn(), update: vi.fn(), }; @@ -104,6 +113,7 @@ const agentInstructionsSvc = { }; const instanceSettingsSvc = { + get: vi.fn(async () => ({ general: {}, experimental: {}, defaultEnvironmentId: null })), getExperimental: vi.fn(async (): Promise<{ enableNativeRunner: boolean; enableOpenAiDot?: boolean }> => ({ enableNativeRunner: false })), }; @@ -214,6 +224,9 @@ describe("company portability", () => { beforeEach(() => { vi.clearAllMocks(); + runnerTarget.platform = "linux"; + runnerTarget.architecture = "x64"; + agentSvc.getById.mockImplementation(async (id: string) => (await agentSvc.list()).find((agent: { id: string }) => agent.id === id) ?? null); instanceSettingsSvc.getExperimental.mockResolvedValue({ enableNativeRunner: false }); managedAgentProfileSvc.requireQualified.mockResolvedValue({ id: "managed-primary", @@ -1914,7 +1927,7 @@ describe("company portability", () => { }, }, }), - { strictMode: false, adapterType: "codex_local" }, + { strictMode: false, adapterType: "paperclip_runner" }, ); expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ adapterConfig: expect.objectContaining({ @@ -3715,6 +3728,7 @@ describe("company portability", () => { adapterOverrides: { claudecoder: { adapterType: "codex_local", + runner: "legacy", adapterConfig: { dangerouslyBypassApprovalsAndSandbox: true, instructionsFilePath: "/tmp/should-not-survive.md", @@ -3815,6 +3829,7 @@ describe("company portability", () => { adapterOverrides: { claudecoder: { adapterType: "codex_local", + runner: "legacy", adapterConfig: { extraArgs: [], args: ["--legacy-arg"], @@ -5801,6 +5816,7 @@ describe("company portability", () => { adapterOverrides: { claudecoder: { adapterType: "codex_local", + runner: "legacy", adapterConfig: { model: "gpt-5.4", }, @@ -5989,6 +6005,80 @@ describe("company portability", () => { expect(preview.plan.issuePlans).toHaveLength(0); }); + it.each([ + { runner: "legacy", platform: "linux" }, + { runner: "legacy", platform: "darwin" }, + { runner: "paperclip", platform: "linux" }, + { runner: "paperclip", platform: "darwin" }, + ] as const)("exports the resolved Codex runner and preserves it on import ($runner/$platform)", async ({ runner, platform }) => { + runnerTarget.platform = platform; + runnerTarget.architecture = platform === "darwin" ? "arm64" : "x64"; + const [fixture] = await agentSvc.list(); + agentSvc.list.mockResolvedValue([{ ...fixture, adapterType: runner === "legacy" ? "codex_local" : "paperclip_runner", adapterConfig: { ...(runner === "paperclip" ? { provider: "codex" } : {}), model: "gpt-5.4" } }]); + const portability = companyPortabilityService({} as any); + const exported = await portability.exportBundle("company-1", { include: { company: false, agents: true, projects: false, issues: false } }); + expect(exported.manifest.agents[0]?.runner).toBe(runner); + expect(asTextFile(exported.files[".paperclip.yaml"])).toContain(`runner: "${runner}"`); + agentSvc.list.mockResolvedValue([]); + agentSvc.create.mockImplementation(async (_companyId: string, input: Record) => ({ id: "agent-created", ...input })); + await portability.importBundle({ source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, include: { company: false, agents: true, projects: false, issues: false }, target: { mode: "existing_company", companyId: "company-1" }, agents: "all", collisionStrategy: "rename" }, "user-1"); + expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ adapterType: runner === "legacy" ? "codex_local" : "paperclip_runner", runner, adapterConfig: expect.objectContaining(runner === "paperclip" ? { provider: "codex", model: "gpt-5.4" } : { model: "gpt-5.4" }) }), { createdByUserId: "user-1" }); + await portability.importBundle({ source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, include: { company: false, agents: true, projects: false, issues: false }, target: { mode: "existing_company", companyId: "company-1" }, agents: "all", collisionStrategy: "rename", adapterOverrides: { claudecoder: { adapterType: "codex_local", adapterConfig: { model: "gpt-5.5" } } } }, "user-1"); + expect(agentSvc.create).toHaveBeenLastCalledWith("company-1", expect.objectContaining({ adapterType: runner === "legacy" ? "codex_local" : "paperclip_runner", runner, adapterConfig: expect.objectContaining({ model: "gpt-5.5" }) }), { createdByUserId: "user-1" }); + }); + + it("rejects an invalid package runner without selecting a default or creating an agent", async () => { + const portability = companyPortabilityService({} as any); + const exported = await portability.exportBundle("company-1", { include: { company: false, agents: true, projects: false, issues: false } }); + const files = { ...exported.files, ".paperclip.yaml": asTextFile(exported.files[".paperclip.yaml"]).replace('runner: "legacy"', 'runner: "typo"') }; + agentSvc.list.mockResolvedValue([]); + await expect(portability.importBundle({ source: { type: "inline", rootPath: exported.rootPath, files }, include: { company: false, agents: true, projects: false, issues: false }, target: { mode: "existing_company", companyId: "company-1" }, agents: "all", collisionStrategy: "rename" }, "user-1")).rejects.toMatchObject({ status: 422, details: { code: "agent_runner_choice_invalid", field: "agents.claudecoder.adapter.runner" } }); + expect(agentSvc.create).not.toHaveBeenCalled(); + }); + + it.each([ + { action: "create", platform: "linux", expectedType: "paperclip_runner" }, + { action: "update", platform: "linux", expectedType: "codex_local" }, + { action: "create", platform: "darwin", expectedType: "paperclip_runner" }, + { action: "update", platform: "darwin", expectedType: "codex_local" }, + { action: "create", platform: "win32", expectedType: "codex_local" }, + ])("applies qualified Codex defaults only to new imports without a runner ($platform/$action)", async ({ action, platform, expectedType }) => { + runnerTarget.platform = platform; + runnerTarget.architecture = platform === "darwin" ? "arm64" : "x64"; + const [fixture] = await agentSvc.list(); + agentSvc.list.mockResolvedValue([{ ...fixture, adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } }]); + const portability = companyPortabilityService({} as any); + const exported = await portability.exportBundle("company-1", { include: { company: false, agents: true, projects: false, issues: false } }); + const files = { ...exported.files, ".paperclip.yaml": asTextFile(exported.files[".paperclip.yaml"]).replace(/\n\s+runner: "legacy"/, "") }; + if (action === "create") agentSvc.list.mockResolvedValue([]); + agentSvc.create.mockImplementation(async (_companyId: string, input: Record) => ({ id: "agent-created", ...input })); + agentSvc.update.mockImplementation(async (id: string, input: Record) => ({ id, ...fixture, ...input })); + await portability.importBundle({ source: { type: "inline", rootPath: exported.rootPath, files }, include: { company: false, agents: true, projects: false, issues: false }, target: { mode: "existing_company", companyId: "company-1" }, agents: "all", collisionStrategy: action === "update" ? "replace" : "rename" }, "user-1"); + if (action === "create") expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ adapterType: expectedType, adapterConfig: expect.objectContaining(expectedType === "paperclip_runner" ? { provider: "codex" } : { model: "gpt-5.4" }) }), { createdByUserId: "user-1" }); + else expect(agentSvc.update).toHaveBeenCalledWith("agent-1", expect.objectContaining({ adapterType: "codex_local", adapterConfig: expect.objectContaining({ model: "gpt-5.4" }) })); + }); + + it.each([ + { existingRunner: "legacy", existingHarness: "codex_local", expectedType: "codex_local", platform: "linux" }, + { existingRunner: "legacy", existingHarness: "codex_local", expectedType: "codex_local", platform: "darwin" }, + { existingRunner: "paperclip", existingHarness: "codex_local", expectedType: "paperclip_runner", platform: "linux" }, + { existingRunner: "paperclip", existingHarness: "codex_local", expectedType: "paperclip_runner", platform: "darwin" }, + { existingRunner: "legacy", existingHarness: "claude_local", expectedType: "paperclip_runner", platform: "linux" }, + ])("keeps execution on same-harness replace-import edits and defaults genuine changes ($existingRunner/$existingHarness/$platform)", async ({ existingRunner, existingHarness, expectedType, platform }) => { + runnerTarget.platform = platform; + runnerTarget.architecture = platform === "darwin" ? "arm64" : "x64"; + const [fixture] = await agentSvc.list(); + const existing = { ...fixture, adapterType: existingRunner === "paperclip" ? "paperclip_runner" : existingHarness, adapterConfig: { ...(existingRunner === "paperclip" ? { provider: "codex" } : {}), model: existingHarness === "codex_local" ? "gpt-5.4" : "claude-sonnet-4-6" } }; + agentSvc.list.mockResolvedValue([existing]); + const portability = companyPortabilityService({} as any); + const exported = await portability.exportBundle("company-1", { include: { company: false, agents: true, projects: false, issues: false } }); + const files = { ...exported.files, ".paperclip.yaml": asTextFile(exported.files[".paperclip.yaml"]).replace(/\n\s+runner: "(?:legacy|paperclip)"/, "") }; + agentSvc.update.mockImplementation(async (id: string, input: Record) => ({ id, ...existing, ...input })); + await portability.importBundle({ source: { type: "inline", rootPath: exported.rootPath, files }, include: { company: false, agents: true, projects: false, issues: false }, target: { mode: "existing_company", companyId: "company-1" }, agents: "all", collisionStrategy: "replace", adapterOverrides: { claudecoder: { adapterType: "codex_local", adapterConfig: { model: "gpt-5.5" } } } }, "user-1"); + expect(agentSvc.update).toHaveBeenCalledWith("agent-1", expect.objectContaining({ adapterType: expectedType, adapterConfig: expect.objectContaining({ model: "gpt-5.5", ...(expectedType === "paperclip_runner" ? { provider: "codex" } : {}) }) })); + expect(agentSvc.create).not.toHaveBeenCalled(); + }); + it("imports an unpaired Dot using its own option without enabling other Runner providers", async () => { const portability = companyPortabilityService({} as any); const exported = await portability.exportBundle("company-1", { include: { company: false, agents: true, projects: false, issues: false } }); @@ -6005,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: "codex" } } } }, "user-1")) + await expect(portability.importBundle({ ...input, adapterOverrides: { claudecoder: { adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6" } } } }, "user-1")) .rejects.toMatchObject({ status: 422, details: { code: "paperclip_runner_rollout_disabled" } }); expect(agentSvc.create).not.toHaveBeenCalled(); await portability.importBundle(input, "user-1"); @@ -6039,7 +6129,7 @@ describe("company portability", () => { adapterOverrides: { claudecoder: { adapterType: "paperclip_runner", - adapterConfig: { provider: "codex" }, + adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6" }, }, }, }; @@ -6071,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: "codex" }), + adapterConfig: expect.objectContaining({ provider: "acpx", acpxAgent: "claude" }), }), { createdByUserId: "user-1" }); await portability.importBundle({ diff --git a/server/src/__tests__/dev-runner-native-binary.test.ts b/server/src/__tests__/dev-runner-native-binary.test.ts index cb7640ba43a..eba039ec3f6 100644 --- a/server/src/__tests__/dev-runner-native-binary.test.ts +++ b/server/src/__tests__/dev-runner-native-binary.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { localPaperclipRunnerSupported } from "../lib/agent-runner-platform.js"; import { paperclipRunnerBinaryNeedsBuild, @@ -61,6 +62,14 @@ function nativeHeader(platform: string, architecture: string): Buffer { } describe("paperclip runner native dev prerequisite", () => { + it.each(["x64", "arm64"].flatMap(architecture => [[undefined, false], ["2.38", false], ["2.39", true], ["3.0", true]].map(([glibcVersionRuntime, native]) => ({ architecture, glibcVersionRuntime, native }))))("uses the measured libc prerequisite on Linux $architecture/$glibcVersionRuntime when no native run or flag requires it", ({ architecture, glibcVersionRuntime, native }) => { + Object.defineProperty(process, "platform", { ...initialPlatform, value: "linux" }); + Object.defineProperty(process, "arch", { ...initialArchitecture, value: architecture }); + vi.spyOn(process.report, "getReport").mockReturnValue({ header: { glibcVersionRuntime } } as never); + const nativeRunnerRequired = localPaperclipRunnerSupported(); + expect(nativeRunnerRequired).toBe(native); + expect(paperclipRunnerBinaryNeedsBuild({ repoRoot: "/checkout/without/a/staged/binary", nativeRunnerRequired })).toBe(native); + }); it("uses an explicit status response and fails safe when status is unknown", () => { expect( resolveNativeRunnerRequirement({ diff --git a/server/src/__tests__/dot-runner.test.ts b/server/src/__tests__/dot-runner.test.ts index d641662920a..6068d843bc0 100644 --- a/server/src/__tests__/dot-runner.test.ts +++ b/server/src/__tests__/dot-runner.test.ts @@ -101,7 +101,7 @@ describe("durable Dot Runner integration", () => { expect(() => resolveNativeRuntimeMode(input)).toThrow("experimental settings"); expect(resolveNativeRuntimeMode({ ...input, enabled: false, dotEnabled: true })).toMatchObject({ kind: "native", profile: { backend: "openai_dot_mcp" } }); expect(() => resolveNativeRuntimeMode({ ...input, enabled: false, dotEnabled: true, - adapterConfig: { provider: "codex" }, + adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" }, })).toThrow("Paperclip Runner is experimental and disabled"); expect(resolveHeartbeatNativeRuntimeMode({ ...input, enabled: false, dotEnabled: false, persisted: { runtimeMode: "native", runtimeModeReason: null, runtimeModeResolvedAt: new Date(), driverKind: "openai_dot_mcp" }, diff --git a/server/src/__tests__/native-status-arbiter-corpus.test.ts b/server/src/__tests__/native-status-arbiter-corpus.test.ts index 246b4e19f92..0cb7250694d 100644 --- a/server/src/__tests__/native-status-arbiter-corpus.test.ts +++ b/server/src/__tests__/native-status-arbiter-corpus.test.ts @@ -848,8 +848,12 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => { completionState === "kill_switch_during_active_native_run" && (fixture.covers.migrationRows ?? []).includes("MIG-08") ) { + const experimentalAdapterConfig = { + provider: "opencode", + model: "openrouter/deepseek/deepseek-v4-flash-0731", + }; const runtimeConfig = { - nativeRunner: { mode: "native", backend: "codex_app_server", protocolVersion: 1 }, + nativeRunner: { mode: "native", backend: "opencode_server", protocolVersion: 1 }, }; const enabled = options.disableLiveEntrypoint === "rollout"; const activeResolution = resolveHeartbeatNativeRuntimeMode({ @@ -860,7 +864,7 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => { }, enabled, runtimeConfig, - adapterConfig: { provider: "codex" }, + adapterConfig: experimentalAdapterConfig, agent: { id: agentId, status: "running", adapterType: "paperclip_runner" }, issue: { id: seeded.issueId, workMode: "standard" }, target: { kind: "local" }, @@ -873,7 +877,7 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => { persisted: { runtimeMode: null, runtimeModeReason: null, runtimeModeResolvedAt: null }, enabled, runtimeConfig, - adapterConfig: { provider: "codex" }, + adapterConfig: experimentalAdapterConfig, agent: { id: agentId, status: "running", adapterType: "paperclip_runner" }, issue: { id: seeded.issueId, workMode: "standard" }, target: { kind: "local" }, diff --git a/server/src/__tests__/onboarding-seed-route.test.ts b/server/src/__tests__/onboarding-seed-route.test.ts index a7d75aa3c49..d8cc783913b 100644 --- a/server/src/__tests__/onboarding-seed-route.test.ts +++ b/server/src/__tests__/onboarding-seed-route.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import request from "supertest"; import { and, eq } from "drizzle-orm"; -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { activityLog, agents, @@ -38,8 +38,16 @@ const SEED = { describeEmbeddedPostgres("POST /api/companies/:companyId/onboarding-seed", () => { const ctx = useEmbeddedPostgres("onboarding-seed-route"); + let runnerSelectionSpy: { mockRestore(): void } | undefined; + + beforeEach(async () => { + const selection = await import("../services/agent-runner-selection.js"); + runnerSelectionSpy = vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) => + selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "linux", architecture: "x64" } })); + }); afterEach(async () => { + runnerSelectionSpy?.mockRestore(); await ctx.db.delete(activityLog); await ctx.db.delete(companyOnboardingSeeds); await ctx.db.delete(issues); @@ -143,6 +151,25 @@ describeEmbeddedPostgres("POST /api/companies/:companyId/onboarding-seed", () => } }); + it("uses the creation default for server-seeded Codex without changing the Claude fallback", async () => { + const previous = process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE; + process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE = "codex_local"; + try { + const { companyId, app } = await seedCompany(); + const response = await post(app, companyId, SEED); + expect(response.status).toBe(200); + const [agent] = await ctx.db.select().from(agents).where(eq(agents.companyId, companyId)); + expect(agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } }); + expect(agent?.adapterConfig).toMatchObject({ paperclipSkillSync: { desiredSkills: expect.arrayContaining(["paperclipai/paperclip/paperclip-create-agent"]) } }); + expect((await post(app, companyId, SEED)).body.changed).toBe(false); + const [replayed] = await ctx.db.select().from(agents).where(eq(agents.companyId, companyId)); + expect(replayed?.adapterConfig).toEqual(agent?.adapterConfig); + } finally { + if (previous === undefined) delete process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE; + else process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE = previous; + } + }); + it("is idempotent per revision — a replay creates no second agent or task", async () => { const { companyId, app } = await seedCompany(); diff --git a/server/src/__tests__/plugin-managed-agents.test.ts b/server/src/__tests__/plugin-managed-agents.test.ts index a1661fef5e4..e07ba0e1fc3 100644 --- a/server/src/__tests__/plugin-managed-agents.test.ts +++ b/server/src/__tests__/plugin-managed-agents.test.ts @@ -4,7 +4,7 @@ import { promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; import { eq } from "drizzle-orm"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { activityLog, agentConfigRevisions, @@ -96,13 +96,24 @@ if (!embeddedPostgresSupport.supported) { describeEmbeddedPostgres("plugin-managed agents", () => { let db!: ReturnType; let tempDb: Awaited> | null = null; + let runnerSelectionSpy: { mockRestore(): void } | undefined; + const runnerTarget = { driver: "local", platform: "linux", architecture: "x64" }; beforeAll(async () => { tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-agents-"); db = createDb(tempDb.connectionString); }, 20_000); + beforeEach(async () => { + runnerTarget.platform = "linux"; + runnerTarget.architecture = "x64"; + const selection = await import("../services/agent-runner-selection.js"); + runnerSelectionSpy = vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) => + selection.resolveNewAgentRunner({ ...input, target: runnerTarget })); + }); + afterEach(async () => { + runnerSelectionSpy?.mockRestore(); await db.delete(agentConfigRevisions); await db.delete(activityLog); await db.delete(pluginEntities); @@ -272,7 +283,9 @@ describeEmbeddedPostgres("plugin-managed agents", () => { }); }); - it("creates managed agents with the most-used compatible company adapter", async () => { + it.each([{ platform: "linux", architecture: "x64" }, { platform: "linux", architecture: "arm64" }, { platform: "darwin", architecture: "arm64" }, { platform: "darwin", architecture: "x64" }])("creates managed agents with the most-used compatible company adapter ($platform/$architecture)", async ({ platform, architecture }) => { + runnerTarget.platform = platform; + runnerTarget.architecture = architecture; const pluginManifest = manifest(); pluginManifest.agents![0] = { ...pluginManifest.agents![0]!, @@ -288,8 +301,8 @@ describeEmbeddedPostgres("plugin-managed agents", () => { name: "Codex One", role: "engineer", status: "idle", - adapterType: "codex_local", - adapterConfig: {}, + adapterType: "paperclip_runner", + adapterConfig: { provider: "codex", model: "gpt-5.4" }, runtimeConfig: {}, permissions: {}, }, @@ -320,7 +333,13 @@ describeEmbeddedPostgres("plugin-managed agents", () => { const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" }); expect(created.status).toBe("created"); - expect(created.agent?.adapterType).toBe("codex_local"); + expect(created.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } }); + const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" }); + expect(reconciled.agent?.adapterConfig).toEqual(created.agent?.adapterConfig); + runnerTarget.platform = "win32"; + runnerTarget.architecture = "x64"; + const reset = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" }); + expect(reset.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } }); }); it("materializes declared managed agent instructions with local folder paths", async () => { diff --git a/server/src/__tests__/recovery-stale-issue-lock-sweep.test.ts b/server/src/__tests__/recovery-stale-issue-lock-sweep.test.ts index c7e6a07f183..04f6b69165f 100644 --- a/server/src/__tests__/recovery-stale-issue-lock-sweep.test.ts +++ b/server/src/__tests__/recovery-stale-issue-lock-sweep.test.ts @@ -249,6 +249,95 @@ describeEmbeddedPostgres("recovery sweepStaleIssueLocks", () => { ).resolves.toBe("running"); }); + async function seedCompletedOnboardingReport(options: { + runtimeMode?: "native" | "legacy"; + variant?: "ordinary" | "cancelled" | "wrong_child" | "wrong_reason" | "wrong_agent" | "unfinished_child" | "foreign_child"; + } = {}) { + const { companyId, agentId, runningRunId } = await seed(); + const issueId = randomUUID(); + const childIssueId = randomUUID(); + const variant = options.variant; + let childCompanyId = companyId; + if (variant === "foreign_child") { + childCompanyId = randomUUID(); + await db.insert(companies).values({ + id: childCompanyId, + name: "Other company", + issuePrefix: `T${childCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + }); + } + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Completed onboarding awaiting its child result", + originKind: variant === "ordinary" ? "manual" : "onboarding_first_task", + status: variant === "cancelled" ? "cancelled" : "done", + assigneeAgentId: variant === "wrong_agent" ? null : agentId, + checkoutRunId: runningRunId, + executionRunId: runningRunId, + executionLockedAt: new Date(), + }); + await db.insert(issues).values({ + id: childIssueId, + companyId: childCompanyId, + title: "Accepted child output", + status: variant === "unfinished_child" ? "in_progress" : "done", + parentId: variant === "foreign_child" ? null : issueId, + }); + await db.update(heartbeatRuns).set({ + runtimeMode: options.runtimeMode ?? "native", + nativeIssueId: options.runtimeMode === "legacy" ? null : issueId, + processPid: 2_000_000_000, + contextSnapshot: { + issueId, + wakeReason: variant === "wrong_reason" ? "issue_commented" : "issue_children_completed", + completedChildIssueId: variant === "wrong_child" ? randomUUID() : childIssueId, + onboardingCompletion: true, + }, + }).where(eq(heartbeatRuns.id, runningRunId)); + return { companyId, agentId, issueId, childIssueId, runningRunId }; + } + + it.each(["native", "legacy"] as const)("preserves a live %s completed-onboarding reporting turn during delayed startup", async runtimeMode => { + const { issueId, runningRunId } = await seedCompletedOnboardingReport({ runtimeMode }); + const result = await recoveryService(db, { + enqueueWakeup: vi.fn(), + liveRunExecutions: new Set([runningRunId]), + }).sweepStaleIssueLocks(); + + expect(result).toEqual({ cleared: 0, issueIds: [], terminalizedRunIds: [] }); + expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runningRunId)))[0]?.status).toBe("running"); + expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]).toMatchObject({ + status: "done", checkoutRunId: runningRunId, executionRunId: runningRunId, + }); + expect(mockTelemetryClient.track).not.toHaveBeenCalled(); + }); + + it.each(["ordinary", "cancelled", "wrong_child", "wrong_reason", "wrong_agent", "unfinished_child", "foreign_child"] as const)("does not exempt an invalid completed-onboarding reporting turn (%s)", async variant => { + const { issueId, runningRunId } = await seedCompletedOnboardingReport({ variant }); + const result = await recoveryService(db, { + enqueueWakeup: vi.fn(), + liveRunExecutions: new Set([runningRunId]), + }).sweepStaleIssueLocks(); + + expect(result.terminalizedRunIds).toEqual([runningRunId]); + expect(result.cleared).toBe(1); + expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runningRunId)))[0]?.status).toBe(variant === "cancelled" ? "cancelled" : "succeeded"); + expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]).toMatchObject({ checkoutRunId: null, executionRunId: null }); + }); + + it("still interrupts a completed-onboarding reporting turn after its process and execution owner are gone", async () => { + const { issueId, runningRunId } = await seedCompletedOnboardingReport(); + const result = await recoveryService(db, { enqueueWakeup: vi.fn() }).sweepStaleIssueLocks(); + + expect(result.terminalizedRunIds).toEqual([runningRunId]); + expect(result.cleared).toBe(1); + expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runningRunId)))[0]).toMatchObject({ + status: "interrupted", errorCode: "orphaned_running_run", + }); + expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]).toMatchObject({ checkoutRunId: null, executionRunId: null }); + }); + it("does not clear when checkoutRunId is terminal but executionRunId is still running", async () => { const { companyId, agentId, failedRunId, runningRunId } = await seed(); const issueId = randomUUID(); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 1754f81e3df..5789821b052 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -488,9 +488,11 @@ const paperclipRunnerAdapter: ServerAdapterModule = { }], }; } - const result = profile.provider === "opencode" - ? await openCodeTestEnvironment(context) - : await codexTestEnvironment(context); + if (profile.provider === "codex") { + const { testNativeRunnerAuthentication } = await import("../services/native-runtime/setup-readiness.js"); + return testNativeRunnerAuthentication(context, "codex", profile.model); + } + const result = await openCodeTestEnvironment(context); return { ...result, adapterType: "paperclip_runner" }; }, listSkills: listCodexSkills, @@ -1182,6 +1184,12 @@ export function isOverridePaused(type: string): boolean { return pausedOverrides.has(type); } +export function hasActiveAdapterOverride(type: string): boolean { + const registered = adaptersByType.get(type); + return builtinFallbacks.has(type) && registered !== undefined + && registered !== builtinFallbacks.get(type) && !pausedOverrides.has(type); +} + /** Get the set of types whose overrides are currently paused. */ export function getPausedOverrides(): Set { return pausedOverrides; diff --git a/server/src/app.ts b/server/src/app.ts index 1633fead8ca..ac90c205575 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -86,6 +86,7 @@ import { summarySlotRoutes } from "./routes/summary-slots.js"; import { statusCardRoutes } from "./routes/status-cards.js"; import { teamsCatalogRoutes } from "./routes/teams-catalog.js"; import { agentRoutes } from "./routes/agents.js"; +import { resolveAgentRunnerTargetForCompany } from "./services/agent-runner-selection.js"; import type { SetupTokenSessionService } from "./services/setup-token-session.js"; import { buildSetupTokenLoginTransport, @@ -1043,6 +1044,7 @@ export async function createApp( ); api.use( adapterRoutes({ + resolveRunnerTarget: (companyId, environmentId) => resolveAgentRunnerTargetForCompany(db, companyId, environmentId), getNativeRunnerEnabled: async () => (await instanceSettingsService(db).getExperimental()) .enableNativeRunner === true, diff --git a/server/src/dev-native-runner-status.ts b/server/src/dev-native-runner-status.ts index d0e99be5a56..0e5fb8b408f 100644 --- a/server/src/dev-native-runner-status.ts +++ b/server/src/dev-native-runner-status.ts @@ -6,6 +6,7 @@ import { nativeRunFinalizations, } from "@paperclipai/db"; import { resolveMigrationConnection } from "@paperclipai/db/migration-runtime"; +import { localPaperclipRunnerSupported } from "./lib/agent-runner-platform.js"; import { instanceSettingsService } from "./services/instance-settings.js"; @@ -48,8 +49,10 @@ async function main(): Promise { console.log( JSON.stringify({ - nativeRunnerRequired: - experimental.enableNativeRunner === true || experimental.enableOpenAiDot === true || persistedNativeRun, + nativeRunnerRequired: localPaperclipRunnerSupported() + || experimental.enableNativeRunner === true + || experimental.enableOpenAiDot === true + || persistedNativeRun, rolloutEnabled: experimental.enableNativeRunner === true, dotRolloutEnabled: experimental.enableOpenAiDot === true, persistedNativeRun, diff --git a/server/src/lib/agent-records.ts b/server/src/lib/agent-records.ts index da1702f5955..354d4268f8e 100644 --- a/server/src/lib/agent-records.ts +++ b/server/src/lib/agent-records.ts @@ -4,7 +4,7 @@ import { agents, costEvents, type Db } from "@paperclipai/db"; import { agentAppearanceSchema, resolveAgentAppearance, agentAvatarUrl, AGENT_DEFAULT_MAX_CONCURRENT_RUNS, agentRuntimeConfigSchema, getAgentWorkEligibility, - normalizeAgentUrlKey, type AgentEligibilityAgent, + normalizeAgentUrlKey, type AgentEligibilityAgent, type AgentRunnerChoice, } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js"; @@ -80,13 +80,14 @@ export interface UpdateAgentOptions { claudeLogin?: ClaudeLoginContext; } -export type CreateAgentData = Omit; +export type CreateAgentData = Omit & { runner?: AgentRunnerChoice }; export type AgentConfigurationRecord = typeof agents.$inferSelect; export const AGENT_CONFIGURATION_FIELDS = [...CONFIG_REVISION_FIELDS, "permissions"] as const; export type AgentConfigurationPatch = Partial>; export type AgentHireRecord = Pick; export interface CreateAgentOptions { + runnerResolved?: boolean; responsibleUserId?: string | null; createdByUserId?: string | null; aiConnectionInstall?: { connectionId: string; memberConnectionIds?: string[]; createdByUserId: string | null }; diff --git a/server/src/lib/agent-runner-platform.ts b/server/src/lib/agent-runner-platform.ts new file mode 100644 index 00000000000..1e67b12e4c8 --- /dev/null +++ b/server/src/lib/agent-runner-platform.ts @@ -0,0 +1,20 @@ +import { paperclipRunnerSupportsPlatform } from "@paperclipai/shared"; + +export function localAgentRunnerTarget(): { driver: "local"; platform: NodeJS.Platform; architecture: string; libc?: "glibc" | "unknown"; glibcVersion?: string } { + if (process.platform !== "linux") return { driver: "local", platform: process.platform, architecture: process.arch }; + let libc: "glibc" | "unknown" = "unknown"; + let glibcVersion: string | undefined; + try { + const { header } = process.report.getReport() as { header?: { glibcVersionRuntime?: unknown } }; + if (typeof header?.glibcVersionRuntime === "string" && header.glibcVersionRuntime.trim()) { + libc = "glibc"; + glibcVersion = header.glibcVersionRuntime.trim(); + } + } catch { /* An unidentified libc must not select a GNU/Linux binary. */ } + return { driver: "local", platform: process.platform, architecture: process.arch, libc, ...(glibcVersion === undefined ? {} : { glibcVersion }) }; +} + +export function localPaperclipRunnerSupported(): boolean { + const target = localAgentRunnerTarget(); + return paperclipRunnerSupportsPlatform("codex_local", target.platform, target.architecture, target.libc, target.glibcVersion); +} diff --git a/server/src/modules/agent-lifecycle/adapters/approvals.ts b/server/src/modules/agent-lifecycle/adapters/approvals.ts index e1c21fe195a..f652d448ca8 100644 --- a/server/src/modules/agent-lifecycle/adapters/approvals.ts +++ b/server/src/modules/agent-lifecycle/adapters/approvals.ts @@ -73,7 +73,7 @@ export function hireApprovalService(db: Db, effects: LifecycleEffects) { spentMonthlyCents: 0, permissions: undefined, lastHeartbeatAt: null, - }, { createdByUserId: approval!.requestedByAgentId ? null : approval!.requestedByUserId, responsibleUserId: approval!.requestedByUserId }); + }, { runnerResolved: true, createdByUserId: approval!.requestedByAgentId ? null : approval!.requestedByUserId, responsibleUserId: approval!.requestedByUserId }); agentId = created.id; } if (approval && agentId && typeof payload.budgetMonthlyCents === "number" && payload.budgetMonthlyCents > 0) { diff --git a/server/src/onboarding-assets/first-task/skills/first-task/SKILL.md b/server/src/onboarding-assets/first-task/skills/first-task/SKILL.md index 4a02ca1f8aa..b69019bf93b 100644 --- a/server/src/onboarding-assets/first-task/skills/first-task/SKILL.md +++ b/server/src/onboarding-assets/first-task/skills/first-task/SKILL.md @@ -48,5 +48,8 @@ Work in this order. 4. Carry out the accepted scope. - For a plan-only request, retain the accepted plan on this task. Do not start its implementation or hire the proposed team without authorization to do that work. - - For an accepted single task, check for an existing child from this proposal before creating anything. Create exactly one child linked to this onboarding task, assign it to yourself, and execute it. On later wakes, continue that same child instead of creating another. - - Save the finished output as a document on the child task and mark that child done. Link it from the onboarding conversation. Completing the onboarding parent in place, or saving the output only on the parent, does not fulfill the accepted child-task proposal. + - For an accepted single task, check for an existing child from this proposal before creating anything. Create exactly one child linked to this onboarding task and assign it to yourself. On later wakes, continue that same child instead of creating another. + - If the accepted child work requires a file deliverable, include the approved scope, document requirements, and requested filenames in a self-contained child description. Delegate the work to the child's own Paperclip execution, even when the child is assigned to yourself. That execution must save the document and publish the requested file on the child before marking it done. Do not create or register that file from the onboarding run, or mark the child done before its execution delivers the file. Native `register_deliverable` is bound to its current task. + - While waiting for that file-producing child, preserve existing blocker IDs and add the child as a dependency. Native runners use `set_dependencies`, then `paperclip_block` with the child agent as owner and child completion as the unblock action; legacy runners use the normal issue API with `blockedByIssueIds` and a blocked disposition. End the turn so the child can execute. Do not poll for its result while holding the onboarding run. + - When child completion resumes the onboarding task, verify the saved child document and retrievable file, then reply with the actual child task and download links. Reuse the recorded result instead of producing another file on the parent or creating another child. + - For document-only child work, you may execute it directly. Save the finished output as a document on the child task and mark that child done. Link it from the onboarding conversation. Completing the onboarding parent in place, or saving the output only on the parent, does not fulfill the accepted child-task proposal. diff --git a/server/src/realtime/runner-prp-outbound.test.ts b/server/src/realtime/runner-prp-outbound.test.ts index 4c814c59c50..e0bf46c6395 100644 --- a/server/src/realtime/runner-prp-outbound.test.ts +++ b/server/src/realtime/runner-prp-outbound.test.ts @@ -8,15 +8,46 @@ import { WsJsonWireConnection, } from "./runner-prp-outbound.js"; +const sockets = vi.hoisted(() => ({ create: null as (() => unknown) | null })); + +vi.mock("ws", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: Object.assign(vi.fn(function () { return sockets.create!(); }), { + CONNECTING: actual.default.CONNECTING, + OPEN: actual.default.OPEN, + CLOSING: actual.default.CLOSING, + CLOSED: actual.default.CLOSED, + }), + }; +}); + class FakeSocket extends EventEmitter { readyState: number = WebSocket.OPEN; send = vi.fn(); + terminate = vi.fn(() => { + this.readyState = WebSocket.CLOSED; + this.emit("close", 1006, Buffer.alloc(0)); + }); close = vi.fn((code?: number) => { this.readyState = WebSocket.CLOSED; this.emit("close", code ?? 1000, Buffer.alloc(0)); }); } +function openingSocket(socket = new FakeSocket()): FakeSocket { + socket.readyState = WebSocket.CONNECTING; + queueMicrotask(() => { + if (socket.readyState !== WebSocket.CONNECTING) return; + socket.readyState = WebSocket.OPEN; + socket.emit("open"); + }); + return socket; +} + +sockets.create = () => openingSocket(); + describe("runner provider-ingress WebSocket wire", () => { it("bounds credential refresh by the fixed deadline and cancellation", async () => { const signal = new AbortController().signal; @@ -140,6 +171,200 @@ describe("runner provider-ingress WebSocket wire", () => { } }); + it.each(["delayed_ack", "no_ack", "unconfirmed_termination", "error_before_close"] as const)("retires authenticated %s ingress within the native setup close budget", async (mode) => { + vi.useFakeTimers(); + const socket = new FakeSocket(); + let activeConnections = 0; + const closeFrameOwners: number[] = []; + socket.close = vi.fn(() => { + closeFrameOwners.push(activeConnections); + socket.readyState = WebSocket.CLOSING; + if (mode === "delayed_ack") { + setTimeout(() => { + socket.readyState = WebSocket.CLOSED; + socket.emit("close", 1001, Buffer.alloc(0)); + }, 250); + } + }); + const finishClose = () => { + socket.readyState = WebSocket.CLOSED; + socket.emit("close", 1006, Buffer.alloc(0)); + }; + const terminate = vi.fn(() => { + if (mode === "error_before_close") socket.emit("error", new Error("fixture termination pending")); + else if (mode !== "unconfirmed_termination") finishClose(); + }); + Object.assign(socket, { terminate }); + const create = sockets.create; + sockets.create = () => openingSocket(socket); + const endpoint = { + kind: "authenticated_websocket" as const, + websocketUrl: "wss://preview.invalid/api/runner/v1/connect/run-authenticated", + secretHeaders: [{ name: "Authorization", value: "fixture-only" }], + generation: "generation-authenticated", + refresh: vi.fn(async () => endpoint), + close: vi.fn(async () => { expect(activeConnections).toBe(0); }), + }; + const attachWireConnection = vi.fn((wire: WsJsonWireConnection) => { + expect(activeConnections).toBe(0); + activeConnections += 1; + wire.onClose(() => { activeConnections -= 1; }); + return { isAuthenticated: () => true }; + }); + const handle = connectRunnerPrpIngress({ + authority: { attachWireConnection, activeRunnerConnectionCount: () => activeConnections } as unknown as DurablePrpControlPlane, + endpoint, + startupDeadlineMs: 60_000, + recoveryGraceMs: 60_000, + }); + const failureObserver = vi.fn(); + void handle.failure.catch(failureObserver); + let closing: Promise | undefined; + let deadline: ReturnType | undefined; + try { + await vi.advanceTimersByTimeAsync(0); + await handle.ready; + expect(activeConnections).toBe(1); + closing = handle.close(); + expect(closeFrameOwners).toEqual([1]); + if (socket.readyState !== WebSocket.CLOSED) { + expect(activeConnections).toBe(mode === "error_before_close" ? 0 : 1); + expect(endpoint.close).not.toHaveBeenCalled(); + } + const outcome = Promise.race([ + closing.then(() => "closed"), + new Promise((resolve) => { deadline = setTimeout(() => resolve("setup_cleanup_deadline"), 10_000); }), + ]); + await vi.advanceTimersByTimeAsync(10_000); + expect(terminate).toHaveBeenCalledOnce(); + expect({ outcome: await outcome, activeConnections, endpointReleased: endpoint.close.mock.calls.length }).toEqual(["unconfirmed_termination", "error_before_close"].includes(mode) + ? { outcome: "setup_cleanup_deadline", activeConnections: mode === "error_before_close" ? 0 : 1, endpointReleased: 0 } + : { outcome: "closed", activeConnections: 0, endpointReleased: 1 }); + expect(attachWireConnection).toHaveBeenCalledOnce(); + expect(endpoint.refresh).not.toHaveBeenCalled(); + expect(failureObserver).not.toHaveBeenCalled(); + if (["unconfirmed_termination", "error_before_close"].includes(mode)) return; + sockets.create = () => openingSocket(); + const successor = connectRunnerPrpIngress({ + authority: { attachWireConnection, activeRunnerConnectionCount: () => activeConnections } as unknown as DurablePrpControlPlane, + endpoint, + }); + await vi.advanceTimersByTimeAsync(0); + await successor.ready; + expect(activeConnections).toBe(1); + await successor.close(); + expect(activeConnections).toBe(0); + expect(attachWireConnection).toHaveBeenCalledTimes(2); + } finally { + if (deadline) clearTimeout(deadline); + finishClose(); + await (closing ?? handle.close()); + sockets.create = create; + vi.useRealTimers(); + } + }); + + it("retains an errored prior socket until physical close during reconnect shutdown", async () => { + const prior = new FakeSocket(); + prior.close = vi.fn(() => { prior.readyState = WebSocket.CLOSING; }); + const pending = new FakeSocket(); + const create = sockets.create; + let activeConnections = 0; + let opens = 0; + sockets.create = () => { + opens += 1; + if (opens === 1) return openingSocket(prior); + pending.readyState = WebSocket.CONNECTING; + return pending; + }; + const endpointCloseStates: number[][] = []; + const endpoint = { + kind: "authenticated_websocket" as const, + websocketUrl: "wss://preview.invalid/api/runner/v1/connect/run-reconnecting", + secretHeaders: [], + generation: "generation-reconnecting", + refresh: vi.fn(async () => endpoint), + close: vi.fn(async () => { endpointCloseStates.push([prior.readyState, pending.readyState]); }), + }; + const attachWireConnection = vi.fn((wire: WsJsonWireConnection) => { + activeConnections += 1; + wire.onClose(() => { activeConnections -= 1; }); + return { isAuthenticated: () => true }; + }); + const handle = connectRunnerPrpIngress({ + authority: { attachWireConnection, activeRunnerConnectionCount: () => activeConnections } as unknown as DurablePrpControlPlane, + endpoint, + }); + const failureObserver = vi.fn(); + void handle.failure.catch(failureObserver); + let closing: Promise | undefined; + try { + await handle.ready; + prior.readyState = WebSocket.CLOSING; + prior.emit("error", new Error("fixture prior socket error")); + await new Promise(resolve => setImmediate(resolve)); + expect(opens).toBe(2); + expect(activeConnections).toBe(0); + expect(prior.readyState).toBe(WebSocket.CLOSING); + closing = handle.close(); + await closing; + expect({ priorTerminations: prior.terminate.mock.calls.length, endpointCloseStates: endpointCloseStates.map(states => [...states]) }).toEqual({ + priorTerminations: 1, endpointCloseStates: [[WebSocket.CLOSED, WebSocket.CLOSED]], + }); + expect(attachWireConnection).toHaveBeenCalledOnce(); + expect(failureObserver).not.toHaveBeenCalled(); + expect(endpoint.refresh).not.toHaveBeenCalled(); + } finally { + prior.terminate(); + pending.terminate(); + await (closing ?? handle.close()); + sockets.create = create; + } + }); + + it("joins a just-opened socket without attaching authority after intentional close", async () => { + const socket = new FakeSocket(); + const create = sockets.create; + let closing: Promise | undefined; + sockets.create = () => { + socket.readyState = WebSocket.CONNECTING; + queueMicrotask(() => { + socket.readyState = WebSocket.OPEN; + socket.emit("open"); + closing = handle.close(); + }); + return socket; + }; + const endpoint = { + kind: "authenticated_websocket" as const, + websocketUrl: "wss://preview.invalid/api/runner/v1/connect/run-opening", + secretHeaders: [], + generation: "generation-opening", + refresh: vi.fn(async () => endpoint), + close: vi.fn(async () => { expect(socket.readyState).toBe(WebSocket.CLOSED); }), + }; + const attachWireConnection = vi.fn(() => ({ isAuthenticated: () => true })); + const handle = connectRunnerPrpIngress({ + authority: { attachWireConnection, activeRunnerConnectionCount: () => 0 } as unknown as DurablePrpControlPlane, + endpoint, + }); + const failureObserver = vi.fn(); + void handle.failure.catch(failureObserver); + try { + await expect(handle.ready).rejects.toThrow("closed before it became ready"); + await closing; + expect(socket.close).toHaveBeenCalledExactlyOnceWith(1001); + expect(socket.terminate).toHaveBeenCalledOnce(); + expect(attachWireConnection).not.toHaveBeenCalled(); + expect(endpoint.close).toHaveBeenCalledOnce(); + expect(endpoint.refresh).not.toHaveBeenCalled(); + expect(failureObserver).not.toHaveBeenCalled(); + } finally { + await (closing ?? handle.close()); + sockets.create = create; + } + }); + it("does not report intentional close as an active-run ingress failure", async () => { const endpoint = { kind: "authenticated_websocket" as const, diff --git a/server/src/realtime/runner-prp-outbound.ts b/server/src/realtime/runner-prp-outbound.ts index 6146b8b7fce..a548a5e58b8 100644 --- a/server/src/realtime/runner-prp-outbound.ts +++ b/server/src/realtime/runner-prp-outbound.ts @@ -167,9 +167,19 @@ async function awaitWithinDeadline(input: { } } +async function closeOwnedWire(wire: WsJsonWireConnection): Promise { + const closed = wire.socket.readyState === WebSocket.CLOSED + ? Promise.resolve() + : new Promise((resolve) => wire.socket.once("close", () => resolve())); + wire.close(1001); + wire.socket.terminate(); + await closed; +} + async function openEndpoint(input: { endpoint: RunnerIngressEndpoint; signal: AbortSignal; + onWire: (wire: WsJsonWireConnection) => void; }): Promise<{ wire: WsJsonWireConnection; statusCode: number | null }> { validateEndpoint(input.endpoint); return await new Promise((resolve, reject) => { @@ -211,6 +221,7 @@ async function openEndpoint(input: { input.signal.removeEventListener("abort", onAbort); reject(Object.assign(error, { statusCode })); }); + input.onWire(wire); }); } @@ -258,7 +269,7 @@ export function connectRunnerPrpIngress(input: { ) => void; }): RunnerPrpOutboundHandle { const abort = new AbortController(); - let activeWire: WsJsonWireConnection | null = null; + const ownedWires = new Set(); let endpoint = input.endpoint; const startupDeadline = Date.now() + (input.startupDeadlineMs ?? DEFAULT_STARTUP_DEADLINE_MS); @@ -288,8 +299,16 @@ export function connectRunnerPrpIngress(input: { while (!abort.signal.aborted) { try { input.onStateChange?.(attempt === 0 ? "connecting" : "reconnecting"); - const opened = await openEndpoint({ endpoint, signal: abort.signal }); - activeWire = opened.wire; + if (abort.signal.aborted) return; + const opened = await openEndpoint({ + endpoint, + signal: abort.signal, + onWire: (wire) => { + ownedWires.add(wire); + wire.socket.once("close", () => ownedWires.delete(wire)); + }, + }); + if (abort.signal.aborted) return; const attachment = input.authority.attachWireConnection(opened.wire); const authenticated = await waitForPrpReady({ isAuthenticated: attachment.isAuthenticated, @@ -311,10 +330,8 @@ export function connectRunnerPrpIngress(input: { input.onStateChange?.("authenticated"); attempt = 0; await socketClosed(opened.wire); - activeWire = null; recoveryDeadline = Date.now() + recoveryGraceMs; } catch (error) { - activeWire = null; // close() owns intentional shutdown. openEndpoint rejects its pending // handshake when the abort signal fires, but that cancellation is not // an active-run transport failure and must not reject the long-lived @@ -380,7 +397,7 @@ export function connectRunnerPrpIngress(input: { failure, async close() { abort.abort(); - activeWire?.close(1001); + await Promise.all([...ownedWires].map(closeOwnedWire)); await loop; await endpoint.close(); input.onStateChange?.("closed"); diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index eb20f325e4a..43020eff513 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -29,6 +29,7 @@ import { unregisterServerAdapter, isOverridePaused, setOverridePaused, + waitForExternalAdapters, } from "../adapters/registry.js"; import { listAdapterPlugins, @@ -47,11 +48,13 @@ import type { } from "@paperclipai/adapter-utils"; import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js"; import { logger } from "../middleware/logger.js"; -import { forbidden } from "../errors.js"; +import { badRequest, forbidden } from "../errors.js"; import { isCloudManagedInstance } from "../services/cloud-instance.js"; import { getHiddenSettings } from "../services/settings-visibility.js"; -import { assertBoardOrgAccess, assertInstanceAdmin } from "./authz.js"; +import { assertBoardOrgAccess, assertCompanyAccess, assertInstanceAdmin } from "./authz.js"; import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js"; +import { agentRunnerAvailability, type RunnerTarget } from "../services/agent-runner-selection.js"; +import type { AgentRunnerAvailability } from "@paperclipai/shared"; const execFileAsync = promisify(execFile); @@ -123,7 +126,7 @@ interface AdapterCapabilities { login?: AdapterLoginProjection; } -interface AdapterInfo { +interface AdapterInfo extends AgentRunnerAvailability { type: string; label: string; source: "builtin" | "external"; @@ -193,7 +196,7 @@ export function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterC }; } -function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterPluginRecord | undefined, disabledSet: Set): AdapterInfo { +function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterPluginRecord | undefined, disabledSet: Set, target?: RunnerTarget): AdapterInfo { const fromDisk = externalRecord ? readAdapterPackageVersionFromDisk(externalRecord) : undefined; return { type: adapter.type, @@ -203,6 +206,7 @@ function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterP loaded: true, // If it's in the registry, it's loaded disabled: disabledSet.has(adapter.type), capabilities: buildAdapterCapabilities(adapter), + ...agentRunnerAvailability(adapter.type, target), ...(adapter.acp ? { acp: adapter.acp } : {}), overriddenBuiltin: externalRecord ? BUILTIN_ADAPTER_TYPES.has(adapter.type) : undefined, overridePaused: BUILTIN_ADAPTER_TYPES.has(adapter.type) ? isOverridePaused(adapter.type) : undefined, @@ -260,6 +264,7 @@ function registerWithSessionManagement(adapter: ServerAdapterModule): void { export function adapterRoutes(options: { getNativeRunnerEnabled?: () => Promise; getOpenAiDotEnabled?: () => Promise; + resolveRunnerTarget?: (companyId: string, environmentId?: string) => Promise; } = {}) { const router = Router(); @@ -276,19 +281,31 @@ export function adapterRoutes(options: { // instance-admin only because they affect the whole server runtime. assertBoardOrgAccess(_req); + const companyId = _req.query.companyId; + const environmentId = _req.query.environmentId; + if ((companyId !== undefined && (typeof companyId !== "string" || !companyId.trim())) + || (environmentId !== undefined && (typeof environmentId !== "string" || !environmentId.trim()))) { + throw badRequest("companyId and environmentId must be nonempty strings."); + } + if (environmentId !== undefined && companyId === undefined) throw badRequest("companyId is required when selecting an environment."); + let target: RunnerTarget | undefined; + if (typeof companyId === "string") { + assertCompanyAccess(_req, companyId); + if (!options.resolveRunnerTarget) throw badRequest("Scoped adapter discovery is unavailable."); + target = await options.resolveRunnerTarget(companyId, typeof environmentId === "string" ? environmentId : undefined); + } + await waitForExternalAdapters(); + const registeredAdapters = listServerAdapters(); const externalRecords = new Map( listAdapterPlugins().map((r) => [r.type, r]), ); const disabledSet = new Set(getDisabledAdapterTypes()); - const nativeRunnerEnabled = await options.getNativeRunnerEnabled?.().catch(() => false) ?? false; - const openAiDotEnabled = await options.getOpenAiDotEnabled?.().catch(() => false) ?? false; // One shared implementation, with independent provider rollouts. Explicit // adapter-admin disabling still applies to both choices. - if (!nativeRunnerEnabled && !openAiDotEnabled) disabledSet.add("paperclip_runner"); const result: AdapterInfo[] = registeredAdapters.map((adapter) => - buildAdapterInfo(adapter, externalRecords.get(adapter.type), disabledSet), + buildAdapterInfo(adapter, externalRecords.get(adapter.type), disabledSet, target), ).sort((a, b) => a.type.localeCompare(b.type)); res.json(result); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index b466ecc55e9..fc081b13607 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,4 +1,6 @@ import { cancellationRequestId } from "../services/native-runtime/native-cancellation-request.js"; +import { agentHarnessType, type AgentRunnerChoice } from "@paperclipai/shared"; +import { resolveNewAgentRunnerForCompany } from "../services/agent-runner-selection.js"; import { agentEnvironmentTestService } from "../services/agent-environment-test.js"; import { createAgentLifecycle } from "../services/agent-lifecycle.js"; @@ -1845,7 +1847,7 @@ export function agentRoutes( ); } 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 (experimental.enableNativeRunner !== true) { + } else if (!([undefined, "codex"].includes(asRecord(config)?.provider as string | undefined)) && experimental.enableNativeRunner !== true) { throw unprocessable( "Paperclip Runner is experimental and disabled on this instance.", { code: "paperclip_runner_rollout_disabled" }, @@ -2200,7 +2202,7 @@ export function agentRoutes( adapterType: string | null | undefined, adapterConfig: Record, ): Record { - if (adapterType !== "codex_local") return adapterConfig; + if (agentHarnessType(adapterType ?? "", adapterConfig) !== "codex_local") return adapterConfig; const existingEnv = asRecord(adapterConfig.env); if (!existingEnv) return adapterConfig; if (!codexLocalEnvKeyConfigured(existingEnv.OPENAI_API_KEY)) return adapterConfig; @@ -2251,11 +2253,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 credentialKeys = adapterType ? INHERITABLE_AGENT_CREDENTIAL_ENV_KEYS[adapterType] : undefined; + const harness = agentHarnessType(adapterType ?? "", adapterConfig) === "codex_local" ? "codex_local" : adapterType ?? ""; + 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 || parent.adapterType !== adapterType) return noInheritance; + if (!parent || parent.companyId !== companyId || (harness === "codex_local" ? agentHarnessType(parent.adapterType, parent.adapterConfig) !== harness : parent.adapterType !== adapterType)) 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); @@ -3069,7 +3072,9 @@ export function agentRoutes( try { if (!target.executionTarget && target.fallbackChecks.length > 0) throw unprocessable("The agent environment is not available for adoption"); await withManagedAiProbe(db, { companyId, agentId, responsibleUserId: userId, adapterType, binding, config, allowUninstalledPersonal: newAgent, allowUninstalledShared, allowLegacyValidation: true }, async managed => { - const result = await testManagedEnvironment(adapterType, { companyId, adapterType, config: managed.config, executionTarget: target.executionTarget, environmentName: target.environmentName }, binding, managed, agentId); + const managedAiCredentialHome = adapterType === "paperclip_runner" && asRecord(managed.config)?.provider === "codex" && managed.home + ? path.join(managed.home, "provider") : undefined; + const result = await testManagedEnvironment(adapterType, { companyId, adapterType, config: managed.config, managedAiCredentialHome, executionTarget: target.executionTarget, environmentName: target.environmentName }, binding, managed, agentId); if (result.status === "fail" || result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)) throw unprocessable("The selected AI connection failed validation in this agent’s environment. Run the agent test to see the failing checks.", { code: "ai_connection_validation_failed", checks: result.checks.filter(check => check.level === "error" || check.code === ADAPTER_AUTH_MISSING_CHECK_CODE).map(check => ({ code: check.code, level: check.level })), @@ -3086,11 +3091,9 @@ export function agentRoutes( validate(testAdapterEnvironmentSchema), async (req, res) => { const companyId = req.params.companyId as string; - const type = assertKnownAdapterType(req.params.type as string); + let type = assertKnownAdapterType(req.params.type as string); await assertCanCreateAgentsForCompany(req, companyId); - const adapter = requireServerAdapter(type); - const requestedBinding = req.body.aiConnection ? aiRuntimeConnectionBindingSchema.parse(req.body.aiConnection) : undefined; const aiBinding = requestedBinding?.mode === "router" ? undefined : requestedBinding; if (aiBinding && req.body.testCredentials && Object.keys(req.body.testCredentials).length) throw unprocessable("A managed connection test cannot override its credentials"); @@ -3105,11 +3108,6 @@ export function agentRoutes( await assertCanUpdateAgent(req, savedAgent); } const poolBinding = requestedBinding ?? (savedAgent?.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(savedAgent.runtimeConfig.aiConnection) : undefined); - if (poolBinding?.mode === "router") { - await validateManagedAgentBinding(req, companyId, savedAgentId ?? "", type, req.body.adapterConfig ?? {}, poolBinding, req.body.environmentId, false, !savedAgentId); - res.json({ adapterType: type, status: "warn", testedAt: new Date().toISOString(), checks: [{ code: "ai_connection_pool_task_test_required", level: "warn", message: "Pool configuration is available. Run a task to verify the selected account and harness; this preview does not allocate a task or contact a provider." }] }); - return; - } const requestedEnvironmentId = await resolveAdapterTestEnvironmentId( companyId, // Omission tests the saved selection. Explicit null tests a prospective @@ -3149,6 +3147,25 @@ export function agentRoutes( ? restoreRedactedAgentEnv(inputAdapterConfig, savedAgent.adapterConfig) : inputAdapterConfig; } + const retainsSavedExecution = savedAgent && req.body.runner === undefined + && savedAgent.adapterType === type + && agentHarnessType(type, adapterConfigForTest) === agentHarnessType(savedAgent.adapterType, savedAgent.adapterConfig); + const selection = retainsSavedExecution + ? { adapterType: type, adapterConfig: adapterConfigForTest } + : await resolveNewAgentRunnerForCompany(db, companyId, { + adapterType: type, adapterConfig: adapterConfigForTest, + runner: req.body.runner, + defaultEnvironmentId: requestedEnvironmentId, + }); + type = selection.adapterType; + adapterConfigForTest = selection.adapterConfig; + const adapter = requireServerAdapter(type); + if (!retainsSavedExecution) await assertSelectableAdapterType(type, adapterConfigForTest); + if (poolBinding?.mode === "router") { + await validateManagedAgentBinding(req, companyId, savedAgentId ?? "", type, adapterConfigForTest, poolBinding, requestedEnvironmentId, false, !savedAgentId); + res.json({ adapterType: type, status: "warn", testedAt: new Date().toISOString(), checks: [{ code: "ai_connection_pool_task_test_required", level: "warn", message: "Pool configuration is available. Run a task to verify the selected account and harness; this preview does not allocate a task or contact a provider." }] }); + return; + } if (type === "paperclip_runner" && inputAdapterConfig.provider === "openai_dot") { const dotEnabled = await dotRunnerBroker(db).enabled(); const binding = savedAgentId ? await dotRunnerBroker(db).bindingForAgent(companyId, savedAgentId) : null; @@ -3295,7 +3312,9 @@ export function agentRoutes( } } const result = aiBinding ? await withManagedAiProbe(db, { companyId, agentId: req.body.agentId ?? "", responsibleUserId: responsibleUserForAiRequest(req), adapterType: type, binding: aiBinding, config: effectiveAdapterConfig, allowUninstalledPersonal: !req.body.agentId, allowUninstalledShared: !req.body.agentId && await canInstallSharedAiConnectionForNewAgent(db, req, companyId, aiBinding) }, async managed => { - const tested = await testManagedEnvironment(type, { companyId, adapterType: type, config: managed.config, executionTarget, environmentName }, aiBinding, managed, savedAgentId ?? undefined); + const managedAiCredentialHome = type === "paperclip_runner" && asRecord(managed.config)?.provider === "codex" && managed.home + ? path.join(managed.home, "provider") : undefined; + const tested = await testManagedEnvironment(type, { companyId, adapterType: type, config: managed.config, managedAiCredentialHome, executionTarget, environmentName }, aiBinding, managed, savedAgentId ?? undefined); tested.checks.unshift({ code: "ai_connection_tested", level: "info", message: `Tested ${managed.accountName} — ${managed.accountOwnerUserId ? managed.accountOwnerUserId === responsibleUserForAiRequest(req) ? "your personal account" : "the owner’s account authorized for this agent" : "company-shared account"}. Responsible user: ${req.actor.type === "agent" ? responsibleUserForAiRequest(req) ?? "unavailable" : "the signed-in user"}.` }); return tested; }) : await adapter.testEnvironment({ companyId, adapterType: type, config: effectiveAdapterConfig, executionTarget, environmentName }); @@ -4209,9 +4228,18 @@ export function agentRoutes( onboardingFirstAgent: hireOnboardingFirstAgent, // This intent flag is consumed below and must never reach agent create. inheritRuntimeFrom, + runner: hireRunner, ...hireInput } = req.body; + const hiredAgentId = randomUUID(); + const requestedHireConfig = (hireInput.adapterConfig ?? {}) as Record; + assertProviderTraceSettingTransition(req, hireInput.runtimeConfig); + assertNoAgentAdapterConfigMutation(req, requestedHireConfig); + assertNoAgentLocalAdapterHostCommandMutation(req, hireInput.adapterType ?? "process", requestedHireConfig, await callerUsesAiConnectionPool(req, companyId)); + assertNoAgentProcessAdapterMutation(req, hireInput.adapterType ?? "process", Object.keys(requestedHireConfig).length > 0); + assertExternalInstructionsAdmin(req, { id: hiredAgentId, companyId, name: hireInput.name, adapterConfig: requestedHireConfig }); + if (inheritRuntimeFrom === "caller") { if (req.actor.type !== "agent" || !req.actor.agentId) { throw forbidden("Only an agent can inherit native runtime settings from the caller"); @@ -4234,6 +4262,8 @@ export function agentRoutes( hireInput.adapterConfig = inheritNativeRunnerAdapterConfig(caller.adapterConfig); hireInput.defaultEnvironmentId = caller.defaultEnvironmentId ?? null; } + if (inheritRuntimeFrom === "caller" && hireRunner !== undefined) throw unprocessable("inheritRuntimeFrom=caller cannot be combined with runner"); + Object.assign(hireInput, await resolveNewAgentRunnerForCompany(db, companyId, { ...hireInput, runner: hireRunner })); hireInput.adapterType = await assertSelectableAdapterType(hireInput.adapterType, hireInput.adapterConfig); const rawHireAdapterConfig = (hireInput.adapterConfig ?? {}) as Record; assertProviderTraceSettingTransition(req, hireInput.runtimeConfig); @@ -4249,7 +4279,6 @@ export function agentRoutes( assertNoAgentAdapterConfigMutation(req, rawHireAdapterConfig); assertNoAgentLocalAdapterHostCommandMutation(req, hireInput.adapterType, rawHireAdapterConfig, await callerUsesAiConnectionPool(req, companyId)); assertNoAgentProcessAdapterMutation(req, hireInput.adapterType, Object.keys(rawHireAdapterConfig).length > 0); - const hiredAgentId = randomUUID(); const authInheritance = await applyHiringAgentAuthInheritance( req, companyId, @@ -4364,7 +4393,8 @@ export function agentRoutes( }, { createdByUserId: req.actor.type === "board" ? req.actor.userId : null, - responsibleUserId: responsibleUserForAiRequest(req), + runnerResolved: true, + responsibleUserId: responsibleUserForAiRequest(req), aiConnectionInstall: managedHireConnection ? { ...managedHireConnection, createdByUserId: responsibleUserForAiRequest(req) } : undefined, claudeLogin: { storedSessionId: hireStoredSessionId ?? null, @@ -4546,8 +4576,17 @@ export function agentRoutes( // The onboarding marker is not an agent column. The server consumes it to // seed the chief-of-staff persona; it never reaches the insert values. onboardingFirstAgent: createOnboardingFirstAgent, + runner: createRunner, ...createInput } = req.body; + const agentId = randomUUID(); + const requestedCreateConfig = (createInput.adapterConfig ?? {}) as Record; + assertProviderTraceSettingTransition(req, createInput.runtimeConfig); + assertNoAgentAdapterConfigMutation(req, requestedCreateConfig); + assertNoAgentLocalAdapterHostCommandMutation(req, createInput.adapterType ?? "process", requestedCreateConfig, await callerUsesAiConnectionPool(req, companyId)); + assertNoAgentProcessAdapterMutation(req, createInput.adapterType ?? "process", Object.keys(requestedCreateConfig).length > 0); + assertExternalInstructionsAdmin(req, { id: agentId, companyId, name: createInput.name, adapterConfig: requestedCreateConfig }); + Object.assign(createInput, await resolveNewAgentRunnerForCompany(db, companyId, { ...createInput, runner: createRunner })); createInput.adapterType = await assertSelectableAdapterType(createInput.adapterType, createInput.adapterConfig); const rawCreateAdapterConfig = (createInput.adapterConfig ?? {}) as Record; assertProviderTraceSettingTransition(req, createInput.runtimeConfig); @@ -4563,7 +4602,6 @@ export function agentRoutes( assertNoAgentAdapterConfigMutation(req, rawCreateAdapterConfig); assertNoAgentLocalAdapterHostCommandMutation(req, createInput.adapterType, rawCreateAdapterConfig, await callerUsesAiConnectionPool(req, companyId)); assertNoAgentProcessAdapterMutation(req, createInput.adapterType, Object.keys(rawCreateAdapterConfig).length > 0); - const agentId = randomUUID(); const requestedAdapterConfig = applyCodexLocalKeyIsolation( companyId, agentId, @@ -4621,6 +4659,7 @@ export function agentRoutes( }, { createdByUserId: req.actor.type === "board" ? req.actor.userId : null, + runnerResolved: true, responsibleUserId: responsibleUserForAiRequest(req), aiConnectionInstall: managedConnection ? { ...managedConnection, createdByUserId: responsibleUserForAiRequest(req) } : undefined, claudeLogin: { @@ -5164,6 +5203,8 @@ export function agentRoutes( } const patchData = { ...(req.body as Record) }; + const runner = patchData.runner as AgentRunnerChoice | undefined; + delete patchData.runner; const requestedStatus = patchData.status; delete patchData.status; if (requestedStatus !== undefined && !["paused", "idle", "terminated"].includes(String(requestedStatus))) throw badRequest("Use an agent lifecycle command to change status"); @@ -5192,6 +5233,15 @@ export function agentRoutes( // it gets the selectable check; keeping the agent's current adapter (even // one since disabled) stays allowed, so a disabled harness does not make an // existing agent uneditable. + if (runner !== undefined) { + await assertCanUpdateAgent(req, existing); + const config = hasOwn(patchData, "adapterConfig") ? { ...(replaceAdapterConfig ? {} : existing.adapterConfig), ...asRecord(patchData.adapterConfig) } : existing.adapterConfig; + Object.assign(patchData, await resolveNewAgentRunnerForCompany(db, existing.companyId, { + adapterType: typeof patchData.adapterType === "string" ? patchData.adapterType : existing.adapterType, + adapterConfig: config, runner, + defaultEnvironmentId: hasOwn(patchData, "defaultEnvironmentId") ? patchData.defaultEnvironmentId as string | null : existing.defaultEnvironmentId, + })); + } const nextAdapterType = hasOwn(patchData, "adapterType") ? assertKnownAdapterType(patchData.adapterType as string | null | undefined) : existing.adapterType; diff --git a/server/src/services/agent-hiring.ts b/server/src/services/agent-hiring.ts index c35299352ee..4e0199a1ade 100644 --- a/server/src/services/agent-hiring.ts +++ b/server/src/services/agent-hiring.ts @@ -12,8 +12,17 @@ import { secretService, assertClaudeOAuthBindingInvariant } from "./secrets.js"; import { agentCredentialService } from "./agent-credentials.js"; import { agentIdentityService } from "./agent-identity.js"; import { initializePrimaryAgent } from "./primary-agent.js"; +import { resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js"; -export async function prepareAgentHire(db: Db, companyId: string, data: CreateAgentData, options?: CreateAgentOptions): Promise { +export async function prepareAgentHire(db: Db, companyId: string, input: CreateAgentData, options?: CreateAgentOptions): Promise { + const { runner, ...data } = input; + const selection = options?.runnerResolved + ? { adapterType: data.adapterType ?? "process", adapterConfig: isPlainRecord(data.adapterConfig) ? data.adapterConfig : {} } + : await resolveNewAgentRunnerForCompany(db, companyId, { + ...data, adapterConfig: isPlainRecord(data.adapterConfig) ? data.adapterConfig : {}, runner, + }); + data.adapterType = selection.adapterType; + data.adapterConfig = selection.adapterConfig; const { ensureManager, assertBuiltInAgentMetadataMutationAllowed } = agentRecordQueries(db); if (data.appearance?.customAvatarAssetId) throw unprocessable("Create the agent before uploading its avatar"); assertBuiltInAgentMetadataMutationAllowed(null, data.metadata, options); diff --git a/server/src/services/agent-runner-selection.test.ts b/server/src/services/agent-runner-selection.test.ts new file mode 100644 index 00000000000..48734f355e1 --- /dev/null +++ b/server/src/services/agent-runner-selection.test.ts @@ -0,0 +1,406 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION, createAgentSchema, updateAgentSchema, testAdapterEnvironmentSchema } from "@paperclipai/shared"; +const state = vi.hoisted(() => ({ disabled: [] as string[], overridden: new Set(), settings: {} as Record, getEnvironment: vi.fn(), bindings: vi.fn(), managedEnvironment: vi.fn(), kubernetesEnvironment: vi.fn(), ensureKubernetesEnvironment: vi.fn(), ssh: vi.fn(), resolveEnvironment: vi.fn(), sandboxQualification: vi.fn() })); +vi.mock("../adapters/registry.js", () => ({ findActiveServerAdapter: (type: string) => ({ type }), hasActiveAdapterOverride: (type: string) => state.overridden.has(type), waitForExternalAdapters: async () => {} })); +vi.mock("./adapter-plugin-store.js", () => ({ getDisabledAdapterTypes: () => state.disabled })); +vi.mock("./instance-settings.js", () => ({ instanceSettingsService: () => ({ get: async () => state.settings }) })); +vi.mock("./environments.js", () => ({ environmentService: () => ({ getById: state.getEnvironment, listBoundCompanyIds: state.bindings, findManagedSandboxEnvironment: state.managedEnvironment, findKubernetesEnvironment: state.kubernetesEnvironment, ensureKubernetesEnvironment: state.ensureKubernetesEnvironment }) })); +vi.mock("./environment-probe.js", () => ({ readSandboxPlatformQualification: state.sandboxQualification })); +vi.mock("./environment-config.js", () => ({ resolveEnvironmentDriverConfigForRuntime: state.resolveEnvironment })); +vi.mock("@paperclipai/adapter-utils/ssh", () => ({ runSshCommand: state.ssh })); +import { AGENT_RUNNER_SSH_PLATFORM_COMMAND, agentRunnerAvailability, resolveAgentRunnerTargetForCompany, resolveNewAgentRunner, resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js"; +import { buildCodexAcpConfig, resolveCodexExecutionEngine } from "@paperclipai/adapter-codex-local/server"; +import { secretService } from "./secrets.js"; +import { localAgentRunnerTarget } from "../lib/agent-runner-platform.js"; +const target = { driver: "local", platform: "linux", architecture: "x64" }; +const qualifiedTargets = [target, { driver: "local", platform: "linux", architecture: "arm64" }, { driver: "local", platform: "darwin", architecture: "arm64" }, { driver: "local", platform: "darwin", architecture: "x64" }]; +const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; +const originalArchitecture = Object.getOwnPropertyDescriptor(process, "arch")!; +afterEach(() => { vi.restoreAllMocks(); Object.defineProperty(process, "platform", originalPlatform); Object.defineProperty(process, "arch", originalArchitecture); }); + +describe("server-owned Codex runner selection", () => { + beforeEach(() => { state.disabled = []; state.overridden.clear(); state.settings = {}; state.getEnvironment.mockReset(); state.bindings.mockReset().mockResolvedValue([]); state.managedEnvironment.mockReset(); state.kubernetesEnvironment.mockReset().mockResolvedValue(null); state.ensureKubernetesEnvironment.mockReset(); state.ssh.mockReset().mockResolvedValue({ stdout: "Linux\nx86_64\n" }); state.sandboxQualification.mockReset().mockResolvedValue(null); state.resolveEnvironment.mockReset().mockResolvedValue({ driver: "ssh", config: {} }); }); + it.each(["x64", "arm64"])("keeps detected musl/unknown Linux %s legacy and preserves saved execution", architecture => { + for (const libc of ["musl", "unknown"] as const) { + const target = { driver: "local", platform: "linux", architecture, libc }; + expect(agentRunnerAvailability("codex_local", target)).toEqual({ supportedRunners: ["legacy"], defaultRunner: "legacy" }); + const input = { adapterType: "codex_local", adapterConfig: { extraArgs: ["--sandbox", "read-only"] }, target }; + expect(resolveNewAgentRunner(input)).toEqual({ adapterType: input.adapterType, adapterConfig: input.adapterConfig }); + expect(() => resolveNewAgentRunner({ ...input, runner: "paperclip" })).toThrow("unavailable"); + const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, target }; + expect(resolveNewAgentRunner(saved)).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig }); + expect(resolveNewAgentRunner({ ...saved, runner: "paperclip" })).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig }); + expect(agentRunnerAvailability("codex_local", { ...target, libc: "glibc" }).defaultRunner).toBe("paperclip"); + } + }); + it.each([[undefined, false], ["2.38", false], ["2.39", true], ["3.0", true], [" 2.39 ", true], ["malformed", false], ["", false]] as const)("uses the measured local Node libc version (%s) for creation", async (glibcVersionRuntime, native) => { + Object.defineProperty(process, "platform", { ...originalPlatform, value: "linux" }); + Object.defineProperty(process, "arch", { ...originalArchitecture, value: "arm64" }); + vi.spyOn(process.report, "getReport").mockReturnValue({ header: { glibcVersionRuntime }, environmentVariables: { PRIVATE_SENTINEL: "do-not-retain" } } as never); + const localTarget = localAgentRunnerTarget(); + expect(localTarget).toEqual({ driver: "local", platform: "linux", architecture: "arm64", libc: glibcVersionRuntime ? "glibc" : "unknown", ...(glibcVersionRuntime ? { glibcVersion: glibcVersionRuntime.trim() } : {}) }); + expect(JSON.stringify(localTarget)).not.toContain("do-not-retain"); + expect(agentRunnerAvailability("codex_local").defaultRunner).toBe(native ? "paperclip" : "legacy"); + const saved = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local" }); + expect(saved.adapterType).toBe(native ? "paperclip_runner" : "codex_local"); + }); + it.each([ + ["2.38", false], ["2.39", true], ["2.40", true], ["3.0", true], ["2.9", false], + ["glibc 2.39", true], [" 2.39 ", true], ["2.39.1", true], ["02.039", true], + ["2.39ubuntu1", false], ["unknown", false], ["", false], ["2", false], ["2.-39", false], + ["999999999999999999999999.0", false], + ])("qualifies the measured GNU/Linux daemon ABI for glibc %s (%s)", (glibcVersion, native) => { + expect(PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION).toBe("2.39"); + for (const architecture of ["x64", "arm64"]) { + const target = { driver: "local", platform: "linux", architecture, libc: "glibc" as const, glibcVersion: String(glibcVersion) }; + expect(paperclipRunnerSupportsPlatform("codex_local", target.platform, architecture, target.libc, target.glibcVersion)).toBe(native); + expect(agentRunnerAvailability("codex_local", target).defaultRunner).toBe(native ? "paperclip" : "legacy"); + expect(resolveNewAgentRunner({ adapterType: "codex_local", target }).adapterType).toBe(native ? "paperclip_runner" : "codex_local"); + if (!native) expect(() => resolveNewAgentRunner({ adapterType: "codex_local", runner: "paperclip", target })).toThrow("unavailable"); + const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, target, runner: "paperclip" as const }; + expect(resolveNewAgentRunner(saved)).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig }); + } + }); + it("keeps an unidentified local Linux libc legacy if Node's report is unavailable", () => { + Object.defineProperty(process, "platform", { ...originalPlatform, value: "linux" }); + vi.spyOn(process.report, "getReport").mockImplementation(() => { throw new Error("private-report-details"); }); + expect(localAgentRunnerTarget()).toMatchObject({ libc: "unknown" }); + expect(agentRunnerAvailability("codex_local").defaultRunner).toBe("legacy"); + }); + it.each(qualifiedTargets)("uses native Codex automatically with compatible configuration and is idempotent on $platform/$architecture", target => { + const input = { adapterType: "codex_local", target, adapterConfig: { model: "gpt-5.6-sol", modelReasoningEffort: "high", env: { OPENAI_API_KEY: { type: "secret_ref", secretId: "secret" } }, cwd: "/workspace", instructionsFilePath: "/workspace/AGENTS.md", workspaceStrategy: { type: "git_worktree" }, timeoutSec: 60, search: false, fastMode: false, dangerouslyBypassApprovalsAndSandbox: true } }; + const resolved = resolveNewAgentRunner(input); + expect(resolved).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", codexPermissionMode: "never", modelReasoningEffort: "high", env: input.adapterConfig.env, workspaceStrategy: input.adapterConfig.workspaceStrategy, timeoutSec: 60 } }); + expect(resolved.adapterConfig).not.toHaveProperty("search"); + expect(resolveNewAgentRunner(resolved)).toEqual(resolved); + expect(input.adapterConfig).toHaveProperty("search", false); + }); + it.each(["claude_local", "opencode_local", "grok_local", "cursor", "pi_local", "process", "external"])("keeps %s default unchanged", adapterType => { + expect(resolveNewAgentRunner({ adapterType, target, adapterConfig: { custom: "preserved" } })).toEqual({ adapterType, adapterConfig: { custom: "preserved" } }); + }); + it.each(qualifiedTargets)("allows an explicit legacy runner and native to legacy round trip on $platform/$architecture", target => { + expect(resolveNewAgentRunner({ adapterType: "codex_local", target, runner: "legacy", adapterConfig: { extraArgs: ["--search"] } }).adapterType).toBe("codex_local"); + expect(resolveNewAgentRunner({ adapterType: "paperclip_runner", runner: "legacy", adapterConfig: { provider: "codex", model: "gpt-5.6-sol", lifecycleMode: "per_turn", codexPermissionMode: "never", modelReasoningEffort: "high" } })).toEqual({ adapterType: "codex_local", adapterConfig: { model: "gpt-5.6-sol", modelReasoningEffort: "high", dangerouslyBypassApprovalsAndSandbox: true } }); + }); + it("preserves explicit legacy permission policy alongside compatible native lifecycle defaults", () => { + expect(resolveNewAgentRunner({ adapterType: "codex_local", runner: "legacy", adapterConfig: { dangerouslyBypassApprovalsAndSandbox: false, lifecycleMode: "per_turn" } })) + .toEqual({ adapterType: "codex_local", adapterConfig: { dangerouslyBypassApprovalsAndSandbox: false } }); + expect(resolveNewAgentRunner({ adapterType: "paperclip_runner", runner: "legacy", adapterConfig: { provider: "codex", codexPermissionMode: "never", lifecycleMode: "per_turn", dangerouslyBypassApprovalsAndSandbox: false } })) + .toEqual({ adapterType: "codex_local", adapterConfig: { dangerouslyBypassApprovalsAndSandbox: false } }); + }); + it.each([{ platform: "win32", architecture: "x64" }, { platform: "linux", architecture: "arm" }, { platform: "darwin", architecture: "ia32" }])("keeps unqualified $platform/$architecture targets legacy while preserving explicit native profiles", ({ platform, architecture }) => { + const input = { adapterType: "codex_local", target: { driver: "local", platform, architecture } }; + expect(resolveNewAgentRunner(input).adapterType).toBe("codex_local"); + expect(() => resolveNewAgentRunner({ ...input, runner: "paperclip" })).toThrow("unavailable"); + const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, target: input.target }; + expect(resolveNewAgentRunner(saved)).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig }); + expect(resolveNewAgentRunner({ ...saved, runner: "paperclip" })).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig }); + }); + it.each(qualifiedTargets)("automatically keeps unsupported targets and active external overrides legacy on $platform/$architecture", target => { + const input = { adapterType: "codex_local", target: { driver: "local", platform: "win32", architecture: "x64" } }; + expect(resolveNewAgentRunner(input).adapterType).toBe("codex_local"); + expect(() => resolveNewAgentRunner({ ...input, runner: "paperclip" })).toThrow("unavailable"); + state.overridden.add("codex_local"); + expect(agentRunnerAvailability("codex_local", target).defaultRunner).toBe("legacy"); + expect(resolveNewAgentRunner({ adapterType: "codex_local", target }).adapterType).toBe("codex_local"); + state.overridden.clear(); + state.overridden.add("paperclip_runner"); + expect(resolveNewAgentRunner({ adapterType: "codex_local", target }).adapterType).toBe("codex_local"); + }); + it.each(qualifiedTargets)("does not select a disabled runner on $platform/$architecture", target => { + state.disabled = ["paperclip_runner"]; + expect(resolveNewAgentRunner({ adapterType: "codex_local", target }).adapterType).toBe("codex_local"); + expect(() => resolveNewAgentRunner({ adapterType: "codex_local", target, runner: "paperclip" })).toThrow("unavailable"); + state.disabled = ["codex_local"]; + expect(() => resolveNewAgentRunner({ adapterType: "codex_local", target })).toThrow("Codex is disabled"); + }); + it.each(["extraArgs", "agentCommand", "networkScope", "filesystemScope", "search"])("rejects incompatible %s without changing runner", field => { + expect(() => resolveNewAgentRunner({ adapterType: "codex_local", target, adapterConfig: { [field]: true } })).toThrow("Legacy runner"); + try { resolveNewAgentRunner({ adapterType: "codex_local", target, adapterConfig: { [field]: true } }); } + catch (error) { expect(error).toMatchObject({ status: 422, details: { fields: [field] } }); } + }); + it.each([ + ["agentCommand", "acpAgentCommand", "custom-codex-acp"], + ["stateDir", "acpStateDir", "/custom/acp-state"], + ["mode", "acpMode", "oneshot"], + ["permissionMode", "acpPermissionMode", "deny-all"], + ["nonInteractivePermissions", "acpNonInteractivePermissions", "fail"], + ["warmHandleIdleMs", "acpWarmHandleIdleMs", 30_000], + ])("rejects effective Legacy ACP alias %s/%s before automatic native translation", (canonical, alias, value) => { + const adapterConfig = { engine: "acp", [alias]: value }; + expect(buildCodexAcpConfig(adapterConfig)).toHaveProperty(canonical, value); + for (const runner of ["auto", "paperclip"] as const) { + expect(() => resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target, runner })) + .toThrow(expect.objectContaining({ status: 422, details: { code: "agent_runner_config_incompatible", fields: [alias] } })); + } + expect(resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target, runner: "legacy" })) + .toEqual({ adapterType: "codex_local", adapterConfig }); + }); + + it.each([ + ["agentCommand", "acpAgentCommand", "canonical-command", "alias-command"], + ["stateDir", "acpStateDir", "/canonical-state", "/alias-state"], + ["permissionMode", "acpPermissionMode", "approve-all", "deny-all"], + ])("checks the effective canonical ACP %s ahead of its alias", (canonical, alias, value, aliasedValue) => { + const adapterConfig = { engine: "acp", [canonical]: value, [alias]: aliasedValue }; + expect(buildCodexAcpConfig(adapterConfig)).toHaveProperty(canonical, value); + expect(() => resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target })) + .toThrow(expect.objectContaining({ status: 422, details: { code: "agent_runner_config_incompatible", fields: [canonical] } })); + }); + + it("uses Legacy ACP canonical precedence and removes compatible or shadowed aliases idempotently", () => { + const adapterConfig = { engine: "acp", mode: "persistent", acpMode: "oneshot", + nonInteractivePermissions: "deny", acpNonInteractivePermissions: "fail", + warmHandleIdleMs: 0, acpWarmHandleIdleMs: 30_000 }; + expect(buildCodexAcpConfig(adapterConfig)).toMatchObject({ mode: "persistent", nonInteractivePermissions: "deny", warmHandleIdleMs: 0 }); + const resolved = resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target }); + expect(resolved).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { codexPermissionMode: "never" } }); + for (const field of Object.keys(adapterConfig)) expect(resolved.adapterConfig).not.toHaveProperty(field); + expect(resolveNewAgentRunner(resolved)).toEqual(resolved); + expect(resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target, runner: "legacy" })) + .toEqual({ adapterType: "codex_local", adapterConfig }); + }); + + it("accepts equivalent ACP default aliases without persisting legacy-only controls", () => { + const adapterConfig = { engine: "acp", acpMode: " persistent ", acpNonInteractivePermissions: " deny ", acpWarmHandleIdleMs: 0 }; + expect(buildCodexAcpConfig(adapterConfig)).toMatchObject({ mode: "persistent", nonInteractivePermissions: "deny", warmHandleIdleMs: 0 }); + const resolved = resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target }); + expect(resolved.adapterType).toBe("paperclip_runner"); + for (const field of Object.keys(adapterConfig)) expect(resolved.adapterConfig).not.toHaveProperty(field); + expect(resolveNewAgentRunner(resolved)).toEqual(resolved); + }); + + it("uses nullish ACP idle precedence instead of treating a canonical zero as absent", () => { + const adapterConfig = { engine: "acp", warmHandleIdleMs: null, acpWarmHandleIdleMs: 30_000 }; + expect(buildCodexAcpConfig(adapterConfig)).toHaveProperty("warmHandleIdleMs", 30_000); + expect(() => resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target })) + .toThrow(expect.objectContaining({ status: 422, details: { code: "agent_runner_config_incompatible", fields: ["acpWarmHandleIdleMs"] } })); + }); + + it("uses a nonempty ACP alias when its canonical string is blank", () => { + const adapterConfig = { engine: "acp", permissionMode: " ", acpPermissionMode: " deny-all " }; + expect(buildCodexAcpConfig(adapterConfig)).toHaveProperty("permissionMode", "deny-all"); + expect(() => resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target })) + .toThrow(expect.objectContaining({ status: 422, details: { code: "agent_runner_config_incompatible", fields: ["acpPermissionMode"] } })); + }); + + it.each([undefined, "acp"])("rejects effective Legacy ACP network denial for engine=%s before native selection", async engine => { + const adapterConfig = { ...(engine ? { engine } : {}), env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "false" } }; + expect(resolveCodexExecutionEngine(adapterConfig).engine).toBe("acp"); + expect(buildCodexAcpConfig(adapterConfig)).toMatchObject({ env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "false" } }); + const { codexNetworkAccess } = await import(new URL( + "../../../packages/paperclip-runner/src/drivers/codex/codex-security-config.ts", import.meta.url, + ).href); + expect(codexNetworkAccess({ PAPERCLIP_RUNNER_NETWORK_ACCESS: "enabled", PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "false" })).toBe(true); + for (const runner of [undefined, "auto", "paperclip"] as const) { + expect(() => resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target, runner })) + .toThrow(expect.objectContaining({ status: 422, details: { code: "agent_runner_config_incompatible", fields: ["env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS"] } })); + } + expect(resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target, runner: "legacy" })) + .toEqual({ adapterType: "codex_local", adapterConfig }); + expect(adapterConfig.env).toEqual({ PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "false" }); + }); + + it.each([ + { type: "plain", value: "false" }, + { type: "secret_ref", secretId: "fixture-policy", version: "latest" }, + { type: "user_secret_ref", key: "fixture-policy", version: "latest" }, + ])("does not discard a bound ACP network restriction or guess its value (%j)", async binding => { + const adapterConfig = { engine: "acp", env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: binding } }; + if (binding.type === "plain") { + const resolved = await secretService({} as never).resolveEnvBindings("fixture-company", adapterConfig.env); + expect(buildCodexAcpConfig({ ...adapterConfig, env: resolved.env })) + .toMatchObject({ env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "false" } }); + } + expect(() => resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target })) + .toThrow(expect.objectContaining({ status: 422, details: { code: "agent_runner_config_incompatible", fields: ["env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS"] } })); + expect(resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target, runner: "legacy" })) + .toEqual({ adapterType: "codex_local", adapterConfig }); + }); + + it.each([ + { extraArgs: ["-c", "sandbox_workspace_write.network_access=true"] }, + { extraArgs: [], args: ["-c", "sandbox_workspace_write.network_access=true"] }, + ])("retains actionable argument errors when Legacy arguments override a network flag (%j)", args => { + const adapterConfig = { engine: "acp", ...args, env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "false" } }; + expect(buildCodexAcpConfig(adapterConfig)).toMatchObject({ env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "true" } }); + expect(() => resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target })) + .toThrow(expect.objectContaining({ status: 422, details: { code: "agent_runner_config_incompatible", fields: [args.extraArgs.length ? "extraArgs" : "args"] } })); + }); + + it.each(["true", "False", " false ", { type: "plain", value: "true" }])("keeps compatible ACP environment settings (%j) and native idempotence", value => { + const adapterConfig = { engine: "acp", env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: value, OPENAI_API_KEY: { type: "secret_ref", secretId: "fixture-api-key" } } }; + const literal = typeof value === "string" ? value : value.value; + expect(buildCodexAcpConfig({ ...adapterConfig, env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: literal } })) + .toMatchObject({ env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "true" } }); + const resolved = resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target }); + expect(resolved.adapterType).toBe("paperclip_runner"); + expect(resolved.adapterConfig.env).toEqual(adapterConfig.env); + expect(resolveNewAgentRunner(resolved)).toEqual(resolved); + }); + + it("keeps a saved native profile and an explicitly selected Legacy CLI's unused ACP flag unchanged", () => { + const adapterConfig = { engine: "cli", env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "false" } }; + expect(resolveCodexExecutionEngine(adapterConfig).engine).toBe("cli"); + expect(resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target }).adapterType).toBe("paperclip_runner"); + const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", env: adapterConfig.env } }; + expect(resolveNewAgentRunner(saved)).toEqual(saved); + }); + + it("preserves explicit historical native profiles, including managed configurations", () => { + for (const config of [{ provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" }, { provider: "claude_managed", managedProfileId: "profile", managedAgentsRetentionAcknowledged: true }, { provider: "aws_agentcore", agentCoreProfileId: "profile" }]) expect(resolveNewAgentRunner({ adapterType: "paperclip_runner", adapterConfig: config })).toEqual({ adapterType: "paperclip_runner", adapterConfig: config }); + }); + it("advertises Legacy for a forced Kubernetes target before lazy environment creation", async () => { + state.settings = { general: { executionMode: "kubernetes" } }; + const selected = await resolveAgentRunnerTargetForCompany({} as never, "company"); + expect(selected).toEqual({ driver: "sandbox" }); + expect(agentRunnerAvailability("codex_local", selected)).toEqual({ supportedRunners: ["legacy"], defaultRunner: "legacy" }); + expect(state.kubernetesEnvironment).toHaveBeenCalledWith("company"); + expect(state.ensureKubernetesEnvironment).not.toHaveBeenCalled(); + expect(state.resolveEnvironment).not.toHaveBeenCalled(); + expect(state.sandboxQualification).not.toHaveBeenCalled(); + expect(state.ssh).not.toHaveBeenCalled(); + }); + it.each([undefined, "auto"] as const)("preserves Codex %s creation before a forced Kubernetes environment exists", async runner => { + state.settings = { general: { executionMode: "kubernetes" } }; + const adapterConfig = { model: "gpt-5.6-sol", extraArgs: ["--search"], env: { OPENAI_API_KEY: { type: "secret_ref", secretId: "synthetic-secret" } } }; + const resolved = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", adapterConfig, runner }); + expect(resolved).toEqual({ adapterType: "codex_local", adapterConfig }); + expect(state.ensureKubernetesEnvironment).not.toHaveBeenCalled(); + expect(state.resolveEnvironment).not.toHaveBeenCalled(); + expect(state.sandboxQualification).not.toHaveBeenCalled(); + expect(state.ssh).not.toHaveBeenCalled(); + }); + it("rejects explicit Paperclip execution on an unqualified forced Kubernetes target without provisioning", async () => { + state.settings = { general: { executionMode: "kubernetes" } }; + await expect(resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", runner: "paperclip" })) + .rejects.toMatchObject({ status: 422, message: expect.stringContaining("Legacy runner"), details: { code: "agent_runner_unavailable" } }); + expect(state.ensureKubernetesEnvironment).not.toHaveBeenCalled(); + expect(state.resolveEnvironment).not.toHaveBeenCalled(); + expect(state.sandboxQualification).not.toHaveBeenCalled(); + expect(state.ssh).not.toHaveBeenCalled(); + }); + it("preserves explicit Legacy and recorded native execution without reading the forced Kubernetes target", async () => { + state.settings = { general: { executionMode: "kubernetes" } }; + for (const input of [ + { adapterType: "codex_local", runner: "legacy" as const, adapterConfig: { extraArgs: ["--search"] } }, + { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" } }, + ]) { + expect(await resolveNewAgentRunnerForCompany({} as never, "company", input)) + .toEqual({ adapterType: input.adapterType, adapterConfig: input.adapterConfig }); + } + expect(state.kubernetesEnvironment).not.toHaveBeenCalled(); + expect(state.ensureKubernetesEnvironment).not.toHaveBeenCalled(); + }); + it("retains company checks for an existing forced Kubernetes environment", async () => { + state.settings = { general: { executionMode: "kubernetes" } }; + state.kubernetesEnvironment.mockResolvedValue({ id: "kubernetes", driver: "sandbox", status: "active", config: { provider: "kubernetes" } }); + state.bindings.mockResolvedValue(["another-company"]); + await expect(resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local" })) + .rejects.toMatchObject({ status: 403, details: { code: "environment_company_mismatch" } }); + expect(state.ensureKubernetesEnvironment).not.toHaveBeenCalled(); + expect(state.sandboxQualification).not.toHaveBeenCalled(); + expect(state.resolveEnvironment).not.toHaveBeenCalled(); + }); + it("rejects a foreign environment before resolving credentials or probing its platform", async () => { + state.getEnvironment.mockResolvedValue({ id: "foreign", driver: "ssh", status: "active", config: {} }); + state.bindings.mockResolvedValue(["another-company"]); + await expect(resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: "foreign" })).rejects.toMatchObject({ status: 403, details: { code: "environment_company_mismatch" } }); + expect(state.resolveEnvironment).not.toHaveBeenCalled(); expect(state.ssh).not.toHaveBeenCalled(); + }); + it("uses dispatch's instance default for a null agent override and checks the selected SSH platform", async () => { + state.settings = { defaultEnvironmentId: "default" }; + state.getEnvironment.mockResolvedValue({ id: "default", driver: "ssh", status: "active", config: {} }); + state.ssh.mockResolvedValue({ stdout: "Linux\naarch64\n" }); + const result = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: null }); + expect(state.getEnvironment).toHaveBeenCalledWith("default"); expect(state.bindings).toHaveBeenCalledWith("default"); + expect(result.adapterType).toBe("paperclip_runner"); + }); + it.each([ + ["Linux\nx86_64\n", "paperclip", "paperclip_runner"], + ["Darwin\narm64\n", "paperclip", "paperclip_runner"], + ["Darwin\nx86_64\n", "paperclip", "paperclip_runner"], + ["Linux\naarch64\n", "paperclip", "paperclip_runner"], + ["Linux\narm64\n", "paperclip", "paperclip_runner"], + ["Linux\nx86_64\nglibc\n", "paperclip", "paperclip_runner"], + ["Linux\naarch64\nglibc\n", "paperclip", "paperclip_runner"], + ["Linux\nx86_64\nglibc\nglibc 2.38\n", "legacy", "codex_local"], + ["Linux\naarch64\nglibc\nglibc 2.38\n", "legacy", "codex_local"], + ["Linux\nx86_64\nglibc\nglibc 2.39\n", "paperclip", "paperclip_runner"], + ["Linux\naarch64\nglibc\nglibc 3.0\n", "paperclip", "paperclip_runner"], + ["Linux\nx86_64\nglibc\nmalformed\n", "legacy", "codex_local"], + ["Linux\nx86_64\nmusl\n", "legacy", "codex_local"], + ["Linux\naarch64\nmusl\n", "legacy", "codex_local"], + ["Linux\nx86_64\nunknown\n", "legacy", "codex_local"], + ["Linux\narmv7l\n", "legacy", "codex_local"], + ])("uses the same selected SSH target for discovery and saving (%s)", async (stdout, expectedRunner, expectedAdapter) => { + state.getEnvironment.mockResolvedValue({ id: "selected", driver: "ssh", status: "active", config: {} }); + state.ssh.mockResolvedValue({ stdout }); + const discoveredTarget = await resolveAgentRunnerTargetForCompany({} as never, "company", "selected"); + expect(agentRunnerAvailability("codex_local", discoveredTarget).defaultRunner).toBe(expectedRunner); + const saved = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: "selected" }); + expect(saved.adapterType).toBe(expectedAdapter); + expect(state.ssh).toHaveBeenCalledTimes(2); + expect(state.ssh).toHaveBeenCalledWith({}, AGENT_RUNNER_SSH_PLATFORM_COMMAND, { timeoutMs: 10_000 }); + }); + it("preserves recorded explicit native execution without rechecking creation defaults", async () => { + const recorded = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, runner: "paperclip" as const }; + expect(await resolveNewAgentRunnerForCompany({} as never, "company", recorded)).toEqual({ adapterType: recorded.adapterType, adapterConfig: recorded.adapterConfig }); + expect(state.getEnvironment).not.toHaveBeenCalled(); + expect(state.ssh).not.toHaveBeenCalled(); + }); + it.each([ + [{ platform: "linux", architecture: "x64", libc: "glibc", glibcVersion: "2.39" }, "paperclip_runner"], + [{ platform: "linux", architecture: "arm64", libc: "glibc", glibcVersion: "3.0" }, "paperclip_runner"], + [{ platform: "linux", architecture: "arm64", libc: "glibc", glibcVersion: "2.38" }, "codex_local"], + [{ platform: "linux", architecture: "x64", libc: "glibc" }, "codex_local"], + [{ platform: "linux", architecture: "arm64", libc: "musl" }, "codex_local"], + [{ platform: "linux", architecture: "unknown", libc: "glibc", glibcVersion: "2.39" }, "codex_local"], + ])("uses cached selected-image measurements for sandbox creation/discovery (%j)", async (facts, expected) => { + state.getEnvironment.mockResolvedValue({ id: "sandbox", driver: "sandbox", status: "active", config: { provider: "daytona" } }); + state.sandboxQualification.mockResolvedValue(facts); + const selectedTarget = await resolveAgentRunnerTargetForCompany({} as never, "company", "sandbox"); + expect(agentRunnerAvailability("codex_local", selectedTarget).defaultRunner).toBe(expected === "paperclip_runner" ? "paperclip" : "legacy"); + const result = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: "sandbox" }); + expect(result.adapterType).toBe(expected); + expect(state.ssh).not.toHaveBeenCalled(); + expect(state.resolveEnvironment).not.toHaveBeenCalled(); + if (expected === "codex_local") await expect(resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", runner: "paperclip", defaultEnvironmentId: "sandbox" })).rejects.toMatchObject({ status: 422 }); + }); + it("does not infer a sandbox image architecture from its driver", async () => { + state.getEnvironment.mockResolvedValue({ id: "sandbox", driver: "sandbox", status: "active", config: { provider: "daytona" } }); + const result = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: "sandbox" }); + expect(result.adapterType).toBe("codex_local"); expect(state.ssh).not.toHaveBeenCalled(); + expect(state.sandboxQualification).toHaveBeenCalledWith({}, expect.objectContaining({ id: "sandbox" }), "company"); + await expect(resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", runner: "paperclip", defaultEnvironmentId: "sandbox" })).rejects.toMatchObject({ status: 422, details: { code: "agent_runner_unavailable" } }); + }); +}); + +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(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"); + expect(agentHarnessType("paperclip_runner", { provider: "acpx", acpxAgent: "unknown" })).toBe("acpx:unknown"); + expect(agentRunner("codex_local")).toBe("legacy"); + expect(agentRunner("paperclip_runner")).toBe("paperclip"); + }); + it("qualifies only the Codex daemon targets shipped in public server packages", () => { + for (const { platform, architecture } of qualifiedTargets) expect(paperclipRunnerSupportsPlatform("codex_local", platform, architecture)).toBe(true); + for (const architecture of ["x64", "arm64"]) { + expect(paperclipRunnerSupportsPlatform("codex_local", "linux", architecture, "glibc")).toBe(true); + expect(paperclipRunnerSupportsPlatform("codex_local", "linux", architecture, "musl")).toBe(false); + 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); + }); + it("keeps auto out of saved update defaults and validates request intent", () => { + expect(createAgentSchema.parse({ name: "Codex", adapterType: "codex_local" }).runner).toBeUndefined(); + expect(updateAgentSchema.parse({ title: "same execution" })).not.toHaveProperty("runner"); + expect(testAdapterEnvironmentSchema.parse({ runner: "legacy" }).runner).toBe("legacy"); + expect(createAgentSchema.safeParse({ name: "Codex", adapterType: "codex_local", runner: "other" }).success).toBe(false); + }); +}); diff --git a/server/src/services/agent-runner-selection.ts b/server/src/services/agent-runner-selection.ts new file mode 100644 index 00000000000..e47d433db48 --- /dev/null +++ b/server/src/services/agent-runner-selection.ts @@ -0,0 +1,164 @@ +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 { findActiveServerAdapter, hasActiveAdapterOverride, waitForExternalAdapters } from "../adapters/registry.js"; +import { getDisabledAdapterTypes } from "./adapter-plugin-store.js"; +import { 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"; +import { localAgentRunnerTarget } from "../lib/agent-runner-platform.js"; + +export type RunnerTarget = { driver: string; platform?: string; architecture?: string; libc?: "glibc" | "musl" | "unknown"; glibcVersion?: string }; +export const AGENT_RUNNER_SSH_PLATFORM_COMMAND = "uname -s; uname -m; if paperclip_glibc_version=$(getconf GNU_LIBC_VERSION 2>/dev/null) && [ -n \"$paperclip_glibc_version\" ]; then printf 'glibc\\n%s\\n' \"$paperclip_glibc_version\"; else case \"$(ldd --version 2>&1)\" in *musl*) printf 'musl\\n' ;; *) printf 'unknown\\n' ;; esac; fi"; +export interface AgentRunnerSelection { + adapterType?: string | null; + adapterConfig?: Record; + runner?: AgentRunnerChoice; + target?: RunnerTarget; +} + +export function agentRunnerAvailability(harness: string, target: RunnerTarget = localAgentRunnerTarget()): AgentRunnerAvailability { + const disabled = new Set(getDisabledAdapterTypes()); + const supported = harness === "codex_local" + && !disabled.has(harness) && !disabled.has("paperclip_runner") + && !!findActiveServerAdapter(harness) && !!findActiveServerAdapter("paperclip_runner") + && !hasActiveAdapterOverride(harness) && !hasActiveAdapterOverride("paperclip_runner") + && isEnvironmentDriverSupportedForAdapter("paperclip_runner", 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" }; +} + +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 }); +} + +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 (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 (!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" }); + if (config.codexPermissionMode !== undefined && config.codexPermissionMode !== "never") incompatible(["codexPermissionMode"]); + if (config.lifecycleMode !== undefined && config.lifecycleMode !== "per_turn") incompatible(["lifecycleMode"]); + for (const key of ["provider", "codexPermissionMode", "opencodePermissionMode", "acpxPermissionMode", "lifecycleMode", "idleTimeoutMs"]) delete config[key]; + 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" + ? "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) }; + const aliasSources = new Map(); + for (const [canonical, alias] of [ + ["agentCommand", "acpAgentCommand"], ["stateDir", "acpStateDir"], + ["mode", "acpMode"], ["permissionMode", "acpPermissionMode"], + ["nonInteractivePermissions", "acpNonInteractivePermissions"], + ]) { + if (config[alias] === undefined) continue; + const canonicalValue = typeof config[canonical] === "string" ? config[canonical].trim() : ""; + const aliasValue = typeof config[alias] === "string" ? config[alias].trim() : ""; + if (canonicalValue || aliasValue) { + config[canonical] = canonicalValue || aliasValue; + if (!canonicalValue) aliasSources.set(canonical, alias); + } else if (config[alias] !== null && typeof config[alias] !== "string") incompatible([alias]); + else if (typeof config[canonical] === "string") delete config[canonical]; + delete config[alias]; + } + if (config.acpWarmHandleIdleMs !== undefined) { + if (config.warmHandleIdleMs == null && config.acpWarmHandleIdleMs != null) { + config.warmHandleIdleMs = config.acpWarmHandleIdleMs; + aliasSources.set("warmHandleIdleMs", "acpWarmHandleIdleMs"); + } + delete config.acpWarmHandleIdleMs; + } + const unsupported = ["agentCommand", "stateDir", "warmHandleIdleMs", "extraArgs", "args", "search", "fastMode", "variant", "filesystemScope", "filesystemExtraPaths", "filesystemSandboxCommand", "networkScope", "networkAllowlist", "permissionMode", "maxTurns", "disableWebSearch", "outputInactivityTimeoutMs", "terminalResultCleanupGraceMs"] + .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 !== "codex") unsupported.push("command"); + if (config.mode && config.mode !== "persistent") unsupported.push("mode"); + if (config.engine && config.engine !== "cli" && config.engine !== "acp") unsupported.push("engine"); + if (config.nonInteractivePermissions && config.nonInteractivePermissions !== "deny") unsupported.push("nonInteractivePermissions"); + if (config.dangerouslyBypassApprovalsAndSandbox === false) unsupported.push("dangerouslyBypassApprovalsAndSandbox"); + if (config.dangerouslyBypassSandbox === false) unsupported.push("dangerouslyBypassSandbox"); + if (unsupported.length) incompatible(unsupported.map(field => aliasSources.get(field) ?? field)); + const env = config.env && typeof config.env === "object" && !Array.isArray(config.env) + ? config.env as Record : {}; + const network = env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS; + const binding = network && typeof network === "object" && !Array.isArray(network) + ? network as Record : null; + if (config.engine !== "cli" && (network === "false" + || (binding?.type === "plain" && binding.value === "false") + || binding?.type === "secret_ref" || binding?.type === "user_secret_ref")) { + incompatible(["env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS"]); + } + const effort = config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort; + for (const key of ["engine", "command", "extraArgs", "args", "agentCommand", "mode", "nonInteractivePermissions", "stateDir", "warmHandleIdleMs", "dangerouslyBypassApprovalsAndSandbox", "dangerouslyBypassSandbox", "search", "fastMode", "reasoningEffort", "effort"]) delete config[key]; + if (config.model !== undefined && typeof config.model !== "string") incompatible(["model"]); + config = { ...config, provider: "codex", model: typeof config.model === "string" && config.model.trim() ? config.model.trim() : PAPERCLIP_RUNNER_DEFAULT_MODELS.codex, codexPermissionMode: config.codexPermissionMode ?? "never", lifecycleMode: config.lifecycleMode ?? "per_turn", ...(effort === undefined ? {} : { modelReasoningEffort: effort }) }; + try { resolvePaperclipRunnerNativeProviderInput({ backend: "codex_app_server", adapterConfig: config }); } + catch (error) { + if (error instanceof PaperclipRunnerProviderProfileError) throw unprocessable(error.message, { code: error.code }); + throw error; + } + return { adapterType: "paperclip_runner", adapterConfig: config }; +} + +export async function resolveNewAgentRunnerForCompany(db: Db, companyId: string, input: AgentRunnerSelection & { defaultEnvironmentId?: string | null }) { + if (agentHarnessType(input.adapterType ?? "process", input.adapterConfig) !== "codex_local" + || 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 }); +} + +export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: string, defaultEnvironmentId?: string | null): Promise { + await waitForExternalAdapters(); + const { instanceSettingsService } = await import("./instance-settings.js"); + const { environmentService } = await import("./environments.js"); + const settings = await instanceSettingsService(db).get(); + const envs = environmentService(db); + const environmentId = defaultEnvironmentId ?? settings.defaultEnvironmentId; + let environment = environmentId ? await envs.getById(environmentId) : null; + if (settings.experimental?.enableManagedSandboxOnly && (!environment || environment.driver === "local")) { + environment = await envs.findManagedSandboxEnvironment(companyId); + if (!environment) throw unprocessable("The managed sandbox is unavailable. Restore Paperclip Computer and retry.", { code: "managed_sandbox_unavailable" }); + } + if (settings.general?.executionMode === "kubernetes") { + environment = await envs.findKubernetesEnvironment(companyId); + if (!environment) return { driver: "sandbox" }; + } + if (environmentId && !environment) throw unprocessable("Environment not found."); + if (environment) { + const boundCompanyIds = await envs.listBoundCompanyIds(environment.id); + if (boundCompanyIds.length > 0 && !boundCompanyIds.includes(companyId)) throw forbidden("The selected environment belongs to another company.", { code: "environment_company_mismatch" }); + if (environment.status === "archived") throw unprocessable("Environment is archived."); + if (environment.driver === "sandbox" && environment.config?.provider === "fake") throw unprocessable("The fake sandbox provider cannot execute runs."); + } + const driver = environment?.driver ?? "local"; + let target: RunnerTarget = driver === "local" ? localAgentRunnerTarget() + : { driver }; + if (driver === "sandbox" && environment) { + const { readSandboxPlatformQualification } = await import("./environment-probe.js"); + const qualified = await readSandboxPlatformQualification(db, environment, companyId); + if (qualified) target = { driver, ...qualified }; + } + if (driver === "ssh" && environment && agentRunnerAvailability("codex_local", target).defaultRunner === "paperclip") { + const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment); + if (parsed.driver !== "ssh") throw unprocessable("The selected SSH environment is unavailable."); + let output: string; + try { output = (await runSshCommand(parsed.config, AGENT_RUNNER_SSH_PLATFORM_COMMAND, { timeoutMs: 10_000 })).stdout; } + catch { throw unprocessable("Could not check the SSH host's platform. Check its connection and retry, or select Legacy runner in Advanced.", { code: "agent_runner_platform_probe_failed" }); } + const [os = "", arch = "", libc, glibcVersion] = output.trim().split(/\r?\n/); + target = { driver, platform: os === "Linux" ? "linux" : os === "Darwin" ? "darwin" : "unknown", architecture: arch === "x86_64" ? "x64" : ["aarch64", "arm64"].includes(arch) ? "arm64" : arch, ...(os === "Linux" && libc !== undefined ? { libc: libc === "glibc" || libc === "musl" ? libc : "unknown", ...(libc === "glibc" && glibcVersion !== undefined ? { glibcVersion } : {}) } : {}) }; + } + return target; +} diff --git a/server/src/services/built-in-agents.ts b/server/src/services/built-in-agents.ts index af69debdc5c..24664c9662a 100644 --- a/server/src/services/built-in-agents.ts +++ b/server/src/services/built-in-agents.ts @@ -8,7 +8,7 @@ import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference } f import { and, desc, eq, ne } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { activityLog, agents, builtInManagedResources, companies, issueThreadInteractions, issues, routines, routineTriggers } from "@paperclipai/db"; -import { syncRoutineVariablesWithTemplate } from "@paperclipai/shared"; +import { agentHarnessType, agentRunner, syncRoutineVariablesWithTemplate, type AgentRunnerChoice } from "@paperclipai/shared"; import type { Agent, Approval, CompanySkill, PermissionKey, Routine, RoutineTrigger, RoutineVariable } from "@paperclipai/shared"; import { conflict, HttpError, notFound, unprocessable } from "../errors.js"; import { logActivity } from "./activity-log.js"; @@ -18,6 +18,7 @@ import { agentInstructionRevisionService } from "./agent-instruction-revisions.j import type { AuthorizationActor } from "./authorization.js"; import { agentInstructionsBundleMode, agentInstructionsService } from "./agent-instructions.js"; import { agentService } from "./agents.js"; +import { resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js"; import { approvalService } from "./approvals.js"; import { readBuiltInAgentMarker, @@ -67,6 +68,7 @@ export interface BuiltInAgentState { } export interface BuiltInAgentProvisionInput { + runner?: AgentRunnerChoice; adapterType?: string; adapterConfig?: Record; budgetMonthlyCents?: number; @@ -690,8 +692,8 @@ function selectPreferredAdapterType( return selected?.adapterType ?? fallback; } -function assertAdapterAllowed(definition: BuiltInAgentDefinition, adapterType: string) { - if (definition.allowedAdapterTypes && !definition.allowedAdapterTypes.includes(adapterType)) { +function assertAdapterAllowed(definition: BuiltInAgentDefinition, adapterType: string, adapterConfig: Record = {}) { + if (definition.allowedAdapterTypes && !definition.allowedAdapterTypes.includes(agentHarnessType(adapterType, adapterConfig))) { throw unprocessable(`Adapter type ${adapterType} is not allowed for built-in agent ${definition.key}`, { code: "built_in_agent_adapter_not_allowed", key: definition.key, @@ -714,11 +716,11 @@ function hasCompleteAdapterConfig(adapterType: string, adapterConfig: unknown) { return nonEmptyString(adapterConfig.model); } -export function deriveBuiltInAgentStatus(agent: Pick | null): BuiltInAgentStatus { +export function deriveBuiltInAgentStatus(agent: Pick & { metadata?: Agent["metadata"] } | null): BuiltInAgentStatus { if (!agent) return "not_provisioned"; if (agent.status === "pending_approval") return "pending_approval"; if (agent.status === "paused" || agent.pausedAt) return "paused"; - return hasCompleteAdapterConfig(agent.adapterType, agent.adapterConfig) ? "ready" : "needs_setup"; + return agent.metadata?.paperclipBuiltInSetupRequired !== true && hasCompleteAdapterConfig(agent.adapterType, agent.adapterConfig) ? "ready" : "needs_setup"; } function builtInMetadata(definition: BuiltInAgentDefinition, existing?: Record | null) { @@ -730,8 +732,9 @@ function builtInMetadata(definition: BuiltInAgentDefinition, existing?: Record candidate.id === model)) return; throw unprocessable(`Model "${model}" is not available for adapter ${adapterType}.`, { @@ -776,7 +779,7 @@ function builtInAgentNotConfiguredError(state: BuiltInAgentState) { } function hasProvisionSetupInput(input: BuiltInAgentProvisionInput) { - return input.adapterType !== undefined || input.adapterConfig !== undefined || input.budgetMonthlyCents !== undefined; + return input.adapterType !== undefined || input.adapterConfig !== undefined || input.runner !== undefined || input.budgetMonthlyCents !== undefined; } function rowIsBuiltInAgent(row: typeof agents.$inferSelect, key: string) { @@ -885,13 +888,13 @@ export function builtInAgentService(db: Db) { .from(agents) .where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated"))); const candidate = rows.find((row) => - definition.allowedAdapterTypes?.includes(row.adapterType) + definition.allowedAdapterTypes?.includes(agentHarnessType(row.adapterType, row.adapterConfig)) && hasCompleteAdapterConfig(row.adapterType, row.adapterConfig) ); if (!candidate) return input; return { ...input, - adapterType: candidate.adapterType, + adapterType: agentHarnessType(candidate.adapterType, candidate.adapterConfig), adapterConfig: {}, }; } @@ -1737,11 +1740,13 @@ export function builtInAgentService(db: Db) { && !existingPendingApproval && input.adapterType === undefined && input.adapterConfig === undefined - && hasCompleteAdapterConfig(existing.adapterType, existing.adapterConfig), + && input.runner === undefined, ); const resolvedInput = existingPendingApproval || preserveExistingAdapter ? input - : await defaultProvisionInput(companyId, definition, input); + : existing && input.runner !== undefined && input.adapterType === undefined && input.adapterConfig === undefined + ? { ...input, adapterType: existing.adapterType, adapterConfig: existing.adapterConfig } + : await defaultProvisionInput(companyId, definition, input); if (!existingPendingApproval && !preserveExistingAdapter) { await assertKnownBuiltInAgentModel(definition, resolvedInput); } @@ -1751,12 +1756,32 @@ export function builtInAgentService(db: Db) { }; if ( !existingPendingApproval - && (resolvedInput.adapterType !== undefined || resolvedInput.adapterConfig !== undefined) + && (resolvedInput.adapterType !== undefined || resolvedInput.adapterConfig !== undefined || resolvedInput.runner !== undefined) ) { const adapterType = resolvedInput.adapterType ?? existing.adapterType; - assertAdapterAllowed(definition, adapterType); - patch.adapterType = adapterType; - patch.adapterConfig = resolvedInput.adapterConfig ?? existing.adapterConfig; + 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 + ? agentRunner(existing.adapterType) : undefined); + const retainedNative = sameCodexHarness && runner === "paperclip" && existing.adapterType === "paperclip_runner"; + const resolvedAdapterConfig = { ...adapterConfig }; + if (retainedNative) { + for (const field of ["provider", "acpxAgent", "acpxMode"]) { + if (existing.adapterConfig[field] !== undefined) resolvedAdapterConfig[field] = existing.adapterConfig[field]; + } + } + Object.assign(patch, runner !== undefined || existing.metadata?.paperclipBuiltInSetupRequired === true + ? await resolveNewAgentRunnerForCompany(db, companyId, { + adapterType: retainedNative ? existing.adapterType : adapterType, + adapterConfig: resolvedAdapterConfig, runner, + defaultEnvironmentId: existing.defaultEnvironmentId, + }) + : { adapterType, adapterConfig }); + if (hasCompleteAdapterConfig(patch.adapterType ?? existing.adapterType, patch.adapterConfig ?? existing.adapterConfig)) { + patch.metadata = { ...patch.metadata, paperclipBuiltInSetupRequired: false }; + } } if (!existingPendingApproval && resolvedInput.budgetMonthlyCents !== undefined) { patch.budgetMonthlyCents = resolvedInput.budgetMonthlyCents; @@ -1795,7 +1820,13 @@ export function builtInAgentService(db: Db) { : null, pausedAt: definition.defaultStatus === "paused" ? new Date() : null, reportsTo, - metadata: builtInMetadata(definition), + metadata: { + ...builtInMetadata(definition), + paperclipBuiltInSetupRequired: !hasCompleteAdapterConfig( + definitionPatch(definition, resolvedInput).adapterType, + definitionPatch(definition, resolvedInput).adapterConfig, + ), + }, runtimeConfig: definition.defaultRuntimeConfig ?? {}, permissions: definition.defaultPermissions ?? {}, spentMonthlyCents: 0, @@ -1860,7 +1891,7 @@ export function builtInAgentService(db: Db) { }; } - const providesAdapterSetup = input.adapterType !== undefined || input.adapterConfig !== undefined; + const providesAdapterSetup = input.adapterType !== undefined || input.adapterConfig !== undefined || input.runner !== undefined; // A built-in row that has never completed adapter setup (incomplete // config, i.e. `needs_setup`) is still first-time configuration, not a @@ -1870,7 +1901,7 @@ export function builtInAgentService(db: Db) { // adapterConfig is still empty. Completing that setup applies directly, as // it does when board approval is not required, instead of dead-ending on a // fresh board-approval requirement the operator can never satisfy. - if (providesAdapterSetup && !hasCompleteAdapterConfig(existing.adapterType, existing.adapterConfig)) { + if (providesAdapterSetup && (existing.metadata?.paperclipBuiltInSetupRequired === true || !hasCompleteAdapterConfig(existing.adapterType, existing.adapterConfig))) { return { state: await ensure(companyId, key, input), approval: null }; } @@ -1896,7 +1927,13 @@ export function builtInAgentService(db: Db) { ...definitionPatch(definition, input), status: "pending_approval", reportsTo, - metadata: builtInMetadata(definition), + metadata: { + ...builtInMetadata(definition), + paperclipBuiltInSetupRequired: !hasCompleteAdapterConfig( + definitionPatch(definition, input).adapterType, + definitionPatch(definition, input).adapterConfig, + ), + }, runtimeConfig: definition.defaultRuntimeConfig ?? {}, permissions: definition.defaultPermissions ?? {}, spentMonthlyCents: 0, diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index c3509bcfd17..9fb0a4e3cc9 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -1,5 +1,6 @@ import { createAgentLifecycle } from "./agent-lifecycle.js"; -import { agentAppearanceSchema } from "@paperclipai/shared"; +import { agentAppearanceSchema, agentHarnessType, agentRunner, type AgentRunnerChoice } from "@paperclipai/shared"; +import { resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js"; import { createHash, randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; import { execFile } from "node:child_process"; @@ -3235,6 +3236,12 @@ function buildManifestFromPackageFiles( const slug = asString(frontmatter.slug) ?? fallbackSlug; const extension = isPlainRecord(paperclipAgents[slug]) ? paperclipAgents[slug] : {}; const extensionAdapter = isPlainRecord(extension.adapter) ? extension.adapter : null; + const runner = extensionAdapter?.runner; + if (runner !== undefined && (typeof runner !== "string" || !["auto", "paperclip", "legacy"].includes(runner))) { + throw unprocessable(`Invalid runner choice for agent ${slug}. Use auto, paperclip, or legacy.`, { + code: "agent_runner_choice_invalid", field: `agents.${slug}.adapter.runner`, + }); + } const extensionRuntime = isPlainRecord(extension.runtime) ? extension.runtime : null; const extensionPermissions = isPlainRecord(extension.permissions) ? extension.permissions : null; const extensionPermissionGrants = normalizePortablePermissionGrants(extension.permissionGrants); @@ -3258,6 +3265,7 @@ function buildManifestFromPackageFiles( reportsToSlug: asString(frontmatter.reportsTo) ?? asString(extension.reportsTo), reportsToExistingAgentId: asString(extension.reportsToExistingAgentId), reportsToExistingAgentSlug: asString(extension.reportsToExistingAgentSlug), + runner: runner as AgentRunnerChoice | undefined, adapterType: asString(extensionAdapter?.type) ?? "process", adapterConfig, runtimeConfig, @@ -3654,8 +3662,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { adapterConfig: Record, desiredSkills: string[], mode: ImportMode, + runner?: AgentRunnerChoice, + defaultEnvironmentId?: string | null, ) { - const effectiveAdapterType = assertKnownImportAdapterType(adapterType); + const resolved = await resolveNewAgentRunnerForCompany(db, companyId, { + adapterType, adapterConfig, runner, defaultEnvironmentId, + }); + adapterConfig = resolved.adapterConfig; + const effectiveAdapterType = assertKnownImportAdapterType(resolved.adapterType); if (mode === "agent_safe" && IMPORT_FORBIDDEN_ADAPTER_TYPES.has(effectiveAdapterType)) { throw forbidden(`Adapter type "${effectiveAdapterType}" is not allowed in safe imports`); } @@ -4284,6 +4298,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { capabilities: agent.capabilities ?? null, adapter: { type: agent.adapterType, + runner: agentRunner(agent.adapterType), config: portableAdapterConfig, }, runtime: portableRuntimeConfig, @@ -5285,7 +5300,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { "OpenAI Dot is experimental and disabled on this instance.", { code: "paperclip_runner_dot_disabled" }, ); - } else if (experimental.enableNativeRunner !== true) { + } else if (config.provider !== "codex" && config.provider !== undefined && experimental.enableNativeRunner !== true) { throw unprocessable( "Paperclip Runner is experimental and disabled on this instance.", { code: "paperclip_runner_rollout_disabled" }, @@ -5615,12 +5630,38 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { : { ...manifestAgent.adapterConfig } as Record; const desiredSkills = (manifestAgent.skills ?? []).map((skillRef) => desiredSkillRefMap.get(skillRef) ?? skillRef); + const existingImportAgent = planAgent.action === "update" && planAgent.existingAgentId + ? await agents.getById(planAgent.existingAgentId) : null; + let selectedAdapterType = adapterOverride?.adapterType ?? manifestAgent.adapterType; + const selectedHarness = agentHarnessType(selectedAdapterType, baseAdapterConfig); + let runner = adapterOverride?.runner; + if (runner === undefined && adapterOverride?.adapterType !== "paperclip_runner") { + if (selectedHarness === agentHarnessType(manifestAgent.adapterType, manifestAgent.adapterConfig)) { + runner = manifestAgent.runner; + } + if (runner === undefined && existingImportAgent + && selectedHarness === agentHarnessType(existingImportAgent.adapterType, existingImportAgent.adapterConfig)) { + runner = agentRunner(existingImportAgent.adapterType); + } + } + const retainedNative = runner === "paperclip" && selectedHarness === "codex_local" + ? [manifestAgent, existingImportAgent].find((agent) => agent?.adapterType === "paperclip_runner" + && agentHarnessType(agent.adapterType, agent.adapterConfig) === selectedHarness) + : undefined; + if (retainedNative) { + selectedAdapterType = "paperclip_runner"; + for (const field of ["provider", "acpxAgent", "acpxMode"]) { + if (retainedNative.adapterConfig[field] !== undefined) baseAdapterConfig[field] = retainedNative.adapterConfig[field]; + } + } const normalizedAdapter = await prepareImportedAgentAdapter( targetCompany.id, - adapterOverride?.adapterType ?? manifestAgent.adapterType, + selectedAdapterType, baseAdapterConfig, desiredSkills, mode, + runner, + existingImportAgent?.defaultEnvironmentId, ); const patch = { name: planAgent.plannedName, @@ -5700,6 +5741,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { let created = await agentsLifecycle.requestHire(targetCompany.id, { ...patch, + runner: agentRunner(patch.adapterType), ...automationPausePatch, status: pauseAutomations ? "paused" : "idle", }, { createdByUserId: actorUserId }); diff --git a/server/src/services/explicit-native-continuation.test.ts b/server/src/services/explicit-native-continuation.test.ts index b26bac40ab2..ad7c352d71a 100644 --- a/server/src/services/explicit-native-continuation.test.ts +++ b/server/src/services/explicit-native-continuation.test.ts @@ -36,7 +36,7 @@ const support = await getEmbeddedPostgresTestSupport(); const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(); const sourceRunId = randomUUID(), successorRunId = randomUUID(); const commentId: string = randomUUID(); - await db.insert(companies).values({ id: companyId, name: "Explicit turn", defaultResponsibleUserId: "board", issuePrefix: `E${companyId.slice(0, 6)}` }); + await db.insert(companies).values({ id: companyId, name: "Explicit turn", defaultResponsibleUserId: "board", issuePrefix: `E${companyId.replaceAll("-", "")}` }); await db.insert(agents).values({ id: agentId, companyId, name: "Native", role: "engineer", adapterType: "paperclip_runner", status: "idle", runtimeConfig: { heartbeat: { maxConcurrentRuns: 1 } } }); await db.insert(issues).values({ id: issueId, companyId, title: "Deploy", status: "blocked", assigneeAgentId: agentId }); await db.insert(heartbeatRuns).values({ id: sourceRunId, companyId, agentId, diff --git a/server/src/services/native-runtime/runtime-mode.test.ts b/server/src/services/native-runtime/runtime-mode.test.ts index 11eb8d808aa..e053604a422 100644 --- a/server/src/services/native-runtime/runtime-mode.test.ts +++ b/server/src/services/native-runtime/runtime-mode.test.ts @@ -43,10 +43,47 @@ describe("resolveNativeRuntimeMode", () => { } }); - it("rejects a fresh Paperclip Runner start while the rollout flag is disabled", () => { + it("admits fresh native Codex when the experimental rollout is disabled", () => { + expect(resolveNativeRuntimeMode({ + ...eligible, + enabled: false, + })).toMatchObject({ kind: "native", profile: { backend: "codex_app_server" } }); + }); + + it.each([ + [false, false], + [false, true], + [true, false], + ])("retains the Grok Bot gate with runner enabled=%s and Grok enabled=%s", (enabled, grokEnabled) => { + expect(() => resolveNativeRuntimeMode({ + ...eligible, + enabled, + grokEnabled, + adapterConfig: { + provider: "grok_bot", + externalBindingId: "00000000-0000-4000-8000-000000000001", + allowUnmeteredProvider: true, + }, + })).toThrow(expect.objectContaining({ code: "paperclip_runner_grok_disabled" })); + }); + + it("admits a bound Grok Bot when its existing experimental gates are enabled", () => { + expect(resolveNativeRuntimeMode({ + ...eligible, + grokEnabled: true, + adapterConfig: { + provider: "grok_bot", + externalBindingId: "00000000-0000-4000-8000-000000000001", + allowUnmeteredProvider: true, + }, + })).toMatchObject({ kind: "native", profile: { backend: "grok_bot_mcp" } }); + }); + + it("still rejects experimental providers while the rollout flag is disabled", () => { expect(() => resolveNativeRuntimeMode({ ...eligible, enabled: false, + adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" }, })).toThrow(expect.objectContaining({ code: "paperclip_runner_rollout_disabled", })); @@ -187,14 +224,17 @@ describe("resolveNativeRuntimeMode", () => { })); }); - it("keeps a persisted active run native while the global flag rejects a fresh runner start", () => { - const disabled = { ...eligible, enabled: false }; + it("keeps a persisted experimental run native while the flag rejects a fresh start", () => { + const disabled = { ...eligible, enabled: false, + adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" }, + }; expect(resolveHeartbeatNativeRuntimeMode({ ...disabled, persisted: { runtimeMode: "native", runtimeModeReason: "eligible_opt_in", runtimeModeResolvedAt: new Date(), + driverKind: "opencode_server", }, })).toEqual(expect.objectContaining({ kind: "native", @@ -458,6 +498,7 @@ describe("resolveHeartbeatRuntimeMode compatibility", () => { ...compatibilityInput, enabled: false, adapterType: "paperclip_runner", + adapterConfig: { provider: "opencode", model: "opencode/gpt-5" }, })).toThrow(NativeRunnerSelectionError); }); }); diff --git a/server/src/services/native-runtime/runtime-mode.ts b/server/src/services/native-runtime/runtime-mode.ts index c0748584643..7b8e05e4c2c 100644 --- a/server/src/services/native-runtime/runtime-mode.ts +++ b/server/src/services/native-runtime/runtime-mode.ts @@ -127,12 +127,11 @@ export function resolveNativeRuntimeMode(input: { } throw error; } - // Dot has its own rollout; enabling it does not opt in other Runner providers. if (runnerProfile.provider === "openai_dot") { 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 (!input.enabled) { + } else if (runnerProfile.provider !== "codex" && !input.enabled) { throw ineligible( "paperclip_runner_rollout_disabled", "Paperclip Runner is experimental and disabled on this instance.", diff --git a/server/src/services/native-runtime/setup-readiness.test.ts b/server/src/services/native-runtime/setup-readiness.test.ts new file mode 100644 index 00000000000..cffe993feb4 --- /dev/null +++ b/server/src/services/native-runtime/setup-readiness.test.ts @@ -0,0 +1,528 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { chmod, lstat, mkdir, mkdtemp, readFile, readlink, readdir, realpath, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, posix } from "node:path"; +import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared"; +import * as localCredentialFile from "../local-ai-credential-file.js"; +import * as codexCredentials from "@paperclipai/adapter-codex-local/server"; +const { prepareIsolatedCodexHome } = await import(new URL("../../../../packages/paperclip-runner/src/drivers/runtime-context-materializer.ts", import.meta.url).href); +const { execute, nativeProbe, selectedRunner, selectedRemoteRunner, sshExecute, sshRunner, nativeArtifacts, remoteLauncher, registerPrp, ingressConnect } = vi.hoisted(() => ({ execute: vi.fn(), nativeProbe: vi.fn(), selectedRunner: vi.fn(), selectedRemoteRunner: vi.fn(), sshExecute: vi.fn(), sshRunner: vi.fn(), nativeArtifacts: vi.fn(), remoteLauncher: vi.fn(), registerPrp: vi.fn(), ingressConnect: vi.fn() })); +const nativeProbeRuntimeDirectories = vi.hoisted(() => [] as string[]); +vi.mock("node:fs/promises", async original => { + const filesystem = await original(); + return { ...filesystem, mkdtemp: async (...args: Parameters) => { + const directory = await filesystem.mkdtemp(...args); + if (typeof args[0] === "string" && args[0].endsWith("paperclip-native-setup-") && typeof directory === "string") nativeProbeRuntimeDirectories.push(directory); + return directory; + } }; +}); +vi.mock("@paperclipai/adapter-utils/execution-target", () => ({ runAdapterExecutionTargetShellCommand: execute })); +vi.mock("./native-ssh-command-runner.js", () => ({ createNativeSshCommandRunner: sshRunner })); +vi.mock("../../realtime/runner-prp-ws.js", () => ({ registerRunnerPrpAuthority: registerPrp })); +vi.mock("../../realtime/runner-prp-outbound.js", () => ({ connectRunnerPrpIngress: ingressConnect })); +vi.mock("./native-codex-runner.js", () => ({ resolvePaperclipRunnerBinary: selectedRunner, resolveRemotePaperclipRunnerBinary: selectedRemoteRunner })); +vi.mock("../../vendor/paperclip-runner/index.js", async original => ({ ...await original(), probeNativeRunnerEnvironment: nativeProbe })); +vi.mock("./native-session-executor.js", async original => ({ ...await original(), createRemoteNativeArtifactPreparation: nativeArtifacts, createRemoteRunnerProcessLauncher: remoteLauncher })); +import { testNativeRunnerAuthentication } from "./setup-readiness.js"; +const context = { + companyId: "company", adapterType: "paperclip_runner", config: { externallySandboxed: false, env: { PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "false" } }, + executionTarget: { kind: "remote" as const, transport: "sandbox" as const, providerKey: "test", remoteCwd: "/workspace", runner: { execute: vi.fn() } }, +}; +describe("Codex selected native account verification", () => { + const receipt = (provider: "codex", model: string | null) => ({ provider, effectiveModel: model, providerDriver: "codex_app_server", helloProbePassed: true, cleanupConfirmed: true }); + beforeEach(() => { + execute.mockReset(); nativeProbe.mockReset(); nativeProbeRuntimeDirectories.splice(0); + sshExecute.mockReset().mockResolvedValue({ exitCode: 0, timedOut: false, stdout: "", stderr: "" }); + sshRunner.mockReset().mockReturnValue({ execute: sshExecute }); + nativeArtifacts.mockReset().mockReturnValue({ prepare: vi.fn().mockResolvedValue(undefined) }); + remoteLauncher.mockReset().mockReturnValue(vi.fn()); + registerPrp.mockReset().mockImplementation(async () => ({ release: vi.fn() })); + vi.stubEnv("PAPERCLIP_RUNNER_PUBLIC_URL", "wss://paperclip.example.test"); + vi.stubEnv("PAPERCLIP_RUNNER_CA_BUNDLE_PATH", ""); + vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_CODEX_PATH", ""); + vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC", ""); + selectedRunner.mockReset().mockReturnValue("/fixture/bin/paperclip-runnerd"); + selectedRemoteRunner.mockReset().mockResolvedValue("/fixture/bin/paperclip-runnerd"); + vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH", ""); + vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_BINARY_PATH", ""); + }); + afterEach(async () => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + for (const root of nativeProbeRuntimeDirectories) await rm(root, { recursive: true, force: true }); + }); + it.each([["codex", "gpt-6.1-sol", "OPENAI_API_KEY"]] as const)("requires the native %s host and only the selected credential", async (provider, model, key) => { + vi.stubEnv(key, "ambient-account"); + vi.stubEnv("ANTHROPIC_API_KEY", "ambient-anthropic"); + nativeProbe.mockImplementation(async input => { await input.onCleanupConfirmed(); return receipt(provider, model); }); + const result = await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: { externallySandboxed: true, env: { [key]: "bound-account", PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "true" }, modelReasoningEffort: "low" } }, provider, model); + expect(result.status).toBe("pass"); + const input = nativeProbe.mock.calls[0][0]; + expect(input).toMatchObject({ provider, model, timeoutMs: 45_000, environment: { [key]: "bound-account" } }); + expect(input.transportOptions.runnerBinary).toBe("/fixture/bin/paperclip-runnerd"); + expect(input.transportOptions.externallySandboxed).toBe(false); + expect(selectedRunner).toHaveBeenCalledOnce(); + expect(input.environment.ANTHROPIC_API_KEY).toBeUndefined(); + if (provider === "codex") expect(input.reasoningEffort).toBe("low"); + else expect(input.reasoningEffort).toBeUndefined(); + await expect(stat(input.runtimeDirectory)).rejects.toThrow(); + expect(execute).not.toHaveBeenCalled(); + }); + it("rejects a missing local daemon before creating private credential staging", async () => { + const home = await realpath(await mkdtemp(join(tmpdir(), "native-preflight-daemon-"))); + const auth = '{"tokens":{"account_id":"fixture-account","access_token":"fixture-access"}}'; + const readCredential = vi.spyOn(localCredentialFile, "readLocalAiCredentialFile"); + try { + await writeFile(join(home, "account-store.json"), auth, { mode: 0o600 }); + await symlink(join(home, "account-store.json"), join(home, "auth.json")); + selectedRunner.mockImplementation(() => { throw new Error("native executable is missing"); }); + const result = await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", managedAiCredentialHome: home, config: {} }, "codex", "gpt-6.1-sol"); + expect(result.status).toBe("fail"); + expect(result.checks[0].message).toBe("native executable is missing"); + expect(readCredential).toHaveBeenCalledOnce(); + expect(nativeProbeRuntimeDirectories).toEqual([]); + expect(nativeProbe).not.toHaveBeenCalled(); + expect(await readFile(join(home, "account-store.json"), "utf8")).toBe(auth); + expect(await readlink(join(home, "auth.json"))).toBe(join(home, "account-store.json")); + expect((await readdir(home)).sort()).toEqual(["account-store.json", "auth.json"]); + } finally { readCredential.mockRestore(); await rm(home, { recursive: true, force: true }); } + }); + it.each(["local", "remote"])("cleans its own private root when %s credential staging fails before probe handoff", async transport => { + const home = await realpath(await mkdtemp(join(tmpdir(), "native-preflight-staging-"))); + const auth = '{"tokens":{"account_id":"fixture-account","access_token":"fixture-access"}}'; + try { + await writeFile(join(home, "account-store.json"), auth, { mode: 0o600 }); + await symlink(join(home, "account-store.json"), join(home, "auth.json")); + await writeFile(join(home, "config.toml"), "x".repeat(1024 * 1024 + 1), { mode: 0o600 }); + const selectedContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} }; + const result = await testNativeRunnerAuthentication({ ...selectedContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol"); + expect(result.status).toBe("fail"); + expect(result.checks[0].message).toContain("configuration exceeds its size limit"); + expect(nativeProbe).not.toHaveBeenCalled(); + expect(sshExecute).not.toHaveBeenCalled(); + expect(nativeProbeRuntimeDirectories).toHaveLength(1); + await expect(stat(nativeProbeRuntimeDirectories[0]!)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(join(home, "account-store.json"), "utf8")).toBe(auth); + expect((await stat(join(home, "config.toml"))).size).toBe(1024 * 1024 + 1); + expect(await readlink(join(home, "auth.json"))).toBe(join(home, "account-store.json")); + } finally { await rm(home, { recursive: true, force: true }); } + }); + it.each(["host-home", "host-codex-home", "configured-home", "managed-home"] as const)("reuses the task's authorized local Codex home before isolation (%s)", async mode => { + vi.stubEnv("HOME", "/host-home"); + vi.stubEnv("CODEX_HOME", mode === "host-home" ? "" : "/host-codex"); + vi.stubEnv("OPENAI_API_KEY", "ambient-key-is-not-authorized"); + nativeProbe.mockResolvedValue(receipt("codex", "observed-model")); + const config = mode === "configured-home" || mode === "managed-home" ? { env: { CODEX_HOME: "/configured-codex" } } : {}; + expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config, + ...(mode === "managed-home" ? { managedAiCredentialHome: "/managed-codex" } : {}) }, "codex", null)).status).toBe("pass"); + const input = nativeProbe.mock.calls[0][0]; + expect(input.transportOptions.sourceCodexHome).toBe({ "host-home": "/host-home/.codex", "host-codex-home": "/host-codex", "configured-home": "/configured-codex", "managed-home": "/managed-codex" }[mode]); + expect(input.environment.OPENAI_API_KEY).toBeUndefined(); + expect(input.environment.HOME).toBeUndefined(); + expect(input.runtimeDirectory).not.toContain("host-home"); + }); + it.each(["same-account", "another-account", "bound-key", "invalid-handoff"] as const)("preserves the server-selected login refresh before local cleanup (%s)", async mode => { + const home = await realpath(await mkdtemp(join(tmpdir(), "native-local-login-"))); + const auth = (account: string, marker: string, age: number) => JSON.stringify({ tokens: { account_id: account, id_token: `id-${marker}`, access_token: `access-${marker}`, refresh_token: `refresh-${marker}` }, last_refresh: new Date(Date.now() - age).toISOString() }); + const store = join(home, "account.json"); + try { + await writeFile(store, auth("qa-account", "old", 120_000), { mode: 0o600 }); + await symlink(store, join(home, "auth.json")); + nativeProbe.mockImplementation(async input => { + const privateHome = join(input.runtimeDirectory, "codex-home"); + await mkdir(privateHome, { mode: 0o700 }); + const filename = join(privateHome, "auth.json"); + await writeFile(filename, auth(mode === "another-account" ? "other-account" : "qa-account", "new", 60_000), { mode: 0o600 }); + if (mode === "bound-key") expect(input.onCodexCredentialRefresh).toBeUndefined(); + else await input.onCodexCredentialRefresh(mode === "invalid-handoff" ? store : filename); + await input.onCleanupConfirmed(); + return receipt("codex", "gpt-6.1-sol"); + }); + const result = await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", managedAiCredentialHome: home, config: { env: { CODEX_HOME: home, ...(mode === "bound-key" ? { OPENAI_API_KEY: "selected-key" } : {}) } } }, "codex", "gpt-6.1-sol"); + expect(result.status).toBe(mode === "invalid-handoff" ? "fail" : "pass"); + expect(JSON.parse(await readFile(store, "utf8")).tokens.access_token).toBe(mode === "same-account" ? "access-new" : "access-old"); + expect((await stat(store)).mode & 0o777).toBe(0o600); + expect(await realpath(join(home, "auth.json"))).toBe(store); + expect(JSON.stringify(result)).not.toContain("access-new"); + if (mode !== "invalid-handoff") await expect(stat(nativeProbe.mock.calls[0][0].runtimeDirectory)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { await rm(home, { recursive: true, force: true }); } + }); + const sshContext = { companyId: "company", adapterType: "paperclip_runner", config: {}, executionTarget: { + kind: "remote" as const, transport: "ssh" as const, remoteCwd: "/qa-workspace", spec: { host: "qa-host", username: "qa-user", port: 22, remoteCwd: "/qa-workspace", remoteWorkspacePath: "/qa-workspace", privateKey: null, knownHosts: null, strictHostKeyChecking: true }, + } }; + it.each(["local", "remote"].flatMap(transport => ["configured", "operator"].flatMap(source => [false, true].map(linked => ({ transport, source, linked }))))) + ("matches actual task auth seeding before $transport setup (source=$source, linked=$linked)", async ({ transport, source, linked }) => { + const home = await realpath(await mkdtemp(join(tmpdir(), "native-unbound-auth-"))); + const auth = '{"tokens":{"account_id":"fixture-unbound-account","access_token":"fixture-unbound-access","refresh_token":"fixture-unbound-refresh","id_token":"fixture-unbound-id"}}'; + try { + const stored = join(home, linked ? "account-store.json" : "auth.json"); + await writeFile(stored, auth, { mode: 0o600 }); + if (linked) await symlink(stored, join(home, "auth.json")); + const taskHome = join(home, "actual-task-home"); + await prepareIsolatedCodexHome({ context: null, codexHome: taskHome, sourceCodexHome: home }); + const taskAuth = await readFile(join(taskHome, "auth.json"), "utf8").catch(() => null); + expect(taskAuth).toBe(linked ? null : auth); + vi.stubEnv("CODEX_HOME", source === "operator" ? home : ""); + nativeProbe.mockImplementation(async input => { + const probeHome = join(input.runtimeDirectory, "codex-home"); + await prepareIsolatedCodexHome({ context: null, codexHome: probeHome, sourceCodexHome: input.transportOptions.sourceCodexHome }); + expect(await readFile(join(probeHome, "auth.json"), "utf8")).toBe(auth); + await input.onCleanupConfirmed(); + return receipt("codex", "gpt-6.1-sol"); + }); + const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} }; + const result = await testNativeRunnerAuthentication({ ...testContext, config: { + managedAiCredentialHome: home, + env: source === "configured" ? { CODEX_HOME: home } : {}, + } }, "codex", "gpt-6.1-sol"); + expect(result.status).toBe(linked ? "fail" : "pass"); + if (linked) { + expect(nativeProbe).not.toHaveBeenCalled(); + expect(sshExecute).not.toHaveBeenCalled(); + expect(result.checks[0].message).toContain(source === "configured" ? "env.CODEX_HOME" : "operator CODEX_HOME"); + expect(result.checks[0].message).toContain("Legacy runner"); + expect(result.checks[0].message).toContain("managed Codex Connection"); + } else expect(nativeProbe).toHaveBeenCalledOnce(); + expect(await readFile(stored, "utf8")).toBe(auth); + expect(JSON.stringify(result)).not.toContain("fixture-unbound-access"); + } finally { await rm(home, { recursive: true, force: true }); } + }); + it.each(["local", "remote"].flatMap(transport => ["OPENAI_API_KEY", "CODEX_API_KEY"].flatMap(key => ["valid", "dangling", "invalid-store", "probe-error"].map(authState => ({ transport, key, authState }))))) + ("keeps the configured $key task credential authoritative over $authState linked auth during $transport setup", async ({ transport, key, authState }) => { + const home = await realpath(await mkdtemp(join(tmpdir(), "native-key-linked-auth-"))); + const auth = '{"tokens":{"account_id":"unused-subscription","access_token":"unused-subscription-access"}}'; + const stored = join(home, "account-store.json"); + const readCredential = vi.spyOn(localCredentialFile, "readLocalAiCredentialFile"); + const copyBack = vi.spyOn(codexCredentials, "copyBackCodexAuth"); + try { + if (authState === "valid" || authState === "probe-error") await writeFile(stored, auth, { mode: 0o600 }); + else if (authState === "invalid-store") await mkdir(stored, { mode: 0o700 }); + await symlink(stored, join(home, "auth.json")); + const taskHome = join(home, "actual-key-task-home"); + await prepareIsolatedCodexHome({ context: null, codexHome: taskHome, sourceCodexHome: home, apiKey: "configured-fixture-key" }); + expect(JSON.parse(await readFile(join(taskHome, "auth.json"), "utf8"))).toEqual({ OPENAI_API_KEY: "configured-fixture-key" }); + nativeProbe.mockImplementation(async input => { + expect(input.onCodexCredentialRefresh).toBeUndefined(); + expect(input.transportOptions.sourceCodexHome).toBe(home); + const probeHome = join(input.runtimeDirectory, "codex-home"); + await prepareIsolatedCodexHome({ context: null, codexHome: probeHome, sourceCodexHome: input.transportOptions.sourceCodexHome, + apiKey: input.environment.CODEX_API_KEY ?? input.environment.OPENAI_API_KEY }); + expect(JSON.parse(await readFile(join(probeHome, "auth.json"), "utf8"))).toEqual({ OPENAI_API_KEY: "configured-fixture-key" }); + await input.onCleanupConfirmed(); + if (authState === "probe-error") throw new Error("authentication failed: configured-fixture-key"); + return receipt("codex", "gpt-6.1-sol"); + }); + const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} }; + const result = await testNativeRunnerAuthentication({ ...testContext, config: { env: { CODEX_HOME: home, [key]: "configured-fixture-key" } } }, "codex", "gpt-6.1-sol"); + expect(result.status).toBe(authState === "probe-error" ? "fail" : "pass"); + if (authState === "probe-error") expect(result.checks[0]).toMatchObject({ code: "codex_hello_probe_auth_required", message: "authentication failed: [REDACTED]" }); + expect(nativeProbe).toHaveBeenCalledOnce(); + expect(readCredential).not.toHaveBeenCalled(); + expect(copyBack).not.toHaveBeenCalled(); + expect((await lstat(join(home, "auth.json"))).isSymbolicLink()).toBe(true); + expect(await readlink(join(home, "auth.json"))).toBe(stored); + if (authState === "valid" || authState === "probe-error") expect(await readFile(stored, "utf8")).toBe(auth); + else if (authState === "invalid-store") expect(await readdir(stored)).toEqual([]); + else await expect(lstat(stored)).rejects.toMatchObject({ code: "ENOENT" }); + expect(JSON.stringify(result)).not.toContain("configured-fixture-key"); + } finally { readCredential.mockRestore(); copyBack.mockRestore(); await rm(home, { recursive: true, force: true }); } + }); + it.each(["local", "remote"].flatMap(transport => [false, true].map(linked => ({ transport, linked })))) + ("seeds the selected subscription before isolated $transport setup (linked=$linked)", async ({ transport, linked }) => { + const home = await realpath(await mkdtemp(join(tmpdir(), "native-selected-auth-"))); + const auth = JSON.stringify({ tokens: { account_id: "fixture-account", id_token: "fixture-id", access_token: "fixture-access", refresh_token: "fixture-refresh" }, last_refresh: "2026-01-01T00:00:00.000Z" }); + const stored = join(home, linked ? "account-store.json" : "auth.json"); + let seeded = false; + try { + await writeFile(stored, auth, { mode: 0o600 }); + if (linked) await symlink(stored, join(home, "auth.json")); + await writeFile(join(home, "config.toml"), 'cli_auth_credentials_store = "file"\n[mcp_servers.host_only]\ncommand = "untrusted-host-tool"\n', { mode: 0o600 }); + await writeFile(join(home, "unassigned-private-file"), "must-not-copy"); + nativeProbe.mockImplementation(async input => { + expect(input.environment._PAPERCLIP_NATIVE_SETUP_CODEX_AUTH_JSON_SECRET).toBeUndefined(); + const codexHome = join(input.runtimeDirectory, "codex-home"); + await prepareIsolatedCodexHome({ context: null, codexHome, sourceCodexHome: input.transportOptions.sourceCodexHome, + apiKey: input.environment.CODEX_API_KEY ?? input.environment.OPENAI_API_KEY }); + const privateAuth = await readFile(join(codexHome, "auth.json"), "utf8").catch(() => null); + seeded = privateAuth === auth; + expect((await readFile(join(codexHome, "config.toml"), "utf8"))).not.toContain("untrusted-host-tool"); + if (!seeded) throw new Error("authentication required"); + if (linked) { + const source = join(input.runtimeDirectory, "codex-credential-source"); + expect(input.transportOptions.sourceCodexHome).toBe(source); + expect((await stat(source)).mode & 0o777).toBe(0o700); + expect((await stat(join(source, "auth.json"))).mode & 0o777).toBe(0o600); + expect((await readdir(source)).sort()).toEqual(["auth.json", "config.toml"]); + } + if (transport === "remote") { + await remoteLauncher.mock.calls[0][0].ensureArtifact(); + const upload = sshExecute.mock.calls.find(([value]) => value.args?.[1]?.includes("/codex-home/auth.json"))?.[0]; + expect(upload).toBeDefined(); + expect(Buffer.from(upload!.stdin, "base64").toString("utf8")).toBe(auth); + expect(upload!.args[1]).toContain("chmod 600"); + } + await input.onCleanupConfirmed(); + return receipt("codex", "gpt-6.1-sol"); + }); + const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} }; + const result = await testNativeRunnerAuthentication({ ...testContext, config: { env: { CODEX_HOME: "/untrusted-configured-home", _PAPERCLIP_NATIVE_SETUP_CODEX_AUTH_JSON_SECRET: "spoofed-auth" } }, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol"); + expect(result.status).toBe("pass"); + expect(seeded).toBe(true); + expect(await readFile(stored, "utf8")).toBe(auth); + expect(JSON.stringify(result)).not.toContain("fixture-access"); + await expect(stat(nativeProbe.mock.calls[0][0].runtimeDirectory)).rejects.toMatchObject({ code: "ENOENT" }); + expect(execute).not.toHaveBeenCalled(); + } finally { await rm(home, { recursive: true, force: true }); } + }); + it.each(["local", "remote"].flatMap(transport => ["dangling", "foreign-owner", "unsafe-mode", "oversize"].map(mode => ({ transport, mode })))) + ("rejects $mode linked subscription before $transport provider admission", async ({ transport, mode }) => { + const home = await realpath(await mkdtemp(join(tmpdir(), "native-invalid-link-"))); + const stored = join(home, "account-store.json"); + let owner: ReturnType | undefined; + try { + if (mode !== "dangling") await writeFile(stored, mode === "oversize" ? "x".repeat(65_537) : '{"tokens":{"account_id":"fixture-account"}}', { mode: 0o600 }); + await symlink(stored, join(home, "auth.json")); + if (mode === "unsafe-mode") await chmod(stored, 0o644); + if (mode === "foreign-owner") owner = vi.spyOn(process, "getuid").mockReturnValue(process.getuid!() + 1); + nativeProbe.mockResolvedValue(receipt("codex", "gpt-6.1-sol")); + const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} }; + const result = await testNativeRunnerAuthentication({ ...testContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol"); + expect(result.status).toBe("fail"); + expect(nativeProbe).not.toHaveBeenCalled(); + expect(sshExecute).not.toHaveBeenCalled(); + if (mode === "dangling") expect(result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)).toBe(true); + } finally { owner?.mockRestore(); await rm(home, { recursive: true, force: true }); } + }); + it.each(["local", "remote"])("redacts checked file credentials from %s native probe errors without exporting them to env", async transport => { + const home = await realpath(await mkdtemp(join(tmpdir(), "native-file-redaction-"))); + const auth = JSON.stringify({ tokens: { account_id: "fixture-account", id_token: "fixture-id", access_token: "fixture-access", refresh_token: "fixture-refresh" } }); + try { + await writeFile(join(home, "account-store.json"), auth, { mode: 0o600 }); + await symlink(join(home, "account-store.json"), join(home, "auth.json")); + nativeProbe.mockImplementation(async input => { + expect(input.environment._PAPERCLIP_NATIVE_SETUP_CODEX_AUTH_JSON_SECRET).toBeUndefined(); + throw new Error("authentication failed: " + auth + " fixture-access fixture-refresh"); + }); + const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} }; + const result = await testNativeRunnerAuthentication({ ...testContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol"); + expect(result.status).toBe("fail"); + expect(JSON.stringify(result)).not.toMatch(/fixture-account|fixture-access|fixture-refresh|fixture-id/); + expect(result.checks[0].message).toContain("[REDACTED]"); + } finally { await rm(home, { recursive: true, force: true }); } + }); + it.each(["local", "remote"].flatMap(transport => [false, true].map(confirmed => ({ transport, confirmed })))) + ("settles private linked credentials only after confirmed canceled $transport setup (confirmed=$confirmed)", async ({ transport, confirmed }) => { + const home = await realpath(await mkdtemp(join(tmpdir(), "native-linked-cancel-"))); + const auth = '{"tokens":{"account_id":"fixture-account","access_token":"fixture-access"}}'; + try { + await writeFile(join(home, "account-store.json"), auth, { mode: 0o600 }); + await symlink(join(home, "account-store.json"), join(home, "auth.json")); + nativeProbe.mockImplementation(async input => { + expect(await readFile(join(input.transportOptions.sourceCodexHome, "auth.json"), "utf8")).toBe(auth); + if (confirmed) await input.onCleanupConfirmed(); + throw new Error("Native provider setup canceled"); + }); + const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} }; + expect((await testNativeRunnerAuthentication({ ...testContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol")).status).toBe("fail"); + const root = nativeProbe.mock.calls[0][0].runtimeDirectory; + if (confirmed) await expect(stat(root)).rejects.toMatchObject({ code: "ENOENT" }); + else expect(await readFile(join(root, "codex-credential-source", "auth.json"), "utf8")).toBe(auth); + expect(await readFile(join(home, "account-store.json"), "utf8")).toBe(auth); + } finally { await rm(home, { recursive: true, force: true }); } + }); + it.each(["linux-arm64", "linux-x64"])("uses task staging and the selected %s SSH daemon without a provider pack", async target => { + const selectedBinary = `/fixture/bin/${target}/paperclip-runnerd`; + selectedRemoteRunner.mockResolvedValue(selectedBinary); + let released = false; + registerPrp.mockResolvedValue({ release: async () => { released = true; } }); + nativeProbe.mockImplementation(async input => { + const registration = await input.transportOptions.controlPlaneRegistration({ marker: "native-authority" }); + expect(registration.connection).toMatchObject({ mode: "connect", connectUrl: expect.stringMatching(/^wss:\/\/paperclip\.example\.test\/api\/runner\/v1\/connect\//) }); + await registration.release(); + await input.onCleanupConfirmed(); + return receipt("codex", "gpt-6.1-sol"); + }); + expect((await testNativeRunnerAuthentication({ ...sshContext, config: { externallySandboxed: true, env: { OPENAI_API_KEY: "company-account", PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "true" }, modelReasoningEffort: "low" } }, "codex", "gpt-6.1-sol")).status).toBe("pass"); + const input = nativeProbe.mock.calls[0][0]; + expect(input).toMatchObject({ reasoningEffort: "low", environment: { OPENAI_API_KEY: "company-account" }, workingDirectory: expect.stringMatching(/^\/qa-workspace\/.paperclip-runtime\/paperclip-native-setup-.*\/filesystem\/workspace$/) }); + expect(input.transportOptions).toMatchObject({ runnerBinary: selectedBinary, codexCommand: expect.stringMatching(/\/bin\/codex$/), runnerFilesystemRoot: expect.stringMatching(/\/filesystem$/), externallySandboxed: false }); + expect(nativeArtifacts).toHaveBeenCalledWith(expect.objectContaining({ controllerRunnerBinary: selectedBinary, model: "gpt-6.1-sol", targetPlatformArtifactVerified: true })); + expect(selectedRemoteRunner).toHaveBeenCalledExactlyOnceWith({ runner: { execute: sshExecute }, remoteCwd: "/qa-workspace", configuredPath: null }); + expect(selectedRunner).not.toHaveBeenCalled(); + expect(nativeArtifacts.mock.results[0]!.value.prepare).toHaveBeenCalledWith("dial_wss"); + expect(registerPrp).toHaveBeenCalledWith({ companyId: "company", runId: input.transportOptions.prpIdentity.runId, authority: { marker: "native-authority" } }); + expect(remoteLauncher).toHaveBeenCalledWith(expect.objectContaining({ runnerInstanceId: input.transportOptions.prpIdentity.runnerInstanceId })); + expect(released).toBe(true); + expect(sshExecute.mock.calls.at(-1)![0]).toMatchObject({ command: "rm", args: ["-rf", "--", expect.stringContaining("paperclip-native-setup-")] }); + expect(sshExecute.mock.calls.some(([value]) => JSON.stringify(value).includes("company-account"))).toBe(false); + expect(execute).not.toHaveBeenCalled(); + }); + it.each(["runtime missing", "authentication failed: selected-key", "no native completion", "model mismatch"])("fails SSH native setup without a legacy fallback (%s)", async message => { + nativeProbe.mockRejectedValue(new Error(message)); + const result = await testNativeRunnerAuthentication({ ...sshContext, config: { env: { OPENAI_API_KEY: "selected-key" } } }, "codex", "gpt-6.1-sol"); + expect(result.status).toBe("fail"); expect(JSON.stringify(result)).not.toContain("selected-key"); + expect(nativeProbe).toHaveBeenCalledOnce(); expect(execute).not.toHaveBeenCalled(); + expect(sshExecute.mock.calls.some(([value]) => value.command === "rm")).toBe(false); + expect((await stat(nativeProbe.mock.calls[0][0].runtimeDirectory)).isDirectory()).toBe(true); + }); + it("fails SSH transport eligibility before staging or opening the native provider", async () => { + vi.stubEnv("PAPERCLIP_RUNNER_PUBLIC_URL", ""); + expect((await testNativeRunnerAuthentication(sshContext, "codex", null)).status).toBe("fail"); + expect(sshExecute).not.toHaveBeenCalled(); expect(nativeArtifacts).not.toHaveBeenCalled(); expect(nativeProbe).not.toHaveBeenCalled(); + }); + it("reports an unavailable SSH target daemon before probing credentials or switching runtimes", async () => { + selectedRemoteRunner.mockRejectedValue(new Error("runner_remote_artifact_unavailable: install the matching linux-arm64 Paperclip release")); + const result = await testNativeRunnerAuthentication(sshContext, "codex", null); + expect(result.status).toBe("fail"); expect(result.checks[0].message).toContain("matching linux-arm64 Paperclip release"); + expect(nativeArtifacts).not.toHaveBeenCalled(); expect(nativeProbe).not.toHaveBeenCalled(); + expect(selectedRunner).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled(); + }); + it.each([true, false])("removes SSH probe state only after confirmed teardown (%s)", async confirmed => { + nativeProbe.mockImplementation(async input => { + const registration = await input.transportOptions.controlPlaneRegistration({}); + await registration.release(); + if (confirmed) await input.onCleanupConfirmed(); + throw new Error("authentication failed"); + }); + expect((await testNativeRunnerAuthentication(sshContext, "codex", null)).status).toBe("fail"); + expect(sshExecute.mock.calls.some(([value]) => value.command === "rm")).toBe(confirmed); + const root = nativeProbe.mock.calls[0][0].runtimeDirectory; + if (confirmed) await expect(stat(root)).rejects.toThrow(); else expect((await stat(root)).isDirectory()).toBe(true); + }); + it.each(["same-account", "another-account", "read-failure", "uncertain-close"] as const)("preserves managed SSH account refresh and confirmed cleanup (%s)", async mode => { + const home = await mkdtemp(join(tmpdir(), "native-ssh-managed-account-")); + const auth = (account: string, marker: string, age: number) => JSON.stringify({ tokens: { account_id: account, id_token: `id-${marker}`, access_token: `access-${marker}`, refresh_token: `refresh-${marker}` }, last_refresh: new Date(Date.now() - age).toISOString() }); + try { + await writeFile(join(home, "account-store.json"), auth("qa-account", "old", 120_000), { mode: 0o600 }); + await symlink(join(home, "account-store.json"), join(home, "auth.json")); + const refreshed = auth(mode === "another-account" ? "different-account" : "qa-account", "new", 60_000); + sshExecute.mockImplementation(async input => ({ exitCode: input.args?.[2] === "paperclip-native-refresh" && mode === "read-failure" ? 1 : 0, timedOut: false, + stdout: input.args?.[2] === "paperclip-native-refresh" ? Buffer.from(refreshed).toString("base64") : "", stderr: "" })); + nativeProbe.mockImplementation(async input => { + const registration = await input.transportOptions.controlPlaneRegistration({}); + await registration.release(); + const original = new Error("authentication failed in native SSH" + (mode === "uncertain-close" ? "; daemon teardown was not confirmed" : "")); + try { await input.onCodexCredentialRefresh(posix.join(input.transportOptions.runnerFilesystemRoot, "codex-home", "auth.json")); } + catch (error) { throw new AggregateError([original, error], original.message + "; " + (error as Error).message, { cause: original }); } + if (mode !== "uncertain-close") await input.onCleanupConfirmed(); + throw original; + }); + const result = await testNativeRunnerAuthentication({ ...sshContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol"); + expect(result.status).toBe("fail"); expect(result.checks[0].message).toContain("authentication failed in native SSH"); + expect(JSON.parse(await readFile(join(home, "auth.json"), "utf8")).tokens.access_token).toBe(mode === "same-account" || mode === "uncertain-close" ? "access-new" : "access-old"); + expect(sshExecute.mock.calls.some(([input]) => input.command === "rm")).toBe(mode === "same-account" || mode === "another-account"); + expect(JSON.stringify(result)).not.toContain("access-new"); + const read = sshExecute.mock.calls.find(([input]) => input.args?.[2] === "paperclip-native-refresh")![0]; + expect(read.command).toBe("sh"); expect(read.args[1]).toContain("head -c 65537"); expect(read.args[1]).toContain("test ! -L"); + expect(nativeProbe.mock.calls[0][0].transportOptions.sourceCodexHome).toBe(join(nativeProbe.mock.calls[0][0].runtimeDirectory, "codex-credential-source")); + if (mode === "read-failure") expect(result.checks[0].message).toContain("refresh handoff unavailable"); + if (mode === "uncertain-close") expect(result.checks[0].message).toContain("teardown was not confirmed"); + } finally { await rm(home, { recursive: true, force: true }); } + }); + it.each([ + ["authentication failed: bound-account", "auth_required"], ["Native provider hello probe timed out.", "timeout"], + ["Model is unavailable", "failed"], ["native executable is missing", "failed"], ["native provider returned no response", "failed"], + ])("fails closed and redacts native failures (%s)", async (message, code) => { + nativeProbe.mockRejectedValue(new Error(message)); + const result = await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: { env: { OPENAI_API_KEY: "bound-account" } } }, "codex", "gpt-6.1-sol"); + expect(result.status).toBe("fail"); + expect(result.checks[0]).toMatchObject({ code: `codex_hello_probe_${code}`, level: "error" }); + expect(result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)).toBe(code === "auth_required"); + expect(JSON.stringify(result)).not.toContain("bound-account"); + expect(nativeProbe).toHaveBeenCalledOnce(); + expect(execute).not.toHaveBeenCalled(); + }); + it.each([ + ["authentication required: selected-account", true], + ["Model is unavailable", false], + ["native executable is missing", false], + ])("projects the canonical sandbox login check only for authentication failures (%s)", async (message, authentication) => { + nativeProbe.mockRejectedValue(new Error(message)); + const result = await testNativeRunnerAuthentication(context, "codex", "gpt-6.1-sol"); + expect(result.status).toBe("fail"); + expect(result.checks[0].code).toBe(`codex_hello_probe_${authentication ? "auth_required" : "failed"}`); + expect(result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)).toBe(authentication); + if (authentication) expect(result.checks).toContainEqual(expect.objectContaining({ code: ADAPTER_AUTH_MISSING_CHECK_CODE, level: "error" })); + expect(nativeProbe).toHaveBeenCalledOnce(); + expect(execute).not.toHaveBeenCalled(); + }); + it.each([ + { helloProbePassed: undefined }, { effectiveModel: "another-model" }, { effectiveModel: null }, { effectiveModel: "" }, { provider: "opencode" }, { providerDriver: "codex_cli" }, + ])("rejects installation-only, legacy, or mismatched receipts", async override => { + nativeProbe.mockResolvedValue({ ...receipt("codex", "gpt-6.1-sol"), ...override }); + expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: {} }, "codex", "gpt-6.1-sol")).status).toBe("fail"); + }); + it("accepts an automatic Codex model only when the native provider observed a nonempty model", async () => { + nativeProbe.mockResolvedValue(receipt("codex", "gpt-6.1-sol")); + expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: {} }, "codex", null)).status).toBe("pass"); + expect(nativeProbe.mock.calls[0][0].model).toBeNull(); + nativeProbe.mockResolvedValue(receipt("codex", null)); + expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: {} }, "codex", null)).status).toBe("fail"); + nativeProbe.mockResolvedValue(receipt("codex", "unknown")); + expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: {} }, "codex", null)).status).toBe("fail"); + }); + it.each([true, false])("removes failed local probe state only after confirmed teardown (%s)", async confirmed => { + nativeProbe.mockImplementation(async input => { + if (confirmed) await input.onCleanupConfirmed(); + throw new Error("authentication failed"); + }); + expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: {} }, "codex", null)).status).toBe("fail"); + const root = nativeProbe.mock.calls[0][0].runtimeDirectory; + if (confirmed) await expect(stat(root)).rejects.toThrow(); + else expect((await stat(root)).isDirectory()).toBe(true); + }); + it.each(["arm64", "x64"].flatMap(controllerArch => ["arm64", "x64"].flatMap(targetArch => [false, true].map(explicit => ({ controllerArch, targetArch, explicit }))))) + ("uses target $targetArch artifacts and controller PRP from Mac $controllerArch for sandbox setup (override=$explicit)", async ({ controllerArch, targetArch, explicit }) => { + vi.stubGlobal("process", { ...process, platform: "darwin", arch: controllerArch }); + const selectedBinary = `/controller/artifacts/linux-${targetArch}/paperclip-runnerd`; + selectedRemoteRunner.mockResolvedValue(selectedBinary); + if (explicit) vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_BINARY_PATH", selectedBinary); + nativeProbe.mockImplementation(async input => { + const registration = await input.transportOptions.controlPlaneRegistration({}); + expect(registration.connection.mode).toBe("connect"); + await registration.release(); await input.onCleanupConfirmed(); + return receipt("codex", "gpt-6.1-sol"); + }); + const target = { ...context.executionTarget, runner: { execute: sshExecute } }; + expect((await testNativeRunnerAuthentication({ ...context, executionTarget: target }, "codex", "gpt-6.1-sol")).status).toBe("pass"); + expect(sshRunner).not.toHaveBeenCalled(); + expect(selectedRemoteRunner).toHaveBeenCalledExactlyOnceWith({ runner: target.runner, remoteCwd: target.remoteCwd, configuredPath: explicit ? selectedBinary : null }); + expect(selectedRunner).not.toHaveBeenCalled(); + expect(nativeArtifacts).toHaveBeenCalledWith(expect.objectContaining({ target, runner: target.runner, controllerRunnerBinary: selectedBinary, targetPlatformArtifactVerified: true })); + expect(nativeProbe.mock.calls[0][0].transportOptions.runnerBinary).toBe(selectedBinary); + expect(nativeProbe.mock.calls[0][0].transportOptions.externallySandboxed).toBe(true); + expect(nativeArtifacts.mock.results[0].value.prepare).toHaveBeenCalledWith("dial_wss"); + expect(execute).not.toHaveBeenCalled(); + }); + it.each(["runner_remote_artifact_unavailable", "runner_remote_artifact_platform_mismatch"])("fails sandbox %s before setup or a controller fallback", async code => { + selectedRemoteRunner.mockRejectedValue(new Error(code)); + const target = { ...context.executionTarget, runner: { execute: sshExecute } }; + const result = await testNativeRunnerAuthentication({ ...context, executionTarget: target }, "codex", "gpt-6.1-sol"); + expect(result.status).toBe("fail"); + expect(result.checks[0].message).toContain(code); + expect(selectedRunner).not.toHaveBeenCalled(); expect(nativeArtifacts).not.toHaveBeenCalled(); + expect(nativeProbe).not.toHaveBeenCalled(); expect(sshExecute).not.toHaveBeenCalled(); + expect(execute).not.toHaveBeenCalled(); + }); + it("prepares listener artifacts before acquiring sandbox ingress and owns its release", async () => { + const order: string[] = []; + const close = vi.fn(); + const endpoint = { kind: "authenticated_websocket" as const, websocketUrl: "wss://ingress.test", secretHeaders: [], generation: "generation", refresh: vi.fn(), close }; + const getRunnerIngressEndpoint = vi.fn(async () => { order.push("ingress"); return endpoint; }); + nativeArtifacts.mockReturnValue({ prepare: vi.fn(async mode => { expect(mode).toBe("listen_ws"); order.push("artifacts"); }) }); + const connectionClose = vi.fn(); + ingressConnect.mockImplementation(() => { order.push("connect"); return { ready: Promise.resolve(), close: connectionClose }; }); + nativeProbe.mockImplementation(async input => { + const registration = await input.transportOptions.controlPlaneRegistration({}); + expect(registration.connection.mode).toBe("listen"); + remoteLauncher.mock.calls[0][0].onRunnerProcessSpawned(); + await registration.activate(); await registration.ready(); await registration.release(); + await input.onCleanupConfirmed(); return receipt("codex", "gpt-6.1-sol"); + }); + const target = { ...context.executionTarget, providerKey: "daytona", runner: { execute: sshExecute }, leaseId: "lease", effectiveCapabilities: { reusableLeases: false, nativeSyncIn: false, nativeSyncOut: false, persistentProcessSessions: false, independentControlCommands: true, incrementalSessionOutput: false, concurrentSyncOperations: false, duplexCommandStream: false, runnerWebSocketIngress: true }, getRunnerIngressEndpoint }; + expect((await testNativeRunnerAuthentication({ ...context, executionTarget: target }, "codex", "gpt-6.1-sol")).status).toBe("pass"); + expect(order).toEqual(["artifacts", "ingress", "connect"]); + expect(nativeProbe.mock.calls[0][0].transportOptions.externallySandboxed).toBe(true); + expect(connectionClose).toHaveBeenCalledOnce(); expect(registerPrp).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/services/native-runtime/setup-readiness.ts b/server/src/services/native-runtime/setup-readiness.ts new file mode 100644 index 00000000000..b78da1acb1a --- /dev/null +++ b/server/src/services/native-runtime/setup-readiness.ts @@ -0,0 +1,275 @@ +import { constants } from "node:fs"; +import { lstat, mkdir, mkdtemp, open, realpath, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, posix } from "node:path"; +import { copyBackCodexAuth } from "@paperclipai/adapter-codex-local/server"; +import type { AdapterEnvironmentTestContext, AdapterEnvironmentTestResult } from "@paperclipai/adapter-utils"; +import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared"; +import { probeNativeRunnerEnvironment, resolveSourceCodexHome } from "../../vendor/paperclip-runner/index.js"; +import { resolvePaperclipRunnerBinary, resolveRemotePaperclipRunnerBinary } from "./native-codex-runner.js"; +import { readLocalAiCredentialFile } from "../local-ai-credential-file.js"; +import { createNativeSshCommandRunner } from "./native-ssh-command-runner.js"; +import { resolvePaperclipRunnerTransport } from "@paperclipai/adapter-utils/runner-connectivity"; +import { registerRunnerPrpAuthority } from "../../realtime/runner-prp-ws.js"; +import { connectRunnerPrpIngress } from "../../realtime/runner-prp-outbound.js"; + + +const PROBE_TRANSPORT_ENV_KEYS = ["PATH", "LANG", "LANGUAGE", "TZ", "TMPDIR", "TEMP", "TMP", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "no_proxy", "all_proxy"]; + +function redactNativeProbeMessage(message: string, environment: Record): string { + const redact = (value: unknown): void => { + if (typeof value === "string" && value) message = message.replaceAll(value, "[REDACTED]"); + else if (value && typeof value === "object") for (const item of Object.values(value)) redact(item); + }; + for (const [name, value] of Object.entries(environment)) { + if (!/key|token|secret|password/i.test(name) || !value) continue; + redact(value); + try { redact(JSON.parse(value)); } catch { /* Most credentials are plain strings. */ } + } + return message.slice(0, 2000); +} + +async function prepareProbeCodexSource(runtimeDirectory: string, sourceCodexHome: string, authJson?: string): Promise { + if (authJson === undefined) return sourceCodexHome; + let config: Buffer | undefined; + const source = await open(join(sourceCodexHome, "config.toml"), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK) + .catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT" || error.code === "ELOOP") return null; throw error; }); + if (source) { + try { + const metadata = await source.stat(); + if (metadata.isFile()) { + const bytes = Buffer.alloc(1024 * 1024 + 1); + let size = 0; + while (size < bytes.length) { + const result = await source.read(bytes, size, bytes.length - size, size); + if (!result.bytesRead) break; + size += result.bytesRead; + } + if (size > 1024 * 1024) throw new Error("The selected Codex configuration exceeds its size limit."); + config = bytes.subarray(0, size); + } + } finally { await source.close(); } + } + const credentialSource = join(runtimeDirectory, "codex-credential-source"); + let created = false; + try { + await mkdir(credentialSource, { mode: 0o700 }); + created = true; + await writeFile(join(credentialSource, "auth.json"), authJson, { mode: 0o600, flag: "wx" }); + if (config) await writeFile(join(credentialSource, "config.toml"), config, { mode: 0o600, flag: "wx" }); + return credentialSource; + } catch (error) { + if (created) await rm(credentialSource, { recursive: true, force: true }); + throw error; + } +} + +async function probePreparedNativeRuntime(runtimeDirectory: string, prepare: () => Promise[0]>) { + let handedOff = false; + try { + const options = await prepare(); + handedOff = true; + return await probeNativeRunnerEnvironment(options); + } catch (error) { + if (!handedOff) { + try { await rm(runtimeDirectory, { recursive: true, force: true }); } + catch (cleanupError) { + const message = error instanceof Error ? error.message : "Native setup preparation failed."; + throw new AggregateError([error, cleanupError], `${message}; Native setup preparation cleanup is incomplete.`, { cause: error }); + } + } + throw error; + } +} + +async function probeRemoteNativeCodex(options: { + context: AdapterEnvironmentTestContext; + model: string | null; + reasoningEffort?: string; + environment: Record; + sourceCodexHome: string; + credentialRefreshAuthPath?: string; + sourceAuthJson?: string; + timeoutMs: number; +}) { + const target = options.context.executionTarget; + if (target?.kind !== "remote") throw new Error("Native setup requires the selected remote execution target."); + const { createRemoteNativeArtifactPreparation, createRemoteRunnerProcessLauncher, stageRemoteRunnerFile, readRemoteRunnerState } = await import("./native-session-executor.js"); + const runner = target.transport === "ssh" ? createNativeSshCommandRunner({ spec: target.spec, defaultCwd: target.remoteCwd }) : target.runner; + if (!runner) throw new Error("runner_transport_ineligible: remote process runner is unavailable"); + const id = crypto.randomUUID(); + const runnerInstanceId = `setup-${id}`; + const ingress = target.transport === "sandbox" && target.effectiveCapabilities?.runnerWebSocketIngress === true; + const outbound = !ingress ? await resolvePaperclipRunnerTransport({ target, runId: id, localConnectUrl: "ws://127.0.0.1/unused", + runnerPublicUrl: process.env.PAPERCLIP_RUNNER_PUBLIC_URL?.trim() || null, + runnerCaBundlePath: process.env.PAPERCLIP_RUNNER_CA_BUNDLE_PATH?.trim() || null, runnerIngressAuthorized: true }) : null; + if (outbound && outbound.mode !== "direct_outbound") throw new Error("runner_transport_ineligible: expected outbound native transport"); + if (outbound?.mode === "direct_outbound" && outbound.caBundlePath) await stat(outbound.caBundlePath).catch(() => { throw new Error("runner_direct_wss_failed: configured runner CA bundle is unavailable"); }); + const remoteRoot = posix.join(target.remoteCwd, ".paperclip-runtime", `paperclip-native-setup-${id}`); + const remoteBinary = posix.join(remoteRoot, "bin", "paperclip-runnerd"); + const remoteStateDirectory = posix.join(remoteRoot, "runner"); + const remoteFilesystemRoot = posix.join(remoteRoot, "filesystem"); + const remoteWorkspace = posix.join(remoteFilesystemRoot, "workspace"); + const remoteCodexNpmSpec = process.env.PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC?.trim() || null; + const remoteCodexBinary = remoteCodexNpmSpec ? posix.join(remoteRoot, "harnesses", "codex", "node_modules", ".bin", "codex") : posix.join(remoteRoot, "bin", "codex"); + const explicitRunner = process.env.PAPERCLIP_RUNNER_REMOTE_BINARY_PATH?.trim() || null; + const controllerRunnerBinary = await resolveRemotePaperclipRunnerBinary({ runner, remoteCwd: target.remoteCwd, configuredPath: explicitRunner }); + const artifacts = createRemoteNativeArtifactPreparation({ target, runner, remoteBinary, controllerRunnerBinary, remoteRuntimeRoot: remoteRoot, remoteCodexBinary, + targetPlatformArtifactVerified: true, + runnerRemoteBinaryPath: explicitRunner, runnerRemoteCodexPath: process.env.PAPERCLIP_RUNNER_REMOTE_CODEX_PATH?.trim(), runnerRemoteCodexNpmSpec: remoteCodexNpmSpec, model: options.model }); + const requireSuccess = (result: { timedOut: boolean; exitCode: number | null }, message: string) => { + if (result.timedOut || result.exitCode !== 0) throw new Error(message); + }; + const runtimeDirectory = await realpath(await mkdtemp(join(tmpdir(), "paperclip-native-setup-"))); + return probePreparedNativeRuntime(runtimeDirectory, async () => { + const sourceCodexHome = await prepareProbeCodexSource(runtimeDirectory, options.sourceCodexHome, options.sourceAuthJson); + let remoteDirectoryCreated = false; + let remoteCaBundlePath: string | undefined; + let signalSpawned!: () => void; + const spawned = new Promise(resolve => { signalSpawned = resolve; }); + return { runtimeDirectory: runtimeDirectory, provider: "codex", model: options.model, reasoningEffort: options.reasoningEffort, + workingDirectory: remoteWorkspace, environment: options.environment, timeoutMs: options.timeoutMs, + transportOptions: { + externallySandboxed: target.transport === "sandbox", + runnerBinary: controllerRunnerBinary, codexCommand: remoteCodexBinary, sourceCodexHome, + runnerFilesystemRoot: remoteFilesystemRoot, runnerStateDirectory: remoteStateDirectory, + prpIdentity: { runnerInstanceId, runId: id, environmentLeaseId: `setup-${id}`, normalizedSessionId: `setup-${id}`, turnId: `turn-${id}`, itemId: `item-${id}` }, + readRunnerState: () => readRemoteRunnerState({ runner, stateDirectory: remoteStateDirectory }), + controlPlaneRegistration: async authority => { + const created = await runner.execute({ command: "sh", args: ["-c", 'set -eu; umask 077; mkdir -p -- "$1"; test -d "$1" && test ! -L "$1"; mkdir -- "$2"; install -d -m 0700 "$3" "$4" "$5"', + "paperclip-native-setup", posix.dirname(remoteRoot), remoteRoot, remoteFilesystemRoot, remoteWorkspace, posix.join(remoteFilesystemRoot, "probe-home")], cwd: target.remoteCwd, bypassSession: true, timeoutMs: 10_000 }); + requireSuccess(created, "Native setup could not claim a private runtime directory."); + remoteDirectoryCreated = true; + await artifacts.prepare(ingress ? "listen_ws" : "dial_wss"); + if (outbound?.mode === "direct_outbound" && outbound.caBundlePath) { + remoteCaBundlePath = posix.join(remoteRoot, "bin", "runner-ca-bundle.pem"); + await stageRemoteRunnerFile({ target, runner, sourcePath: outbound.caBundlePath, targetPath: remoteCaBundlePath, mode: 0o600 }); + } + if (outbound?.mode === "direct_outbound") { + const registration = await registerRunnerPrpAuthority({ companyId: options.context.companyId, runId: id, authority }); + return { connection: { mode: "connect" as const, connectUrl: outbound.connectUrl, ...(remoteCaBundlePath ? { caBundlePath: remoteCaBundlePath } : {}) }, + startupFailureCode: "runner_direct_wss_failed" as const, release: registration.release }; + } + const transport = await resolvePaperclipRunnerTransport({ target, runId: id, localConnectUrl: "ws://127.0.0.1/unused", runnerIngressAuthorized: true }); + if (transport.mode !== "provider_ingress") throw new Error("runner_transport_ineligible: expected provider ingress"); + let connection: ReturnType | undefined; + let activation: Promise | undefined; + return { + connection: { mode: "listen" as const, listenAddress: transport.listenAddress, listenPort: transport.listenPort, listenPath: transport.listenPath }, + activate: () => { + activation = spawned.then(() => { connection = connectRunnerPrpIngress({ authority, endpoint: transport.ingress }); }); + void activation.catch(() => undefined); + }, + ready: async () => { await activation; if (!connection) throw new Error("runner_ingress_unavailable"); await connection.ready; }, + get failure() { return connection?.failure; }, + startupFailureCode: "runner_ingress_unavailable" as const, + release: async () => { if (connection) await connection.close(); else await transport.ingress.close(); }, + }; + }, + runnerProcessLauncher: createRemoteRunnerProcessLauncher({ target, runner, remoteBinary, stateDirectory: remoteStateDirectory, + onRunnerProcessSpawned: signalSpawned, processIdentityPath: posix.join(remoteStateDirectory, "runner-process.identity"), diagnosticsDirectory: posix.join(remoteRoot, "diagnostics"), runnerInstanceId, + ensureArtifact: async () => { + for (const name of ["auth.json", "config.toml"] as const) { + const sourcePath = join(runtimeDirectory, "codex-home", name); + try { await stat(sourcePath); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; } + await stageRemoteRunnerFile({ target, runner, sourcePath, targetPath: posix.join(remoteFilesystemRoot, "codex-home", name), mode: 0o600 }); + } + } }), + }, + ...(options.credentialRefreshAuthPath ? { onCodexCredentialRefresh: async (filename: string) => { + if (filename !== posix.join(remoteFilesystemRoot, "codex-home", "auth.json")) throw new Error("The native Codex credential refresh handoff is invalid."); + await copyBackCodexAuth({ hostAuthPath: options.credentialRefreshAuthPath!, log: () => {}, readSandboxAuth: async () => { + const read = await runner.execute({ command: "sh", args: ["-c", 'set -eu; file=$1; test -f "$file" || exit 66; test ! -L "$file"; parent=$(dirname -- "$file"); while test "$parent" != /; do test -d "$parent" && test ! -L "$parent"; parent=$(dirname -- "$parent"); done; metadata=$(stat -c "%u %a %s" "$file" 2>/dev/null || stat -f "%u %Lp %z" "$file"); set -- $metadata; test "$1" = "$(id -u)" && test "$2" = 600 && test "$3" -le 65536; head -c 65537 -- "$file" | base64', "paperclip-native-refresh", filename], bypassSession: true, timeoutMs: 10_000 }); + if (read.timedOut || read.exitCode !== 0) throw Object.assign(new Error("Native Codex credential refresh handoff unavailable."), { code: read.exitCode === 66 ? "ENOENT" : "INVALID_CREDENTIAL" }); + const body = Buffer.from(read.stdout, "base64"); + if (body.length > 65_536) throw new Error("Native Codex credential refresh handoff exceeds the credential size limit."); + return body; + } }); + } } : {}), + onCleanupConfirmed: async () => { + if (remoteDirectoryCreated) { + const cleaned = await runner.execute({ command: "rm", args: ["-rf", "--", remoteRoot], bypassSession: true, timeoutMs: 10_000 }); + requireSuccess(cleaned, "Native setup cleanup is incomplete; private recovery state was retained."); + } + await rm(runtimeDirectory, { recursive: true, force: true }); + }, + }; + }); +} + +export async function testNativeRunnerAuthentication(context: AdapterEnvironmentTestContext, provider: "codex", model: string | null): Promise { + const remote = context.executionTarget?.kind === "remote"; + const configured = context.config.env; + const environment: Record = Object.fromEntries([ + ...(remote ? [] : PROBE_TRANSPORT_ENV_KEYS.flatMap(key => typeof process.env[key] === "string" ? [[key, process.env[key]!]] : [])), + ...Object.entries(configured && typeof configured === "object" && !Array.isArray(configured) ? configured : {}) + .filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ]); + const authKey = "_PAPERCLIP_NATIVE_SETUP_CODEX_AUTH_JSON_SECRET"; + const configKey = "_PAPERCLIP_NATIVE_SETUP_CODEX_CONFIG_TOML_SECRET"; + delete environment[authKey]; + delete environment[configKey]; + let sourceCodexHome: string | null | undefined; + const effort = context.config.modelReasoningEffort ?? context.config.reasoningEffort ?? context.config.effort; + const timeoutMs = remote ? 90_000 : 45_000; + let runtimeDirectory: string | undefined; + let sourceAuthJson: string | undefined; + try { + if (provider === "codex") { + const { buildNativeProviderEnvironment } = await import("./native-session-executor.js"); + sourceCodexHome = context.managedAiCredentialHome ?? resolveSourceCodexHome(buildNativeProviderEnvironment(environment)); + } + let credentialRefreshAuthPath: string | undefined; + if (sourceCodexHome && !environment.CODEX_API_KEY?.trim() && !environment.OPENAI_API_KEY?.trim() && !environment.PAPERCLIP_AI_PROVIDER_KEY?.trim()) { + let linked = false; + try { + const authPath = join(sourceCodexHome, "auth.json"); + linked = (await lstat(authPath)).isSymbolicLink(); + if (linked && !context.managedAiCredentialHome?.trim()) { + const field = environment.CODEX_HOME?.trim() ? "env.CODEX_HOME" : "operator CODEX_HOME"; + throw new Error(`${field} uses a linked auth.json that Paperclip Runner tasks cannot read. Choose Legacy runner in Advanced or use a managed Codex Connection.`); + } + const resolvedAuthPath = await realpath(authPath); + const source = await stat(resolvedAuthPath); + if (!source.isFile() || source.uid !== process.getuid?.()) throw new Error("Invalid Codex source credential owner."); + if (linked) sourceAuthJson = await readLocalAiCredentialFile(resolvedAuthPath); + credentialRefreshAuthPath = resolvedAuthPath; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + if (linked) throw new Error("Codex authentication required: the selected credential link is unavailable."); + } + } + let receipt: Awaited>; + if (context.executionTarget?.kind === "remote") { + receipt = await probeRemoteNativeCodex({ context, model, environment, sourceCodexHome: sourceCodexHome ?? "", credentialRefreshAuthPath, sourceAuthJson, timeoutMs, + ...(typeof effort === "string" && effort ? { reasoningEffort: effort } : {}) }); + } else { + const runnerBinary = resolvePaperclipRunnerBinary(); + runtimeDirectory = await realpath(await mkdtemp(join(tmpdir(), "paperclip-native-setup-"))); + receipt = await probePreparedNativeRuntime(runtimeDirectory, async () => { + const probeSourceCodexHome = await prepareProbeCodexSource(runtimeDirectory!, sourceCodexHome ?? "", sourceAuthJson); + return { runtimeDirectory: runtimeDirectory!, provider, model, environment, timeoutMs, + ...(provider === "codex" && typeof effort === "string" && effort ? { reasoningEffort: effort } : {}), + onCleanupConfirmed: async () => { await rm(runtimeDirectory!, { recursive: true, force: true }); }, + transportOptions: { runnerBinary, sourceCodexHome: probeSourceCodexHome, externallySandboxed: false }, + ...(credentialRefreshAuthPath ? { onCodexCredentialRefresh: async (filename: string) => { + if (filename !== join(runtimeDirectory!, "codex-home", "auth.json")) throw new Error("The native Codex credential refresh handoff is invalid."); + await copyBackCodexAuth({ hostAuthPath: credentialRefreshAuthPath!, log: () => {}, readSandboxAuth: async () => Buffer.from(await readLocalAiCredentialFile(filename)) }); + } } : {}), + }; + }); + } + if (receipt.helloProbePassed !== true || receipt.provider !== provider || typeof receipt.effectiveModel !== "string" || !receipt.effectiveModel.trim() || receipt.effectiveModel === "unknown" || (model !== null && receipt.effectiveModel !== model) + || receipt.providerDriver !== "codex_app_server") throw new Error("The selected native runtime returned an incompatible account or model verification receipt."); + return { adapterType: "paperclip_runner", status: "pass", testedAt: new Date().toISOString(), checks: [{ code: `${provider}_hello_probe_passed`, level: "info", + message: `The native ${provider} runtime verified the selected account and model in ${remote ? "the selected environment" : "the Paperclip host"}.` }] }; + } catch (error) { + const message = redactNativeProbeMessage(error instanceof Error ? error.message : "The selected native runtime could not verify this account.", { ...environment, ...(sourceAuthJson ? { [authKey]: sourceAuthJson } : {}) }); + const authentication = /(?:invalid (?:api[- ]?key|auth(?:entication)? token)|authentication (?:failed|required)|unauthenticated|unauthorized|not authenticated|please (?:log|sign) in|not logged in|\b(?:401|403)\b)/i.test(message); + return { adapterType: "paperclip_runner", status: "fail", testedAt: new Date().toISOString(), checks: [{ code: `${provider}_hello_probe_${authentication ? "auth_required" : /timed out/i.test(message) ? "timeout" : "failed"}`, level: "error", message, + hint: "Check the selected account, model access, and native runtime prerequisites, then retry. Legacy runner is available explicitly in Advanced." }, + ...(authentication && provider === "codex" ? [{ code: ADAPTER_AUTH_MISSING_CHECK_CODE, level: "error" as const, + message: "The selected Codex account needs authentication.", + hint: "Sign in to Codex or choose an authenticated account, then test again." }] : [])] }; + } +} diff --git a/server/src/services/onboarding-first-task-assets.test.ts b/server/src/services/onboarding-first-task-assets.test.ts index b0a15609518..a64c9a2587b 100644 --- a/server/src/services/onboarding-first-task-assets.test.ts +++ b/server/src/services/onboarding-first-task-assets.test.ts @@ -93,6 +93,35 @@ describe("first-task proposal mode policy", () => { expect(skill).toContain("`request_checkbox_confirmation` targeting its saved revision"); expect(skill).toContain("explicit plan requests regardless of the single-task proposal mode"); }); + + it("hands accepted file-producing child work to its own execution before reporting results", async () => { + const skill = await readFile(new URL("../onboarding-assets/first-task/skills/first-task/SKILL.md", import.meta.url), "utf8"); + expect(skill).toContain("If the accepted child work requires a file deliverable"); + expect(skill).toContain("self-contained child description"); + expect(skill).toContain("requested filenames"); + expect(skill).toContain("child's own Paperclip execution"); + expect(skill).toContain("even when the child is assigned to yourself"); + expect(skill).toContain("Do not create or register that file from the onboarding run"); + expect(skill).toContain("`register_deliverable` is bound to its current task"); + expect(skill).toContain("preserve existing blocker IDs"); + expect(skill).toContain("`set_dependencies`"); + expect(skill).toContain("`paperclip_block`"); + expect(skill).toContain("`blockedByIssueIds`"); + expect(skill).toContain("End the turn so the child can execute"); + expect(skill).toContain("Do not poll for its result"); + expect(skill).toContain("verify the saved child document and retrievable file"); + expect(skill).toContain("actual child task and download links"); + }); + + it("keeps plan-only authorization and document-only child execution distinct from file handoff", async () => { + const skill = await readFile(new URL("../onboarding-assets/first-task/skills/first-task/SKILL.md", import.meta.url), "utf8"); + expect(skill).toContain("For a plan-only request, retain the accepted plan on this task"); + expect(skill).toContain("Do not start its implementation or hire the proposed team without authorization"); + expect(skill).toContain("Create exactly one child linked to this onboarding task"); + expect(skill).toContain("For document-only child work, you may execute it directly"); + expect(skill).toContain("Save the finished output as a document on the child task and mark that child done"); + expect(skill).toContain("On later wakes, continue that same child instead of creating another"); + }); }); describe("chief-of-staff persona", () => { diff --git a/server/src/services/plugin-managed-agents.ts b/server/src/services/plugin-managed-agents.ts index 39ca9fd3b09..e2e2c965e8c 100644 --- a/server/src/services/plugin-managed-agents.ts +++ b/server/src/services/plugin-managed-agents.ts @@ -15,9 +15,10 @@ import type { PluginManagedAgentDeclaration, PluginManagedAgentResolution, } from "@paperclipai/shared"; -import { isUuidLike } from "@paperclipai/shared"; +import { agentHarnessType, agentRunner, isUuidLike } from "@paperclipai/shared"; import { conflict, forbidden, notFound } from "../errors.js"; import { agentService } from "./agents.js"; +import { resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js"; import { approvalService } from "./approvals.js"; import { logActivity } from "./activity-log.js"; import { agentInstructionsBundleMode, agentInstructionsService } from "./agent-instructions.js"; @@ -356,12 +357,12 @@ export function pluginManagedAgentService( async function companyAdapterUsage(companyId: string) { const rows = await db - .select({ adapterType: agents.adapterType }) + .select({ adapterType: agents.adapterType, adapterConfig: agents.adapterConfig }) .from(agents) .where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated"))); const counts = new Map(); for (const row of rows) { - const adapterType = normalizeAdapterType(row.adapterType); + const adapterType = normalizeAdapterType(agentHarnessType(row.adapterType, row.adapterConfig)); if (!adapterType) continue; counts.set(adapterType, (counts.get(adapterType) ?? 0) + 1); } @@ -645,11 +646,26 @@ export function pluginManagedAgentService( ? reconciled.agent.metadata : {}; const adapterType = await resolveManagedAdapterType(companyId, declaration); + const defaults = declarationPatch(declaration, { adapterType }); + const sameCodexHarness = adapterType === "codex_local" && agentHarnessType(reconciled.agent.adapterType, reconciled.agent.adapterConfig) === adapterType; + const retainedNative = sameCodexHarness && reconciled.agent.adapterType === "paperclip_runner"; + const resetAdapterConfig = { ...defaults.adapterConfig }; + if (retainedNative) { + for (const field of ["provider", "acpxAgent", "acpxMode"]) { + if (reconciled.agent.adapterConfig[field] !== undefined) resetAdapterConfig[field] = reconciled.agent.adapterConfig[field]; + } + } + const execution = await resolveNewAgentRunnerForCompany(db, companyId, { + ...defaults, + adapterType: retainedNative ? reconciled.agent.adapterType : adapterType, + adapterConfig: resetAdapterConfig, + defaultEnvironmentId: reconciled.agent.defaultEnvironmentId, + runner: sameCodexHarness ? agentRunner(reconciled.agent.adapterType) : undefined, + }); // Reset content through the canonical CAS path before changing defaults. // A conflict must leave the existing adapter configuration untouched. const withInstructions = await materializeDeclaredInstructions(companyId, reconciled.agent, declaration, { replaceExisting: true }); - const defaults = declarationPatch(declaration, { adapterType }); - const adapterConfig = { ...defaults.adapterConfig } as Record; + const adapterConfig = { ...execution.adapterConfig }; if (declaration.instructions) { for (const key of ["instructionsBundleMode", "instructionsRootPath", "instructionsEntryFile", "instructionsFilePath"]) { if (withInstructions.adapterConfig[key] !== undefined) adapterConfig[key] = withInstructions.adapterConfig[key]; @@ -657,6 +673,7 @@ export function pluginManagedAgentService( } const updated = await agentSvc.update(reconciled.agent.id, { ...defaults, + adapterType: execution.adapterType, adapterConfig, metadata: managedMetadata(options.pluginId, options.pluginKey, declaration, currentMetadata), }, { diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index e4c0f0eacc9..5d43e26ae05 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -13,6 +13,7 @@ import { LEGACY_DISPOSITION_REPAIR_INSTRUCTION, type LegacyDispositionEpisode, } from "./legacy-continuation.js"; import { hasLiveLegacyController } from "../legacy-controller-lease.js"; +import { isCompletedOnboardingHandoffWake } from "../chat-completion-delivery.js"; import { instanceSettingsService } from "../instance-settings.js"; import { isWaitingConversation, settleConversationTurn, deliverConversationComments } from "../agent-conversations.js"; import { @@ -5938,13 +5939,21 @@ export function recoveryService( const isTerminalSessionGoalControl = runContext.resumeIntent === true && readNonEmptyString(runContext.goalControlRequestId) !== null; + const issueId = issueIdFromRunContext(run.contextSnapshot); + const preservesTerminalIssueReport = isTerminalSessionGoalControl || + (issueId !== null && await isCompletedOnboardingHandoffWake(db, { + companyId: run.companyId, + issueId, + agentId: run.agentId, + reason: readNonEmptyString(runContext.wakeReason), + contextSnapshot: runContext, + })); let issueTerminalStatus: "succeeded" | "cancelled" | null = - isTerminalSessionGoalControl + preservesTerminalIssueReport ? null : (options?.referencingIssueTerminalStatus ?? null); - const issueId = issueIdFromRunContext(run.contextSnapshot); if ( - !isTerminalSessionGoalControl && + !preservesTerminalIssueReport && !issueTerminalStatus && !options?.runReferencedByActiveIssue && issueId diff --git a/skills/paperclip-create-agent/SKILL.md b/skills/paperclip-create-agent/SKILL.md index fa9e7b3581c..800c58fb6e7 100644 --- a/skills/paperclip-create-agent/SKILL.md +++ b/skills/paperclip-create-agent/SKILL.md @@ -186,3 +186,21 @@ For each linked issue, either: - Short role drafting guide: `skills/paperclip-create-agent/references/baseline-role-guide.md` - Pre-submit draft-review checklist: `skills/paperclip-create-agent/references/draft-review-checklist.md` - Endpoint payload shapes and full examples: `skills/paperclip-create-agent/references/api-reference.md` + +## Harness and runner selection + +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`; +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. +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. + +CLI equivalents: `paperclipai agent create --runner legacy --payload-json ...` +and `paperclipai agent hire --runner legacy --payload-json ...`. diff --git a/tests/e2e/chat-adapters-ui-messaging.spec.ts b/tests/e2e/chat-adapters-ui-messaging.spec.ts index db2e26c4a16..8ce693a2b03 100644 --- a/tests/e2e/chat-adapters-ui-messaging.spec.ts +++ b/tests/e2e/chat-adapters-ui-messaging.spec.ts @@ -1437,10 +1437,26 @@ test.describe("Exact failed chat run retry", () => { surface === "agent run" ? `/${seed.prefix}/agents/maya/runs/${failedRunId}` : `/${seed.prefix}/inbox/all`; + const canonicalAgentResponse = + surface === "agent run" + ? page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + response.request().method() === "GET" && + url.pathname === "/api/agents/maya" && + url.searchParams.get("companyId") === seed.companyId + ); + }) + : null; await page.goto(startPath); - if (surface === "agent run") { - // Begin on the canonical route: observing the URL replacement - // alone can race the remount and discard retry mutation feedback. + if (canonicalAgentResponse) { + const response = await canonicalAgentResponse; + expect(response.ok()).toBe(true); + expect(await response.json()).toMatchObject({ + id: seed.agentId, + companyId: seed.companyId, + urlKey: "maya", + }); await expect(page).toHaveURL( new RegExp(`/${seed.prefix}/agents/maya/runs/${failedRunId}$`), ); diff --git a/tests/e2e/new-task-picker-touch.spec.ts b/tests/e2e/new-task-picker-touch.spec.ts index 6804125114e..50c591d3a9b 100644 --- a/tests/e2e/new-task-picker-touch.spec.ts +++ b/tests/e2e/new-task-picker-touch.spec.ts @@ -95,7 +95,7 @@ test("new-task assignee, model, and project sheets scroll by touch and retain th await json(await request.post(`/api/companies/${company.id}/agents`, { data: { name: `Touch Agent ${String(index).padStart(2, "0")}`, role: "engineer", - adapterType: "codex_local", adapterConfig: { model: "gpt-6-sol" }, + adapterType: "codex_local", runner: "legacy", adapterConfig: { model: "gpt-6-sol" }, runtimeConfig: { heartbeat: { enabled: false } }, }, })); @@ -196,7 +196,7 @@ test.describe("desktop picker scrolling", () => { await json(await request.post(`/api/companies/${company.id}/agents`, { data: { name: `Wheel Agent ${String(index).padStart(2, "0")}`, role: "engineer", - adapterType: "codex_local", adapterConfig: { model: "gpt-6-sol" }, + adapterType: "codex_local", runner: "legacy", adapterConfig: { model: "gpt-6-sol" }, runtimeConfig: { heartbeat: { enabled: false } }, }, })); diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index 24d54928c94..e311961d238 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -307,28 +307,25 @@ test.describe("Onboarding wizard", () => { expect(pageErrors, pageErrors.join("\n")).toHaveLength(0); }); test("a reload during a login resumes the same session", async ({ page }) => { - // The last piece of the resume feature: a customer who reloads the page - // mid-login must see the same login still running, not the tile row - // again and not a second session started behind their back. - // - // A bare `/onboarding` reload is not the right way to exercise this: that - // route always forces its own fixed entry step (see - // `resolveRouteOnboardingOptions` — a bare `/onboarding` hit is a request - // to start a new company, by design, and always wins over a saved draft's - // step). A returning visit to an existing company's own onboarding path - // (`/{prefix}/onboarding`) is the real "reopen onboarding" entry point, so - // this test reloads through that path instead, landing on the agent step - // with the draft's agent name already restored, then advances once to the - // connect step — where the resumed login must already be running. const pageErrors: string[] = []; page.on("pageerror", (err) => pageErrors.push(err.message)); + const loginSessionReads: string[] = []; + page.on("request", (request) => { + const match = new URL(request.url()).pathname.match(/\/setup-token-login-sessions\/([^/]+)(?:\/prompt)?$/); + if (match && match[1] !== "active") loginSessionReads.push(match[1]); + }); + let hireCalls = 0; + await page.route("**/agent-hires", (route) => { + hireCalls += 1; + return route.fulfill({ status: 400, json: { error: "Unexpected hire while login is pending." } }); + }); const flagRes = await page.request.patch("/api/instance/settings/experimental", { data: { enableConferenceRoomChat: true }, }); expect(flagRes.ok()).toBe(true); - const FAKE_SANDBOX_ENVIRONMENT_ID = "e2e-fake-sandbox-environment-reload"; + const FAKE_SANDBOX_ENVIRONMENT_ID = "a6d2e530-1e8b-4f0b-9c94-42f1d23e8750"; const FAKE_SANDBOX_PROVIDER = "e2e-fake-provider-reload"; await page.route("**/environments", async (route) => { @@ -382,6 +379,15 @@ test.describe("Onboarding wizard", () => { }), ); + await page.route("**/api/adapters?**", async (route) => { + const url = new URL(route.request().url()); + if (url.searchParams.get("environmentId") !== FAKE_SANDBOX_ENVIRONMENT_ID) return route.continue(); + url.searchParams.delete("environmentId"); + const response = await route.fetch({ url: url.toString() }); + expect(response.ok()).toBe(true); + await route.fulfill({ response }); + }); + const SESSION_ID = "e2e-reload-setup-token-session"; const AUTHORIZATION_URL = "https://claude.ai/oauth/authorize?code=true&client=e2e-reload"; let startCalls = 0; @@ -390,6 +396,7 @@ test.describe("Onboarding wizard", () => { // route, and keeping the pre-Connect part of this test the same as the // ordinary sign-in test above. let sessionStarted = false; + const resumedCompanyIds: string[] = []; let aiConnection: Record | undefined; await page.route("**/setup-token-login-sessions", (route) => { if (route.request().method() === "POST") { @@ -441,6 +448,7 @@ test.describe("Onboarding wizard", () => { body: JSON.stringify({ error: "Setup-token login session not found." }), }); } + resumedCompanyIds.push(new URL(route.request().url()).pathname.split("/")[3]); return route.fulfill({ contentType: "application/json", body: JSON.stringify({ @@ -490,7 +498,7 @@ test.describe("Onboarding wizard", () => { await expect(cardInstruction).toBeVisible({ timeout: 30_000 }); await expect(authorizationLink).toBeVisible({ timeout: 15_000 }); - await expect(authorizationLink).toHaveAttribute("href", /claude\.ai\/oauth\/authorize/); + await expect(authorizationLink).toHaveAttribute("href", AUTHORIZATION_URL); expect(startCalls).toBe(1); const companiesRes = await page.request.get("/api/companies"); @@ -501,22 +509,44 @@ test.describe("Onboarding wizard", () => { ); expect(company, "the created company should exist").toBeTruthy(); - // Reload through the company's own onboarding path — the real "reopen - // onboarding" entry point for a company that already exists. + const savedSelection = { + step: 4, + companyName: `${COMPANY_NAME}-reload`, + agentName: "Ada", + adapterType: "claude_local", + runner: "auto", + credentialMode: "subscription", + credentialModeChoice: null, + createdCompanyId: company.id, + createdCompanyPrefix: company.issuePrefix, + createdAgentId: null, + }; + const readDraft = () => page.evaluate(() => JSON.parse(localStorage.getItem("paperclip-onboarding-state") ?? "null")); + await expect.poll(readDraft).toMatchObject(savedSelection); + const assertResumedLogin = async () => { + await expect(page.getByRole("heading", { name: "Connect a model", exact: true })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText("Ada", { exact: true })).toBeVisible(); + await expect(page.getByRole("radio", { name: "Claude Subscription", exact: true })).toBeChecked(); + await expect(cardInstruction).toBeVisible({ timeout: 15_000 }); + await expect(authorizationLink).toBeVisible({ timeout: 15_000 }); + await expect(authorizationLink).toHaveAttribute("href", AUTHORIZATION_URL); + await expect.poll(readDraft).toMatchObject(savedSelection); + expect(startCalls, "reopening must resume the session without a second source choice").toBe(1); + expect(hireCalls, "a pending login must not hire an agent").toBe(0); + expect(loginSessionReads.length).toBeGreaterThan(0); + expect(new Set(loginSessionReads)).toEqual(new Set([SESSION_ID])); + expect(new Set(resumedCompanyIds)).toEqual(new Set([company.id])); + }; + + const beforeReopenReads = resumedCompanyIds.length; await page.goto(`/${company.issuePrefix}/onboarding`); + await expect.poll(() => resumedCompanyIds.length, { timeout: 15_000 }).toBeGreaterThan(beforeReopenReads); + await assertResumedLogin(); - // This entry point always re-enters on the agent step, with the agent - // name restored from the draft. One more press reaches the connect step. - await page.waitForSelector("#onboarding-agent-name", { timeout: 30_000 }); - await expect(page.locator("#onboarding-agent-name")).toHaveValue("Ada"); - await page.getByRole("button", { name: "Next" }).click(); - - // The resumed login shows with no new source pick and no fresh sign-in - // press: the panel and the step both discover it from the caller's active - // session. - await expect(cardInstruction).toBeVisible({ timeout: 15_000 }); - await expect(authorizationLink).toBeVisible({ timeout: 15_000 }); - expect(startCalls, "the reload must resume the session, not start a new one").toBe(1); + const beforeReloadReads = resumedCompanyIds.length; + await page.reload(); + await expect.poll(() => resumedCompanyIds.length, { timeout: 15_000 }).toBeGreaterThan(beforeReloadReads); + await assertResumedLogin(); expect(pageErrors, pageErrors.join("\n")).toHaveLength(0); }); diff --git a/tests/e2e/project-repositories.spec.ts b/tests/e2e/project-repositories.spec.ts index 54b25a52141..e13640dc834 100644 --- a/tests/e2e/project-repositories.spec.ts +++ b/tests/e2e/project-repositories.spec.ts @@ -48,6 +48,7 @@ test("project creation and repository configuration persist on a short mobile vi await section.getByRole("button", { name: "Edit", exact: true }).click(); await section.getByRole("textbox", { name: "Existing repo URL" }).fill("https://git.example/org/updated"); await section.getByRole("button", { name: "Save URL" }).click(); + await expect(section.getByText("https://git.example/org/updated", { exact: true })).toBeVisible(); await page.reload(); await expect(section.getByText("https://git.example/org/updated", { exact: true })).toBeVisible(); await expect(page.getByText("Set the KEY to the env var name", { exact: false })).toHaveCount(0); diff --git a/tests/e2e/signoff-policy.spec.ts b/tests/e2e/signoff-policy.spec.ts index 9bdb0f4ad10..670304fb2d8 100644 --- a/tests/e2e/signoff-policy.spec.ts +++ b/tests/e2e/signoff-policy.spec.ts @@ -111,7 +111,7 @@ async function fetchVerifiedRun( candidateRunId: string | null | undefined, agentId: string, issueId: string, -): Promise<{ id: string; status: string } | null> { +): Promise<{ id: string; status: string; errorCode: string | null } | null> { if (!candidateRunId) return null; const runRes = await board.get(`${BASE_URL}/api/heartbeat-runs/${candidateRunId}`); if (!runRes.ok()) return null; @@ -119,7 +119,9 @@ async function fetchVerifiedRun( const context = candidateRun.contextSnapshot ?? {}; if (candidateRun.agentId !== agentId) return null; if (context.issueId !== issueId && context.taskId !== issueId) return null; - return { id: candidateRunId, status: candidateRun.status }; + const errorCode = typeof candidateRun.errorCode === "string" && /^[a-z][a-z0-9_.-]{0,79}$/i.test(candidateRun.errorCode) + ? candidateRun.errorCode : null; + return { id: candidateRunId, status: candidateRun.status, errorCode }; } /** @@ -289,7 +291,7 @@ async function waitForFixtureRun( status = run!.status; if (status === "succeeded") return true; if (status === "cancelled" && allowCancelledLock) return false; - expect(["queued", "running"], `Fixture run ${runId} ended as ${status}`).toContain(status); + expect(["queued", "running"], `Fixture run ${runId} ended as ${status} (errorCode=${run!.errorCode ?? "unavailable"})`).toContain(status); await new Promise((resolve) => setTimeout(resolve, WAKEUP_POLL_INTERVAL_MS)); } while (Date.now() < deadline); throw new Error(`Fixture run ${runId} did not succeed within ${waitBudgetMs}ms (last status: ${status})`); diff --git a/tests/runner-e2e/FIXTURES.md b/tests/runner-e2e/FIXTURES.md index 37e99d29ce1..ed12a9c60c6 100644 --- a/tests/runner-e2e/FIXTURES.md +++ b/tests/runner-e2e/FIXTURES.md @@ -124,6 +124,19 @@ attribution. Failed setup before fixture completion retains unavailable/unknown cost coverage and its holds. A connection-only probe or name-only snapshot cannot qualify native defaults. +The manual managed first-task cell is the exception to fixture prequalification: +`confirmation-replies.runner-codex.daytona.task-card-accept` leaves the saved +environment unqualified until the ordinary onboarding Connect action. It +observes exactly one platform response and one selected-runtime response, fresh +scoped native discovery and the saved automatic choice. Both setup leases must +have authoritative response IDs, match this company/environment, and reach +released/success before setup is accepted. Exact IDs join the existing task-run +lease collector and runtime estimate, including failed setup. New uncorrelated +or response-less leases remain unknown and unattributed; incomplete coverage +removes the estimate and retains its budget holds. No retry or extra fixture +probe closes that gap. The existing environment deletion, expiry backstops and +instance cleanup still apply on every outcome. + Daytona creates sandbox environments through the public API. The core fixture keeps `reuseLease:false` and `runnerLifecycleMode:"per_turn"`. The dedicated warm-continuity fixture uses `reuseLease:true` and diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index 8ec26e4befc..da3e4f63268 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -139,8 +139,9 @@ Paperclip task, run an agent, or replace a Product E2E result. `--suite confirmation-replies` selects ten local native Claude/Codex cells: conversational single-task approval, saved-plan approval, rejection, card-click acceptance as a control, and ambiguous approval with two independent pending -proposals. The onboarding cells use the production wizard, runtime switch and -persona. The ambiguity fixture creates ordinary board cards through the public +proposals. The onboarding cells use the production wizard and persona. Codex +retains its automatic native selection; Claude uses an explicit runtime switch. +The ambiguity fixture creates ordinary board cards through the public API, sends "Yes, go ahead" through the browser, requires both to stay pending with a clarification reply and no execution, then approves only one and rejects the other through separate browser messages. @@ -184,8 +185,9 @@ pnpm test:e2e:runner -- --suite confirmation-replies and native Claude, each with onboarding, idle handoff, busy handoff, two-task handoff, and restart recovery. These exercise the production completion-delivery path and agent-authored responses. There is no separate completion feature flag. -The onboarding cell reuses the real wizard and its existing pre-execution native -runtime switch, retaining the production persona. +The onboarding cell reuses the real wizard and retains the production persona. +Codex uses its automatic native default; Claude explicitly changes runtime +before execution. The idle chat cell asks the agent to delegate one welcome note to a named worker and report its result without another user message. A bounded local file read in @@ -1082,18 +1084,19 @@ default is retained**, even when it differs from that profile's normal harness 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). Native profiles complete the same -production wizard using their legacy provider, then change only the agent's -runtime configuration via the public API before its first task. The wizard does -not currently offer native Runner. Persona, managed instructions, skills, seeded -question, and task invocation are preserved. Explicit model choices are retained; -an unset model resolves through the production runtime-switch defaults. The -production switch removes the legacy Paperclip operational skill because Runner -supplies its control-plane contract through its protocol; other assigned skills, -including `/first-task`, are retained. Native runtime permissions come from the -existing qualified profile. Evidence labels -this setup `post-onboarding-runtime-switch`; it does not claim a native wizard -path exists. Legacy setup is labeled `production-wizard`. +`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 +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 +separate campaign reservation because they do not create task cost events. | First response / control | Complete journey | | --- | --- | @@ -1449,12 +1452,11 @@ released on failure and normal isolated-instance cleanup removes the workspace. The usual provider billing and partial-attempt reporting apply. No production prompts, onboarding defaults, or provider permissions are changed. -For pre-default native onboarding qualification, select all `first-task` cases -with profiles `runner-codex,runner-acpx-claude` (26 cells). The existing public-API -runtime switch occurs after the real wizard creates its first agent and before -any provider work. It preserves the wizard's model, persona, skills, and task. -This tests the native first-task process in advance of the UI/default rollout; -it does not certify a native option in the wizard, which is not offered yet. +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. Current proof and remaining decisions are recorded in [the 21 September qualification report](QUALIFICATION-2026-09-21.md). In particular, @@ -1999,12 +2001,31 @@ Run the explicit `paperclipai runtime setup pi` for that installation first. This lane rejects candidate flags, local/remote daemon overrides, provider asset or pack overrides, and Node injection. It launches the installed JavaScript CLI without a TypeScript loader, rechecks its pins on controller restart, and records -`installed-cli-admission.json` in private attempt evidence. It supports only the -explicit Pi extended, native, controls and warm suites, which require none of the +`installed-cli-admission.json` in private attempt evidence. Pi uses the explicit +extended, native, controls and warm suites, which require none of the source-only response barriers. Cursor/Copilot qualification gates stay intact. Pi's historical `qualificationCandidate` fixture selector remains a roster key; it no longer grants an opt-in when the source Pi declaration admits normal use. +The same four installed-package pins also admit three existing local native +Codex cells: `first-task.runner-codex.local.task-card-accept`, +`everyday-workflows.runner-codex.local.build-revise`, and +`agent-chat.runner-codex.local.continuity-restart`. Select exact IDs; other +Codex cells, legacy profiles and remote environments remain outside this lane. +These reuse the onboarding, independently checked file/revision, and restart +graders. They require separately authorized provider access and are not executed +by the provider-free release smoke. + +The release consumer calls `probeInstalledCodexStartup` from `installed-cli.ts` +against its installed server, normal platform daemon and contained npm Codex. +It checks a real native read-only session and joined cleanup using empty homes, +file-only authentication storage and a closed environment. It sends no provider +turn, consults no account, records the actual CLI version without an equality +gate, and exposes no session IDs. Build/source, Mach-O/ELF and official npm +provenance checks precede this probe. Startup proof does not establish useful-file +delivery, authentication, reasoning effort or follow-up behavior; the existing +live journeys must prove those separately. + Installed Pi Daytona cells also require the published plugin fixture inputs `PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN` (canonical package root), `PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN_AUTHORITY` (canonical JSON file), @@ -2131,6 +2152,29 @@ the changed chief-of-staff persona and skill selections; it needs no fixture change for that default selection. Hiring from that wizard-created chief of staff remains a separate follow-up qualification. +The explicit-only cell `confirmation-replies.runner-codex.daytona.task-card-accept` +extends that wizard journey to one fresh managed company. Select this exact ID; +the automatic roster and all local prompts remain unchanged. Its fixture seeds +encrypted credentials and an immutable OCI image environment, but does not +prequalify the image or create an agent. Connect owns the existing saved +environment probe, fresh scoped adapter discovery, and selected runtime setup. +The case requires one confirmed platform probe and one native Codex setup lease, +then verifies the automatically saved native configuration after reload. + +The completed child must expose a downloaded `garden-welcome.txt` attachment +whose byte size, SHA-256, content and scenario marker are independently checked. +A saved document alone cannot pass. Existing task-card grading and the same-task +follow-up continuity checks remain required. This cell has one attempt, zero +automatic retries and a shared fifteen-minute deadline with cleanup time +reserved. The script authors three turns; four task runs were observed in the +historical local journey. The existing twelve-run guard and $5 company task cap +remain, with native setup, one completion judge request, sandbox time and cleanup +requiring separate reservation. Registration and provider-free controls do not +establish live qualification or funding within the remaining paid reserve. + +This OCI-image Core case does not prove Cloud signup, warm/cold provisioning or +name-only snapshot qualification. Those remain separate acceptance gates. + ### Hiring completion accounting evidence The v3 hiring grader uses turn-accounting v2 in both executable guards. It requires complete per-run public event streams, exact native tool-use/result pairing and canonical execution IDs for completion actions. Only successful known GET issue/document/comment operations, verified reads/discovery, and attributed native chat finish are admitted. Writes, failed mutation attempts, incomplete streams and unknown actions cannot pass. Separate ACPX host request IDs and provider execution IDs are not joined by name/order/count; missing mapping is uncomparable action coverage, not a measured task failure. The original source-read and exact template checks remain unchanged. diff --git a/tests/runner-e2e/SECURITY.md b/tests/runner-e2e/SECURITY.md index b20d625c286..70281a25c54 100644 --- a/tests/runner-e2e/SECURITY.md +++ b/tests/runner-e2e/SECURITY.md @@ -126,6 +126,16 @@ The Paperclip server process also receives none; the browser posts each value once to the encrypted company secret API and agents/environments retain only secret references. +The explicit managed first-task cell observes the wizard's two owned setup +responses only to correlate their exact lease IDs with same-company environment +records. Raw probe/setup responses, account references, opaque provider lease +identities and setup screenshots remain private. Public first-task evidence +continues through the existing projection. Missing or foreign lease identities +cannot acquire attribution or a zero-cost estimate; incomplete setup coverage +stays unavailable even if later task-run billing succeeds. Platform qualification +uses the existing permission-gated environment probe, without granting new +permissions or accepting fixture-authored qualification metadata. + The context-integrity catalog lists six pending Kimi/Grok cells for discovery: two cases each across `legacy-kimi-cli`, `legacy-kimi-acp`, and `legacy-grok`. The launcher and direct Playwright runner reject these profiles before loading diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts index d0344178d2d..6920d750651 100644 --- a/tests/runner-e2e/catalog.test.ts +++ b/tests/runner-e2e/catalog.test.ts @@ -32,6 +32,23 @@ import { } from "./selectors.js"; describe("runner E2E catalog", () => { + it("keeps the single managed onboarding variant explicit-only without changing local onboarding coverage", () => { + const id = "confirmation-replies.runner-codex.daytona.task-card-accept"; + const suite = runnerSuites.find(candidate => candidate.id === "confirmation-replies")!; + const remote = runnerMatrix.filter(entry => entry.suite.id === suite.id && entry.environment.id === "daytona"); + expect(remote.map(entry => entry.id)).toEqual([id]); + expect(suite.manualOnly).toBe(true); + expect(remote[0]!.task.flow).toBe("first_task"); + expect(remote[0]!.task.expectedRunCount).toBe(4); + expect(remote[0]!.task.automaticRetryPolicy).toBe("single_attempt"); + expect(remote[0]!.requiredCredentials).toEqual(["OPENAI_API_KEY", "DAYTONA_API_KEY"]); + expect(runnerMatrix.filter(entry => entry.suite.id === "first-task")).toHaveLength(52); + expect(runnerMatrix.filter(entry => entry.suite.id === suite.id && entry.environment.id === "local")).toHaveLength(12); + expect(selectRunnerExecutions(parseRunnerSelectors(["--id", id])).map(entry => entry.id)).toEqual([id]); + expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(entry => entry.id === id)).toBe(false); + expect(selectRunnerExecutions(parseRunnerSelectors(["--profile", "runner-codex", "--environment", "daytona"])).some(entry => entry.id === id)).toBe(false); + }); + it("keeps private repository skill delivery explicit and bounded in both environments", () => { const cases = runnerMatrix.filter(entry => entry.suite.id === "repository-skills"); expect(cases).toHaveLength(2); @@ -159,10 +176,10 @@ describe("runner E2E catalog", () => { expect(localIntegrityTasks).toHaveLength(2); expect(openRouterBreadthTasks).toHaveLength(3); expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([ - 3, 4, 8, 10, 4, 4, 6, 63, 12, 6, 2, 30, 3, 16, 16, 2, 6, 8, 46, 2, 23, 15, 52, 6, 6, 20, 26, 52, 28, 18, 2, 6, 6, 12, 10, 48, 16, 10, 2, 1, 1, 116, 2, + 3, 4, 8, 10, 4, 4, 6, 63, 12, 6, 2, 30, 3, 16, 16, 2, 6, 8, 46, 2, 23, 15, 52, 6, 6, 20, 26, 52, 28, 18, 2, 6, 6, 13, 10, 48, 16, 10, 2, 1, 1, 116, 2, ]); - expect(validateRunnerCatalog()).toHaveLength(729); - expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(729); + expect(validateRunnerCatalog()).toHaveLength(730); + expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(730); expect( runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"), ).toHaveLength(48); diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index 23a7a2b5417..bccc1d17fb7 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -101,6 +101,7 @@ function commonAgent( capabilities: "Completes deterministic standard, planning, and ask-mode runner acceptance tasks.", adapterType, + ...(adapterType === "codex_local" ? { runner: "legacy" as const } : {}), adapterConfig, defaultEnvironmentId: input.environmentId, budgetMonthlyCents: 0, @@ -1439,7 +1440,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: 4, runGrading: "evidenced-nonexecution-and-refusal", credentialPersistenceCheck: false, questionChoiceMinimum: 2, nativeSetup: "post-onboarding-runtime-switch", productionInstructions: true, qualityGrading: "informational" }, + definitionMetadata: { version: 5, runGrading: "evidenced-nonexecution-and-refusal", credentialPersistenceCheck: false, questionChoiceMinimum: 2, nativeSetup: "codex-production-default-claude-explicit-runtime-switch", productionInstructions: true, qualityGrading: "informational" }, }, { id: "agent-chat", label: "Persistent Agent Chat", @@ -1495,10 +1496,14 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [ groups: ["chat", "native"], profiles: runnerProfiles.filter(profile => ["runner-codex", "runner-acpx-claude"].includes(profile.id)) .map(profile => productionStoryProfile(defaultPermissionProfile(profile))), - environments: [localEnvironment], + environments: [localEnvironment, runnerEnvironments.find(environment => environment.id === "daytona")!], + excludedExecutionIds: ["runner-codex", "runner-acpx-claude"].flatMap(profile => + [...firstTaskTasks.filter(task => ["task-reply-accept", "interview-plan-accept", "reject-no-execution", "task-card-accept"].includes(task.id)), ...chatConfirmationTasks] + .map(task => `confirmation-replies.${profile}.daytona.${task.id}`) + .filter(id => id !== "confirmation-replies.runner-codex.daytona.task-card-accept")), tasks: [...firstTaskTasks.filter(task => ["task-reply-accept", "interview-plan-accept", "reject-no-execution", "task-card-accept"].includes(task.id)), ...chatConfirmationTasks], - expectedMatrixSize: 12, - definitionMetadata: { version: 6, instructions: "production", grading: "card-message-provenance-before-child-creation", unansweredQuestionComposer: "history-card-only-including-fresh-dismissal", completionObservation: "120-seconds", scheduling: "explicit-only" }, + expectedMatrixSize: 13, + definitionMetadata: { version: 7, managedOnboarding: "ui-owned-qualification-and-physical-file", managedExpectedTaskRuns: 4, managedAutomaticRetries: 0, instructions: "production", grading: "card-message-provenance-before-child-creation", unansweredQuestionComposer: "history-card-only-including-fresh-dismissal", completionObservation: "120-seconds", scheduling: "explicit-only" }, }, { id: "completion-updates", label: "Delegated Completion Updates", manualOnly: true, @@ -1676,8 +1681,10 @@ export function buildRunnerMatrix( suiteDefinitionHash: suiteDefinitionHash(suite), profile, environment, - task: suite.id === "extended-harnesses" && profile.qualificationCandidate === "pi" && task.id === "file-edit-validate" - ? { ...task, buildPrompt: piFilePrompt } : task, + task: suite.id === "confirmation-replies" && profile.id === "runner-codex" && environment.id === "daytona" && task.id === "task-card-accept" + ? { ...task, expectedRunCount: 4, automaticRetryPolicy: "single_attempt" as const } + : suite.id === "extended-harnesses" && profile.qualificationCandidate === "pi" && task.id === "file-edit-validate" + ? { ...task, buildPrompt: piFilePrompt } : task, groups: [ ...new Set([ ...suite.groups, diff --git a/tests/runner-e2e/completion-quality.test.ts b/tests/runner-e2e/completion-quality.test.ts index 991225f1098..6e2f91d18ee 100644 --- a/tests/runner-e2e/completion-quality.test.ts +++ b/tests/runner-e2e/completion-quality.test.ts @@ -1,17 +1,23 @@ import { describe, expect, it } from "vitest"; import { runsCompletionUpdateProbe, COMPLETION_QUALITY_CONFIG, completionQualityControls, completionQualityStatus, completionQualityRequest, judgeCompletionQuality, reserveCompletionQuality, validateCompletionQuality } from "./completion-quality.js"; import { runnerMatrix } from "./catalog.js"; +import { MANAGED_FIRST_TASK_CELL } from "./first-task-fixtures.js"; const observation = { sourceId: "chat", marker: "REF", worker: { id: "task", title: "Welcome", status: "done", completedAt: "2026-09-01T00:00:00Z" }, documents: [{ id: "doc", issueId: "task", key: "welcome", body: "Welcome to the garden. Meet at 10:30." }], comments: [{ id: "reply", issueId: "chat", authorAgentId: "agent", createdAt: "2026-09-01T00:01:00Z", body: "The note is ready at /issues/task", createdByRunId: "run" }], runs: [] }; const criteria = Object.keys(COMPLETION_QUALITY_CONFIG.rubric).map(id => ({ id, passed: true, rationale: "Supported by the saved note and reply", evidenceIds: ["reply", "doc"] })); const reports = [{ replyId: "reply", rationale: "Reports the completed task", completedTaskIdsReferenced: ["task"], resultAccessTaskIds: ["task"], correctsReplyIds: [] as string[] }]; describe("completion semantic qualification", () => { it.each(["runner-codex", "runner-acpx-claude"])("requires a completion judge only for accepted work on %s", profile => { - const cells = runnerMatrix.filter(e => e.suite.id === "confirmation-replies" && e.profile.id === profile); + const cells = runnerMatrix.filter(e => e.suite.id === "confirmation-replies" && e.profile.id === profile && e.environment.id === "local"); expect(cells).toHaveLength(6); expect(cells.filter(runsCompletionUpdateProbe).map(e => e.task.id).sort()).toEqual([ "interview-plan-accept", "task-card-accept", "task-reply-accept", ]); }); + it("requires the existing completion judge for accepted managed onboarding work", () => { + const managed = runnerMatrix.find(e => e.id === MANAGED_FIRST_TASK_CELL); + expect(managed).toBeDefined(); + expect(runsCompletionUpdateProbe(managed!)).toBe(true); + }); it("retains the completion suite's gate and excludes ordinary chat journeys", () => { const completion = runnerMatrix.filter(e => e.suite.id === "completion-updates"); expect(completion).toHaveLength(10); diff --git a/tests/runner-e2e/confirmation-replies.test.ts b/tests/runner-e2e/confirmation-replies.test.ts index ff7c79b4991..7b329395312 100644 --- a/tests/runner-e2e/confirmation-replies.test.ts +++ b/tests/runner-e2e/confirmation-replies.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { gradeConfirmationReply, assertAmbiguousReplyUnresolved, ambiguousConfirmationFixtures } from "./confirmation-replies.js"; import { firstTaskScenario } from "./first-task-cases.js"; import type { FirstTaskEvidence } from "./first-task-scoring.js"; -import { provisionFirstTaskFixtures } from "./first-task-fixtures.js"; +import { MANAGED_FIRST_TASK_CELL, provisionFirstTaskFixtures } from "./first-task-fixtures.js"; import { runnerMatrix } from "./catalog.js"; import { parseRunnerSelectors, selectRunnerExecutions } from "./selectors.js"; @@ -111,13 +111,15 @@ describe("confirmation-reply independent oracle", () => { payload: { questions: [{ prompt, options: [{ label: "Welcome note" }, { label: "Poster" }] }] }, }] })).toThrow(); }); - it.each(runnerMatrix.filter(e => e.suite.id === "confirmation-replies" && e.task.flow === "first_task"))("provisions the real onboarding fixture contract for $id", async execution => { + it.each(runnerMatrix.filter(e => e.suite.id === "confirmation-replies" && e.task.flow === "first_task" && e.environment.id === "local"))("provisions the real local onboarding fixture contract for $id", async execution => { const get = vi.fn().mockResolvedValue([{ id: "local", driver: "local" }]); + const patch = vi.fn().mockResolvedValue({ id: "company" }); const postSensitive = vi.fn().mockResolvedValue({ id: "secret" }); const credential = execution.profile.credential; - const fixtures = await provisionFirstTaskFixtures({ api: { get, postSensitive }, execution, nonce: "fixture", + const fixtures = await provisionFirstTaskFixtures({ api: { get, patch, postSensitive }, execution, nonce: "fixture", company: { id: "company", name: "Garden" }, credentials: { [credential]: "test-credential" } }); expect(get).toHaveBeenCalledExactlyOnceWith("/api/companies/company/environments?driver=local"); + expect(patch).toHaveBeenCalledExactlyOnceWith("/api/companies/company", { budgetMonthlyCents: 500 }); expect(postSensitive).toHaveBeenCalledExactlyOnceWith("/api/companies/company/secrets", expect.objectContaining({ key: credential })); expect(fixtures.agent.id).toBe(""); // The real wizard must still create the agent. expect(JSON.stringify(fixtures)).not.toContain("test-credential"); @@ -135,12 +137,14 @@ describe("confirmation-reply independent oracle", () => { originalIds: ["a", "b"], tasks: [], reply: "", agentId: "agent", answerId: "reply" })).toThrow(); }); - it("selects exactly twelve explicit-only cases using production native profiles", () => { + it("selects twelve local and one managed explicit-only cases using production native profiles", () => { const cells = runnerMatrix.filter(e => e.suite.id === "confirmation-replies"); - expect(cells).toHaveLength(12); + expect(cells).toHaveLength(13); + expect(cells.filter(c => c.environment.id === "local")).toHaveLength(12); + expect(cells.filter(c => c.environment.id === "daytona").map(c => c.id)).toEqual([MANAGED_FIRST_TASK_CELL]); expect(new Set(cells.map(c => c.profile.id))).toEqual(new Set(["runner-codex", "runner-acpx-claude"])); expect(new Set(cells.map(c => c.task.id)).size).toBe(6); - expect(selectRunnerExecutions(parseRunnerSelectors(["--suite", "confirmation-replies"]))).toHaveLength(12); + expect(selectRunnerExecutions(parseRunnerSelectors(["--suite", "confirmation-replies"]))).toHaveLength(13); expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(c => c.suite.id === "confirmation-replies")).toBe(false); }); }); diff --git a/tests/runner-e2e/first-task-fixtures.ts b/tests/runner-e2e/first-task-fixtures.ts index f0a6ecd2aa7..45f1d39df68 100644 --- a/tests/runner-e2e/first-task-fixtures.ts +++ b/tests/runner-e2e/first-task-fixtures.ts @@ -1,21 +1,70 @@ import { normalizePaperclipOperationalSkillPreference } from "../../packages/adapter-utils/src/server-utils.js"; +import { isDeepStrictEqual } from "node:util"; import type { RunnerApi } from "./api.js"; -import type { LiveFixtureValues } from "./live-fixtures.js"; +import { deleteDaytonaEnvironment, installFixtureDaytonaProvider, type LiveFixtureValues } from "./live-fixtures.js"; +import { isImmutableDaytonaImage } from "./catalog.js"; import type { CredentialName, MatrixExecution } from "./types.js"; -/** Provision only credentials for the UI-created company. The production wizard - * remains the sole creator/configurer of its first agent and onboarding task. */ +export const MANAGED_FIRST_TASK_CELL = "confirmation-replies.runner-codex.daytona.task-card-accept"; +export function isManagedFirstTask(execution: Pick): boolean { + return execution.id === MANAGED_FIRST_TASK_CELL; +} + +interface FirstTaskRuntimeBound { + timeoutSec: 180; + maxConcurrentRuns: 1; + before: Record; + after: Record; +} + +export type FirstTaskFixtureValues = LiveFixtureValues & { + onboardingRuntimeBound?: FirstTaskRuntimeBound; +}; + +export async function startFirstTaskWithRuntimeBound(input: { + api: Pick; + execution: MatrixExecution; + agent: Record; + observe: (bound: FirstTaskRuntimeBound) => void; + start: () => Promise; +}): Promise { + if (input.execution.profile.id === "runner-codex") { + const snapshot = (agent: Record) => structuredClone(Object.fromEntries([ + "id", "companyId", "adapterType", "adapterConfig", "runtimeConfig", "permissions", "defaultEnvironmentId", "budgetMonthlyCents", "metadata", + ].map(key => [key, agent?.[key]]))) as Record; + const before = snapshot(input.agent); + if (!before.id || !before.companyId || before.adapterType !== "paperclip_runner" || before.adapterConfig?.provider !== "codex") { + throw new Error("First-task runtime bound requires the wizard's original native Codex selection"); + } + const agentPath = `/api/agents/${encodeURIComponent(before.id)}`; + if (!isDeepStrictEqual(snapshot(await input.api.get(agentPath)), before)) { + throw new Error("The automatic wizard configuration changed before the first-task runtime bound"); + } + const runtimeConfig = { ...before.runtimeConfig, heartbeat: { ...before.runtimeConfig?.heartbeat, maxConcurrentRuns: 1 } }; + await input.api.patch(agentPath, { adapterConfig: { timeoutSec: 180 }, runtimeConfig }); + const after = snapshot(await input.api.get(agentPath)); + const expected = { ...before, adapterConfig: { ...before.adapterConfig, timeoutSec: 180 }, runtimeConfig }; + if (!isDeepStrictEqual(after, expected)) { + throw new Error("The first-task runtime bound was not confirmed with the original execution and account settings"); + } + input.observe({ timeoutSec: 180, maxConcurrentRuns: 1, before, after }); + } + await input.start(); +} + export async function provisionFirstTaskFixtures(input: { - api: Pick; + api: Pick & Partial>; execution: MatrixExecution; nonce: string; + daytonaImage?: string; company: LiveFixtureValues["company"]; credentials: Partial>; -}): Promise { +}): Promise { const { api, execution, nonce, company, credentials } = input; + const managed = isManagedFirstTask(execution); if ( !["first-task", "completion-updates", "confirmation-replies"].includes(execution.suite.id) || execution.task.flow !== "first_task" || - execution.environment.id !== "local" || + (execution.environment.id !== "local" && !managed) || ![ "legacy-codex", "legacy-claude", @@ -27,40 +76,89 @@ export async function provisionFirstTaskFixtures(input: { "First-task fixtures require a supported local onboarding profile", ); } - const credential = execution.profile.credential; - const value = credentials[credential]; - if (!value) throw new Error(`Missing credential ${credential}`); - const environments = await api.get>( - `/api/companies/${company.id}/environments?driver=local`, - ); - const environment = environments.find((e) => e.driver === "local"); - if (!environment) - throw new Error("Onboarding fixture local environment missing"); - const secret = await api.postSensitive<{ id: string }>( - `/api/companies/${company.id}/secrets`, - { - name: `First task ${credential}`, - key: credential, - value, - }, - ); + if (managed && (!isImmutableDaytonaImage(input.daytonaImage) || !api.post || !api.delete)) { + throw new Error("Managed onboarding requires the immutable image and normal environment create/cleanup APIs"); + } + const required = managed ? [execution.profile.credential, "DAYTONA_API_KEY" as const] : [execution.profile.credential]; + for (const name of required) if (!credentials[name]) throw new Error(`Missing credential ${name}`); + const previousSettings = managed ? await api.get<{ + defaultEnvironmentId: string | null; + experimental: { enableManagedSandboxOnly: boolean }; + }>("/api/instance/settings") : null; + if (managed && (!previousSettings || + (previousSettings.defaultEnvironmentId !== null && typeof previousSettings.defaultEnvironmentId !== "string") || + typeof previousSettings.experimental?.enableManagedSandboxOnly !== "boolean")) { + throw new Error("Managed onboarding fixture prior instance settings could not be read"); + } + await api.patch(`/api/companies/${company.id}`, { budgetMonthlyCents: 500 }); + let localEnvironment: LiveFixtureValues["environment"] | undefined; + if (!managed) { + const environments = await api.get>(`/api/companies/${company.id}/environments?driver=local`); + localEnvironment = environments.find(candidate => candidate.driver === "local"); + if (!localEnvironment) throw new Error("Onboarding fixture local environment missing"); + } + const secretRefs: LiveFixtureValues["secretRefs"] = {}; + for (const name of required) { + const secret = await api.postSensitive<{ id: string }>(`/api/companies/${company.id}/secrets`, { + name: `First task ${name}`, key: name, value: credentials[name], + }); + secretRefs[name] = { type: "secret_ref", secretId: secret.id, version: "latest" }; + } + let environment: LiveFixtureValues["environment"]; + let teardown = async () => {}; + if (managed) { + const remoteApi = api as Pick; + await installFixtureDaytonaProvider(remoteApi, execution); + environment = await remoteApi.post(`/api/companies/${company.id}/environments`, + execution.environment.buildEnvironment({ secretRefs, daytonaImage: input.daytonaImage, executionId: nonce })); + let restoreDefault = false; + let restoreManagedOnly = false; + let environmentDeleted = false; + teardown = async () => { + const failures: unknown[] = []; + if (restoreManagedOnly) { + try { + await api.patch("/api/instance/settings/experimental", { enableManagedSandboxOnly: previousSettings!.experimental.enableManagedSandboxOnly }); + restoreManagedOnly = false; + } catch (error) { failures.push(error); } + } + if (restoreDefault) { + try { + await api.patch("/api/instance/settings", { defaultEnvironmentId: previousSettings!.defaultEnvironmentId }); + restoreDefault = false; + } catch (error) { failures.push(error); } + } + if (!restoreDefault && !environmentDeleted) { + try { + await deleteDaytonaEnvironment(remoteApi, environment.id); + environmentDeleted = true; + } catch (error) { failures.push(error); } + } + if (failures.length) throw new AggregateError(failures, restoreDefault + ? "Managed onboarding fixture cleanup failed; instance default restoration is unconfirmed and environment deletion was not attempted" + : "Managed onboarding fixture cleanup failed"); + }; + try { + restoreDefault = true; + await api.patch("/api/instance/settings", { defaultEnvironmentId: environment.id }); + restoreManagedOnly = true; + await api.patch("/api/instance/settings/experimental", { enableManagedSandboxOnly: true }); + } catch (error) { + try { await teardown(); } catch (cleanup) { throw new AggregateError([error, cleanup], "Managed onboarding fixture setup and cleanup failed"); } + throw error; + } + } else environment = localEnvironment!; + return { company, environment, - secretRefs: { - [credential]: { - type: "secret_ref", - secretId: secret.id, - version: "latest", - }, - }, + secretRefs, agent: { id: "", name: `Garden lead ${nonce}`, companyId: company.id }, - teardown: async () => {}, // Existing launcher removes the complete isolated instance. + ...(managed ? { setupLeases: [], setupQualification: { recorded: false, adapterType: null, platformResponses: 0, adapterResponses: 0 } } : {}), + teardown, }; } -/** Reuse the qualified runtime configuration only, never the QA persona from - * buildAgent. Native onboarding is not offered by the production wizard yet. */ export function firstTaskNativeRuntimePatch( execution: MatrixExecution, fixtures: LiveFixtureValues, diff --git a/tests/runner-e2e/first-task-flow.ts b/tests/runner-e2e/first-task-flow.ts index 5a25b8f7e12..b190c824254 100644 --- a/tests/runner-e2e/first-task-flow.ts +++ b/tests/runner-e2e/first-task-flow.ts @@ -10,9 +10,12 @@ import { observeCompletionUpdate } from "./completion-update-flow.js"; import { firstTaskNativeRuntimePatch, provisionFirstTaskFixtures, + isManagedFirstTask, + startFirstTaskWithRuntimeBound, + type FirstTaskFixtureValues, } from "./first-task-fixtures.js"; import { execFileSync } from "node:child_process"; -import { expect, type Page } from "@playwright/test"; +import { expect, type Page, type Request, type Response } from "@playwright/test"; import { readFile } from "node:fs/promises"; import { pollUntil, type RunnerApi } from "./api.js"; import { @@ -21,10 +24,11 @@ import { collectChatRunEvidence, } from "./chat-flow.js"; import type { LiveFixtureValues } from "./live-fixtures.js"; -import type { CredentialName, MatrixExecution } from "./types.js"; +import type { CredentialName, MatrixExecution, RunnerE2EResult } from "./types.js"; import { firstTaskScenario } from "./first-task-cases.js"; import { activeRuns, + digestText, snapshotInstruction, gradeFirstTask, firstTaskCompletionSettled, @@ -33,16 +37,247 @@ import { type Row, } from "./first-task-scoring.js"; -/** The wizard creates the agent/task. Only credential provisioning uses Node fetch, - * keeping plaintext keys out of Playwright's trace and recorded form values. */ +export function projectFirstTaskResult(result: RunnerE2EResult): RunnerE2EResult { + const e = result.firstTask; + if (!e) return result; + const pick = (value: unknown, fields: readonly string[]): Row => { + const row = value && typeof value === "object" ? value as Record : {}; + return Object.fromEntries(fields.flatMap(key => { + const item = row[key]; + return item === null || ["string", "number", "boolean"].includes(typeof item) + ? [[key, item]] : []; + })) as Row; + }; + const rows = (value: unknown): Row[] => Array.isArray(value) + ? value.filter(row => row && typeof row === "object") : []; + const strings = (value: unknown): string[] => Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") : []; + const usage = (value: unknown) => { + const u = value as Row | undefined; + const measurements = ["inputTokens", "outputTokens", "cachedInputTokens", "cacheWriteTokens", "cacheReadTokens", "cachedReadTokens", "input", "output", "promptTokens", "completionTokens", "cacheAdjustedCostUsd", "costUsd", "providerCostUsd", "costUsdExact"]; + return { + ...pick(u, [...measurements, "model", "provider", "biller", "billingType", "costStatus", "usageSource", "accountingReceiptReady"]), + ...Object.fromEntries(["runDelta", "total", "cumulative"].filter(key => u?.[key]).map(key => [key, pick(u?.[key], measurements)])), + ...(u?.cost ? { cost: pick(u.cost, ["currency", "amount", "total"]) } : {}), + ...(u?.pricingProvenance ? { pricingProvenance: pick(u.pricingProvenance, ["source", "version", "contextTier", "serviceTier", "inputCentsPerMillion", "outputCentsPerMillion", "cacheWriteCentsPerMillion", "cachedInputCentsPerMillion"]) } : {}), + }; + }; + const questionSet = (value: unknown) => { + const set = value as Row | undefined; + return { ...pick(set, ["title", "description", "submitLabel"]), questions: rows(set?.questions).map(q => ({ + ...pick(q, ["id", "prompt", "header", "helpText", "required", "answerMode", "selectionMode", "allowOther"]), + options: rows(q.options).map(option => pick(option, ["id", "label", "description", "recommended", "freeText"])), + ...(q.customAnswer ? { customAnswer: pick(q.customAnswer, ["enabled", "label", "placeholder"]) } : {}), + })) }; + }; + const interaction = (row: Row): Row => { + const payload = row.payload as Row | undefined; + const answers = row.result?.answers; + const answerRows: Row[] = Array.isArray(answers) ? rows(answers) : answers && typeof answers === "object" + ? Object.entries(answers).map(([questionId, answer]) => ({ ...answer as Row, questionId })) : []; + return { + ...pick(row, ["id", "issueId", "kind", "status", "createdAt", "resolvedAt", "resolvedByAgentId"]), + ...(row.resolvedByUserId ? { resolvedByUserId: "fixture-board" } : {}), + payload: { + ...pick(payload, ["version", "title", "prompt", "detailsMarkdown", "acceptLabel", "rejectLabel", "rejectRequiresReason", "rejectReasonLabel", "submitLabel"]), + ...(payload?.questions ? questionSet(payload) : {}), + ...(payload?.questionSet ? { questionSet: questionSet(payload.questionSet) } : {}), + ...(payload?.target ? { target: pick(payload.target, ["type", "key", "label", "revisionNumber"]) } : {}), + }, + ...(row.result ? { result: { + ...pick(row.result, ["outcome", "reason", "commentId"]), + answers: answerRows.map(answer => ({ + ...pick(answer, ["id", "questionId", "text", "otherText", "customText"]), + optionIds: strings(answer.optionIds ?? answer.selectedOptionIds), + })), + } } : {}), + }; + }; + const privateInstructions = "Full instructions are retained in the access-controlled first-task snapshot."; + const settings = e.runtimeSettings as Record | undefined; + const checkpointIds = new Set(e.checkpoints.map(c => c.id)); + const firstTask: FirstTaskEvidence = { + caseId: e.caseId, nonce: e.nonce, onboardingIssueId: e.onboardingIssueId, + agentId: e.agentId, initialTaskIds: [...e.initialTaskIds], + ...(e.source ? { source: { sha: e.source.sha, ref: e.source.ref, dirty: e.source.dirty } } : {}), + configuredModel: e.configuredModel, observedModels: [...e.observedModels], + instructions: e.instructions.map(i => ({ + path: /^(?:\/|[A-Za-z]:)|(?:^|\/)\.\.(?:\/|$)/.test(i.path) ? "private-instruction-path" : i.path, + sha256: i.sha256, content: privateInstructions, contentSha256: digestText(privateInstructions), redacted: true, + })), + runtimeSettings: { + ...pick(settings, ["adapterType", "completionDeliveryProbe"]), + adapterConfig: pick(settings?.adapterConfig, ["provider", "model", "modelReasoningEffort", "lifecycleMode", "codexPermissionMode"]), + ...(settings?.onboardingRuntime ? { onboardingRuntime: pick(settings.onboardingRuntime, ["mode", "originalAdapterType", "testedAdapterType", "originalModel"]) } : {}), + }, + checkpoints: e.checkpoints.map(c => ({ + id: c.id, at: c.at, phase: c.phase, issueId: c.issueId, + tasks: c.tasks.map(t => pick(t, ["id", "identifier", "title", "description", "status", "parentId", "assigneeAgentId", "createdAt", "completedAt"])), + agents: c.agents.map(a => ({ ...pick(a, ["id", "name", "adapterType"]), + adapterConfig: pick(a.adapterConfig, ["provider", "model", "modelReasoningEffort", "lifecycleMode", "codexPermissionMode"]) })), + comments: c.comments.map(comment => ({ ...pick(comment, ["id", "issueId", "body", "authorAgentId", "createdAt"]), + ...(comment.authorUserId ? { authorUserId: "fixture-board" } : {}) })), + interactions: c.interactions.map(interaction), + documents: c.documents.map(d => pick(d, ["id", "issueId", "key", "title", "body", "format", "latestRevisionId", "latestRevisionNumber", "createdAt", "updatedAt"])), + attachments: (c.attachments ?? []).map(a => pick(a, ["id", "issueId", "title", "originalFilename", "filename", "body", "contentVerified", "contentSha256", "byteSize", "createdAt"])), + runs: c.runs.map(run => ({ ...pick(run, ["id", "issueId", "status", "startedAt", "finishedAt", "runtimeMode", "driverKind"]), + ...pick({ model: run.resultJson?.model ?? run.usageJson?.model }, ["model"]) })), + })), + checks: e.checks.map(check => ({ + id: check.id, passed: check.passed, + ...(check.notReached ? { notReached: "The journey did not reach this check; private evidence retains the reason." } : {}), + evidence: check.evidence.filter(ref => checkpointIds.has(ref)), + detail: `${check.id}: ${check.notReached ? "not reached" : check.passed ? "passed" : "failed"}. Full diagnostic evidence is retained in the access-controlled snapshot.`, + })), + }; + return { + ...result, firstTask, + ...(result.usage ? { usage: Array.isArray(result.usage.runs) + ? { runs: rows(result.usage.runs).map(run => ({ runId: run.runId, usage: run.usage ? usage(run.usage) : null })) } + : usage(result.usage) } : {}), + ...(result.error ? { error: "First-task journey failed; inspect the recorded checks and access-controlled evidence for the original error." } : {}), + matcherResults: (result.matcherResults ?? []).map(matcher => ({ + ...matcher, + detail: matcher.matcher.kind === "json_path" + ? firstTask.checks.find(check => matcher.matcher.kind === "json_path" && matcher.matcher.path === `firstTask.checks.${check.id}`)?.detail + ?? `${matcher.matcher.kind}: ${matcher.passed ? "passed" : "failed"}. Private evidence retains diagnostics.` + : `${matcher.matcher.kind}: ${matcher.passed ? "passed" : "failed"}. Private evidence retains diagnostics.`, + })), + }; +} + +export function managedFirstTaskFileCheck(e: FirstTaskEvidence) { + const last = e.checkpoints.at(-1); + const children = last?.tasks.filter(task => task.parentId === e.onboardingIssueId && task.status === "done") ?? []; + const marker = firstTaskScenario(e.caseId, e.nonce).marker; + const passed = children.length === 1 && (last?.attachments ?? []).some(attachment => + attachment.issueId === children[0]!.id && attachment.originalFilename === "garden-welcome.txt" + && /^text\/(?:plain|markdown)(?:;|$)/i.test(String(attachment.contentType)) + && attachment.contentVerified === true && typeof attachment.body === "string" + && attachment.contentSha256 === digestText(attachment.body) && attachment.body.includes(marker)); + return { id: "managed-onboarding-physical-artifact", passed, evidence: last ? [last.id] : [], + detail: "The completed child has a downloaded, size/hash-verified garden-welcome.txt attachment with the requested marker." }; +} + +export async function observeManagedFirstTaskSetup(page: Page, api: RunnerApi, fixtures: LiveFixtureValues, deadlineAt: number) { + const before = new Set((await api.get(`/api/environments/${fixtures.environment.id}/leases`)).map(lease => lease.id)); + const startedAt = Date.now(); + const qualification = fixtures.setupQualification!; + const operations: Promise[] = []; + let rejectFailure!: (error: unknown) => void; + const failure = new Promise((_resolve, reject) => { rejectFailure = reject; }); + void failure.catch(() => undefined); + const requestStarts = new WeakMap(); + const pendingSetupRequests = new Set(); + let platformEndedAt = Infinity; + let qualifiedDefault = false; + let automaticRequest = false; + function phase(request: Request): "platform" | "adapter" | "discovery" | null { + const url = new URL(request.url()); + if (request.method() === "POST" && url.pathname === `/api/environments/${fixtures.environment.id}/probe` + && url.searchParams.get("companyId") === fixtures.company.id) return "platform"; + if (request.method() === "POST" && url.pathname === `/api/companies/${fixtures.company.id}/adapters/codex_local/test-environment`) return "adapter"; + if (request.method() === "GET" && url.pathname === "/api/adapters" && url.searchParams.get("companyId") === fixtures.company.id + && url.searchParams.get("environmentId") === fixtures.environment.id) return "discovery"; + return null; + } + const onRequest = (request: Request) => { + const kind = phase(request); + if (kind) requestStarts.set(request, Date.now()); + if (kind === "platform" || kind === "adapter") pendingSetupRequests.add(request); + }; + const onRequestFailed = (request: Request) => { + if (pendingSetupRequests.has(request)) fixtures.setupLeaseAccountingIncomplete = true; + }; + const onResponse = (response: Response) => { + const request = response.request(); + const kind = phase(request); + if (!kind) return; + const responseAt = Date.now(); + if (kind === "platform") { qualification.platformResponses++; platformEndedAt = responseAt; } + if (kind === "adapter") qualification.adapterResponses++; + if (qualification.platformResponses > 1 || qualification.adapterResponses > 1) rejectFailure(new Error("The UI repeated a managed setup request")); + const operation = (async () => { + const body = await response.json() as Row | Row[]; + if (kind === "discovery") { + if (Array.isArray(body) && responseAt >= platformEndedAt) { + const codex = body.find(adapter => adapter.type === "codex_local"); + qualifiedDefault = codex?.defaultRunner === "paperclip" && codex?.supportedRunners?.includes("paperclip") === true; + if (!qualifiedDefault) rejectFailure(new Error("The selected image is not qualified for native Codex onboarding")); + } + return; + } + const data = body as Row; + if (kind === "platform") { + qualification.recorded = response.ok() && data.ok === true && data.details?.platformQualificationRecorded === true; + if (!qualification.recorded) rejectFailure(new Error("The UI environment qualification did not pass")); + } + else { + qualification.adapterType = response.ok() && data.status === "pass" ? data.adapterType : null; + if (qualification.adapterType !== "paperclip_runner") rejectFailure(new Error("The UI native runtime setup did not pass")); + automaticRequest = request.postDataJSON()?.runner === undefined && (requestStarts.get(request) ?? 0) >= platformEndedAt; + } + const leaseId = kind === "platform" ? data.details?.leaseId + : /(?:^|;\s*)paperclipLeaseId=([a-zA-Z0-9-]+)(?:;|$)/.exec(String(data.checks?.find((check: Row) => check.code === "sandbox_test_identity")?.detail ?? ""))?.[1]; + if (typeof leaseId !== "string" || !leaseId) { + fixtures.setupLeaseAccountingIncomplete = true; + return; + } + const lease = await api.get(`/api/environment-leases/${encodeURIComponent(leaseId)}`, { timeout: 10_000 }); + if (before.has(leaseId) || lease.companyId !== fixtures.company.id || lease.environmentId !== fixtures.environment.id + || lease.provider !== "daytona" || lease.heartbeatRunId || lease.issueId) throw new Error("Setup response references a foreign or non-setup lease"); + pendingSetupRequests.delete(request); + if (!fixtures.setupLeases!.some(entry => entry.leaseId === leaseId)) fixtures.setupLeases!.push({ leaseId, phase: kind, + durationMs: responseAt - (requestStarts.get(request) ?? startedAt) }); + })(); + void operation.catch(error => { fixtures.setupLeaseAccountingIncomplete = true; rejectFailure(error); }); + operations.push(operation); + }; + page.on("request", onRequest); + page.on("response", onResponse); + page.on("requestfailed", onRequestFailed); + const stop = async (requireSuccess: boolean) => { + page.off("request", onRequest); + page.off("response", onResponse); + page.off("requestfailed", onRequestFailed); + const settled = await Promise.allSettled(operations); + if (pendingSetupRequests.size > 0) fixtures.setupLeaseAccountingIncomplete = true; + const failures = settled.flatMap(result => result.status === "rejected" ? [result.reason] : []); + let leases: Row[]; + try { leases = await api.get(`/api/environments/${fixtures.environment.id}/leases`, { timeout: 10_000 }); } + catch (error) { fixtures.setupLeaseAccountingIncomplete = true; throw error; } + for (const lease of leases) { + if (before.has(lease.id) || lease.companyId !== fixtures.company.id || lease.environmentId !== fixtures.environment.id + || lease.provider !== "daytona" || lease.heartbeatRunId || lease.issueId) continue; + if (!fixtures.setupLeases!.some(entry => entry.leaseId === lease.id)) fixtures.setupLeaseAccountingIncomplete = true; + } + if (failures.length) throw new AggregateError(failures, "Managed onboarding setup evidence could not be verified"); + if (!requireSuccess) return; + if (!qualification.recorded || qualification.platformResponses !== 1 || qualification.adapterResponses !== 1 + || qualification.adapterType !== "paperclip_runner" || !automaticRequest || !qualifiedDefault + || fixtures.setupLeaseAccountingIncomplete || fixtures.setupLeases!.length !== 2 || !["platform", "adapter"].every(kind => fixtures.setupLeases!.some(lease => lease.phase === kind))) { + throw new Error("The UI did not prove one qualified automatic native setup with both owned leases"); + } + await pollUntil({ label: "managed onboarding setup lease cleanup", deadlineAt: Math.min(deadlineAt, Date.now() + 120_000), + load: () => api.get(`/api/environments/${fixtures.environment.id}/leases`), + accept: rows => fixtures.setupLeases!.every(entry => rows.some(row => row.id === entry.leaseId + && row.companyId === fixtures.company.id && row.environmentId === fixtures.environment.id + && row.status === "released" && row.cleanupStatus === "success")), + }); + }; + return Object.assign(stop, { failure }); +} + export async function setupFirstTaskFixtures(input: { page: Page; api: RunnerApi; execution: MatrixExecution; nonce: string; credentials: Partial>; + daytonaImage?: string; + deadlineAt?: number; observe: (fixtures: LiveFixtureValues) => void; -}): Promise { +}): Promise { const { page, api, execution, nonce } = input; await api.patch("/api/instance/settings/experimental", { enableClassicTaskInterface: false, @@ -67,6 +302,7 @@ export async function setupFirstTaskFixtures(input: { execution, nonce, credentials: input.credentials, + daytonaImage: input.daytonaImage, company: { id: company.id, name: company.name, @@ -93,33 +329,48 @@ export async function setupFirstTaskFixtures(input: { exact: true, }); const savedKey = page.getByRole("combobox", { name: "Saved API key" }); + if (execution.profile.id === "legacy-codex") { + 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(); + } // Credential mode depends on the selected provider and its asynchronous key lookup. await expect(savedKey.or(useKey).first()).toBeVisible(); if (await useKey.isVisible()) await useKey.click(); await page .getByRole("combobox", { name: "Saved API key" }) .selectOption(`company:${secret.secretId}`); - await page - .getByRole("button", { name: /^(Connect|Next)$/, exact: true }) - .last() - .click(); - await expect( - page.getByRole("button", { name: "Get started", exact: true }), - ).toBeVisible({ timeout: 120_000 }); + const managed = isManagedFirstTask(execution); + if (managed && (!input.deadlineAt || input.deadlineAt - Date.now() <= 120_000)) throw new Error("Managed onboarding has no bounded setup deadline"); + const stopSetup = managed ? await observeManagedFirstTaskSetup(page, api, fixtures, input.deadlineAt! - 60_000) : null; + let setupError: unknown; + try { + await page.getByRole("button", { name: /^(Connect|Next)$/, exact: true }).last().click(); + const visible = expect(page.getByRole("button", { name: "Get started", exact: true })).toBeVisible({ + timeout: managed ? input.deadlineAt! - Date.now() - 120_000 : 120_000, + }); + if (stopSetup) await Promise.race([visible, stopSetup.failure]); else await visible; + } catch (error) { setupError = error; } + try { await stopSetup?.(!setupError); } + catch (error) { setupError = setupError ? new AggregateError([setupError, error], "Managed onboarding and setup evidence failed") : error; } + if (setupError) throw setupError; const agents = await api.get(`/api/companies/${company.id}/agents`); expect(agents).toHaveLength(1); const wizardAdapter = execution.profile.credential === "OPENAI_API_KEY" - ? "codex_local" + ? execution.profile.id === "runner-codex" ? "paperclip_runner" : "codex_local" : "claude_local"; expect(agents[0].adapterType).toBe(wizardAdapter); + if (execution.profile.id === "runner-codex") { + expect(agents[0].adapterConfig?.provider).toBe("codex"); + } fixtures.onboardingRuntime = { mode: "production-wizard", originalAdapterType: wizardAdapter, testedAdapterType: wizardAdapter, originalModel: agents[0].adapterConfig?.model ?? null, }; - if (execution.profile.generation === "native") { + if (execution.profile.id === "runner-acpx-claude") { const runtimePatch = firstTaskNativeRuntimePatch( execution, fixtures, @@ -154,16 +405,27 @@ export async function setupFirstTaskFixtures(input: { name: agents[0].name, }; input.observe(fixtures); - await page.getByRole("button", { name: "Get started", exact: true }).click(); + if (managed) { + await page.reload({ waitUntil: "domcontentloaded" }); + const persisted = await api.get(`/api/agents/${fixtures.agent.id}`); + expect(persisted.adapterType).toBe("paperclip_runner"); + expect(persisted.adapterConfig?.provider).toBe("codex"); + } + await startFirstTaskWithRuntimeBound({ + api, execution, agent: agents[0], + observe: bound => { fixtures.onboardingRuntimeBound = bound; input.observe(fixtures); }, + start: () => page.getByRole("button", { name: "Get started", exact: true }).click(), + }); return fixtures; } export async function runFirstTaskFlow(input: { page: Page; api: RunnerApi; - fixtures: LiveFixtureValues; + fixtures: FirstTaskFixtureValues; execution: MatrixExecution; nonce: string; + deadlineAt?: number; observe: (issue: any, runs: any[], evidence: FirstTaskEvidence) => void; secrets: readonly string[]; createOrdinary: (title: string, prompt: string) => Promise; @@ -172,8 +434,14 @@ export async function runFirstTaskFlow(input: { }) { const { page, api, fixtures, execution, nonce } = input; const scenario = firstTaskScenario(execution.task.id, nonce); - const deadlineAt = - Date.now() + execution.task.attemptTimeoutMs.local - 120_000; + const managed = isManagedFirstTask(execution); + if (managed) { + const attachment = "Attach the finished note as a UTF-8 text file named garden-welcome.txt."; + scenario.prompt += ` ${attachment}`; + scenario.facts += ` ${attachment}`; + } + const deadlineAt = managed ? (input.deadlineAt ?? Date.now()) - 120_000 + : Date.now() + execution.task.attemptTimeoutMs.local - 120_000; const tasksPath = `/api/companies/${fixtures.company.id}/issues?limit=100`; const allRuns = async () => { const runs = await api.get( @@ -258,6 +526,7 @@ export async function runFirstTaskFlow(input: { }; e.checkpoints.push(checkpoint); e.checks = gradeFirstTask(e); + if (managed && e.checkpoints.at(-1)?.phase === "finished") e.checks.push(managedFirstTaskFileCheck(e)); if (execution.suite.id === "confirmation-replies" && scenario.id !== "task-card-accept") e.checks.push(...gradeConfirmationReply(e)); input.observe(issue, runs, e); await input.evidence("first-task.json", e); @@ -348,6 +617,7 @@ export async function runFirstTaskFlow(input: { e.runtimeSettings = { completionDeliveryProbe: runsCompletionUpdateProbe(execution), onboardingRuntime: fixtures.onboardingRuntime, + ...(fixtures.onboardingRuntimeBound ? { onboardingRuntimeBound: fixtures.onboardingRuntimeBound } : {}), adapterType: agent.adapterType, adapterConfig: agent.adapterConfig, runtimeConfig: agent.runtimeConfig, @@ -619,6 +889,7 @@ export async function runFirstTaskFlow(input: { await snapshot("finished"); } e.checks = gradeFirstTask(e); + if (managed && e.checkpoints.at(-1)?.phase === "finished") e.checks.push(managedFirstTaskFileCheck(e)); if (execution.suite.id === "confirmation-replies" && scenario.id !== "task-card-accept") e.checks.push(...gradeConfirmationReply(e)); await input.evidence("first-task.json", e); await input.capture( diff --git a/tests/runner-e2e/first-task.test.ts b/tests/runner-e2e/first-task.test.ts index d469f3a2811..a5b30654497 100644 --- a/tests/runner-e2e/first-task.test.ts +++ b/tests/runner-e2e/first-task.test.ts @@ -6,9 +6,15 @@ import { firstTaskRejectionReplyRecorded, isFirstTaskRejectionCancellation } fro import { createIssueThreadInteractionSchema } from "../../packages/shared/src/validators/issue.js"; import { renderInteractionCard } from "./interaction-report.js"; import { main as judgeCommand } from "./first-task-judge.js"; +import { projectFirstTaskResult, managedFirstTaskFileCheck, observeManagedFirstTaskSetup } from "./first-task-flow.js"; +import { validateRetainedRunnerResult } from "./result-validation.js"; +import { buildRuntimeUsage } from "./billing.js"; +import { fixtureRuntimeCostCoverage, selectFixtureRuntimeLeases } from "./live-fixtures.js"; import { firstTaskNativeRuntimePatch, provisionFirstTaskFixtures, + startFirstTaskWithRuntimeBound, + MANAGED_FIRST_TASK_CELL, } from "./first-task-fixtures.js"; import { describe, expect, it, vi } from "vitest"; import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"; @@ -31,6 +37,7 @@ import { QUALITY_DIMENSIONS, judgeFirstTask, pendingQuality, + qualityInput, validateQualityScores, } from "./first-task-quality.js"; import { renderFirstTaskDetails } from "./first-task-report.js"; @@ -216,6 +223,82 @@ const scores = () => evidence: ["response-1"], })); +describe("first-task public result projection", () => { + it("keeps fixture-visible evidence while excluding private records from both normalized result fields", () => { + const e = recording(); + const sentinel = "PRIVATE_SESSION_ACCOUNT_SENTINEL"; + e.instructions[0].content = sentinel; + e.instructions[0].path = `/private/${sentinel}/AGENTS.md`; + e.runtimeSettings = { + adapterType: "paperclip_runner", + adapterConfig: { provider: "codex", model: "gpt-5.6-sol", env: { OPENAI_API_KEY: sentinel }, codexHome: sentinel }, + runtimeConfig: { accountId: sentinel }, permissions: { privateTrace: sentinel }, + onboardingRuntime: { mode: "production-wizard", originalAdapterType: "paperclip_runner", testedAdapterType: "paperclip_runner", accountId: sentinel }, + onboardingRuntimeBound: { timeoutSec: 180, maxConcurrentRuns: 1, before: { runtimeConfig: { accountId: sentinel } }, after: { runtimeConfig: { accountId: sentinel } } }, + }; + for (const checkpoint of e.checkpoints) { + checkpoint.tasks.forEach(t => Object.assign(t, { executionState: { sessionId: sentinel }, responsibleUserId: sentinel })); + checkpoint.agents.forEach(a => Object.assign(a, { accountId: sentinel, metadata: { auth: sentinel }, adapterType: "paperclip_runner", + adapterConfig: { provider: "codex", model: "gpt-5.6-sol", env: { TOKEN: sentinel }, home: sentinel } })); + checkpoint.comments.forEach(c => Object.assign(c, { hiddenReasoning: sentinel, metadata: { sessionId: sentinel }, + ...(c.authorUserId ? { authorUserId: sentinel } : {}) })); + checkpoint.interactions.forEach(i => Object.assign(i, { nativeQuestionId: sentinel, metadata: { sessionId: sentinel }, + payload: { ...i.payload, providerSessionId: sentinel, toolAction: { secret: sentinel } }, + result: { ...i.result, outcome: "answered", refreshToken: sentinel } })); + checkpoint.documents.forEach(d => Object.assign(d, { privateStorageRef: sentinel })); + checkpoint.runs.forEach(r => Object.assign(r, { nativeSessionId: sentinel, sessionIdBefore: sentinel, sessionIdAfter: sentinel, + runnerProfileJson: { sessionCheckpoint: { providerSessionId: sentinel } }, contextSnapshot: { home: sentinel }, + resultJson: { model: "gpt-5.6-sol", privateAuth: sentinel }, stdoutExcerpt: sentinel, error: sentinel })); + } + e.checks = gradeFirstTask(e); + e.checks.push({ id: "native-session-continuity", passed: false, evidence: ["finished-3"], detail: sentinel }); + const original = structuredClone(e); + const raw = { ...result(e), error: sentinel, failureClass: "candidate_failure" as const, + usage: { runs: [{ runId: "parent-run", usage: { inputTokens: 100, outputTokens: 20, cachedInputTokens: 30, + costUsdExact: "0.499592800", costUsd: null, costStatus: "estimated", accountingReceiptReady: true, + pricingProvenance: { source: "rate_card", version: "openai-standard-2026-09-30", accountId: sentinel }, + persistedSessionId: sentinel, providerRequestId: sentinel, accountingReceiptSourceId: sentinel } }] }, + matcherResults: e.checks.map(c => ({ matcher: { kind: "json_path" as const, path: `firstTask.checks.${c.id}`, expected: true }, passed: c.passed, detail: c.detail })) }; + raw.matcherResults.push({ matcher: { kind: "json_path", path: "cleanup.provider-retired", expected: true }, passed: false, detail: sentinel }); + const projected = projectFirstTaskResult(raw); + const normalized = { results: [projected] }; + expect(JSON.stringify(normalized)).not.toContain(sentinel); + expect(JSON.stringify(raw)).toContain(sentinel); + expect(e).toEqual(original); + expect(projected.firstTask!.checks.map(c => ({ id: c.id, passed: c.passed, notReached: Boolean(c.notReached) }))) + .toEqual(e.checks.map(c => ({ id: c.id, passed: c.passed, notReached: Boolean(c.notReached) }))); + expect(projected.matcherResults!.map(c => ({ matcher: c.matcher, passed: c.passed }))) + .toEqual(raw.matcherResults.map(c => ({ matcher: c.matcher, passed: c.passed }))); + expect(projected.firstTask!.runtimeSettings).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" } }); + expect(projected.usage).toMatchObject({ runs: [{ runId: "parent-run", usage: { inputTokens: 100, outputTokens: 20, + cachedInputTokens: 30, costUsdExact: "0.499592800", costStatus: "estimated", accountingReceiptReady: true, + pricingProvenance: { source: "rate_card", version: "openai-standard-2026-09-30" } } }] }); + expect(summarizeExecutionBilling(projected)).toEqual(summarizeExecutionBilling(raw)); + expect(qualityInput(projected.firstTask!)).toContain(firstTaskScenario(e.caseId, e.nonce).acceptance); + expect(qualityInput(projected.firstTask!)).toContain(firstTaskScenario(e.caseId, e.nonce).marker); + expect(renderFirstTaskDetails(projected)).not.toContain(sentinel); + expect(projected.firstTask!.checkpoints.at(-1)!.documents[0].body).toBe(e.checkpoints.at(-1)!.documents[0].body); + validateRetainedRunnerResult(projected); + }); + + it("preserves failures, unreached checks, cleanup controls and private artifact hashes", () => { + const e = recording(); + e.checks = [{ id: "recorded-response", passed: false, evidence: ["response-1"], detail: "private diagnostic" }, + { id: "durable-completion", passed: false, notReached: "private interrupted-session reason", evidence: ["finished-3"], detail: "private diagnostic" }]; + const raw = { ...result(e), error: "private transport/session error", failureClass: "candidate_failure" as const, cleanup: "failed" as const, + matcherResults: [{ matcher: { kind: "json_path" as const, path: "cleanup.provider-retired", expected: true }, passed: false, detail: "private process details" }] }; + const projected = projectFirstTaskResult(raw); + expect(projected).toMatchObject({ status: "failed", failureClass: "candidate_failure", cleanup: "failed" }); + expect(projected.firstTask!.checks[0].passed).toBe(false); + expect(projected.firstTask!.checks[1]).toMatchObject({ passed: false, notReached: expect.any(String) }); + expect(projected.matcherResults![0]).toMatchObject({ matcher: raw.matcherResults[0].matcher, passed: false }); + expect(projected.firstTask!.instructions[0]).toMatchObject({ sha256: e.instructions[0].sha256, redacted: true }); + expect(raw.error).toBe("private transport/session error"); + expect(projected.error).not.toContain("private transport/session error"); + expect(projectFirstTaskResult(projected)).toEqual(projected); + }); +}); + describe("first-task attachment evidence", () => { it("reads persisted bytes, verifies their hash, and redacts the retained text", async () => { const body = "Welcome SECRET-VALUE"; @@ -695,9 +778,10 @@ Accept the card above and I write it. This task stays in review until then.`; (e) => e.suite.id === "first-task" && e.profile.id === "legacy-codex", )!; const get = vi.fn().mockResolvedValue([{ id: "local", driver: "local" }]); + const patch = vi.fn().mockResolvedValue({}); const postSensitive = vi.fn().mockResolvedValue({ id: "encrypted-secret" }); const fixtures = await provisionFirstTaskFixtures({ - api: { get, postSensitive }, + api: { get, patch, postSensitive }, execution, nonce: "test", company: { @@ -710,6 +794,9 @@ Accept the card above and I write it. This task stays in review until then.`; expect(get).toHaveBeenCalledExactlyOnceWith( "/api/companies/ui-created-company/environments?driver=local", ); + expect(patch).toHaveBeenCalledExactlyOnceWith( + "/api/companies/ui-created-company", { budgetMonthlyCents: 500 }, + ); expect(postSensitive).toHaveBeenCalledExactlyOnceWith( "/api/companies/ui-created-company/secrets", expect.objectContaining({ key: "OPENAI_API_KEY", value: "fixture-key" }), @@ -723,7 +810,7 @@ Accept the card above and I write it. This task stays in review until then.`; expect(JSON.stringify(fixtures)).not.toContain("fixture-key"); await expect( provisionFirstTaskFixtures({ - api: { get, postSensitive }, + api: { get, patch, postSensitive }, execution, nonce: "test", company: fixtures.company, @@ -733,6 +820,110 @@ Accept the card above and I write it. This task stays in review until then.`; expect(postSensitive).toHaveBeenCalledTimes(1); }); + describe("bounded automatic Codex onboarding admission", () => { + function harness(environment = "local", profile = "runner-codex") { + const execution = runnerMatrix.find(entry => entry.task.flow === "first_task" && entry.profile.id === profile && entry.environment.id === environment)!; + const original = { + id: "agent", companyId: "company", adapterType: "paperclip_runner", + adapterConfig: { provider: "codex", timeoutSec: 0, model: "gpt-5.6-sol", modelReasoningEffort: "high", codexPermissionMode: "readOnly", + env: { OPENAI_API_KEY: { type: "secret_ref", secretId: "encrypted-key", version: "latest" } }, paperclipSkillSync: { desiredSkills: ["paperclipai/paperclip/first-task"] } }, + runtimeConfig: { aiConnection: { provider: "openai", method: "api_key", mode: "responsible_user" }, + heartbeat: { maxConcurrentRuns: 20, enabled: false, intervalSec: 0, wakeOnAssignment: true, customHeartbeatSetting: "retained" }, customSetting: "retained" }, + permissions: { canCreateAgents: true }, defaultEnvironmentId: environment, budgetMonthlyCents: 500, metadata: { ownerUserId: "qa-user" }, + }; + let stored = structuredClone(original); + const calls: string[] = []; + const api = { + get: vi.fn(async (_path: string) => { calls.push("GET"); return structuredClone(stored); }), + patch: vi.fn(async (_path: string, data: any) => { + calls.push("PATCH"); + stored.adapterConfig = { ...stored.adapterConfig, ...data.adapterConfig }; + if (data.runtimeConfig) stored.runtimeConfig = structuredClone(data.runtimeConfig); + return structuredClone(stored); + }), + }; + const observe = vi.fn(() => { calls.push("OBSERVE"); }); + const start = vi.fn(async () => { calls.push("START"); }); + const admit = () => startFirstTaskWithRuntimeBound({ api: api as any, execution, agent: original, observe, start }); + return { api, original, calls, observe, start, admit, stored: () => stored }; + } + + it.each(["local", "daytona"])("bounds %s without choosing a runner, provider, model, or account", async environment => { + const h = harness(environment); + const original = structuredClone(h.original); + const expected = { ...original, adapterConfig: { ...original.adapterConfig, timeoutSec: 180 }, + runtimeConfig: { ...original.runtimeConfig, heartbeat: { ...original.runtimeConfig.heartbeat, maxConcurrentRuns: 1 } } }; + await h.admit(); + expect(h.stored()).toEqual(expected); + expect(h.api.patch).toHaveBeenCalledExactlyOnceWith("/api/agents/agent", { adapterConfig: { timeoutSec: 180 }, runtimeConfig: expected.runtimeConfig }); + expect(h.calls).toEqual(["GET", "PATCH", "GET", "OBSERVE", "START"]); + expect(h.original).toEqual(original); + expect(h.observe).toHaveBeenCalledExactlyOnceWith({ timeoutSec: 180, maxConcurrentRuns: 1, before: original, after: expected }); + }); + + it.each(["read-failure", "patch-failure", "verification-failure", "missing-timeout", "missing-concurrency"])("does not admit task execution after %s", async mode => { + const h = harness(); + if (mode === "read-failure") h.api.get.mockRejectedValue(new Error("Agent read failed")); + if (mode === "patch-failure") h.api.patch.mockRejectedValue(new Error("Timeout patch failed")); + if (mode === "verification-failure") h.api.get.mockResolvedValueOnce(structuredClone(h.original)).mockRejectedValueOnce(new Error("Verification read failed")); + if (mode === "missing-timeout") h.api.patch.mockImplementation(async (_path, data) => { + h.stored().runtimeConfig = structuredClone(data.runtimeConfig); + return structuredClone(h.stored()); + }); + if (mode === "missing-concurrency") h.api.patch.mockImplementation(async (_path, data) => { + h.stored().adapterConfig = { ...h.stored().adapterConfig, ...data.adapterConfig }; + return structuredClone(h.stored()); + }); + await expect(h.admit()).rejects.toThrow(); + expect(h.start).not.toHaveBeenCalled(); + expect(h.observe).not.toHaveBeenCalled(); + if (mode === "read-failure") expect(h.api.patch).not.toHaveBeenCalled(); + }); + + it.each(["legacy", "wrong-provider", "foreign-agent", "changed-account"])("rejects %s before changing the automatic wizard choice", async mode => { + const h = harness(); + if (mode === "legacy") h.original.adapterType = "codex_local"; + if (mode === "wrong-provider") h.original.adapterConfig.provider = "claude"; + if (mode === "foreign-agent") h.api.get.mockResolvedValue({ ...h.original, companyId: "another-company" }); + if (mode === "changed-account") h.api.get.mockResolvedValue({ ...h.original, runtimeConfig: { ...h.original.runtimeConfig, + aiConnection: { ...h.original.runtimeConfig.aiConnection, method: "subscription" } } }); + await expect(h.admit()).rejects.toThrow(); + expect(h.api.patch).not.toHaveBeenCalled(); + expect(h.start).not.toHaveBeenCalled(); + expect(h.observe).not.toHaveBeenCalled(); + }); + + it.each(["runner", "provider", "model", "account", "permissions", "environment", "instructions", "heartbeat-policy", "other-runtime", "missing-heartbeat"])("refuses a persisted %s change instead of treating the bound PATCH as proof", async field => { + const h = harness(); + const altered = structuredClone(h.original); + altered.adapterConfig.timeoutSec = 180; + altered.runtimeConfig.heartbeat.maxConcurrentRuns = 1; + if (field === "runner") altered.adapterType = "codex_local"; + if (field === "provider") altered.adapterConfig.provider = "claude"; + if (field === "model") altered.adapterConfig.model = "another-model"; + if (field === "account") altered.runtimeConfig.aiConnection.mode = "fixed"; + if (field === "permissions") altered.permissions.canCreateAgents = false; + if (field === "environment") altered.defaultEnvironmentId = "another-environment"; + if (field === "instructions") altered.adapterConfig.paperclipSkillSync.desiredSkills = []; + if (field === "heartbeat-policy") altered.runtimeConfig.heartbeat.wakeOnAssignment = false; + if (field === "other-runtime") altered.runtimeConfig.customSetting = "lost"; + if (field === "missing-heartbeat") delete (altered.runtimeConfig as Partial).heartbeat; + h.api.get.mockResolvedValueOnce(structuredClone(h.original)).mockResolvedValueOnce(altered); + await expect(h.admit()).rejects.toThrow("runtime bound was not confirmed"); + expect(h.start).not.toHaveBeenCalled(); + expect(h.observe).not.toHaveBeenCalled(); + }); + + it.each(["legacy-codex", "legacy-claude", "runner-acpx-claude"])("leaves %s admission unchanged", async profile => { + const h = harness("local", profile); + await h.admit(); + expect(h.api.get).not.toHaveBeenCalled(); + expect(h.api.patch).not.toHaveBeenCalled(); + expect(h.observe).not.toHaveBeenCalled(); + expect(h.start).toHaveBeenCalledTimes(1); + }); + }); + it.each(["runner-codex", "runner-acpx-claude"])( "switches only the runtime for %s, preserving onboarding assets and the default model", (id) => { @@ -1360,3 +1551,342 @@ describe("recorded refusal cancellation", () => { } }); }); + +describe("managed onboarding through the production wizard", () => { + const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`; + const execution = () => runnerMatrix.find(entry => entry.id === MANAGED_FIRST_TASK_CELL)!; + + function managedFixtureApi(previousDefault: string | null = "previous-default", previousManagedOnly = false) { + const state = { defaultEnvironmentId: previousDefault, experimental: { enableManagedSandboxOnly: previousManagedOnly, enableAgentChat: true } }; + const calls: string[] = []; + const api = { + get: vi.fn(async () => structuredClone(state)), + patch: vi.fn(async (path: string, data: any) => { + calls.push(`PATCH ${path}`); + if (path === "/api/instance/settings") state.defaultEnvironmentId = data.defaultEnvironmentId; + if (path === "/api/instance/settings/experimental") Object.assign(state.experimental, data); + return structuredClone(state); + }), + postSensitive: vi.fn(async (_path: string, data: any) => ({ id: `encrypted-${data.key}` })), + post: vi.fn(async (path: string, data: any) => { + calls.push(`POST ${path}`); + return path === "/api/plugins/install" ? { id: "plugin", status: "ready" } : { id: "environment", driver: "sandbox", config: data.config }; + }), + delete: vi.fn(async (path: string) => { calls.push(`DELETE ${path}`); }), + }; + const provision = () => provisionFirstTaskFixtures({ api: api as any, execution: execution(), nonce: "private", company: { id: "company", name: "Fresh" }, + daytonaImage: image, credentials: { OPENAI_API_KEY: "synthetic-key", DAYTONA_API_KEY: "synthetic-platform" } }); + return { api, state, calls, provision }; + } + + it.each([[null, false], ["previous-default", false], ["previous-default", true]] as const)( + "restores prior default %s and managed-only %s before deleting the managed fixture", async (previousDefault, previousManagedOnly) => { + const h = managedFixtureApi(previousDefault, previousManagedOnly); + const fixtures = await h.provision(); + expect(h.state).toMatchObject({ defaultEnvironmentId: "environment", experimental: { enableManagedSandboxOnly: true } }); + let deletedState: typeof h.state | undefined; + h.api.delete.mockImplementation(async () => { deletedState = structuredClone(h.state); }); + await fixtures.teardown(); + expect(deletedState).toMatchObject({ defaultEnvironmentId: previousDefault, experimental: { enableManagedSandboxOnly: previousManagedOnly } }); + expect(h.api.get).toHaveBeenCalledExactlyOnceWith("/api/instance/settings"); + expect(h.api.patch).toHaveBeenLastCalledWith("/api/instance/settings", { defaultEnvironmentId: previousDefault }); + expect(h.state.experimental.enableAgentChat).toBe(true); + expect(h.api.delete).toHaveBeenCalledExactlyOnceWith("/api/environments/environment?destroyReusableSandboxLeases=true", { allowNotFound: true }); + }, + ); + + it.each(["default", "managed-only"])("restores a %s setup write whose response is lost after committing", async phase => { + const h = managedFixtureApi(); + const patch = h.api.patch.getMockImplementation()!; + let failed = false; + h.api.patch.mockImplementation(async (path, data) => { + const result = await patch(path, data); + if (!failed && (phase === "default" ? data.defaultEnvironmentId === "environment" : data.enableManagedSandboxOnly === true)) { + failed = true; + throw new Error("Setup response lost after commit"); + } + return result; + }); + await expect(h.provision()).rejects.toThrow("Setup response lost after commit"); + expect(h.state).toEqual({ defaultEnvironmentId: "previous-default", experimental: { enableManagedSandboxOnly: false, enableAgentChat: true } }); + expect(h.api.delete).toHaveBeenCalledTimes(1); + if (phase === "default") expect(h.api.patch.mock.calls.some(([path]) => path === "/api/instance/settings/experimental")).toBe(false); + }); + + it("retains setup and restoration failures instead of deleting an unconfirmed instance default", async () => { + const h = managedFixtureApi(); + const patch = h.api.patch.getMockImplementation()!; + h.api.patch.mockImplementation(async (path, data) => { + if (path === "/api/instance/settings/experimental" && data.enableManagedSandboxOnly === true) throw new Error("Managed mode setup failed"); + if (path === "/api/instance/settings" && data.defaultEnvironmentId === "previous-default") throw new Error("Default restoration failed"); + return patch(path, data); + }); + const error = await h.provision().catch(error => error); + expect(error).toBeInstanceOf(AggregateError); + expect(error.errors[0].message).toBe("Managed mode setup failed"); + expect(error.errors[1].errors[0].message).toBe("Default restoration failed"); + expect(error.errors[1].message).toContain("environment deletion was not attempted"); + expect(h.api.delete).not.toHaveBeenCalled(); + expect(h.state.defaultEnvironmentId).toBe("environment"); + }); + + it("keeps failed default restoration outstanding and only deletes after a later confirmed restoration", async () => { + const h = managedFixtureApi(); + const fixtures = await h.provision(); + const patch = h.api.patch.getMockImplementation()!; + h.api.patch.mockImplementation(async (path, data) => { + if (path === "/api/instance/settings") throw new Error("Default restoration failed"); + return patch(path, data); + }); + await expect(fixtures.teardown()).rejects.toThrow("environment deletion was not attempted"); + expect(h.state).toMatchObject({ defaultEnvironmentId: "environment", experimental: { enableManagedSandboxOnly: false } }); + expect(h.api.delete).not.toHaveBeenCalled(); + h.api.patch.mockImplementation(patch); + await fixtures.teardown(); + await fixtures.teardown(); + expect(h.state.defaultEnvironmentId).toBe("previous-default"); + expect(h.api.delete).toHaveBeenCalledTimes(1); + }); + + it("reports failed managed-mode restoration while still removing an environment that is no longer the default", async () => { + const h = managedFixtureApi(); + const fixtures = await h.provision(); + h.state.experimental.enableAgentChat = false; + const patch = h.api.patch.getMockImplementation()!; + h.api.patch.mockImplementation(async (path, data) => { + if (path === "/api/instance/settings/experimental") throw new Error("Managed mode restoration failed"); + return patch(path, data); + }); + const error = await fixtures.teardown().catch(error => error); + expect(error).toBeInstanceOf(AggregateError); + expect(error.errors.map((error: Error) => error.message)).toEqual(["Managed mode restoration failed"]); + expect(h.state.defaultEnvironmentId).toBe("previous-default"); + expect(h.api.delete).toHaveBeenCalledTimes(1); + h.api.patch.mockImplementation(patch); + await fixtures.teardown(); + expect(h.state.experimental).toEqual({ enableManagedSandboxOnly: false, enableAgentChat: false }); + expect(h.api.delete).toHaveBeenCalledTimes(1); + }); + + it("keeps failed environment deletion red after successful settings restoration", async () => { + const h = managedFixtureApi(); + const fixtures = await h.provision(); + h.api.delete.mockRejectedValue(new Error("Provider cleanup unconfirmed")); + vi.useFakeTimers(); + try { + const rejected = expect(fixtures.teardown()).rejects.toThrow("Managed onboarding fixture cleanup failed"); + await vi.runAllTimersAsync(); + await rejected; + expect(h.state).toMatchObject({ defaultEnvironmentId: "previous-default", experimental: { enableManagedSandboxOnly: false } }); + h.api.delete.mockResolvedValue(); + await fixtures.teardown(); + const deletes = h.api.delete.mock.calls.length; + await fixtures.teardown(); + expect(h.api.delete).toHaveBeenCalledTimes(deletes); + } finally { vi.useRealTimers(); } + }); + + it.each(["read-failure", "missing-default", "missing-mode"])("fails before fixture mutations when prior settings have %s", async mode => { + const h = managedFixtureApi(); + if (mode === "read-failure") h.api.get.mockRejectedValue(new Error("Settings read failed")); + else h.api.get.mockResolvedValue((mode === "missing-default" ? { experimental: { enableManagedSandboxOnly: false } } : { defaultEnvironmentId: null, experimental: {} }) as any); + await expect(h.provision()).rejects.toThrow(mode === "read-failure" ? "Settings read failed" : "prior instance settings could not be read"); + expect(h.api.patch).not.toHaveBeenCalled(); + expect(h.api.postSensitive).not.toHaveBeenCalled(); + expect(h.api.post).not.toHaveBeenCalled(); + expect(h.api.delete).not.toHaveBeenCalled(); + }); + + it("prepares only encrypted credentials and an immutable managed default, with no fixture probe or agent", async () => { + const calls: string[] = []; + const api = { + get: vi.fn(async () => ({ defaultEnvironmentId: null, experimental: { enableManagedSandboxOnly: false } })), + patch: vi.fn(async (path: string) => { calls.push(`PATCH ${path}`); return {}; }), + postSensitive: vi.fn(async (_path: string, data: any) => ({ id: `encrypted-${data.key}` })), + post: vi.fn(async (path: string, data: any) => { + calls.push(`POST ${path}`); + if (path === "/api/plugins/install") return { id: "plugin", status: "ready" }; + expect(path).toBe("/api/companies/company/environments"); + expect(data.config).toMatchObject({ image, apiKey: { type: "secret_ref", secretId: "encrypted-DAYTONA_API_KEY", version: "latest" } }); + expect(data).not.toHaveProperty("metadata"); + return { id: "environment", driver: "sandbox", config: data.config }; + }), + delete: vi.fn(async () => {}), + }; + const fixtures = await provisionFirstTaskFixtures({ api: api as any, execution: execution(), nonce: "private", company: { id: "company", name: "Fresh" }, + daytonaImage: image, credentials: { OPENAI_API_KEY: "synthetic-key", DAYTONA_API_KEY: "synthetic-platform" } }); + expect(calls).toEqual(["PATCH /api/companies/company", "POST /api/plugins/install", "POST /api/companies/company/environments", "PATCH /api/instance/settings", "PATCH /api/instance/settings/experimental"]); + expect(api.patch).toHaveBeenCalledWith("/api/instance/settings", { defaultEnvironmentId: "environment" }); + expect(api.patch).toHaveBeenCalledWith("/api/instance/settings/experimental", { enableManagedSandboxOnly: true }); + expect(fixtures.agent.id).toBe(""); + expect(fixtures.setupLeases).toEqual([]); + expect(JSON.stringify(fixtures)).not.toContain("synthetic"); + await fixtures.teardown(); + expect(api.delete).toHaveBeenCalledExactlyOnceWith("/api/environments/environment?destroyReusableSandboxLeases=true", { allowNotFound: true }); + }); + + it.each(["missing-key", "mutable-image", "other-cell"])("rejects %s before admitting a managed browser fixture", async mode => { + const api = { get: vi.fn(), patch: vi.fn(), postSensitive: vi.fn(), post: vi.fn(), delete: vi.fn() }; + await expect(provisionFirstTaskFixtures({ api, execution: mode === "other-cell" ? { ...execution(), id: "other" } : execution(), nonce: "private", company: { id: "company", name: "Fresh" }, + daytonaImage: mode === "mutable-image" ? "ubuntu:24.04" : image, + credentials: mode === "missing-key" ? { OPENAI_API_KEY: "synthetic" } : { OPENAI_API_KEY: "synthetic", DAYTONA_API_KEY: "synthetic" } })).rejects.toThrow(); + expect(api.post).not.toHaveBeenCalled(); + expect(api.postSensitive).not.toHaveBeenCalled(); + }); + + function fileEvidence() { + const e = recording("task-card-accept"); + const last = e.checkpoints.at(-1)!; + last.tasks = [{ id: "child", parentId: e.onboardingIssueId, status: "done" }]; + const body = `Welcome beginners. Join our free Saturday meetup ${firstTaskScenario(e.caseId, e.nonce).marker}.`; + last.attachments = [{ id: "file", issueId: "child", originalFilename: "garden-welcome.txt", contentType: "text/plain", body, + contentVerified: true, contentSha256: digestText(body) }]; + return e; + } + it("requires the downloaded verified physical artifact", () => { + expect(managedFirstTaskFileCheck(fileEvidence()).passed).toBe(true); + }); + it.each(["document-only", "unverified", "wrong-hash", "wrong-child", "wrong-name", "wrong-marker"])("rejects %s as physical-file proof", async mode => { + const e = fileEvidence(); + const last = e.checkpoints.at(-1)!; + const file = last.attachments![0]!; + if (mode === "document-only") { last.documents = [{ ...file, key: "welcome" }]; last.attachments = []; } + if (mode === "unverified") file.contentVerified = false; + if (mode === "wrong-hash") file.contentSha256 = "0".repeat(64); + if (mode === "wrong-child") file.issueId = "foreign"; + if (mode === "wrong-name") file.originalFilename = "plan.txt"; + if (mode === "wrong-marker") { file.body = "A different welcome note"; file.contentSha256 = digestText(file.body); } + expect(managedFirstTaskFileCheck(e).passed).toBe(false); + }); + + function setupHarness(mode = "success") { + const listeners = new Map void>>(); + const page = { on: (name: string, callback: (event: any) => void) => { const set = listeners.get(name) ?? new Set(); set.add(callback); listeners.set(name, set); }, + off: (name: string, callback: (event: any) => void) => listeners.get(name)?.delete(callback) }; + const fixtures = { company: { id: "company", name: "Fresh" }, environment: { id: "environment", driver: "sandbox" }, + agent: { id: "", companyId: "company", name: "Ada" }, secretRefs: {}, setupLeases: [], + setupQualification: { recorded: false, adapterType: null, platformResponses: 0, adapterResponses: 0 }, teardown: async () => {} } as import("./live-fixtures.js").LiveFixtureValues; + const rows = ["platform", "adapter"].map(id => ({ id, companyId: "company", environmentId: "environment", provider: "daytona", heartbeatRunId: null, issueId: null, + status: mode === "cleanup" ? "failed" : "released", cleanupStatus: "success" })); + if (mode === "foreign") rows[0]!.companyId = "another-company"; + let begun = false; + const api = { get: vi.fn(async (path: string) => path.includes("environment-leases/") ? rows.find(row => path.endsWith(`/${row.id}`)) + : begun ? [...rows, { ...rows[0], id: "unrelated", issueId: "other-task" }] : []) } as unknown as RunnerApi; + const start = (kind: "platform" | "discovery" | "adapter", runner?: string) => { + begun = true; + const request = { url: () => `http://fixture.test${kind === "platform" ? "/api/environments/environment/probe?companyId=company" + : kind === "discovery" ? "/api/adapters?companyId=company&environmentId=environment" : "/api/companies/company/adapters/codex_local/test-environment"}`, + method: () => kind === "discovery" ? "GET" : "POST", postDataJSON: () => runner ? { runner } : {} }; + for (const listener of listeners.get("request") ?? []) listener(request); + return request; + }; + const respond = (request: ReturnType, body: any) => { + const response = { request: () => request, json: async () => body, ok: () => true }; + for (const listener of listeners.get("response") ?? []) listener(response); + }; + const emit = (kind: "platform" | "discovery" | "adapter", body: any, runner?: string) => respond(start(kind, runner), body); + const fail = (request: ReturnType) => { + for (const listener of listeners.get("requestfailed") ?? []) listener(request); + }; + return { fixtures, page: page as unknown as import("@playwright/test").Page, api, emit, start, respond, fail, listeners, rows }; + } + + it.each(["missing-response", "request-failed"])("retains unknown adapter setup cost after %s without an attributable row", async mode => { + const h = setupHarness(); + h.rows.splice(1); + const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, Date.now() + 1000); + h.emit("platform", { ok: true, details: { platformQualificationRecorded: true, leaseId: "platform" } }); + const request = h.start("adapter"); + if (mode === "request-failed") h.fail(request); + await stop(false); + expect(h.fixtures.setupLeases!.map(row => row.leaseId)).toEqual(["platform"]); + expect(h.fixtures.setupLeaseAccountingIncomplete).toBe(true); + expect(h.fixtures.setupQualification!.adapterResponses).toBe(0); + const leases = selectFixtureRuntimeLeases({ leases: h.rows.map(row => ({ ...row, + acquiredAt: "2026-10-13T00:00:00Z", releasedAt: "2026-10-13T00:00:02Z", metadata: { cpu: 4, memory: 4, disk: 10 } })), + runIds: [], setupLeaseIds: h.fixtures.setupLeases!.map(row => row.leaseId) }); + const usage = fixtureRuntimeCostCoverage(buildRuntimeUsage({ environmentId: "daytona", runs: [], leases }), h.fixtures); + expect(usage.leaseCount).toBe(1); + expect(usage.costStatus).toBe("unavailable"); + expect(usage).not.toHaveProperty("estimatedListCostUsd"); + expect([...h.listeners.values()].every(listeners => listeners.size === 0)).toBe(true); + }); + + it("retains unknown failed platform setup without a response or visible lease row", async () => { + const h = setupHarness(); + h.rows.splice(0); + const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, Date.now() + 1000); + h.fail(h.start("platform")); + await stop(false); + expect(h.fixtures.setupLeases).toEqual([]); + expect(h.fixtures.setupLeaseAccountingIncomplete).toBe(true); + expect(h.fixtures.setupQualification!.recorded).toBe(false); + }); + + it("does not clear failed-request accounting uncertainty when later responses correlate", async () => { + const h = setupHarness(); + const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, Date.now() + 1000); + h.emit("platform", { ok: true, details: { platformQualificationRecorded: true, leaseId: "platform" } }); + const request = h.start("adapter"); + h.fail(request); + h.respond(request, { adapterType: "paperclip_runner", status: "pass", checks: [{ code: "sandbox_test_identity", detail: "paperclipLeaseId=adapter" }] }); + await stop(false); + expect(h.fixtures.setupLeases!.map(row => row.leaseId)).toEqual(["platform", "adapter"]); + expect(h.fixtures.setupLeaseAccountingIncomplete).toBe(true); + }); + + it.each(["success", "missing-discovery", "explicit-runner", "duplicate", "foreign", "cleanup"])("verifies exact UI setup responses and owned cleanup for %s", async mode => { + const h = setupHarness(mode); + const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, mode === "cleanup" ? Date.now() - 1 : Date.now() + 1000); + const platform = { ok: true, details: { platformQualificationRecorded: true, leaseId: "platform" } }; + h.emit("platform", platform); + if (mode === "duplicate") h.emit("platform", platform); + if (mode !== "missing-discovery") h.emit("discovery", [{ type: "codex_local", defaultRunner: "paperclip", supportedRunners: ["paperclip", "legacy"] }]); + h.emit("adapter", { adapterType: "paperclip_runner", status: "pass", checks: [{ code: "sandbox_test_identity", detail: "paperclipLeaseId=adapter; providerLeaseId=private-provider" }] }, mode === "explicit-runner" ? "paperclip" : undefined); + if (mode === "success") { await stop(true); expect(h.fixtures.setupLeases!.map(row => row.leaseId)).toEqual(["platform", "adapter"]); } + else await expect(stop(true)).rejects.toThrow(); + expect(h.fixtures.setupLeases!.some(row => row.leaseId === "unrelated")).toBe(false); + expect([...h.listeners.values()].every(listeners => listeners.size === 0)).toBe(true); + }); + + it("reports a rejected platform setup immediately without waiting for the long browser deadline", async () => { + const h = setupHarness(); + const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, Date.now() + 1000); + const rejected = expect(stop.failure).rejects.toThrow("UI environment qualification did not pass"); + h.emit("platform", { ok: false, details: { leaseId: "platform", platformQualificationRecorded: false } }); + await rejected; + await stop(false); + expect(h.fixtures.setupLeases!.map(row => row.leaseId)).toEqual(["platform"]); + }); + + it("keeps an uncorrelated lease out of setup billing and retains unavailable cost coverage", async () => { + const h = setupHarness(); + const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, Date.now() + 1000); + h.emit("platform", { ok: false, details: { leaseId: "platform", platformQualificationRecorded: false } }); + await stop(false); + expect(h.fixtures.setupLeases!.map(row => row.leaseId)).toEqual(["platform"]); + expect(h.fixtures.setupLeaseAccountingIncomplete).toBe(true); + expect(h.fixtures.setupQualification!.recorded).toBe(false); + }); + + it("retains exact failed setup leases for accounting without granting qualification", async () => { + const h = setupHarness(); + const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, Date.now() + 1000); + h.emit("platform", { ok: true, details: { leaseId: "platform", platformQualificationRecorded: true } }); + h.emit("adapter", { adapterType: "paperclip_runner", status: "fail", checks: [{ code: "sandbox_test_identity", detail: "paperclipLeaseId=adapter; providerLeaseId=private-provider" }] }); + await stop(false); + expect(h.fixtures.setupLeases!.map(row => row.leaseId)).toEqual(["platform", "adapter"]); + expect(h.fixtures.setupQualification!.adapterType).toBeNull(); + }); + + it("keeps a missing response lease ID unknown even without visible lease history", async () => { + const h = setupHarness(); + h.rows.splice(0); + const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, Date.now() + 1000); + h.emit("platform", { ok: false, details: { platformQualificationRecorded: false } }); + await stop(false); + expect(h.fixtures.setupLeases).toEqual([]); + expect(h.fixtures.setupLeaseAccountingIncomplete).toBe(true); + expect(h.fixtures.setupQualification!.recorded).toBe(false); + }); +}); diff --git a/tests/runner-e2e/live-fixtures.test.ts b/tests/runner-e2e/live-fixtures.test.ts index a9722e956d7..30cbc24c0c9 100644 --- a/tests/runner-e2e/live-fixtures.test.ts +++ b/tests/runner-e2e/live-fixtures.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import type { RunnerApi } from "./api.js"; import { buildRuntimeUsage } from "./billing.js"; import { runnerMatrix } from "./catalog.js"; -import { setupLiveFixtures, selectFixtureRuntimeLeases } from "./live-fixtures.js"; +import { setupLiveFixtures, selectFixtureRuntimeLeases, fixtureRuntimeCostCoverage } from "./live-fixtures.js"; describe("live runner fixtures", () => { it.each(["runner-codex", "runner-acpx-claude", "runner-opencode"].flatMap(profile => @@ -398,3 +398,31 @@ describe("live runner fixtures", () => { await fixtures.teardown(); }); }); + +describe("owned browser setup lease accounting", () => { + it("includes both exact setup IDs and task runs while excluding unrelated leases", () => { + const leases = [{ id: "platform", provider: "daytona" }, { id: "adapter", provider: "daytona" }, + { id: "task", provider: "daytona", heartbeatRunId: "run" }, { id: "unrelated", provider: "daytona" }]; + expect(selectFixtureRuntimeLeases({ leases, runIds: ["run"], setupLeaseIds: ["platform", "adapter"] }).map(lease => lease.id)).toEqual(["platform", "adapter", "task"]); + expect(selectFixtureRuntimeLeases({ leases, runIds: [], setupLeaseIds: ["platform"] }).map(lease => lease.id)).toEqual(["platform"]); + expect(selectFixtureRuntimeLeases({ leases, runIds: [], setupLeaseIds: [] })).toEqual([]); + }); +}); + +describe("managed setup runtime cost coverage", () => { + it("prices both exact setup windows with task usage and keeps missing attribution unknown", () => { + const leases = ["platform", "adapter", "task", "unrelated"].map((id, index) => ({ id, provider: "daytona", heartbeatRunId: id === "task" ? "run" : null, + acquiredAt: "2026-10-13T00:00:00Z", releasedAt: `2026-10-13T00:00:0${index + 1}Z`, metadata: { cpu: 4, memory: 4, disk: 10 } })); + const selected = selectFixtureRuntimeLeases({ leases, runIds: ["run"], setupLeaseIds: ["platform", "adapter"] }); + const usage = buildRuntimeUsage({ environmentId: "daytona", runs: [], leases: selected }); + expect(usage.leaseCount).toBe(3); + expect(usage.leaseDurationMs).toBe(6000); + expect(usage.estimatedListCostUsd).toBeGreaterThan(0); + expect(fixtureRuntimeCostCoverage(usage, {})).toEqual(usage); + const incomplete = fixtureRuntimeCostCoverage(usage, { setupLeaseAccountingIncomplete: true }); + expect(incomplete.costStatus).toBe("unavailable"); + expect(incomplete.costSource).toBe("provider_cost_unavailable"); + expect(incomplete).not.toHaveProperty("estimatedListCostUsd"); + expect(incomplete.leaseCount).toBe(3); + }); +}); diff --git a/tests/runner-e2e/live-fixtures.ts b/tests/runner-e2e/live-fixtures.ts index ff5e344256e..a39709ac314 100644 --- a/tests/runner-e2e/live-fixtures.ts +++ b/tests/runner-e2e/live-fixtures.ts @@ -13,6 +13,7 @@ import type { MatrixExecution, SecretReference, SecretReferenceMap, + RunnerE2ERuntimeUsage, } from "./types.js"; interface CompanyRecord { @@ -65,6 +66,9 @@ export interface LiveFixtureValues { project?: ProjectRecord; aiConnection?: ManagedAccountFixture; setupProbe?: { leaseId: string; durationMs: number; defaultRunner?: string }; + setupLeases?: Array<{ leaseId: string; phase: "platform" | "adapter"; durationMs: number }>; + setupLeaseAccountingIncomplete?: boolean; + setupQualification?: { recorded: boolean; adapterType: string | null; platformResponses: number; adapterResponses: number }; onboardingRuntime?: { mode: "production-wizard" | "post-onboarding-runtime-switch"; @@ -83,17 +87,24 @@ function value(resolved: ReadonlyMap, id: string): T { export function selectFixtureRuntimeLeases(input: { leases: T[]; runIds: string[]; issueId?: string; setupProbeLeaseId?: string }): T[] { +}>(input: { leases: T[]; runIds: string[]; issueId?: string; setupProbeLeaseId?: string; setupLeaseIds?: readonly string[] }): T[] { const selectedRunIds = new Set(input.runIds); + const setupIds = new Set([...(input.setupLeaseIds ?? []), ...(input.setupProbeLeaseId ? [input.setupProbeLeaseId] : [])]); const relevant = input.leases.filter(lease => lease.provider === "daytona" && ( - lease.id === input.setupProbeLeaseId || selectedRunIds.size === 0 + setupIds.has(lease.id) || (input.setupLeaseIds === undefined && selectedRunIds.size === 0) || (lease.heartbeatRunId && selectedRunIds.has(lease.heartbeatRunId)) || (input.issueId && lease.issueId === input.issueId) )); - return relevant.length > 0 ? relevant : input.leases; + return input.setupLeaseIds !== undefined || relevant.length > 0 ? relevant : input.leases; } -async function deleteDaytonaEnvironment(api: RunnerApi, environmentId: string) { +export function fixtureRuntimeCostCoverage(usage: RunnerE2ERuntimeUsage, fixtures: Pick): RunnerE2ERuntimeUsage { + if (!fixtures.setupLeaseAccountingIncomplete) return usage; + const { estimatedListCostUsd: _estimate, ...known } = usage; + return { ...known, costStatus: "unavailable", costSource: "provider_cost_unavailable" }; +} + +export async function deleteDaytonaEnvironment(api: Pick, environmentId: string) { const deadlineAt = Date.now() + 120_000; let lastError: unknown; while (Date.now() < deadlineAt) { @@ -115,6 +126,14 @@ async function deleteDaytonaEnvironment(api: RunnerApi, environmentId: string) { ); } +export async function installFixtureDaytonaProvider(api: Pick, execution: MatrixExecution) { + const installed = await verifyInstalledDaytonaPlugin(process.env, [execution]); + return api.post("/api/plugins/install", { + packageName: installed?.packageRoot ?? path.resolve(import.meta.dirname, "../../packages/plugins/sandbox-providers/daytona"), + isLocalPath: true, + }, { timeout: 120_000 }); +} + export async function setupLiveFixtures(input: { api: RunnerApi; execution: MatrixExecution; @@ -131,14 +150,7 @@ export async function setupLiveFixtures(input: { registry.register({ id: "sandbox-provider", async setup() { - const installed = await verifyInstalledDaytonaPlugin(process.env, [execution]); - return api.post("/api/plugins/install", { - packageName: installed?.packageRoot ?? path.resolve( - import.meta.dirname, - "../../packages/plugins/sandbox-providers/daytona", - ), - isLocalPath: true, - }, { timeout: 120_000 }); + return installFixtureDaytonaProvider(api, execution); }, async teardown() { // The plugin is installed only in the isolated instance/database. The diff --git a/tests/runner-e2e/pi-controls-evidence.test.ts b/tests/runner-e2e/pi-controls-evidence.test.ts index 23f45210d8a..9cc6ea1c2ae 100644 --- a/tests/runner-e2e/pi-controls-evidence.test.ts +++ b/tests/runner-e2e/pi-controls-evidence.test.ts @@ -180,7 +180,7 @@ describe("Pi controls catalog admission", () => { expect(cells).toHaveLength(4); expect(cells.every(c => c.profile.qualificationCandidate === "pi" && c.task.expectedRunCount === 1)).toBe(true); expect(cells.map(c => `${c.environment.id}/${c.task.id}`).sort()).toEqual(["daytona/pending-permission-stop", "daytona/same-turn-steering", "local/pending-permission-stop", "local/same-turn-steering"]); expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(c => c.suite.id === "pi-controls")).toBe(false); - expect(validateRunnerCatalog()).toHaveLength(729); + expect(validateRunnerCatalog()).toHaveLength(730); for (const cell of cells) expect(buildRunnerE2EProcessEnvironment({}, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeUndefined(); expect(() => assertRemoteNativeEvidencePrerequisites(cells, {})).toThrow(); }); diff --git a/tests/runner-e2e/runner.spec.ts b/tests/runner-e2e/runner.spec.ts index d1fc5a8960c..d286c4419c8 100644 --- a/tests/runner-e2e/runner.spec.ts +++ b/tests/runner-e2e/runner.spec.ts @@ -36,7 +36,7 @@ import { captureStockHarness, gradeStockHarness, gradeStockHire } from "./stock- import { verifyStockHarnessPreflight, STOCK_PREFLIGHT_ENV } from "./stock-harness-admission.js"; import { createTaskThroughUi, submitTaskReply } from "./user-actions.js"; -import { runFirstTaskFlow, setupFirstTaskFixtures } from "./first-task-flow.js"; +import { projectFirstTaskResult, runFirstTaskFlow, setupFirstTaskFixtures } from "./first-task-flow.js"; import { runChatFlow } from "./chat-flow.js"; import { restartChatServer } from "./chat-restart.js"; import { matchesRunCount, minimumRunCount } from "./run-count.js"; @@ -52,7 +52,7 @@ import { runnerExecutionById } from "./catalog.js"; import { classifyFailure } from "./failure-classifier.js"; import { runnerE2EServerControlPaths } from "./harness-env.js"; import { setupConnectionReview } from "./connection-reviews.js"; -import { setupLiveFixtures, selectFixtureRuntimeLeases, type LiveFixtureValues } from "./live-fixtures.js"; +import { setupLiveFixtures, selectFixtureRuntimeLeases, fixtureRuntimeCostCoverage, type LiveFixtureValues } from "./live-fixtures.js"; import { evaluateMatcher, persistedFinalRunMessage, type MatcherResult } from "./matchers.js"; import { readRegisteredArtifacts } from "./registered-artifact.js"; import { @@ -698,7 +698,7 @@ for (const execution of executions) { ); runtimeLeases = selectFixtureRuntimeLeases({ leases: listed, runIds: selectedRuns.map(run => run.id), issueId: issue?.id, - setupProbeLeaseId: fixtures.setupProbe?.leaseId }); + setupProbeLeaseId: fixtures.setupProbe?.leaseId, setupLeaseIds: fixtures.setupLeases?.map(lease => lease.leaseId) }); }; const cancelActiveRunsForCleanup = async () => { @@ -865,10 +865,11 @@ for (const execution of executions) { const receipt = verifyStockHarnessPreflight(process.env[STOCK_PREFLIGHT_ENV]); await writeSanitizedJson(snapshotsDir, "stock-harness-preflight.json", receipt, secrets); } + const nativeRolloutEnabled = execution.profile.provider !== "codex"; const experimental = await api.patch<{ enableNativeRunner: boolean; }>("/api/instance/settings/experimental", { - enableNativeRunner: true, + enableNativeRunner: nativeRolloutEnabled, ...(["warm_three_turn", "everyday_workflow"].includes(execution.task.flow) ? { enableIsolatedWorkspaces: true } : {}), @@ -877,7 +878,7 @@ for (const execution of executions) { ? { enableRunnerPreviewIngress: true } : {}), }); - expect(experimental.enableNativeRunner).toBe(true); + expect(experimental.enableNativeRunner).toBe(nativeRolloutEnabled); if (execution.suite.id === "extended-harnesses" && execution.profile.qualificationCandidate === "pi" && execution.task.id === "file-edit-validate") { piFileSeed = await seedPiFile(workspacePath, nonce); @@ -888,7 +889,7 @@ for (const execution of executions) { await setupGitStreamingWorkspace(workspacePath); } fixtures = execution.task.flow === "first_task" - ? await setupFirstTaskFixtures({ page, api, execution, nonce, credentials, observe: value => { fixtures = value; } }) + ? await setupFirstTaskFixtures({ page, api, execution, nonce, credentials, daytonaImage: process.env.PAPERCLIP_E2E_DAYTONA_IMAGE, deadlineAt: startedAtMs + deadlineMs, observe: value => { fixtures = value; } }) : await setupLiveFixtures({ api, execution, @@ -1156,7 +1157,7 @@ for (const execution of executions) { if (matcherResults.length === 0) matcherResults = [{ matcher: { kind: "issue_status", expected: "in_review" }, passed: true, detail: "Chat workflow and durable handoff/session assertions passed" }]; } else if (execution.task.flow === "first_task") { const firstTask = await runFirstTaskFlow({ - page, api, fixtures, execution, nonce, secrets, + page, api, fixtures, execution, nonce, deadlineAt: startedAtMs + deadlineMs, secrets, observe: (currentIssue, runs, evidence) => { issue = currentIssue; selectedRuns = runs; firstTaskEvidence = evidence; matcherResults = evidence.checks.map(check => ({ matcher: { kind: "json_path" as const, path: `firstTask.checks.${check.id}`, expected: true }, passed: check.passed, detail: check.detail })); @@ -3099,12 +3100,12 @@ for (const execution of executions) { } const finishedAtMs = Date.now(); - const runtimeUsage = buildRuntimeUsage({ + const runtimeUsage = fixtureRuntimeCostCoverage(buildRuntimeUsage({ environmentId: execution.environment.id, runs: selectedRuns, leases: runtimeLeases, fallbackFinishedAt: new Date(finishedAtMs), - }); + }), fixtures ?? {}); const resultWithoutBilling: RunnerE2EResult = { schema: "paperclip.runner-e2e.result/v2", executionId: execution.id, @@ -3160,10 +3161,13 @@ for (const execution of executions) { ...resultWithoutBilling, billing: summarizeExecutionBilling(resultWithoutBilling), }; + if (firstTaskEvidence) { + await writeSanitizedJson(snapshotsDir, "first-task-result-private.json", result, secrets); + } await mkdir(privateDir, { recursive: true }); await writeFile( resultPath, - `${JSON.stringify(sanitizeJson(result, secrets), null, 2)}\n`, + `${JSON.stringify(sanitizeJson(projectFirstTaskResult(result), secrets), null, 2)}\n`, "utf8", ); await testInfo.attach("runner-e2e-result", { diff --git a/tests/storybook-visual/storybook-visual.spec.ts b/tests/storybook-visual/storybook-visual.spec.ts index ff80d457e3b..13ee0f417b5 100644 --- a/tests/storybook-visual/storybook-visual.spec.ts +++ b/tests/storybook-visual/storybook-visual.spec.ts @@ -37,6 +37,8 @@ const EXTRA_SETTLE_MS: Record = { // waiting. The affected element is masked (solid overlay in both baseline and // comparison) so the rest of the story still snapshot-verifies. const MASKED_SELECTORS: Record = { + "agents-codex-runner-creation--experimental-native-harnesses": + '[role="dialog"] [data-slot="agent-avatar"]', // DocumentAnnotationLayer's ::highlight range over "two selectors" ends 1-2 // characters short on ~half of renders (anchor offsets race). Mask only the // paragraph that carries that highlight. @@ -82,6 +84,18 @@ async function renderStory(page: Page, storyId: string, theme: (typeof THEMES)[n }); const errored = await page.locator(".sb-show-errordisplay").count(); expect(errored, `story ${storyId} threw during render`).toBe(0); + const codexCreation = storyId.startsWith("agents-codex-runner-creation--"); + const codexSettings = /^agents-configuration-refresh--(?:codex-runtime|runner-runtime|codex-(?:legacy|native)-)/.test(storyId); + if (codexCreation || codexSettings) { + await page.waitForFunction(id => { + const preview = (window as unknown as { __STORYBOOK_PREVIEW__?: { storyRenders?: Array<{ id: string; phase: string }> } }).__STORYBOOK_PREVIEW__; + return preview?.storyRenders?.some(render => render.id === id && render.phase === "finished"); + }, storyId); + const expected = storyId.endsWith("--experimental-native-harnesses") ? page.getByRole("button", { name: "Experimental harness", exact: true }) + : storyId.endsWith("--task-ready") ? page.getByRole("heading", { name: /is ready/ }) + : page.getByRole("button", { name: codexCreation || storyId.endsWith("--codex-legacy-advanced") ? "Runner" : "Harness", exact: true }); + await expect(expected).toBeVisible(); + } if (storyId.startsWith("connections-speko-")) { await page.waitForFunction((id) => document.body.dataset.spekoStoryReady === id, storyId); expect(await page.locator("body").getAttribute("data-speko-story-error")).toBeNull(); @@ -95,7 +109,7 @@ for (const entry of entries) { for (const theme of THEMES) { test(`${entry.id} [${theme}]`, async ({ page }) => { await renderStory(page, entry.id, theme); - const maskSelector = MASKED_SELECTORS[entry.id]; + const maskSelector = MASKED_SELECTORS[entry.id] ?? (entry.id.startsWith("agents-codex-runner-creation--") ? 'header [data-slot="agent-avatar"]' : undefined); await expect(page).toHaveScreenshot(`${entry.id}--${theme}.png`, { fullPage: true, mask: maskSelector ? [page.locator(maskSelector)] : undefined, diff --git a/ui/src/adapters/adapter-display-registry.test.ts b/ui/src/adapters/adapter-display-registry.test.ts index 0b34460347d..9c22f717666 100644 --- a/ui/src/adapters/adapter-display-registry.test.ts +++ b/ui/src/adapters/adapter-display-registry.test.ts @@ -1,8 +1,22 @@ import { describe, expect, it } from "vitest"; -import { getAdapterDisplay, getAdapterLabel, getAdapterLabels } from "./adapter-display-registry"; +import { agentDisplayAdapterType, getAdapterDisplay, getAdapterLabel, getAdapterLabels } from "./adapter-display-registry"; describe("adapter display registry", () => { + it.each([ + ["openai_dot", "OpenAI Dot"], + ["grok_bot", "Grok Bot"], + ])("keeps saved %s external runners branded by provider", (provider, label) => { + const type = agentDisplayAdapterType({ adapterType: "paperclip_runner", adapterConfig: { provider } }); + expect(getAdapterDisplay(type).label).toBe(label); + }); + it("brands saved Codex runners as Codex without guessing managed or unknown providers", () => { + expect(agentDisplayAdapterType({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } })).toBe("codex_local"); + expect(agentDisplayAdapterType({ adapterType: "paperclip_runner", adapterConfig: {} })).toBe("codex_local"); + for (const provider of ["claude_managed", "aws_agentcore", "unknown"]) { + expect(agentDisplayAdapterType({ adapterType: "paperclip_runner", adapterConfig: { provider } })).toBe("paperclip_runner"); + } + }); it("uses user-facing labels without the legacy local qualifier for built-in adapters", () => { expect(getAdapterLabel("codex_local")).toBe("Codex"); expect(getAdapterLabel("claude_local")).toBe("Claude Code"); diff --git a/ui/src/adapters/adapter-display-registry.ts b/ui/src/adapters/adapter-display-registry.ts index b80011952f9..64c734016b0 100644 --- a/ui/src/adapters/adapter-display-registry.ts +++ b/ui/src/adapters/adapter-display-registry.ts @@ -6,6 +6,7 @@ * `getAdapterDisplay()`. */ import type { ComponentType } from "react"; +import { agentHarnessType } from "@paperclipai/shared"; import { Bot, Code, @@ -208,6 +209,14 @@ export function getAdapterDisplay(type: string): AdapterDisplayInfo { }; } +export function agentDisplayAdapterType(agent: { adapterType: string; adapterConfig: Record }): string { + if (agent.adapterType !== "paperclip_runner") return agent.adapterType; + if (agent.adapterConfig.provider === "openai_dot" || agent.adapterConfig.provider === "grok_bot") { + return agent.adapterConfig.provider; + } + return agentHarnessType(agent.adapterType, agent.adapterConfig) === "codex_local" ? "codex_local" : agent.adapterType; +} + export function isKnownAdapterType(type: string): boolean { return type in adapterDisplayMap; } diff --git a/ui/src/adapters/codex-local/config-fields.test.tsx b/ui/src/adapters/codex-local/config-fields.test.tsx index eb5be53b44c..3c483953c32 100644 --- a/ui/src/adapters/codex-local/config-fields.test.tsx +++ b/ui/src/adapters/codex-local/config-fields.test.tsx @@ -1,14 +1,19 @@ // @vitest-environment jsdom import { createRoot } from "react-dom/client"; -import { act } from "react"; +import { act, useState } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; import { beforeAll, describe, expect, it } from "vitest"; import { TooltipProvider } from "@/components/ui/tooltip"; import { CodexLocalConfigFields } from "./config-fields"; +import { buildCodexRunnerConfig } from "./config-fields"; +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 }); }); @@ -50,7 +55,170 @@ async function renderRunner(config: Record, expand?: string, in ); } +async function selectField(container: HTMLElement, label: string, value: string) { + const picker = container.querySelector(`[aria-label="${label}"]`)!; + await act(async () => { + picker.focus(); + await new Promise(resolve => window.setTimeout(resolve, 0)); + }); + await act(async () => picker.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }))); + const option = document.body.querySelector(`[role="listbox"][aria-label="${label}"] [data-value="${value}"]`)!; + expect(option).not.toBeNull(); + await act(async () => { + option.click(); + await new Promise(resolve => window.setTimeout(resolve, 0)); + }); +} + +async function editFields( + mode: "create" | "edit", + adapterType: "codex_local" | "paperclip_runner", + inspect: (container: HTMLElement, saved: () => Record) => Promise, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + let currentConfig: Record = { + ...(adapterType === "paperclip_runner" ? { provider: "codex", lifecycleMode: "warm", idleTimeoutMs: 45_000 } : { engine: "acp", mode: "persistent", nonInteractivePermissions: "deny" }), + env: { CODEX_HOME: { type: "secret_ref", secretId: "codex-account" } }, + }; + let currentValues: CreateConfigValues = { + ...defaultCreateValues, + adapterType, + runner: adapterType === "paperclip_runner" ? "paperclip" : "legacy", + adapterSchemaValues: { provider: "codex" }, + envBindings: { CODEX_HOME: { type: "secret_ref", secretId: "codex-account" } }, + paperclipRunnerLifecycleMode: "warm", + paperclipRunnerIdleTimeoutMs: 45_000, + codexEngine: "acp", + codexAcpMode: "persistent", + codexAcpNonInteractivePermissions: "deny", + }; + function Fixture() { + const [config, setConfig] = useState(currentConfig); + const [values, setValues] = useState(currentValues); + currentConfig = config; + currentValues = values; + return setValues(previous => ({ ...previous, ...patch }))} + config={config} eff={(_group, _field, original) => original} + mark={(_group, field, value) => setConfig(previous => ({ ...previous, [field]: value }))} + models={[]} hideInstructionsFile hideRunnerHarness />; + } + try { + await act(async () => root.render()); + await inspect(container, () => mode === "create" + ? adapterType === "paperclip_runner" ? buildPaperclipRunnerConfig(currentValues) : buildCodexRunnerConfig(currentValues) + : currentConfig); + if (mode === "create") { + expect(currentValues.adapterType).toBe(adapterType); + expect(currentValues.runner).toBe(adapterType === "paperclip_runner" ? "paperclip" : "legacy"); + } + } finally { + await act(async () => root.unmount()); + container.remove(); + } +} + 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); + const root = createRoot(container); + let saved: typeof defaultCreateValues = { ...defaultCreateValues, adapterType: "codex_local", runner: "auto", extraArgs }; + function Fixture() { + const [values, setValues] = useState(saved); + saved = values; + return setValues(current => ({ ...current, ...patch }))} config={{}} + eff={(_group, _field, original) => original} mark={() => undefined} models={[]} hideInstructionsFile />; + } + try { + await act(async () => root.render()); + const toggle = container.querySelector('[aria-label="Bypass sandbox"]'); + expect(toggle).toBeDefined(); + expect(toggle?.getAttribute("aria-checked")).toBe(String(resolveCodexLocalCliPolicy(buildCodexRunnerConfig(saved)).bypass)); + await act(async () => toggle!.click()); + const config = buildCodexRunnerConfig(saved); + expect(saved.runner).toBe("auto"); + expect(config.dangerouslyBypassApprovalsAndSandbox).toBe(extraArgs !== ""); + expect(toggle?.getAttribute("aria-checked")).toBe(String(resolveCodexLocalCliPolicy(config).bypass)); + } finally { + await act(async () => root.unmount()); + container.remove(); + } + }); + it.each([ + {}, + { dangerouslyBypassApprovalsAndSandbox: false, dangerouslyBypassSandbox: true }, + { dangerouslyBypassSandbox: false }, + { extraArgs: ["--sandbox", "read-only"] }, + ])("shows the actual saved Legacy CLI policy while editing %j", async config => { + await renderMarkup( original} mark={() => undefined} models={[]} hideInstructionsFile /> + , undefined, container => { + expect(container.querySelector('[aria-label="Bypass sandbox"]')?.getAttribute("aria-checked")) + .toBe(String(resolveCodexLocalCliPolicy(config).bypass)); + }); + }); + it("edits the legacy execution engine through the shared keyboard picker", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const changes: unknown[][] = []; + await act(async () => root.render( + original} + mark={(...change) => { changes.push(change); }} models={[]} hideInstructionsFile /> + )); + const picker = container.querySelector('[aria-label="Execution engine"]')!; + await act(async () => picker.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }))); + const selected = document.body.querySelector('[role="option"][aria-selected="true"]')!; + expect(selected.textContent).toBe("ACP"); + await act(async () => selected.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }))); + expect(document.activeElement?.textContent).toBe("Codex CLI"); + await act(async () => (document.activeElement as HTMLButtonElement).click()); + expect(changes).toEqual([["adapterConfig", "engine", "cli"]]); + await act(async () => root.unmount()); + container.remove(); + }); + it.each(["create", "edit"] as const)("changes native lifecycle while retaining timeout and account in %s mode", async mode => { + await editFields(mode, "paperclip_runner", async (container, saved) => { + expect(container.querySelector('[aria-label="Runner lifecycle"]')?.textContent).toContain("Warm session"); + expect(container.textContent).toContain("Warm idle timeout (ms)"); + const account = saved().env; + await selectField(container, "Runner lifecycle", "per_turn"); + expect(container.textContent).not.toContain("Warm idle timeout (ms)"); + expect(saved()).toMatchObject({ provider: "codex", lifecycleMode: "per_turn", env: account }); + await selectField(container, "Runner lifecycle", "warm"); + expect(container.textContent).toContain("Warm idle timeout (ms)"); + expect(saved()).toMatchObject({ provider: "codex", lifecycleMode: "warm", idleTimeoutMs: 45_000, env: account }); + }); + }); + it.each(["create", "edit"] as const)("changes Legacy ACP session and permissions without replacing the account in %s mode", async mode => { + await editFields(mode, "codex_local", async (container, saved) => { + expect(container.querySelector('[aria-label="ACP session mode"]')?.textContent).toContain("Persistent"); + expect(container.querySelector('[aria-label="ACP non-interactive permissions"]')?.textContent).toContain("Deny"); + const account = saved().env; + await selectField(container, "ACP session mode", "oneshot"); + await selectField(container, "ACP non-interactive permissions", "fail"); + expect(saved()).toMatchObject({ engine: "acp", mode: "oneshot", nonInteractivePermissions: "fail", env: account }); + await selectField(container, "ACP session mode", "persistent"); + await selectField(container, "ACP non-interactive permissions", "deny"); + expect(saved()).toMatchObject({ engine: "acp", mode: "persistent", nonInteractivePermissions: "deny", env: account }); + }); + }); + it("preserves an unknown native provider instead of presenting it as Codex", async () => { + const html = await renderRunner({ provider: "future-provider" }); + expect(html).toContain("future-provider"); + expect(html).not.toContain('aria-label="Permission mode"'); + }); it("keeps Dot out of the general harness picker and hides that picker for Dot", async () => { expect(await renderRunner({ provider: "codex" }, "Harness", true)).not.toContain("OpenAI Dot"); const dot = await renderRunner({ provider: "openai_dot" }); @@ -58,6 +226,11 @@ describe("Paperclip Runner Codex configuration", () => { expect(dot).toContain("Dot connection"); expect(dot).toContain("Allow externally billed provider"); }); + it("keeps the general harness picker hidden for an existing Grok Bot", async () => { + const html = await renderRunner({ provider: "grok_bot" }); + expect(html).not.toContain('aria-label="Harness"'); + expect(html).toContain("Save the Grok Bot agent before connecting it."); + }); it.each([ [undefined, "Full auto (approve all)"], ["approve-paperclip", "Automatic Paperclip actions"], diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index e460d183e80..4e45d7f933e 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -1,9 +1,11 @@ -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectPopover } from "../../components/ui/select"; import { AdapterMark } from "../../components/AdapterMark"; import { GrokBotConnection } from "../../components/GrokBotConnection"; import { DotRunnerConnection } from "../../components/DotRunnerConnection"; import { configFieldsForSection } from "../config-sections"; import type { AdapterConfigFieldsProps } from "../types"; +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; +import { buildCodexLocalConfig } from "@paperclipai/adapter-codex-local/ui"; import { Field, ToggleField, @@ -18,6 +20,7 @@ import { CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS, isCodexLocalFastModeSupported, isCodexLocalManualModel, + resolveCodexLocalCliPolicy, } from "@paperclipai/adapter-codex-local"; import { PAPERCLIP_RUNNER_IDLE_TIMEOUT_DEFAULT_MS, @@ -28,7 +31,7 @@ import { resolvePaperclipRunnerIdleTimeoutMs, resolvePaperclipRunnerPermissionMode, type PaperclipRunnerPermissionMode, - type PaperclipRunnerProvider, + type PaperclipRunnerPermissionCapability, } from "@paperclipai/adapter-utils"; const inputClass = @@ -48,6 +51,21 @@ const runnerHarnessOptions = [ { value: "grok", label: "Grok Build", adapter: "grok_local" }, ]; +export function buildCodexRunnerConfig(values: CreateConfigValues): Record { + const config = buildCodexLocalConfig(values); + if (values.codexLegacySandboxBypass !== undefined) { + config.dangerouslyBypassApprovalsAndSandbox = values.codexLegacySandboxBypass; + } else if (values.runner !== "legacy") { + delete config.dangerouslyBypassApprovalsAndSandbox; + } + return { + ...config, + ...(values.codexPermissionMode !== undefined ? { codexPermissionMode: values.codexPermissionMode } : {}), + ...(values.paperclipRunnerLifecycleMode !== undefined ? { lifecycleMode: values.paperclipRunnerLifecycleMode } : {}), + ...(values.paperclipRunnerIdleTimeoutMs !== undefined ? { idleTimeoutMs: values.paperclipRunnerIdleTimeoutMs } : {}), + }; +} + export function CodexLocalConfigFields({ section, companyId, @@ -63,6 +81,7 @@ export function CodexLocalConfigFields({ models, hideInstructionsFile, managedSandboxOnly, + hideRunnerHarness, }: AdapterConfigFieldsProps) { const runnerManaged = adapterType === "paperclip_runner"; // The execution engine picks which binary runs on the execution host, and the @@ -75,13 +94,11 @@ export function CodexLocalConfigFields({ ? values!.adapterSchemaValues?.provider : eff("adapterConfig", "provider", config.provider === "acpx" && config.acpxAgent === "codex" ? "codex" : config.provider ?? "codex") : "codex"; - const runnerProvider: PaperclipRunnerProvider = isPaperclipRunnerProvider( - configuredRunnerProvider, - ) - ? configuredRunnerProvider - : "codex"; - const runnerPermissionCapability = - PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES[runnerProvider]; + const runnerProvider = String(configuredRunnerProvider ?? "codex"); + const runnerPermissionCapability: PaperclipRunnerPermissionCapability = + isPaperclipRunnerProvider(runnerProvider) + ? PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES[runnerProvider] + : { configurable: false, defaultMode: "provider-managed", options: [], description: "Unknown provider configuration is preserved." }; const configuredRunnerPermissionMode = runnerManaged && runnerPermissionCapability.configurable ? isCreate @@ -107,7 +124,7 @@ export function CodexLocalConfigFields({ const runnerPermissionMode = runnerManaged && runnerPermissionCapability.configurable ? resolvePaperclipRunnerPermissionMode( - runnerProvider, + isPaperclipRunnerProvider(runnerProvider) ? runnerProvider : "codex", configuredRunnerPermissionMode, ) : runnerPermissionCapability.defaultMode; @@ -151,9 +168,13 @@ export function CodexLocalConfigFields({ const engine = rawEngine === "acp" || rawEngine === "cli" ? rawEngine : "auto"; const acpSelected = engine === "acp"; - const bypassEnabled = - config.dangerouslyBypassApprovalsAndSandbox === true || - config.dangerouslyBypassSandbox === true; + const bypassEnabled = resolveCodexLocalCliPolicy({ + ...config, + dangerouslyBypassApprovalsAndSandbox: eff("adapterConfig", "dangerouslyBypassApprovalsAndSandbox", config.dangerouslyBypassApprovalsAndSandbox), + dangerouslyBypassSandbox: eff("adapterConfig", "dangerouslyBypassSandbox", config.dangerouslyBypassSandbox), + extraArgs: eff("adapterConfig", "extraArgs", config.extraArgs), + args: eff("adapterConfig", "args", config.args), + }).bypass; const fastModeEnabled = isCreate ? Boolean(values!.fastMode) : eff("adapterConfig", "fastMode", Boolean(config.fastMode)); @@ -177,14 +198,14 @@ export function CodexLocalConfigFields({ label="Execution engine" hint="Default uses ACP. If ACP is unavailable, the run fails with a setup error. Choose CLI explicitly to use it." > - + options={[ + { value: "auto", label: "Default (ACP)" }, + { value: "cli", label: "Codex CLI" }, + { value: "acp", label: "ACP" }, + ]} + /> )} - {runnerManaged && runnerProvider !== "openai_dot" && runnerProvider !== "grok_bot" && ( + {runnerManaged && !hideRunnerHarness && runnerProvider !== "openai_dot" && runnerProvider !== "grok_bot" && ( + {!isPaperclipRunnerProvider(runnerProvider) && {runnerProvider} (unknown provider)} {runnerHarnessOptions.map((option) => ( @@ -471,7 +494,7 @@ export function CodexLocalConfigFields({ } onValueChange={(selectedMode) => { const value = resolvePaperclipRunnerPermissionMode( - runnerProvider, + isPaperclipRunnerProvider(runnerProvider) ? runnerProvider : "codex", selectedMode, ) as PaperclipRunnerPermissionMode; if (isCreate) { @@ -523,19 +546,20 @@ export function CodexLocalConfigFields({ label="Runner lifecycle" hint="Turn by turn suspends after each run. Warm keeps the same provider process available between governed runs." > - + options={[ + { value: "per_turn", label: "Turn by turn" }, + { value: "warm", label: "Warm session" }, + ]} + /> )} {runnerManaged && runnerProvider !== "openai_dot" && runnerProvider !== "grok_bot" && runnerLifecycleMode === "warm" && ( @@ -609,8 +633,8 @@ export function CodexLocalConfigFields({ label="ACP session mode" hint="Persistent keeps ACP session state between runs. One-shot starts fresh each run." > - + options={[ + { value: "persistent", label: "Persistent" }, + { value: "oneshot", label: "One-shot" }, + ]} + /> - + options={[ + { value: "deny", label: "Deny" }, + { value: "fail", label: "Fail" }, + ]} + /> {!managedSandboxOnly && ( isCreate - ? set!({ dangerouslyBypassSandbox: v }) + ? set!({ dangerouslyBypassSandbox: v, codexLegacySandboxBypass: v }) : mark( "adapterConfig", "dangerouslyBypassApprovalsAndSandbox", diff --git a/ui/src/adapters/codex-local/index.ts b/ui/src/adapters/codex-local/index.ts index 0d9dd1a3dd4..52a86ec41f8 100644 --- a/ui/src/adapters/codex-local/index.ts +++ b/ui/src/adapters/codex-local/index.ts @@ -1,12 +1,11 @@ import type { UIAdapterModule } from "../types"; import { parseCodexStdoutLine } from "@paperclipai/adapter-codex-local/ui"; -import { CodexLocalConfigFields } from "./config-fields"; -import { buildCodexLocalConfig } from "@paperclipai/adapter-codex-local/ui"; +import { CodexLocalConfigFields, buildCodexRunnerConfig } from "./config-fields"; export const codexLocalUIAdapter: UIAdapterModule = { type: "codex_local", label: "Codex", parseStdoutLine: parseCodexStdoutLine, ConfigFields: CodexLocalConfigFields, - buildAdapterConfig: buildCodexLocalConfig, + buildAdapterConfig: buildCodexRunnerConfig, }; diff --git a/ui/src/adapters/types.ts b/ui/src/adapters/types.ts index d45d7486de5..116b8435485 100644 --- a/ui/src/adapters/types.ts +++ b/ui/src/adapters/types.ts @@ -53,6 +53,7 @@ export interface AdapterConfigFieldsProps { managedSandboxOnly?: boolean; /** Show Dot for new selections only when its experimental prerequisites are enabled. */ openAiDotEnabled?: boolean; + hideRunnerHarness?: boolean; } export interface UIAdapterModule extends TranscriptParserSource { diff --git a/ui/src/api/adapters.ts b/ui/src/api/adapters.ts index 0641b1ed7d0..89c6e358749 100644 --- a/ui/src/api/adapters.ts +++ b/ui/src/api/adapters.ts @@ -34,6 +34,8 @@ export interface AcpTargetDescriptor { } export interface AdapterInfo { + supportedRunners?: import("@paperclipai/shared").AgentRunner[]; + defaultRunner?: import("@paperclipai/shared").AgentRunner; type: string; label: string; source: "builtin" | "external"; @@ -63,7 +65,14 @@ export interface AdapterInstallResult { export const adaptersApi = { /** List all registered adapters (built-in + external). */ - list: () => api.get("/adapters"), + list: (context?: { companyId: string; environmentId?: string | null }) => { + const query = new URLSearchParams(); + if (context) { + query.set("companyId", context.companyId); + if (context.environmentId) query.set("environmentId", context.environmentId); + } + return api.get(`/adapters${query.size ? `?${query}` : ""}`); + }, /** Install an external adapter from npm or a local path. */ install: (params: { packageName: string; version?: string; isLocalPath?: boolean }) => diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index da04cf2aedb..cb5f28fe07f 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -253,6 +253,7 @@ export const agentsApi = { type: string, data: { adapterConfig: Record; + runner?: import("@paperclipai/shared").AgentRunnerChoice; aiConnection?: import("@paperclipai/shared").AiRuntimeConnectionBinding; agentId?: string; testCredentials?: Record; diff --git a/ui/src/api/builtInAgents.ts b/ui/src/api/builtInAgents.ts index 39ec7aa4bda..07a61aee8e5 100644 --- a/ui/src/api/builtInAgents.ts +++ b/ui/src/api/builtInAgents.ts @@ -108,6 +108,7 @@ export interface BuiltInAgentState { } export interface BuiltInAgentProvisionInput { + runner?: import("@paperclipai/shared").AgentRunnerChoice; adapterType?: string; adapterConfig?: Record; budgetMonthlyCents?: number; diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index d985d3e0895..d880d96d4fd 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -9,7 +9,7 @@ import type { Agent, Environment, UserSecretDefinition } from "@paperclipai/shar import { getEnvironmentCapabilities } from "@paperclipai/shared"; import { TooltipProvider } from "@/components/ui/tooltip"; import { ToastProvider } from "../context/ToastContext"; -import { AgentConfigForm, AdapterLoginPanel, subtractPersistedOverlay, type AdapterLoginDescriptor } from "./AgentConfigForm"; +import { AgentConfigForm, AdapterLoginPanel, ModelDropdown, subtractPersistedOverlay, type AdapterLoginDescriptor } from "./AgentConfigForm"; import { AgentLifecycleStatus, useAgentLifecycleStatus } from "./AgentLifecycleStatus"; import { queryKeys } from "../lib/queryKeys"; import { defaultCreateValues } from "./agent-config-defaults"; @@ -73,6 +73,10 @@ vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi, })); +vi.mock("../api/adapters", () => ({ + adaptersApi: { list: async () => [{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }] }, +})); + vi.mock("../api/environments", () => ({ environmentsApi: mockEnvironmentsApi, })); @@ -307,6 +311,7 @@ async function renderForm( agentOverrides: Partial = {}, options: { showAdapterTestEnvironmentButton?: boolean; + showAdapterTypeField?: boolean; content?: "configuration" | "secrets"; environmentVariablesPlacement?: "configuration" | "secrets"; hideInlineSave?: boolean; @@ -336,7 +341,7 @@ async function renderForm( onDirtyChange={options.onDirtyChange} onSaveActionChange={options.onSaveActionChange} onCancelActionChange={options.onCancelActionChange} - showAdapterTypeField={false} + showAdapterTypeField={options.showAdapterTypeField ?? false} showAdapterTestEnvironmentButton={options.showAdapterTestEnvironmentButton ?? false} /> ); @@ -571,16 +576,20 @@ async function renderStatefulCreateClaudeSandbox(environments: Environment[]) { return { container, root, valuesRef }; } +async function environmentOptions(container: HTMLElement) { + const picker = container.querySelector('[aria-label="Environment override"]'); + expect(picker).not.toBeNull(); + await clickElement(picker); + const options = document.body.querySelector('[role="listbox"][aria-label="Environment override"]'); + expect(options).not.toBeNull(); + return options!; +} + async function selectEnvironment(container: HTMLElement, environmentId: string) { - const select = container.querySelector("select"); - await act(async () => { - if (select) { - const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set; - setter?.call(select, environmentId); - select.dispatchEvent(new Event("change", { bubbles: true })); - } - }); - await flushReact(); + const options = await environmentOptions(container); + const option = options.querySelector(`[role="option"][data-value="${environmentId}"]`); + expect(option).not.toBeNull(); + await clickElement(option); } async function clickByText(container: HTMLElement, label: string) { @@ -623,6 +632,125 @@ async function flushUntil(check: () => boolean, timeoutMs = 4000) { describe("AgentConfigForm environment selector", () => { let roots: Root[] = []; + it("keeps an explicit Codex draft policy until a harness reset clears it", async () => { + mockEnvironmentsApi.list.mockResolvedValue([]); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + let current = { ...defaultCreateValues, adapterType: "codex_local", runner: "auto" as const, + dangerouslyBypassSandbox: false, codexLegacySandboxBypass: false }; + function Fixture() { + const [values, setValues] = useState(current); + current = values as typeof current; + return setValues(prev => ({ ...prev, ...patch }))} showAdapterTypeField hidePromptTemplate />; + } + try { + await act(async () => root.render( + + )); + await flushReact(); + expect(current.codexLegacySandboxBypass).toBe(false); + for (const harness of ["claude_local", "codex_local"]) { + await clickElement(container.querySelector('[aria-label="Harness"]')); + await clickElement(document.querySelector(`[role="option"][data-value="${harness}"]`)); + await flushReact(); + expect(current.adapterType).toBe(harness); + expect(current).toHaveProperty("codexLegacySandboxBypass", undefined); + } + } finally { + await act(async () => root.unmount()); + container.remove(); + } + }); + + it.each(["codex_local", "paperclip_runner"])("keeps a saved %s runner during unrelated edits", async adapterType => { + const adapterConfig = { model: "gpt-5.6-sol", ...(adapterType === "paperclip_runner" ? { provider: "codex", reasoningEffort: "high" } : { modelReasoningEffort: "high" }) }; + const result = await renderForm([], { adapterType, adapterConfig }, { showAdapterTypeField: true }); + roots.push(result.root); + expect(result.container.querySelector('[aria-label="Harness"]')?.textContent).toContain("Codex"); + await clickByText(result.container, "Advanced"); + expect(result.container.querySelector('[aria-label="Runner"]')?.textContent).toContain(adapterType === "paperclip_runner" ? "Paperclip Runner" : "Legacy runner"); + await act(async () => setInputValue(result.container.querySelector('[placeholder="Agent name"]')!, "Renamed Cody")); + await flushReact(); + await clickByText(result.container, "Save"); + expect(result.onSave).toHaveBeenCalledWith({ name: "Renamed Cody" }); + }); + + 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", + ...(adapterType === "paperclip_runner" ? { provider: "codex", reasoningEffort: "high" } : { modelReasoningEffort: "high" }), + env: { CODEX_HOME: { type: "secret_ref", secretId: "codex-account" } }, + }; + const result = await renderForm([ + ...daytonaEnvironments(), + makeEnvironment({ id: "sandbox-2", name: "Daytona Two", driver: "sandbox", config: { provider: "daytona" } }), + ], { adapterType, adapterConfig, defaultEnvironmentId: "sandbox-1" }, { showAdapterTypeField: true }); + roots.push(result.root); + await clickByText(result.container, "Advanced"); + const runnerLabel = adapterType === "paperclip_runner" ? "Paperclip Runner" : "Legacy runner"; + + await selectEnvironment(result.container, "sandbox-2"); + expect(result.container.querySelector('[aria-label="Environment override"]')?.textContent).toContain("Daytona Two"); + expect(result.container.querySelector('[aria-label="Runner"]')?.textContent).toContain(runnerLabel); + await clickByText(result.container, "Save"); + expect(result.onSave).toHaveBeenLastCalledWith({ defaultEnvironmentId: "sandbox-2" }); + + await selectEnvironment(result.container, ""); + expect(result.container.querySelector('[aria-label="Environment override"]')?.textContent).toContain("Default: Local"); + expect(result.container.querySelector('[aria-label="Runner"]')?.textContent).toContain(runnerLabel); + await clickByText(result.container, "Save"); + expect(result.onSave).toHaveBeenLastCalledWith({ defaultEnvironmentId: null }); + }); + + it("clears a new agent's environment override to the empty draft value", async () => { + const result = await renderCreateForm(daytonaEnvironments(), { defaultEnvironmentId: "sandbox-1", runner: "legacy" }); + roots.push(result.root); + await selectEnvironment(result.container, ""); + expect(result.onChange).toHaveBeenLastCalledWith({ defaultEnvironmentId: "" }); + }); + + it("submits only an explicit runner change and retains saved configuration for server translation", async () => { + const result = await renderForm([], { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol", reasoningEffort: "high", env: { CODEX_HOME: { type: "secret_ref", secretId: "codex-account" } } } }); + roots.push(result.root); + await clickByText(result.container, "Advanced"); + await clickElement(result.container.querySelector('[aria-label="Runner"]')); + await clickElement([...document.querySelectorAll('[role="option"]')].find(option => option.textContent?.startsWith("Legacy runner"))); + await clickByText(result.container, "Save"); + expect(result.onSave).toHaveBeenCalledWith({ runner: "legacy" }); + }); + + it.each([false, true])("keeps experimental native selection gated when enableNativeRunner is %s", async enabled => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: enabled }); + const result = await renderForm([], { adapterType: "claude_local", adapterConfig: { model: "claude-sonnet-5" } }, { showAdapterTypeField: true }); + roots.push(result.root); + await clickElement(result.container.querySelector('[aria-label="Harness"]')); + const option = [...document.querySelectorAll('[role="option"]')].find(element => element.getAttribute("data-value") === "paperclip_runner"); + expect(Boolean(option)).toBe(enabled); + if (!enabled) return; + expect(document.body.textContent).toContain("Advanced"); + await clickElement(option); + await clickByText(result.container, "Save"); + expect(result.onSave).toHaveBeenCalledWith(expect.objectContaining({ + adapterType: "paperclip_runner", + adapterConfig: expect.objectContaining({ provider: "acpx", acpxAgent: "claude" }), + })); + 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 => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false }); + const result = await renderForm([], { adapterType: "paperclip_runner", adapterConfig }, { showAdapterTypeField: true }); + roots.push(result.root); + expect(result.container.querySelector('[aria-label="Runner"]')).toBeNull(); + await act(async () => setInputValue(result.container.querySelector('[placeholder="Agent name"]')!, "Renamed native agent")); + await flushReact(); + await clickByText(result.container, "Save"); + expect(result.onSave).toHaveBeenCalledWith({ name: "Renamed native agent" }); + }); + beforeEach(() => { mockAgentsApi.adapterModels.mockResolvedValue([]); mockAgentsApi.detectModel.mockResolvedValue(null); @@ -820,7 +948,7 @@ describe("AgentConfigForm environment selector", () => { roots.push(result.root); expect(result.container.textContent).not.toContain("Environment override"); - expect(result.container.querySelector("select")).toBeNull(); + expect(result.container.querySelector('[aria-label="Environment override"]')).toBeNull(); }); it("renders GPT-6 Astra and its model-specific reasoning efforts", async () => { @@ -939,7 +1067,7 @@ describe("AgentConfigForm environment selector", () => { roots.push(result.root); const text = result.container.textContent ?? ""; - const selector = result.container.querySelector("select"); + const selector = await environmentOptions(result.container); expect(text).toContain("Environment"); expect(text).toContain("Environment override"); @@ -958,7 +1086,7 @@ describe("AgentConfigForm environment selector", () => { roots.push(result.root); const text = result.container.textContent ?? ""; - const selector = result.container.querySelector("select"); + const selector = await environmentOptions(result.container); expect(text).toContain("Environment override"); expect(selector?.textContent).toContain("E2B · sandbox"); @@ -972,7 +1100,7 @@ describe("AgentConfigForm environment selector", () => { roots.push(result.root); const text = result.container.textContent ?? ""; - const selector = result.container.querySelector("select"); + const selector = await environmentOptions(result.container); expect(text).toContain("Environment override"); expect(selector?.textContent).toContain("E2B · sandbox"); @@ -994,7 +1122,7 @@ describe("AgentConfigForm environment selector", () => { roots.push(result.root); const text = result.container.textContent ?? ""; - const selector = result.container.querySelector("select"); + const selector = await environmentOptions(result.container); expect(text).toContain("Environment override"); expect(selector?.textContent).toContain("Default: Local"); @@ -1014,7 +1142,7 @@ describe("AgentConfigForm environment selector", () => { ]); roots.push(result.root); - const selector = result.container.querySelector("select"); + const selector = await environmentOptions(result.container); expect(selector?.textContent).toContain("Default: Paperclip Computer"); expect(selector?.textContent).toContain("Paperclip Computer"); @@ -1191,6 +1319,33 @@ describe("AgentConfigForm environment selector", () => { expect(result.container.textContent).toContain("Network unavailable"); }); + it("moves through model options with the same keyboard controls as the harness picker", async () => { + const curated = [ + { id: "claude-fable-5-1", label: "Claude Fable 5.1" }, + { id: "claude-opus-5-5", label: "Claude Opus 5.5" }, + { id: "claude-haiku-4-5", label: "Claude Haiku 4.5" }, + { id: "claude-opus-4-8", label: "Claude Opus 4.8" }, + ]; + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + try { + await act(() => root.render( + {}} open onOpenChange={() => {}} allowDefault={false} required groupByProvider={false} preserveOrder /> + )); + const search = document.body.querySelector("input")!; + await act(() => { search.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); }); + expect(document.activeElement?.textContent).toBe(curated[0].label); + await act(() => { document.activeElement?.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true })); }); + expect(document.activeElement?.textContent).toBe(curated.at(-1)?.label); + await act(() => { document.activeElement?.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true })); }); + expect(document.activeElement?.textContent).toBe(curated[0].label); + } finally { + await act(() => root.unmount()); + container.remove(); + } + }); + it("hides the Login button before Test and shows it after the adapter_auth_missing check for a Codex sandbox", async () => { mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT); const result = await renderCodexSandbox(); @@ -1203,6 +1358,29 @@ describe("AgentConfigForm environment selector", () => { expect(findButton(result.container, "Sign in")).toBeTruthy(); }); + it.each([ + ["auth_required", false], ["auth_required", true], ["failed", false], + ] as const)("gates native Codex sandbox login on the shared auth check (%s, %s)", async (diagnostic, authentication) => { + mockAgentsApi.testEnvironment.mockResolvedValue({ + adapterType: "paperclip_runner", status: "fail", testedAt: new Date(0).toISOString(), + checks: [ + { code: `codex_hello_probe_${diagnostic}`, level: "error", message: "Native Codex setup failed." }, + ...(authentication ? [{ code: "adapter_auth_missing", level: "error", message: "The selected Codex account needs authentication." }] : []), + ], + }); + const result = await renderCodexSandbox({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } }); + roots.push(result.root); + expect(findButton(result.container, "Sign in")).toBeFalsy(); + await runTest(result.container); + expect(Boolean(findButton(result.container, "Sign in"))).toBe(authentication); + if (authentication) { + await startLogin(result.container); + expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledWith("company-1", "codex_local", { environmentId: "sandbox-1" }); + } else { + expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled(); + } + }); + it("hides the Codex login for a provider without the login pseudo-terminal capability", async () => { // The Codex device login runs on a real pseudo-terminal, so it needs a // provider that advertises the login pseudo-terminal capability. E2B reports @@ -2348,15 +2526,7 @@ describe("AgentConfigForm environment selector", () => { await runTest(result.container); expect(findButton(result.container, "Sign in")).toBeTruthy(); - const select = result.container.querySelector("select"); - await act(async () => { - if (select) { - const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set; - setter?.call(select, ""); - select.dispatchEvent(new Event("change", { bubbles: true })); - } - }); - await flushReact(); + await selectEnvironment(result.container, ""); expect(findButton(result.container, "Sign in")).toBeFalsy(); }); diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 9ca3caa200a..7b6ef27182a 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1,4 +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 { aiRoutingHarness } from "@paperclipai/shared"; import { AiConnectionField } from "./ai-connections/AiConnectionField"; import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema } from "@paperclipai/shared"; @@ -38,16 +41,11 @@ import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; import { DEFAULT_KIMI_LOCAL_MODEL } from "@paperclipai/adapter-kimi-local"; import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { NativeSelect, selectTriggerClassName } from "@/components/ui/select"; +import { NativeSelect, SelectPopover } from "@/components/ui/select"; import { AdapterMark } from "./AdapterMark"; -import { FolderOpen, Heart, ChevronDown, X, Copy, Check, ExternalLink, Loader2, TriangleAlert, Bug } from "lucide-react"; +import { FolderOpen, Heart, X, Copy, Check, ExternalLink, Loader2, TriangleAlert, Bug } from "lucide-react"; import { asBoolean, asFiniteNumber, asObject, cn } from "../lib/utils"; import { copyTextToClipboard } from "../lib/clipboard"; import { @@ -191,6 +189,7 @@ function isOverlayDirty(o: AgentConfigOverlay): boolean { return ( Object.keys(o.identity).length > 0 || o.adapterType !== undefined || + o.runner !== undefined || Object.keys(o.adapterConfig).length > 0 || Object.keys(o.heartbeat).length > 0 || Object.keys(o.debug).length > 0 || @@ -246,6 +245,7 @@ export function subtractPersistedOverlay( ); return { identity: subtractGroup(current.identity, persisted.identity), + ...(current.runner !== undefined && current.runner !== persisted.runner ? { runner: current.runner } : {}), ...(current.adapterType !== undefined && current.adapterType !== persisted.adapterType ? { adapterType: current.adapterType } : {}), @@ -606,8 +606,29 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const adapterType = isCreate ? props.values.adapterType : overlay.adapterType ?? props.agent.adapterType; + const effectiveConfig = isCreate ? props.values.adapterSchemaValues ?? {} : { ...config, ...overlay.adapterConfig }; + const harnessType = agentHarnessType(adapterType, effectiveConfig); + const isCodexHarness = harnessType === "codex_local"; + const rawCurrentDefaultEnvironmentId = isCreate + ? props.values.defaultEnvironmentId ?? "" + : eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? ""); + const currentDefaultEnvironmentId = useMemo(() => { + if (!rawCurrentDefaultEnvironmentId) return ""; + const selected = environments.find((environment) => environment.id === rawCurrentDefaultEnvironmentId) ?? null; + return selected?.driver === "local" ? "" : rawCurrentDefaultEnvironmentId; + }, [environments, rawCurrentDefaultEnvironmentId]); + const runnerDiscovery = useQuery({ + queryKey: queryKeys.adapters.availability(selectedCompanyId, rawCurrentDefaultEnvironmentId || null), + queryFn: () => adaptersApi.list({ companyId: selectedCompanyId!, environmentId: rawCurrentDefaultEnvironmentId || null }), + enabled: Boolean(selectedCompanyId), + }); + const codexRunnerAvailability = runnerDiscovery.data?.find(item => item.type === "codex_local"); + 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") + : adapterType; const getCapabilities = useAdapterCapabilities(); - const adapterCaps = getCapabilities(adapterType); + const adapterCaps = getCapabilities(isCodexHarness ? harnessType : adapterType); const isDotRunner = adapterType === "paperclip_runner" && (isCreate ? props.values.adapterSchemaValues?.provider : eff("adapterConfig", "provider", config.provider)) === "openai_dot"; const isLocal = !isDotRunner && (adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt); @@ -619,9 +640,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) { && !hideHostPaths && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config }); const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); + const fieldsUiAdapter = useMemo(() => getUIAdapter(fieldsAdapterType), [fieldsAdapterType]); const supportedEnvironmentDrivers = useMemo( - () => new Set(supportedEnvironmentDriversForAdapter(adapterType)), - [adapterType], + () => new Set(supportedEnvironmentDriversForAdapter(fieldsAdapterType)), + [fieldsAdapterType], ); const val = isCreate ? props.values : null; const set = isCreate @@ -774,14 +796,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) { invalidateUserSecretDefinitions(); }; - const rawCurrentDefaultEnvironmentId = isCreate - ? val!.defaultEnvironmentId ?? "" - : eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? ""); - const currentDefaultEnvironmentId = useMemo(() => { - if (!rawCurrentDefaultEnvironmentId) return ""; - const selected = environments.find((environment) => environment.id === rawCurrentDefaultEnvironmentId) ?? null; - return selected?.driver === "local" ? "" : rawCurrentDefaultEnvironmentId; - }, [environments, rawCurrentDefaultEnvironmentId]); const currentDefaultEnvironment = useMemo( () => environments.find((environment) => environment.id === currentDefaultEnvironmentId) ?? null, [currentDefaultEnvironmentId, environments], @@ -904,8 +918,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ).data?.provider === "openrouter" ? "openrouter" : runnerProvider; const connectionModels = useConnectionModels(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 modelQueryKey = selectedCompanyId - ? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, currentDefaultEnvironmentId || null, modelProvider) + ? queryKeys.agents.adapterModels(selectedCompanyId, modelAdapterType, currentDefaultEnvironmentId || null, modelProvider) : ["agents", "none", "adapter-models", adapterType]; const { data: fetchedModels, @@ -913,7 +928,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { isLoading: fetchingModels, } = useQuery({ queryKey: modelQueryKey, - queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType, { + queryFn: () => agentsApi.adapterModels(selectedCompanyId!, modelAdapterType, { environmentId: currentDefaultEnvironmentId || null, provider: modelProvider, }), @@ -954,7 +969,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { agentId: isCreate ? undefined : props.agent.id, mode, isCreate, - adapterType, + adapterType: fieldsAdapterType, + hideRunnerHarness: isCodexHarness, values: isCreate ? props.values : null, set: isCreate ? (patch: Partial) => props.onChange(patch) : null, config, @@ -979,7 +995,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const renderAdapterFields = (section: AdapterConfigSection) => ( <> {adapterType === "claude_local" && } - + ); // Popover states @@ -1088,9 +1104,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 }); + return testAgentSetup({ companyId: selectedCompanyId, adapterType, providerAdapter, adapterConfig, agentId, aiConnection, environmentId, ...(isCodexHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {}) }); } - return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, aiConnection, environmentId }); + return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, aiConnection, environmentId, ...(isCodexHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {}) }); }, }); const [testActionPending, setTestActionPending] = useState(false); @@ -1137,7 +1153,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { resetTestEnvironmentRef.current(); setTestActionError(null); clearClaudeLoginClaimRef.current(); - }, [adapterType, effectiveLoginEnvironmentId]); + }, [adapterType, runner, effectiveLoginEnvironmentId, runner === "auto" ? codexRunnerAvailability?.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 @@ -1148,7 +1164,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const adapterSupportsSandboxLogin = adapterCaps.login != null; const testResult = testEnvironment.data; const testResultSupportsSandboxLogin = - testResult != null && getCapabilities(testResult.adapterType).login != null; + testResult != null && getCapabilities(isCodexHarness ? harnessType : testResult.adapterType).login != null; const authMissingCheck = testResult && testResultSupportsSandboxLogin ? testResult.checks.find((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE) ?? null @@ -1243,7 +1259,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { // environment id, so both are present here. login: showAdapterLogin && selectedCompanyId && effectiveLoginEnvironmentId - ? { companyId: selectedCompanyId, adapterType, environmentId: effectiveLoginEnvironmentId } + ? { companyId: selectedCompanyId, adapterType: isCodexHarness ? harnessType : adapterType, environmentId: effectiveLoginEnvironmentId } : null, }); return () => { @@ -1271,7 +1287,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { setRefreshingModels(true); setRefreshModelsError(null); try { - const refreshed = await agentsApi.adapterModels(selectedCompanyId, adapterType, { refresh: true, environmentId: currentDefaultEnvironmentId || null, provider: modelProvider }); + const refreshed = await agentsApi.adapterModels(selectedCompanyId, modelAdapterType, { refresh: true, environmentId: currentDefaultEnvironmentId || null, provider: modelProvider }); queryClient.setQueryData(modelQueryKey, refreshed); } catch (error) { setRefreshModelsError(error instanceof Error ? error.message : "Failed to refresh adapter models."); @@ -1281,7 +1297,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } const thinkingEffortKey = - adapterType === "codex_local" + isCodexHarness ? "modelReasoningEffort" : adapterType === "cursor" ? "mode" @@ -1290,7 +1306,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { : adapterType === "grok_local" ? "reasoningEffort" : adapterType === "pi_local" ? "thinking" : "effort"; const thinkingEffortOptions = - adapterType === "codex_local" + isCodexHarness ? codexReasoningEffortOptions(currentModelId, "Auto").map((option) => ({ id: option.value, label: option.label, @@ -1311,11 +1327,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) { : claudeThinkingEffortOptions; const currentThinkingEffort = isCreate ? val!.thinkingEffort - : adapterType === "codex_local" + : isCodexHarness ? eff( "adapterConfig", - "modelReasoningEffort", - String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""), + thinkingEffortKey, + String(config[thinkingEffortKey] ?? config.reasoningEffort ?? config.modelReasoningEffort ?? ""), ) : adapterType === "cursor" ? eff("adapterConfig", "mode", String(config.mode ?? "")) @@ -1324,7 +1340,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { : eff("adapterConfig", thinkingEffortKey, String(config[thinkingEffortKey] ?? "")); const showThinkingEffort = adapterType !== "gemini_local" && adapterType !== "cursor_cloud" - && adapterType !== "paperclip_runner"; + && (adapterType !== "paperclip_runner" || isCodexHarness); const codexSearchEnabled = adapterType === "codex_local" ? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search))) : false; @@ -1563,25 +1579,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
- + options={[ + { value: "", label: `Default: ${inheritedEnvironmentLabel}` }, + ...environmentOptions.map((environment) => ({ + value: environment.id, + label: environmentDisplayLabel(environment), + })), + ]} + />
@@ -1610,18 +1625,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{showAdapterTypeField && ( - + { const dot = choice === "openai_dot"; const t = dot ? "paperclip_runner" : choice; if (isCreate) { // Reset all adapter-specific fields to defaults when switching adapter type const { adapterType: _at, ...defaults } = defaultCreateValues; - const nextValues: CreateConfigValues = { ...defaults, adapterType: t }; + const nextValues: CreateConfigValues = { ...defaults, adapterType: t, runner: undefined }; if (t === "codex_local") { nextValues.dangerouslyBypassSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; @@ -1647,6 +1663,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { setOverlay((prev) => ({ ...prev, adapterType: t, + runner: t === "codex_local" ? "auto" : undefined, adapterConfig: { model: t === "gemini_local" @@ -1665,12 +1682,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { modelReasoningEffort: "", variant: "", mode: "", - ...(t === "codex_local" - ? { - dangerouslyBypassApprovalsAndSandbox: - DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, - } - : dot + ...(dot ? { provider: "openai_dot", lifecycleMode: "per_turn", allowUnmeteredProvider: false, dotAttachmentAccess: false, dotWorkspaceAccess: false } : t === "paperclip_runner" ? { @@ -1707,7 +1719,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { { - const supportedEfforts = setupEfforts(adapterType, v); - const clearUnsupportedEffort = ["codex_local", "claude_local", "grok_local"].includes(adapterType) + const supportedEfforts = setupEfforts(isCodexHarness ? harnessType : adapterType, v); + const clearUnsupportedEffort = ["codex_local", "claude_local", "grok_local"].includes(isCodexHarness ? harnessType : adapterType) && Boolean(currentThinkingEffort) && !supportedEfforts.includes(String(currentThinkingEffort)); if (isCreate) { @@ -1816,7 +1828,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) { onChange={(v) => isCreate ? set!({ thinkingEffort: v }) - : mark("adapterConfig", thinkingEffortKey, v || undefined) + : (() => { + mark("adapterConfig", thinkingEffortKey, v || undefined); + if (isCodexHarness) mark("adapterConfig", thinkingEffortKey === "reasoningEffort" ? "modelReasoningEffort" : "reasoningEffort", undefined); + })() } open={thinkingEffortOpen} onOpenChange={setThinkingEffortOpen} @@ -1877,6 +1892,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) { onToggle={() => setConfigurationAdvancedOpen(!configurationAdvancedOpen)} >
+ {isCodexHarness && { void runnerDiscovery.refetch(); }} + defaultRunner={codexRunnerAvailability?.defaultRunner} + supportedRunners={codexRunnerAvailability?.supportedRunners} + onChange={next => isCreate ? set!({ runner: next }) : setOverlay(prev => ({ ...prev, runner: next }))} />} {isLocal && (<> {/* The command names a binary on the execution host, so the managed-sandbox-only policy hides it: the platform-managed image @@ -3679,77 +3699,32 @@ export function AdapterTypeDropdown({ onChange, disabledTypes, openAiDotEnabled = false, + nativeRunnerEnabled = false, }: { value: string; onChange: (type: string) => void; disabledTypes: Set; openAiDotEnabled?: boolean; + nativeRunnerEnabled?: boolean; }) { - const [open, setOpen] = useState(false); const selectedDisplay = getAdapterDisplay(value); - const adapterList = useMemo( - () => - [...listAdapterOptions((type) => adapterLabels[type] ?? getAdapterLabel(type)), - ...(openAiDotEnabled ? [{ value: "openai_dot", label: "OpenAI Dot", experimental: true, comingSoon: false }] : []), - ].filter( - (item) => !disabledTypes.has(item.value), - ), - [disabledTypes, openAiDotEnabled], - ); - - return ( - - - - - - {adapterList.map((item) => ( - - ))} - - - ); + const adapterList = useMemo(() => + [...listAdapterOptions(type => adapterLabels[type] ?? getAdapterLabel(type)), + ...(openAiDotEnabled ? [{ value: "openai_dot", label: "OpenAI Dot", experimental: true, comingSoon: false }] : []), + ].filter(item => (item.value !== "paperclip_runner" || nativeRunnerEnabled) && !disabledTypes.has(item.value)) + .sort((a, b) => Number(a.value === "paperclip_runner") - Number(b.value === "paperclip_runner")), + [disabledTypes, openAiDotEnabled, nativeRunnerEnabled]); + return + + {adapterLabels[value] ?? getAdapterLabel(value)} + {selectedDisplay.experimental && } + } + options={adapterList.map(item => ({ value: item.value, disabled: item.comingSoon, + group: item.value === "paperclip_runner" ? "Advanced" : undefined, + label: {item.label}{item.experimental && }, + suffix: item.comingSoon ? Coming soon : undefined, + }))} />; } function ExperimentalBadge() { @@ -3917,29 +3892,17 @@ export function ModelDropdown({ return ( - + {selected?.label ?? (value || (allowDefault ? (defaultLabel ?? "Default") : required ? "Select model (required)" : "Select model"))} + } open={open} onOpenChange={(nextOpen) => { onOpenChange(nextOpen); if (!nextOpen) setModelSearch(""); }} > - - - - +
m.id === value) || promotedModelIds.has(value)) && (
- - +
+
); } @@ -4143,36 +4112,11 @@ function ThinkingEffortDropdown({ open: boolean; onOpenChange: (open: boolean) => void; }) { - const selected = options.find((option) => option.id === value) ?? options[0]; - - return ( - - - - - - - {options.map((option) => ( - - ))} - - - - ); + return + ({ value: option.id, label: option.label, + suffix: option.id ? {option.id} : undefined, + }))} /> + ; } diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx index a2cbd4dd082..6fc60c8731e 100644 --- a/ui/src/components/AgentProperties.tsx +++ b/ui/src/components/AgentProperties.tsx @@ -4,7 +4,7 @@ import { Link } from "@/lib/router"; import { AGENT_ROLE_LABELS, type Agent, type AgentRuntimeState } from "@paperclipai/shared"; import { agentsApi } from "../api/agents"; import { useCompany } from "../context/CompanyContext"; -import { getAdapterLabel } from "../adapters/adapter-display-registry"; +import { agentDisplayAdapterType, getAdapterLabel } from "../adapters/adapter-display-registry"; import { queryKeys } from "../lib/queryKeys"; import { AgentStatusBadge } from "./StatusBadge"; import { Identity } from "./Identity"; @@ -61,7 +61,7 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) { )} - {getAdapterLabel(agent.adapterType)} + {getAdapterLabel(agentDisplayAdapterType(agent))}
diff --git a/ui/src/components/CodexRunnerSelect.tsx b/ui/src/components/CodexRunnerSelect.tsx new file mode 100644 index 00000000000..6059f3e4ba3 --- /dev/null +++ b/ui/src/components/CodexRunnerSelect.tsx @@ -0,0 +1,44 @@ +import type { AgentRunner, AgentRunnerChoice } from "@paperclipai/shared"; +import { Field } from "./agent-config-primitives"; +import { SelectPopover } from "./ui/select"; +import { Button } from "./ui/button"; + +export function CodexRunnerSelect({ + value, + onChange, + defaultRunner, + supportedRunners = ["legacy"], + disabled, + pending, + error, + onRetry, +}: { + value: AgentRunnerChoice; + onChange: (value: AgentRunnerChoice) => void; + defaultRunner?: AgentRunner; + supportedRunners?: readonly AgentRunner[]; + disabled?: boolean; + pending?: boolean; + error?: string; + onRetry?: () => void; +}) { + return ( + + onChange(next as AgentRunnerChoice)} + options={[ + { value: "paperclip", label: `Paperclip Runner${defaultRunner === "paperclip" ? " (default)" : ""}`, disabled: !supportedRunners.includes("paperclip") && value !== "paperclip" }, + { value: "legacy", label: `Legacy runner${defaultRunner === "legacy" ? " (default)" : ""}`, disabled: !supportedRunners.includes("legacy") && value !== "legacy" }, + ]} + /> + {error &&
+

{error}

+ {onRetry && } +
} +
+ ); +} diff --git a/ui/src/components/ConfigureBuiltInAgentModal.test.tsx b/ui/src/components/ConfigureBuiltInAgentModal.test.tsx index 17b981304c4..4bc3e2d30c4 100644 --- a/ui/src/components/ConfigureBuiltInAgentModal.test.tsx +++ b/ui/src/components/ConfigureBuiltInAgentModal.test.tsx @@ -11,6 +11,9 @@ import type { BuiltInAgentState } from "@/api/builtInAgents"; const provisionMock = vi.hoisted(() => vi.fn()); const updateMock = vi.hoisted(() => vi.fn()); const adapterModelsMock = vi.hoisted(() => vi.fn()); +const adapterDiscoveryMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/adapters", () => ({ adaptersApi: { list: adapterDiscoveryMock } })); vi.mock("@/api/builtInAgents", async (importOriginal) => { const actual = await importOriginal(); @@ -72,6 +75,15 @@ function makeState(overrides: Partial = {}): BuiltInAgentStat }; } +function makeSavedAgent(adapterType: "codex_local" | "paperclip_runner", adapterConfig: Record): NonNullable { + return { + id: "a1", companyId: "c1", name: "Briefs Agent", urlKey: "briefs-agent", role: "general", title: null, icon: null, + status: "idle", reportsTo: null, capabilities: null, adapterType, adapterConfig, runtimeConfig: {}, defaultEnvironmentId: null, + budgetMonthlyCents: 0, spentMonthlyCents: 0, pauseReason: null, pausedAt: null, permissions: { canCreateAgents: false }, + lastHeartbeatAt: null, metadata: null, createdAt: new Date(0), updatedAt: new Date(0), + }; +} + async function flushReact() { for (let index = 0; index < 6; index += 1) { await Promise.resolve(); @@ -117,6 +129,7 @@ describe("ConfigureBuiltInAgentModal (PAP-12978)", () => { provisionMock.mockReset(); updateMock.mockReset(); adapterModelsMock.mockReset().mockResolvedValue([]); + adapterDiscoveryMock.mockReset().mockResolvedValue([{ type: "codex_local", supportedRunners: ["paperclip", "legacy"], defaultRunner: "legacy" }]); onOpenChange.mockReset(); onConfigured.mockReset(); }); @@ -189,6 +202,85 @@ describe("ConfigureBuiltInAgentModal (PAP-12978)", () => { }); }); + it.each([ + ["codex_local", "ready"], + ["paperclip_runner", "ready"], + ["codex_local", "paused"], + ["paperclip_runner", "paused"], + ] as const)("lets the server translate saved %s settings for a runner-only change while %s", async (adapterType, status) => { + const savedConfig = { model: "gpt-5", timeoutSec: 45, modelReasoningEffort: "high", env: { CODEX_HOME: { type: "secret_ref", secretId: "account-1" } }, ...(adapterType === "paperclip_runner" ? { provider: "codex", codexPermissionMode: "never", lifecycleMode: "per_turn" } : {}) }; + const state = makeState({ status, agentId: "a1", agent: makeSavedAgent(adapterType, savedConfig) }); + provisionMock.mockResolvedValue(state); + await renderModal(state); + const summary = document.querySelector("summary")!; + flushSync(() => summary.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + flushSync(() => document.querySelector('[aria-label="Runner"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + await flushReact(); + const runner = adapterType === "paperclip_runner" ? "legacy" : "paperclip"; + flushSync(() => document.querySelector(`[role="option"][data-value="${runner}"]`)!.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + await flushReact(); + flushSync(() => findButton("Configure")!.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + await flushReact(); + expect(provisionMock).toHaveBeenCalledWith("c1", "briefs", { runner }); + expect(state.agent?.adapterConfig).toEqual(savedConfig); + }); + + it.each([ + ["codex_local", "saved"], + ["paperclip_runner", "saved"], + ["codex_local", "definition"], + ["paperclip_runner", "definition"], + ] as const)("submits the displayed %s configuration to finish setup with its %s model", async (adapterType, modelSource) => { + const savedConfig = { + ...(modelSource === "saved" ? { model: "gpt-5" } : {}), + timeoutSec: 45, + modelReasoningEffort: "high", + env: { CODEX_HOME: { type: "secret_ref", secretId: "account-1" } }, + ...(adapterType === "paperclip_runner" ? { provider: "codex", codexPermissionMode: "never", lifecycleMode: "per_turn" } : {}), + }; + const agent = makeSavedAgent(adapterType, savedConfig); + agent.metadata = { paperclipBuiltInSetupRequired: true }; + const state = makeState({ + status: "needs_setup", + agentId: agent.id, + agent, + definition: { ...makeState().definition, defaultAdapterConfig: { model: "gpt-5" } }, + }); + const configured = { ...state, status: "ready" as const, agent: { ...agent, metadata: { paperclipBuiltInSetupRequired: false } } }; + provisionMock.mockResolvedValue(configured); + await renderModal(state); + + expect(document.querySelector('[data-testid="model-input"]')?.value).toBe("gpt-5"); + expect(findButton("Configure")?.disabled).toBe(false); + flushSync(() => findButton("Configure")!.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + await flushReact(); + + expect(provisionMock).toHaveBeenCalledWith("c1", "briefs", { + adapterType, + adapterConfig: { ...savedConfig, model: "gpt-5" }, + }); + expect(onConfigured).toHaveBeenCalledWith(configured); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(state.agent?.metadata?.paperclipBuiltInSetupRequired).toBe(true); + expect(state.agent?.adapterConfig).toEqual(savedConfig); + }); + + it("merges a built-in model edit into the saved native configuration", async () => { + const savedConfig = { provider: "codex", model: "gpt-5", timeoutSec: 45, modelReasoningEffort: "high", env: { CODEX_HOME: { type: "secret_ref", secretId: "account-1" } } }; + const state = makeState({ status: "ready", agentId: "a1", agent: makeSavedAgent("paperclip_runner", savedConfig) }); + provisionMock.mockResolvedValue(state); + await renderModal(state); + const modelInput = document.querySelector('[data-testid="model-input"]') as HTMLInputElement; + flushSync(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(modelInput, "gpt-6-sol"); + modelInput.dispatchEvent(new Event("input", { bubbles: true })); + }); + await flushReact(); + flushSync(() => findButton("Configure")!.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + await flushReact(); + expect(provisionMock).toHaveBeenCalledWith("c1", "briefs", { adapterType: "paperclip_runner", adapterConfig: { ...savedConfig, model: "gpt-6-sol" } }); + }); + it("shows a visible error and blocks provisioning for an unknown model", async () => { adapterModelsMock.mockResolvedValue([ { id: "claude-haiku-4-5", label: "Claude Haiku 4.5" }, diff --git a/ui/src/components/ConfigureBuiltInAgentModal.tsx b/ui/src/components/ConfigureBuiltInAgentModal.tsx index 0a8c01479f0..8e6e63176bb 100644 --- a/ui/src/components/ConfigureBuiltInAgentModal.tsx +++ b/ui/src/components/ConfigureBuiltInAgentModal.tsx @@ -1,4 +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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -65,8 +68,12 @@ export function ConfigureBuiltInAgentModal({ const { definition } = state; const [adapterType, setAdapterType] = useState( - () => state.agent?.adapterType ?? defaultAdapterType(state), + () => state.agent && agentHarnessType(state.agent.adapterType, state.agent.adapterConfig) === "codex_local" ? "codex_local" : 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 [model, setModel] = useState(() => { const config = state.agent?.adapterConfig; const configuredModel = typeof config === "object" && config !== null @@ -127,9 +134,16 @@ export function ConfigureBuiltInAgentModal({ mutationFn: async () => { const adapterConfig: Record = {}; if (model.trim()) adapterConfig.model = model.trim(); + const existing = state.agent; + const sameHarness = existing && agentHarnessType(existing.adapterType, existing.adapterConfig) === adapterType; + const savedModel = typeof existing?.adapterConfig.model === "string" ? existing.adapterConfig.model.trim() : ""; + const alreadyConfigured = state.status === "ready" || state.status === "paused"; + const adapterInput = alreadyConfigured && sameHarness && normalizedModel === savedModel ? {} + : sameHarness ? { adapterType: existing.adapterType, adapterConfig: { ...existing.adapterConfig, ...adapterConfig } } + : { adapterType, adapterConfig }; const result = await builtInAgentsApi.provision(companyId, definition.key, { - adapterType, - adapterConfig, + ...adapterInput, + ...(adapterType === "codex_local" && runner !== undefined ? { runner } : {}), ...(budgetMonthlyCents !== undefined ? { budgetMonthlyCents } : {}), }); return result; @@ -163,17 +177,26 @@ export function ConfigureBuiltInAgentModal({ board. - + { setAdapterType(next); setModel(""); + setRunner(undefined); }} disabledTypes={disabledTypes} /> + {adapterType === "codex_local" &&
+ Advanced + { void adapters.refetch(); }} + defaultRunner={codexAvailability?.defaultRunner} supportedRunners={codexAvailability?.supportedRunners} + onChange={setRunner} /> +
} + {modelRequired && ( // ModelDropdown supplies its own "Model" Field label + hint. { expect(query.get("name")).toBe("Ada & Co"); expect(query.get("adapterType")).toBe("codex_local"); }); -it("offers native Codex, Claude ACPX, and OpenCode runners", async () => { +it("offers one Codex harness without a runner/provider tile", async () => { await name(); - await act(async () => - ( - document.querySelector( - 'input[value="paperclip_runner"]', - ) as HTMLInputElement - ).click(), - ); - const options = [...document.querySelectorAll("option")].map( - (option) => option.textContent, - ); - expect(options).toContain("Codex (app server)"); - expect(options).toContain("Claude (ACPX)"); - expect(options).toContain("OpenCode"); - expect(options.join(" ")).not.toContain("ACPX Codex"); + expect(document.querySelector('input[value="paperclip_runner"]')).toBeNull(); + expect(document.querySelectorAll('input[value="codex_local"]')).toHaveLength(1); + expect(document.querySelector("select")).toBeNull(); }); - -it.each([false, undefined])( - "hides the runner unless explicitly enabled (%s)", - async (enableNativeRunner) => { - await act(async () => { - cache.setQueryData(queryKeys.instance.experimentalSettings, { - enableNativeRunner, - }); - }); - await name(); - expect( - document.querySelector('input[value="paperclip_runner"]'), - ).toBeNull(); - expect(document.querySelector('input[value="codex_local"]')).not.toBeNull(); - }, -); - -it.each([true, false, undefined])("gates the Cloud native runner on explicit enablement (%s)", async (enableNativeRunner) => { - await act(async () => { - cache.setQueryData(queryKeys.instance.experimentalSettings, { enableNativeRunner }); - cache.setQueryData(queryKeys.health, { - status: "ok", - cloud: { managed: true }, - }); - cache.setQueryData( - queryKeys.adapters.all, - [ - "claude_local", - "codex_local", - "opencode_local", - "cursor", - "cursor_cloud", - "gemini_local", - "grok_local", - "kimi_local", - "pi_local", - "hermes_local", - "paperclip_runner", - ].map((type) => ({ type, loaded: true })), - ); - }); +it.each(["claude", "grok", "opencode"])("preserves explicit experimental %s creation in Advanced", async provider => { await name(); - expect( - [...document.querySelectorAll('input[type="radio"]')].map( - (input) => input.value, - ), - ).toEqual(["claude_local", "codex_local", "opencode_local", "grok_local", ...(enableNativeRunner ? ["paperclip_runner"] : [])]); - await act(async () => - document.querySelector('input[value="grok_local"]')!.click(), - ); + await act(async () => (document.querySelector("summary") as HTMLElement).click()); + await act(async () => document.querySelector('[aria-label="Experimental harness"]')!.click()); + await act(async () => document.querySelector(`[role="option"][data-value="${provider}"]`)!.click()); await click("Configure agent"); const query = new URL(state.navigate.mock.calls[0][0], "http://local").searchParams; - expect(query.get("adapterType")).toBe("grok_local"); - expect(query.get("name")).toBe("Ada & Co"); + expect(query.get("adapterType")).toBe("paperclip_runner"); + expect(query.get("runnerProvider")).toBe(provider); +}); +it.each(["gate off", "disabled adapter"])("hides experimental native creation when %s", async reason => { + await act(async () => { + if (reason === "gate off") cache.setQueryData(queryKeys.instance.experimentalSettings, { enableNativeRunner: false }); + else cache.setQueryData(queryKeys.adapters.all, state.adapters.map((entry: any) => entry.type === "paperclip_runner" ? { ...entry, disabled: true } : entry)); + }); + await name(); + expect(document.querySelector('[aria-label="Experimental harness"]')).toBeNull(); + expect(document.querySelectorAll('input[value="codex_local"]')).toHaveLength(1); }); it("moves Dot out of the harness picker into experimental external invitations", async () => { diff --git a/ui/src/components/OnboardingWizard.adapters.test.tsx b/ui/src/components/OnboardingWizard.adapters.test.tsx index ba564056272..0ccb83066d0 100644 --- a/ui/src/components/OnboardingWizard.adapters.test.tsx +++ b/ui/src/components/OnboardingWizard.adapters.test.tsx @@ -200,7 +200,7 @@ describe("OnboardingWizard adapter selection", () => { }); }); - it("keeps onboarding on legacy adapters even when Paperclip Runner is enabled", async () => { + it("recovers a saved Codex runner draft without changing its model or runner", async () => { mockAdapterRegistry.list = [ { type: "paperclip_runner" }, { type: "codex_local" }, @@ -223,7 +223,8 @@ describe("OnboardingWizard adapter selection", () => { window.localStorage.getItem(ONBOARDING_STORAGE_KEY) ?? "{}", ); expect(saved.adapterType).toBe("codex_local"); - expect(saved.model).toBe(""); + expect(saved.runner).toBe("paperclip"); + expect(saved.model).toBe("gpt-runner-only"); expect(saved.command).toBe(""); expect(saved.args).toBe(""); expect(saved.url).toBe(""); @@ -253,8 +254,9 @@ describe("OnboardingWizard adapter selection", () => { const saved = JSON.parse( window.localStorage.getItem(ONBOARDING_STORAGE_KEY) ?? "{}", ); - expect(saved.adapterType).toBe("claude_local"); - expect(saved.model).toBe(""); + expect(saved.adapterType).toBe("codex_local"); + expect(saved.runner).toBe("paperclip"); + expect(saved.model).toBe("gpt-runner-only"); expect(saved.command).toBe(""); expect(saved.args).toBe(""); expect(saved.url).toBe(""); diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index fcd0ad19144..9f4dd00fc58 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { act } from "react"; +import { act, StrictMode } from "react"; import type { LocalAiLoginStatus } from "@paperclipai/shared"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -12,6 +12,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // *succeeds*. A seeded entry is stale under the test client and refetches, so // the refetch has to answer too — otherwise the identity errors and the list // never runs. +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 })); + const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn() })); const localHealth = vi.hoisted(() => ({ get: vi.fn() })); vi.mock("@/api/health", () => ({ healthApi: localHealth })); @@ -130,6 +133,7 @@ const mockAdapterBuild = vi.hoisted(() => ({ // the probe dies on "Could not load environment settings" and the hire never // runs — which reads as a mysterious 0-call assertion, not an error. const mockEnvironmentsApi = vi.hoisted(() => ({ + probe: vi.fn(async (): Promise => ({ ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "platform-lease" } })), list: vi.fn(async () => [] as Array>), capabilities: vi.fn( async (): Promise => @@ -167,8 +171,10 @@ const mockAdapterRegistry = vi.hoisted(() => ({ disabled: new Set(), })); +const mockRouterState = vi.hoisted(() => ({ pathname: "/" })); + vi.mock("@/lib/router", () => ({ - useLocation: () => ({ pathname: "/", search: "", hash: "", state: null }), + useLocation: () => ({ pathname: mockRouterState.pathname, search: "", hash: "", state: null }), useNavigate: () => vi.fn(), useParams: () => ({}), })); @@ -285,12 +291,12 @@ function renderWizardView(root: ReturnType, queryClient: Quer const SESSION_USER_ID = "user-b"; -function render() { +function render(staleTime = 0) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, + defaultOptions: { queries: { retry: false, staleTime } }, }); // The company list is keyed by account, so it stays disabled until the // session is known. Seeding it is how these tests say "signed in as B". @@ -335,6 +341,8 @@ function isArcPrimary(text: string): boolean { describe("OnboardingWizard restore-gate (stale localStorage across accounts)", () => { beforeEach(() => { + mockAdaptersApi.list.mockReset().mockResolvedValue([]); + mockEnvironmentsApi.probe.mockReset().mockResolvedValue({ ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "platform-lease" } }); localHealth.get.mockResolvedValue({ deploymentMode: "authenticated" }); managedApi.checkLocalLogin.mockReset().mockResolvedValue({ status: "sign_in_required", @@ -350,6 +358,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( mockSecretsApi.listMyUserSecrets.mockResolvedValue([]); mockSecretsApi.removeUserSecretDefinition.mockResolvedValue({ ok: true }); window.localStorage.clear(); + mockRouterState.pathname = "/"; mockDialog.onboardingOpen = true; mockDialog.onboardingOptions = {}; mockDialog.onboardingRouteDismissed = false; @@ -1252,6 +1261,116 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( }); }); + describe("restored route progress", () => { + function savedReview(runner = "auto") { + return { step: 5, companyName: "Saved Co", createdCompanyId: "c1", createdCompanyPrefix: "SC", + createdAgentId: "saved-agent", agentName: "Saved lead", adapterType: "codex_local", runner, + model: "gpt-5.6-sol", credentialMode: "api" }; + } + + async function openSavedReview(pathname: string, runner = "auto", options: { strict?: boolean; companiesLoading?: boolean; dialogStep?: number } = {}) { + const draft = savedReview(runner); + window.localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify(draft)); + mockRouterState.pathname = pathname; + mockDialog.onboardingOpen = options.dialogStep !== undefined; + mockDialog.onboardingOptions = options.dialogStep === undefined ? {} : { initialStep: options.dialogStep, companyId: "c1" }; + const companies = [{ id: "c1", name: "Saved Co", issuePrefix: "SC" }, + { id: "c2", name: "Other Co", issuePrefix: "OTHER" }]; + mockCompany.companies = companies; + mockCompany.loading = options.companiesLoading ?? false; + mockCompaniesApi.list.mockResolvedValue(companies); + const handles = render(); + if (options.strict) { + await act(async () => handles.root.render( + , + )); + } else await renderWizardView(handles.root, handles.queryClient); + for (let tick = 0; tick < 8; tick++) await flushReact(); + return { ...handles, draft }; + } + + it.each([ + ["/onboarding", "auto"], ["/SC/onboarding", "auto"], + ["/onboarding", "legacy"], ["/SC/onboarding", "legacy"], + ])("restores the authorized review draft after route reload %s with %s runner", async (pathname, runner) => { + const { root, draft } = await openSavedReview(pathname, runner); + try { + expect([...document.querySelectorAll("button")].some(button => button.textContent?.trim() === "Get started")).toBe(true); + expect(document.body.textContent).not.toContain("What is the name of your organization?"); + const persisted = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!); + expect(persisted).toMatchObject(draft); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + } finally { await act(async () => root.unmount()); } + }); + + it("keeps restored review progress when initial effects replay in StrictMode", async () => { + const { root, draft } = await openSavedReview("/SC/onboarding", "auto", { strict: true }); + try { + expect([...document.querySelectorAll("button")].some(button => button.textContent?.trim() === "Get started")).toBe(true); + expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject(draft); + } finally { await act(async () => root.unmount()); } + }); + + it("waits for route company loading before deciding the initial restored step", async () => { + const { root, queryClient, draft } = await openSavedReview("/SC/onboarding", "auto", { companiesLoading: true }); + try { + mockCompany.loading = false; + await renderWizardView(root, queryClient); + for (let tick = 0; tick < 8; tick++) await flushReact(); + expect([...document.querySelectorAll("button")].some(button => button.textContent?.trim() === "Get started")).toBe(true); + expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject(draft); + } finally { await act(async () => root.unmount()); } + }); + + it("does not restore another company's review into a different route", async () => { + const { root } = await openSavedReview("/OTHER/onboarding"); + try { + expect(document.querySelector("#onboarding-agent-name")).not.toBeNull(); + const persisted = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!); + expect(persisted.createdCompanyId).toBe("c2"); + expect(persisted.createdAgentId).toBeNull(); + expect(persisted.step).toBe(3); + } finally { await act(async () => root.unmount()); } + }); + + it("does not bind an unmatched company prefix to the restored company", async () => { + const { root } = await openSavedReview("/MISSING/onboarding"); + try { + const persisted = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!); + expect(persisted.step).toBe(1); + expect(persisted.createdCompanyId).toBeNull(); + expect(persisted.createdAgentId).toBeNull(); + expect(persisted.companyName).toBe(""); + } finally { await act(async () => root.unmount()); } + }); + + it.each([2, 3])("honors explicit prefilled-company dialog step %s on initial mount", async initialStep => { + const { root } = await openSavedReview("/SC/onboarding", "auto", { dialogStep: initialStep }); + try { + const persisted = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!); + expect(persisted.step).toBe(initialStep); + expect(persisted.createdCompanyId).toBe("c1"); + expect([...document.querySelectorAll("button")].some(button => button.textContent?.trim() === "Get started")).toBe(false); + } finally { await act(async () => root.unmount()); } + }); + + it("honors an explicit dialog step instead of resuming the route draft", async () => { + const { root, queryClient } = await openSavedReview("/SC/onboarding"); + try { + mockDialog.onboardingOpen = true; + mockDialog.onboardingOptions = { companyId: "c2", initialStep: 3 }; + await renderWizardView(root, queryClient); + await flushReact(); + expect(document.querySelector("#onboarding-agent-name")).not.toBeNull(); + const persisted = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!); + expect(persisted.createdCompanyId).toBe("c2"); + expect(persisted.createdAgentId).toBeNull(); + expect(persisted.step).toBe(3); + } finally { await act(async () => root.unmount()); } + }); + }); + it("re-syncs a restored draft once companies resolve asynchronously (companies start empty/loading)", async () => { // Regression for the initializer-only restore bug: the inner wizard's // ~20 useState(saved?.x ?? default) initializers only read `saved` on @@ -1864,7 +1983,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( }); /** Drives the wizard to the Connect step with a company already created. */ - async function openStep4(overrides: Record = {}) { + async function openStep4(overrides: Record = {}, staleTime = 0) { mockCompany.companies = [{ id: "company-new", name: "Initech", issuePrefix: "INI" }]; mockCompany.loading = false; mockCompaniesApi.list.mockResolvedValue(mockCompany.companies); @@ -1881,7 +2000,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( ); mockDialog.onboardingOptions = {}; - const { root, queryClient } = render(); + const { root, queryClient } = render(staleTime); await renderWizardView(root, queryClient); // The login-panel gate chains several dependent queries (environments, // instance settings, environment capabilities, then the auth signal @@ -2973,6 +3092,162 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( await act(async () => root.unmount()); }); + describe("managed Codex automatic setup qualification", () => { + const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`; + const discovery = (defaultRunner: "paperclip" | "legacy") => [{ type: "codex_local", defaultRunner, + supportedRunners: defaultRunner === "paperclip" ? ["paperclip", "legacy"] : ["legacy"] }] as import("../api/adapters").AdapterInfo[]; + + async function openManaged(overrides: Record = {}, config: Record = { provider: "daytona", image }, staleTime = 0) { + mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config }]); + mockAdaptersApi.list.mockResolvedValue(discovery("legacy")); + const handles = await openStep4({ adapterType: "codex_local", runner: "auto", ...overrides }, staleTime); + await act(async () => handles.queryClient.setQueryData(["ai-connections", "company-new"], { + currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "openai", method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }], + })); + return handles; + } + + it.each([["paperclip", 0], ["legacy", 0], ["paperclip", 30_000], ["legacy", 30_000]] as const)("qualifies the image on Connect and honors the measured %s default with %i ms cache", async (defaultRunner, staleTime) => { + const calls: string[] = []; + const handles = await openManaged({}, { provider: "daytona", image }, staleTime); + let qualified = false; + 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 ? defaultRunner : "legacy"); + }); + mockEnvironmentsApi.probe.mockImplementation(async () => { + calls.push("platform"); + qualified = true; + return { ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "platform-lease" } }; + }); + mockAgentsApi.testEnvironment.mockImplementation(async () => { + calls.push("adapter"); + return { adapterType: defaultRunner === "paperclip" ? "paperclip_runner" : "codex_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(/OpenAI/); + expect(calls).toEqual(["discovery-before", "platform", "discovery-after", "adapter", "hire"]); + expect(handles.queryClient.getQueryData(queryKeys.adapters.availability("company-new", "env-sandbox-1"))).toEqual(discovery(defaultRunner)); + expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-sandbox-1", "company-new"); + expect(mockAgentsApi.testEnvironment).toHaveBeenCalledWith("company-new", "codex_local", expect.objectContaining({ environmentId: "env-sandbox-1", aiConnection: { provider: "openai", method: "subscription", mode: "responsible_user" } })); + expect((mockAgentsApi.testEnvironment.mock.calls[0] as unknown[])[2]).not.toHaveProperty("runner"); + expect((mockAgentsApi.hire.mock.calls[0] as unknown[])[1]).not.toHaveProperty("runner"); + expect(managedApi.connectLocal).not.toHaveBeenCalled(); + } finally { await act(async () => handles.root.unmount()); } + }); + + it.each(["permission", "cleanup", "config-race", "missing-lease", "connection", "discovery"].flatMap(failure => [0, 30_000].map(staleTime => [failure, staleTime] as const)))("stops managed automatic setup after %s qualification failure with %i ms cache", async (failure, staleTime) => { + const handles = await openManaged({}, { provider: "daytona", image }, staleTime); + const diagnostic = failure === "permission" ? "Instance admin access required" : "Qualification could not be confirmed"; + if (failure === "permission") mockEnvironmentsApi.probe.mockRejectedValue(new ApiError(diagnostic, 403, null)); + else mockEnvironmentsApi.probe.mockImplementation(async () => { + if (failure === "discovery") mockAdaptersApi.list.mockRejectedValue(new Error(diagnostic)); + return { ok: failure !== "connection", driver: "sandbox", summary: diagnostic, + details: { platformQualificationRecorded: !["cleanup", "config-race"].includes(failure), + ...(failure === "missing-lease" ? {} : { leaseId: "platform-lease" }), platformQualificationDiagnostic: diagnostic } }; + }); + try { + await pickSource(/OpenAI/); + expect(document.body.textContent).toContain(diagnostic); + expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + } finally { await act(async () => handles.root.unmount()); } + }); + + it.each(["legacy", "native-cache", "snapshot", "tag", "local", "saved"])("keeps the %s path free of an extra platform probe", async mode => { + const handles = await openManaged(mode === "legacy" ? { runner: "legacy" } : mode === "saved" ? { createdAgentId: "existing" } : {}, + mode === "snapshot" ? { provider: "daytona", snapshot: "named" } : mode === "tag" ? { provider: "daytona", image: "ubuntu:24.04" } : { provider: "daytona", image }); + if (mode === "native-cache") { + mockAdaptersApi.list.mockResolvedValue(discovery("paperclip")); + await act(async () => handles.queryClient.setQueryData(queryKeys.adapters.availability("company-new", "env-sandbox-1"), discovery("paperclip"))); + } + if (mode === "local") { + await act(async () => handles.queryClient.setQueryData(queryKeys.environments.list("company-new"), [LOCAL_ENVIRONMENT])); + mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null }); + await act(async () => handles.queryClient.setQueryData(queryKeys.instance.settings, { defaultEnvironmentId: null })); + } + try { + await pickSource(/OpenAI/); + expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled(); + } finally { await act(async () => handles.root.unmount()); } + }); + + it.each(["missing", "invalid-default", "unsupported-default"])("does not probe or hire with %s authoritative discovery", async mode => { + const handles = await openManaged(); + mockAdaptersApi.list.mockResolvedValue(mode === "missing" ? [] : mode === "invalid-default" + ? [{ type: "codex_local", supportedRunners: ["legacy"] }] as import("../api/adapters").AdapterInfo[] + : [{ type: "codex_local", defaultRunner: "paperclip", supportedRunners: ["legacy"] }] as import("../api/adapters").AdapterInfo[]); + try { + await pickSource(/OpenAI/); + expect(document.body.textContent).toContain("Could not confirm Codex runner availability"); + expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled(); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + } finally { await act(async () => handles.root.unmount()); } + }); + + it.each([0, 30_000])("rechecks stale native discovery and stops a changed image from becoming an unnoticed Legacy hire with %i ms cache", async staleTime => { + const handles = await openManaged({}, { provider: "daytona", image }, staleTime); + await act(async () => handles.queryClient.setQueryData(queryKeys.adapters.availability("company-new", "env-sandbox-1"), discovery("paperclip"))); + mockEnvironmentsApi.probe.mockResolvedValue({ ok: true, driver: "sandbox", summary: "Connected", details: { + platformQualificationRecorded: false, leaseId: "platform-lease", platformQualificationDiagnostic: "The environment changed during the probe. Probe its current configuration again." } }); + try { + await pickSource(/OpenAI/); + expect(mockAdaptersApi.list).toHaveBeenCalledWith({ companyId: "company-new", environmentId: "env-sandbox-1" }); + expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1); + expect(document.body.textContent).toContain("The environment changed during the probe"); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + } finally { await act(async () => handles.root.unmount()); } + }); + + it.each(["authentication", "dependency", "model"])("keeps selected native %s setup failures actionable without retrying Legacy", async failure => { + const handles = await openManaged(); + mockEnvironmentsApi.probe.mockImplementation(async () => { + mockAdaptersApi.list.mockResolvedValue(discovery("paperclip")); + return { ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "platform-lease" } }; + }); + mockAgentsApi.testEnvironment.mockResolvedValue({ adapterType: "paperclip_runner", status: "fail", testedAt: "2026-10-13T00:00:00Z", + checks: [{ code: `native_${failure}`, level: "error", message: `Repair native ${failure}` }] }); + try { + await pickSource(/OpenAI/); + expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1); + expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); + expect((mockAgentsApi.testEnvironment.mock.calls[0] as unknown[])[2]).not.toHaveProperty("runner"); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain(`Repair native ${failure}`); + } finally { await act(async () => handles.root.unmount()); } + }); + + it.each(["back", "environment-config"])("does not test or hire after a held probe is abandoned by %s", async change => { + let finish!: (value: import("@paperclipai/shared").EnvironmentProbeResult) => void; + const handles = await openManaged(); + mockEnvironmentsApi.probe.mockReturnValue(new Promise(resolve => { finish = resolve; })); + try { + await pickSource(/OpenAI/); + expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + 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: "platform-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()); } + }); + }); + describe("automatic subscription verification", () => { const providers = [ ["claude_local", "anthropic", /Claude/], diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 8749e63cf4d..833012c8d12 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -1,3 +1,7 @@ +import type { AgentRunnerChoice } from "@paperclipai/shared"; +import { agentHarnessType } from "@paperclipai/shared"; +import { adaptersApi } from "../api/adapters"; +import { CodexRunnerSelect } from "./CodexRunnerSelect"; import { healthApi } from "@/api/health"; import { LocalProviderLoginInstructions } from "./AdapterLoginChrome"; import { useLocalAiLogin } from "./ai-connections/useLocalAiLogin"; @@ -147,16 +151,14 @@ type Step = 0 | 1 | 2 | 3 | 4 | 5; // wizard's registry-driven approach rather than a fixed union. type AdapterType = string; -// First-run onboarding stays on the proven direct adapters even when an -// instance administrator has opted into Paperclip Runner elsewhere. The -// experimental flag only exposes the runner in explicit agent configuration. const ONBOARDING_EXCLUDED_ADAPTER_TYPES = new Set([ "process", "http", "paperclip_runner", ]); -function restoreOnboardingAdapterType(savedAdapterType: unknown): AdapterType { +function restoreOnboardingAdapterType(savedAdapterType: unknown, provider?: unknown): AdapterType { + if (savedAdapterType === "paperclip_runner" && (provider === undefined || provider === "codex")) return "codex_local"; return typeof savedAdapterType === "string" && savedAdapterType !== "paperclip_runner" ? savedAdapterType : "claude_local"; @@ -594,8 +596,10 @@ function OnboardingWizardInner({ (saved?.agentRole as AgentRole) || DEFAULT_AGENT_ROLE, ); const [adapterType, setAdapterType] = useState(() => - restoreOnboardingAdapterType(saved?.adapterType), + 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"); /** * Whether a model source has been chosen, as opposed to which one * `adapterType` happens to hold. @@ -621,11 +625,8 @@ function OnboardingWizardInner({ const [sourcePicked, setSourcePicked] = useState(false); const savedNativeRunnerDraft = saved?.adapterType === "paperclip_runner"; const [cwd, setCwd] = useState((saved?.cwd as string) ?? ""); - // Native drafts may carry provider-specific configuration that is invalid - // for the legacy adapter selected above. Keep the portable working - // directory, but clear runner-specific execution fields while restoring. const [model, setModel] = useState( - savedNativeRunnerDraft ? "" : (saved?.model as string) ?? "", + savedNativeRunnerDraft && saved?.runnerProvider !== undefined && saved.runnerProvider !== "codex" ? "" : (saved?.model as string) ?? "", ); const [command, setCommand] = useState( savedNativeRunnerDraft ? "" : (saved?.command as string) ?? "", @@ -780,6 +781,7 @@ function OnboardingWizardInner({ // changes, and takes whatever the step is at that moment. const initialStepRef = useRef(undefined); initialStepRef.current = effectiveOnboardingOptions.initialStep; + const openingRequestRef = useRef<{ open: boolean; companyId: string | null; hasOpened: boolean } | null>(null); // Reset the route-dismissed flag when navigating to a different path. useEffect(() => { @@ -819,20 +821,29 @@ function OnboardingWizardInner({ // Navigating from one company's onboarding path to another re-decides the // step; the same request re-deriving a fresher value does not. useEffect(() => { + const routeCompanyId = effectiveOnboardingOptions.companyId ?? null; + const previousRequest = openingRequestRef.current; + openingRequestRef.current = { open: effectiveOnboardingOpen, companyId: routeCompanyId, + hasOpened: previousRequest?.hasOpened === true || effectiveOnboardingOpen }; if (!effectiveOnboardingOpen) return; - // If explicit options are provided, they take precedence over saved state - if (initialStepRef.current) { + if (previousRequest?.open && previousRequest.companyId === routeCompanyId) return; + const initialRequest = previousRequest?.hasOpened !== true; + const resumesRouteDraft = initialRequest && !onboardingOpen && saved !== null + && typeof saved.step === "number" && saved.step >= 1 && saved.step <= 5 + && (routeCompanyId ? routeCompanyId === saved.createdCompanyId : !companyPrefix); + if (initialStepRef.current && !resumesRouteDraft) { setStep(initialStepRef.current); setEntryStep(initialStepRef.current); } - const routeCompanyId = effectiveOnboardingOptions.companyId ?? null; if (routeCompanyId) { // Claim ownership only when the route *introduces* a company. A route // that merely names the one already in hand - the wizard created it, // then the user navigated to that company's onboarding path - has not // supplied anything, so it must not take ownership of it. Otherwise // navigating on to `/onboarding` would clear work the wizard did. - if (routeCompanyId !== createdCompanyIdRef.current) { + if (routeCompanyId !== createdCompanyIdRef.current + || (initialRequest && typeof saved?.createdCompanyId === "string" + && routeCompanyId !== saved.createdCompanyId)) { setCreatedCompanyId(routeCompanyId); clearCompanyScopedState(); } @@ -856,7 +867,7 @@ function OnboardingWizardInner({ routeCompanyIdRef.current = routeCompanyId; return; } - if (routeCompanyIdRef.current) { + if (routeCompanyIdRef.current || (initialRequest && companyPrefix && saved?.createdCompanyId)) { // The route named a company and now does not - the user navigated from // an existing company's onboarding to `/onboarding`, or to a prefix that // matches nothing. Drop it. Keeping it leaves the wizard showing step 1, @@ -904,7 +915,7 @@ function OnboardingWizardInner({ if (!effectiveOnboardingOpen) return; const state = { step, companyName, - agentName, agentAppearance, agentRole, adapterType, cwd, model, command, args, url, + agentName, agentAppearance, agentRole, adapterType, runner, cwd, model, command, args, url, // The mode, never the key: this blob is localStorage. credentialMode, credentialModeChoice, createdCompanyId, createdCompanyPrefix, createdAgentId, @@ -913,7 +924,7 @@ function OnboardingWizardInner({ onboardingDraftStorage.write(JSON.stringify(state)); }, [ effectiveOnboardingOpen, step, companyName, - agentName, agentAppearance, agentRole, adapterType, cwd, model, command, args, url, + agentName, agentAppearance, agentRole, adapterType, runner, cwd, model, command, args, url, credentialMode, credentialModeChoice, createdCompanyId, createdCompanyPrefix, createdAgentId, createdCompanyGoalId, createdProjectId, createdIssueRef, @@ -981,12 +992,22 @@ function OnboardingWizardInner({ loginEnvironmentList, experimentalSettingsForLogin?.enableManagedSandboxOnly, ]); + const runnerDiscovery = useQuery({ + queryKey: queryKeys.adapters.availability(createdCompanyId, resolvedLoginEnvironmentId), + queryFn: () => adaptersApi.list({ companyId: createdCompanyId!, environmentId: resolvedLoginEnvironmentId }), + enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4, + }); const resolvedLoginEnvironment = useMemo( () => loginEnvironmentList.find((environment) => environment.id === resolvedLoginEnvironmentId) ?? null, [loginEnvironmentList, resolvedLoginEnvironmentId], ); + const adapterSetupSelectionRef = useRef(""); + const adapterEnvErrorSelectionRef = useRef(null); + adapterSetupSelectionRef.current = adapterType === "codex_local" && runner === "auto" && resolvedLoginEnvironment?.driver === "sandbox" + ? JSON.stringify([createdCompanyId, adapterType, runner, model, command, args, url, credentialMode, apiKey, + selectedSavedKey, selectedApiKey?.binding, savedSubscription?.binding, resolvedLoginEnvironmentId, resolvedLoginEnvironment.config]) : ""; // Sandbox provider capabilities for the login pseudo-terminal gate, loaded // only when the adapter declares a login capability — the same query the // agent configuration form runs (AgentConfigForm.tsx:652-658). @@ -1540,8 +1561,8 @@ function OnboardingWizardInner({ if (step !== 4) return; setAdapterEnvResult(null); adapterEnvResultAppliedStoredLoginRef.current = false; - setAdapterEnvError(null); - }, [step, adapterType, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id]); + 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]); /** * Leaving the step puts the row back to a question. @@ -1628,6 +1649,7 @@ function OnboardingWizardInner({ setAgentAppearance(randomAgentAppearance()); setAgentRole(DEFAULT_AGENT_ROLE); setAdapterType("claude_local"); + setRunner("auto"); setModel(""); setCommand(""); setArgs(""); @@ -1821,6 +1843,7 @@ function OnboardingWizardInner({ const config = adapter.buildAdapterConfig({ ...defaultCreateValues, adapterType, + runner, model: adapterType === "gemini_local" ? model || DEFAULT_GEMINI_LOCAL_MODEL @@ -1838,7 +1861,7 @@ function OnboardingWizardInner({ adapterType === "claude_local" || adapterType === "opencode_local", dangerouslyBypassSandbox: adapterType === "codex_local" - ? DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX + ? runner === "legacy" && DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX : defaultCreateValues.dangerouslyBypassSandbox }); if (adapterType === "claude_local" && forceUnsetAnthropicApiKey) { @@ -1893,6 +1916,9 @@ function OnboardingWizardInner({ ); return null; } + const selection = adapterSetupSelectionRef.current; + const testIsCurrent = () => isCurrent() && adapterSetupSelectionRef.current === selection; + adapterEnvErrorSelectionRef.current = null; setAdapterEnvLoading(true); setAdapterEnvError(null); try { @@ -1923,7 +1949,7 @@ function OnboardingWizardInner({ settings = generalSettings; managedSandboxOnly = experimentalSettings?.enableManagedSandboxOnly === true; } catch { - if (!isCurrent()) return null; + if (!testIsCurrent()) return null; setAdapterEnvError( "Could not load environment settings to determine which environment to test in. Retry the test.", ); @@ -1946,22 +1972,61 @@ function OnboardingWizardInner({ // default to the managed sandbox instead of sending the hidden local id. visibleEnvironmentIds: environmentList.map((environment) => environment.id), }); - if (!isCurrent()) return null; + if (!testIsCurrent()) return null; + const selectedEnvironment = environmentList.find(environment => environment.id === environmentId); + const selectedConfig = selectedEnvironment?.config; + if (!createdAgentId && adapterType === "codex_local" && runner === "auto" + && selectedEnvironment?.driver === "sandbox" && typeof selectedConfig?.image === "string" + && /^[^\s@]+@sha256:[a-f0-9]{64}$/.test(selectedConfig.image) + && !selectedConfig.snapshot && !selectedConfig.template && !selectedConfig.templateRef) { + const availabilityKey = queryKeys.adapters.availability(createdCompanyId, environmentId); + const adapters = await queryClient.fetchQuery({ queryKey: availabilityKey, staleTime: 0, + queryFn: () => adaptersApi.list({ companyId: createdCompanyId, environmentId }) }); + if (!testIsCurrent()) return null; + const codex = adapters.find(adapter => adapter.type === "codex_local"); + const native = adapters.find(adapter => adapter.type === "paperclip_runner"); + if (!codex || (codex.defaultRunner !== "paperclip" && codex.defaultRunner !== "legacy") + || !codex.supportedRunners?.includes(codex.defaultRunner)) { + throw new Error("Could not confirm Codex runner availability for this environment. Retry setup."); + } + if (codex.defaultRunner !== "paperclip" && !codex?.disabled && !codex?.overriddenBuiltin + && !native?.disabled && !native?.overriddenBuiltin) { + const probe = await environmentsApi.probe(environmentId!, createdCompanyId); + if (!testIsCurrent()) return null; + 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."); + } + const refreshed = await queryClient.fetchQuery({ queryKey: availabilityKey, staleTime: 0, + queryFn: () => adaptersApi.list({ companyId: createdCompanyId, environmentId }) }); + if (!testIsCurrent()) return null; + const refreshedCodex = refreshed.find(adapter => adapter.type === "codex_local"); + const defaultRunner = refreshedCodex?.defaultRunner; + if ((defaultRunner !== "paperclip" && defaultRunner !== "legacy") + || !refreshedCodex?.supportedRunners?.includes(defaultRunner)) { + throw new Error("Could not confirm the environment's default runner. Retry setup."); + } + } + } const result = await agentsApi.testEnvironment( createdCompanyId, adapterType, { adapterConfig: adapterConfigOverride ?? buildAdapterConfig(), + ...(adapterType === "codex_local" && runner !== "auto" ? { runner } : {}), ...(managedBindingForStep() ? { aiConnection: managedBindingForStep() } : {}), environmentId, } ); - if (!isCurrent()) return null; + if (!testIsCurrent()) return null; setAdapterEnvResult(result); adapterEnvResultAppliedStoredLoginRef.current = appliedStoredClaudeLoginBinding; return result; } catch (err) { - if (!isCurrent()) return null; + if (!testIsCurrent()) return null; + adapterEnvErrorSelectionRef.current = selection || null; setAdapterEnvError( err instanceof Error ? err.message : "Adapter environment test failed" ); @@ -2018,13 +2083,10 @@ function OnboardingWizardInner({ // doesn't hire a second agent. async function handleGiveHeartbeat() { if (!createdCompanyId) return; - // The grid and restore path both exclude native runner. Keep this final - // guard at the mutation boundary so a stale or modified client cannot use - // first-run onboarding to create a native agent. if (adapterType === "paperclip_runner") { setAdapterType("claude_local"); setModel(""); - setError("Paperclip Runner is not available during onboarding. Choose a legacy adapter."); + setError("Choose a harness to continue onboarding."); return; } if (createdAgentId) { @@ -2150,10 +2212,12 @@ function OnboardingWizardInner({ // pass or a warn result that reports missing authentication — the // agent cannot run without one of those. if (blocksAgentCreate(result)) { + const nativeCodexError = adapterType === "codex_local" && result.adapterType === "paperclip_runner" + ? result.checks.find(check => check.level === "error")?.message : undefined; setError( - result.status === "fail" + nativeCodexError || (result.status === "fail" ? "The environment test failed. Fix the reported checks before you hire this agent." - : "No working authentication was found. Fix the reported checks before you hire this agent.", + : "No working authentication was found. Fix the reported checks before you hire this agent."), ); return; } @@ -2178,7 +2242,7 @@ function OnboardingWizardInner({ const existing = existingAgents?.find( (agent) => agent.name.trim().toLowerCase() === hireName.toLowerCase() && - agent.adapterType === adapterType, + agentHarnessType(agent.adapterType, agent.adapterConfig) === adapterType, ); if (existing) { if (!isCurrent()) return; @@ -2202,6 +2266,7 @@ function OnboardingWizardInner({ role: agentRole, adapterType, adapterConfig: hireAdapterConfig, + ...(adapterType === "codex_local" && 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. @@ -2692,6 +2757,7 @@ function OnboardingWizardInner({ autoConnectStartedRef.current = false; setSourcePicked(true); setAdapterType(id); + if (id !== adapterType) setRunner("auto"); if (id === "opencode_local") setModel(DEFAULT_OPENCODE_LOCAL_MODEL); else if (id !== "codex_local") setModel(""); setConnectPhase("collapsing"); @@ -2896,6 +2962,15 @@ function OnboardingWizardInner({ ) : null} + {sourceSelected && adapterType === "codex_local" &&
+ Advanced + { void runnerDiscovery.refetch(); }} + defaultRunner={runnerDiscovery.data?.find(item => item.type === "codex_local")?.defaultRunner} + supportedRunners={runnerDiscovery.data?.find(item => item.type === "codex_local")?.supportedRunners} + onChange={setRunner} /> +
} + {/* Conditional adapter fields */} {/* No model picker. Every adapter this step offers resolves its own default (see buildAdapterConfig), so the picker diff --git a/ui/src/components/agent-config-defaults.ts b/ui/src/components/agent-config-defaults.ts index 118494fd90b..cf844320805 100644 --- a/ui/src/components/agent-config-defaults.ts +++ b/ui/src/components/agent-config-defaults.ts @@ -12,6 +12,7 @@ export const defaultCreateValues: CreateConfigValues = { search: false, fastMode: false, dangerouslyBypassSandbox: false, + codexLegacySandboxBypass: undefined, command: "", args: "", extraArgs: "", diff --git a/ui/src/components/new-agent/AgentBasicsDialog.tsx b/ui/src/components/new-agent/AgentBasicsDialog.tsx index d6ddb1fc6f9..b84b2390958 100644 --- a/ui/src/components/new-agent/AgentBasicsDialog.tsx +++ b/ui/src/components/new-agent/AgentBasicsDialog.tsx @@ -14,6 +14,7 @@ import { getAdapterDisplay } from "@/adapters/adapter-display-registry"; import { cn } from "@/lib/utils"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { SelectPopover } from "../ui/select"; import { Dialog, DialogContent, @@ -65,13 +66,14 @@ export function AgentBasicsDialog({ error, } = useQuery({ queryKey: queryKeys.adapters.all, - queryFn: adaptersApi.list, + queryFn: () => adaptersApi.list(), enabled: open, }); const choices = (adapters ?? []).filter( (adapter) => adapter.loaded && !adapter.disabled && + adapter.type !== "paperclip_runner" && isNewAgentAdapterAllowed(adapter.type, { cloud, nativeRunnerEnabled: experimental.data?.enableNativeRunner === true, @@ -79,7 +81,10 @@ export function AgentBasicsDialog({ !["process", "http"].includes(adapter.type) && !getAdapterDisplay(adapter.type).comingSoon, ); - const validAdapter = choices.some((adapter) => adapter.type === adapterType); + const experimentalRunnerAvailable = experimental.data?.enableNativeRunner === true + && adapters?.some(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled); + const validAdapter = choices.some((adapter) => adapter.type === adapterType) + || (experimentalRunnerAvailable && adapterType === "paperclip_runner"); return (
@@ -211,23 +216,22 @@ export function AgentBasicsDialog({ ); })}
- {validAdapter && adapterType === "paperclip_runner" && ( - - )} + {experimentalRunnerAvailable &&
+ Advanced +
+ + { 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 c9902d8cad1..0fb59e9aaaa 100644 --- a/ui/src/components/new-agent/NewAgentSetup.tsx +++ b/ui/src/components/new-agent/NewAgentSetup.tsx @@ -3,6 +3,9 @@ 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 { SelectPopover } from "../ui/select"; import { DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local"; import { SETUP_CREDENTIAL_KEYS, @@ -84,10 +87,12 @@ export function NewAgentSetup() { ); return ( @@ -99,18 +104,24 @@ function Setup({ name, adapterType, runnerProvider, + initialRunner, + preserveNativeCodex, createdAgentId, }: { companyId: string; name: string; adapterType: string; runnerProvider: string; + initialRunner: string; + preserveNativeCodex: boolean; createdAgentId: string | null; }) { const navigate = useNavigate(); const cache = useQueryClient(); 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 isRunner = adapterType === "paperclip_runner"; const isDot = isRunner && runnerProvider === "openai_dot"; const brandType = isDot ? "openai_dot" : isRunner @@ -120,7 +131,7 @@ function Setup({ ? "claude_local" : runnerProvider === "opencode" ? "opencode_local" - : "codex_local" + : runnerProvider === "codex" ? "codex_local" : runnerProvider : adapterType; const connectionAdapter = brandType === "claude_local" || brandType === "codex_local" || brandType === "grok_local" @@ -197,9 +208,9 @@ function Setup({ resetTest(); setScreen("connect"); }; - const adapters = useQuery({ + const adapterInventory = useQuery({ queryKey: queryKeys.adapters.all, - queryFn: adaptersApi.list, + queryFn: () => adaptersApi.list(), }); const agents = useQuery({ queryKey: queryKeys.agents.list(companyId), @@ -272,6 +283,14 @@ function Setup({ : "Could not resolve the environment."; } const environment = envs.data?.find((env) => env.id === environmentId); + 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; + useEffect(() => { + resetTest(); + }, [companyId, environmentId, runner === "auto" ? defaultRunner : undefined]); const sandboxProvider = typeof environment?.config?.provider === "string" ? environment.config.provider @@ -314,15 +333,16 @@ function Setup({ openAiDotEnabled: experimental.data?.enableOpenAiDot === true, runnerProvider, }) && - adapters.data?.some( + adapterInventory.data?.some( (adapter) => adapter.type === adapterType && adapter.loaded && !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( available && + (adapterType !== "codex_local" || runner !== "auto" || Boolean(adapters.data)) && !environmentError && !envs.isPending && !settings.isPending && @@ -347,10 +367,11 @@ function Setup({ const values = { ...defaultCreateValues, adapterType, + runner, model: model || (brandType === "codex_local" ? DEFAULT_CODEX_LOCAL_MODEL : ""), thinkingEffort: effort, - dangerouslyBypassSandbox: adapterType === "codex_local", + dangerouslyBypassSandbox: adapterType === "codex_local" && runner === "legacy", envBindings: nextConnection?.env ?? {}, ...(isRunner ? { @@ -363,6 +384,9 @@ function Setup({ : {}), }; const config = getUIAdapter(adapterType).buildAdapterConfig(values); + if (preserveNativeCodex && runner === "paperclip") { + Object.assign(config, { provider: "codex", codexPermissionMode: "never", lifecycleMode: "per_turn" }); + } if (isRunner) Object.assign(config, { provider: (runnerProvider === "claude" || runnerProvider === "grok") ? "acpx" : runnerProvider, @@ -447,8 +471,9 @@ function Setup({ const config = await preparedConfig(nextConnection); const tested = await testAgentSetup({ companyId, - adapterType, + adapterType: requestAdapterType, providerAdapter: brandType, + ...(adapterType === "codex_local" && runner !== "auto" ? { runner } : {}), adapterConfig: config, testCredentials: pendingCredentials(nextConnection), aiConnection: runtimeAiBinding ?? nextConnection?.aiConnection, @@ -492,6 +517,7 @@ 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. @@ -519,7 +545,8 @@ function Setup({ appearance: appearanceDraft.appearance, role: existing.length ? "general" : "ceo", ...(leader ? { reportsTo: leader.id } : {}), - adapterType, + adapterType: requestAdapterType, + ...(adapterType === "codex_local" && runner !== "auto" ? { runner } : {}), adapterConfig: config, defaultEnvironmentId: environmentOverride || @@ -540,7 +567,7 @@ function Setup({ appearanceDraft.clear(); setScreen("saved"); navigate( - `/agents/new?${new URLSearchParams({ name: response.agent.name, adapterType, runnerProvider, createdAgentId: response.agent.id })}`, + `/agents/new?${new URLSearchParams({ name: response.agent.name, adapterType, runnerProvider, ...(runner !== "auto" ? { runner } : {}), createdAgentId: response.agent.id })}`, { replace: true }, ); cache.setQueryData( @@ -635,6 +662,7 @@ function Setup({ : "Local machine"; const setupError = adapters.error ?? + adapterInventory.error ?? envs.error ?? settings.error ?? experimental.error ?? @@ -674,7 +702,7 @@ function Setup({ {setupError.message}

)} - {adapters.data && !available && ( + {adapterInventory.data && !available && (

This adapter is unavailable. Choose an enabled adapter.

@@ -783,10 +811,17 @@ function Setup({ onConnected={(next) => { setConnection(next); setRuntimeAiBinding(undefined); - resetTest(); setScreen("runtime"); }} /> :

Loading connection settings…

} + {adapterType === "codex_local" &&
+ Advanced + { void adapters.refetch(); }} + defaultRunner={adapters.data?.find(item => item.type === "codex_local")?.defaultRunner} + supportedRunners={adapters.data?.find(item => item.type === "codex_local")?.supportedRunners} + onChange={value => { setRunner(value); resetTest(); }} /> +
} ) : screen === "saved" && created ? (
@@ -866,7 +901,7 @@ function Setup({
{showModel && !usingKimiApi && ( 0 && ( - { setEffort(value); resetTest(); }} + options={[{ value: "", label: "Auto" }, ...efforts.map(value => ({ value, label: value }))]} /> : { @@ -918,7 +955,7 @@ function Setup({ {value} ))} - + } )}
@@ -1128,6 +1165,14 @@ function Setup({
)} + {adapterType === "codex_local" &&
+ Advanced + { void adapters.refetch(); }} + defaultRunner={adapters.data?.find(item => item.type === "codex_local")?.defaultRunner} + supportedRunners={adapters.data?.find(item => item.type === "codex_local")?.supportedRunners} + onChange={value => { setRunner(value); resetTest(); }} /> +
} {!["cursor_cloud", "hermes_gateway"].includes( adapterType, diff --git a/ui/src/components/ui/select.tsx b/ui/src/components/ui/select.tsx index 9c35a9b2409..4e5b48fcd6b 100644 --- a/ui/src/components/ui/select.tsx +++ b/ui/src/components/ui/select.tsx @@ -3,6 +3,7 @@ import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" import { Select as SelectPrimitive } from "radix-ui" import { cn } from "@/lib/utils" +import { Popover, PopoverContent, PopoverTrigger } from "./popover" export const nativeSelectClassName = "w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-sm leading-5 outline-none focus-visible:ring-2 focus-visible:ring-ring" @@ -13,6 +14,109 @@ export function NativeSelect({ className, ...props }: React.ComponentProps<"sele // Shared with searchable picker triggers so their size and states match Select. export const selectTriggerClassName = "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-base md:text-sm whitespace-nowrap shadow-xs transition-(--tp-color-box-shadow) outline-none focus-visible:ring-(length:--rad-3) disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" +export interface SelectPopoverOption { + value: string; + label: React.ReactNode; + disabled?: boolean; + suffix?: React.ReactNode; + group?: string; +} + +export function SelectPopover({ + value, + onValueChange, + options = [], + placeholder = "Select…", + displayValue, + "aria-label": ariaLabel, + "aria-busy": ariaBusy, + disabled, + className, + open: controlledOpen, + onOpenChange, + onCloseAutoFocus, + children, +}: { + value: string; + onValueChange?: (value: string) => void; + options?: readonly SelectPopoverOption[]; + placeholder?: string; + displayValue?: React.ReactNode; + "aria-label": string; + "aria-busy"?: boolean; + disabled?: boolean; + className?: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; + onCloseAutoFocus?: React.ComponentProps["onCloseAutoFocus"]; + children?: React.ReactNode; +}) { + const [localOpen, setLocalOpen] = React.useState(false); + const open = controlledOpen ?? localOpen; + const setOpen = (next: boolean) => { + setLocalOpen(next); + onOpenChange?.(next); + }; + const contentRef = React.useRef(null); + const selected = options.find(option => option.value === value); + + return ( + + + + + { + if (contentRef.current?.querySelector("input:not(:disabled)")) return; + event.preventDefault(); + (contentRef.current?.querySelector('[aria-selected="true"]:not(:disabled)') + ?? contentRef.current?.querySelector('[role="option"]:not(:disabled)'))?.focus(); + }} + onKeyDown={event => { + if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key) + || (event.target instanceof HTMLInputElement && ["Home", "End"].includes(event.key))) return; + const items = [...event.currentTarget.querySelectorAll('[role="option"]:not(:disabled)')]; + if (!items.length) return; + event.preventDefault(); + const current = items.indexOf(document.activeElement as HTMLButtonElement); + const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 + : current < 0 ? (event.key === "ArrowDown" ? 0 : items.length - 1) + : (current + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length; + items[next]?.focus(); + }}> + {children ??
+ {options.map((option, index) => + {option.group && option.group !== options[index - 1]?.group &&
{option.group}
} + { if (option.value !== value) onValueChange?.(option.value); setOpen(false); }}> + {option.label} + {option.suffix} +
)} +
} +
+
+ ); +} + +export function SelectPopoverItem({ selected, className, ...props }: React.ComponentProps<"button"> & { selected?: boolean }) { + return