diff --git a/cli/src/__tests__/agent-lifecycle.test.ts b/cli/src/__tests__/agent-lifecycle.test.ts index 41817823db..73f602ab0e 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 3f81da6c6b..50e1a4388c 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 d28e7960ea..0bc3235ffd 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -1,6 +1,6 @@ # Runner defaults: staged delivery -Updated 2026-10-08. +Updated 2026-10-09. ## Outcome and ownership @@ -19,78 +19,102 @@ 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`. +Frozen master base: `4fb1ab53a` (includes both prerequisite merges). +Canonical PR and tested revision: pending preparation. +Preview: [isolated Codex QA](http://127.0.0.1:3124), normal dev supervisor, +separate empty database. Current-head live evidence is pending the source freeze +and verified binary provenance; older services and artifacts are not proof. -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 | 88 selection/setup, 39 route/artifact and 6 transport controls pass; server noemit passes; rebuilt daemon and Linux DB proof pending | +| Production create/edit/onboarding UI and stories | UI worker | Implemented; 339 affected tests, UI typecheck and token gates pass; story evidence in progress | +| Built-ins, plugins, approvals, imports/exports, CLI | Creation-path worker | Implemented; 102 portability, 8 approval, 3 CLI and 3 built-in unit checks pass; 72 DB checks require Linux | +| Execution/dev gate, E2E gaps, packaging, live journeys | Lead | 28 runtime-selection and 122 onboarding/catalog support checks pass; actual journeys pending | +| Full CI and final review | Lead | Run on frozen final candidate | +| Local API/subscription task + follow-up | Lead | Credential/runtime identity inventory; unverified on this branch | +| Managed staging API/subscription onboarding | Lead | Exact-source preview/artifact and budget qualification pending | +| Shipped npm/Docker/cloud artifacts | Lead | Reuse existing hosted checks; prerequisite packaging proof is historical | -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. -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 -slice after the user chooses to proceed. Merging and deployment remain outside -this task. +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. No new paid call has run in this +slice. 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: finish core integration checks, commit and push the candidate, +then run existing Linux CI/install checks and bounded actual journeys. Preserve +failed attempts and costs. Unrelated findings and remaining harness qualification +belong to later slices. diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index d648b4fd3c..2633d62fc9 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -370,6 +370,8 @@ export interface AdapterEnvironmentTestContext { companyId: string; adapterType: string; config: Record; + /** Server-owned account materialization; native probes copy refreshed auth back before cleanup. */ + managedAiCredentialHome?: string; /** * Optional execution target the adapter should run probes against. * @@ -722,6 +724,7 @@ export interface CLIAdapterModule { // --------------------------------------------------------------------------- export interface CreateConfigValues { + runner?: "auto" | "paperclip" | "legacy"; adapterType: string; codexPermissionMode?: "never" | "on-request" | "untrusted"; paperclipRunnerLifecycleMode?: "per_turn" | "warm"; diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index 0ffe729b92..c5ab8289c7 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -188,6 +188,8 @@ export const models = [ export const agentConfigurationDoc = `# codex_local agent configuration +New agents select this Codex harness with adapterType: codex_local. On supported targets, automatic selection stores adapterType: paperclip_runner with provider: codex. Other harness defaults are unchanged. Set the request-level runner: legacy to use this adapter's CLI execution and custom settings. Existing agents and reviewed pending hires retain their saved runner; runtime errors never silently switch runners. + Adapter: codex_local Core fields: diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs index b1ed069b09..8568cb65f2 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs @@ -1034,6 +1034,9 @@ fn run() -> Result<(), Box> { } "initialized" => {} "thread/start" => { + if let Some(delay) = argument(&args, "--thread-start-delay-ms") { + thread::sleep(Duration::from_millis(delay.parse()?)); + } if require_external_sandbox && (message.pointer("/params/sandbox") != Some(&json!("danger-full-access")) || message.pointer("/params/permissions").is_some()) @@ -1076,7 +1079,8 @@ fn run() -> Result<(), Box> { } send(json!({ "id": id, - "result": {"thread": {"id": state.thread_id, "sessionId": "codex-account-session"}} + "result": {"thread": {"id": state.thread_id, "sessionId": "codex-account-session"}, + "model": argument(&args, "--returned-model").map(Value::String).unwrap_or_else(|| message.pointer("/params/model").filter(|model| model.is_string()).cloned().unwrap_or(json!("gpt-test")))} }))?; if agent_created_goal { send(json!({ diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs index 29494a4e21..f0c2384e01 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs @@ -367,6 +367,8 @@ pub struct CodexProviderConfig { pub approval_policy: String, #[serde(default)] pub externally_sandboxed: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub read_only: bool, // Older persisted configurations deliberately retain the provider default. #[serde(default, skip_serializing_if = "Option::is_none")] pub include_skill_instructions: Option, @@ -662,6 +664,7 @@ pub struct CodexProvider { next_request_id: u64, thread_id: String, provider_session_id: Option, + observed_model: Option, active_provider_turn_id: Option, pending_messages: VecDeque, deferred_ambiguous_messages: VecDeque, @@ -824,6 +827,10 @@ fn codex_permission_profile(provider: &str, external_sandbox: bool) -> &'static } } +fn is_false(value: &bool) -> bool { + !*value +} + impl CodexProvider { pub fn start( config: &CodexProviderConfig, @@ -897,11 +904,15 @@ impl CodexProvider { )); } let authorized_tools = authorized_tools.into_iter().collect::>(); - let permission_profile = codex_permission_profile( - &config.provider, - config.externally_sandboxed - || std::env::var("PAPERCLIP_RUNNER_EXTERNAL_SANDBOX").as_deref() == Ok("1"), - ); + let permission_profile = if config.read_only { + "paperclip-runner-workspace-read-only" + } else { + codex_permission_profile( + &config.provider, + config.externally_sandboxed + || std::env::var("PAPERCLIP_RUNNER_EXTERNAL_SANDBOX").as_deref() == Ok("1"), + ) + }; let (dynamic_tools, authorized_tool_ids) = codex_dynamic_tools(authorized_tools.iter().cloned())?; let common_environment_keys = [ @@ -996,6 +1007,7 @@ impl CodexProvider { next_request_id: 1, thread_id: String::new(), provider_session_id: None, + observed_model: None, active_provider_turn_id: None, pending_messages: VecDeque::new(), deferred_ambiguous_messages: VecDeque::new(), @@ -1126,6 +1138,12 @@ impl CodexProvider { .and_then(Value::as_str) .filter(|value| !value.is_empty()) .map(str::to_owned); + provider.observed_model = opened + .get("model") + .or_else(|| opened.pointer("/thread/model")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty() && value.len() <= 240) + .map(str::to_owned); if resume_thread_id.is_some() { stage = ProviderStartupStage::ThreadRead; @@ -1168,6 +1186,10 @@ impl CodexProvider { self.provider_session_id.as_deref() } + pub fn observed_model(&self) -> Option<&str> { + self.observed_model.as_deref() + } + pub(crate) fn take_provider_trace_frame_id(&mut self) -> Option { self.last_trace_frame_id.take() } @@ -1678,6 +1700,16 @@ impl CodexProvider { message: &str, cwd: &str, skills: &[CodexSkillInput], + ) -> Result { + self.start_turn_with_options(message, cwd, skills, None) + } + + pub(crate) fn start_turn_with_options( + &mut self, + message: &str, + cwd: &str, + skills: &[CodexSkillInput], + options: Option<&Value>, ) -> Result { if skills.len() > 64 || (self.config.provider != "codex" && !skills.is_empty()) { return Err(LocalRunnerError::invalid( @@ -1687,6 +1719,19 @@ impl CodexProvider { for skill in skills { skill.validate()?; } + let effort = options + .and_then(|value| value.get("effort")) + .filter(|value| !value.is_null()); + if effort.is_some_and(|value| { + !matches!( + value.as_str(), + Some("minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra") + ) + }) { + return Err(LocalRunnerError::invalid( + "unsupported native Codex reasoning effort", + )); + } if self.quarantined { return Err(LocalRunnerError::invalid( "Codex provider is quarantined after unsafe recovered work", @@ -1727,6 +1772,9 @@ impl CodexProvider { let turn_params_object = turn_params .as_object_mut() .expect("Codex turn parameters are an object"); + if let Some(effort) = effort { + turn_params_object.insert("effort".to_owned(), effort.clone()); + } if self.permission_profile == "paperclip-runner-external-sandbox" { turn_params_object.insert( "sandboxPolicy".to_owned(), @@ -4073,6 +4121,7 @@ done instructions: "Test only.".to_owned(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }; @@ -4460,6 +4509,7 @@ done instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }; @@ -4580,6 +4630,7 @@ done instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs index 23268296bd..3d56f4650d 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs @@ -2608,7 +2608,14 @@ impl CodexCommandExecutor { .as_ref() .and_then(|state| state.thread_id.as_ref()) .is_some(); - let (thread_id, provider_session_id, process_id, active_provider_turn_id, goal_probe) = { + let ( + thread_id, + provider_session_id, + process_id, + active_provider_turn_id, + goal_probe, + observed_model, + ) = { let provider = self.ensure_provider()?; ( provider.thread_id().to_owned(), @@ -2616,6 +2623,7 @@ impl CodexCommandExecutor { provider.process_id(), provider.active_provider_turn_id().map(str::to_owned), provider.get_goal(), + provider.observed_model().map(str::to_owned), ) }; let (goal_capability, goal) = match goal_probe { @@ -2703,6 +2711,7 @@ impl CodexCommandExecutor { "providerSessionId": thread_id, "providerAccountSessionId": provider_session_id, "processId": process_id, + "providerDescriptor": { "driver": driver, "providerVersion": provider_version, "effectiveModel": observed_model }, }), ), ( @@ -2986,7 +2995,7 @@ impl CodexCommandExecutor { rejected_accepted_turn, ) = { let provider = self.ensure_provider()?; - let result = provider.start_turn_with_skills(text, &cwd, &skills); + let result = provider.start_turn_with_options(text, &cwd, &skills, Some(payload)); ( result, provider.completed_turn_authority().is_some(), @@ -4974,6 +4983,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5355,6 +5365,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5446,6 +5457,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5499,6 +5511,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5542,6 +5555,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5634,6 +5648,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5701,6 +5716,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5751,6 +5767,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5875,6 +5892,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5987,6 +6005,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -6044,6 +6063,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -6165,6 +6185,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -6206,6 +6227,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -6244,6 +6266,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -6321,6 +6344,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs index b4bc2a316b..e586105d51 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs @@ -65,6 +65,7 @@ fn provider_config(directory: &Path, switches: &[&str]) -> CodexProviderConfig { instructions: "Stay inside the test workspace.".to_owned(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, } diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index fe7e4c7097..fb12731857 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -59,6 +59,7 @@ export * from "./drivers/acpx/sidecar-protocol.js"; export * from "./drivers/runner-tool-bridge.js"; export { createRunnerdCodexTransport, + probeNativeRunnerEnvironment, runnerCodexDynamicToolsFit, defaultCapabilityRunnerdBinary, readRunnerdArtifactBinding, diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index 857109376d..9de8ee81c6 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -72,6 +72,7 @@ import { releaseMaterializedNativeRuntimeSkills } from "../drivers/runtime-conte import { RUNNERD_CANONICAL_ITEM } from "../drivers/codex/codex-driver-values.js"; import { + probeNativeRunnerEnvironment, parseAcpxTurnControlCapabilities, authorizedToolSetForProvider, createCapabilityRunnerdCodexTransport, @@ -2694,6 +2695,105 @@ function fakeCodexArgs(stateDirectory: string, ...args: string[]): string[] { ]; } +it.each(["complete", "api-key", "default-model", "mismatched-model", "model-refresh-error", "model-cleanup-error", "missing-model", "missing-runtime", "missing-completion", "late-bootstrap", "remote-complete", "remote-mismatched-model", "late-remote-preparation", "late-remote-release-error"] as const)("native Codex setup requires its daemon/app-server hello (%s)", async mode => { + const root = await mkdtemp(join(tmpdir(), "runnerd-native-setup-")); + const runtimeDirectory = join(root, "native-state"); + await mkdir(runtimeDirectory, { mode: 0o700 }); + const calls = join(root, "calls.jsonl"); + const sourceHome = join(root, "bound-account"); + await mkdir(sourceHome, { mode: 0o700 }); + await writeFile(join(sourceHome, "auth.json"), JSON.stringify({ OPENAI_API_KEY: mode === "api-key" ? "unrelated-login" : "selected-account" }), { mode: 0o600 }); + if (mode === "api-key") await writeFile(join(sourceHome, "config.toml"), 'model = "ambient-model"\n[mcp_servers.ambient]\ncommand = "untrusted-ambient-command"\n', { mode: 0o600 }); + const remote = mode.startsWith("remote-") || mode.startsWith("late-remote-"); + const remoteRoot = join(root, "assigned-remote-filesystem"); + const remoteFilesystem = join(remoteRoot, "filesystem"); + const remoteWorkspace = join(remoteFilesystem, "workspace"); + let registrationReleased = false; + let refreshed = false; + let cleanupConfirmed = false; + try { + const probe = probeNativeRunnerEnvironment({ + runtimeDirectory, provider: "codex", model: mode === "default-model" ? null : "gpt-6.1-sol", reasoningEffort: "low", + environment: { PATH: process.env.PATH, OPENAI_API_KEY: mode === "api-key" ? "selected-account" : "", HOME: "/unrelated/home" }, + timeoutMs: mode.startsWith("late-remote-") ? 800 : mode === "late-bootstrap" ? 100 : mode === "missing-completion" ? 1_000 : 10_000, + ...(remote ? { workingDirectory: remoteWorkspace } : {}), + transportOptions: { + ...(remote ? { + runnerFilesystemRoot: remoteFilesystem, runnerStateDirectory: join(remoteRoot, "runner"), + readRunnerState: async () => JSON.parse(await readFile(join(remoteRoot, "runner", "runner-state.json"), "utf8")), + controlPlaneRegistration: async authority => { + await mkdir(remoteWorkspace, { recursive: true, mode: 0o700 }); + const remoteHome = join(remoteFilesystem, "codex-home"); + await mkdir(remoteHome, { mode: 0o700 }); + for (const name of ["auth.json", "config.toml"] as const) await cp(join(runtimeDirectory, "codex-home", name), join(remoteHome, name)); + if (mode.startsWith("late-remote-")) await new Promise(resolve => setTimeout(resolve, 1000)); + await authority.start(); + return { connectUrl: authority.connectUrl, release: async () => { registrationReleased = true; await authority.stop(); if (mode === "late-remote-release-error") throw new Error("PRP registration release failed"); } }; + }, + } : {}), + runnerBinary: mode === "missing-runtime" ? join(root, "missing-runner") : resolve("runner/target/debug/paperclip-runnerd"), + codexCommand: fakeCodex, codexArgs: fakeCodexArgs(root, "--request-log", calls, + ...(mode === "missing-completion" ? ["--hold-turn"] : []), + ...(mode === "mismatched-model" || mode === "remote-mismatched-model" || mode === "model-refresh-error" || mode === "model-cleanup-error" ? ["--returned-model", "another-model"] : []), + ...(mode === "missing-model" ? ["--returned-model", ""] : []), + ...(mode === "late-bootstrap" ? ["--thread-start-delay-ms", "1000"] : [])), + sourceCodexHome: sourceHome, + }, + onCodexCredentialRefresh: async filename => { + expect(filename).toBe(join(remote ? remoteFilesystem : runtimeDirectory, "codex-home", "auth.json")); + expect(JSON.parse(await readFile(filename, "utf8"))).toEqual({ OPENAI_API_KEY: "selected-account" }); + if (mode === "api-key") { + const config = await readFile(join(runtimeDirectory, "codex-home", "config.toml"), "utf8"); + expect(config).not.toContain("ambient-model"); expect(config).not.toContain("untrusted-ambient-command"); + expect((await stat(filename)).mode & 0o777).toBe(0o600); + } + refreshed = true; + if (mode === "model-refresh-error") throw new Error("credential refresh failed after the native model error"); + }, + onCleanupConfirmed: async () => { + cleanupConfirmed = true; + expect(refreshed || mode === "missing-runtime" || mode === "late-bootstrap" || mode.startsWith("late-remote-")).toBe(true); + if (mode === "model-cleanup-error") throw new Error("owned temporary state cleanup failed"); + await rm(runtimeDirectory, { recursive: true, force: true }); + }, + }); + if (mode === "complete" || mode === "api-key" || mode === "default-model" || mode === "remote-complete") { + const observedModel = mode === "default-model" ? "gpt-test" : "gpt-6.1-sol"; + await expect(probe).resolves.toEqual({ provider: "codex", providerDriver: "codex_app_server", effectiveModel: observedModel, helloProbePassed: true }); + expect(refreshed).toBe(true); + if (remote) expect(registrationReleased).toBe(true); + expect(cleanupConfirmed).toBe(true); + await expect(stat(runtimeDirectory)).rejects.toThrow(); + const requests = (await readFile(calls, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + expect(requests.find(value => value.method === "thread/start").params).toMatchObject({ ...(remote ? { cwd: remoteWorkspace } : {}), permissions: "paperclip-runner-workspace-read-only", dynamicTools: [] }); + expect(requests.find(value => value.method === "turn/start").params).toMatchObject({ effort: "low", permissions: "paperclip-runner-workspace-read-only" }); + } else { + await expect(probe).rejects.toThrow(mode === "missing-runtime" ? /ENOENT|unavailable|cleanup/i : mode === "mismatched-model" || mode === "remote-mismatched-model" || mode === "model-refresh-error" || mode === "model-cleanup-error" || mode === "missing-model" ? /observed model|mismatched provider model/i : /timed out|cleanup|suspension/i); + if (mode === "late-remote-release-error") { + expect(registrationReleased).toBe(true); expect(cleanupConfirmed).toBe(false); + expect((await stat(runtimeDirectory)).isDirectory()).toBe(true); + } + if (mode === "model-refresh-error") { + expect(refreshed).toBe(true); + expect(cleanupConfirmed).toBe(false); + expect((await stat(runtimeDirectory)).isDirectory()).toBe(true); + } + if (mode === "model-cleanup-error") { + expect(cleanupConfirmed).toBe(true); + expect((await stat(runtimeDirectory)).isDirectory()).toBe(true); + } + if (mode === "mismatched-model" || mode === "remote-mismatched-model" || mode === "missing-model") { + expect(cleanupConfirmed).toBe(true); + await expect(stat(runtimeDirectory)).rejects.toThrow(); + } + if (mode === "mismatched-model" || mode === "remote-mismatched-model" || mode === "model-refresh-error" || mode === "model-cleanup-error" || mode === "missing-model" || mode === "late-bootstrap" || mode.startsWith("late-remote-")) { + const requests = (await readFile(calls, "utf8").catch(() => "")).trim().split("\n").filter(Boolean).map(line => JSON.parse(line)); + expect(requests.some(value => value.method === "turn/start")).toBe(false); + } + } + } finally { await rm(root, { recursive: true, force: true }); } +}, 20_000); + function assignedRuntimeContext( skillRoot: string, instructionRoot: string, diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 4290d68d90..8cda98c42f 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -38,6 +38,7 @@ import type { } from "../drivers/codex/app-server-transport.js"; import { createSanitizedCodexEnvironment } from "../drivers/codex/app-server-transport.js"; import { + CodexAppServerDriver, codexSemanticToolSpecs, createIsolatedCodexAppServerArgs, } from "../drivers/codex/codex-app-server-driver.js"; @@ -49,6 +50,7 @@ import type { NativeRunIdentity, NativeTurnControlCapabilities } from "../contra import type { PrpEvent } from "../protocol/replay-contract.js"; import { NativeSessionCloseUnrecoverableError } from "../contracts/native-session-backend.js"; import type { + HarnessSession, HarnessRuntimeRequestResolution, PersistedHarnessProviderIdentity, } from "../contracts/harness-driver.js"; @@ -3525,6 +3527,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { #deferredTurnStartEvents: DurableRecoveryCommittedEvent[] = []; #recoveryTurnBindingPending = false; #threadId = ""; + #observedProviderModel: string | undefined; #sessionId: string | null = null; #providerIdentity: Record | null = null; #turnControls: NativeTurnControlCapabilities = { steering: false, queuedFollowUp: false }; @@ -4969,6 +4972,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { externallySandboxed: provider === "codex" && this.options.externallySandboxed === true, + ...(provider === "codex" && params.permissions === "paperclip-runner-workspace-read-only" ? { readOnly: true } : {}), instructions: provider === "codex" ? withCodexCollaborationRuntimeInstructions( @@ -5076,6 +5080,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { const provider = this.options.provider ?? "codex"; const acpxAgent = this.options.acpxAgent ?? "codex"; return { + ...(provider === "codex" ? { model: this.#observedProviderModel } : {}), thread: { id: this.#threadId, sessionId: this.#sessionId, @@ -5909,6 +5914,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { "turn.start", { text: message, + ...((this.options.provider ?? "codex") === "codex" && params.effort !== undefined ? { effort: params.effort } : {}), ...(skills.length ? { skills } : {}), turnId: pendingTurnId, }, @@ -6538,6 +6544,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { const started = record(record(event.envelope.payload).payload); const runtimeIdentity = record(started.runtimeIdentity); const descriptor = record(started.providerDescriptor); + if (typeof descriptor.effectiveModel === "string" && descriptor.effectiveModel.trim()) this.#observedProviderModel = descriptor.effectiveModel; const { processId: pid, threadId, @@ -7016,6 +7023,176 @@ export function createCapabilityRunnerdCodexTransport( export const createRunnerdCodexTransport = createCapabilityRunnerdCodexTransport; +/** A setup turn uses the same daemon/provider boundary as ordinary native runs. */ +export async function probeNativeRunnerEnvironment(options: { + runtimeDirectory: string; + provider: "codex"; + model: string | null; + reasoningEffort?: string; + environment: NodeJS.ProcessEnv; + timeoutMs?: number; + /** The selected remote filesystem supplies this cwd; never inspect it on the controller. */ + workingDirectory?: string; + transportOptions?: CapabilityRunnerdCodexTransportOptions; + onCodexCredentialRefresh?: (filename: string) => Promise; + onCleanupConfirmed?: () => Promise; +}): Promise<{ + provider: "codex"; + providerDriver: string; + effectiveModel: string; + helloProbePassed: true; +}> { + const environment = { ...options.environment }; + const remoteFilesystemRoot = options.transportOptions?.runnerFilesystemRoot; + if (options.workingDirectory !== undefined && !remoteFilesystemRoot) throw new Error("Native setup remote working directory requires a remote runner filesystem."); + const workingDirectory = options.workingDirectory ?? resolve(options.runtimeDirectory, "workspace"); + if (!remoteFilesystemRoot) mkdirSync(workingDirectory, { mode: 0o700 }); + const controller = new AbortController(); + let registrationTeardownError: unknown; + let registrationOperation: Promise | undefined; + const sourceCodexHome = options.transportOptions?.sourceCodexHome ?? resolveSourceCodexHome(environment); + // Never let an unbound home or an external-sandbox flag admit host state or + // weaken this read-only probe. The normal materializer copies bound auth and + // the managed provider projection into a new daemon-owned Codex home. + delete environment.PAPERCLIP_RUNNER_EXTERNAL_SANDBOX; + environment.HOME = resolve(remoteFilesystemRoot ?? options.runtimeDirectory, "probe-home"); + environment.CODEX_HOME = resolve(remoteFilesystemRoot ?? options.runtimeDirectory, "codex-home"); + environment.PAPERCLIP_WORKSPACE_CWD = workingDirectory; + const bundle = createRunnerdCodexTransport({ + ...options.transportOptions, + ...(options.transportOptions?.controlPlaneRegistration ? { controlPlaneRegistration: (authority, identity) => { + const operation = (async () => { + controller.signal.throwIfAborted(); + const registration = await options.transportOptions!.controlPlaneRegistration!(authority, identity); + if (controller.signal.aborted) { + try { await registration.release(); } + catch (error) { registrationTeardownError = error; throw error; } + controller.signal.throwIfAborted(); + } + return registration; + })(); + registrationOperation = operation; + return operation; + } } : {}), + ...(options.transportOptions?.runnerProcessLauncher ? { runnerProcessLauncher: spec => { + controller.signal.throwIfAborted(); + return options.transportOptions!.runnerProcessLauncher!(spec); + } } : {}), + provider: options.provider, + sourceCodexHome: sourceCodexHome ?? "", + stateDirectory: options.runtimeDirectory, + environment, + externallySandboxed: false, + closeGraceMs: 10_000, + lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null }, + }); + const driver = new CodexAppServerDriver({ + taskEnvelope: { + schema: "paperclip.skillless_task.v1", objective: "Verify the selected native account and model.", + completionContract: { revision: "environment-probe-v1", criteria: [] }, + constraints: [], expectedResultSchema: "paperclip.run_result.v1", + }, + ...(options.model ? { model: options.model } : {}), + ...(options.reasoningEffort ? { reasoningEffort: options.reasoningEffort } : {}), + conversationMode: "direct", + requestedCollaborationMode: "plan", + approvalPolicy: "never", + includeCollaborationModeInstructions: false, + dynamicTools: [], + environment, + transportFactory: () => bundle.transport, + requireProviderSessionIdentity: true, + ...(remoteFilesystemRoot ? { workingDirectoryAuthority: "remote_runner" as const } : {}), + }); + let rejectAborted!: (error: unknown) => void; + const aborted = new Promise((_resolve, reject) => { rejectAborted = reject; }); + const timer = setTimeout(() => { + const error = new Error("Native provider hello probe timed out."); + controller.abort(error); + rejectAborted(error); + }, Math.max(1, Math.min(options.timeoutMs ?? 45_000, 120_000))); + let session: HarnessSession | undefined; + let completed = false; + let effectiveModel: string | undefined; + let failure: unknown; + const operation = (async () => { + session = await driver.openSession({ runId: "environment-probe", normalizedSessionId: "environment-probe", + workingDirectory, signal: controller.signal }); + controller.signal.throwIfAborted(); + const ids = session.ids(); + const expectedDriver = "codex_app_server"; + if (!ids.driverSessionId || !ids.providerSessionId || bundle.evidence().providerDriver !== expectedDriver) { + throw new Error(`The native environment probe returned no matching provider identity (${bundle.evidence().providerDriver ?? "missing driver"}).`); + } + const events = session.events()[Symbol.asyncIterator](); + const first = await events.next(); + const context = record(first.value?.payload.context); + if (first.value?.eventType !== "session.started" || !context.model || record(record(context.sandbox).permissionProfile).id !== "paperclip-runner-workspace-read-only") { + throw new Error("The native environment probe returned no observed model or read-only runtime identity."); + } + { + effectiveModel = typeof context.model === "string" && context.model.trim() && context.model !== "unknown" ? context.model : undefined; + if (!effectiveModel || (options.model !== null && effectiveModel !== options.model)) throw new Error("The selected native runtime returned a missing or mismatched provider model."); + } + controller.signal.throwIfAborted(); + const { turnId } = await session.startTurn({ message: { role: "user", text: "Respond only with hello. Do not use tools or inspect files." } }); + let responseObserved = false; + while (true) { + const next = await events.next(); + if (next.done) break; + const event = next.value; + if (event.turnId !== turnId) continue; + if (event.eventType === "item.completed" && event.payload.kind === "agentMessage" + && typeof event.payload.text === "string" && event.payload.text.trim()) { + responseObserved = true; + } + if (event.eventType === "turn.completed") { + if (!responseObserved || !effectiveModel) throw new Error("The native provider hello probe returned no response or observed model."); + completed = true; + return; + } + if (event.eventType === "turn.failed" || event.eventType === "turn.interrupted" || event.eventType === "turn.cancelled") { + const detail = record(event.payload.error).message; + throw new Error(typeof detail === "string" ? detail : "The native provider hello probe failed."); + } + } + throw new Error("The native provider hello probe ended without a successful turn."); + })(); + try { + await Promise.race([operation, aborted]); + } catch (error) { + failure = error; + } finally { + clearTimeout(timer); + // Preserve private recovery state if the production close cannot confirm + // teardown; callers remove it only after an authoritative receipt. + const teardownErrors: unknown[] = []; + try { + if (session) await session.close({ reason: "environment probe complete" }); + else await bundle.transport.close("environment probe complete"); + if (bundle.evidence().runnerPid !== null && !bundle.evidence().runnerExited) throw new Error("Native setup probe cleanup is incomplete."); + } catch (error) { teardownErrors.push(error); } + await operation.catch(() => undefined); + await registrationOperation?.catch(() => undefined); + if (registrationTeardownError !== undefined) teardownErrors.push(registrationTeardownError); + if (bundle.evidence().runnerPid !== null && bundle.evidence().runnerExited && options.provider === "codex" && options.onCodexCredentialRefresh) { + try { await options.onCodexCredentialRefresh(resolve(remoteFilesystemRoot ?? options.runtimeDirectory, "codex-home", "auth.json")); } + catch (error) { teardownErrors.push(error); } + } + if (!teardownErrors.length && options.onCleanupConfirmed) { + try { await options.onCleanupConfirmed(); } + catch (error) { teardownErrors.push(error); } + } + if (teardownErrors.length) { + const errors = [...(failure === undefined ? [] : [failure]), ...teardownErrors]; + throw new AggregateError(errors, errors.map(error => error instanceof Error ? error.message : "Native probe cleanup failed.").join("; "), { cause: failure ?? teardownErrors[0] }); + } + } + if (failure !== undefined) throw failure; + if (!completed) throw new Error("The native provider hello probe did not complete."); + return Object.freeze({ provider: options.provider, providerDriver: bundle.evidence().providerDriver!, effectiveModel: effectiveModel!, helloProbePassed: true }); +} + /** The transport provider identifies the biller, independently of model family. */ function openedThreadModelProvider( provider: CapabilityRunnerdCodexTransportOptions["provider"], diff --git a/packages/shared/src/agent-runner.test.ts b/packages/shared/src/agent-runner.test.ts new file mode 100644 index 0000000000..6ce2bd8eb1 --- /dev/null +++ b/packages/shared/src/agent-runner.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform } from "./agent-runner.js"; +import { createAgentSchema, updateAgentSchema, testAdapterEnvironmentSchema } from "./validators/agent.js"; + +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 daemon actually shipped in public server packages", () => { + expect(paperclipRunnerSupportsPlatform("codex_local", "linux", "x64")).toBe(true); + for (const [platform, architecture] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "arm64"], ["win32", "x64"], ["freebsd", "x64"]]) expect(paperclipRunnerSupportsPlatform("codex_local", platform, architecture)).toBe(false); + }); + it("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/packages/shared/src/agent-runner.ts b/packages/shared/src/agent-runner.ts new file mode 100644 index 0000000000..324d163c48 --- /dev/null +++ b/packages/shared/src/agent-runner.ts @@ -0,0 +1,35 @@ +/** Request intent only. Agents persist the selected adapter, not an automatic choice. */ +export type AgentRunnerChoice = "auto" | "paperclip" | "legacy"; +export type AgentRunner = Exclude; + +export interface AgentRunnerAvailability { + supportedRunners: AgentRunner[]; + defaultRunner: AgentRunner; +} + +/** Codex is the only harness whose creation default graduates in this release. */ +export function paperclipRunnerProfileForHarness(harness: string): { provider: "codex" } | undefined { + return harness === "codex_local" ? { provider: "codex" } : undefined; +} + +/** Public server packages currently ship a Linux x64 daemon; source-build targets are not release qualification. */ +export function paperclipRunnerSupportsPlatform(harness: string, platform: string, architecture: string): boolean { + return !!paperclipRunnerProfileForHarness(harness) + && platform === "linux" && architecture === "x64"; +} + +/** Recover harness identity without changing execution or guessing unknown providers. */ +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 a94a156b7f..1c7dc1b883 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2858,3 +2858,4 @@ 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, type AgentRunnerChoice, type AgentRunner, type AgentRunnerAvailability } from "./agent-runner.js"; diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index 7c3bc6cc75..76ce2cd19c 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 9f023c2b59..f51e4f1bdb 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -94,6 +94,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({}), @@ -122,6 +123,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(); @@ -257,6 +259,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 8244c3d4ec..6045d608a0 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-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 341079db14..8d01d88427 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__/agent-adapter-validation-routes.test.ts b/server/src/__tests__/agent-adapter-validation-routes.test.ts index 1aa1217977..073dc682a8 100644 --- a/server/src/__tests__/agent-adapter-validation-routes.test.ts +++ b/server/src/__tests__/agent-adapter-validation-routes.test.ts @@ -672,20 +672,47 @@ 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 () => { + // Exercise route ordering and the real translator with an explicitly qualified target. + // Environment/platform discovery has its own company-boundary and platform tests. + 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 +795,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__/agents-pending-approval-config.test.ts b/server/src/__tests__/agents-pending-approval-config.test.ts index 7c02a10da1..69eb9c2136 100644 --- a/server/src/__tests__/agents-pending-approval-config.test.ts +++ b/server/src/__tests__/agents-pending-approval-config.test.ts @@ -63,6 +63,31 @@ 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 native = await service.create(companyId, { name: "New Codex", adapterType: "codex_local", adapterConfig: { modelReasoningEffort: "high" }, status: "pending_approval" }); + const legacy = await service.create(companyId, { name: "Legacy Codex", adapterType: "codex_local", runner: "legacy", adapterConfig: { extraArgs: ["--search"] }, status: "pending_approval" }); + const reviewedLegacy = await service.create(companyId, { name: "Historical approved hire", adapterType: "codex_local", adapterConfig: {}, status: "pending_approval" }, { runnerResolved: true }); + // Only public Linux x64 artifacts qualify an automatic native default; + // source-built macOS/Windows agents keep legacy until their packaging is qualified. + if (process.platform === "linux" && process.arch === "x64") { + 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]) { + // Ordinary title changes on an activated agent must not revisit creation defaults. + await service.activatePendingApproval(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); diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index 57217dd238..610d3c9bc9 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -22,6 +22,8 @@ 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 { 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"; @@ -171,6 +173,39 @@ 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 probe = vi.spyOn(runnerProbe, "probeNativeRunnerEnvironment").mockImplementation(async input => { + 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).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(); } + }); + 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 47aedf9169..f053bb8ef3 100644 --- a/server/src/__tests__/approvals-service.test.ts +++ b/server/src/__tests__/approvals-service.test.ts @@ -139,6 +139,7 @@ describe("approvalService resolution idempotency", () => { requestedByUserId, payload: { name: "New Agent", + adapterType: "codex_local", adapterConfig: { env: { API_KEY: { @@ -159,9 +160,10 @@ describe("approvalService resolution idempotency", () => { expect(mockAgentService.create).toHaveBeenCalledWith( "company-1", expect.objectContaining({ + adapterType: "codex_local", adapterConfig: approved.payload.adapterConfig, }), - { createdByUserId: expectedCreator }, + { runnerResolved: true, createdByUserId: expectedCreator }, ); }); }); diff --git a/server/src/__tests__/built-in-agents.test.ts b/server/src/__tests__/built-in-agents.test.ts index 7a90b5b5ec..c4d0c348c7 100644 --- a/server/src/__tests__/built-in-agents.test.ts +++ b/server/src/__tests__/built-in-agents.test.ts @@ -257,21 +257,51 @@ 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 created = await svc.ensure(companyId, "briefs", { adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" }, 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" } }); + await svc.ensure(companyId, "briefs", { budgetMonthlyCents: 200 }); + await reconcileBuiltInAgentsOnStartup(db); + expect((await svc.get(companyId, "briefs")).agent?.adapterConfig).toEqual(switched.agent?.adapterConfig); + 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" } }); + 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("routes policy-gated built-in provisioning through a pending hire approval", async () => { const companyId = await seedCompany(); const builtIns = builtInAgentService(db); @@ -383,7 +413,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" } }, }); }); @@ -410,8 +440,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, }, }); @@ -577,8 +607,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" }, }, }); }); @@ -624,7 +654,7 @@ describeEmbeddedPostgres("built-in agents", () => { role: "general", title: "Reflection Coach", icon: "eye", - adapterType: "codex_local", + adapterType: "paperclip_runner", permissions: { canCreateAgents: false, canCreateSkills: false, @@ -1240,7 +1270,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 461da04f26..99f13d4668 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -19,6 +19,7 @@ const companySvc = { const agentSvc = { list: vi.fn(), + getById: vi.fn(), create: vi.fn(), update: vi.fn(), }; @@ -103,6 +104,7 @@ const agentInstructionsSvc = { }; const instanceSettingsSvc = { + get: vi.fn(async () => ({ general: {}, experimental: {}, defaultEnvironmentId: null })), getExperimental: vi.fn(async (): Promise<{ enableNativeRunner: boolean; enableOpenAiDot?: boolean }> => ({ enableNativeRunner: false })), }; @@ -199,6 +201,7 @@ describe("company portability", () => { beforeEach(() => { vi.clearAllMocks(); + 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", @@ -1895,7 +1898,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({ @@ -3745,6 +3748,7 @@ describe("company portability", () => { adapterOverrides: { claudecoder: { adapterType: "codex_local", + runner: "legacy", adapterConfig: { dangerouslyBypassApprovalsAndSandbox: true, instructionsFilePath: "/tmp/should-not-survive.md", @@ -3852,6 +3856,7 @@ describe("company portability", () => { adapterOverrides: { claudecoder: { adapterType: "codex_local", + runner: "legacy", adapterConfig: { extraArgs: [], args: ["--legacy-arg"], @@ -5852,6 +5857,7 @@ describe("company portability", () => { adapterOverrides: { claudecoder: { adapterType: "codex_local", + runner: "legacy", adapterConfig: { model: "gpt-5.4", }, @@ -6040,6 +6046,62 @@ describe("company portability", () => { expect(preview.plan.issuePlans).toHaveLength(0); }); + it.each(["legacy", "paperclip"] as const)("exports the resolved %s Codex runner and preserves it on import", async (runner) => { + 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" }); + // The import UI sends the underlying harness even for config-only edits. + 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(["create", "update"] as const)("applies Codex defaults only to new imports without a runner (%s)", async (action) => { + 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: "paperclip_runner", adapterConfig: expect.objectContaining({ provider: "codex" }) }), { 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" }, + { existingRunner: "paperclip", existingHarness: "codex_local", expectedType: "paperclip_runner" }, + { existingRunner: "legacy", existingHarness: "claude_local", expectedType: "paperclip_runner" }, + ])("keeps execution on same-harness replace-import edits and defaults genuine changes ($existingRunner/$existingHarness)", async ({ existingRunner, existingHarness, expectedType }) => { + 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 } }); @@ -6056,7 +6118,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"); @@ -6090,7 +6152,7 @@ describe("company portability", () => { adapterOverrides: { claudecoder: { adapterType: "paperclip_runner", - adapterConfig: { provider: "codex" }, + adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6" }, }, }, }; @@ -6122,7 +6184,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__/dot-runner.test.ts b/server/src/__tests__/dot-runner.test.ts index fe7caa79c8..895e773136 100644 --- a/server/src/__tests__/dot-runner.test.ts +++ b/server/src/__tests__/dot-runner.test.ts @@ -87,7 +87,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__/onboarding-seed-route.test.ts b/server/src/__tests__/onboarding-seed-route.test.ts index 766d8c9668..49ab0998e4 100644 --- a/server/src/__tests__/onboarding-seed-route.test.ts +++ b/server/src/__tests__/onboarding-seed-route.test.ts @@ -143,6 +143,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 95abe9db66..6f3507f0c3 100644 --- a/server/src/__tests__/plugin-managed-agents.test.ts +++ b/server/src/__tests__/plugin-managed-agents.test.ts @@ -283,8 +283,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: {}, }, @@ -315,7 +315,9 @@ 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); }); it("materializes declared managed agent instructions with local folder paths", async () => { diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 54b72aa3bd..afbf94194b 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -483,9 +483,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, @@ -1177,6 +1179,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/dev-native-runner-status.ts b/server/src/dev-native-runner-status.ts index d0e99be5a5..85fb2e9254 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 { paperclipRunnerSupportsPlatform } from "@paperclipai/shared"; import { instanceSettingsService } from "./services/instance-settings.js"; @@ -48,8 +49,12 @@ async function main(): Promise { console.log( JSON.stringify({ - nativeRunnerRequired: - experimental.enableNativeRunner === true || experimental.enableOpenAiDot === true || persistedNativeRun, + // New Codex agents can use runnerd immediately, including on instances + // that have disabled the experimental providers. + nativeRunnerRequired: paperclipRunnerSupportsPlatform("codex_local", process.platform, process.arch) + || experimental.enableNativeRunner === true + || experimental.enableOpenAiDot === true + || persistedNativeRun, rolloutEnabled: experimental.enableNativeRunner === true, dotRolloutEnabled: experimental.enableOpenAiDot === true, persistedNativeRun, diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index eb20f325e4..b2adcd0bc1 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -52,6 +52,8 @@ import { isCloudManagedInstance } from "../services/cloud-instance.js"; import { getHiddenSettings } from "../services/settings-visibility.js"; import { assertBoardOrgAccess, assertInstanceAdmin } from "./authz.js"; import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js"; +import { agentRunnerAvailability } from "../services/agent-runner-selection.js"; +import type { AgentRunnerAvailability } from "@paperclipai/shared"; const execFileAsync = promisify(execFile); @@ -123,7 +125,7 @@ interface AdapterCapabilities { login?: AdapterLoginProjection; } -interface AdapterInfo { +interface AdapterInfo extends AgentRunnerAvailability { type: string; label: string; source: "builtin" | "external"; @@ -203,6 +205,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), ...(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, @@ -281,11 +284,10 @@ export function adapterRoutes(options: { 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"); + // Codex is generally available; the other native providers retain their + // provider-specific selection gates. Explicit adapter disabling still applies. const result: AdapterInfo[] = registeredAdapters.map((adapter) => buildAdapterInfo(adapter, externalRecords.get(adapter.type), disabledSet), diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 3c68a3d49d..3752624386 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,5 +1,7 @@ import { cancellationRequestId } from "../services/native-runtime/native-cancellation-request.js"; import { aiRoutingHarness } from "@paperclipai/shared"; +import { agentHarnessType, type AgentRunnerChoice } from "@paperclipai/shared"; +import { resolveNewAgentRunnerForCompany } from "../services/agent-runner-selection.js"; import { agentIdentityService } from "../services/agent-identity.js"; import { aiConnectionRouterService, poolMemberRuntimeConfig } from "../services/ai-connection-router.js"; import { connectionIntentService } from "../services/connection-intents.js"; @@ -2243,7 +2245,7 @@ export function agentRoutes( "OpenAI Dot is experimental and disabled on this instance.", { code: "paperclip_runner_dot_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" }, @@ -2594,7 +2596,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; @@ -2645,11 +2647,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); @@ -3451,7 +3454,9 @@ export function agentRoutes( async function testManagedEnvironment(adapterType: string, context: Parameters["testEnvironment"]>[0], binding: AiConnectionBinding, managed: Awaited>, agentId?: string) { const startedAt = new Date(); - const result = await probeManagedEnvironment(adapterType, context, binding); + const managedAiCredentialHome = adapterType === "paperclip_runner" && context.config.provider === "codex" && managed.home + ? path.join(managed.home, "provider") : undefined; + const result = await probeManagedEnvironment(adapterType, { ...context, ...(managedAiCredentialHome ? { managedAiCredentialHome } : {}) }, binding); // A provider rejection invalidates the tested credential generation. A // missing CLI, unavailable environment, or other runtime error does not. if (result.status === "fail" && result.checks.some(check => @@ -3577,11 +3582,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"); @@ -3596,11 +3599,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 @@ -3640,6 +3638,20 @@ export function agentRoutes( ? restoreRedactedAgentEnv(inputAdapterConfig, savedAgent.adapterConfig) : inputAdapterConfig; } + const selection = await resolveNewAgentRunnerForCompany(db, companyId, { + adapterType: type, adapterConfig: adapterConfigForTest, + runner: req.body.runner ?? (savedAgent && savedAgent.adapterType === type ? (type === "paperclip_runner" ? undefined : "legacy") : undefined), + defaultEnvironmentId: requestedEnvironmentId, + }); + type = selection.adapterType; + adapterConfigForTest = selection.adapterConfig; + const adapter = requireServerAdapter(type); + if (!savedAgent || req.body.runner !== undefined || type !== savedAgent.adapterType) 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; @@ -4690,6 +4702,7 @@ export function agentRoutes( onboardingFirstAgent: hireOnboardingFirstAgent, // This intent flag is consumed below and must never reach agent create. inheritRuntimeFrom, + runner: hireRunner, ...hireInput } = req.body; @@ -4715,6 +4728,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); @@ -4845,6 +4860,7 @@ export function agentRoutes( }, { createdByUserId: req.actor.type === "board" ? req.actor.userId : null, + runnerResolved: true, aiConnectionInstall: managedHireConnection ? { ...managedHireConnection, createdByUserId: responsibleUserForAiRequest(req) } : undefined, claudeLogin: { storedSessionId: hireStoredSessionId ?? null, @@ -5026,8 +5042,10 @@ 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; + 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); @@ -5101,6 +5119,7 @@ export function agentRoutes( }, { createdByUserId: req.actor.type === "board" ? req.actor.userId : null, + runnerResolved: true, aiConnectionInstall: managedConnection ? { ...managedConnection, createdByUserId: responsibleUserForAiRequest(req) } : undefined, claudeLogin: { storedSessionId: createStoredSessionId ?? null, @@ -5643,6 +5662,8 @@ export function agentRoutes( } const patchData = { ...(req.body as Record) }; + const runner = patchData.runner as AgentRunnerChoice | undefined; + delete patchData.runner; const replaceAdapterConfig = patchData.replaceAdapterConfig === true; delete patchData.replaceAdapterConfig; // The apply-existing flag is not an agent column. The server binds the fixed @@ -5668,6 +5689,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-runner-selection.test.ts b/server/src/services/agent-runner-selection.test.ts new file mode 100644 index 0000000000..598f7ce598 --- /dev/null +++ b/server/src/services/agent-runner-selection.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +const state = vi.hoisted(() => ({ disabled: [] as string[], overridden: new Set(), settings: {} as Record, getEnvironment: vi.fn(), bindings: vi.fn(), managedEnvironment: vi.fn(), ssh: vi.fn(), resolveEnvironment: 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.managedEnvironment }) })); +vi.mock("./environment-config.js", () => ({ resolveEnvironmentDriverConfigForRuntime: state.resolveEnvironment })); +vi.mock("@paperclipai/adapter-utils/ssh", () => ({ runSshCommand: state.ssh })); +import { agentRunnerAvailability, resolveNewAgentRunner, resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js"; +const target = { driver: "local", platform: "linux", architecture: "x64" }; + +describe("server-owned Codex runner selection", () => { + beforeEach(() => { state.disabled = []; state.overridden.clear(); state.settings = {}; state.getEnvironment.mockReset(); state.bindings.mockReset().mockResolvedValue([]); state.managedEnvironment.mockReset(); state.ssh.mockReset().mockResolvedValue({ stdout: "Linux\nx86_64\n" }); state.resolveEnvironment.mockReset().mockResolvedValue({ driver: "ssh", config: {} }); }); + it("uses native Codex automatically with compatible configuration and is idempotent", () => { + 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("allows an explicit legacy runner and native to legacy round trip", () => { + 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.each(["darwin", "win32"])("keeps unqualified %s targets legacy while preserving explicit native profiles", platform => { + const input = { adapterType: "codex_local", target: { driver: "local", platform, architecture: "x64" } }; + 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 }); + }); + it("automatically keeps unsupported targets and active external overrides legacy", () => { + 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"); + }); + it("does not select a disabled runner", () => { + 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("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("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("codex_local"); + }); + 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("paperclip_runner"); expect(state.ssh).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/services/agent-runner-selection.ts b/server/src/services/agent-runner-selection.ts new file mode 100644 index 0000000000..ac5985f57c --- /dev/null +++ b/server/src/services/agent-runner-selection.ts @@ -0,0 +1,118 @@ +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"; + +type RunnerTarget = { driver: string; platform?: string; architecture?: string }; +export interface AgentRunnerSelection { + adapterType?: string | null; + adapterConfig?: Record; + runner?: AgentRunnerChoice; + target?: RunnerTarget; +} + +export function agentRunnerAvailability(harness: string, target: RunnerTarget = { driver: "local", platform: process.platform, architecture: process.arch }): 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.platform || paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "")); + 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 }); +} + +/** Idempotent translation. Missing dependencies or credentials never select another runner. */ +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" }); + // Explicit saved native profiles (including historical provider omission) retain execution. + if (native && (input.runner === undefined || (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: true } }; + } + if (native && harness !== "codex_local" && choice === "paperclip") return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) }; + if (availability.defaultRunner !== "paperclip") throw unprocessable("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 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); + 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 }; +} + +/** Use the execution environment selected by dispatch, without starting a provider. */ +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)) return resolveNewAgentRunner(input); + 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 = input.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) throw unprocessable("The required Kubernetes environment is unavailable.", { code: "kubernetes_environment_unavailable" }); + } + 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" ? { driver, platform: process.platform, architecture: process.arch } + : { driver }; // Sandbox platform/artifact identity is verified by selected-runtime setup; do not guess an image's architecture. + 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, "uname -s; uname -m", { 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 = ""] = 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 }; + } + return resolveNewAgentRunner({ ...input, target }); +} diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 59e3a90512..7d4ff5b4af 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -1,4 +1,6 @@ import { budgetServiceInTransaction, deliverBudgetEnforcement, type BudgetServiceHooks } from "./budgets.js"; +import { resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js"; +import type { AgentRunnerChoice } from "@paperclipai/shared"; import { withAccountingTransaction } from "./accounting-transaction.js"; import type { ActivityPublication } from "./activity-log.js"; import { agentAppearanceSchema, randomAgentAppearance, resolveAgentAppearance, agentAvatarUrl } from "@paperclipai/shared"; @@ -136,6 +138,8 @@ interface UpdateAgentOptions { } interface CreateAgentOptions { + /** Reviewed/historical execution already resolved by a governed internal caller. */ + runnerResolved?: boolean; createdByUserId?: string | null; aiConnectionInstall?: { connectionId: string; memberConnectionIds?: string[]; createdByUserId: string | null }; allowBuiltInAgentMetadata?: boolean; @@ -922,7 +926,12 @@ export function agentService(db: Db, budgetHooks: BudgetServiceHooks = {}) { getById, - create: async (companyId: string, data: Omit, options?: CreateAgentOptions) => { + create: async (companyId: string, input: Omit & { runner?: AgentRunnerChoice }, options?: CreateAgentOptions) => { + 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; if (data.appearance?.customAvatarAssetId) throw unprocessable("Create the agent before uploading its avatar"); assertBuiltInAgentMetadataMutationAllowed(null, data.metadata, options); if (data.reportsTo) { diff --git a/server/src/services/approvals.ts b/server/src/services/approvals.ts index f90542846c..0e5a403d17 100644 --- a/server/src/services/approvals.ts +++ b/server/src/services/approvals.ts @@ -189,7 +189,7 @@ export function approvalService(db: Db) { spentMonthlyCents: 0, permissions: undefined, lastHeartbeatAt: null, - }, { createdByUserId: updated.requestedByAgentId ? null : updated.requestedByUserId }); + }, { runnerResolved: true, createdByUserId: updated.requestedByAgentId ? null : updated.requestedByUserId }); hireApprovedAgentId = created?.id ?? null; } if (hireApprovedAgentId) { diff --git a/server/src/services/built-in-agents.ts b/server/src/services/built-in-agents.ts index 1b5f70308d..c719eee102 100644 --- a/server/src/services/built-in-agents.ts +++ b/server/src/services/built-in-agents.ts @@ -7,7 +7,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"; @@ -17,6 +17,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, @@ -66,6 +67,7 @@ export interface BuiltInAgentState { } export interface BuiltInAgentProvisionInput { + runner?: AgentRunnerChoice; adapterType?: string; adapterConfig?: Record; budgetMonthlyCents?: number; @@ -689,8 +691,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, @@ -713,11 +715,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) { @@ -729,8 +731,9 @@ function builtInMetadata(definition: BuiltInAgentDefinition, existing?: Record candidate.id === model)) return; throw unprocessable(`Model "${model}" is not available for adapter ${adapterType}.`, { @@ -775,7 +778,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) { @@ -883,13 +886,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: {}, }; } @@ -1735,7 +1738,7 @@ 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 @@ -1749,12 +1752,24 @@ 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); + Object.assign(patch, runner !== undefined || existing.metadata?.paperclipBuiltInSetupRequired === true + ? await resolveNewAgentRunnerForCompany(db, companyId, { + adapterType, adapterConfig, 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; @@ -1793,7 +1808,14 @@ export function builtInAgentService(db: Db) { : null, pausedAt: definition.defaultStatus === "paused" ? new Date() : null, reportsTo, - metadata: builtInMetadata(definition), + metadata: { + ...builtInMetadata(definition), + // Provider defaults must not turn an unconfigured built-in into a ready agent. + paperclipBuiltInSetupRequired: !hasCompleteAdapterConfig( + definitionPatch(definition, resolvedInput).adapterType, + definitionPatch(definition, resolvedInput).adapterConfig, + ), + }, runtimeConfig: definition.defaultRuntimeConfig ?? {}, permissions: definition.defaultPermissions ?? {}, spentMonthlyCents: 0, @@ -1858,7 +1880,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 @@ -1868,7 +1890,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 }; } @@ -1894,7 +1916,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 ad438f8829..9c7fb0990c 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -1,4 +1,5 @@ -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"; @@ -3233,6 +3234,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); @@ -3256,6 +3263,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, @@ -3651,8 +3659,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`); } @@ -4276,6 +4290,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { capabilities: agent.capabilities ?? null, adapter: { type: agent.adapterType, + runner: agentRunner(agent.adapterType), config: portableAdapterConfig, }, runtime: portableRuntimeConfig, @@ -5277,7 +5292,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" }, @@ -5607,12 +5622,30 @@ 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; + const selectedAdapterType = adapterOverride?.adapterType ?? manifestAgent.adapterType; + const selectedHarness = agentHarnessType(selectedAdapterType, baseAdapterConfig); + // Config-only edits keep the package's explicit runner or an existing + // agent's execution. A genuinely changed harness selects its default. + 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 normalizedAdapter = await prepareImportedAgentAdapter( targetCompany.id, - adapterOverride?.adapterType ?? manifestAgent.adapterType, + selectedAdapterType, baseAdapterConfig, desiredSkills, mode, + runner, + existingImportAgent?.defaultEnvironmentId, ); const patch = { name: planAgent.plannedName, @@ -5692,6 +5725,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { let created = await agents.create(targetCompany.id, { ...patch, + runner: agentRunner(patch.adapterType), ...automationPausePatch, status: pauseAutomations ? "paused" : "idle", }, { createdByUserId: actorUserId }); diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index e61211cee1..f8bfff9c55 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -12624,12 +12624,12 @@ describe("runnerd provider runtime wiring", () => { it.each([ ...["current", "preinstalled-exact", "preinstalled-mismatch", "preinstalled-error", "preinstalled-timeout", "stale", "missing", "retained", "retained-mismatch", "retained-error", "retained-timeout", "retained-explicit"] - .map((image) => ({ image, version: "0.160.0", compatible: true })), + .map((image) => ({ image, version: "0.160.0" })), ...["0.149.0", "0.149.1", "0.153.4", "0.156.0", "0.160.1"] - .map((version) => ({ image: "current", version, compatible: true })), + .map((version) => ({ image: "current", version })), ...["0.148.9", "0.161.0", "1.0.0", "0.160.0-alpha.1", "unknown"] - .map((version) => ({ image: "current", version, compatible: false })), - ])("uses shared Codex and the server-owned replacement artifact (image=$image, Codex=$version)", async ({ image, version, compatible }) => { + .map((version) => ({ image: "current", version })), + ])("uses shared Codex and the server-owned replacement artifact (image=$image, Codex=$version)", async ({ image, version }) => { const retained = image.startsWith("retained"); const exactRetained = image === "retained" || image === "retained-explicit"; const needsReplacement = image !== "current" && image !== "preinstalled-exact" && !exactRetained; @@ -12721,16 +12721,10 @@ describe("runnerd provider runtime wiring", () => { controlPlaneRegistration: (authority: unknown) => Promise; }; await expect(transport.controlPlaneRegistration({})).rejects.toThrow( - compatible ? "reached-preinstalled-codex-verification" : "runner_remote_provider_artifact_incompatible: supported Codex versions >=0.149.0 <0.161.0", + "reached-preinstalled-codex-verification", ); - if (!compatible) { - expect(syncIn).not.toHaveBeenCalled(); - expect(remoteExecute.mock.calls.some(([call]) => call.command === "npm" || call.args?.[1]?.includes("paperclip_codex_launcher_tmp"))).toBe(false); - expect(onLog).not.toHaveBeenCalledWith("stderr", expect.stringContaining("using compatible Codex")); - return; - } if (version !== "0.160.0") { - expect(onLog).toHaveBeenCalledWith("stderr", expect.stringContaining(`using compatible Codex ${version}`)); + expect(onLog).toHaveBeenCalledWith("stderr", expect.stringContaining(`using installed Codex ${version === "unknown" || version.includes("alpha") ? "unrecognized version" : version}`)); } if (needsReplacement) { expect(transport.runnerBinary).toBe(controllerArtifact); @@ -12776,12 +12770,9 @@ describe("runnerd provider runtime wiring", () => { ].map((scenario) => ({ ...scenario, recover: false })), { version: "0.156.0", model: "gpt-6.1-sol", floor: null, recover: true }, { version: "0.158.0", model: "gpt-6.1-sol", floor: null, recover: true }, - ])("verifies sandbox Codex $version against the prepared $model selection (floor=$floor, recover=$recover)", async ({ version, model, floor, recover }) => { - // A preinstalled Codex inside the compatibility window can still be too - // old for the configured model: the ChatGPT backend rejects every turn - // with "not supported when using Codex with a ChatGPT account". The - // verifier names the stale image instead of letting the run fail as an - // account problem. + ])("lets sandbox Codex $version reach actual model verification ($model, recover=$recover)", async ({ version, model, recover }) => { + // Version metadata is diagnostic. A working app-server reaches the model + // turn instead of being rejected or replaced by a static CLI version floor. const preparedModel = recover ? resolvePaperclipRunnerNativeProviderInput({ backend: "codex_app_server", adapterConfig: { provider: "codex", model }, codexCliVersion: version, }).model : model; @@ -12851,16 +12842,10 @@ describe("runnerd provider runtime wiring", () => { ); } await expect(transport.controlPlaneRegistration({})).rejects.toThrow( - floor - ? `runner_remote_provider_artifact_incompatible: ${model} requires Codex ${floor} or newer with ChatGPT sign-in, received ${version} from the sandbox image; promote a sandbox image with Codex 0.160.0 or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC=@openai/codex@0.160.0` - : "reached-preinstalled-codex-verification", + "reached-preinstalled-codex-verification", ); - if (floor) { - // No npm spec is configured, so there is no fallback install; the run - // fails before launch instead of running against the stale CLI. - expect(remoteExecute.mock.calls.some(([call]) => call.command === "npm")).toBe(false); - expect(remoteExecute.mock.calls.some(([call]) => call.args?.[1]?.includes("paperclip_codex_launcher_tmp"))).toBe(false); - } + expect(remoteExecute.mock.calls.some(([call]) => call.command === "npm")).toBe(false); + }); it("binds a remote launch to the configured controller-owned runner artifact", async () => { diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 14dd0fb692..c2d43eac58 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -16,11 +16,7 @@ import { copyBackGrokAuth } from "@paperclipai/adapter-grok-local/server"; import { inferOpenAiCompatibleBiller, type AdapterUsageCheckpoint } from "@paperclipai/adapter-utils"; import { createAgentIdentityRedactor } from "../agent-identity-redaction.js"; -import { - isSupportedRemoteCodexVersion, - parseCodexCliVersion, - REMOTE_CODEX_SUPPORTED_RANGE, -} from "./codex-runtime-compatibility.js"; +import { parseCodexCliVersion } from "./codex-runtime-compatibility.js"; import { createNativeToolTrace, type NativeToolTrace } from "./native-tool-trace.js"; import { createNativeProviderFailureObservation } from "./native-provider-failure.js"; import { createNativeGitHubAccess, type NativeGitHubAccess } from "./native-github-access.js"; @@ -29,11 +25,6 @@ import { bindManagedNativeCredentialTurn, completeManagedNativeCredentialTurn } import { createLocalNativeQuestionBridge } from "./local-native-question-bridge.js"; import { readVerifiedRemoteWorkspaceFile } from "./remote-deliverable-file.js"; import { copyBackCodexAuth } from "@paperclipai/adapter-codex-local/server"; -import { - codexCliVersionAtLeast, - minimumCodexCliVersionForModel, - normalizeCodexModel, -} from "@paperclipai/adapter-codex-local"; import { nativeCompletionFeedback } from "./native-completion-feedback.js"; import { hasAcknowledgedNativeReassignmentStopIntent, hasAcknowledgedNativeStopIntent } from "../acknowledged-native-stop.js"; import { stoppedCodexTurnIsTextOnly } from "./stopped-codex-turn.js"; @@ -10174,7 +10165,7 @@ export function assertRemoteRunnerBuildMetadata( } } -async function stageRemoteRunnerFile(input: { +export async function stageRemoteRunnerFile(input: { target: Extract; runner: CommandManagedRuntimeRunner; sourcePath: string; @@ -10566,7 +10557,7 @@ export async function syncRemoteRunnerDirectoryOut(input: { } } -async function readRemoteRunnerState(input: { +export async function readRemoteRunnerState(input: { runner: CommandManagedRuntimeRunner; stateDirectory: string; }): Promise> { @@ -11498,76 +11489,6 @@ async function createRunnerdBackendWithinSessionClaim( }) : Promise.resolve(); - const verifyRemoteRunner = async ( - requiredMode: "dial_wss" | "listen_ws", - executable = remoteBinary, - ) => { - if (!remoteTarget || !remoteCommandRunner || !executable) return; - const metadataResult = await remoteCommandRunner.execute({ - command: executable, - args: ["--build-metadata"], - cwd: remoteTarget.remoteCwd, - bypassSession: true, - timeoutMs: 30_000, - }); - if (metadataResult.exitCode !== 0 || metadataResult.timedOut) { - throw new Error("runner_remote_artifact_verification_failed"); - } - let metadata: Record; - try { - metadata = JSON.parse(metadataResult.stdout) as Record; - } catch (error) { - throw new Error("runner_remote_artifact_metadata_invalid", { - cause: error, - }); - } - assertRemoteRunnerBuildMetadata(metadata, requiredMode); - }; - - const reportedCodexVersions = new Set(); - const verifyRemoteCodex = async (executable = remoteCodexBinary) => { - if (!remoteTarget || !remoteCommandRunner || !executable) return; - const versionResult = await remoteCommandRunner.execute({ - command: executable, - args: ["--version"], - cwd: remoteTarget.remoteCwd, - bypassSession: true, - timeoutMs: 30_000, - }); - if (versionResult.exitCode !== 0 || versionResult.timedOut) { - throw new Error("runner_remote_codex_artifact_verification_failed"); - } - const versionOutput = `${versionResult.stdout}\n${versionResult.stderr}`; - const version = parseCodexCliVersion(versionOutput); - if (!version || !isSupportedRemoteCodexVersion(version)) { - throw new Error( - `runner_remote_provider_artifact_incompatible: supported Codex versions ${REMOTE_CODEX_SUPPORTED_RANGE}, received ${version ?? "an unrecognized or prerelease version"}; install a supported stable Codex release or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC=@openai/codex@${REMOTE_PROVIDER_PACK_PINS.codex}`, - ); - } - // A Codex inside the compatibility window can still be too old for the - // configured model: the ChatGPT backend rejects a model from clients - // below the model's floor on every turn. Fail before launch with the - // exact gap, so a stale sandbox image is not reported as an account - // problem. When a preinstalled Codex fails here and an npm spec is - // configured, the caller falls back to installing the pinned release. - const configuredModel = input.execution.provider.kind === "codex" - ? input.execution.provider.model - : null; - const modelMinimum = minimumCodexCliVersionForModel(configuredModel); - if (modelMinimum && !codexCliVersionAtLeast(version, modelMinimum)) { - throw new Error( - `runner_remote_provider_artifact_incompatible: ${normalizeCodexModel(configuredModel)} requires Codex ${modelMinimum} or newer with ChatGPT sign-in, received ${version} from the sandbox image; promote a sandbox image with Codex ${REMOTE_PROVIDER_PACK_PINS.codex} or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC=@openai/codex@${REMOTE_PROVIDER_PACK_PINS.codex}`, - ); - } - if (version !== REMOTE_PROVIDER_PACK_PINS.codex && !reportedCodexVersions.has(version)) { - reportedCodexVersions.add(version); - await input.onLog?.( - "stderr", - `[paperclip-runner] using compatible Codex ${version} (supported ${REMOTE_CODEX_SUPPORTED_RANGE}; install pin ${REMOTE_PROVIDER_PACK_PINS.codex})\n`, - ); - } - }; - const verifyRemoteProviderPack = async (packRoot: string) => { if (!remoteTarget || !remoteCommandRunner || !expectedProviderPackManifest) return; @@ -11652,41 +11573,20 @@ async function createRunnerdBackendWithinSessionClaim( return parseRemoteExecutableCandidate(result.stdout); }; - // Image policy: keep one latest stable CLI installation shared by native and - // local adapters. Preferred bin entries must point to that same installation; - // never bake an older global CLI alongside a private runner-only version. - const discoverPreinstalledExecutable = async ( - name: "paperclip-runnerd" | "codex", - ) => { - if (!remoteTarget || !remoteCommandRunner) return null; - return discoverRemoteExecutable(remoteCommandRunner, remoteTarget.remoteCwd, name); - }; - - const linkPreinstalledExecutable = async ( - sourcePath: string, - targetPath: string, - ) => { - if (!remoteTarget || !remoteCommandRunner) return; - const escapedSource = sourcePath.replaceAll("'", "'\\''"); - const escapedTarget = targetPath.replaceAll("'", "'\\''"); - const escapedDirectory = posix.dirname(targetPath).replaceAll("'", "'\\''"); - const result = await remoteCommandRunner.execute({ - command: "sh", - args: [ - "-c", - targetPath === remoteCodexBinary - ? buildRemoteCodexLauncherCommand(sourcePath, targetPath) - : `umask 077; mkdir -p '${escapedDirectory}' && ` + - `ln -sfn '${escapedSource}' '${escapedTarget}'`, - ], - cwd: remoteTarget.remoteCwd, - bypassSession: true, - timeoutMs: 10_000, - }); - if (result.exitCode !== 0 || result.timedOut) { - throw new Error("runner_remote_preinstalled_link_failed"); - } + const remoteNativeArtifacts = remoteTarget && remoteCommandRunner && remoteBinary && remoteRuntimeRoot + ? createRemoteNativeArtifactPreparation({ + target: remoteTarget, runner: remoteCommandRunner, remoteBinary, controllerRunnerBinary, + remoteRuntimeRoot, remoteCodexBinary, runnerRemoteBinaryPath: input.runnerRemoteBinaryPath, + runnerRemoteCodexPath: input.runnerRemoteCodexPath, runnerRemoteCodexNpmSpec: input.runnerRemoteCodexNpmSpec, + model: input.execution.provider.kind === "codex" ? input.execution.provider.model : null, + reuseRetainedRunner: sandboxLeaseAcquisition?.outcome === "resumed", targetPlatformArtifactVerified: remotePiCompanion !== null, + trace: input.trace, onLog: input.onLog, + }) + : null; + const verifyRemoteRunner = async (mode: "dial_wss" | "listen_ws", executable?: string | null) => { + if (remoteNativeArtifacts) await remoteNativeArtifacts.verifyRemoteRunner(mode, executable ?? undefined); }; + const verifyRemoteCodex = async () => { await remoteNativeArtifacts?.verifyRemoteCodex(); }; const prepareRemoteRunner = async ( requiredMode: "dial_wss" | "listen_ws", @@ -11708,234 +11608,7 @@ async function createRunnerdBackendWithinSessionClaim( remotePrepared = true; return; } - // Compatibility metadata does not prove artifact identity. Reuse only the - // exact controller-owned bytes when the controller artifact is available. - const matchesControllerRunnerArtifact = async (executable: string): Promise => { - const expected = createHash("sha256") - .update(readFileSync(controllerRunnerBinary)) - .digest("hex"); - try { - const probe = await remoteCommandRunner.execute({ - command: "sh", - args: [ - "-c", - 'test -x "$1" || exit 1; if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1"; else shasum -a 256 "$1"; fi', - "paperclip-runner-artifact", - executable, - ], - cwd: remoteTarget.remoteCwd, - bypassSession: true, - timeoutMs: 10_000, - }); - return probe.exitCode === 0 && !probe.timedOut && - /^[a-f0-9]{64}\s/.test(probe.stdout) && - probe.stdout.trim().split(/\s+/)[0] === expected; - } catch { - // An unavailable checksum uses the verified staging path. - return false; - } - }; - let runnerArtifactPrepared = false; - if ( - sandboxLeaseAcquisition?.outcome === "resumed" && - existsSync(controllerRunnerBinary) - ) { - runnerArtifactPrepared = await measureNativeRunnerSpan( - input.trace, - "runner.artifact.verify_retained", - () => matchesControllerRunnerArtifact(remoteBinary), - ); - } - const explicitRemoteBinary = input.runnerRemoteBinaryPath?.trim() || null; - if (!runnerArtifactPrepared && mayUsePreinstalledRunnerArtifact(explicitRemoteBinary)) { - const preinstalledRunner = await measureNativeRunnerSpan( - input.trace, - "runner.artifact.discover", - () => discoverPreinstalledExecutable("paperclip-runnerd"), - ); - if (preinstalledRunner) { - try { - await measureNativeRunnerSpan( - input.trace, - "runner.artifact.verify_preinstalled", - () => verifyRemoteRunner(requiredMode, preinstalledRunner), - ); - if (existsSync(controllerRunnerBinary) && - !await matchesControllerRunnerArtifact(preinstalledRunner)) { - throw new Error("runner_remote_preinstalled_artifact_mismatch"); - } - await measureNativeRunnerSpan( - input.trace, - "runner.artifact.link", - () => linkPreinstalledExecutable(preinstalledRunner, remoteBinary), - ); - runnerArtifactPrepared = true; - await input.onLog?.( - "stderr", - "[paperclip-runner] using preinstalled runnerd from the sandbox image\n", - ); - } catch { - runnerArtifactPrepared = false; - } - } - } - if (!runnerArtifactPrepared) { - // Upload the same artifact used for the controller identity. The server - // vendors the runner under vendor/paperclip-runner/bin, so the package - // development fallback cannot locate it in a deployed server. - const sourceBinary = controllerRunnerBinary; - if (!existsSync(sourceBinary)) { - throw new Error("runner_remote_artifact_unavailable"); - } - if (!explicitRemoteBinary && !remotePiCompanion) { - const platform = await remoteCommandRunner.execute({ - command: "sh", - args: ["-c", "uname -s; uname -m"], - cwd: remoteTarget.remoteCwd, - bypassSession: true, - timeoutMs: 10_000, - }); - const [remoteOs = "", remoteArch = ""] = platform.stdout - .trim() - .split(/\r?\n/); - const localOs = - process.platform === "darwin" - ? "Darwin" - : process.platform === "linux" - ? "Linux" - : process.platform; - const localArch = - process.arch === "x64" - ? "x86_64" - : process.arch === "arm64" - ? "aarch64" - : process.arch; - const archMatches = - remoteArch === localArch || - (localArch === "aarch64" && remoteArch === "arm64"); - if ( - platform.exitCode !== 0 || - platform.timedOut || - remoteOs !== localOs || - !archMatches - ) { - throw new Error( - "runner_remote_artifact_platform_mismatch: configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH for the remote OS and architecture", - ); - } - } - await stageRemoteRunnerFile({ - target: remoteTarget, - runner: remoteCommandRunner, - sourcePath: sourceBinary, - targetPath: remoteBinary, - mode: 0o700, - }); - } - await measureNativeRunnerSpan(input.trace, "runner.artifact.verify", () => - verifyRemoteRunner(requiredMode), - ); - if (remoteCodexBinary && explicitRemoteCodex) { - if (!existsSync(explicitRemoteCodex)) { - throw new Error("runner_remote_codex_artifact_unavailable"); - } - await stageRemoteRunnerFile({ - target: remoteTarget, - runner: remoteCommandRunner, - sourcePath: explicitRemoteCodex, - targetPath: remoteCodexBinary, - mode: 0o700, - }); - await verifyRemoteCodex(); - } - if (remoteCodexBinary && remoteCodexNpmSpec) { - let usedPreinstalledCodex = false; - const preinstalledCodex = await measureNativeRunnerSpan( - input.trace, - "harness.artifact.discover", - () => discoverPreinstalledExecutable("codex"), - ); - if (preinstalledCodex) { - try { - await measureNativeRunnerSpan( - input.trace, - "harness.artifact.verify_preinstalled", - () => verifyRemoteCodex(preinstalledCodex), - ); - await measureNativeRunnerSpan( - input.trace, - "harness.artifact.link", - () => - linkPreinstalledExecutable(preinstalledCodex, remoteCodexBinary), - ); - usedPreinstalledCodex = true; - await input.onLog?.( - "stderr", - "[paperclip-runner] using preinstalled Codex from the sandbox image\n", - ); - } catch { - usedPreinstalledCodex = false; - } - } - if (!usedPreinstalledCodex) { - const installRoot = posix.join( - remoteRuntimeRoot!, - "harnesses", - "codex", - ); - const installResult = await remoteCommandRunner.execute({ - command: "npm", - args: [ - "install", - "--prefix", - installRoot, - "--no-audit", - "--no-fund", - remoteCodexNpmSpec, - ], - cwd: remoteTarget.remoteCwd, - bypassSession: true, - timeoutMs: 180_000, - }); - if (installResult.exitCode !== 0 || installResult.timedOut) { - throw new Error("runner_remote_codex_install_failed"); - } - } - await measureNativeRunnerSpan( - input.trace, - "harness.artifact.verify", - () => verifyRemoteCodex(), - ); - } - if (remoteCodexBinary && !explicitRemoteCodex && !remoteCodexNpmSpec) { - const preinstalledCodex = await measureNativeRunnerSpan( - input.trace, - "harness.artifact.discover", - () => discoverPreinstalledExecutable("codex"), - ); - if (!preinstalledCodex) { - throw new Error( - "runner_remote_codex_artifact_unavailable: install codex in the sandbox image or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC", - ); - } - await measureNativeRunnerSpan( - input.trace, - "harness.artifact.verify_preinstalled", - () => verifyRemoteCodex(preinstalledCodex), - ); - await measureNativeRunnerSpan(input.trace, "harness.artifact.link", () => - linkPreinstalledExecutable(preinstalledCodex, remoteCodexBinary), - ); - await measureNativeRunnerSpan( - input.trace, - "harness.artifact.verify", - () => verifyRemoteCodex(), - ); - await input.onLog?.( - "stderr", - "[paperclip-runner] using preinstalled Codex from the sandbox image\n", - ); - } + await remoteNativeArtifacts?.prepare(requiredMode); if ( requiresRemoteProviderPack && expectedProviderPackManifest && @@ -13559,3 +13232,350 @@ async function createRunnerdBackendWithinSessionClaim( }, } satisfies NativeSessionBackend & { bindManagedSession(session: NativeSession): NativeSession }; } + +/** Shared task/setup preparation; setup exercises the same remote artifact contract. */ +export function createRemoteNativeArtifactPreparation(input: { + target: Extract; + runner: CommandManagedRuntimeRunner; + remoteBinary: string; + controllerRunnerBinary: string; + remoteRuntimeRoot: string; + remoteCodexBinary?: string | null; + runnerRemoteBinaryPath?: string | null; + runnerRemoteCodexPath?: string | null; + runnerRemoteCodexNpmSpec?: string | null; + model?: string | null; + reuseRetainedRunner?: boolean; + /** Existing operator-imported Pi companion already verifies its target-platform artifact. */ + targetPlatformArtifactVerified?: boolean; + trace?: NativeRunTrace; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; +}) { + const remoteTarget = input.target; + const remoteCommandRunner = input.runner; + const { remoteBinary, controllerRunnerBinary, remoteRuntimeRoot } = input; + const remoteCodexBinary = input.remoteCodexBinary ?? null; + const explicitRemoteCodex = input.runnerRemoteCodexPath?.trim() || null; + const remoteCodexNpmSpec = input.runnerRemoteCodexNpmSpec?.trim() || null; + if (explicitRemoteCodex && remoteCodexNpmSpec) throw new Error("runner_remote_codex_source_conflict"); + const verifyRemoteRunner = async ( + requiredMode: "dial_wss" | "listen_ws", + executable = remoteBinary, + ) => { + if (!remoteTarget || !remoteCommandRunner || !executable) return; + const metadataResult = await remoteCommandRunner.execute({ + command: executable, + args: ["--build-metadata"], + cwd: remoteTarget.remoteCwd, + bypassSession: true, + timeoutMs: 30_000, + }); + if (metadataResult.exitCode !== 0 || metadataResult.timedOut) { + throw new Error("runner_remote_artifact_verification_failed"); + } + let metadata: Record; + try { + metadata = JSON.parse(metadataResult.stdout) as Record; + } catch (error) { + throw new Error("runner_remote_artifact_metadata_invalid", { + cause: error, + }); + } + assertRemoteRunnerBuildMetadata(metadata, requiredMode); + }; + + const reportedCodexVersions = new Set(); + const verifyRemoteCodex = async (executable = remoteCodexBinary) => { + if (!remoteTarget || !remoteCommandRunner || !executable) return; + const versionResult = await remoteCommandRunner.execute({ + command: executable, + args: ["--version"], + cwd: remoteTarget.remoteCwd, + bypassSession: true, + timeoutMs: 30_000, + }); + if (versionResult.exitCode !== 0 || versionResult.timedOut) { + throw new Error("runner_remote_codex_artifact_verification_failed"); + } + const versionOutput = `${versionResult.stdout}\n${versionResult.stderr}`; + const version = parseCodexCliVersion(versionOutput); + // The install pin describes our tested image, not a CLI admission rule. + // Ordinary installations reach the real app-server/model probe; a version + // difference alone must neither fail setup nor replace a working CLI. + if (version !== REMOTE_PROVIDER_PACK_PINS.codex && !reportedCodexVersions.has(version)) { + reportedCodexVersions.add(version); + await input.onLog?.( + "stderr", + `[paperclip-runner] using installed Codex ${version ?? "unrecognized version"} (image install pin ${REMOTE_PROVIDER_PACK_PINS.codex}); compatibility is checked by the native app-server\n`, + ); + } + }; + + // Image policy: keep one latest stable CLI installation shared by native and + // local adapters. Preferred bin entries must point to that same installation; + // never bake an older global CLI alongside a private runner-only version. + const discoverPreinstalledExecutable = async ( + name: "paperclip-runnerd" | "codex", + ) => { + if (!remoteTarget || !remoteCommandRunner) return null; + return discoverRemoteExecutable(remoteCommandRunner, remoteTarget.remoteCwd, name); + }; + + const linkPreinstalledExecutable = async ( + sourcePath: string, + targetPath: string, + ) => { + if (!remoteTarget || !remoteCommandRunner) return; + const escapedSource = sourcePath.replaceAll("'", "'\\''"); + const escapedTarget = targetPath.replaceAll("'", "'\\''"); + const escapedDirectory = posix.dirname(targetPath).replaceAll("'", "'\\''"); + const result = await remoteCommandRunner.execute({ + command: "sh", + args: [ + "-c", + targetPath === remoteCodexBinary + ? buildRemoteCodexLauncherCommand(sourcePath, targetPath) + : `umask 077; mkdir -p '${escapedDirectory}' && ` + + `ln -sfn '${escapedSource}' '${escapedTarget}'`, + ], + cwd: remoteTarget.remoteCwd, + bypassSession: true, + timeoutMs: 10_000, + }); + if (result.exitCode !== 0 || result.timedOut) { + throw new Error("runner_remote_preinstalled_link_failed"); + } + }; + + const prepare = async (requiredMode: "dial_wss" | "listen_ws") => { + // Compatibility metadata does not prove artifact identity. Reuse only the + // exact controller-owned bytes when the controller artifact is available. + const matchesControllerRunnerArtifact = async (executable: string): Promise => { + const expected = createHash("sha256") + .update(readFileSync(controllerRunnerBinary)) + .digest("hex"); + try { + const probe = await remoteCommandRunner.execute({ + command: "sh", + args: [ + "-c", + 'test -x "$1" || exit 1; if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1"; else shasum -a 256 "$1"; fi', + "paperclip-runner-artifact", + executable, + ], + cwd: remoteTarget.remoteCwd, + bypassSession: true, + timeoutMs: 10_000, + }); + return probe.exitCode === 0 && !probe.timedOut && + /^[a-f0-9]{64}\s/.test(probe.stdout) && + probe.stdout.trim().split(/\s+/)[0] === expected; + } catch { + // An unavailable checksum uses the verified staging path. + return false; + } + }; + let runnerArtifactPrepared = false; + if ( + input.reuseRetainedRunner === true && + existsSync(controllerRunnerBinary) + ) { + runnerArtifactPrepared = await measureNativeRunnerSpan( + input.trace, + "runner.artifact.verify_retained", + () => matchesControllerRunnerArtifact(remoteBinary), + ); + } + const explicitRemoteBinary = input.runnerRemoteBinaryPath?.trim() || null; + if (!runnerArtifactPrepared && mayUsePreinstalledRunnerArtifact(explicitRemoteBinary)) { + const preinstalledRunner = await measureNativeRunnerSpan( + input.trace, + "runner.artifact.discover", + () => discoverPreinstalledExecutable("paperclip-runnerd"), + ); + if (preinstalledRunner) { + try { + await measureNativeRunnerSpan( + input.trace, + "runner.artifact.verify_preinstalled", + () => verifyRemoteRunner(requiredMode, preinstalledRunner), + ); + if (existsSync(controllerRunnerBinary) && + !await matchesControllerRunnerArtifact(preinstalledRunner)) { + throw new Error("runner_remote_preinstalled_artifact_mismatch"); + } + await measureNativeRunnerSpan( + input.trace, + "runner.artifact.link", + () => linkPreinstalledExecutable(preinstalledRunner, remoteBinary), + ); + runnerArtifactPrepared = true; + await input.onLog?.( + "stderr", + "[paperclip-runner] using preinstalled runnerd from the sandbox image\n", + ); + } catch { + runnerArtifactPrepared = false; + } + } + } + if (!runnerArtifactPrepared) { + // Upload the same artifact used for the controller identity. The server + // vendors the runner under vendor/paperclip-runner/bin, so the package + // development fallback cannot locate it in a deployed server. + const sourceBinary = controllerRunnerBinary; + if (!existsSync(sourceBinary)) { + throw new Error("runner_remote_artifact_unavailable"); + } + if (!explicitRemoteBinary && !input.targetPlatformArtifactVerified) { + const platform = await remoteCommandRunner.execute({ + command: "sh", + args: ["-c", "uname -s; uname -m"], + cwd: remoteTarget.remoteCwd, + bypassSession: true, + timeoutMs: 10_000, + }); + const [remoteOs = "", remoteArch = ""] = platform.stdout + .trim() + .split(/\r?\n/); + const localOs = + process.platform === "darwin" + ? "Darwin" + : process.platform === "linux" + ? "Linux" + : process.platform; + const localArch = + process.arch === "x64" + ? "x86_64" + : process.arch === "arm64" + ? "aarch64" + : process.arch; + const archMatches = + remoteArch === localArch || + (localArch === "aarch64" && remoteArch === "arm64"); + if ( + platform.exitCode !== 0 || + platform.timedOut || + remoteOs !== localOs || + !archMatches + ) { + throw new Error( + "runner_remote_artifact_platform_mismatch: configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH for the remote OS and architecture", + ); + } + } + await stageRemoteRunnerFile({ + target: remoteTarget, + runner: remoteCommandRunner, + sourcePath: sourceBinary, + targetPath: remoteBinary, + mode: 0o700, + }); + } + await measureNativeRunnerSpan(input.trace, "runner.artifact.verify", () => + verifyRemoteRunner(requiredMode), + ); + if (remoteCodexBinary && explicitRemoteCodex) { + if (!existsSync(explicitRemoteCodex)) { + throw new Error("runner_remote_codex_artifact_unavailable"); + } + await stageRemoteRunnerFile({ + target: remoteTarget, + runner: remoteCommandRunner, + sourcePath: explicitRemoteCodex, + targetPath: remoteCodexBinary, + mode: 0o700, + }); + await verifyRemoteCodex(); + } + if (remoteCodexBinary && remoteCodexNpmSpec) { + let usedPreinstalledCodex = false; + const preinstalledCodex = await measureNativeRunnerSpan( + input.trace, + "harness.artifact.discover", + () => discoverPreinstalledExecutable("codex"), + ); + if (preinstalledCodex) { + try { + await measureNativeRunnerSpan( + input.trace, + "harness.artifact.verify_preinstalled", + () => verifyRemoteCodex(preinstalledCodex), + ); + await measureNativeRunnerSpan( + input.trace, + "harness.artifact.link", + () => + linkPreinstalledExecutable(preinstalledCodex, remoteCodexBinary), + ); + usedPreinstalledCodex = true; + await input.onLog?.( + "stderr", + "[paperclip-runner] using preinstalled Codex from the sandbox image\n", + ); + } catch { + usedPreinstalledCodex = false; + } + } + if (!usedPreinstalledCodex) { + const installRoot = posix.join( + remoteRuntimeRoot!, + "harnesses", + "codex", + ); + const installResult = await remoteCommandRunner.execute({ + command: "npm", + args: [ + "install", + "--prefix", + installRoot, + "--no-audit", + "--no-fund", + remoteCodexNpmSpec, + ], + cwd: remoteTarget.remoteCwd, + bypassSession: true, + timeoutMs: 180_000, + }); + if (installResult.exitCode !== 0 || installResult.timedOut) { + throw new Error("runner_remote_codex_install_failed"); + } + } + await measureNativeRunnerSpan( + input.trace, + "harness.artifact.verify", + () => verifyRemoteCodex(), + ); + } + if (remoteCodexBinary && !explicitRemoteCodex && !remoteCodexNpmSpec) { + const preinstalledCodex = await measureNativeRunnerSpan( + input.trace, + "harness.artifact.discover", + () => discoverPreinstalledExecutable("codex"), + ); + if (!preinstalledCodex) { + throw new Error( + "runner_remote_codex_artifact_unavailable: install codex in the sandbox image or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC", + ); + } + await measureNativeRunnerSpan( + input.trace, + "harness.artifact.verify_preinstalled", + () => verifyRemoteCodex(preinstalledCodex), + ); + await measureNativeRunnerSpan(input.trace, "harness.artifact.link", () => + linkPreinstalledExecutable(preinstalledCodex, remoteCodexBinary), + ); + await measureNativeRunnerSpan( + input.trace, + "harness.artifact.verify", + () => verifyRemoteCodex(), + ); + await input.onLog?.( + "stderr", + "[paperclip-runner] using preinstalled Codex from the sandbox image\n", + ); + } + }; + return { prepare, verifyRemoteRunner, verifyRemoteCodex }; +} diff --git a/server/src/services/native-runtime/runtime-mode.test.ts b/server/src/services/native-runtime/runtime-mode.test.ts index 11eb8d808a..6bcf2e4bce 100644 --- a/server/src/services/native-runtime/runtime-mode.test.ts +++ b/server/src/services/native-runtime/runtime-mode.test.ts @@ -43,10 +43,18 @@ 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("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 +195,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 +469,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 5eaf04ed5c..d785c4c1dc 100644 --- a/server/src/services/native-runtime/runtime-mode.ts +++ b/server/src/services/native-runtime/runtime-mode.ts @@ -126,10 +126,11 @@ export function resolveNativeRuntimeMode(input: { } throw error; } - // Dot has its own rollout; enabling it does not opt in other Runner providers. + // Native Codex is a supported default. Other providers retain their rollout; + // enabling Dot does not opt in any of those 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 (!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 0000000000..0773717a49 --- /dev/null +++ b/server/src/services/native-runtime/setup-readiness.test.ts @@ -0,0 +1,219 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, posix } from "node:path"; +const { execute, nativeProbe, selectedRunner, sshExecute, sshRunner, nativeArtifacts, remoteLauncher, registerPrp, ingressConnect } = vi.hoisted(() => ({ execute: vi.fn(), nativeProbe: vi.fn(), selectedRunner: vi.fn(), sshExecute: vi.fn(), sshRunner: vi.fn(), nativeArtifacts: vi.fn(), remoteLauncher: vi.fn(), registerPrp: vi.fn(), ingressConnect: vi.fn() })); +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 })); +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: {}, + 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(); + 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"); + vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH", ""); + vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_BINARY_PATH", ""); + }); + afterEach(async () => { + vi.unstubAllEnvs(); + for (const [input] of nativeProbe.mock.calls) if (input.runtimeDirectory) await rm(input.runtimeDirectory, { 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: { env: { [key]: "bound-account" }, 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(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.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"); + }); + 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("uses task staging and the company-scoped native SSH transport without a provider pack", async () => { + 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: { env: { OPENAI_API_KEY: "company-account" }, 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: "/fixture/bin/paperclip-runnerd", codexCommand: expect.stringMatching(/\/bin\/codex$/), runnerFilesystemRoot: expect.stringMatching(/\/filesystem$/) }); + expect(nativeArtifacts).toHaveBeenCalledWith(expect.objectContaining({ controllerRunnerBinary: "/fixture/bin/paperclip-runnerd", model: "gpt-6.1-sol" })); + 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.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, "auth.json"), auth("qa-account", "old", 120_000), { mode: 0o600 }); + 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(home); + 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).toMatchObject({ status: "fail", checks: [{ code: `codex_hello_probe_${code}`, level: "error" }] }); + expect(JSON.stringify(result)).not.toContain("bound-account"); + 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("uses the same preparation and controller PRP for a sandbox, without a guest provider pack", async () => { + 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(nativeArtifacts).toHaveBeenCalledWith(expect.objectContaining({ target, runner: target.runner })); + expect(nativeArtifacts.mock.results[0].value.prepare).toHaveBeenCalledWith("dial_wss"); + 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(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 0000000000..dab78465fa --- /dev/null +++ b/server/src/services/native-runtime/setup-readiness.ts @@ -0,0 +1,198 @@ +import { mkdtemp, rm, stat } 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 { probeNativeRunnerEnvironment, resolveSourceCodexHome } from "../../vendor/paperclip-runner/index.js"; +import { resolvePaperclipRunnerBinary } 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); +} + +/** Remote setup shares task artifacts, PRP, isolated launch home, and process ownership. */ +async function probeRemoteNativeCodex(options: { + context: AdapterEnvironmentTestContext; + model: string | null; + reasoningEffort?: string; + environment: Record; + sourceCodexHome: 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}`; + // Eligibility is checked before writing to the selected host, using the + // same server-owned public URL and CA configuration as ordinary tasks. + const ingress = target.transport === "sandbox" && target.effectiveCapabilities?.runnerWebSocketIngress === true; + // Outbound eligibility is read-only; acquire provider ingress only after the + // exact daemon has been prepared and its listener capability verified. + 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 = explicitRunner || resolvePaperclipRunnerBinary(); + const artifacts = createRemoteNativeArtifactPreparation({ target, runner, remoteBinary, controllerRunnerBinary, remoteRuntimeRoot: remoteRoot, remoteCodexBinary, + 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 mkdtemp(join(tmpdir(), "paperclip-native-setup-")); + let remoteDirectoryCreated = false; + let remoteCaBundlePath: string | undefined; + let signalSpawned!: () => void; + const spawned = new Promise(resolve => { signalSpawned = resolve; }); + return probeNativeRunnerEnvironment({ runtimeDirectory: runtimeDirectory, provider: "codex", model: options.model, reasoningEffort: options.reasoningEffort, + workingDirectory: remoteWorkspace, environment: options.environment, timeoutMs: options.timeoutMs, + transportOptions: { + runnerBinary: controllerRunnerBinary, codexCommand: remoteCodexBinary, sourceCodexHome: options.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 => { + // Claim a private per-probe root. Never merge credentials or state into + // another session's directory on the remote host. + 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 () => { + // The normal transport has already materialized the bound account and + // its managed config locally. Publish only those two fresh files. + 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.context.managedAiCredentialHome ? { 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: join(options.context.managedAiCredentialHome!, "auth.json"), log: () => {}, readSandboxAuth: async () => { + // Reuse the task's shell/base64 handoff without requiring a guest JS + // runtime. Bound and validate the private owner-only credential file. + 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 }); + }, + }); +} + +/** Codex readiness uses the same daemon, bound credentials and cleanup as task execution. */ +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; + try { + // Match task execution's closed host projection before the probe replaces + // HOME. API keys never enter this projection from the ambient process. + if (provider === "codex") { + const { buildNativeProviderEnvironment } = await import("./native-session-executor.js"); + sourceCodexHome = context.managedAiCredentialHome ?? resolveSourceCodexHome(buildNativeProviderEnvironment(environment)); + } + let receipt: Awaited>; + if (context.executionTarget?.kind === "remote") { + receipt = await probeRemoteNativeCodex({ context, model, environment, sourceCodexHome: sourceCodexHome ?? "", timeoutMs, + ...(typeof effort === "string" && effort ? { reasoningEffort: effort } : {}) }); + } else { + runtimeDirectory = await mkdtemp(join(tmpdir(), "paperclip-native-setup-")); + receipt = await probeNativeRunnerEnvironment({ runtimeDirectory, provider, model, environment, timeoutMs, + ...(provider === "codex" && typeof effort === "string" && effort ? { reasoningEffort: effort } : {}), + onCleanupConfirmed: async () => { await rm(runtimeDirectory!, { recursive: true, force: true }); }, + transportOptions: { runnerBinary: resolvePaperclipRunnerBinary(), sourceCodexHome: sourceCodexHome ?? "" }, + ...(context.managedAiCredentialHome && provider === "codex" ? { onCodexCredentialRefresh: async (filename: string) => { + await copyBackCodexAuth({ hostAuthPath: join(context.managedAiCredentialHome!, "auth.json"), 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); + 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." }] }; + } +} diff --git a/server/src/services/plugin-managed-agents.ts b/server/src/services/plugin-managed-agents.ts index 4c4e2ee1ae..76500f19df 100644 --- a/server/src/services/plugin-managed-agents.ts +++ b/server/src/services/plugin-managed-agents.ts @@ -14,9 +14,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"; @@ -354,12 +355,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); } @@ -656,11 +657,18 @@ export function pluginManagedAgentService( ? reconciled.agent.metadata : {}; const adapterType = await resolveManagedAdapterType(companyId, declaration); + const defaults = declarationPatch(declaration, { adapterType }); + // A Codex reset keeps its selected runner; other harness reset behavior is unchanged. + const execution = await resolveNewAgentRunnerForCompany(db, companyId, { + ...defaults, + defaultEnvironmentId: reconciled.agent.defaultEnvironmentId, + runner: adapterType === "codex_local" && agentHarnessType(reconciled.agent.adapterType, reconciled.agent.adapterConfig) === adapterType + ? 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]; @@ -668,6 +676,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/vendor/paperclip-runner/index.ts b/server/src/vendor/paperclip-runner/index.ts index a7c81b2b03..c00567c104 100644 --- a/server/src/vendor/paperclip-runner/index.ts +++ b/server/src/vendor/paperclip-runner/index.ts @@ -158,3 +158,5 @@ export const CONFIGURED_ENVIRONMENT_KEYS = runner.CONFIGURED_ENVIRONMENT_KEYS; export const GENERATED_RUNTIME_ENVIRONMENT_KEYS = runner.GENERATED_RUNTIME_ENVIRONMENT_KEYS; export const configuredEnvironmentProjection = runner.configuredEnvironmentProjection; export const configuredEnvironment = runner.configuredEnvironment; + +export const probeNativeRunnerEnvironment = runner.probeNativeRunnerEnvironment; diff --git a/skills/paperclip-create-agent/SKILL.md b/skills/paperclip-create-agent/SKILL.md index fa9e7b3581..0787e1383a 100644 --- a/skills/paperclip-create-agent/SKILL.md +++ b/skills/paperclip-create-agent/SKILL.md @@ -186,3 +186,18 @@ 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. diff --git a/skills/paperclip-create-agent/references/api-reference.md b/skills/paperclip-create-agent/references/api-reference.md index 422e0be98c..e77ec696df 100644 --- a/skills/paperclip-create-agent/references/api-reference.md +++ b/skills/paperclip-create-agent/references/api-reference.md @@ -108,3 +108,15 @@ For hire approvals: - All actions are logged in activity for auditability. - Use markdown in issue/approval comments and include links to approval, agent, and source issue. - After approval resolution, requester may be woken with `PAPERCLIP_APPROVAL_ID` and should reconcile linked issues. + +## Codex execution choice + +Hire requests and direct creation can include `runner: "auto" | "paperclip" | +"legacy"`. Omitted is automatic for new agents: `adapterType: "codex_local"` +selects Paperclip Runner on supported targets; other harnesses keep their +existing execution. Explicit `legacy` preserves Codex CLI execution. The server +returns the resolved adapter and configuration, which are frozen for approval. +Use those returned values for later edits; unrelated updates do not upgrade bots. + +CLI equivalents: `paperclipai agent create --runner legacy --payload-json ...` +and `paperclipai agent hire --runner legacy --payload-json ...`. diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 596e0648e2..61094b5e96 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -898,6 +898,13 @@ POST /api/companies/{companyId}/agent-hires } ``` +New hires can set `runner` to `auto`, `paperclip`, or `legacy`. Omitted means +automatic: the Codex harness (`codex_local`) uses Paperclip Runner on supported +targets, while other harness defaults stay unchanged. Use `legacy` for custom +Codex CLI settings. The returned execution configuration is frozen for approval; +existing agents do not change runners after unrelated edits. Caller inheritance +continues to use the caller's validated runtime and credential restrictions. + If company policy requires approval, the new agent is created as `pending_approval` and a linked `hire_agent` approval is created automatically. Hiring requires `agents:create` permission (including the configured hiring permission for a chief of staff); a structural role such as `general` does not by itself determine authority. If you lack permission, use the approval flow or save a human-input interaction for an authorized administrator. Do not bypass a permission denial. diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index a87cd9d695..b6bf07ccb6 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, diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index 7ea255c204..821d442561 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -99,6 +99,9 @@ function commonAgent( capabilities: "Completes deterministic standard, planning, and ask-mode runner acceptance tasks.", adapterType, + // These are explicit legacy regression controls. Ordinary Codex creation + // defaults are exercised through the production onboarding wizard below. + ...(adapterType === "codex_local" ? { runner: "legacy" as const } : {}), adapterConfig, defaultEnvironmentId: input.environmentId, budgetMonthlyCents: 0, @@ -1427,7 +1430,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", diff --git a/tests/runner-e2e/first-task-fixtures.ts b/tests/runner-e2e/first-task-fixtures.ts index f0a6ecd2aa..4cc62e9cc2 100644 --- a/tests/runner-e2e/first-task-fixtures.ts +++ b/tests/runner-e2e/first-task-fixtures.ts @@ -3,10 +3,10 @@ import type { RunnerApi } from "./api.js"; import type { LiveFixtureValues } from "./live-fixtures.js"; import type { CredentialName, MatrixExecution } from "./types.js"; -/** Provision only credentials for the UI-created company. The production wizard +/** Provision credentials and a bounded budget for the UI-created company. The production wizard * remains the sole creator/configurer of its first agent and onboarding task. */ export async function provisionFirstTaskFixtures(input: { - api: Pick; + api: Pick; execution: MatrixExecution; nonce: string; company: LiveFixtureValues["company"]; @@ -30,6 +30,7 @@ export async function provisionFirstTaskFixtures(input: { const credential = execution.profile.credential; const value = credentials[credential]; if (!value) throw new Error(`Missing credential ${credential}`); + await api.patch(`/api/companies/${company.id}`, { budgetMonthlyCents: 500 }); const environments = await api.get>( `/api/companies/${company.id}/environments?driver=local`, ); @@ -59,8 +60,9 @@ export async function provisionFirstTaskFixtures(input: { }; } -/** Reuse the qualified runtime configuration only, never the QA persona from - * buildAgent. Native onboarding is not offered by the production wizard yet. */ +/** Reuse the qualified runtime configuration for explicit regression switches, + * never the QA persona from buildAgent. Codex default qualification does not + * call this helper; its wizard must persist the native selection directly. */ 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 5a25b8f7e1..3185a395d0 100644 --- a/tests/runner-e2e/first-task-flow.ts +++ b/tests/runner-e2e/first-task-flow.ts @@ -93,6 +93,11 @@ 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(); @@ -110,16 +115,22 @@ export async function setupFirstTaskFixtures(input: { 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") { + // Claude remains an explicit native regression path in this Codex-only + // rollout. Codex is never switched after onboarding: the saved default is + // the execution under test. + if (execution.profile.id === "runner-acpx-claude") { const runtimePatch = firstTaskNativeRuntimePatch( execution, fixtures, diff --git a/tests/runner-e2e/first-task.test.ts b/tests/runner-e2e/first-task.test.ts index d469f3a281..dd15fa65e4 100644 --- a/tests/runner-e2e/first-task.test.ts +++ b/tests/runner-e2e/first-task.test.ts @@ -695,9 +695,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 +711,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 +727,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, diff --git a/tests/runner-e2e/runner.spec.ts b/tests/runner-e2e/runner.spec.ts index 980c5ebff2..4faebe3556 100644 --- a/tests/runner-e2e/runner.spec.ts +++ b/tests/runner-e2e/runner.spec.ts @@ -870,10 +870,12 @@ for (const execution of executions) { const receipt = verifyStockHarnessPreflight(process.env[STOCK_PREFLIGHT_ENV]); await writeSanitizedJson(snapshotsDir, "stock-harness-preflight.json", receipt, secrets); } + // Codex default journeys must work with the experimental gate disabled. + 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 } : {}), @@ -882,7 +884,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); diff --git a/tests/storybook-visual/storybook-visual.spec.ts b/tests/storybook-visual/storybook-visual.spec.ts index eb1ec09eca..219e658072 100644 --- a/tests/storybook-visual/storybook-visual.spec.ts +++ b/tests/storybook-visual/storybook-visual.spec.ts @@ -77,6 +77,19 @@ 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) { + // These production journeys fetch accounts and then exercise controls in + // play. sb-show-main precedes both; a stable loading frame proves neither. + 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("--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(); + } await page.evaluate(() => document.fonts.ready.then(() => undefined)); const settleMs = EXTRA_SETTLE_MS[storyId]; if (settleMs) await page.waitForTimeout(settleMs); @@ -86,7 +99,10 @@ 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]; + // The fixture avatar's SVG snooze marks animate independently of reduced + // motion. Exclude the unchanged avatar; keep harness branding and every + // setup control in these production creation captures. + 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 0b34460347..266894e603 100644 --- a/ui/src/adapters/adapter-display-registry.test.ts +++ b/ui/src/adapters/adapter-display-registry.test.ts @@ -1,8 +1,15 @@ 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("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 d9fac5385c..560bfc18f8 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, @@ -207,6 +208,13 @@ export function getAdapterDisplay(type: string): AdapterDisplayInfo { }; } +/** Codex branding follows its harness; diagnostics retain the saved adapter. */ +export function agentDisplayAdapterType(agent: { adapterType: string; adapterConfig: Record }): string { + if (agent.adapterType !== "paperclip_runner") return agent.adapterType; + if (agent.adapterConfig.provider === "openai_dot") return "openai_dot"; + 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 eb5be53b44..14ff83245b 100644 --- a/ui/src/adapters/codex-local/config-fields.test.tsx +++ b/ui/src/adapters/codex-local/config-fields.test.tsx @@ -51,6 +51,32 @@ async function renderRunner(config: Record, expand?: string, in } describe("Paperclip Runner Codex configuration", () => { + 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("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" }); diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index 6147d5f89c..2880d293cf 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -1,4 +1,4 @@ -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 { DotRunnerConnection } from "../../components/DotRunnerConnection"; import { configFieldsForSection } from "../config-sections"; @@ -27,7 +27,7 @@ import { resolvePaperclipRunnerIdleTimeoutMs, resolvePaperclipRunnerPermissionMode, type PaperclipRunnerPermissionMode, - type PaperclipRunnerProvider, + type PaperclipRunnerPermissionCapability, } from "@paperclipai/adapter-utils"; const inputClass = @@ -62,6 +62,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 @@ -74,13 +75,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 @@ -106,7 +105,7 @@ export function CodexLocalConfigFields({ const runnerPermissionMode = runnerManaged && runnerPermissionCapability.configurable ? resolvePaperclipRunnerPermissionMode( - runnerProvider, + isPaperclipRunnerProvider(runnerProvider) ? runnerProvider : "codex", configuredRunnerPermissionMode, ) : runnerPermissionCapability.defaultMode; @@ -176,14 +175,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" && ( + {runnerManaged && !hideRunnerHarness && runnerProvider !== "openai_dot" && ( + {!isPaperclipRunnerProvider(runnerProvider) && {runnerProvider} (unknown provider)} {runnerHarnessOptions.map((option) => ( @@ -469,7 +470,7 @@ export function CodexLocalConfigFields({ } onValueChange={(selectedMode) => { const value = resolvePaperclipRunnerPermissionMode( - runnerProvider, + isPaperclipRunnerProvider(runnerProvider) ? runnerProvider : "codex", selectedMode, ) as PaperclipRunnerPermissionMode; if (isCreate) { diff --git a/ui/src/adapters/codex-local/index.ts b/ui/src/adapters/codex-local/index.ts index 0d9dd1a3dd..29e4d15e7d 100644 --- a/ui/src/adapters/codex-local/index.ts +++ b/ui/src/adapters/codex-local/index.ts @@ -8,5 +8,15 @@ export const codexLocalUIAdapter: UIAdapterModule = { label: "Codex", parseStdoutLine: parseCodexStdoutLine, ConfigFields: CodexLocalConfigFields, - buildAdapterConfig: buildCodexLocalConfig, + buildAdapterConfig: (values) => { + const config = buildCodexLocalConfig(values); + // Legacy permission flags cannot describe the native provider policy. + 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 } : {}), + }; + }, }; diff --git a/ui/src/adapters/types.ts b/ui/src/adapters/types.ts index d45d7486de..e2e87abe6f 100644 --- a/ui/src/adapters/types.ts +++ b/ui/src/adapters/types.ts @@ -53,6 +53,8 @@ export interface AdapterConfigFieldsProps { managedSandboxOnly?: boolean; /** Show Dot for new selections only when its experimental prerequisites are enabled. */ openAiDotEnabled?: boolean; + /** Codex's harness identity and runner are selected by the shared form. */ + hideRunnerHarness?: boolean; } export interface UIAdapterModule extends TranscriptParserSource { diff --git a/ui/src/api/adapters.ts b/ui/src/api/adapters.ts index 0641b1ed7d..ad419dbb75 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"; diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index b3dd11025c..3ca51c9613 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -252,6 +252,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 39ec7aa4bd..07a61aee8e 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 e649c736a9..318edd08cf 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -70,6 +70,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, })); @@ -264,6 +268,7 @@ async function renderForm( agentOverrides: Partial = {}, options: { showAdapterTestEnvironmentButton?: boolean; + showAdapterTypeField?: boolean; content?: "configuration" | "secrets"; environmentVariablesPlacement?: "configuration" | "secrets"; hideInlineSave?: boolean; @@ -301,7 +306,7 @@ async function renderForm( onDirtyChange={options.onDirtyChange} onSaveActionChange={options.onSaveActionChange} onCancelActionChange={options.onCancelActionChange} - showAdapterTypeField={false} + showAdapterTypeField={options.showAdapterTypeField ?? false} showAdapterTestEnvironmentButton={options.showAdapterTestEnvironmentButton ?? false} /> @@ -652,6 +657,29 @@ async function flushUntil(check: () => boolean, timeoutMs = 4000) { describe("AgentConfigForm environment selector", () => { let roots: Root[] = []; + 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("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" }); + }); + beforeEach(() => { mockAgentsApi.adapterModels.mockResolvedValue([]); mockAgentsApi.detectModel.mockResolvedValue(null); diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 9ca3caa200..eb10561c68 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,17 @@ 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 runnerDiscovery = useQuery({ queryKey: queryKeys.adapters.all, queryFn: adaptersApi.list }); + 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 +628,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 @@ -904,8 +914,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 +924,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 +965,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 +991,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const renderAdapterFields = (section: AdapterConfigSection) => ( <> {adapterType === "claude_local" && } - + ); // Popover states @@ -1088,9 +1100,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 +1149,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { resetTestEnvironmentRef.current(); setTestActionError(null); clearClaudeLoginClaimRef.current(); - }, [adapterType, effectiveLoginEnvironmentId]); + }, [adapterType, runner, effectiveLoginEnvironmentId]); // 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 +1160,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 +1255,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 +1283,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 +1293,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } const thinkingEffortKey = - adapterType === "codex_local" + isCodexHarness ? "modelReasoningEffort" : adapterType === "cursor" ? "mode" @@ -1290,7 +1302,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 +1323,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 +1336,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; @@ -1610,9 +1622,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{showAdapterTypeField && ( - + { @@ -1621,7 +1633,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { 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 +1659,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { setOverlay((prev) => ({ ...prev, adapterType: t, + runner: t === "codex_local" ? "auto" : undefined, adapterConfig: { model: t === "gemini_local" @@ -1665,12 +1678,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 +1715,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 +1824,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 +1888,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) { onToggle={() => setConfigurationAdvancedOpen(!configurationAdvancedOpen)} >
+ {isCodexHarness && 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 @@ -3685,71 +3700,22 @@ export function AdapterTypeDropdown({ disabledTypes: Set; openAiDotEnabled?: 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" && !disabledTypes.has(item.value)), + [disabledTypes, openAiDotEnabled]); + return + + {adapterLabels[value] ?? getAdapterLabel(value)} + {selectedDisplay.experimental && } + } + options={adapterList.map(item => ({ value: item.value, disabled: item.comingSoon, + label: {item.label}{item.experimental && }, + suffix: item.comingSoon ? Coming soon : undefined, + }))} />; } function ExperimentalBadge() { @@ -3917,29 +3883,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 +4103,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 a2cbd4dd08..6fc60c8731 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 0000000000..08e452a536 --- /dev/null +++ b/ui/src/components/CodexRunnerSelect.tsx @@ -0,0 +1,33 @@ +import type { AgentRunner, AgentRunnerChoice } from "@paperclipai/shared"; +import { Field } from "./agent-config-primitives"; +import { SelectPopover } from "./ui/select"; + +/** The override is request-level. Automatic selection is resolved by the server. */ +export function CodexRunnerSelect({ + value, + onChange, + defaultRunner = "legacy", + supportedRunners = ["legacy"], + disabled, +}: { + value: AgentRunnerChoice; + onChange: (value: AgentRunnerChoice) => void; + defaultRunner?: AgentRunner; + supportedRunners?: readonly AgentRunner[]; + disabled?: boolean; +}) { + 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" }, + ]} + /> + + ); +} diff --git a/ui/src/components/ConfigureBuiltInAgentModal.tsx b/ui/src/components/ConfigureBuiltInAgentModal.tsx index 0a8c01479f..3722b10338 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,11 @@ 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 adapters = useQuery({ queryKey: queryKeys.adapters.all, queryFn: adaptersApi.list, 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 @@ -128,7 +134,8 @@ export function ConfigureBuiltInAgentModal({ const adapterConfig: Record = {}; if (model.trim()) adapterConfig.model = model.trim(); const result = await builtInAgentsApi.provision(companyId, definition.key, { - adapterType, + adapterType: state.agent && agentHarnessType(state.agent.adapterType, state.agent.adapterConfig) === adapterType ? state.agent.adapterType : adapterType, + ...(adapterType === "codex_local" && runner !== undefined ? { runner } : {}), adapterConfig, ...(budgetMonthlyCents !== undefined ? { budgetMonthlyCents } : {}), }); @@ -163,17 +170,25 @@ export function ConfigureBuiltInAgentModal({ board. - + { setAdapterType(next); setModel(""); + setRunner(undefined); }} disabledTypes={disabledTypes} /> + {adapterType === "codex_local" &&
+ Advanced + +
} + {modelRequired && ( // ModelDropdown supplies its own "Model" Field label + hint. { expect(shownModelIds()).toEqual(["openai/gpt-6-astra", "openai/gpt-6-sol"]); }); + + it("moves through model options with the same keyboard controls as the harness picker", () => { + renderOpenDropdown(curated, { preserveOrder: true }); + const search = document.body.querySelector("input")!; + act(() => search.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }))); + expect(document.activeElement?.textContent).toBe(curated[0].label); + act(() => document.activeElement?.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }))); + expect(document.activeElement?.textContent).toBe(curated.at(-1)?.label); + act(() => document.activeElement?.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }))); + expect(document.activeElement?.textContent).toBe(curated[0].label); + }); }); diff --git a/ui/src/components/NewAgentDialog.test.tsx b/ui/src/components/NewAgentDialog.test.tsx index 224174ce68..45365d9192 100644 --- a/ui/src/components/NewAgentDialog.test.tsx +++ b/ui/src/components/NewAgentDialog.test.tsx @@ -101,77 +101,11 @@ it("requires a name and an enabled adapter before navigating", async () => { 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"); -}); - -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 })), - ); - }); - 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 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(document.querySelector('input[value="paperclip_runner"]')).toBeNull(); + expect(document.querySelectorAll('input[value="codex_local"]')).toHaveLength(1); + expect(document.querySelector("select")).toBeNull(); }); 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 ba56405627..0ccb83066d 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.tsx b/ui/src/components/OnboardingWizard.tsx index 8749e63cf4..0998082037 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,15 @@ 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. +// People select a harness. The server resolves the runner for a new hire. 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 +597,11 @@ 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"); + const runnerDiscovery = useQuery({ queryKey: queryKeys.adapters.all, queryFn: adaptersApi.list, enabled: effectiveOnboardingOpen }); /** * Whether a model source has been chosen, as opposed to which one * `adapterType` happens to hold. @@ -621,11 +627,10 @@ 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. + // Recover the harness and runner separately. Host command overrides from an + // old native draft cannot describe the provider; portable model/cwd survive. 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) ?? "", @@ -904,7 +909,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 +918,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, @@ -1541,7 +1546,7 @@ function OnboardingWizardInner({ setAdapterEnvResult(null); adapterEnvResultAppliedStoredLoginRef.current = false; setAdapterEnvError(null); - }, [step, adapterType, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id]); + }, [step, adapterType, runner, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id]); /** * Leaving the step puts the row back to a question. @@ -1628,6 +1633,7 @@ function OnboardingWizardInner({ setAgentAppearance(randomAgentAppearance()); setAgentRole(DEFAULT_AGENT_ROLE); setAdapterType("claude_local"); + setRunner("auto"); setModel(""); setCommand(""); setArgs(""); @@ -1821,6 +1827,7 @@ function OnboardingWizardInner({ const config = adapter.buildAdapterConfig({ ...defaultCreateValues, adapterType, + runner, model: adapterType === "gemini_local" ? model || DEFAULT_GEMINI_LOCAL_MODEL @@ -1838,7 +1845,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) { @@ -1952,6 +1959,7 @@ function OnboardingWizardInner({ adapterType, { adapterConfig: adapterConfigOverride ?? buildAdapterConfig(), + ...(adapterType === "codex_local" && runner !== "auto" ? { runner } : {}), ...(managedBindingForStep() ? { aiConnection: managedBindingForStep() } : {}), environmentId, } @@ -2018,13 +2026,11 @@ 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. + // Onboarding submits a harness. The server resolves its execution choice. 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) { @@ -2178,7 +2184,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 +2208,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 +2699,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 +2904,14 @@ function OnboardingWizardInner({ ) : null} + {sourceSelected && adapterType === "codex_local" &&
+ Advanced + 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/new-agent/AgentBasicsDialog.tsx b/ui/src/components/new-agent/AgentBasicsDialog.tsx index d6ddb1fc6f..b3d2455688 100644 --- a/ui/src/components/new-agent/AgentBasicsDialog.tsx +++ b/ui/src/components/new-agent/AgentBasicsDialog.tsx @@ -57,7 +57,7 @@ export function AgentBasicsDialog({ }); const [name, setName] = useState(""); const [adapterType, setAdapterType] = useState(initialAdapter); - const [runnerProvider, setRunnerProvider] = useState("codex"); + const runnerProvider = "codex"; const [step, setStep] = useState<"name" | "adapter">("name"); const { data: adapters, @@ -72,6 +72,7 @@ export function AgentBasicsDialog({ (adapter) => adapter.loaded && !adapter.disabled && + adapter.type !== "paperclip_runner" && isNewAgentAdapterAllowed(adapter.type, { cloud, nativeRunnerEnabled: experimental.data?.enableNativeRunner === true, @@ -211,23 +212,6 @@ export function AgentBasicsDialog({ ); })}
- {validAdapter && adapterType === "paperclip_runner" && ( - - )} )} diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx index c9902d8cad..d7af40755e 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,11 @@ export function NewAgentSetup() { ); return ( @@ -99,18 +103,21 @@ function Setup({ name, adapterType, runnerProvider, + initialRunner, createdAgentId, }: { companyId: string; name: string; adapterType: string; runnerProvider: string; + initialRunner: string; 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 isRunner = adapterType === "paperclip_runner"; const isDot = isRunner && runnerProvider === "openai_dot"; const brandType = isDot ? "openai_dot" : isRunner @@ -120,7 +127,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" @@ -347,10 +354,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 ? { @@ -449,6 +457,7 @@ function Setup({ companyId, adapterType, providerAdapter: brandType, + ...(adapterType === "codex_local" && runner !== "auto" ? { runner } : {}), adapterConfig: config, testCredentials: pendingCredentials(nextConnection), aiConnection: runtimeAiBinding ?? nextConnection?.aiConnection, @@ -492,6 +501,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. @@ -520,6 +530,7 @@ function Setup({ role: existing.length ? "general" : "ceo", ...(leader ? { reportsTo: leader.id } : {}), adapterType, + ...(adapterType === "codex_local" && runner !== "auto" ? { runner } : {}), adapterConfig: config, defaultEnvironmentId: environmentOverride || @@ -540,7 +551,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( @@ -783,10 +794,16 @@ function Setup({ onConnected={(next) => { setConnection(next); setRuntimeAiBinding(undefined); - resetTest(); setScreen("runtime"); }} /> :

Loading connection settings…

} + {adapterType === "codex_local" &&
+ Advanced + 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 +883,7 @@ function Setup({
{showModel && !usingKimiApi && ( 0 && ( - { setEffort(value); resetTest(); }} + options={[{ value: "", label: "Auto" }, ...efforts.map(value => ({ value, label: value }))]} /> : { @@ -918,7 +937,7 @@ function Setup({ {value} ))} - + } )}
@@ -1128,6 +1147,13 @@ function Setup({
)} + {adapterType === "codex_local" &&
+ Advanced + 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 9c35a9b240..7088ad0008 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,110 @@ 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; +} + +/** The harness picker surface, shared by simple and searchable configuration pickers. */ +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