feat: default new Codex agents to the qualified Paperclip Runner

Resolve creation intent across onboarding, hires, imports, and the UI. Preserve existing execution and qualify automatic defaults for shipped Linux x64 artifacts.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-09 10:12:15 -05:00
1 parent 835a022936
commit aa879604ae
91 files changed
+2718 -884

No files matched your search

+12
View File
@@ -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);
+5 -2
View File
@@ -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 <id>", "Company ID")
.requiredOption("--payload-json <json>", "CreateAgent JSON payload")
.option("--runner <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<Agent>(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 <id>", "Company ID")
.requiredOption("--payload-json <json>", "CreateAgentHire JSON payload")
.option("--runner <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);
+87 -63
View File
@@ -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.
+3
View File
@@ -370,6 +370,8 @@ export interface AdapterEnvironmentTestContext {
companyId: string;
adapterType: string;
config: Record<string, unknown>;
/** 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";
@@ -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:
@@ -1034,6 +1034,9 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
}
"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<dyn std::error::Error>> {
}
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!({
@@ -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<bool>,
@@ -662,6 +664,7 @@ pub struct CodexProvider {
next_request_id: u64,
thread_id: String,
provider_session_id: Option<String>,
observed_model: Option<String>,
active_provider_turn_id: Option<String>,
pending_messages: VecDeque<BufferedProviderMessage>,
deferred_ambiguous_messages: VecDeque<BufferedProviderMessage>,
@@ -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::<Vec<_>>();
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<u64> {
self.last_trace_frame_id.take()
}
@@ -1678,6 +1700,16 @@ impl CodexProvider {
message: &str,
cwd: &str,
skills: &[CodexSkillInput],
) -> Result<Value, LocalRunnerError> {
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<Value, LocalRunnerError> {
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,
};
@@ -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,
},
@@ -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,
}
+1
View File
@@ -59,6 +59,7 @@ export * from "./drivers/acpx/sidecar-protocol.js";
export * from "./drivers/runner-tool-bridge.js";
export {
createRunnerdCodexTransport,
probeNativeRunnerEnvironment,
runnerCodexDynamicToolsFit,
defaultCapabilityRunnerdBinary,
readRunnerdArtifactBinding,
@@ -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,
@@ -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<string, unknown> | 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<void>;
onCleanupConfirmed?: () => Promise<void>;
}): 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<unknown> | 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<never>((_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"],
+26
View File
@@ -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);
});
});
+35
View File
@@ -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<AgentRunnerChoice, "auto">;
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, unknown> = {}): 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, string>)[String(agent)] ?? `acpx:${String(agent)}`;
}
return String(config.provider);
}
export function agentRunner(adapterType: string): AgentRunner {
return adapterType === "paperclip_runner" ? "paperclip" : "legacy";
}
+1
View File
@@ -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";
@@ -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<string, unknown>;
}
+3
View File
@@ -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<typeof createAgentSchema>;
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<typeof resetAgentSessionSchema>;
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(),
@@ -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<typeof companyPortabilityPreviewSchema>;
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(),
});
@@ -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",
@@ -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 () => {
@@ -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);
@@ -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" });
@@ -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 },
);
});
});
+41 -11
View File
@@ -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");
@@ -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<string, unknown>) => ({ 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<string, unknown>) => ({ id: "agent-created", ...input }));
agentSvc.update.mockImplementation(async (id: string, input: Record<string, unknown>) => ({ 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<string, unknown>) => ({ 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({
+1 -1
View File
@@ -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" },
@@ -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();
@@ -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 () => {
+11 -3
View File
@@ -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<string> {
return pausedOverrides;
+7 -2
View File
@@ -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<void> {
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,
+6 -4
View File
@@ -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),
+43 -13
View File
@@ -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<string, unknown>,
): Record<string, unknown> {
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<ReturnType<typeof requireServerAdapter>["testEnvironment"]>[0], binding: AiConnectionBinding, managed: Awaited<ReturnType<typeof prepareManagedAiRuntime>>, 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<string, unknown>;
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<string, unknown>;
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<string, unknown>) };
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;
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({ disabled: [] as string[], overridden: new Set<string>(), settings: {} as Record<string, unknown>, 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();
});
});
@@ -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<string, unknown>;
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<string, unknown> } {
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 });
}
+10 -1
View File
@@ -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<typeof agents.$inferInsert, "companyId">, options?: CreateAgentOptions) => {
create: async (companyId: string, input: Omit<typeof agents.$inferInsert, "companyId"> & { 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) {
+1 -1
View File
@@ -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) {
+47 -19
View File
@@ -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<string, unknown>;
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<string, unknown> = {}) {
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<Agent, "adapterType" | "adapterConfig" | "status" | "pausedAt"> | null): BuiltInAgentStatus {
export function deriveBuiltInAgentStatus(agent: Pick<Agent, "adapterType" | "adapterConfig" | "status" | "pausedAt"> & { 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<string, unknown> | null) {
@@ -729,8 +731,9 @@ function builtInMetadata(definition: BuiltInAgentDefinition, existing?: Record<s
function definitionPatch(definition: BuiltInAgentDefinition, input: BuiltInAgentProvisionInput = {}) {
const adapterType = input.adapterType ?? defaultAdapterType(definition);
assertAdapterAllowed(definition, adapterType);
assertAdapterAllowed(definition, adapterType, input.adapterConfig);
return {
runner: input.runner,
name: definition.displayName,
role: definition.defaultRole,
title: definition.defaultTitle ?? null,
@@ -752,7 +755,7 @@ async function assertKnownBuiltInAgentModel(
const model = typeof adapterConfig.model === "string" ? adapterConfig.model.trim() : "";
if (!model || !hasCompleteAdapterConfig(adapterType, adapterConfig)) return;
const models = await listAdapterModels(adapterType);
const models = await listAdapterModels(agentHarnessType(adapterType, adapterConfig));
if (models.length === 0 || models.some((candidate) => 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,
+38 -4
View File
@@ -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<string, unknown>,
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<string, unknown>;
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 });
@@ -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<unknown>;
};
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 () => {
@@ -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<AdapterExecutionTarget, { kind: "remote" }>;
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<Record<string, unknown>> {
@@ -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<string, unknown>;
try {
metadata = JSON.parse(metadataResult.stdout) as Record<string, unknown>;
} catch (error) {
throw new Error("runner_remote_artifact_metadata_invalid", {
cause: error,
});
}
assertRemoteRunnerBuildMetadata(metadata, requiredMode);
};
const reportedCodexVersions = new Set<string>();
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<boolean> => {
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<AdapterExecutionTarget, { kind: "remote" }>;
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<void>;
}) {
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<string, unknown>;
try {
metadata = JSON.parse(metadataResult.stdout) as Record<string, unknown>;
} catch (error) {
throw new Error("runner_remote_artifact_metadata_invalid", {
cause: error,
});
}
assertRemoteRunnerBuildMetadata(metadata, requiredMode);
};
const reportedCodexVersions = new Set<string | null>();
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<boolean> => {
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 };
}
@@ -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);
});
});
@@ -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.",
@@ -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<typeof import("../../vendor/paperclip-runner/index.js")>(), probeNativeRunnerEnvironment: nativeProbe }));
vi.mock("./native-session-executor.js", async original => ({ ...await original<typeof import("./native-session-executor.js")>(), 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();
});
});
@@ -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, string>): 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<string, string>;
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<void>(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<typeof connectRunnerPrpIngress> | undefined;
let activation: Promise<void> | 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<AdapterEnvironmentTestResult> {
const remote = context.executionTarget?.kind === "remote";
const configured = context.config.env;
const environment: Record<string, string> = 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<ReturnType<typeof probeNativeRunnerEnvironment>>;
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." }] };
}
}
+14 -5
View File
@@ -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<string, number>();
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<string, unknown>;
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),
}, {
+2
View File
@@ -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;
+15
View File
@@ -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.
@@ -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 ...`.
@@ -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.
+24 -22
View File
@@ -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,
+4 -1
View File
@@ -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",
+6 -4
View File
@@ -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<RunnerApi, "get" | "postSensitive">;
api: Pick<RunnerApi, "get" | "patch" | "postSensitive">;
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<Array<LiveFixtureValues["environment"]>>(
`/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,
+13 -2
View File
@@ -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,
+6 -2
View File
@@ -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,
+4 -2
View File
@@ -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);
@@ -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,
@@ -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");
@@ -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, unknown> }): 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;
}
@@ -51,6 +51,32 @@ async function renderRunner(config: Record<string, unknown>, 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(<QueryClientProvider client={new QueryClient()}><TooltipProvider>
<CodexLocalConfigFields mode="edit" isCreate={false} adapterType="codex_local" values={null} set={null}
config={{ engine: "acp" }} eff={(_group, _field, original) => original}
mark={(...change) => { changes.push(change); }} models={[]} hideInstructionsFile />
</TooltipProvider></QueryClientProvider>));
const picker = container.querySelector<HTMLButtonElement>('[aria-label="Execution engine"]')!;
await act(async () => picker.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })));
const selected = document.body.querySelector<HTMLButtonElement>('[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" });
+23 -22
View File
@@ -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."
>
<select
className={inputClass}
<SelectPopover
aria-label="Execution engine"
value={engine}
onChange={(e) => {
onValueChange={(next) => {
const value =
e.target.value === "acp"
next === "acp"
? "acp"
: e.target.value === "cli"
: next === "cli"
? "cli"
: "auto";
isCreate
@@ -194,14 +193,15 @@ export function CodexLocalConfigFields({
value === "auto" ? undefined : value,
);
}}
>
<option value="auto">Default (ACP)</option>
<option value="cli">Codex CLI</option>
<option value="acp">ACP</option>
</select>
options={[
{ value: "auto", label: "Default (ACP)" },
{ value: "cli", label: "Codex CLI" },
{ value: "acp", label: "ACP" },
]}
/>
</Field>
)}
{runnerManaged && runnerProvider !== "openai_dot" && (
{runnerManaged && !hideRunnerHarness && runnerProvider !== "openai_dot" && (
<Field configSection="adapter"
label="Harness"
hint="Choose the agent harness that runs your tasks."
@@ -253,6 +253,7 @@ export function CodexLocalConfigFields({
>
<SelectTrigger className="w-full" aria-label="Harness"><SelectValue /></SelectTrigger>
<SelectContent>
{!isPaperclipRunnerProvider(runnerProvider) && <SelectItem value={runnerProvider} disabled>{runnerProvider} (unknown provider)</SelectItem>}
{runnerHarnessOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<AdapterMark type={option.adapter} className="size-4" />
@@ -469,7 +470,7 @@ export function CodexLocalConfigFields({
}
onValueChange={(selectedMode) => {
const value = resolvePaperclipRunnerPermissionMode(
runnerProvider,
isPaperclipRunnerProvider(runnerProvider) ? runnerProvider : "codex",
selectedMode,
) as PaperclipRunnerPermissionMode;
if (isCreate) {
+11 -1
View File
@@ -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 } : {}),
};
},
};
+2
View File
@@ -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 {
+2
View File
@@ -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";
+1
View File
@@ -252,6 +252,7 @@ export const agentsApi = {
type: string,
data: {
adapterConfig: Record<string, unknown>;
runner?: import("@paperclipai/shared").AgentRunnerChoice;
aiConnection?: import("@paperclipai/shared").AiRuntimeConnectionBinding;
agentId?: string;
testCredentials?: Record<string, string>;
+1
View File
@@ -108,6 +108,7 @@ export interface BuiltInAgentState {
}
export interface BuiltInAgentProvisionInput {
runner?: import("@paperclipai/shared").AgentRunnerChoice;
adapterType?: string;
adapterConfig?: Record<string, unknown>;
budgetMonthlyCents?: number;
@@ -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<Agent> = {},
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}
/>
</TooltipProvider>
@@ -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<HTMLInputElement>('[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);
+89 -154
View File
@@ -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<string, unknown> | 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<CreateConfigValues>) => props.onChange(patch) : null,
config,
@@ -979,7 +991,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const renderAdapterFields = (section: AdapterConfigSection) => (
<>
{adapterType === "claude_local" && <ClaudeLocalAdvancedFields {...adapterFieldProps} section={section} />}
<uiAdapter.ConfigFields {...adapterFieldProps} section={section} hideModel={isLocal} />
<fieldsUiAdapter.ConfigFields {...adapterFieldProps} section={section} hideModel={isLocal} />
</>
);
// 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) {
</div>
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
{showAdapterTypeField && (
<Field label="Adapter type" hint={help.adapterType}>
<Field label="Harness" hint={help.adapterType}>
<AdapterTypeDropdown
value={isDotRunner ? "openai_dot" : adapterType}
value={isDotRunner ? "openai_dot" : isCodexHarness ? harnessType : adapterType}
disabledTypes={adapterPickerDisabledTypes}
openAiDotEnabled={experimentalSettings?.enableOpenAiDot === true}
onChange={(choice) => {
@@ -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) {
<AdapterLoginPanel
key={`${adapterType}:${effectiveLoginEnvironmentId}`}
companyId={selectedCompanyId!}
adapterType={adapterType}
adapterType={isCodexHarness ? harnessType : adapterType}
environmentId={effectiveLoginEnvironmentId!}
onStored={isCreate ? handleClaudeLoginStored : handleClaudeLoginStoredEdit}
onApplyStored={
@@ -1749,8 +1757,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
loadingModels={connectionModels?.isLoading ?? fetchingModels}
value={currentModelId}
onChange={(v) => {
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)}
>
<div className="space-y-3">
{isCodexHarness && <CodexRunnerSelect value={runner}
defaultRunner={codexRunnerAvailability?.defaultRunner}
supportedRunners={codexRunnerAvailability?.supportedRunners}
onChange={next => isCreate ? set!({ runner: next }) : setOverlay(prev => ({ ...prev, runner: next }))} />}
{isLocal && (<> {/*
The command names a binary on the execution host, so the
managed-sandbox-only policy hides it: the platform-managed image
@@ -3685,71 +3700,22 @@ export function AdapterTypeDropdown({
disabledTypes: Set<string>;
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
data-size="default"
className={cn(selectTriggerClassName, "w-full")}
>
<span className="inline-flex min-w-0 items-center gap-1.5">
<span aria-hidden="true" className="inline-flex shrink-0">
<AdapterMark type={value} className="size-4" />
</span>
<span className="truncate">{adapterLabels[value] ?? getAdapterLabel(value)}</span>
{selectedDisplay.experimental && <ExperimentalBadge />}
</span>
<ChevronDown className="size-4 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent className="w-(--radix-popover-trigger-width) p-1" align="start">
{adapterList.map((item) => (
<button
key={item.value}
type="button"
disabled={item.comingSoon}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded",
item.comingSoon
? "opacity-40 cursor-not-allowed"
: "hover:bg-accent/50",
item.value === value && !item.comingSoon && "bg-accent",
)}
onClick={() => {
if (!item.comingSoon) {
onChange(item.value);
setOpen(false);
}
}}
>
<span className="inline-flex items-center gap-1.5">
<span aria-hidden="true" className="inline-flex shrink-0">
<AdapterMark type={item.value} className="size-4" />
</span>
<span>{item.label}</span>
{item.experimental && <ExperimentalBadge />}
</span>
{item.comingSoon && (
<span className="text-(length:--text-nano) text-muted-foreground">Coming soon</span>
)}
</button>
))}
</PopoverContent>
</Popover>
);
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 <SelectPopover aria-label="Harness" value={value} onValueChange={onChange}
displayValue={<span className="inline-flex min-w-0 items-center gap-1.5">
<span aria-hidden="true" className="inline-flex shrink-0"><AdapterMark type={value} className="size-4" /></span>
<span className="truncate">{adapterLabels[value] ?? getAdapterLabel(value)}</span>
{selectedDisplay.experimental && <ExperimentalBadge />}
</span>}
options={adapterList.map(item => ({ value: item.value, disabled: item.comingSoon,
label: <span className="inline-flex items-center gap-1.5"><span aria-hidden="true" className="inline-flex shrink-0"><AdapterMark type={item.value} className="size-4" /></span><span>{item.label}</span>{item.experimental && <ExperimentalBadge />}</span>,
suffix: item.comingSoon ? <span className="text-(length:--text-nano) text-muted-foreground">Coming soon</span> : undefined,
}))} />;
}
function ExperimentalBadge() {
@@ -3917,29 +3883,17 @@ export function ModelDropdown({
return (
<Field label="Model" hint={help.model}>
<Popover
<SelectPopover aria-label="Model" value={value}
displayValue={<span className={cn("truncate", !value && "text-muted-foreground")}>
{selected?.label ?? (value || (allowDefault ? (defaultLabel ?? "Default") : required ? "Select model (required)" : "Select model"))}
</span>}
open={open}
onOpenChange={(nextOpen) => {
onOpenChange(nextOpen);
if (!nextOpen) setModelSearch("");
}}
>
<PopoverTrigger asChild>
<button
type="button"
data-size="default"
className={cn(selectTriggerClassName, "w-full")}
>
<span className={cn("truncate", !value && "text-muted-foreground")}>
{selected
? selected.label
: value
|| (allowDefault ? (defaultLabel ?? "Default") : required ? "Select model (required)" : "Select model")}
</span>
<ChevronDown className="size-4 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent className="w-(--radix-popover-trigger-width) p-1" align="start">
<div role="listbox" aria-label="Model">
<div className="relative mb-1">
<input
className="w-full px-2 py-1.5 pr-6 text-xs bg-transparent outline-none border-b border-border placeholder:text-muted-foreground/50"
@@ -3998,6 +3952,7 @@ export function ModelDropdown({
{value && (!models.some((m) => m.id === value) || promotedModelIds.has(value)) && (
<button
type="button"
role="option" aria-selected
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded bg-accent/50",
)}
@@ -4016,6 +3971,7 @@ export function ModelDropdown({
{detectedModel && detectedModel !== value && (
<button
type="button"
role="option" aria-selected={false}
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
)}
@@ -4040,6 +3996,7 @@ export function ModelDropdown({
<button
key={`detected-${candidate}`}
type="button"
role="option" aria-selected={false}
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
)}
@@ -4061,6 +4018,7 @@ export function ModelDropdown({
{allowDefault && (
<button
type="button"
role="option" aria-selected={!value}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
!value && "bg-accent",
@@ -4076,6 +4034,7 @@ export function ModelDropdown({
{canCreateManualModel && (
<button
type="button"
role="option" aria-selected={false}
className="flex items-center justify-between gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50"
onClick={() => {
onChange(manualModel);
@@ -4098,6 +4057,7 @@ export function ModelDropdown({
<button
type="button"
key={m.id}
role="option" aria-selected={m.id === value}
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
m.id === value && "bg-accent",
@@ -4124,8 +4084,8 @@ export function ModelDropdown({
</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
</SelectPopover>
</Field>
);
}
@@ -4143,36 +4103,11 @@ function ThinkingEffortDropdown({
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const selected = options.find((option) => option.id === value) ?? options[0];
return (
<Field label="Thinking effort" hint={help.thinkingEffort}>
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className={cn(!value && "text-muted-foreground")}>{selected?.label ?? "Auto"}</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-(--radix-popover-trigger-width) p-1" align="start">
{options.map((option) => (
<button
key={option.id || "auto"}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
option.id === value && "bg-accent",
)}
onClick={() => {
onChange(option.id);
onOpenChange(false);
}}
>
<span>{option.label}</span>
{option.id ? <span className="text-xs text-muted-foreground font-mono">{option.id}</span> : null}
</button>
))}
</PopoverContent>
</Popover>
</Field>
);
return <Field label="Thinking effort" hint={help.thinkingEffort}>
<SelectPopover aria-label="Thinking effort" value={value} onValueChange={onChange}
open={open} onOpenChange={onOpenChange}
options={options.map(option => ({ value: option.id, label: option.label,
suffix: option.id ? <span className="text-xs text-muted-foreground font-mono">{option.id}</span> : undefined,
}))} />
</Field>;
}
+2 -2
View File
@@ -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) {
</PropertyRow>
)}
<PropertyRow label="Adapter">
<span className="text-sm font-mono">{getAdapterLabel(agent.adapterType)}</span>
<span className="text-sm font-mono">{getAdapterLabel(agentDisplayAdapterType(agent))}</span>
</PropertyRow>
</div>
+33
View File
@@ -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 (
<Field label="Runner">
<SelectPopover
aria-label="Runner"
disabled={disabled}
value={value === "auto" ? defaultRunner : value}
onValueChange={next => 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" },
]}
/>
</Field>
);
}
@@ -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<string>(
() => 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<AgentRunnerChoice | undefined>();
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<string>(() => {
const config = state.agent?.adapterConfig;
const configuredModel = typeof config === "object" && config !== null
@@ -128,7 +134,8 @@ export function ConfigureBuiltInAgentModal({
const adapterConfig: Record<string, unknown> = {};
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.
</InlineBanner>
<Field label="Adapter type">
<Field label="Harness">
<AdapterTypeDropdown
value={adapterType}
onChange={(next) => {
setAdapterType(next);
setModel("");
setRunner(undefined);
}}
disabledTypes={disabledTypes}
/>
</Field>
{adapterType === "codex_local" && <details className="space-y-3">
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
<CodexRunnerSelect value={runner ?? (state.agent ? agentRunner(state.agent.adapterType) : "auto")}
defaultRunner={codexAvailability?.defaultRunner} supportedRunners={codexAvailability?.supportedRunners}
onChange={setRunner} />
</details>}
{modelRequired && (
// ModelDropdown supplies its own "Model" Field label + hint.
<ModelDropdown
+11
View File
@@ -78,4 +78,15 @@ describe("ModelDropdown", () => {
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);
});
});
+4 -70
View File
@@ -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<HTMLInputElement>('input[type="radio"]')].map(
(input) => input.value,
),
).toEqual(["claude_local", "codex_local", "opencode_local", "grok_local", ...(enableNativeRunner ? ["paperclip_runner"] : [])]);
await act(async () =>
document.querySelector<HTMLInputElement>('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 () => {
@@ -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("");
+34 -18
View File
@@ -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<AdapterType>(() =>
restoreOnboardingAdapterType(saved?.adapterType),
restoreOnboardingAdapterType(saved?.adapterType, saved?.runnerProvider),
);
const [runner, setRunner] = useState<AgentRunnerChoice>(() => 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}
</motion.div>
{sourceSelected && adapterType === "codex_local" && <details className="space-y-3">
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
<CodexRunnerSelect value={runner} disabled={loading || adapterEnvLoading}
defaultRunner={runnerDiscovery.data?.find(item => item.type === "codex_local")?.defaultRunner}
supportedRunners={runnerDiscovery.data?.find(item => item.type === "codex_local")?.supportedRunners}
onChange={setRunner} />
</details>}
{/* Conditional adapter fields */}
{/* No model picker. Every adapter this step offers resolves
its own default (see buildAdapterConfig), so the picker
@@ -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({
);
})}
</div>
{validAdapter && adapterType === "paperclip_runner" && (
<label className="flex flex-col gap-2 text-sm font-medium">
Runner
<select
className="rounded-md border border-border bg-background px-3 py-2"
value={runnerProvider}
onChange={(event) =>
setRunnerProvider(event.target.value)
}
>
<option value="codex">Codex (app server)</option>
<option value="claude">Claude (ACPX)</option>
<option value="grok">Grok Build (ACPX)</option>
<option value="opencode">OpenCode</option>
</select>
</label>
)}
</fieldset>
)}
</div>
+35 -9
View File
@@ -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 (
<Setup
key={`${selectedCompanyId}:${params.get("name")}:${params.get("adapterType")}:${params.get("runnerProvider")}`}
key={`${selectedCompanyId}:${params.get("name")}:${params.get("adapterType")}:${params.get("runnerProvider")}:${params.get("runner")}`}
companyId={selectedCompanyId}
name={params.get("name") ?? ""}
adapterType={params.get("adapterType") ?? ""}
adapterType={params.get("adapterType") === "paperclip_runner" && (params.get("runnerProvider") ?? "codex") === "codex" ? "codex_local" : params.get("adapterType") ?? ""}
initialRunner={params.get("runner") ?? (params.get("adapterType") === "paperclip_runner" && (params.get("runnerProvider") ?? "codex") === "codex" ? "paperclip" : "auto")}
runnerProvider={params.get("runnerProvider") ?? "codex"}
createdAgentId={params.get("createdAgentId")}
/>
@@ -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<AgentRunnerChoice>(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<unknown> }> = [];
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");
}}
/> : <p role="status" className="text-sm text-muted-foreground">Loading connection settings…</p>}
{adapterType === "codex_local" && <details className="mt-5 space-y-3">
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
<CodexRunnerSelect value={runner} disabled={busy}
defaultRunner={adapters.data?.find(item => item.type === "codex_local")?.defaultRunner}
supportedRunners={adapters.data?.find(item => item.type === "codex_local")?.supportedRunners}
onChange={value => { setRunner(value); resetTest(); }} />
</details>}
</OnboardingCard>
) : screen === "saved" && created ? (
<div className="space-y-6">
@@ -866,7 +883,7 @@ function Setup({
<div className="grid items-start gap-5 sm:grid-cols-2">
{showModel && !usingKimiApi && (
<ModelDropdown
presentation="native"
presentation={brandType === "codex_local" ? "searchable" : "native"}
models={connectionModels?.models ?? models.data ?? []}
loadingModels={connectionModels?.isLoading ?? models.isLoading}
onRefreshModels={connectionModels?.refreshModels}
@@ -904,7 +921,9 @@ function Setup({
)}
{efforts.length > 0 && (
<Field label="Thinking effort">
<NativeSelect
{brandType === "codex_local" ? <SelectPopover aria-label="Thinking effort" value={effort}
onValueChange={value => { setEffort(value); resetTest(); }}
options={[{ value: "", label: "Auto" }, ...efforts.map(value => ({ value, label: value }))]} /> : <NativeSelect
aria-label="Thinking effort"
value={effort}
onChange={(event) => {
@@ -918,7 +937,7 @@ function Setup({
{value}
</option>
))}
</NativeSelect>
</NativeSelect>}
</Field>
)}
</div>
@@ -1128,6 +1147,13 @@ function Setup({
</Field>
</div>
)}
{adapterType === "codex_local" && <details className="space-y-3">
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
<CodexRunnerSelect value={runner} disabled={busy}
defaultRunner={adapters.data?.find(item => item.type === "codex_local")?.defaultRunner}
supportedRunners={adapters.data?.find(item => item.type === "codex_local")?.supportedRunners}
onChange={value => { setRunner(value); resetTest(); }} />
</details>}
</section>
{!["cursor_cloud", "hermes_gateway"].includes(
adapterType,
+105
View File
@@ -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<typeof PopoverContent>["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<HTMLDivElement>(null);
const selected = options.find(option => option.value === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button type="button" aria-label={ariaLabel} aria-haspopup="listbox"
aria-busy={ariaBusy}
disabled={disabled} data-size="default"
onKeyDown={event => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
setOpen(true);
}
}}
className={cn(selectTriggerClassName, "w-full min-w-0", className)}>
<span className="min-w-0 truncate text-left">{displayValue ?? selected?.label ?? (value || placeholder)}</span>
<ChevronDownIcon className="size-4 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent ref={contentRef} align="start"
onCloseAutoFocus={onCloseAutoFocus}
className="w-(--radix-popover-trigger-width) max-h-(--radix-popover-content-available-height) overflow-y-auto p-1"
onOpenAutoFocus={event => {
if (contentRef.current?.querySelector("input:not(:disabled)")) return;
event.preventDefault();
(contentRef.current?.querySelector<HTMLButtonElement>('[aria-selected="true"]:not(:disabled)')
?? contentRef.current?.querySelector<HTMLButtonElement>('[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<HTMLButtonElement>('[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 ?? <div role="listbox" aria-label={ariaLabel}>
{options.map((option, index) => <React.Fragment key={option.value}>
{option.group && option.group !== options[index - 1]?.group && <div className="px-2 py-1 text-xs text-muted-foreground">{option.group}</div>}
<SelectPopoverItem data-value={option.value} selected={option.value === value}
disabled={option.disabled} onClick={() => { if (option.value !== value) onValueChange?.(option.value); setOpen(false); }}>
<span className="min-w-0 truncate">{option.label}</span>
{option.suffix}
</SelectPopoverItem></React.Fragment>)}
</div>}
</PopoverContent>
</Popover>
);
}
export function SelectPopoverItem({ selected, className, ...props }: React.ComponentProps<"button"> & { selected?: boolean }) {
return <button {...props} type="button" role="option" aria-selected={selected ?? false}
className={cn("flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-left text-sm outline-none hover:bg-accent/50 focus-visible:bg-accent disabled:cursor-not-allowed disabled:opacity-40", selected && "bg-accent", className)} />;
}
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
+2
View File
@@ -1,6 +1,7 @@
import { ADAPTER_AGNOSTIC_KEYS, type Agent } from "@paperclipai/shared";
export interface AgentConfigOverlay {
runner?: import("@paperclipai/shared").AgentRunnerChoice;
identity: Record<string, unknown>;
adapterType?: string;
adapterConfig: Record<string, unknown>;
@@ -17,6 +18,7 @@ export function omitUndefinedEntries(value: Record<string, unknown>) {
export function buildAgentUpdatePatch(agent: Agent, overlay: AgentConfigOverlay) {
const patch: Record<string, unknown> = {};
if (overlay.runner !== undefined) patch.runner = overlay.runner;
if (Object.keys(overlay.identity).length > 0) {
Object.assign(patch, overlay.identity);
+10
View File
@@ -2,8 +2,18 @@
import { describe, expect, it } from "vitest";
import { buildNewAgentHirePayload } from "./new-agent-hire-payload";
import { defaultCreateValues } from "../components/agent-config-defaults";
import { codexLocalUIAdapter } from "../adapters/codex-local";
describe("buildNewAgentHirePayload", () => {
it.each([undefined, "auto", "paperclip", "legacy"] as const)("keeps runner %s separate from Codex execution settings", (runner) => {
const values = { ...defaultCreateValues, adapterType: "codex_local", runner, model: "gpt-5.6-sol", thinkingEffort: "high" };
const payload = buildNewAgentHirePayload({ name: "Codex", effectiveRole: "general", configValues: values, adapterConfig: codexLocalUIAdapter.buildAdapterConfig(values) });
expect(payload.adapterType).toBe("codex_local");
expect(payload.runner).toBe(runner);
expect(payload.adapterConfig).toMatchObject({ model: values.model, modelReasoningEffort: "high" });
if (runner === "legacy") expect(payload.adapterConfig.dangerouslyBypassApprovalsAndSandbox).toBe(false);
else expect(payload.adapterConfig).not.toHaveProperty("dangerouslyBypassApprovalsAndSandbox");
});
it("persists the selected default environment id", () => {
expect(
buildNewAgentHirePayload({
+1
View File
@@ -30,6 +30,7 @@ export function buildNewAgentHirePayload(input: {
...(reportsTo ? { reportsTo } : {}),
...(selectedSkillKeys.length > 0 ? { desiredSkills: selectedSkillKeys } : {}),
adapterType: configValues.adapterType,
...(configValues.runner ? { runner: configValues.runner } : {}),
defaultEnvironmentId: configValues.defaultEnvironmentId ?? null,
adapterConfig,
runtimeConfig: buildNewAgentRuntimeConfig({
+10 -1
View File
@@ -57,6 +57,7 @@ it("does not report a connection when runtime readiness passes but provider auth
{
agentId: "agent-1",
environmentId: "sandbox-1",
runner: "legacy",
adapterConfig: { ...input.adapterConfig, engine: "cli" },
},
);
@@ -73,7 +74,7 @@ it("probes native Grok credentials with the pinned prerequisite in the selected
adapterConfig: { provider: "acpx", acpxAgent: "grok", model: "grok-4.7" },
});
expect(testEnvironment).toHaveBeenLastCalledWith("company-1", "grok_local", {
agentId: "agent-1", environmentId: "sandbox-1",
agentId: "agent-1", environmentId: "sandbox-1", runner: "legacy",
adapterConfig: { provider: "acpx", acpxAgent: "grok", model: "grok-4.7", engine: "cli", command: "/opt/paperclip/providers/grok/1.0.13/grok" },
});
expect(result.checks.some((check) => check.code === "grok_hello_probe_passed")).toBe(true);
@@ -98,3 +99,11 @@ it("preserves runtime warnings after a successful provider request", async () =>
.mockResolvedValueOnce(ready);
expect((await testAgentSetup(input)).status).toBe("warn");
});
it("keeps the resolved native identity and explicitly bounds the supplementary Codex CLI probe to legacy", async () => {
testEnvironment.mockResolvedValueOnce(ready).mockResolvedValueOnce({ ...ready, adapterType: "codex_local" });
const result = await testAgentSetup({ ...input, adapterType: "codex_local", providerAdapter: "codex_local", runner: "paperclip" });
expect(result.adapterType).toBe("paperclip_runner");
expect(testEnvironment.mock.calls[0][2].runner).toBe("paperclip");
expect(testEnvironment.mock.calls[1][2].runner).toBe("legacy");
});
+6 -1
View File
@@ -8,6 +8,7 @@ export async function testAgentSetup(input: {
companyId: string;
agentId?: string;
adapterType: string;
runner?: import("@paperclipai/shared").AgentRunnerChoice;
providerAdapter: string;
adapterConfig: Record<string, unknown>;
aiConnection?: import("@paperclipai/shared").AiRuntimeConnectionBinding;
@@ -15,6 +16,7 @@ export async function testAgentSetup(input: {
environmentId: string | null;
}): Promise<AdapterEnvironmentTestResult> {
const payload = {
...(input.runner ? { runner: input.runner } : {}),
...(input.agentId ? { agentId: input.agentId } : {}),
...(input.aiConnection ? { aiConnection: input.aiConnection } : {}),
adapterConfig: input.adapterConfig,
@@ -41,6 +43,9 @@ export async function testAgentSetup(input: {
input.providerAdapter,
{
...payload,
// This supplementary CLI probe is authentication evidence only. The
// first probe must establish readiness of the selected runtime.
runner: "legacy",
adapterConfig: {
...input.adapterConfig,
engine: "cli",
@@ -59,7 +64,7 @@ export async function testAgentSetup(input: {
).values(),
];
return {
adapterType: input.adapterType,
adapterType: runtime.adapterType,
testedAt: provider.testedAt,
status:
provider.status === "fail"
+3 -3
View File
@@ -34,7 +34,7 @@ import { queryKeys } from "../lib/queryKeys";
import { copyTextToClipboard } from "../lib/clipboard";
import { AgentSkillsTab } from "./agent-skills/AgentSkillsTab";
import { AgentConfigForm } from "../components/AgentConfigForm";
import { getAdapterDisplay } from "../adapters/adapter-display-registry";
import { agentDisplayAdapterType, getAdapterDisplay } from "../adapters/adapter-display-registry";
import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { useAdapterCapabilities } from "@/adapters/use-adapter-capabilities";
@@ -1253,7 +1253,7 @@ export function AgentDetail() {
{agent.adapterType === "claude_local" || agent.adapterType === "codex_local"
? <img src={`/brands/${agent.adapterType === "claude_local" ? "claude" : "codex"}-color.svg`} className="size-4" alt="" />
: null}
<span>{getAdapterDisplay(agent.adapterType === "paperclip_runner" && agent.adapterConfig.provider === "openai_dot" ? "openai_dot" : agent.adapterType).label}</span><span>·</span>
<span>{getAdapterDisplay(agentDisplayAdapterType(agent)).label}</span><span>·</span>
<span>{agent.title || roleLabels[agent.role] || agent.role}</span>
</div>
<SetPrimaryAgentButton agent={agent} />
@@ -1778,7 +1778,7 @@ export function AgentOverview({
<Link className="text-xs text-muted-foreground hover:text-foreground" to={agentDetailHref(agentRouteId, "runtime")}>Configure</Link>
</div>
<div className="space-y-3">
<SummaryRow label="Adapter"><span className="text-sm">{getAdapterDisplay(agent.adapterType === "paperclip_runner" && agent.adapterConfig.provider === "openai_dot" ? "openai_dot" : agent.adapterType).label}</span></SummaryRow>
<SummaryRow label="Adapter"><span className="text-sm">{getAdapterDisplay(agentDisplayAdapterType(agent)).label}</span></SummaryRow>
<SummaryRow label="Model"><span className="max-w-64 truncate text-sm font-mono">{configuredModel}</span></SummaryRow>
<SummaryRow label="Session"><span className="max-w-64 truncate text-sm font-mono">{runtimeState?.sessionDisplayId ?? runtimeState?.sessionId ?? "No session"}</span></SummaryRow>
<SummaryRow label="Last run">
+2 -2
View File
@@ -34,7 +34,7 @@ import {
} from "../hooks/useResourceMemberships";
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
import { getAdapterLabel } from "../adapters/adapter-display-registry";
import { agentDisplayAdapterType, getAdapterLabel } from "../adapters/adapter-display-registry";
const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
@@ -700,7 +700,7 @@ function AgentMetaColumns({
showEnvironment: boolean;
}) {
const model = getConfiguredModel(agent);
const adapterLabel = getAdapterLabel(agent.adapterType);
const adapterLabel = getAdapterLabel(agentDisplayAdapterType(agent));
return (
<>
<div className="w-44 min-w-0 leading-tight">
+33 -3
View File
@@ -200,6 +200,33 @@ afterEach(async () => {
container.remove();
});
describe("New agent setup", () => {
it("uses automatic Codex resolution through setup and hire without the experimental gate", async () => {
settings.getExperimental.mockResolvedValue({ enableNativeRunner: false });
await render("codex_local");
await connect("OpenAI");
await click("Finish setup");
expect(api.hire.mock.calls[0][1].adapterType).toBe("codex_local");
expect(api.hire.mock.calls[0][1]).not.toHaveProperty("runner");
expect(api.testEnvironment.mock.calls[0][1]).toBe("codex_local");
expect(api.testEnvironment.mock.calls[0][2]).not.toHaveProperty("runner");
expect(api.hire.mock.calls[0][1].adapterConfig.dangerouslyBypassApprovalsAndSandbox).not.toBe(true);
});
it("allows the explicit legacy override before authentication and rechecks it after a runner change", async () => {
state.adapters = (state.adapters as any[]).map(adapter => adapter.type === "codex_local" ? { ...adapter, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" } : adapter);
await render("codex_local");
await act(async () => (container.querySelector("summary") as HTMLElement).click());
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Runner"]')!.click());
await act(async () => [...document.querySelectorAll<HTMLButtonElement>('[role="option"]')].find(option => option.textContent === "Legacy runner")!.click());
await connect("OpenAI");
expect(api.testEnvironment.mock.calls[0][2].runner).toBe("legacy");
await act(async () => (container.querySelector("summary") as HTMLElement).click());
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Runner"]')!.click());
await act(async () => [...document.querySelectorAll<HTMLButtonElement>('[role="option"]')].find(option => option.textContent === "Paperclip Runner (default)")!.click());
await click("Finish setup");
expect(api.testEnvironment).toHaveBeenLastCalledWith("company-1", "codex_local", expect.objectContaining({ runner: "paperclip" }));
expect(api.hire.mock.calls[0][1]).toMatchObject({ adapterType: "codex_local", runner: "paperclip" });
});
it("creates Dot through its independent choice without CLI or model setup, then routes to pairing", async () => {
settings.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true, enablePublicMcp: true });
await render("paperclip_runner", "openai_dot");
@@ -294,10 +321,10 @@ describe("New agent setup", () => {
await click("Finish setup");
expect(api.hire.mock.calls[0][1].defaultEnvironmentId).toBe("grok-sandbox");
});
it.each([false, true])("blocks direct runner setup links when the experiment is disabled (cloud=%s)", async (cloud) => {
it.each([false, true])("keeps non-Codex runner setup links gated when the experiment is disabled (cloud=%s)", async (cloud) => {
cache.setQueryData(queryKeys.health, { status: "ok", cloud: { managed: cloud } });
settings.getExperimental.mockResolvedValue({ enableNativeRunner: false });
await render("paperclip_runner");
await render("paperclip_runner", "claude");
expect(container.textContent).toContain("This adapter is unavailable");
expect(api.hire).not.toHaveBeenCalled();
});
@@ -725,7 +752,10 @@ describe("New agent setup", () => {
else await fill("Model", "openrouter/anthropic/claude-sonnet-4.6");
await click("Finish setup");
const config = api.hire.mock.calls[0][1].adapterConfig;
expect(config.provider).toBe(runner === "claude" ? "acpx" : runner);
if (runner === "codex") {
expect(api.hire.mock.calls[0][1]).toMatchObject({ adapterType: "codex_local", runner: "paperclip" });
expect(api.testEnvironment).toHaveBeenCalledWith("company-1", "codex_local", expect.objectContaining({ runner: "paperclip" }));
} else expect(config.provider).toBe(runner === "claude" ? "acpx" : runner);
if (runner === "claude") {
expect(config.acpxAgent).toBe("claude");
expect(config.model).toMatch(/^claude-/);
+2 -2
View File
@@ -182,7 +182,7 @@ function touchCenter(a: React.Touch, b: React.Touch, container: HTMLDivElement):
// ── Status dot colors (raw hex for SVG) ─────────────────────────────────
import { getAdapterLabel } from "../adapters/adapter-display-registry";
import { agentDisplayAdapterType, getAdapterLabel } from "../adapters/adapter-display-registry";
const statusDotColor: Record<string, string> = {
running: "var(--hex-22d3ee)",
@@ -647,7 +647,7 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb
</span>
{agent && (
<span className="text-(length:--text-nano) text-muted-foreground/60 font-mono leading-tight mt-1">
{getAdapterLabel(agent.adapterType)}
{getAdapterLabel(agentDisplayAdapterType(agent))}
</span>
)}
{agent && agent.capabilities && (
+6 -1
View File
@@ -162,9 +162,12 @@ function installStorybookApiFixtures() {
if (url.pathname === "/api/instance/settings/experimental") {
return Response.json(experimentalSettings());
}
if (url.pathname === "/api/instance/settings/general") {
return Response.json(instanceGeneralSettingsSchema.parse({}));
}
if (url.pathname === "/api/health") {
return Response.json({ deploymentMode: "authenticated", localAiLoginSupported: onboardingFixtureState.environments === "local" });
return Response.json({ status: "ok", deploymentMode: "authenticated", localAiLoginSupported: onboardingFixtureState.environments === "local" });
}
if (url.pathname === "/api/instance/settings") {
@@ -506,6 +509,8 @@ function installStorybookApiFixtures() {
{
type: "codex_local",
label: "Codex",
supportedRunners: ["paperclip", "legacy"],
defaultRunner: "paperclip",
source: "builtin",
modelsCount: 3,
loaded: true,
@@ -36,7 +36,7 @@ import {
parseAgentDetailView,
type AgentLocalDetailView,
} from "@/pages/agent-detail-navigation";
import { getAdapterDisplay } from "@/adapters/adapter-display-registry";
import { agentDisplayAdapterType, getAdapterDisplay } from "@/adapters/adapter-display-registry";
import { cn } from "@/lib/utils";
import { RuntimeTestCard } from "../RuntimeTestCard";
import { type TestOutcome } from "../new-agent-fixtures";
@@ -216,7 +216,8 @@ function SettingsPage({ runtimeContent }: { runtimeContent?: ReactNode }) {
: AGENT_DETAIL_NAVIGATION.flatMap((s) => s.items).find(
(t) => t.value === view,
)?.label;
const display = getAdapterDisplay(agent.adapterType);
const displayType = agentDisplayAdapterType(agent);
const display = getAdapterDisplay(displayType);
const Icon = display.icon;
const callbacks = {
onDirtyChange: onDirty,
@@ -295,10 +296,10 @@ function SettingsPage({ runtimeContent }: { runtimeContent?: ReactNode }) {
{agent.name}
</h1>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{agent.adapterType === "claude_local" ||
agent.adapterType === "codex_local" ? (
{displayType === "claude_local" ||
displayType === "codex_local" ? (
<img
src={`/brands/${agent.adapterType === "claude_local" ? "claude" : "codex"}-color.svg`}
src={`/brands/${displayType === "claude_local" ? "claude" : "codex"}-color.svg`}
className="size-4"
alt=""
/>
@@ -98,6 +98,7 @@ export function createSettingsFixtures(
adapterType: adapterType as AgentDetail["adapterType"],
adapterConfig: {
"access.MODEL_API": { type: "secret_ref", secretId: "secret-openai" },
...(adapterType === "paperclip_runner" ? { provider: "codex", model: "gpt-5.6-sol", modelReasoningEffort: "high" } : adapterType === "codex_local" ? { model: "gpt-5.6-sol", modelReasoningEffort: "high" } : {}),
},
runtimeConfig: {
heartbeat: {
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { AgentSettingsPreview } from "../prototypes/agent-settings/AgentSettingsPreview";
const meta = {
title: "Agents/Configuration refresh",
@@ -53,6 +54,7 @@ export const Runtime: Story = {
args: { initialTab: "runtime" },
};
export const CodexRuntime: Story = {
name: "Codex · Existing legacy runner",
args: { initialTab: "runtime", adapterType: "codex_local" },
};
export const OpenCodeRuntime: Story = {
@@ -62,8 +64,18 @@ export const PiRuntime: Story = {
args: { initialTab: "runtime", adapterType: "pi_local" },
};
export const RunnerRuntime: Story = {
name: "Codex · Existing Paperclip Runner",
args: { initialTab: "runtime", adapterType: "paperclip_runner" },
};
export const CodexLegacyLight: Story = { ...CodexRuntime, globals: { theme: "light" } };
export const CodexNativeLight: Story = { ...RunnerRuntime, globals: { theme: "light" } };
export const CodexLegacyMobile: Story = { ...CodexRuntime, globals: { viewport: { value: "mobile1", isRotated: false } } };
export const CodexNativeMobile: Story = { ...RunnerRuntime, globals: { viewport: { value: "mobile1", isRotated: false } } };
export const CodexLegacyAdvanced: Story = { ...CodexRuntime, play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole("button", { name: "Advanced" }));
await expect(await canvas.findByRole("button", { name: "Runner" })).toHaveTextContent("Legacy runner");
} };
export const Secrets: Story = {
name: "Secrets & variables",
args: { initialTab: "secrets" },
@@ -0,0 +1,80 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within, waitFor } from "storybook/test";
import { NewAgent } from "@/pages/NewAgent";
import { models } from "@paperclipai/adapter-codex-local";
import { storybookHiredAgent } from "../../fixtures/paperclipData";
import { resetOnboardingFixtureState, setOnboardingFixtureState } from "../../fixtures/onboardingEnvironment";
/** Real creation screen; existing Storybook API fixtures provide isolated accounts. */
function installCodexCreationFixture({ setupFailure = false }: { setupFailure?: boolean } = {}) {
resetOnboardingFixtureState();
setOnboardingFixtureState({ environments: "local", authSignal: "present", localLoginStatus: "ready", savedManagedSubscription: "openai" });
const previous = window.fetch;
let created = storybookHiredAgent;
const fixtureFetch: typeof fetch = async (input, init) => {
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin);
const body = () => JSON.parse(typeof init?.body === "string" ? init.body : "{}");
if (url.pathname === "/api/instance/settings/experimental") {
const response = await previous(input, init);
return Response.json({ ...await response.json(), enableNativeRunner: false });
}
if (url.pathname.endsWith("/adapters/codex_local/models")) return Response.json(models);
if (url.pathname.endsWith("/adapters/codex_local/test-environment")) {
const request = body();
const native = request.runner !== "legacy";
return Response.json({ adapterType: native ? "paperclip_runner" : "codex_local", testedAt: new Date(0).toISOString(), status: setupFailure && native ? "fail" : "pass",
checks: setupFailure && native ? [{ code: "runner_missing", level: "error", message: "Paperclip Runner is missing from this environment.", hint: "Install the runner or choose Legacy runner in Advanced." }]
: [{ code: native ? "paperclip_runner_codex_hello_probe_passed" : "codex_hello_probe_passed", level: "info", message: "The selected runtime responded." }],
});
}
if (url.pathname.endsWith("/agent-hires")) {
const request = body();
const native = request.runner !== "legacy";
created = { ...storybookHiredAgent, ...request, adapterType: native ? "paperclip_runner" : "codex_local", adapterConfig: { ...request.adapterConfig, ...(native ? { provider: "codex" } : {}) } };
return Response.json({ agent: created, approval: null });
}
if (url.pathname === `/api/agents/${created.id}`) return Response.json(created);
return previous(input, init);
};
window.fetch = fixtureFetch;
return () => { if (window.fetch === fixtureFetch) window.fetch = previous; resetOnboardingFixtureState(); };
}
const meta = {
title: "Agents/Codex runner/Creation",
component: NewAgent,
parameters: { layout: "fullscreen", initialEntries: ["/PAP/agents/new?name=Nova&adapterType=codex_local"], docs: { description: { component: "Production NewAgent page and shared pickers. Accounts and API replies use isolated Storybook fixtures; these stories establish UI behavior, not live execution qualification. The native default works with the old experimental gate off." } } },
beforeEach: ({ parameters }) => installCodexCreationFixture(parameters.codexCreationFixture),
render: () => <NewAgent />,
} satisfies Meta<typeof NewAgent>;
export default meta;
type Story = StoryObj<typeof meta>;
const showRunner: NonNullable<Story["play"]> = async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByText("Advanced", { exact: true, selector: "summary" }));
await expect(await canvas.findByRole("button", { name: "Runner" })).toBeVisible();
};
const connectCodex: NonNullable<Story["play"]> = async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole("radio", { name: /OpenAI.*Subscription/ }));
const connect = await canvas.findByRole("button", { name: /^(Connect|Use saved subscription)$/ });
await waitFor(() => expect(connect).toBeEnabled());
await userEvent.click(connect);
};
export const AutomaticCodex: Story = { play: showRunner };
export const ExplicitLegacy: Story = { parameters: { initialEntries: ["/PAP/agents/new?name=Nova&adapterType=codex_local&runner=legacy"] }, play: showRunner };
export const AutomaticLight: Story = { ...AutomaticCodex, globals: { theme: "light" } };
export const LegacyMobile: Story = { ...ExplicitLegacy, globals: { viewport: { value: "mobile1", isRotated: false } } };
export const SetupFailure: Story = { parameters: { codexCreationFixture: { setupFailure: true } }, play: async context => {
await connectCodex(context);
await expect(await within(context.canvasElement).findByText("Paperclip Runner is missing from this environment.")).toBeVisible();
await showRunner(context);
} };
export const TaskReady: Story = {
play: async context => {
await connectCodex(context);
const canvas = within(context.canvasElement);
await userEvent.click(await canvas.findByRole("button", { name: "Finish setup" }));
await expect(await canvas.findByRole("heading", { name: /is ready/ })).toBeVisible();
},
};