feat(runner): activate qualified OpenCode and ACPX providers (#12691)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip Runner is the experimental native runtime for governed agent work. > - The runtime contracts already describe Codex, OpenCode, and ACPX providers. > - The merged control plane still rejected OpenCode and ACPX for new runner agents. > - Runnerd also selected only the Codex provider implementation. > - This pull request activates the qualified OpenCode and ACPX paths from the form to runnerd. > - The benefit is one durable runner path with provider-specific permissions and recovery. ## Linked Issues or Issue Description Refs #12685 **Subsystem affected** This change affects the runner package, server orchestration, adapter configuration, and UI configuration. **Problem or motivation** Paperclip Runner stores provider contracts for OpenCode and ACPX. New agents cannot select those providers. Runnerd cannot execute those stored provider descriptors. The UI also shows only Codex. **Proposed solution** Accept the qualified OpenCode 1.18.17 profile and the fixed ACPX Claude and Codex profiles. Route them through runnerd. Keep provider selection, model selection, permissions, credentials, events, and recovery inside closed provider-specific boundaries. **Alternatives considered** One option was to keep the contracts dormant. That option leaves stored configuration and runtime behavior out of sync. Another option was to enable every ACPX agent. That option is not safe because Pi does not yet have the same verified launch path. **Roadmap alignment** This change supports the completed cloud and sandbox agent milestone. It also supports self-healing runs and governed agent execution. It does not add a new roadmap surface. ## What Changed - Add one server profile resolver for Codex, OpenCode, and qualified ACPX descriptors. - Keep `adapterConfig` as the provider and permission authority for fresh runs. - Add Paperclip Runner provider, ACPX agent, and provider-specific permission controls to the UI. - Reset the model to a compatible qualified value when the provider changes. - Route Codex, OpenCode, and ACPX through the durable runnerd provider selector. - Add a durable ACPX executor with bounded state, recovery, events, tool receipts, and identity checks. - Remove Codex labels from OpenCode events, results, evidence, and recovery diagnostics. - Pass only provider-specific credential names to child processes. - Keep ACPX Pi unavailable and reject it before process launch. - Keep the existing Paperclip Runner experimental flag unchanged. ## Verification - `pnpm exec vitest run packages/paperclip-runner/src/backends/native-backend-factory.test.ts packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts packages/adapters/codex-local/src/ui/build-config.test.ts ui/src/adapters/codex-local/config-fields.test.tsx server/src/__tests__/adapter-registry.test.ts server/src/__tests__/adapter-routes.test.ts server/src/__tests__/agent-adapter-validation-routes.test.ts server/src/__tests__/company-portability.test.ts server/src/services/native-runtime/runtime-mode.test.ts server/src/services/native-runtime/native-session-executor.test.ts server/src/services/heartbeat-runner-provider-config.test.ts` - The focused TypeScript, server, and UI suites passed 274 tests. - `cargo test -p paperclip-runner-core --test native_provider_backend` - The executable native provider integration suite passed 4 tests. - `cargo test -p paperclip-runner-core --lib` - The Rust unit suite passed 91 tests. - `pnpm -r typecheck` - `pnpm check:token-gates` - `pnpm build` - `git diff --check codex/runner-parity-task-runtime...HEAD` ## Risks - This changes provider process selection and durable recovery. The experimental flag still gates every fresh Paperclip Runner run. - OpenCode requires a model in `provider/model` form and stays pinned to version 1.18.17. - ACPX accepts only exact Claude and Codex profile versions and models. Pi stays unavailable. - ACPX steering stays unavailable and reports that limit through the driver capabilities. - Child processes receive explicit environment allowlists. They do not inherit the full server environment. - This pull request has no database migration. ## Model Used OpenAI Codex, GPT-5, with tool use, code execution, and subagent review. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
72b9f92d76
commit
84bedd4ca1
|
|
@ -128,7 +128,7 @@ describe("buildPaperclipRunnerConfig", () => {
|
|||
it("fails closed to the Codex profile and safe defaults for stale schema values", () => {
|
||||
expect(buildPaperclipRunnerConfig(makeValues({
|
||||
adapterSchemaValues: {
|
||||
provider: "opencode",
|
||||
provider: "unknown",
|
||||
codexPermissionMode: "unrestricted",
|
||||
lifecycleMode: "forever",
|
||||
idleTimeoutMs: -1,
|
||||
|
|
@ -140,6 +140,74 @@ describe("buildPaperclipRunnerConfig", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("builds a qualified OpenCode profile from schema-backed values", () => {
|
||||
expect(buildPaperclipRunnerConfig(makeValues({
|
||||
adapterType: "paperclip_runner",
|
||||
model: "",
|
||||
adapterSchemaValues: {
|
||||
provider: "opencode",
|
||||
opencodePermissionMode: "allow",
|
||||
},
|
||||
}))).toMatchObject({
|
||||
provider: "opencode",
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
opencodePermissionMode: "allow",
|
||||
codexPermissionMode: "untrusted",
|
||||
acpxPermissionMode: "approve-reads",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let a stale schema model override the active Codex model", () => {
|
||||
expect(buildPaperclipRunnerConfig(makeValues({
|
||||
adapterType: "paperclip_runner",
|
||||
model: "gpt-5.6-sol",
|
||||
adapterSchemaValues: {
|
||||
provider: "codex",
|
||||
model: "openrouter/stale-model",
|
||||
codexPermissionMode: "on-request",
|
||||
},
|
||||
}))).toMatchObject({
|
||||
provider: "codex",
|
||||
model: "gpt-5.6-sol",
|
||||
codexPermissionMode: "on-request",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["claude", "claude-sonnet-5"],
|
||||
["codex", "gpt-5.6-sol"],
|
||||
] as const)("builds the qualified ACPX %s profile", (acpxAgent, model) => {
|
||||
expect(buildPaperclipRunnerConfig(makeValues({
|
||||
adapterType: "paperclip_runner",
|
||||
model: "stale-model-from-another-provider",
|
||||
adapterSchemaValues: {
|
||||
provider: "acpx",
|
||||
acpxAgent,
|
||||
acpxPermissionMode: "approve-all",
|
||||
},
|
||||
}))).toMatchObject({
|
||||
provider: "acpx",
|
||||
acpxAgent,
|
||||
model,
|
||||
acpxPermissionMode: "approve-all",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not materialize the unavailable ACPX Pi profile", () => {
|
||||
expect(buildPaperclipRunnerConfig(makeValues({
|
||||
adapterType: "paperclip_runner",
|
||||
model: "",
|
||||
adapterSchemaValues: {
|
||||
provider: "acpx",
|
||||
acpxAgent: "pi",
|
||||
},
|
||||
}))).toMatchObject({
|
||||
provider: "acpx",
|
||||
acpxAgent: "claude",
|
||||
model: "claude-sonnet-5",
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds warm lifecycle values to the shared safe default", () => {
|
||||
expect(buildPaperclipRunnerConfig(makeValues({
|
||||
paperclipRunnerLifecycleMode: "warm",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
buildAdapterEnvConfig,
|
||||
isPaperclipRunnerProvider,
|
||||
resolvePaperclipRunnerIdleTimeoutMs,
|
||||
resolvePaperclipRunnerPermissionMode,
|
||||
type CreateConfigValues,
|
||||
|
|
@ -69,6 +70,7 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
|
|||
/** Build a provider profile accepted by the experimental Rust runner. */
|
||||
export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const config = buildCodexLocalConfig(v);
|
||||
const schemaValues = { ...(v.adapterSchemaValues ?? {}) };
|
||||
for (const unsupportedKey of [
|
||||
"engine",
|
||||
"agentCommand",
|
||||
|
|
@ -86,8 +88,19 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
|
|||
"extraArgs",
|
||||
]) {
|
||||
delete config[unsupportedKey];
|
||||
delete schemaValues[unsupportedKey];
|
||||
}
|
||||
const schemaValues = v.adapterSchemaValues ?? {};
|
||||
const providerCandidate = schemaValues.provider;
|
||||
const provider = isPaperclipRunnerProvider(providerCandidate)
|
||||
? providerCandidate
|
||||
: "codex";
|
||||
const acpxAgent = schemaValues.acpxAgent === "codex" ? "codex" : "claude";
|
||||
const schemaModel = typeof schemaValues.model === "string"
|
||||
? schemaValues.model.trim()
|
||||
: "";
|
||||
const configuredModel = typeof config.model === "string"
|
||||
? config.model.trim()
|
||||
: "";
|
||||
const lifecycleCandidate = v.paperclipRunnerLifecycleMode ?? schemaValues.lifecycleMode;
|
||||
const lifecycleMode = lifecycleCandidate === "warm" ? "warm" : "per_turn";
|
||||
const configuredIdleTimeoutMs =
|
||||
|
|
@ -95,13 +108,47 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
|
|||
const idleTimeoutMs = resolvePaperclipRunnerIdleTimeoutMs(
|
||||
configuredIdleTimeoutMs,
|
||||
);
|
||||
for (const normalizedKey of [
|
||||
"provider",
|
||||
"model",
|
||||
"acpxAgent",
|
||||
"codexPermissionMode",
|
||||
"opencodePermissionMode",
|
||||
"acpxPermissionMode",
|
||||
"lifecycleMode",
|
||||
"idleTimeoutMs",
|
||||
]) {
|
||||
delete schemaValues[normalizedKey];
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
provider: "codex",
|
||||
...schemaValues,
|
||||
provider,
|
||||
codexPermissionMode: resolvePaperclipRunnerPermissionMode(
|
||||
"codex",
|
||||
v.codexPermissionMode ?? schemaValues.codexPermissionMode,
|
||||
v.adapterSchemaValues?.codexPermissionMode ?? v.codexPermissionMode,
|
||||
),
|
||||
opencodePermissionMode: resolvePaperclipRunnerPermissionMode(
|
||||
"opencode",
|
||||
v.adapterSchemaValues?.opencodePermissionMode,
|
||||
),
|
||||
acpxPermissionMode: resolvePaperclipRunnerPermissionMode(
|
||||
"acpx",
|
||||
v.adapterSchemaValues?.acpxPermissionMode,
|
||||
),
|
||||
...(provider === "opencode"
|
||||
? {
|
||||
model: schemaModel
|
||||
|| configuredModel
|
||||
|| "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
}
|
||||
: {}),
|
||||
...(provider === "acpx"
|
||||
? {
|
||||
acpxAgent,
|
||||
model: acpxAgent === "claude" ? "claude-sonnet-5" : "gpt-5.6-sol",
|
||||
}
|
||||
: {}),
|
||||
lifecycleMode,
|
||||
...(lifecycleMode === "warm" ? { idleTimeoutMs } : {}),
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -213,7 +213,8 @@ impl AcpxProviderSession {
|
|||
LocalRunnerError::invalid(format!("ACPX authorized tools are invalid: {error}"))
|
||||
})?;
|
||||
let reserved_tool_bridge = reserved_terminal_tool_bridge()?;
|
||||
let mut transport = AcpxSidecarTransport::start(&config.transport)?;
|
||||
let mut transport =
|
||||
AcpxSidecarTransport::start_for_agent(&config.transport, &config.agent)?;
|
||||
let bootstrap = bootstrap(&mut transport, config);
|
||||
let (identity, state) = match bootstrap {
|
||||
Ok(value) => value,
|
||||
|
|
@ -705,7 +706,10 @@ impl AcpxProviderSession {
|
|||
|
||||
let mut restart_config = self.config.clone();
|
||||
restart_config.expected_identity = Some(self.identity.clone());
|
||||
let mut replacement = AcpxSidecarTransport::start(&restart_config.transport)?;
|
||||
let mut replacement = AcpxSidecarTransport::start_for_agent(
|
||||
&restart_config.transport,
|
||||
&restart_config.agent,
|
||||
)?;
|
||||
let (replacement_identity, _) = match bootstrap(&mut replacement, &restart_config) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
|
|
|
|||
|
|
@ -82,12 +82,54 @@ pub struct AcpxSidecarTransport {
|
|||
|
||||
impl AcpxSidecarTransport {
|
||||
pub fn start(config: &AcpxSidecarTransportConfig) -> Result<Self, LocalRunnerError> {
|
||||
Self::start_with_environment_keys(config, &[])
|
||||
}
|
||||
|
||||
pub fn start_for_agent(
|
||||
config: &AcpxSidecarTransportConfig,
|
||||
agent: &str,
|
||||
) -> Result<Self, LocalRunnerError> {
|
||||
let credential_keys: &[&str] = match agent {
|
||||
"claude" => &["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
|
||||
"codex" => &["OPENAI_API_KEY", "CODEX_API_KEY"],
|
||||
_ => {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX sidecar credentials require a qualified claude or codex agent",
|
||||
))
|
||||
}
|
||||
};
|
||||
let mut keys = vec![
|
||||
"LANGUAGE",
|
||||
"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",
|
||||
"RUST_BACKTRACE",
|
||||
"PAPERCLIP_NATIVE_MCP_NAME",
|
||||
"PAPERCLIP_NATIVE_MCP_URL",
|
||||
];
|
||||
keys.extend_from_slice(credential_keys);
|
||||
Self::start_with_environment_keys(config, &keys)
|
||||
}
|
||||
|
||||
fn start_with_environment_keys(
|
||||
config: &AcpxSidecarTransportConfig,
|
||||
environment_keys: &[&str],
|
||||
) -> Result<Self, LocalRunnerError> {
|
||||
config.validate()?;
|
||||
let process = SupervisedProcess::spawn(
|
||||
let process = SupervisedProcess::spawn_with_environment_keys(
|
||||
&config.command,
|
||||
&config.args,
|
||||
config.shutdown_grace,
|
||||
ACPX_SIDECAR_MAX_FRAME_BYTES,
|
||||
environment_keys,
|
||||
)?;
|
||||
Ok(Self {
|
||||
process,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||
.find(|pair| pair[0] == "--mode")
|
||||
.map(|pair| pair[1].as_str())
|
||||
.unwrap_or("happy");
|
||||
let profile_digest = args
|
||||
.windows(2)
|
||||
.find(|pair| pair[0] == "--profile-digest")
|
||||
.map(|pair| pair[1].as_str())
|
||||
.unwrap_or("sha256:1111111111111111111111111111111111111111111111111111111111111111");
|
||||
let stdin = io::stdin();
|
||||
let mut stdout = io::stdout().lock();
|
||||
let mut next_sequence = 1_u64;
|
||||
|
|
@ -34,7 +39,10 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||
.and_then(Value::as_str)
|
||||
.ok_or("request command is missing")?;
|
||||
if command == "permission.resolve" {
|
||||
write_json(&mut stdout, &bootstrap_success(id, command, &request, mode))?;
|
||||
write_json(
|
||||
&mut stdout,
|
||||
&bootstrap_success(id, command, &request, mode, profile_digest),
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
match mode {
|
||||
|
|
@ -118,7 +126,10 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||
| "suspend-wrong-ack"
|
||||
| "suspend-wrong-identity"
|
||||
| "suspend-missing-identity" => {
|
||||
write_json(&mut stdout, &bootstrap_success(id, command, &request, mode))?;
|
||||
write_json(
|
||||
&mut stdout,
|
||||
&bootstrap_success(id, command, &request, mode, profile_digest),
|
||||
)?;
|
||||
let params = request.get("params").unwrap_or(&Value::Null);
|
||||
let turn_id = params
|
||||
.get("turnId")
|
||||
|
|
@ -527,7 +538,13 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn bootstrap_success(id: u64, command: &str, request: &Value, mode: &str) -> Value {
|
||||
fn bootstrap_success(
|
||||
id: u64,
|
||||
command: &str,
|
||||
request: &Value,
|
||||
mode: &str,
|
||||
profile_digest: &str,
|
||||
) -> Value {
|
||||
if command == "permission.resolve" {
|
||||
return json!({
|
||||
"protocolVersion": GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
|
||||
|
|
@ -567,7 +584,7 @@ fn bootstrap_success(id: u64, command: &str, request: &Value, mode: &str) -> Val
|
|||
"acpxRecordId": "record-1",
|
||||
"backendSessionId": "backend-1",
|
||||
"agentSessionId": "agent-1",
|
||||
"profileDigest": format!("sha256:{}", "1".repeat(64)),
|
||||
"profileDigest": profile_digest,
|
||||
"workspaceDigest": format!("sha256:{}", "2".repeat(64)),
|
||||
"requestedModel": model,
|
||||
"effectiveModel": if mode == "bootstrap-wrong-model" { "wrong-model" } else { model },
|
||||
|
|
@ -592,7 +609,7 @@ fn bootstrap_success(id: u64, command: &str, request: &Value, mode: &str) -> Val
|
|||
"acpxRecordId": "record-1",
|
||||
"backendSessionId": "backend-1",
|
||||
"agentSessionId": "agent-1",
|
||||
"profileDigest": format!("sha256:{}", "1".repeat(64)),
|
||||
"profileDigest": profile_digest,
|
||||
"workspaceDigest": format!("sha256:{}", "2".repeat(64)),
|
||||
"requestedModel": "gpt-5.6-sol",
|
||||
"effectiveModel": "gpt-5.6-sol",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use paperclip_runner_core::durable::{
|
|||
capture_bootstrap_ticket, run_durable_runner, DurableRunnerConfig,
|
||||
};
|
||||
use paperclip_runner_core::local_runner::{run_local_runner, LocalRunnerError, RunnerConfig};
|
||||
use paperclip_runner_core::provider_backend::CodexCommandExecutor;
|
||||
use paperclip_runner_core::native_provider_backend::NativeProviderCommandExecutor;
|
||||
use serde_json::json;
|
||||
|
||||
const RUNNERD_BUILD_METADATA_SCHEMA: &str = "paperclip-runner/runnerd-build-metadata/v1";
|
||||
|
|
@ -129,7 +129,7 @@ fn run_durable(args: &[String]) -> Result<(), LocalRunnerError> {
|
|||
reconnect_grace: optional_u64(args, "--reconnect-grace-ms")?.map(Duration::from_millis),
|
||||
max_runtime: duration("--max-runtime-ms", 60 * 60 * 1000)?,
|
||||
};
|
||||
let executor = CodexCommandExecutor::with_runner_config(state_dir, &config);
|
||||
let executor = NativeProviderCommandExecutor::with_runner_config(state_dir, &config);
|
||||
run_durable_runner(config, ticket, executor)
|
||||
.map_err(|error| LocalRunnerError::invalid(error.to_string()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -256,9 +256,12 @@ pub struct CodexProviderConfig {
|
|||
|
||||
impl CodexProviderConfig {
|
||||
pub fn validate(&self) -> Result<(), LocalRunnerError> {
|
||||
if self.provider != "codex" || self.driver != "codex_app_server" {
|
||||
if !matches!(
|
||||
(self.provider.as_str(), self.driver.as_str()),
|
||||
("codex", "codex_app_server") | ("opencode", "opencode_server")
|
||||
) {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"the initial runner provider must be codex through codex_app_server",
|
||||
"local runner provider must be codex through codex_app_server or opencode through opencode_server",
|
||||
));
|
||||
}
|
||||
if self.provider_version.trim().is_empty() || self.provider_version.len() > 120 {
|
||||
|
|
@ -291,6 +294,16 @@ impl CodexProviderConfig {
|
|||
{
|
||||
return Err(LocalRunnerError::invalid("Codex model is invalid"));
|
||||
}
|
||||
if self.provider == "opencode"
|
||||
&& self
|
||||
.model
|
||||
.as_ref()
|
||||
.is_none_or(|model| !model.contains('/') || model.chars().any(char::is_control))
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"OpenCode model must be a qualified provider/model identifier",
|
||||
));
|
||||
}
|
||||
if self.provider_session_id.as_ref().is_some_and(|session_id| {
|
||||
session_id.is_empty()
|
||||
|| session_id.len() > 240
|
||||
|
|
@ -513,12 +526,50 @@ impl CodexProvider {
|
|||
let authorized_tools = authorized_tools.into_iter().collect::<Vec<_>>();
|
||||
let (dynamic_tools, authorized_tool_ids) =
|
||||
codex_dynamic_tools(authorized_tools.iter().cloned())?;
|
||||
let common_environment_keys = [
|
||||
"LANGUAGE",
|
||||
"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",
|
||||
"RUST_BACKTRACE",
|
||||
];
|
||||
let provider_environment_keys = if config.provider == "opencode" {
|
||||
vec![
|
||||
"OPENROUTER_API_KEY",
|
||||
"PAPERCLIP_NATIVE_MCP_NAME",
|
||||
"PAPERCLIP_NATIVE_MCP_URL",
|
||||
"PAPERCLIP_NATIVE_MCP_TOKEN",
|
||||
"PAPERCLIP_OPENCODE_COMMAND",
|
||||
"PAPERCLIP_OPENCODE_PERMISSION_MODE",
|
||||
"PAPERCLIP_OPENCODE_RUNTIME_DIR",
|
||||
"PAPERCLIP_RUNNER_INSTANCE_ID",
|
||||
"PAPERCLIP_RUN_ID",
|
||||
"PAPERCLIP_NORMALIZED_SESSION_ID",
|
||||
"PAPERCLIP_NATIVE_RUNTIME_CONTEXT_PATH",
|
||||
]
|
||||
} else {
|
||||
vec!["CODEX_HOME", "OPENAI_API_KEY", "CODEX_API_KEY"]
|
||||
};
|
||||
let environment_keys = common_environment_keys
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(provider_environment_keys)
|
||||
.collect::<Vec<_>>();
|
||||
let mut provider = Self {
|
||||
process: SupervisedProcess::spawn(
|
||||
process: SupervisedProcess::spawn_with_environment_keys(
|
||||
&config.command,
|
||||
&config.args,
|
||||
Duration::from_secs(2),
|
||||
CODEX_APP_SERVER_MAX_FRAME_BYTES,
|
||||
&environment_keys,
|
||||
)?,
|
||||
config: config.clone(),
|
||||
authorized_tools,
|
||||
|
|
@ -2278,6 +2329,31 @@ fn codex_question_response(
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn admits_only_exact_local_facade_provider_driver_pairs() {
|
||||
let mut config = CodexProviderConfig {
|
||||
provider: "opencode".to_owned(),
|
||||
driver: "opencode_server".to_owned(),
|
||||
provider_version: "1.18.17".to_owned(),
|
||||
command: PathBuf::from("node"),
|
||||
args: Vec::new(),
|
||||
cwd: std::env::current_dir()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
model: Some("openrouter/model".to_owned()),
|
||||
provider_session_id: None,
|
||||
instructions: String::new(),
|
||||
approval_policy: "never".to_owned(),
|
||||
};
|
||||
config.validate().unwrap();
|
||||
config.driver = "codex_app_server".to_owned();
|
||||
assert!(config.validate().is_err());
|
||||
config.driver = "opencode_server".to_owned();
|
||||
config.model = Some("unqualified".to_owned());
|
||||
assert!(config.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_codex_questions_and_responses_without_provider_leakage() {
|
||||
let (request_id, question_set, labels) = codex_question_set(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
pub mod acpx_event_payload;
|
||||
pub mod acpx_event_scope;
|
||||
pub mod acpx_provider_backend;
|
||||
pub mod acpx_provider_checkpoint;
|
||||
pub mod acpx_provider_session;
|
||||
pub mod acpx_provider_state;
|
||||
|
|
@ -11,6 +12,7 @@ pub mod durable;
|
|||
pub mod fake_harness;
|
||||
pub mod generated_acpx_sidecar_contract;
|
||||
pub mod local_runner;
|
||||
pub mod native_provider_backend;
|
||||
pub mod process_supervisor;
|
||||
pub mod provider_backend;
|
||||
pub mod provider_bridge;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,165 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::acpx_provider_backend::{AcpxCommandExecutor, ACPX_PROVIDER_STATE_FILE};
|
||||
use crate::durable::{
|
||||
Command, CommandExecution, CommandExecutor, DurableRunnerConfig, DurableRunnerError,
|
||||
PolledEvent,
|
||||
};
|
||||
use crate::provider_backend::{CodexCommandExecutor, CODEX_PROVIDER_STATE_FILE};
|
||||
|
||||
enum SelectedExecutor {
|
||||
LocalFacade(CodexCommandExecutor),
|
||||
Acpx(AcpxCommandExecutor),
|
||||
}
|
||||
|
||||
impl CommandExecutor for SelectedExecutor {
|
||||
fn execute(&mut self, command: &Command) -> Result<CommandExecution, DurableRunnerError> {
|
||||
match self {
|
||||
Self::LocalFacade(executor) => executor.execute(command),
|
||||
Self::Acpx(executor) => executor.execute(command),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_events(&mut self) -> Result<Vec<PolledEvent>, DurableRunnerError> {
|
||||
match self {
|
||||
Self::LocalFacade(executor) => executor.poll_events(),
|
||||
Self::Acpx(executor) => executor.poll_events(),
|
||||
}
|
||||
}
|
||||
|
||||
fn acknowledge_events(&mut self, count: usize) -> Result<(), DurableRunnerError> {
|
||||
match self {
|
||||
Self::LocalFacade(executor) => executor.acknowledge_events(count),
|
||||
Self::Acpx(executor) => executor.acknowledge_events(count),
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown(&mut self) -> Result<(), DurableRunnerError> {
|
||||
match self {
|
||||
Self::LocalFacade(executor) => executor.shutdown(),
|
||||
Self::Acpx(executor) => executor.shutdown(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects one durable provider implementation for the lifetime of a runner.
|
||||
/// Recovery selection comes only from mutually exclusive private state files;
|
||||
/// fresh selection comes only from the controller's run.prepare descriptor.
|
||||
pub struct NativeProviderCommandExecutor {
|
||||
state_dir: PathBuf,
|
||||
config: DurableRunnerConfig,
|
||||
selected: Option<SelectedExecutor>,
|
||||
recovery_checked: bool,
|
||||
}
|
||||
|
||||
impl NativeProviderCommandExecutor {
|
||||
pub fn with_runner_config(state_dir: impl Into<PathBuf>, config: &DurableRunnerConfig) -> Self {
|
||||
Self {
|
||||
state_dir: state_dir.into(),
|
||||
config: config.clone(),
|
||||
selected: None,
|
||||
recovery_checked: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn select_recovery(&mut self) -> Result<(), DurableRunnerError> {
|
||||
if self.recovery_checked {
|
||||
return Ok(());
|
||||
}
|
||||
self.recovery_checked = true;
|
||||
let codex = self.state_dir.join(CODEX_PROVIDER_STATE_FILE).exists();
|
||||
let acpx = self.state_dir.join(ACPX_PROVIDER_STATE_FILE).exists();
|
||||
if codex && acpx {
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"runner state contains conflicting local provider authorities",
|
||||
));
|
||||
}
|
||||
self.selected = if acpx {
|
||||
Some(SelectedExecutor::Acpx(
|
||||
AcpxCommandExecutor::with_runner_config(&self.state_dir, &self.config),
|
||||
))
|
||||
} else if codex {
|
||||
Some(SelectedExecutor::LocalFacade(
|
||||
CodexCommandExecutor::with_runner_config(&self.state_dir, &self.config),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn select_from_payload(&mut self, payload: &Value) -> Result<(), DurableRunnerError> {
|
||||
let kind = payload
|
||||
.pointer("/provider/kind")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
DurableRunnerError::invalid(
|
||||
"run.prepare requires a supported provider kind before runner execution",
|
||||
)
|
||||
})?;
|
||||
self.selected = Some(match kind {
|
||||
"codex" | "opencode" => SelectedExecutor::LocalFacade(
|
||||
CodexCommandExecutor::with_runner_config(&self.state_dir, &self.config),
|
||||
),
|
||||
"acpx" => SelectedExecutor::Acpx(AcpxCommandExecutor::with_runner_config(
|
||||
&self.state_dir,
|
||||
&self.config,
|
||||
)),
|
||||
_ => {
|
||||
return Err(DurableRunnerError::invalid(format!(
|
||||
"provider kind {kind} is not executable through the local runnerd boundary"
|
||||
)))
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandExecutor for NativeProviderCommandExecutor {
|
||||
fn execute(&mut self, command: &Command) -> Result<CommandExecution, DurableRunnerError> {
|
||||
self.select_recovery()?;
|
||||
if self.selected.is_none()
|
||||
&& matches!(command.command_type.as_str(), "run.prepare" | "run.attach")
|
||||
{
|
||||
self.select_from_payload(&command.payload)?;
|
||||
}
|
||||
self.selected
|
||||
.as_mut()
|
||||
.ok_or_else(|| {
|
||||
DurableRunnerError::invalid(
|
||||
"run.prepare must select a provider before provider commands execute",
|
||||
)
|
||||
})?
|
||||
.execute(command)
|
||||
}
|
||||
|
||||
fn poll_events(&mut self) -> Result<Vec<PolledEvent>, DurableRunnerError> {
|
||||
self.select_recovery()?;
|
||||
self.selected
|
||||
.as_mut()
|
||||
.map_or_else(|| Ok(Vec::new()), CommandExecutor::poll_events)
|
||||
}
|
||||
|
||||
fn acknowledge_events(&mut self, count: usize) -> Result<(), DurableRunnerError> {
|
||||
self.select_recovery()?;
|
||||
if let Some(executor) = self.selected.as_mut() {
|
||||
executor.acknowledge_events(count)
|
||||
} else if count == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DurableRunnerError::invalid(
|
||||
"cannot acknowledge provider events before provider selection",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown(&mut self) -> Result<(), DurableRunnerError> {
|
||||
if let Some(executor) = self.selected.as_mut() {
|
||||
executor.shutdown()
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -201,6 +201,16 @@ impl SupervisedProcess {
|
|||
args: &[String],
|
||||
shutdown_grace: Duration,
|
||||
max_line_bytes: usize,
|
||||
) -> Result<Self, LocalRunnerError> {
|
||||
Self::spawn_with_environment_keys(program, args, shutdown_grace, max_line_bytes, &[])
|
||||
}
|
||||
|
||||
pub fn spawn_with_environment_keys(
|
||||
program: &Path,
|
||||
args: &[String],
|
||||
shutdown_grace: Duration,
|
||||
max_line_bytes: usize,
|
||||
additional_environment_keys: &[&str],
|
||||
) -> Result<Self, LocalRunnerError> {
|
||||
let mut command = Command::new(program);
|
||||
command
|
||||
|
|
@ -222,7 +232,10 @@ impl SupervisedProcess {
|
|||
"TEMP",
|
||||
"TMP",
|
||||
"TZ",
|
||||
] {
|
||||
]
|
||||
.into_iter()
|
||||
.chain(additional_environment_keys.iter().copied())
|
||||
{
|
||||
if let Some(value) = std::env::var_os(key) {
|
||||
command.env(key, value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ use crate::provider_events::{
|
|||
};
|
||||
|
||||
const PROVIDER_STATE_SCHEMA: &str = "paperclip.runner.codex-provider-state.v1";
|
||||
const PROVIDER_STATE_FILE: &str = "codex-provider-state.json";
|
||||
pub const CODEX_PROVIDER_STATE_FILE: &str = "codex-provider-state.json";
|
||||
const MAX_PROVIDER_STATE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
const MAX_EVENTS_PER_POLL: usize = 128;
|
||||
// One accepted semantic call can produce an input and a result event. Normal
|
||||
|
|
@ -242,16 +242,22 @@ fn terminal_events(state: &CodexProviderState, event_type: &str) -> Vec<Normaliz
|
|||
let succeeded = event_type == "turn.completed";
|
||||
let cancelled = matches!(event_type, "turn.cancelled" | "turn.interrupted");
|
||||
let disposition = if succeeded { "done" } else { "needs_review" };
|
||||
let provider = state.config.provider.as_str();
|
||||
let provider_name = if provider == "opencode" {
|
||||
"OpenCode"
|
||||
} else {
|
||||
"Codex"
|
||||
};
|
||||
let summary = state.last_agent_message.clone().unwrap_or_else(|| {
|
||||
if succeeded {
|
||||
"Codex completed the requested work.".to_owned()
|
||||
format!("{provider_name} completed the requested work.")
|
||||
} else if cancelled {
|
||||
"The Codex run stopped before it completed.".to_owned()
|
||||
format!("The {provider_name} run stopped before it completed.")
|
||||
} else {
|
||||
"The Codex run failed before it completed.".to_owned()
|
||||
format!("The {provider_name} run failed before it completed.")
|
||||
}
|
||||
});
|
||||
let evidence_ref = "provider:codex:agent-message";
|
||||
let evidence_ref = format!("provider:{provider}:agent-message");
|
||||
let criteria = contract
|
||||
.criterion_ids
|
||||
.iter()
|
||||
|
|
@ -259,7 +265,7 @@ fn terminal_events(state: &CodexProviderState, event_type: &str) -> Vec<Normaliz
|
|||
json!({
|
||||
"criterionId": criterion_id,
|
||||
"status": if succeeded { "satisfied" } else { "unknown" },
|
||||
"evidenceRefs": if succeeded { vec![evidence_ref] } else { Vec::<&str>::new() },
|
||||
"evidenceRefs": if succeeded { vec![evidence_ref.as_str()] } else { Vec::<&str>::new() },
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
|
@ -272,7 +278,7 @@ fn terminal_events(state: &CodexProviderState, event_type: &str) -> Vec<Normaliz
|
|||
"objectiveSatisfied": succeeded,
|
||||
"criteria": criteria,
|
||||
"remainingWork": if succeeded { Vec::<Value>::new() } else { vec![json!({
|
||||
"description": "Review the stopped Codex run and continue the task.",
|
||||
"description": format!("Review the stopped {provider_name} run and continue the task."),
|
||||
"blocksCompletion": true,
|
||||
})] },
|
||||
},
|
||||
|
|
@ -280,7 +286,7 @@ fn terminal_events(state: &CodexProviderState, event_type: &str) -> Vec<Normaliz
|
|||
"verification": [],
|
||||
"attentionRequests": if succeeded { Vec::<Value>::new() } else { vec![json!({
|
||||
"kind": "review",
|
||||
"summary": "Review the stopped Codex run before continuing.",
|
||||
"summary": format!("Review the stopped {provider_name} run before continuing."),
|
||||
"ownerClass": "human",
|
||||
})] },
|
||||
"artifacts": [],
|
||||
|
|
@ -296,6 +302,7 @@ fn terminal_events(state: &CodexProviderState, event_type: &str) -> Vec<Normaliz
|
|||
};
|
||||
let terminal = json!({
|
||||
"schema": "paperclip.prp.terminal.v1",
|
||||
"provider": provider,
|
||||
"turnTerminalState": turn_terminal_state,
|
||||
"runTerminalState": if succeeded { "succeeded" } else if cancelled { "cancelled" } else { "failed" },
|
||||
"reportedWorkDisposition": disposition,
|
||||
|
|
@ -314,6 +321,35 @@ fn terminal_events(state: &CodexProviderState, event_type: &str) -> Vec<Normaliz
|
|||
]
|
||||
}
|
||||
|
||||
fn relabel_provider_event(
|
||||
mut event: NormalizedProviderEvent,
|
||||
provider: &str,
|
||||
) -> NormalizedProviderEvent {
|
||||
if provider == "codex" {
|
||||
return event;
|
||||
}
|
||||
fn relabel(value: &mut Value, provider: &str) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
if object.get("provider").and_then(Value::as_str) == Some("codex") {
|
||||
object.insert("provider".to_owned(), json!(provider));
|
||||
}
|
||||
for value in object.values_mut() {
|
||||
relabel(value, provider);
|
||||
}
|
||||
}
|
||||
Value::Array(values) => {
|
||||
for value in values {
|
||||
relabel(value, provider);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
relabel(&mut event.payload, provider);
|
||||
event
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodexProviderState {
|
||||
|
|
@ -597,7 +633,7 @@ impl CodexProviderState {
|
|||
event_type: "harness.diagnostic".to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: json!({
|
||||
"provider": "codex",
|
||||
"provider": self.config.provider,
|
||||
"code": "semantic_tool_turn_receipt_limit",
|
||||
"operationId": operation_id,
|
||||
"callId": call_id,
|
||||
|
|
@ -812,7 +848,7 @@ impl CodexCommandExecutor {
|
|||
}
|
||||
|
||||
fn state_path(&self) -> PathBuf {
|
||||
self.state_dir.join(PROVIDER_STATE_FILE)
|
||||
self.state_dir.join(CODEX_PROVIDER_STATE_FILE)
|
||||
}
|
||||
|
||||
fn restore(&mut self) -> Result<(), DurableRunnerError> {
|
||||
|
|
@ -867,15 +903,25 @@ impl CodexCommandExecutor {
|
|||
{
|
||||
return Ok(());
|
||||
}
|
||||
let provider_label = state.config.provider.clone();
|
||||
let provider_name = if provider_label == "opencode" {
|
||||
"OpenCode"
|
||||
} else {
|
||||
"Codex"
|
||||
};
|
||||
let provider_had_exited = state.lifecycle == "provider_exited";
|
||||
let thread_id = state.thread_id.clone().ok_or_else(|| {
|
||||
DurableRunnerError::invalid("recoverable Codex state omitted its thread id")
|
||||
DurableRunnerError::invalid(format!(
|
||||
"recoverable {provider_name} state omitted its thread id"
|
||||
))
|
||||
})?;
|
||||
let previous_active_turn_id = state.active_provider_turn_id.clone();
|
||||
let process_generation = state
|
||||
.provider_process_generation
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| DurableRunnerError::invalid("Codex process generation exhausted"))?;
|
||||
.ok_or_else(|| {
|
||||
DurableRunnerError::invalid(format!("{provider_name} process generation exhausted"))
|
||||
})?;
|
||||
let completed_turn_authoritative = state.completed_turn_authoritative;
|
||||
let completed_turn_process_generation = state.completed_turn_process_generation;
|
||||
let completed_provider_turn_id = state.completed_provider_turn_id.clone();
|
||||
|
|
@ -895,7 +941,9 @@ impl CodexCommandExecutor {
|
|||
process_generation,
|
||||
)
|
||||
.map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("failed to resume Codex provider: {error}"))
|
||||
DurableRunnerError::invalid(format!(
|
||||
"failed to resume {provider_name} provider: {error}"
|
||||
))
|
||||
})?;
|
||||
provider.enable_durable_tool_call_replays();
|
||||
provider
|
||||
|
|
@ -905,7 +953,7 @@ impl CodexCommandExecutor {
|
|||
)
|
||||
.map_err(|error| {
|
||||
DurableRunnerError::invalid(format!(
|
||||
"failed to restore Codex provider turn identities: {error}"
|
||||
"failed to restore local provider turn identities: {error}"
|
||||
))
|
||||
})?;
|
||||
let recovered_active_turn_id = provider.active_provider_turn_id().map(str::to_owned);
|
||||
|
|
@ -946,9 +994,9 @@ impl CodexCommandExecutor {
|
|||
event_type: "harness.diagnostic".to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: json!({
|
||||
"provider": "codex",
|
||||
"provider": provider_label,
|
||||
"code": "legacy_provider_turn_epoch_ambiguous",
|
||||
"message": "Codex recovery could not safely identify and settle active work from a saturated legacy replay epoch; Paperclip terminated the provider and closed the durable run",
|
||||
"message": format!("{provider_name} recovery could not safely identify and settle active work from a saturated legacy replay epoch; Paperclip terminated the provider and closed the durable run"),
|
||||
"paperclipAccepted": false,
|
||||
"providerReportedActive": provider_reported_active,
|
||||
"ambiguousStartPending": ambiguous_turn_start_pending,
|
||||
|
|
@ -1001,10 +1049,10 @@ impl CodexCommandExecutor {
|
|||
event_type: "harness.diagnostic".to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: json!({
|
||||
"provider": "codex",
|
||||
"provider": provider_label,
|
||||
"code": "provider_turn_identity_reused",
|
||||
"providerTurnId": reused_provider_turn_id,
|
||||
"message": "Codex recovery reported a previously settled turn identity as active; Paperclip terminated the provider and closed the durable run",
|
||||
"message": format!("{provider_name} recovery reported a previously settled turn identity as active; Paperclip terminated the provider and closed the durable run"),
|
||||
"paperclipAccepted": false,
|
||||
"providerReportedActive": true,
|
||||
"providerShutdownFailed": provider_shutdown_failed,
|
||||
|
|
@ -1016,12 +1064,12 @@ impl CodexCommandExecutor {
|
|||
if ambiguous_turn_start_pending {
|
||||
let recovered_turn_id = recovered_active_turn_id.as_deref().ok_or_else(|| {
|
||||
DurableRunnerError::invalid(
|
||||
"cannot safely recover an ambiguous Codex turn start without an active replacement turn",
|
||||
format!("cannot safely recover an ambiguous {provider_name} turn start without an active replacement turn"),
|
||||
)
|
||||
})?;
|
||||
if completed_provider_turn_id.as_deref() == Some(recovered_turn_id) {
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"ambiguous Codex turn recovery reused the previously completed turn identity",
|
||||
format!("ambiguous {provider_name} turn recovery reused the previously completed turn identity"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -1035,7 +1083,7 @@ impl CodexCommandExecutor {
|
|||
)
|
||||
.map_err(|error| {
|
||||
DurableRunnerError::invalid(format!(
|
||||
"failed to restore Codex completion authority: {error}"
|
||||
"failed to restore local provider completion authority: {error}"
|
||||
))
|
||||
})?;
|
||||
let resumed_provider_session_id = provider.provider_session_id().map(str::to_owned);
|
||||
|
|
@ -1053,7 +1101,7 @@ impl CodexCommandExecutor {
|
|||
event_type: "session.resumed".to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: json!({
|
||||
"provider": "codex",
|
||||
"provider": provider_label,
|
||||
"providerSessionId": thread_id.clone(),
|
||||
"providerAccountSessionId": resumed_provider_session_id,
|
||||
"processId": resumed_process_id,
|
||||
|
|
@ -1105,7 +1153,7 @@ impl CodexCommandExecutor {
|
|||
event_type: "session.reconciled".to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: json!({
|
||||
"provider": "codex",
|
||||
"provider": provider_label,
|
||||
"providerSessionId": thread_id,
|
||||
"previousProviderTurnId": previous_active_turn_id.clone(),
|
||||
"activeProviderTurnId": recovered_active_turn_id.clone(),
|
||||
|
|
@ -1121,7 +1169,7 @@ impl CodexCommandExecutor {
|
|||
event_type: "turn.failed".to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: json!({
|
||||
"provider": "codex",
|
||||
"provider": provider_label,
|
||||
"providerTurnId": previous_active_turn_id,
|
||||
"status": "failed",
|
||||
"providerTerminalObserved": false,
|
||||
|
|
@ -1207,6 +1255,8 @@ impl CodexCommandExecutor {
|
|||
config
|
||||
.validate()
|
||||
.map_err(|error| DurableRunnerError::invalid(error.to_string()))?;
|
||||
let provider_name = config.provider.clone();
|
||||
let driver = config.driver.clone();
|
||||
let completion_contract = completion_contract(payload)?;
|
||||
let tool_set = authorized_tool_set(payload)?;
|
||||
if let Some(state) = self.state.as_mut() {
|
||||
|
|
@ -1251,8 +1301,8 @@ impl CodexCommandExecutor {
|
|||
}
|
||||
Ok(CommandExecution::result(json!({
|
||||
"status": "prepared",
|
||||
"provider": "codex",
|
||||
"driver": "codex_app_server",
|
||||
"provider": provider_name,
|
||||
"driver": driver,
|
||||
})))
|
||||
}
|
||||
|
||||
|
|
@ -1361,7 +1411,7 @@ impl CodexCommandExecutor {
|
|||
provider.process_id(),
|
||||
)
|
||||
};
|
||||
let provider_version = {
|
||||
let (provider_name, driver, provider_version) = {
|
||||
let state = self
|
||||
.state
|
||||
.as_mut()
|
||||
|
|
@ -1375,14 +1425,18 @@ impl CodexCommandExecutor {
|
|||
state.receipt_limit_interrupt_attempts = 0;
|
||||
state.receipt_limit_interrupt_deadline_unix_ms = None;
|
||||
state.lifecycle = "session_open".to_owned();
|
||||
state.config.provider_version.clone()
|
||||
(
|
||||
state.config.provider.clone(),
|
||||
state.config.driver.clone(),
|
||||
state.config.provider_version.clone(),
|
||||
)
|
||||
};
|
||||
self.save_state()?;
|
||||
Ok(CommandExecution {
|
||||
result: json!({
|
||||
"status": if resumed { "resumed" } else { "started" },
|
||||
"provider": "codex",
|
||||
"driver": "codex_app_server",
|
||||
"provider": provider_name,
|
||||
"driver": driver,
|
||||
"providerVersion": provider_version,
|
||||
"providerSessionId": thread_id,
|
||||
"processId": process_id,
|
||||
|
|
@ -1396,7 +1450,7 @@ impl CodexCommandExecutor {
|
|||
.to_owned(),
|
||||
EventPriority::P0,
|
||||
json!({
|
||||
"provider": "codex",
|
||||
"provider": provider_name,
|
||||
"providerSessionId": thread_id,
|
||||
"providerAccountSessionId": provider_session_id,
|
||||
"processId": process_id,
|
||||
|
|
@ -1436,13 +1490,19 @@ impl CodexCommandExecutor {
|
|||
state.receipt_limit_interrupt_deadline_unix_ms = None;
|
||||
state.last_agent_message = None;
|
||||
state.lifecycle = "closed".to_owned();
|
||||
let provider_label = state.config.provider.clone();
|
||||
let provider_name = if provider_label == "opencode" {
|
||||
"OpenCode"
|
||||
} else {
|
||||
"Codex"
|
||||
};
|
||||
// Closure is the safety boundary. Preserve it even if a saturated
|
||||
// event queue cannot retain this additional diagnostic.
|
||||
let _ = state.push_terminal_event(NormalizedProviderEvent {
|
||||
event_type: "harness.diagnostic".to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: json!({
|
||||
"provider": "codex",
|
||||
"provider": provider_label,
|
||||
"code": match rejected_accepted_turn {
|
||||
RejectedAcceptedTurn::ReusedIdentity(_) => "provider_turn_identity_reused",
|
||||
RejectedAcceptedTurn::InvalidIdentity => "provider_turn_identity_invalid",
|
||||
|
|
@ -1452,8 +1512,8 @@ impl CodexCommandExecutor {
|
|||
RejectedAcceptedTurn::InvalidIdentity => Value::Null,
|
||||
},
|
||||
"message": match rejected_accepted_turn {
|
||||
RejectedAcceptedTurn::ReusedIdentity(_) => "Codex accepted work with a previously settled turn identity; Paperclip terminated the provider and closed the durable run",
|
||||
RejectedAcceptedTurn::InvalidIdentity => "Codex accepted work without a valid bounded turn identity; Paperclip terminated the provider and closed the durable run",
|
||||
RejectedAcceptedTurn::ReusedIdentity(_) => format!("{provider_name} accepted work with a previously settled turn identity; Paperclip terminated the provider and closed the durable run"),
|
||||
RejectedAcceptedTurn::InvalidIdentity => format!("{provider_name} accepted work without a valid bounded turn identity; Paperclip terminated the provider and closed the durable run"),
|
||||
},
|
||||
"paperclipAccepted": false,
|
||||
"providerAccepted": true,
|
||||
|
|
@ -1690,13 +1750,14 @@ impl CodexCommandExecutor {
|
|||
state.receipt_limit_interrupt_deadline_unix_ms = None;
|
||||
state.last_agent_message = None;
|
||||
state.lifecycle = "turn_active".to_owned();
|
||||
let provider_label = state.config.provider.clone();
|
||||
self.save_state()?;
|
||||
Ok(CommandExecution {
|
||||
result: json!({"status": "accepted", "providerTurnId": provider_turn_id}),
|
||||
events: vec![(
|
||||
"turn.accepted".to_owned(),
|
||||
EventPriority::P0,
|
||||
json!({"provider": "codex", "providerSessionId": thread_id, "providerTurnId": provider_turn_id}),
|
||||
json!({"provider": provider_label, "providerSessionId": thread_id, "providerTurnId": provider_turn_id}),
|
||||
)],
|
||||
})
|
||||
}
|
||||
|
|
@ -1763,14 +1824,24 @@ impl CodexCommandExecutor {
|
|||
.get("requestId")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| DurableRunnerError::invalid("request.resolve requires requestId"))?;
|
||||
let provider_label = self
|
||||
.state
|
||||
.as_ref()
|
||||
.map(|state| state.config.provider.clone())
|
||||
.unwrap_or_else(|| "codex".to_owned());
|
||||
let provider_name = if provider_label == "opencode" {
|
||||
"OpenCode"
|
||||
} else {
|
||||
"Codex"
|
||||
};
|
||||
if self
|
||||
.state
|
||||
.as_ref()
|
||||
.is_none_or(|state| state.active_provider_turn_id.is_none())
|
||||
{
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"cannot resolve a Codex runtime request outside an active turn",
|
||||
));
|
||||
return Err(DurableRunnerError::invalid(format!(
|
||||
"cannot resolve a {provider_name} runtime request outside an active turn"
|
||||
)));
|
||||
}
|
||||
let response = payload
|
||||
.get("response")
|
||||
|
|
@ -1778,14 +1849,16 @@ impl CodexCommandExecutor {
|
|||
self.ensure_provider()?
|
||||
.resolve_runtime_request(request_id, response)
|
||||
.map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("Codex runtime response failed: {error}"))
|
||||
DurableRunnerError::invalid(format!(
|
||||
"{provider_name} runtime response failed: {error}"
|
||||
))
|
||||
})?;
|
||||
Ok(CommandExecution {
|
||||
result: json!({"status": "delivered", "requestId": request_id}),
|
||||
events: vec![(
|
||||
"runtime_request.resolved".to_owned(),
|
||||
EventPriority::P0,
|
||||
json!({"provider": "codex", "requestId": request_id, "status": "delivered"}),
|
||||
json!({"provider": provider_label, "requestId": request_id, "status": "delivered"}),
|
||||
)],
|
||||
})
|
||||
}
|
||||
|
|
@ -1812,7 +1885,7 @@ impl CodexCommandExecutor {
|
|||
event_type: "harness.diagnostic".to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: json!({
|
||||
"provider": "codex",
|
||||
"provider": state.config.provider,
|
||||
"code": "semantic_tool_denied",
|
||||
"operationId": operation_id,
|
||||
"callId": call_id,
|
||||
|
|
@ -1961,20 +2034,26 @@ impl CodexCommandExecutor {
|
|||
} else {
|
||||
"turn.failed"
|
||||
};
|
||||
let provider_label = state.config.provider.clone();
|
||||
let provider_name = if provider_label == "opencode" {
|
||||
"OpenCode"
|
||||
} else {
|
||||
"Codex"
|
||||
};
|
||||
state.push_terminal_event(NormalizedProviderEvent {
|
||||
event_type: terminal_event_type.to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: json!({
|
||||
"provider": "codex",
|
||||
"provider": provider_label,
|
||||
"code": if interrupt_accepted {
|
||||
"semantic_tool_turn_receipt_limit_interrupt_deadline"
|
||||
} else {
|
||||
"semantic_tool_turn_receipt_limit_interrupt_unconfirmed"
|
||||
},
|
||||
"message": if interrupt_accepted {
|
||||
"Codex accepted the receipt-limit interruption but did not emit its terminal before the bounded shutdown deadline"
|
||||
format!("{provider_name} accepted the receipt-limit interruption but did not emit its terminal before the bounded shutdown deadline")
|
||||
} else {
|
||||
"Codex did not confirm terminal state after the bounded receipt-limit interruption attempts"
|
||||
format!("{provider_name} did not confirm terminal state after the bounded receipt-limit interruption attempts")
|
||||
},
|
||||
"interruptAccepted": interrupt_accepted,
|
||||
"providerTerminalObserved": false,
|
||||
|
|
@ -2181,13 +2260,14 @@ impl CodexCommandExecutor {
|
|||
state.receipt_limit_interrupt_deadline_unix_ms = None;
|
||||
state.lifecycle = "closed".to_owned();
|
||||
let thread_id = state.thread_id.clone();
|
||||
let provider_name = state.config.provider.clone();
|
||||
self.save_state()?;
|
||||
Ok(CommandExecution {
|
||||
result: json!({"status": "closed", "providerSessionId": thread_id}),
|
||||
events: vec![(
|
||||
"session.closed".to_owned(),
|
||||
EventPriority::P0,
|
||||
json!({"provider": "codex", "providerSessionId": thread_id}),
|
||||
json!({"provider": provider_name, "providerSessionId": thread_id}),
|
||||
)],
|
||||
})
|
||||
}
|
||||
|
|
@ -2200,8 +2280,8 @@ impl CodexCommandExecutor {
|
|||
.ok_or_else(|| DurableRunnerError::invalid("Codex provider is not prepared"))?;
|
||||
Ok(CommandExecution::result(json!({
|
||||
"status": state.lifecycle,
|
||||
"provider": "codex",
|
||||
"driver": "codex_app_server",
|
||||
"provider": state.config.provider,
|
||||
"driver": state.config.driver,
|
||||
"providerSessionId": state.thread_id,
|
||||
"activeProviderTurnId": state.active_provider_turn_id,
|
||||
})))
|
||||
|
|
@ -2275,7 +2355,15 @@ impl CodexCommandExecutor {
|
|||
} else {
|
||||
None
|
||||
};
|
||||
let normalized = normalize_codex_notification(&method, ¶ms);
|
||||
let provider_name = self
|
||||
.state
|
||||
.as_ref()
|
||||
.map(|state| state.config.provider.clone())
|
||||
.unwrap_or_else(|| "codex".to_owned());
|
||||
let normalized = normalize_codex_notification(&method, ¶ms)
|
||||
.into_iter()
|
||||
.map(|event| relabel_provider_event(event, &provider_name))
|
||||
.collect::<Vec<_>>();
|
||||
let normalized_event_count = normalized.len();
|
||||
let terminal_event_type = normalized
|
||||
.iter()
|
||||
|
|
@ -2429,8 +2517,8 @@ impl CodexCommandExecutor {
|
|||
"prompt": prompt,
|
||||
"input": question_set,
|
||||
"origin": {
|
||||
"adapter": "codex-app-server",
|
||||
"provider": "codex",
|
||||
"adapter": if state.config.provider == "opencode" { "opencode-server" } else { "codex-app-server" },
|
||||
"provider": state.config.provider,
|
||||
"method": "item/tool/requestUserInput",
|
||||
},
|
||||
},
|
||||
|
|
@ -2477,7 +2565,7 @@ impl CodexCommandExecutor {
|
|||
.to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: json!({
|
||||
"provider": "codex",
|
||||
"provider": state.config.provider,
|
||||
"code": "provider_exited",
|
||||
"exitCode": exit_code,
|
||||
"expected": success,
|
||||
|
|
@ -2513,10 +2601,15 @@ impl CommandExecutor for CodexCommandExecutor {
|
|||
}
|
||||
self.verify_attached_tools(&command.payload)?;
|
||||
let mut execution = self.open_session()?;
|
||||
let provider = self
|
||||
.state
|
||||
.as_ref()
|
||||
.map(|state| state.config.provider.clone())
|
||||
.unwrap_or_else(|| "codex".to_owned());
|
||||
execution.events.push((
|
||||
"run.attached".to_owned(),
|
||||
EventPriority::P0,
|
||||
json!({"provider": "codex"}),
|
||||
json!({"provider": provider}),
|
||||
));
|
||||
Ok(execution)
|
||||
}
|
||||
|
|
@ -2587,6 +2680,46 @@ impl CommandExecutor for CodexCommandExecutor {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn opencode_terminal_fallback_uses_its_actual_provider_identity() {
|
||||
let mut state = CodexProviderState::new(
|
||||
CodexProviderConfig {
|
||||
provider: "opencode".to_owned(),
|
||||
driver: "opencode_server".to_owned(),
|
||||
provider_version: "1.18.17".to_owned(),
|
||||
command: PathBuf::from("node"),
|
||||
args: Vec::new(),
|
||||
cwd: std::env::current_dir()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
model: Some("openrouter/model".to_owned()),
|
||||
provider_session_id: None,
|
||||
instructions: String::new(),
|
||||
approval_policy: "never".to_owned(),
|
||||
},
|
||||
Some(CompletionContractBinding {
|
||||
revision: "revision-1".to_owned(),
|
||||
criterion_ids: vec!["criterion-1".to_owned()],
|
||||
}),
|
||||
ProviderToolBridge::default(),
|
||||
);
|
||||
state.last_agent_message = None;
|
||||
|
||||
let events = terminal_events(&state, "turn.completed");
|
||||
|
||||
assert_eq!(
|
||||
events[0].payload["summary"],
|
||||
"OpenCode completed the requested work."
|
||||
);
|
||||
assert_eq!(
|
||||
events[0].payload["evidence"][0]["ref"],
|
||||
"provider:opencode:agent-message"
|
||||
);
|
||||
assert_eq!(events[1].payload["provider"], "opencode");
|
||||
assert!(!events[0].payload.to_string().contains("Codex"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_inconsistent_provider_state() {
|
||||
let state = CodexProviderState {
|
||||
|
|
|
|||
|
|
@ -296,8 +296,8 @@ fn project_runtime_request_origin(origin: Option<&Value>) -> Result<Value, Local
|
|||
let Some(origin) = origin else {
|
||||
return Ok(json!({
|
||||
"adapter": "codex-acpx",
|
||||
"provider": "codex",
|
||||
"method": "runtime.input_requested",
|
||||
"provider": "acpx",
|
||||
"method": "item/tool/requestUserInput",
|
||||
}));
|
||||
};
|
||||
let object = origin.as_object().ok_or_else(|| {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,306 @@
|
|||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
use paperclip_runner_core::durable::{Command, CommandExecutor, DurableRunnerConfig};
|
||||
use paperclip_runner_core::native_provider_backend::NativeProviderCommandExecutor;
|
||||
use paperclip_runner_core::provider_bridge::authorized_tool_catalog_digest;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const CODEX_ACPX_DIGEST: &str =
|
||||
"sha256:94049b3e3c3aee87de62703786e4fa81d031d7bd979f99bdf516d84f28791a79";
|
||||
|
||||
fn temporary_directory(label: &str) -> PathBuf {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"paperclip-native-provider-{label}-{}-{nonce}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&directory).unwrap();
|
||||
#[cfg(unix)]
|
||||
fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
directory
|
||||
}
|
||||
|
||||
fn config(state_dir: &Path) -> DurableRunnerConfig {
|
||||
DurableRunnerConfig {
|
||||
connect_url: "ws://127.0.0.1/runner".to_owned(),
|
||||
ca_bundle_path: None,
|
||||
state_dir: state_dir.to_owned(),
|
||||
runner_instance_id: "runner-1".to_owned(),
|
||||
environment_lease_id: "lease-1".to_owned(),
|
||||
run_id: "run-1".to_owned(),
|
||||
normalized_session_id: "session-1".to_owned(),
|
||||
turn_id: "turn-1".to_owned(),
|
||||
item_id: "item-1".to_owned(),
|
||||
runner_version: "0.0.0".to_owned(),
|
||||
runner_digest: "sha256:test".to_owned(),
|
||||
max_outbox_bytes: 1024 * 1024,
|
||||
p0_reserve_bytes: 64 * 1024,
|
||||
max_frame_bytes: 1024 * 1024,
|
||||
reconnect_delay: Duration::from_millis(1),
|
||||
reconnect_grace: None,
|
||||
max_runtime: Duration::from_secs(60),
|
||||
}
|
||||
}
|
||||
|
||||
fn command(sequence: u64, command_type: &str, payload: Value) -> Command {
|
||||
Command {
|
||||
schema: "paperclip.prp.command.v1".to_owned(),
|
||||
command_id: format!("command-{sequence}"),
|
||||
controller_seq: sequence,
|
||||
command_type: command_type.to_owned(),
|
||||
issued_at: "2026-09-01T00:00:00.000Z".to_owned(),
|
||||
deadline_at: None,
|
||||
precondition: None,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_payload(directory: &Path, agent: &str) -> Value {
|
||||
prepare_payload_with_mode(directory, agent, "turns-reserved-result-terminal")
|
||||
}
|
||||
|
||||
fn prepare_payload_with_mode(directory: &Path, agent: &str, mode: &str) -> Value {
|
||||
let operations = Vec::new();
|
||||
json!({
|
||||
"authorizedTools": {
|
||||
"schema": "paperclip.runner.authorized-tools.v1",
|
||||
"schemaVersion": 1,
|
||||
"catalogDigest": authorized_tool_catalog_digest(&operations).unwrap(),
|
||||
"operations": operations,
|
||||
},
|
||||
"provider": {
|
||||
"kind": "acpx",
|
||||
"provider": "acpx",
|
||||
"driver": "acpx_runtime",
|
||||
"providerVersion": "0.13.1",
|
||||
"agent": agent,
|
||||
"model": "gpt-5.6-sol",
|
||||
"acpxVersion": "0.13.1",
|
||||
"agentServerPackage": "@agentclientprotocol/codex-acp",
|
||||
"agentServerVersion": "1.6.2",
|
||||
"agentRuntimePackage": null,
|
||||
"agentRuntimeVersion": null,
|
||||
"commandDigest": CODEX_ACPX_DIGEST,
|
||||
"sidecarCommand": env!("CARGO_BIN_EXE_fake-acpx-sidecar"),
|
||||
"sidecarArgs": [
|
||||
"--mode",
|
||||
mode,
|
||||
"--profile-digest",
|
||||
CODEX_ACPX_DIGEST,
|
||||
],
|
||||
"runtimeDirectory": directory.join("acpx-runtime"),
|
||||
"normalizedSessionId": "session-1",
|
||||
"runId": "run-1",
|
||||
"cwd": directory,
|
||||
"instructions": "Complete the supplied task and report the semantic result.",
|
||||
"permissionMode": "approve-reads",
|
||||
"permissionModePinned": true,
|
||||
"runtimeContext": null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_acpx_semantic_disposition_in_the_run_terminal() {
|
||||
let directory = temporary_directory("acpx-blocked");
|
||||
let config = config(&directory);
|
||||
let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
|
||||
executor
|
||||
.execute(&command(
|
||||
1,
|
||||
"run.prepare",
|
||||
prepare_payload_with_mode(&directory, "codex", "turns-reserved-block-terminal"),
|
||||
))
|
||||
.unwrap();
|
||||
executor
|
||||
.execute(&command(2, "session.open", json!({})))
|
||||
.unwrap();
|
||||
executor
|
||||
.execute(&command(3, "turn.start", json!({"text": "Wait."})))
|
||||
.unwrap();
|
||||
|
||||
let events = executor.poll_events().unwrap();
|
||||
let terminal = events
|
||||
.iter()
|
||||
.find(|event| event.event_type == "run.terminal")
|
||||
.expect("ACPX blocked result must become terminal");
|
||||
assert_eq!(terminal.payload["runTerminalState"], "succeeded");
|
||||
assert_eq!(terminal.payload["reportedWorkDisposition"], "blocked");
|
||||
|
||||
executor.shutdown().unwrap();
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
fn opencode_prepare_payload(directory: &Path) -> Value {
|
||||
let operations = Vec::new();
|
||||
json!({
|
||||
"authorizedTools": {
|
||||
"schema": "paperclip.runner.authorized-tools.v1",
|
||||
"schemaVersion": 1,
|
||||
"catalogDigest": authorized_tool_catalog_digest(&operations).unwrap(),
|
||||
"operations": operations,
|
||||
},
|
||||
"completionContract": {
|
||||
"revision": "revision-1",
|
||||
"criterionIds": ["criterion-1"],
|
||||
},
|
||||
"provider": {
|
||||
"kind": "opencode",
|
||||
"provider": "opencode",
|
||||
"driver": "opencode_server",
|
||||
"providerVersion": "1.18.17",
|
||||
"command": env!("CARGO_BIN_EXE_fake-codex-app-server"),
|
||||
"args": [
|
||||
"--state-file",
|
||||
directory.join("fake-opencode-state.json"),
|
||||
"--call-log",
|
||||
directory.join("fake-opencode-calls.log"),
|
||||
],
|
||||
"cwd": directory,
|
||||
"model": "openrouter/model",
|
||||
"approvalPolicy": "never",
|
||||
"instructions": "Complete the supplied task.",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn executes_a_qualified_acpx_profile_through_the_native_selector() {
|
||||
let directory = temporary_directory("acpx");
|
||||
let config = config(&directory);
|
||||
let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
|
||||
let prepared = executor
|
||||
.execute(&command(
|
||||
1,
|
||||
"run.prepare",
|
||||
prepare_payload(&directory, "codex"),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(prepared.result["provider"], "acpx");
|
||||
let opened = executor
|
||||
.execute(&command(2, "session.open", json!({})))
|
||||
.unwrap();
|
||||
assert_eq!(opened.result["driver"], "acpx_runtime");
|
||||
assert_eq!(opened.events[0].2["providerDescriptor"]["agent"], "codex");
|
||||
|
||||
let started = executor
|
||||
.execute(&command(
|
||||
3,
|
||||
"turn.start",
|
||||
json!({"text": "Finish the task."}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(started.events[0].0, "turn.started");
|
||||
|
||||
let events = executor.poll_events().unwrap();
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|event| event.event_type == "run.result.proposed"));
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|event| event.event_type == "turn.completed"));
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|event| event.event_type == "run.terminal"));
|
||||
executor.acknowledge_events(events.len()).unwrap();
|
||||
executor
|
||||
.execute(&command(4, "session.close", json!({})))
|
||||
.unwrap();
|
||||
executor.shutdown().unwrap();
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn executes_opencode_through_the_local_facade_without_codex_event_labels() {
|
||||
let directory = temporary_directory("opencode");
|
||||
let config = config(&directory);
|
||||
let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
|
||||
let prepared = executor
|
||||
.execute(&command(
|
||||
1,
|
||||
"run.prepare",
|
||||
opencode_prepare_payload(&directory),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(prepared.result["provider"], "opencode");
|
||||
let opened = executor
|
||||
.execute(&command(2, "session.open", json!({})))
|
||||
.unwrap();
|
||||
assert_eq!(opened.result["provider"], "opencode");
|
||||
executor
|
||||
.execute(&command(
|
||||
3,
|
||||
"turn.start",
|
||||
json!({"text": "Finish the task."}),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
let mut observed = Vec::new();
|
||||
while std::time::Instant::now() < deadline {
|
||||
let events = executor.poll_events().unwrap();
|
||||
let count = events.len();
|
||||
observed.extend(events);
|
||||
executor.acknowledge_events(count).unwrap();
|
||||
if observed
|
||||
.iter()
|
||||
.any(|event| event.event_type == "run.terminal")
|
||||
{
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
assert!(observed
|
||||
.iter()
|
||||
.any(|event| event.event_type == "turn.completed"));
|
||||
let terminal = observed
|
||||
.iter()
|
||||
.find(|event| event.event_type == "run.terminal")
|
||||
.expect("OpenCode run must become terminal");
|
||||
assert_eq!(terminal.payload["provider"], "opencode");
|
||||
let result = observed
|
||||
.iter()
|
||||
.find(|event| event.event_type == "run.result.proposed")
|
||||
.expect("OpenCode terminal fallback must propose a result");
|
||||
assert_eq!(
|
||||
result.payload["evidence"][0]["ref"],
|
||||
"provider:opencode:agent-message"
|
||||
);
|
||||
assert!(observed.iter().any(|event| {
|
||||
event.event_type == "item.completed" && event.payload["provider"] == "opencode"
|
||||
}));
|
||||
|
||||
executor
|
||||
.execute(&command(4, "session.close", json!({})))
|
||||
.unwrap();
|
||||
executor.shutdown().unwrap();
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_pi_before_starting_a_sidecar() {
|
||||
let directory = temporary_directory("pi");
|
||||
let config = config(&directory);
|
||||
let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
let error = executor
|
||||
.execute(&command(
|
||||
1,
|
||||
"run.prepare",
|
||||
prepare_payload(&directory, "pi"),
|
||||
))
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("agent pi is not executable"));
|
||||
assert!(!directory.join("acpx-runtime").exists());
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
|
@ -29,33 +29,70 @@ export interface CodexNativeSessionBackendOptions {
|
|||
}) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the first production-native provider boundary. Other provider
|
||||
* contracts may already be persisted, but their runtime implementations are
|
||||
* deliberately shipped in separate provider slices.
|
||||
*/
|
||||
export function createCodexNativeSessionBackend(
|
||||
function transportDriverIdentity(
|
||||
input: NativeExecutionInput,
|
||||
options: CodexNativeSessionBackendOptions = {},
|
||||
): NativeSessionBackend {
|
||||
if (input.provider.kind !== "codex") {
|
||||
throw new Error("Codex native backend requires provider kind codex");
|
||||
): {
|
||||
kind: "codex_app_server" | "opencode_server" | "acpx_runtime";
|
||||
displayName: string;
|
||||
version: string;
|
||||
} {
|
||||
switch (input.provider.kind) {
|
||||
case "codex":
|
||||
return {
|
||||
kind: "codex_app_server",
|
||||
displayName: "Codex app-server",
|
||||
version: "codex-v2",
|
||||
};
|
||||
case "opencode":
|
||||
return {
|
||||
kind: "opencode_server",
|
||||
displayName: "OpenCode server",
|
||||
version: "1.18.17",
|
||||
};
|
||||
case "acpx":
|
||||
if (input.provider.agent === "pi") {
|
||||
throw new Error(
|
||||
"Native ACPX backend for pi is unavailable until descriptor-confined verified launch is implemented",
|
||||
);
|
||||
}
|
||||
return {
|
||||
kind: "acpx_runtime",
|
||||
displayName: `${input.provider.agent === "claude" ? "Claude" : "Codex"} via ACPX`,
|
||||
version: "0.13.1",
|
||||
};
|
||||
default:
|
||||
throw new Error(
|
||||
`Native backend for ${input.provider.kind} is not available through the local runnerd transport`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createTransportBackedNativeSessionBackend(
|
||||
input: NativeExecutionInput,
|
||||
options: CodexNativeSessionBackendOptions,
|
||||
): NativeSessionBackend {
|
||||
const driverIdentity = transportDriverIdentity(input);
|
||||
const isCodex = input.provider.kind === "codex";
|
||||
|
||||
return new HarnessDriverBackend(new CodexAppServerDriver({
|
||||
...(input.provider.model ? { model: input.provider.model } : {}),
|
||||
approvalPolicy: input.provider.approvalPolicy ?? "untrusted",
|
||||
// Runnerd owns provider permissions for the OpenCode/ACPX facades. Their
|
||||
// Codex-compatible surface must never open a second approval channel.
|
||||
approvalPolicy:
|
||||
input.provider.kind === "codex"
|
||||
? input.provider.approvalPolicy ?? "untrusted"
|
||||
: "never",
|
||||
baseInstructions: nativeSystemInstructions(input),
|
||||
includeSkillInstructions: "runtimeContext" in input,
|
||||
includeSkillInstructions: isCodex && "runtimeContext" in input,
|
||||
requestedCollaborationMode:
|
||||
"executionMode" in input ? input.executionMode : "default",
|
||||
isCodex && "executionMode" in input ? input.executionMode : "default",
|
||||
taskEnvelope: createCodexTaskEnvelope({
|
||||
objective: input.completionContract.contract.objective,
|
||||
contractRevision: input.completionContract.contract.revision,
|
||||
criteria: input.completionContract.contract.criteria,
|
||||
constraints: [
|
||||
"Work only inside the supplied working directory.",
|
||||
...("executionMode" in input && input.executionMode === "plan"
|
||||
...(isCodex && "executionMode" in input && input.executionMode === "plan"
|
||||
? [
|
||||
"Use native plan collaboration mode and do not modify workspace files.",
|
||||
"Treat the supplied Paperclip planning context as the canonical pinned base revision.",
|
||||
|
|
@ -73,12 +110,40 @@ export function createCodexNativeSessionBackend(
|
|||
transportFactory: options.transportFactory,
|
||||
dynamicTools: options.dynamicTools,
|
||||
dynamicToolHandler: options.dynamicToolHandler,
|
||||
driverIdentity: {
|
||||
kind: "codex_app_server",
|
||||
displayName: "Codex app-server",
|
||||
version: "codex-v2",
|
||||
},
|
||||
collaborationModes: ["default", "plan"],
|
||||
driverIdentity,
|
||||
capabilities: isCodex
|
||||
? {}
|
||||
: { steering: false, goals: false, threadLineage: false },
|
||||
collaborationModes: isCodex ? ["default", "plan"] : ["default"],
|
||||
requireProviderSessionIdentity: options.transportFactory !== undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the Codex JSON-RPC facade strictly as the TypeScript transport shape.
|
||||
* Runnerd still selects and owns the real provider process from run.prepare.
|
||||
*/
|
||||
export function createRunnerdNativeSessionBackend(
|
||||
input: NativeExecutionInput,
|
||||
options: CodexNativeSessionBackendOptions,
|
||||
): NativeSessionBackend {
|
||||
if (!options.transportFactory) {
|
||||
throw new Error("Runnerd native backend requires a transport factory");
|
||||
}
|
||||
return createTransportBackedNativeSessionBackend(input, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the first production-native provider boundary. Other provider
|
||||
* contracts may already be persisted, but their runtime implementations are
|
||||
* deliberately shipped in separate provider slices.
|
||||
*/
|
||||
export function createCodexNativeSessionBackend(
|
||||
input: NativeExecutionInput,
|
||||
options: CodexNativeSessionBackendOptions = {},
|
||||
): NativeSessionBackend {
|
||||
if (input.provider.kind !== "codex") {
|
||||
throw new Error("Codex native backend requires provider kind codex");
|
||||
}
|
||||
return createTransportBackedNativeSessionBackend(input, options);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,6 +145,26 @@ describe("native backend factory", () => {
|
|||
).toThrow("OpenCode native backend requires an instance runtime directory");
|
||||
});
|
||||
|
||||
it("routes OpenCode through runnerd when a durable transport is supplied", async () => {
|
||||
const backend = createNativeSessionBackend(opencodeExecution(), {
|
||||
codexTransportFactory: () => {
|
||||
throw new Error("descriptor must not launch the transport");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(backend.descriptor()).resolves.toMatchObject({
|
||||
kind: "runner",
|
||||
name: "opencode_server",
|
||||
version: "1.18.17",
|
||||
capabilities: {
|
||||
steering: false,
|
||||
resume: true,
|
||||
interruption: true,
|
||||
dynamicTools: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("constructs the OpenCode backend without starting its process", async () => {
|
||||
const backend = createNativeSessionBackend(opencodeExecution(), {
|
||||
opencodeRuntimeDirectory: "/runtime",
|
||||
|
|
@ -179,6 +199,28 @@ describe("native backend factory", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each(["codex" as const, "claude" as const])(
|
||||
"routes qualified %s ACPX through runnerd",
|
||||
async (agent) => {
|
||||
const backend = createNativeSessionBackend(acpxExecution(agent), {
|
||||
codexTransportFactory: () => {
|
||||
throw new Error("descriptor must not launch the transport");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(backend.descriptor()).resolves.toMatchObject({
|
||||
name: "acpx_runtime",
|
||||
version: "0.13.1",
|
||||
capabilities: {
|
||||
steering: false,
|
||||
resume: true,
|
||||
interruption: true,
|
||||
dynamicTools: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("requires an explicit runtime root", () => {
|
||||
expect(() => createNativeSessionBackend(acpxExecution())).toThrow(
|
||||
"requires an instance runtime directory",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
import type { CodexAppServerTransport } from "../drivers/codex/app-server-transport.js";
|
||||
import {
|
||||
createCodexNativeSessionBackend,
|
||||
createRunnerdNativeSessionBackend,
|
||||
type CodexNativeSessionBackendOptions,
|
||||
} from "./codex-native-backend.js";
|
||||
import {
|
||||
|
|
@ -39,6 +40,15 @@ export function createNativeSessionBackend(
|
|||
input: NativeExecutionInput,
|
||||
options: NativeBackendFactoryOptions = {},
|
||||
): NativeSessionBackend {
|
||||
if (options.codexTransportFactory) {
|
||||
return createRunnerdNativeSessionBackend(input, {
|
||||
runnerInstanceId: options.runnerInstanceId,
|
||||
onSpawn: options.onSpawn,
|
||||
dynamicTools: options.dynamicTools,
|
||||
dynamicToolHandler: options.dynamicToolHandler,
|
||||
transportFactory: options.codexTransportFactory,
|
||||
});
|
||||
}
|
||||
if (input.provider.kind === "opencode") {
|
||||
if (!options.opencodeRuntimeDirectory?.trim()) {
|
||||
throw new Error(
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
rehydrateRunnerdTurnNotification,
|
||||
rehydrateRunnerdUsageNotification,
|
||||
rehydrateRunnerdWorkspaceChangeNotification,
|
||||
resolveRunnerdAcpxPermissionMode,
|
||||
resolveRunnerdSessionIdentity,
|
||||
resolveSourceCodexHome,
|
||||
trustedRuntimeReadOnlyRoots,
|
||||
|
|
@ -35,6 +36,11 @@ import {
|
|||
withCodexCollaborationRuntimeInstructions,
|
||||
} from "./runnerd-codex-transport.js";
|
||||
|
||||
it("defaults runnerd ACPX permissions to approve reads", () => {
|
||||
expect(resolveRunnerdAcpxPermissionMode(undefined)).toBe("approve-reads");
|
||||
expect(resolveRunnerdAcpxPermissionMode("deny-all")).toBe("deny-all");
|
||||
});
|
||||
|
||||
it("adds Codex-style turn updates only when collaboration instructions are enabled", () => {
|
||||
const base = "Base Paperclip instructions.";
|
||||
const enabled = withCodexCollaborationRuntimeInstructions(base, true);
|
||||
|
|
|
|||
|
|
@ -870,6 +870,12 @@ export function createCapabilityRunnerdProviderEnvironment(input: {
|
|||
return environment;
|
||||
}
|
||||
|
||||
export function resolveRunnerdAcpxPermissionMode(
|
||||
configured: CapabilityRunnerdCodexTransportOptions["acpxPermissionMode"],
|
||||
): NonNullable<CapabilityRunnerdCodexTransportOptions["acpxPermissionMode"]> {
|
||||
return configured ?? "approve-reads";
|
||||
}
|
||||
|
||||
const OPEN_CODE_RUNNER_ENVIRONMENT_KEYS = new Set([
|
||||
"PATH",
|
||||
"LANG",
|
||||
|
|
@ -1548,7 +1554,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
|||
runId: identity.runId,
|
||||
cwd: String(params.cwd ?? tmpdir()),
|
||||
instructions: baseInstructions,
|
||||
permissionMode: this.options.acpxPermissionMode ?? "approve-all",
|
||||
permissionMode: resolveRunnerdAcpxPermissionMode(
|
||||
this.options.acpxPermissionMode,
|
||||
),
|
||||
permissionModePinned: this.options.acpxPermissionModePinned ?? true,
|
||||
runtimeContext,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -238,7 +238,7 @@ describe("server adapter registry", () => {
|
|||
const result = await adapter.testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "paperclip_runner",
|
||||
config: { provider: "opencode" },
|
||||
config: { provider: "claude_managed" },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
|
|
@ -251,11 +251,46 @@ describe("server adapter registry", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["claude", "claude-sonnet-5"],
|
||||
["codex", "gpt-5.6-sol"],
|
||||
] as const)("accepts the qualified ACPX %s environment profile", async (acpxAgent, model) => {
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "paperclip_runner",
|
||||
config: { provider: "acpx", acpxAgent, model },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
adapterType: "paperclip_runner",
|
||||
status: "pass",
|
||||
checks: [{ code: "acpx_profile_qualified", level: "info" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the ACPX Pi profile unavailable", async () => {
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "paperclip_runner",
|
||||
config: {
|
||||
provider: "acpx",
|
||||
acpxAgent: "pi",
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "fail",
|
||||
checks: [{ code: "paperclip_runner_acpx_agent_unavailable" }],
|
||||
});
|
||||
});
|
||||
it("wraps built-in npm runtime installs with the sandbox-aware install helper", () => {
|
||||
const expectedClaudeInstall = `if ! command -v 'claude' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@anthropic-ai/claude-code")}; fi`;
|
||||
const expectedCodexInstall = `if ! command -v 'codex' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@openai/codex")}; fi`;
|
||||
const expectedGeminiInstall = `if ! command -v 'gemini' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@google/gemini-cli")}; fi`;
|
||||
const expectedOpenCodeInstall = `if ! command -v 'opencode' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("opencode-ai")}; fi`;
|
||||
const expectedRunnerCodexInstall = `if ! command -v 'codex' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@openai/codex@0.148.0")}; fi`;
|
||||
const expectedRunnerOpenCodeInstall = `if ! command -v 'opencode' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("opencode-ai@1.18.17")}; fi`;
|
||||
|
||||
expect(findActiveServerAdapter("claude_local")?.getRuntimeCommandSpec?.({})).toEqual({
|
||||
command: "claude",
|
||||
|
|
@ -277,6 +312,21 @@ describe("server adapter registry", () => {
|
|||
detectCommand: "opencode",
|
||||
installCommand: expectedOpenCodeInstall,
|
||||
});
|
||||
expect(findActiveServerAdapter("paperclip_runner")?.getRuntimeCommandSpec?.({ provider: "codex" })).toEqual({
|
||||
command: "codex",
|
||||
detectCommand: "codex",
|
||||
installCommand: expectedRunnerCodexInstall,
|
||||
});
|
||||
expect(findActiveServerAdapter("paperclip_runner")?.getRuntimeCommandSpec?.({ provider: "opencode" })).toEqual({
|
||||
command: "opencode",
|
||||
detectCommand: "opencode",
|
||||
installCommand: expectedRunnerOpenCodeInstall,
|
||||
});
|
||||
expect(findActiveServerAdapter("paperclip_runner")?.getRuntimeCommandSpec?.({ provider: "acpx" })).toEqual({
|
||||
command: "paperclip-runnerd",
|
||||
detectCommand: null,
|
||||
installCommand: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("switches active adapter behavior back to the builtin when an override is paused", async () => {
|
||||
|
|
|
|||
|
|
@ -333,6 +333,57 @@ describe("adapter routes", () => {
|
|||
expect(res.body.fields).toEqual([]);
|
||||
});
|
||||
|
||||
it("serves provider-scoped Paperclip Runner configuration fields", async () => {
|
||||
const app = createApp();
|
||||
|
||||
const res = await request(app).get("/api/adapters/paperclip_runner/config-schema");
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body.fields).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: "provider",
|
||||
options: [
|
||||
expect.objectContaining({ value: "codex" }),
|
||||
expect.objectContaining({ value: "opencode" }),
|
||||
expect.objectContaining({ value: "acpx" }),
|
||||
],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: "codexPermissionMode",
|
||||
default: "untrusted",
|
||||
meta: { visibleWhen: { key: "provider", value: "codex" } },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: "opencodePermissionMode",
|
||||
default: "ask",
|
||||
meta: { visibleWhen: { key: "provider", value: "opencode" } },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: "acpxPermissionMode",
|
||||
default: "approve-reads",
|
||||
meta: { visibleWhen: { key: "provider", value: "acpx" } },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: "acpxAgent",
|
||||
options: [
|
||||
expect.objectContaining({ value: "claude" }),
|
||||
expect.objectContaining({ value: "codex" }),
|
||||
],
|
||||
meta: { visibleWhen: { key: "provider", value: "acpx" } },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: "model",
|
||||
meta: { visibleWhen: { key: "provider", value: "opencode" } },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: "idleTimeoutMs",
|
||||
meta: { visibleWhen: { key: "lifecycleMode", value: "warm" } },
|
||||
}),
|
||||
]));
|
||||
const acpxAgent = res.body.fields.find((field: { key?: string }) => field.key === "acpxAgent");
|
||||
expect(acpxAgent.options).not.toContainEqual(expect.objectContaining({ value: "pi" }));
|
||||
});
|
||||
|
||||
it("serves the built-in claude_local ACP engine config schema", async () => {
|
||||
const app = createApp();
|
||||
|
||||
|
|
|
|||
|
|
@ -603,7 +603,7 @@ describe("agent routes adapter validation", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("rejects non-Codex providers on fresh paperclip_runner agents and hires", async () => {
|
||||
it("accepts qualified OpenCode and ACPX providers on fresh runner agents and hires", async () => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
|
||||
const app = await createApp();
|
||||
const createResponse = await requestApp(app, (baseUrl) =>
|
||||
|
|
@ -612,7 +612,10 @@ describe("agent routes adapter validation", () => {
|
|||
.send({
|
||||
name: "Native OpenCode",
|
||||
adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "opencode" },
|
||||
adapterConfig: {
|
||||
provider: "opencode",
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const hireResponse = await requestApp(app, (baseUrl) =>
|
||||
|
|
@ -621,19 +624,17 @@ describe("agent routes adapter validation", () => {
|
|||
.send({
|
||||
name: "Native ACPX",
|
||||
adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "acpx" },
|
||||
adapterConfig: {
|
||||
provider: "acpx",
|
||||
acpxAgent: "claude",
|
||||
model: "claude-sonnet-5",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createResponse.status, JSON.stringify(createResponse.body)).toBe(422);
|
||||
expect(createResponse.body.details).toMatchObject({
|
||||
code: "paperclip_runner_provider_unavailable",
|
||||
});
|
||||
expect(hireResponse.status, JSON.stringify(hireResponse.body)).toBe(422);
|
||||
expect(hireResponse.body.details).toMatchObject({
|
||||
code: "paperclip_runner_provider_unavailable",
|
||||
});
|
||||
expect(mockAgentService.create).not.toHaveBeenCalled();
|
||||
expect(createResponse.status, JSON.stringify(createResponse.body)).toBe(201);
|
||||
expect(hireResponse.status, JSON.stringify(hireResponse.body)).toBe(201);
|
||||
expect(mockAgentService.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects provider changes but preserves edits to historical runner agents", async () => {
|
||||
|
|
@ -658,7 +659,7 @@ describe("agent routes adapter validation", () => {
|
|||
expect(ordinaryEdit.status, JSON.stringify(ordinaryEdit.body)).toBe(200);
|
||||
expect(providerChange.status, JSON.stringify(providerChange.body)).toBe(422);
|
||||
expect(providerChange.body.details).toMatchObject({
|
||||
code: "paperclip_runner_provider_unavailable",
|
||||
code: "paperclip_runner_acpx_agent_unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5945,19 +5945,22 @@ describe("company portability", () => {
|
|||
expect(agentSvc.create).not.toHaveBeenCalled();
|
||||
|
||||
instanceSettingsSvc.getExperimental.mockResolvedValue({ enableNativeRunner: true });
|
||||
await expect(portability.importBundle({
|
||||
await portability.importBundle({
|
||||
...request,
|
||||
adapterOverrides: {
|
||||
claudecoder: {
|
||||
adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "opencode" },
|
||||
adapterConfig: {
|
||||
provider: "opencode",
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, "user-1")).rejects.toMatchObject({
|
||||
status: 422,
|
||||
details: { code: "paperclip_runner_provider_unavailable" },
|
||||
});
|
||||
expect(agentSvc.create).not.toHaveBeenCalled();
|
||||
}, "user-1");
|
||||
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
adapterType: "paperclip_runner",
|
||||
adapterConfig: expect.objectContaining({ provider: "opencode" }),
|
||||
}));
|
||||
|
||||
await portability.importBundle(request, "user-1");
|
||||
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
buildSandboxNpmInstallCommand,
|
||||
getAdapterSessionManagement,
|
||||
PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES,
|
||||
resolvePaperclipRunnerPermissionMode,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import type { AdapterLoginCapability } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
|
|
@ -135,6 +134,13 @@ import { buildExternalAdapters } from "./plugin-loader.js";
|
|||
import { getDisabledAdapterTypes } from "../services/adapter-plugin-store.js";
|
||||
import { processAdapter } from "./process/index.js";
|
||||
import { httpAdapter } from "./http/index.js";
|
||||
import {
|
||||
DEFAULT_OPENCODE_RUNNER_MODEL,
|
||||
PaperclipRunnerProviderProfileError,
|
||||
QUALIFIED_ACPX_RUNNER_MODELS,
|
||||
QUALIFIED_OPENCODE_RUNNER_VERSION,
|
||||
resolvePaperclipRunnerProviderProfile,
|
||||
} from "../services/native-runtime/provider-profile.js";
|
||||
|
||||
function readConfiguredCommand(config: Record<string, unknown>, fallback: string): string {
|
||||
const value = typeof config.command === "string" ? config.command.trim() : "";
|
||||
|
|
@ -362,71 +368,100 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
|||
timedOut: false,
|
||||
errorMessage: message,
|
||||
errorCode: "paperclip_runner_coordinator_required",
|
||||
provider: "codex",
|
||||
provider: ctx.config.provider === "opencode"
|
||||
? "opencode"
|
||||
: ctx.config.provider === "acpx"
|
||||
? "acpx"
|
||||
: "codex",
|
||||
summary: message,
|
||||
};
|
||||
},
|
||||
async testEnvironment(context) {
|
||||
const configuredProvider = context.config.provider ?? "codex";
|
||||
if (configuredProvider !== "codex") {
|
||||
let profile: ReturnType<typeof resolvePaperclipRunnerProviderProfile>;
|
||||
try {
|
||||
profile = resolvePaperclipRunnerProviderProfile(context.config);
|
||||
} catch (error) {
|
||||
const profileError = error instanceof PaperclipRunnerProviderProfileError
|
||||
? error
|
||||
: new PaperclipRunnerProviderProfileError(
|
||||
"paperclip_runner_provider_unsupported",
|
||||
"Paperclip Runner provider configuration is invalid.",
|
||||
);
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "fail" as const,
|
||||
testedAt: new Date().toISOString(),
|
||||
checks: [{
|
||||
code: "paperclip_runner_provider_unsupported",
|
||||
code: profileError.code,
|
||||
level: "error" as const,
|
||||
message: "Paperclip Runner currently supports only the Codex provider.",
|
||||
message: profileError.message,
|
||||
}],
|
||||
};
|
||||
}
|
||||
if (context.executionTarget?.kind === "remote") {
|
||||
if (profile.provider === "acpx") {
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "fail" as const,
|
||||
status: "pass" as const,
|
||||
testedAt: new Date().toISOString(),
|
||||
checks: [{
|
||||
code: "paperclip_runner_environment_unsupported",
|
||||
level: "error" as const,
|
||||
message: "Paperclip Runner currently requires a local execution environment.",
|
||||
code: "acpx_profile_qualified",
|
||||
level: "info" as const,
|
||||
message: `ACPX ${profile.acpxAgent} is pinned to the qualified ${profile.model} profile; process readiness is verified by runnerd before the first turn.`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
const configuredPermission = context.config.codexPermissionMode;
|
||||
if (
|
||||
configuredPermission !== undefined
|
||||
&& resolvePaperclipRunnerPermissionMode("codex", configuredPermission)
|
||||
!== configuredPermission
|
||||
) {
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "fail" as const,
|
||||
testedAt: new Date().toISOString(),
|
||||
checks: [{
|
||||
code: "runner_permission_mode_invalid",
|
||||
level: "error" as const,
|
||||
message: "codexPermissionMode is not supported by Codex.",
|
||||
}],
|
||||
};
|
||||
}
|
||||
const result = await codexTestEnvironment(context);
|
||||
const result = profile.provider === "opencode"
|
||||
? await openCodeTestEnvironment(context)
|
||||
: await codexTestEnvironment(context);
|
||||
return { ...result, adapterType: "paperclip_runner" };
|
||||
},
|
||||
listSkills: listCodexSkills,
|
||||
syncSkills: syncCodexSkills,
|
||||
sessionCodec: codexSessionCodec,
|
||||
models: codexModels,
|
||||
listModels: listCodexModels,
|
||||
refreshModels: refreshCodexModels,
|
||||
models: [
|
||||
...codexModels,
|
||||
{ id: DEFAULT_OPENCODE_RUNNER_MODEL, label: "OpenRouter · DeepSeek V4 Flash 0731" },
|
||||
{ id: QUALIFIED_ACPX_RUNNER_MODELS.claude, label: "Claude Sonnet 5" },
|
||||
],
|
||||
listModels: async () => [
|
||||
...await listCodexModels(),
|
||||
{ id: DEFAULT_OPENCODE_RUNNER_MODEL, label: "OpenRouter · DeepSeek V4 Flash 0731" },
|
||||
{ id: QUALIFIED_ACPX_RUNNER_MODELS.claude, label: "Claude Sonnet 5" },
|
||||
],
|
||||
refreshModels: async () => [
|
||||
...await refreshCodexModels(),
|
||||
{ id: DEFAULT_OPENCODE_RUNNER_MODEL, label: "OpenRouter · DeepSeek V4 Flash 0731" },
|
||||
{ id: QUALIFIED_ACPX_RUNNER_MODELS.claude, label: "Claude Sonnet 5" },
|
||||
],
|
||||
supportsLocalAgentJwt: false,
|
||||
supportsInstructionsBundle: true,
|
||||
instructionsPathKey: "instructionsFilePath",
|
||||
requiresMaterializedRuntimeSkills: false,
|
||||
getRuntimeCommandSpec: (config) => buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex"),
|
||||
getRuntimeCommandSpec: (config) => config.provider === "acpx"
|
||||
? { command: "paperclip-runnerd", detectCommand: null, installCommand: null }
|
||||
: config.provider === "opencode"
|
||||
? buildNpmRuntimeCommandSpec(
|
||||
config,
|
||||
"opencode",
|
||||
`opencode-ai@${QUALIFIED_OPENCODE_RUNNER_VERSION}`,
|
||||
)
|
||||
: buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex@0.148.0"),
|
||||
agentConfigurationDoc:
|
||||
"# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex through the Rust Paperclip runner and authenticated PRP transport.\n",
|
||||
"# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, or a qualified Claude/Codex ACP agent through the Rust Paperclip runner and authenticated PRP transport. Pi is not available through the qualified ACPX profile.\n",
|
||||
getConfigSchema: () => ({
|
||||
fields: [
|
||||
{
|
||||
key: "provider",
|
||||
label: "Provider",
|
||||
type: "select" as const,
|
||||
default: "codex",
|
||||
options: [
|
||||
{ value: "codex", label: "Codex" },
|
||||
{ value: "opencode", label: `OpenCode ${QUALIFIED_OPENCODE_RUNNER_VERSION}` },
|
||||
{ value: "acpx", label: "ACPX" },
|
||||
],
|
||||
hint: "Select Codex, qualified OpenCode, or a qualified Claude/Codex ACPX profile.",
|
||||
},
|
||||
{
|
||||
key: "codexPermissionMode",
|
||||
label: "Codex permission mode",
|
||||
|
|
@ -436,6 +471,50 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
|||
({ value, label }) => ({ value, label }),
|
||||
),
|
||||
hint: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.codex.description,
|
||||
meta: { visibleWhen: { key: "provider", value: "codex" } },
|
||||
},
|
||||
{
|
||||
key: "opencodePermissionMode",
|
||||
label: "OpenCode permission mode",
|
||||
type: "select" as const,
|
||||
default: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.opencode.defaultMode,
|
||||
options: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.opencode.options.map(
|
||||
({ value, label }) => ({ value, label }),
|
||||
),
|
||||
hint: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.opencode.description,
|
||||
meta: { visibleWhen: { key: "provider", value: "opencode" } },
|
||||
},
|
||||
{
|
||||
key: "acpxPermissionMode",
|
||||
label: "ACPX permission mode",
|
||||
type: "select" as const,
|
||||
default: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.acpx.defaultMode,
|
||||
options: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.acpx.options.map(
|
||||
({ value, label }) => ({ value, label }),
|
||||
),
|
||||
hint: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.acpx.description,
|
||||
meta: { visibleWhen: { key: "provider", value: "acpx" } },
|
||||
},
|
||||
{
|
||||
key: "acpxAgent",
|
||||
label: "ACP agent",
|
||||
type: "select" as const,
|
||||
default: "claude",
|
||||
options: [
|
||||
{ value: "claude", label: "Claude via ACPX" },
|
||||
{ value: "codex", label: "Codex via ACPX" },
|
||||
],
|
||||
hint: "Only the pinned Claude and Codex profiles are qualified; Pi is unavailable.",
|
||||
meta: { visibleWhen: { key: "provider", value: "acpx" } },
|
||||
},
|
||||
{
|
||||
key: "model",
|
||||
label: "Provider model",
|
||||
type: "text" as const,
|
||||
default: "",
|
||||
placeholder: DEFAULT_OPENCODE_RUNNER_MODEL,
|
||||
hint: "OpenCode uses provider/model form. ACPX models are pinned by the selected qualified agent profile.",
|
||||
meta: { visibleWhen: { key: "provider", value: "opencode" } },
|
||||
},
|
||||
{
|
||||
key: "lifecycleMode",
|
||||
|
|
@ -454,6 +533,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
|||
type: "number" as const,
|
||||
default: 300_000,
|
||||
hint: "Warm sessions suspend after this much inactivity.",
|
||||
meta: { visibleWhen: { key: "lifecycleMode", value: "warm" } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -209,6 +209,10 @@ import {
|
|||
changeConsentGateService,
|
||||
touchesAgentProfileChangeConsentFields,
|
||||
} from "../services/change-consent-gate.js";
|
||||
import {
|
||||
PaperclipRunnerProviderProfileError,
|
||||
resolvePaperclipRunnerProviderProfile,
|
||||
} from "../services/native-runtime/provider-profile.js";
|
||||
|
||||
const AGENT_SKILL_ASSIGNMENT_MODES = ["add", "remove", "replace"] as const;
|
||||
|
||||
|
|
@ -1686,12 +1690,14 @@ export function agentRoutes(
|
|||
adapterConfig: Record<string, unknown>,
|
||||
): void {
|
||||
if (adapterType !== "paperclip_runner") return;
|
||||
const provider = adapterConfig.provider;
|
||||
if (provider === undefined || provider === "codex") return;
|
||||
throw unprocessable(
|
||||
"Paperclip Runner currently supports Codex for new or changed agent configurations.",
|
||||
{ code: "paperclip_runner_provider_unavailable" },
|
||||
);
|
||||
try {
|
||||
resolvePaperclipRunnerProviderProfile(adapterConfig);
|
||||
} catch (error) {
|
||||
if (error instanceof PaperclipRunnerProviderProfileError) {
|
||||
throw unprocessable(error.message, { code: error.code });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function assertProviderTraceSettingTransition(
|
||||
|
|
@ -1997,6 +2003,10 @@ export function agentRoutes(
|
|||
adapterType: string | null | undefined,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
) {
|
||||
if (adapterType === "paperclip_runner") {
|
||||
assertFreshPaperclipRunnerProvider(adapterType, adapterConfig);
|
||||
return;
|
||||
}
|
||||
if (adapterType !== "opencode_local") return;
|
||||
try {
|
||||
requireOpenCodeModelId(adapterConfig.model);
|
||||
|
|
|
|||
|
|
@ -105,6 +105,10 @@ import type {
|
|||
ImportIssueWorkProductRow,
|
||||
ImportIssueAttachmentRow,
|
||||
} from "./import-write-types.js";
|
||||
import {
|
||||
PaperclipRunnerProviderProfileError,
|
||||
resolvePaperclipRunnerProviderProfile,
|
||||
} from "./native-runtime/provider-profile.js";
|
||||
|
||||
const EXPORT_READ_CONCURRENCY = 8;
|
||||
const EXPORT_ISSUE_READ_CONCURRENCY = 2;
|
||||
|
|
@ -3585,12 +3589,13 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
adapterConfig: Record<string, unknown>,
|
||||
) {
|
||||
if (adapterType === "paperclip_runner") {
|
||||
const provider = adapterConfig.provider ?? "codex";
|
||||
if (provider !== "codex") {
|
||||
throw unprocessable(
|
||||
"Imported Paperclip Runner agents currently support only the Codex provider.",
|
||||
{ code: "paperclip_runner_provider_unavailable" },
|
||||
);
|
||||
try {
|
||||
resolvePaperclipRunnerProviderProfile(adapterConfig);
|
||||
} catch (error) {
|
||||
if (error instanceof PaperclipRunnerProviderProfileError) {
|
||||
throw unprocessable(error.message, { code: error.code });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolvePaperclipRunnerNativeProviderInput } from "./native-runtime/provider-profile.js";
|
||||
|
||||
describe("Paperclip Runner native provider configuration", () => {
|
||||
it("projects OpenCode identity, model, and permissions from adapter config", () => {
|
||||
expect(
|
||||
resolvePaperclipRunnerNativeProviderInput({
|
||||
backend: "opencode_server",
|
||||
adapterConfig: {
|
||||
provider: "opencode",
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
opencodePermissionMode: "deny",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "opencode",
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
opencodePermissionMode: "deny",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["claude", "claude-sonnet-5", "approve-all"],
|
||||
["codex", "gpt-5.6-sol", "deny-all"],
|
||||
] as const)(
|
||||
"projects the qualified ACPX %s descriptor from adapter config",
|
||||
(acpxAgent, model, acpxPermissionMode) => {
|
||||
expect(
|
||||
resolvePaperclipRunnerNativeProviderInput({
|
||||
backend: "acpx_runtime",
|
||||
adapterConfig: { provider: "acpx", acpxAgent, model, acpxPermissionMode },
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "acpx",
|
||||
acpxAgent,
|
||||
model,
|
||||
acpxPermissionMode,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("applies the safe provider permission default from adapter config", () => {
|
||||
expect(
|
||||
resolvePaperclipRunnerNativeProviderInput({
|
||||
backend: "opencode_server",
|
||||
adapterConfig: {
|
||||
provider: "opencode",
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "opencode",
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
opencodePermissionMode: "ask",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when the persisted backend and current provider disagree", () => {
|
||||
expect(() =>
|
||||
resolvePaperclipRunnerNativeProviderInput({
|
||||
backend: "opencode_server",
|
||||
adapterConfig: { provider: "codex" },
|
||||
}),
|
||||
).toThrow("provider changed after this run selected its native backend");
|
||||
});
|
||||
|
||||
it("rejects Pi before a native descriptor is persisted", () => {
|
||||
expect(() =>
|
||||
resolvePaperclipRunnerNativeProviderInput({
|
||||
backend: "acpx_runtime",
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "pi-model" },
|
||||
}),
|
||||
).toThrow("Pi is not available");
|
||||
});
|
||||
});
|
||||
|
|
@ -135,6 +135,7 @@ import {
|
|||
reconcileNativeFinalizations,
|
||||
resolveHeartbeatNativeRuntimeMode,
|
||||
} from "./native-runtime/index.js";
|
||||
import { resolvePaperclipRunnerNativeProviderInput } from "./native-runtime/provider-profile.js";
|
||||
import type { NativeRunHistoricalSpan } from "./native-runtime/native-run-trace.js";
|
||||
import {
|
||||
parseNativeExecutionInput,
|
||||
|
|
@ -373,7 +374,6 @@ import { createRunSecretRedactionRegistry } from "./run-secret-redaction.js";
|
|||
import {
|
||||
hasSessionCompactionThresholds,
|
||||
resolvePaperclipRunnerIdleTimeoutMs,
|
||||
resolvePaperclipRunnerPermissionMode,
|
||||
resolveSessionCompactionPolicy,
|
||||
type RuntimeStatusUpdate,
|
||||
type SessionCompactionPolicy,
|
||||
|
|
@ -19837,34 +19837,10 @@ export function heartbeatService(
|
|||
: {},
|
||||
}
|
||||
: null,
|
||||
provider:
|
||||
nativeRuntimeResolution.profile.backend === "opencode_server"
|
||||
? "opencode"
|
||||
: nativeRuntimeResolution.profile.backend === "acpx_runtime"
|
||||
? "acpx"
|
||||
: "codex",
|
||||
...(nativeRuntimeResolution.profile.backend === "acpx_runtime"
|
||||
? {
|
||||
acpxAgent: parseObject(runtimeConfig).acpxAgent as
|
||||
"pi" | "claude" | "codex",
|
||||
}
|
||||
: {}),
|
||||
codexApprovalPolicy: resolvePaperclipRunnerPermissionMode(
|
||||
"codex",
|
||||
parseObject(agent.adapterConfig).codexPermissionMode,
|
||||
) as "never" | "on-request" | "untrusted",
|
||||
opencodePermissionMode: resolvePaperclipRunnerPermissionMode(
|
||||
"opencode",
|
||||
parseObject(runtimeConfig).opencodePermissionMode,
|
||||
) as "allow" | "ask" | "deny",
|
||||
acpxPermissionMode: resolvePaperclipRunnerPermissionMode(
|
||||
"acpx",
|
||||
parseObject(runtimeConfig).acpxPermissionMode,
|
||||
) as "approve-all" | "approve-reads" | "deny-all",
|
||||
model:
|
||||
typeof parseObject(agent.adapterConfig).model === "string"
|
||||
? String(parseObject(agent.adapterConfig).model)
|
||||
: null,
|
||||
...resolvePaperclipRunnerNativeProviderInput({
|
||||
backend: nativeRuntimeResolution.profile.backend,
|
||||
adapterConfig: agent.adapterConfig,
|
||||
}),
|
||||
lifecyclePolicy: effectiveLifecyclePolicy,
|
||||
interactionResponses,
|
||||
completionContract: {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ describe("buildNativeRunnerPreparePayload", () => {
|
|||
},
|
||||
})).toMatchObject({
|
||||
provider: {
|
||||
kind: "codex",
|
||||
provider: "codex",
|
||||
driver: "codex_app_server",
|
||||
providerSessionId: "thread-1",
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export function buildNativeRunnerPreparePayload(
|
|||
): Record<string, unknown> {
|
||||
return {
|
||||
provider: {
|
||||
kind: "codex",
|
||||
provider: "codex",
|
||||
driver: "codex_app_server",
|
||||
providerVersion: input.providerLaunch?.providerVersion ?? "codex-app-server-v1",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { access, mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
access,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
|
@ -31,13 +38,20 @@ type BackendFactoryOptions = {
|
|||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
type RunnerTransportOptions = {
|
||||
stateDirectory?: string;
|
||||
runnerBinary?: string;
|
||||
provider?: "codex" | "opencode" | "acpx";
|
||||
opencodePermissionMode?: "allow" | "ask" | "deny";
|
||||
acpxAgent?: "claude" | "codex";
|
||||
acpxPermissionMode?: "approve-all" | "approve-reads" | "deny-all";
|
||||
};
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
execute: vi.fn(),
|
||||
createTransport: vi.fn(
|
||||
(_options: { stateDirectory?: string; runnerBinary?: string }) => ({
|
||||
transport: {},
|
||||
}),
|
||||
),
|
||||
createTransport: vi.fn((_options: RunnerTransportOptions) => ({
|
||||
transport: {},
|
||||
})),
|
||||
createBackend: vi.fn(
|
||||
(_input: NativeExecutionInputV1, _options: BackendFactoryOptions) => ({
|
||||
kind: "test",
|
||||
|
|
@ -45,38 +59,33 @@ const state = vi.hoisted(() => ({
|
|||
),
|
||||
cancel: vi.fn(),
|
||||
toolAuthorityExecute: vi.fn(),
|
||||
persistActivity: vi.fn(
|
||||
async (_db: unknown, input: { action: string }) => ({
|
||||
activity: {
|
||||
id:
|
||||
input.action === "native.cancellation_intent_recorded"
|
||||
? "native-cancellation-audit"
|
||||
: "native-cancellation-ack-audit",
|
||||
},
|
||||
publication: {
|
||||
companyId: "company",
|
||||
payload: { action: input.action },
|
||||
pluginEvent: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
persistActivity: vi.fn(async (_db: unknown, input: { action: string }) => ({
|
||||
activity: {
|
||||
id:
|
||||
input.action === "native.cancellation_intent_recorded"
|
||||
? "native-cancellation-audit"
|
||||
: "native-cancellation-ack-audit",
|
||||
},
|
||||
publication: {
|
||||
companyId: "company",
|
||||
payload: { action: input.action },
|
||||
pluginEvent: null,
|
||||
},
|
||||
})),
|
||||
publishActivity: vi.fn(),
|
||||
resolveRunnerBinary: vi.fn(() => "/tmp/paperclip-runnerd"),
|
||||
release: null as null | (() => void),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"../../vendor/paperclip-runner/index.js",
|
||||
async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("../../vendor/paperclip-runner/index.js")
|
||||
>()),
|
||||
createNativeSessionBackend: state.createBackend,
|
||||
createRunnerdCodexTransport: state.createTransport,
|
||||
executeNativeSession: state.execute,
|
||||
parsePaperclipQuestionSet: (value: unknown) => value,
|
||||
}),
|
||||
);
|
||||
vi.mock("../../vendor/paperclip-runner/index.js", async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("../../vendor/paperclip-runner/index.js")
|
||||
>()),
|
||||
createNativeSessionBackend: state.createBackend,
|
||||
createRunnerdCodexTransport: state.createTransport,
|
||||
executeNativeSession: state.execute,
|
||||
parsePaperclipQuestionSet: (value: unknown) => value,
|
||||
}));
|
||||
|
||||
vi.mock("./paperclip-runner-tool-authority.js", () => ({
|
||||
PaperclipRunnerToolAuthority: class {
|
||||
|
|
@ -798,7 +807,9 @@ describe("remote provider checkpoint snapshots", () => {
|
|||
mode: 0o700,
|
||||
}),
|
||||
).rejects.toThrow("runner_remote_checkpoint_archive_unsafe_entry");
|
||||
await expect(access(join(targetPath, "preserved.txt"))).resolves.toBeUndefined();
|
||||
await expect(
|
||||
access(join(targetPath, "preserved.txt")),
|
||||
).resolves.toBeUndefined();
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -961,7 +972,6 @@ describe("remote runner transport authorization", () => {
|
|||
});
|
||||
});
|
||||
|
||||
|
||||
describe("runtime question fallback", () => {
|
||||
const questionSet = {
|
||||
schema: "paperclip.question_set.v1" as const,
|
||||
|
|
@ -1505,7 +1515,8 @@ function leaseDb(
|
|||
const result = Promise.resolve([]) as unknown as Promise<unknown[]> & {
|
||||
returning: () => Promise<Array<{ runId: string }>>;
|
||||
};
|
||||
result.returning = () => Promise.resolve([{ runId: coordinator.runId }]);
|
||||
result.returning = () =>
|
||||
Promise.resolve([{ runId: coordinator.runId }]);
|
||||
return result;
|
||||
},
|
||||
}),
|
||||
|
|
@ -1571,7 +1582,8 @@ function cancellationDb(options?: {
|
|||
: { runId: execution.binding.runId, assessmentId: null };
|
||||
let forUpdateCount = 0;
|
||||
let resultJsonUpdateCount = 0;
|
||||
const updates: Array<{ table: unknown; values: Record<string, unknown> }> = [];
|
||||
const updates: Array<{ table: unknown; values: Record<string, unknown> }> =
|
||||
[];
|
||||
const select = vi.fn(() => ({
|
||||
from: (table: unknown) => {
|
||||
const rows =
|
||||
|
|
@ -2656,11 +2668,100 @@ describe("native process ownership", () => {
|
|||
);
|
||||
expect(onSpawn).toHaveBeenCalledWith(processMetadata);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"OpenCode",
|
||||
{
|
||||
kind: "opencode",
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
permissionMode: "deny",
|
||||
},
|
||||
"opencode_server",
|
||||
],
|
||||
[
|
||||
"Claude ACPX",
|
||||
{
|
||||
kind: "acpx",
|
||||
agent: "claude",
|
||||
model: "claude-sonnet-5",
|
||||
permissionMode: "approve-all",
|
||||
},
|
||||
"acpx_runtime",
|
||||
],
|
||||
[
|
||||
"Codex ACPX",
|
||||
{
|
||||
kind: "acpx",
|
||||
agent: "codex",
|
||||
model: "gpt-5.6-sol",
|
||||
permissionMode: "deny-all",
|
||||
},
|
||||
"acpx_runtime",
|
||||
],
|
||||
])(
|
||||
"admits the qualified %s provider",
|
||||
async (_name, provider, driverKind) => {
|
||||
const providerExecution = {
|
||||
...execution,
|
||||
binding: {
|
||||
...execution.binding,
|
||||
runId: `run-${String(provider.kind)}-${"agent" in provider ? provider.agent : "native"}`,
|
||||
},
|
||||
provider,
|
||||
session: { ...execution.session, driverKind },
|
||||
} as unknown as NativeExecutionInputV1;
|
||||
state.createBackend.mockClear();
|
||||
state.execute.mockReset().mockResolvedValue({
|
||||
result: { summary: "completed" },
|
||||
terminal: { runTerminalState: "succeeded" },
|
||||
turnId: "turn",
|
||||
normalizedSessionId: "session",
|
||||
providerSessionId: null,
|
||||
driverKind,
|
||||
driverVersion: "1",
|
||||
nativeEventCount: 1,
|
||||
highestContiguousSourceSeq: 1,
|
||||
});
|
||||
|
||||
await executePaperclipNativeSession({
|
||||
db: leaseDb(providerExecution),
|
||||
execution: providerExecution,
|
||||
runnerInstanceId: "runner",
|
||||
});
|
||||
|
||||
expect(state.createBackend).toHaveBeenCalledWith(
|
||||
providerExecution,
|
||||
expect.any(Object),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects ACPX Pi before constructing a backend", async () => {
|
||||
const piExecution = {
|
||||
...execution,
|
||||
binding: { ...execution.binding, runId: "run-acpx-pi-rejected" },
|
||||
provider: { kind: "acpx", agent: "pi", model: "pi-model" },
|
||||
session: { ...execution.session, driverKind: "acpx_runtime" },
|
||||
} as unknown as NativeExecutionInputV1;
|
||||
state.createBackend.mockClear();
|
||||
|
||||
await expect(
|
||||
executePaperclipNativeSession({
|
||||
db: leaseDb(piExecution),
|
||||
execution: piExecution,
|
||||
runnerInstanceId: "runner",
|
||||
}),
|
||||
).rejects.toThrow("descriptor-confined verified launch");
|
||||
expect(state.createBackend).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runnerd provider runtime wiring", () => {
|
||||
it("reuses legacy unscoped state only for its exact durable run identity", async () => {
|
||||
const stateBase = await mkdtemp(join(tmpdir(), "paperclip-legacy-runner-state-"));
|
||||
const stateBase = await mkdtemp(
|
||||
join(tmpdir(), "paperclip-legacy-runner-state-"),
|
||||
);
|
||||
const previousStateDirectory = process.env.PAPERCLIP_RUNNER_STATE_DIR;
|
||||
process.env.PAPERCLIP_RUNNER_STATE_DIR = stateBase;
|
||||
const legacyExecution = {
|
||||
|
|
@ -2895,13 +2996,9 @@ describe("runnerd provider runtime wiring", () => {
|
|||
|
||||
it.each([
|
||||
["opencode", { kind: "opencode", model: null }, "opencode_server"],
|
||||
[
|
||||
"acpx",
|
||||
{ kind: "acpx", agent: "codex", model: null },
|
||||
"acpx_runtime",
|
||||
],
|
||||
["acpx", { kind: "acpx", agent: "codex", model: null }, "acpx_runtime"],
|
||||
])(
|
||||
"fails closed before launching the remote %s provider",
|
||||
"requires the build-owned provider pack before launching remote %s",
|
||||
async (providerKind, provider, driverKind) => {
|
||||
const remoteCwd = "/home/daytona/paperclip-workspace";
|
||||
const remoteProviderExecution = {
|
||||
|
|
@ -2941,7 +3038,7 @@ describe("runnerd provider runtime wiring", () => {
|
|||
},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
`runner_remote_provider_artifact_incompatible: remote ${providerKind} is unavailable until runnerd provider dispatch is qualified`,
|
||||
"runner_remote_provider_artifact_incompatible: configure PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH",
|
||||
);
|
||||
expect(state.createBackend).not.toHaveBeenCalled();
|
||||
},
|
||||
|
|
@ -2950,7 +3047,7 @@ describe("runnerd provider runtime wiring", () => {
|
|||
it("passes the isolated ACPX runtime directory to the native backend factory", async () => {
|
||||
const acpxExecution = {
|
||||
...execution,
|
||||
schema: "paperclip.native-execution-input.v3",
|
||||
schema: "paperclip.native-execution-input.v4",
|
||||
task: {
|
||||
identifier: "DOT-ACPX",
|
||||
title: "ACPX task",
|
||||
|
|
@ -2974,7 +3071,7 @@ describe("runnerd provider runtime wiring", () => {
|
|||
kind: "acpx",
|
||||
agent: "codex",
|
||||
model: "gpt-5.6-sol",
|
||||
permissionPolicy: "interactive",
|
||||
permissionMode: "approve-reads",
|
||||
profile: {
|
||||
driverKind: "acpx_runtime",
|
||||
protocolVersion: 1,
|
||||
|
|
@ -3010,6 +3107,47 @@ describe("runnerd provider runtime wiring", () => {
|
|||
acpxDynamicToolHandler: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
state.createTransport.mockClear();
|
||||
state.createBackend.mock.calls[0]![1].codexTransportFactory!();
|
||||
expect(state.createTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "acpx",
|
||||
acpxAgent: "codex",
|
||||
acpxPermissionMode: "approve-reads",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the persisted OpenCode permission mode to runnerd", async () => {
|
||||
const opencodeExecution = {
|
||||
...execution,
|
||||
schema: "paperclip.native-execution-input.v4",
|
||||
binding: { ...execution.binding, runId: "run-opencode-permissions" },
|
||||
session: {
|
||||
...execution.session,
|
||||
normalizedSessionId: "opencode-permissions-session",
|
||||
driverKind: "opencode_server",
|
||||
},
|
||||
provider: {
|
||||
kind: "opencode",
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
permissionMode: "deny",
|
||||
},
|
||||
} as unknown as NativeExecutionInputV1;
|
||||
state.createBackend.mockClear();
|
||||
await createRunnerdBackend({
|
||||
db: leaseDb(opencodeExecution),
|
||||
execution: opencodeExecution,
|
||||
runnerInstanceId: "runner",
|
||||
});
|
||||
|
||||
state.createTransport.mockClear();
|
||||
state.createBackend.mock.calls[0]![1].codexTransportFactory!();
|
||||
expect(state.createTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "opencode",
|
||||
opencodePermissionMode: "deny",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2719,7 +2719,7 @@ export async function executePaperclipNativeSession(input: {
|
|||
processGroupId: number | null;
|
||||
startedAt: string;
|
||||
}) => Promise<void>;
|
||||
/** Test seam at the provider boundary; production always uses the package Codex backend. */
|
||||
/** Test seam at the provider boundary; production uses a qualified package backend. */
|
||||
backend?: NativeSessionBackend;
|
||||
useRunnerd?: boolean;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
|
|
@ -2750,9 +2750,21 @@ export async function executePaperclipNativeSession(input: {
|
|||
},
|
||||
) => Promise<unknown>;
|
||||
}): Promise<AdapterExecutionResult> {
|
||||
if (input.execution.provider.kind !== "codex") {
|
||||
if (
|
||||
input.execution.provider.kind !== "codex"
|
||||
&& input.execution.provider.kind !== "opencode"
|
||||
&& input.execution.provider.kind !== "acpx"
|
||||
) {
|
||||
throw new Error("paperclip_runner_provider_unsupported");
|
||||
}
|
||||
if (
|
||||
input.execution.provider.kind === "acpx"
|
||||
&& input.execution.provider.agent === "pi"
|
||||
) {
|
||||
throw new Error(
|
||||
"paperclip_runner_provider_unsupported: ACPX Pi is unavailable until descriptor-confined verified launch is implemented",
|
||||
);
|
||||
}
|
||||
const earliestPreparationStart = input.preparationSpans?.reduce(
|
||||
(earliest, span) => Math.min(earliest, span.startedAtMs),
|
||||
Date.now(),
|
||||
|
|
@ -4684,11 +4696,6 @@ export async function createRunnerdBackend(input: {
|
|||
) => Promise<unknown>;
|
||||
}): Promise<NativeSessionBackend> {
|
||||
const target = input.runnerExecutionTarget ?? { kind: "local" as const };
|
||||
if (target.kind === "remote" && input.execution.provider.kind !== "codex") {
|
||||
throw new Error(
|
||||
`runner_remote_provider_artifact_incompatible: remote ${input.execution.provider.kind} is unavailable until runnerd provider dispatch is qualified`,
|
||||
);
|
||||
}
|
||||
const authority = new PaperclipRunnerToolAuthority(input.db, {
|
||||
companyId: input.execution.binding.companyId,
|
||||
issueId: input.execution.binding.issueId,
|
||||
|
|
@ -6023,6 +6030,11 @@ export async function createRunnerdBackend(input: {
|
|||
),
|
||||
}
|
||||
: {}),
|
||||
...(input.execution.provider.kind === "opencode"
|
||||
? {
|
||||
opencodePermissionMode: input.execution.provider.permissionMode,
|
||||
}
|
||||
: {}),
|
||||
...(expectedProviderPackManifest && stagedRemoteProviderPackRoot
|
||||
? {
|
||||
providerNodeCommand: posix.join(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
import {
|
||||
isPaperclipRunnerProvider,
|
||||
PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES,
|
||||
resolvePaperclipRunnerPermissionMode,
|
||||
type PaperclipRunnerProvider,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
|
||||
export const QUALIFIED_OPENCODE_RUNNER_VERSION = "1.18.17" as const;
|
||||
export const DEFAULT_OPENCODE_RUNNER_MODEL =
|
||||
"openrouter/deepseek/deepseek-v4-flash-0731" as const;
|
||||
|
||||
export const QUALIFIED_ACPX_RUNNER_MODELS = {
|
||||
claude: "claude-sonnet-5",
|
||||
codex: "gpt-5.6-sol",
|
||||
} as const;
|
||||
|
||||
export type QualifiedPaperclipRunnerAcpxAgent =
|
||||
keyof typeof QUALIFIED_ACPX_RUNNER_MODELS;
|
||||
|
||||
export type PaperclipRunnerProviderProfile =
|
||||
| {
|
||||
provider: "codex";
|
||||
backend: "codex_app_server";
|
||||
model: string | null;
|
||||
}
|
||||
| {
|
||||
provider: "opencode";
|
||||
backend: "opencode_server";
|
||||
model: string;
|
||||
}
|
||||
| {
|
||||
provider: "acpx";
|
||||
backend: "acpx_runtime";
|
||||
model: string;
|
||||
acpxAgent: QualifiedPaperclipRunnerAcpxAgent;
|
||||
};
|
||||
|
||||
export type PaperclipRunnerNativeProviderInput =
|
||||
| {
|
||||
provider: "codex";
|
||||
model: string | null;
|
||||
codexApprovalPolicy: "never" | "on-request" | "untrusted";
|
||||
}
|
||||
| {
|
||||
provider: "opencode";
|
||||
model: string;
|
||||
opencodePermissionMode: "allow" | "ask" | "deny";
|
||||
}
|
||||
| {
|
||||
provider: "acpx";
|
||||
model: string;
|
||||
acpxAgent: QualifiedPaperclipRunnerAcpxAgent;
|
||||
acpxPermissionMode: "approve-all" | "approve-reads" | "deny-all";
|
||||
};
|
||||
|
||||
export class PaperclipRunnerProviderProfileError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "PaperclipRunnerProviderProfileError";
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
? value.trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
function assertPermissionMode(
|
||||
provider: PaperclipRunnerProvider,
|
||||
config: Record<string, unknown>,
|
||||
): void {
|
||||
const capability = PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES[provider];
|
||||
const configured = config[capability.configKey];
|
||||
if (
|
||||
configured !== undefined
|
||||
&& resolvePaperclipRunnerPermissionMode(provider, configured) !== configured
|
||||
) {
|
||||
throw new PaperclipRunnerProviderProfileError(
|
||||
"runner_permission_mode_invalid",
|
||||
`${capability.configKey} is not supported by ${provider}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the immutable provider identity used for a fresh Paperclip Runner
|
||||
* selection. The persisted adapterConfig is the authority; runtimeConfig is
|
||||
* deliberately not consulted so model-profile or migration metadata cannot
|
||||
* silently switch the harness selected for a run.
|
||||
*/
|
||||
export function resolvePaperclipRunnerProviderProfile(
|
||||
adapterConfig: unknown,
|
||||
): PaperclipRunnerProviderProfile {
|
||||
const config = asRecord(adapterConfig);
|
||||
const candidate = config.provider ?? "codex";
|
||||
if (!isPaperclipRunnerProvider(candidate)) {
|
||||
throw new PaperclipRunnerProviderProfileError(
|
||||
"paperclip_runner_provider_unsupported",
|
||||
"Paperclip Runner provider must be codex, opencode, or acpx.",
|
||||
);
|
||||
}
|
||||
|
||||
assertPermissionMode(candidate, config);
|
||||
const model = optionalString(config.model);
|
||||
if (candidate === "codex") {
|
||||
return {
|
||||
provider: "codex",
|
||||
backend: "codex_app_server",
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
if (candidate === "opencode") {
|
||||
if (!model || !model.includes("/") || model.endsWith("/")) {
|
||||
throw new PaperclipRunnerProviderProfileError(
|
||||
"paperclip_runner_opencode_model_invalid",
|
||||
"Paperclip Runner OpenCode requires model in provider/model form.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
provider: "opencode",
|
||||
backend: "opencode_server",
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
const acpxAgent = config.acpxAgent;
|
||||
if (acpxAgent !== "claude" && acpxAgent !== "codex") {
|
||||
throw new PaperclipRunnerProviderProfileError(
|
||||
"paperclip_runner_acpx_agent_unavailable",
|
||||
"Paperclip Runner ACPX requires the qualified Claude or Codex agent profile; Pi is not available.",
|
||||
);
|
||||
}
|
||||
const qualifiedModel = QUALIFIED_ACPX_RUNNER_MODELS[acpxAgent];
|
||||
if (model !== qualifiedModel) {
|
||||
throw new PaperclipRunnerProviderProfileError(
|
||||
"paperclip_runner_acpx_model_unqualified",
|
||||
`Paperclip Runner ACPX ${acpxAgent} requires exact model ${qualifiedModel}.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
provider: "acpx",
|
||||
backend: "acpx_runtime",
|
||||
model,
|
||||
acpxAgent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the operator-owned adapter configuration into the closed native
|
||||
* execution descriptor. The selected backend must still match the provider;
|
||||
* an adapter edit cannot silently change a run that already persisted its
|
||||
* runtime driver.
|
||||
*/
|
||||
export function resolvePaperclipRunnerNativeProviderInput(input: {
|
||||
backend: PaperclipRunnerProviderProfile["backend"];
|
||||
adapterConfig: unknown;
|
||||
}): PaperclipRunnerNativeProviderInput {
|
||||
const config = asRecord(input.adapterConfig);
|
||||
const profile = resolvePaperclipRunnerProviderProfile(config);
|
||||
if (profile.backend !== input.backend) {
|
||||
throw new PaperclipRunnerProviderProfileError(
|
||||
"paperclip_runner_provider_changed",
|
||||
"Paperclip Runner provider changed after this run selected its native backend.",
|
||||
);
|
||||
}
|
||||
if (profile.provider === "opencode") {
|
||||
return {
|
||||
provider: "opencode",
|
||||
model: profile.model,
|
||||
opencodePermissionMode: resolvePaperclipRunnerPermissionMode(
|
||||
"opencode",
|
||||
config.opencodePermissionMode,
|
||||
) as "allow" | "ask" | "deny",
|
||||
};
|
||||
}
|
||||
if (profile.provider === "acpx") {
|
||||
return {
|
||||
provider: "acpx",
|
||||
model: profile.model,
|
||||
acpxAgent: profile.acpxAgent,
|
||||
acpxPermissionMode: resolvePaperclipRunnerPermissionMode(
|
||||
"acpx",
|
||||
config.acpxPermissionMode,
|
||||
) as "approve-all" | "approve-reads" | "deny-all",
|
||||
};
|
||||
}
|
||||
return {
|
||||
provider: "codex",
|
||||
model: profile.model,
|
||||
codexApprovalPolicy: resolvePaperclipRunnerPermissionMode(
|
||||
"codex",
|
||||
config.codexPermissionMode,
|
||||
) as "never" | "on-request" | "untrusted",
|
||||
};
|
||||
}
|
||||
|
|
@ -63,19 +63,66 @@ describe("resolveNativeRuntimeMode", () => {
|
|||
}));
|
||||
});
|
||||
|
||||
it("rejects fresh OpenCode and ACPX starts until their app profiles are activated", () => {
|
||||
expect(() => resolveNativeRuntimeMode({
|
||||
it("admits fresh OpenCode and qualified ACPX profiles", () => {
|
||||
expect(resolveNativeRuntimeMode({
|
||||
...eligible,
|
||||
adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
|
||||
})).toMatchObject({
|
||||
kind: "native",
|
||||
profile: { backend: "opencode_server" },
|
||||
});
|
||||
expect(resolveNativeRuntimeMode({
|
||||
...eligible,
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" },
|
||||
})).toMatchObject({
|
||||
kind: "native",
|
||||
profile: { backend: "acpx_runtime" },
|
||||
});
|
||||
expect(resolveNativeRuntimeMode({
|
||||
...eligible,
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "codex", model: "gpt-5.6-sol" },
|
||||
})).toMatchObject({
|
||||
kind: "native",
|
||||
profile: { backend: "acpx_runtime" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed OpenCode and unqualified ACPX profiles", () => {
|
||||
expect(() => resolveNativeRuntimeMode({
|
||||
...eligible,
|
||||
adapterConfig: { provider: "opencode", model: "gpt-5.6-sol" },
|
||||
})).toThrow(expect.objectContaining({
|
||||
code: "paperclip_runner_provider_unsupported",
|
||||
code: "paperclip_runner_opencode_model_invalid",
|
||||
}));
|
||||
expect(() => resolveNativeRuntimeMode({
|
||||
...eligible,
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" },
|
||||
adapterConfig: {
|
||||
provider: "acpx",
|
||||
acpxAgent: "pi",
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
},
|
||||
})).toThrow(expect.objectContaining({
|
||||
code: "paperclip_runner_provider_unsupported",
|
||||
code: "paperclip_runner_acpx_agent_unavailable",
|
||||
}));
|
||||
expect(() => resolveNativeRuntimeMode({
|
||||
...eligible,
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5" },
|
||||
})).toThrow(expect.objectContaining({
|
||||
code: "paperclip_runner_acpx_model_unqualified",
|
||||
}));
|
||||
});
|
||||
|
||||
it("uses adapterConfig as the fresh provider authority", () => {
|
||||
expect(resolveNativeRuntimeMode({
|
||||
...eligible,
|
||||
runtimeConfig: {
|
||||
nativeRunner: { provider: "opencode" },
|
||||
},
|
||||
adapterConfig: { provider: "codex" },
|
||||
})).toMatchObject({
|
||||
kind: "native",
|
||||
profile: { backend: "codex_app_server" },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves legacy as the default and as the kill-switch behavior", () => {
|
||||
|
|
@ -222,6 +269,26 @@ describe("resolveNativeRuntimeMode", () => {
|
|||
}));
|
||||
});
|
||||
|
||||
it("keeps a persisted ACPX recovery even when fresh selection is disabled", () => {
|
||||
expect(resolveHeartbeatNativeRuntimeMode({
|
||||
...eligible,
|
||||
enabled: false,
|
||||
adapterConfig: {
|
||||
provider: "acpx",
|
||||
acpxAgent: "pi",
|
||||
model: "historical",
|
||||
},
|
||||
persisted: {
|
||||
runtimeMode: "native",
|
||||
runtimeModeReason: "eligible_opt_in",
|
||||
runtimeModeResolvedAt: new Date(),
|
||||
driverKind: "acpx_runtime",
|
||||
},
|
||||
})).toEqual(expect.objectContaining({
|
||||
profile: { mode: "native", backend: "acpx_runtime", protocolVersion: 1 },
|
||||
}));
|
||||
});
|
||||
|
||||
it("rejects an explicit native profile outside the approved boundary", () => {
|
||||
expect(resolveNativeRuntimeMode({ ...eligible, agent: { ...eligible.agent, adapterType: "claude_local" } }))
|
||||
.toEqual(expect.objectContaining({ kind: "legacy", reason: "direct_adapter" }));
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@ import {
|
|||
type NativeAuthoritativeIssueStatus,
|
||||
type NativeStatusDecision,
|
||||
} from "./status-arbiter.js";
|
||||
import {
|
||||
PaperclipRunnerProviderProfileError,
|
||||
resolvePaperclipRunnerProviderProfile,
|
||||
type PaperclipRunnerProviderProfile,
|
||||
} from "./provider-profile.js";
|
||||
|
||||
/**
|
||||
* Public compatibility resolver version. This value is persisted by the
|
||||
|
|
@ -24,7 +29,7 @@ export type HeartbeatRuntimeResolution =
|
|||
kind: "native";
|
||||
resolverVersion: typeof NATIVE_RUNTIME_RESOLVER_VERSION;
|
||||
reason: "explicit_paperclip_runner" | "persisted_native_selection";
|
||||
provider: "codex";
|
||||
provider: PaperclipRunnerProviderProfile["provider"];
|
||||
};
|
||||
|
||||
export type NativeRuntimeResolution =
|
||||
|
|
@ -111,18 +116,14 @@ export function resolveNativeRuntimeMode(input: {
|
|||
"Paperclip Runner is experimental and disabled on this instance.",
|
||||
);
|
||||
}
|
||||
const adapterConfig = input.adapterConfig;
|
||||
const runnerProvider =
|
||||
typeof adapterConfig === "object"
|
||||
&& adapterConfig !== null
|
||||
&& !Array.isArray(adapterConfig)
|
||||
? (adapterConfig as Record<string, unknown>).provider ?? "codex"
|
||||
: "codex";
|
||||
if (runnerProvider !== "codex") {
|
||||
throw ineligible(
|
||||
"paperclip_runner_provider_unsupported",
|
||||
"Paperclip Runner currently supports only the Codex provider.",
|
||||
);
|
||||
let runnerProfile: PaperclipRunnerProviderProfile;
|
||||
try {
|
||||
runnerProfile = resolvePaperclipRunnerProviderProfile(input.adapterConfig);
|
||||
} catch (error) {
|
||||
if (error instanceof PaperclipRunnerProviderProfileError) {
|
||||
throw ineligible(error.code, error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
input.agent.adapterType !== "paperclip_runner"
|
||||
|
|
@ -157,7 +158,7 @@ export function resolveNativeRuntimeMode(input: {
|
|||
reason: "eligible_opt_in",
|
||||
profile: {
|
||||
mode: "native",
|
||||
backend: "codex_app_server",
|
||||
backend: runnerProfile.backend,
|
||||
protocolVersion: 1,
|
||||
},
|
||||
authorityDecision: rollout,
|
||||
|
|
@ -230,7 +231,11 @@ export function resolveHeartbeatRuntimeMode(input: {
|
|||
kind: "native",
|
||||
resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION,
|
||||
reason: "explicit_paperclip_runner",
|
||||
provider: "codex",
|
||||
provider: resolution.profile.backend === "opencode_server"
|
||||
? "opencode"
|
||||
: resolution.profile.backend === "acpx_runtime"
|
||||
? "acpx"
|
||||
: "codex",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,20 +25,47 @@ function renderRunner(config: Record<string, unknown>): string {
|
|||
}
|
||||
|
||||
describe("Paperclip Runner Codex configuration", () => {
|
||||
it("exposes only the qualified Codex provider and permission modes", () => {
|
||||
const html = renderRunner({ provider: "opencode" });
|
||||
it("exposes the qualified provider choices without managed profiles", () => {
|
||||
const html = renderRunner({ provider: "codex" });
|
||||
|
||||
expect(html).toContain('disabled=""><option value="codex" selected="">Codex</option>');
|
||||
expect(html).toContain('<option value="codex" selected="">Codex</option>');
|
||||
expect(html).toContain("OpenCode 1.18.17");
|
||||
expect(html).toContain("ACPX");
|
||||
expect(html).toContain("Full auto (never ask)");
|
||||
expect(html).toContain("Ask when requested");
|
||||
expect(html).toContain("Ask for untrusted operations");
|
||||
expect(html).not.toContain("OpenCode");
|
||||
expect(html).not.toContain("ACPX");
|
||||
expect(html).not.toContain("Claude Agent");
|
||||
expect(html).not.toContain("AWS AgentCore");
|
||||
expect(html).not.toContain("Bypass sandbox");
|
||||
});
|
||||
|
||||
it("renders OpenCode's bounded permission modes", () => {
|
||||
const html = renderRunner({
|
||||
provider: "opencode",
|
||||
opencodePermissionMode: "allow",
|
||||
});
|
||||
|
||||
expect(html).toContain('<option value="opencode" selected="">OpenCode 1.18.17</option>');
|
||||
expect(html).toContain('<option value="allow" selected="">Full auto (allow)</option>');
|
||||
expect(html).toContain("Ask for permission");
|
||||
expect(html).toContain("Deny operations");
|
||||
expect(html).not.toContain("Ask for untrusted operations");
|
||||
});
|
||||
|
||||
it("renders only the qualified ACPX Claude and Codex profiles", () => {
|
||||
const html = renderRunner({
|
||||
provider: "acpx",
|
||||
acpxAgent: "claude",
|
||||
acpxPermissionMode: "approve-reads",
|
||||
});
|
||||
|
||||
expect(html).toContain('<option value="acpx" selected="">ACPX</option>');
|
||||
expect(html).toContain('<option value="claude" selected="">Claude via ACPX</option>');
|
||||
expect(html).toContain("Codex via ACPX");
|
||||
expect(html).not.toContain("Pi via ACPX");
|
||||
expect(html).toContain('<option value="approve-reads" selected="">Ask for mutations</option>');
|
||||
});
|
||||
|
||||
it("falls back to the fail-closed Codex permission mode", () => {
|
||||
const html = renderRunner({ codexPermissionMode: "unrestricted" });
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
||||
import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields";
|
||||
import {
|
||||
DEFAULT_CODEX_LOCAL_MODEL,
|
||||
CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS,
|
||||
isCodexLocalFastModeSupported,
|
||||
isCodexLocalManualModel,
|
||||
|
|
@ -17,15 +18,24 @@ import {
|
|||
PAPERCLIP_RUNNER_IDLE_TIMEOUT_DEFAULT_MS,
|
||||
PAPERCLIP_RUNNER_IDLE_TIMEOUT_MAX_MS,
|
||||
PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES,
|
||||
isPaperclipRunnerProvider,
|
||||
resolvePaperclipRunnerIdleTimeoutMs,
|
||||
resolvePaperclipRunnerPermissionMode,
|
||||
type CodexPermissionMode,
|
||||
type PaperclipRunnerPermissionMode,
|
||||
type PaperclipRunnerProvider,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||
const instructionsFileHint =
|
||||
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime. Note: Codex may still auto-apply repo-scoped AGENTS.md files from the workspace.";
|
||||
const defaultOpenCodeRunnerModel =
|
||||
"openrouter/deepseek/deepseek-v4-flash-0731";
|
||||
const acpxRunnerModels = {
|
||||
claude: "claude-sonnet-5",
|
||||
codex: "gpt-5.6-sol",
|
||||
} as const;
|
||||
|
||||
export function CodexLocalConfigFields({
|
||||
mode,
|
||||
isCreate,
|
||||
|
|
@ -45,19 +55,41 @@ export function CodexLocalConfigFields({
|
|||
// both, so the managed-sandbox-only policy hides them the same way
|
||||
// `runnerManaged` already does for the Paperclip Runner.
|
||||
const hideEngineChoice = runnerManaged || managedSandboxOnly === true;
|
||||
const codexPermissionCapability = PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.codex;
|
||||
const configuredRunnerProvider = runnerManaged
|
||||
? isCreate
|
||||
? values!.adapterSchemaValues?.provider
|
||||
: eff("adapterConfig", "provider", config.provider ?? "codex")
|
||||
: "codex";
|
||||
const runnerProvider: PaperclipRunnerProvider = isPaperclipRunnerProvider(
|
||||
configuredRunnerProvider,
|
||||
)
|
||||
? configuredRunnerProvider
|
||||
: "codex";
|
||||
const runnerPermissionCapability =
|
||||
PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES[runnerProvider];
|
||||
const runnerPermissionMode = runnerManaged
|
||||
? resolvePaperclipRunnerPermissionMode(
|
||||
"codex",
|
||||
runnerProvider,
|
||||
isCreate
|
||||
? values!.codexPermissionMode
|
||||
? values!.adapterSchemaValues?.[
|
||||
runnerPermissionCapability.configKey
|
||||
] ??
|
||||
(runnerProvider === "codex"
|
||||
? values!.codexPermissionMode
|
||||
: undefined)
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"codexPermissionMode",
|
||||
config.codexPermissionMode,
|
||||
runnerPermissionCapability.configKey,
|
||||
config[runnerPermissionCapability.configKey],
|
||||
),
|
||||
)
|
||||
: codexPermissionCapability.defaultMode;
|
||||
: runnerPermissionCapability.defaultMode;
|
||||
const configuredAcpxAgent = runnerManaged && runnerProvider === "acpx"
|
||||
? isCreate
|
||||
? values!.adapterSchemaValues?.acpxAgent
|
||||
: eff("adapterConfig", "acpxAgent", config.acpxAgent ?? "claude")
|
||||
: "claude";
|
||||
const acpxAgent = configuredAcpxAgent === "codex" ? "codex" : "claude";
|
||||
const runnerLifecycleMode = runnerManaged
|
||||
? isCreate
|
||||
? values!.paperclipRunnerLifecycleMode ?? "per_turn"
|
||||
|
|
@ -119,31 +151,106 @@ export function CodexLocalConfigFields({
|
|||
</select>
|
||||
</Field>}
|
||||
{runnerManaged && (
|
||||
<Field label="Provider" hint="Paperclip Runner currently supports Codex through app-server.">
|
||||
<select className={inputClass} value="codex" disabled>
|
||||
<Field
|
||||
label="Provider"
|
||||
hint="The runner persists this provider with each run so recovery cannot drift after configuration changes."
|
||||
>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={runnerProvider}
|
||||
onChange={(event) => {
|
||||
const provider = isPaperclipRunnerProvider(event.target.value)
|
||||
? event.target.value
|
||||
: "codex";
|
||||
const model = provider === "opencode"
|
||||
? defaultOpenCodeRunnerModel
|
||||
: provider === "acpx"
|
||||
? acpxRunnerModels.claude
|
||||
: DEFAULT_CODEX_LOCAL_MODEL;
|
||||
if (isCreate) {
|
||||
set!({
|
||||
model,
|
||||
adapterSchemaValues: {
|
||||
...values!.adapterSchemaValues,
|
||||
provider,
|
||||
...(provider === "acpx" ? { acpxAgent: "claude" } : {}),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
mark("adapterConfig", "provider", provider);
|
||||
mark("adapterConfig", "model", model);
|
||||
if (provider === "acpx") {
|
||||
mark("adapterConfig", "acpxAgent", "claude");
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="codex">Codex</option>
|
||||
<option value="opencode">OpenCode 1.18.17</option>
|
||||
<option value="acpx">ACPX</option>
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{runnerManaged && runnerProvider === "acpx" && (
|
||||
<Field
|
||||
label="ACP agent"
|
||||
hint="Only the pinned Claude and Codex profiles are qualified; Pi is unavailable."
|
||||
>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={acpxAgent}
|
||||
onChange={(event) => {
|
||||
const agent = event.target.value === "codex" ? "codex" : "claude";
|
||||
const model = acpxRunnerModels[agent];
|
||||
if (isCreate) {
|
||||
set!({
|
||||
model,
|
||||
adapterSchemaValues: {
|
||||
...values!.adapterSchemaValues,
|
||||
acpxAgent: agent,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
mark("adapterConfig", "acpxAgent", agent);
|
||||
mark("adapterConfig", "model", model);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="claude">Claude via ACPX</option>
|
||||
<option value="codex">Codex via ACPX</option>
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{runnerManaged && (
|
||||
<Field
|
||||
label="Permission mode"
|
||||
hint={`${codexPermissionCapability.description} Full auto does not widen Paperclip's workspace, network, credential, or planning boundaries.`}
|
||||
hint={`${runnerPermissionCapability.description} Full auto does not widen Paperclip's workspace, network, credential, or planning boundaries.`}
|
||||
>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={runnerPermissionMode}
|
||||
onChange={(event) => {
|
||||
const value = resolvePaperclipRunnerPermissionMode(
|
||||
"codex",
|
||||
runnerProvider,
|
||||
event.target.value,
|
||||
) as CodexPermissionMode;
|
||||
isCreate
|
||||
? set!({ codexPermissionMode: value })
|
||||
: mark("adapterConfig", "codexPermissionMode", value);
|
||||
) as PaperclipRunnerPermissionMode;
|
||||
if (isCreate) {
|
||||
set!({
|
||||
adapterSchemaValues: {
|
||||
...values!.adapterSchemaValues,
|
||||
[runnerPermissionCapability.configKey]: value,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
mark(
|
||||
"adapterConfig",
|
||||
runnerPermissionCapability.configKey,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{codexPermissionCapability.options.map((option) => (
|
||||
{runnerPermissionCapability.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
|
|
@ -154,7 +261,7 @@ export function CodexLocalConfigFields({
|
|||
{runnerManaged && (
|
||||
<Field
|
||||
label="Runner lifecycle"
|
||||
hint="Turn by turn suspends after each run. Warm keeps the same Codex process available between governed runs."
|
||||
hint="Turn by turn suspends after each run. Warm keeps the same provider process available between governed runs."
|
||||
>
|
||||
<select
|
||||
className={inputClass}
|
||||
|
|
@ -174,7 +281,7 @@ export function CodexLocalConfigFields({
|
|||
{runnerManaged && runnerLifecycleMode === "warm" && (
|
||||
<Field
|
||||
label="Warm idle timeout (ms)"
|
||||
hint="After this much inactivity, runnerd checkpoints and suspends the Codex session. The maximum is 24 hours."
|
||||
hint="After this much inactivity, runnerd checkpoints and suspends the provider session. The maximum is 24 hours."
|
||||
>
|
||||
{isCreate ? (
|
||||
<input
|
||||
|
|
|
|||
Loading…
Reference in New Issue