diff --git a/packages/adapters/codex-local/src/ui/build-config.test.ts b/packages/adapters/codex-local/src/ui/build-config.test.ts index 79b011397e..b50f9c1d74 100644 --- a/packages/adapters/codex-local/src/ui/build-config.test.ts +++ b/packages/adapters/codex-local/src/ui/build-config.test.ts @@ -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", diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts index 43b0c21b47..71c64a61b5 100644 --- a/packages/adapters/codex-local/src/ui/build-config.ts +++ b/packages/adapters/codex-local/src/ui/build-config.ts @@ -1,5 +1,6 @@ import { buildAdapterEnvConfig, + isPaperclipRunnerProvider, resolvePaperclipRunnerIdleTimeoutMs, resolvePaperclipRunnerPermissionMode, type CreateConfigValues, @@ -69,6 +70,7 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record { const config = buildCodexLocalConfig(v); + const schemaValues = { ...(v.adapterSchemaValues ?? {}) }; for (const unsupportedKey of [ "engine", "agentCommand", @@ -86,8 +88,19 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record u64 { + 1 +} + +fn event_id(sequence: u64) -> String { + format!("acpx_provider_{sequence:016}") +} + +fn event_sequence(value: &str) -> Option { + let sequence = value.strip_prefix("acpx_provider_")?.parse().ok()?; + (event_id(sequence) == value).then_some(sequence) +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AcpxProviderDescriptor { + kind: String, + provider: String, + driver: String, + provider_version: String, + agent: String, + model: String, + acpx_version: String, + agent_server_package: String, + agent_server_version: String, + agent_runtime_package: Option, + agent_runtime_version: Option, + command_digest: String, + sidecar_command: PathBuf, + #[serde(default)] + sidecar_args: Vec, + runtime_directory: PathBuf, + normalized_session_id: String, + run_id: String, + cwd: String, + #[serde(default)] + instructions: String, + permission_mode: AcpxPermissionMode, + permission_mode_pinned: bool, + #[serde(default)] + runtime_context: Value, +} + +impl AcpxProviderDescriptor { + fn validate(&self, context: &AcpxEventProjectionContext) -> Result<(), DurableRunnerError> { + let expected = match self.agent.as_str() { + "claude" => ( + "claude-sonnet-5", + "@agentclientprotocol/claude-agent-acp", + "0.70.0", + None, + None, + "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", + ), + "codex" => ( + "gpt-5.6-sol", + "@agentclientprotocol/codex-acp", + "1.6.2", + None, + None, + "sha256:94049b3e3c3aee87de62703786e4fa81d031d7bd979f99bdf516d84f28791a79", + ), + "pi" => return Err(DurableRunnerError::invalid( + "ACPX agent pi is not executable through the verified runnerd provider boundary", + )), + _ => { + return Err(DurableRunnerError::invalid( + "ACPX agent must be a qualified claude or codex profile", + )) + } + }; + if self.kind != "acpx" + || self.provider != "acpx" + || self.driver != "acpx_runtime" + || self.provider_version != "0.13.1" + || self.acpx_version != "0.13.1" + || self.model != expected.0 + || self.agent_server_package != expected.1 + || self.agent_server_version != expected.2 + || self.agent_runtime_package.as_deref() != expected.3 + || self.agent_runtime_version.as_deref() != expected.4 + || self.command_digest != expected.5 + { + return Err(DurableRunnerError::invalid( + "ACPX provider descriptor does not match a qualified immutable profile", + )); + } + if !self.permission_mode_pinned { + return Err(DurableRunnerError::invalid( + "ACPX permission mode must be pinned by runner policy", + )); + } + if self.run_id != context.run_id + || self.normalized_session_id != context.normalized_session_id + { + return Err(DurableRunnerError::invalid( + "ACPX descriptor identity conflicts with the durable runner identity", + )); + } + if self.instructions.len() > 1024 * 1024 || self.instructions.contains('\0') { + return Err(DurableRunnerError::invalid( + "ACPX instructions exceed their bounded contract", + )); + } + if !self.runtime_context.is_null() && !self.runtime_context.is_object() { + return Err(DurableRunnerError::invalid( + "ACPX runtimeContext must be an object or null", + )); + } + Ok(()) + } + + fn session_config( + &self, + tool_set: AuthorizedToolSet, + expected_identity: Option, + ) -> Result { + secure_directory(&self.runtime_directory, "ACPX runtime")?; + Ok(AcpxProviderSessionConfig { + transport: AcpxSidecarTransportConfig { + command: self.sidecar_command.clone(), + args: self.sidecar_args.clone(), + request_timeout: Duration::from_secs(30), + shutdown_grace: Duration::from_secs(2), + }, + agent: self.agent.clone(), + model: self.model.clone(), + run_id: self.run_id.clone(), + catalog_revision: 1, + runtime_directory: self.runtime_directory.clone(), + normalized_session_id: self.normalized_session_id.clone(), + working_directory: PathBuf::from(&self.cwd), + permission_mode: self.permission_mode, + permission_mode_pinned: self.permission_mode_pinned, + system_instructions: self.instructions.clone(), + tool_set, + expected_identity, + }) + } + + fn public_descriptor(&self, identity: Option<&AcpxProviderSessionIdentity>) -> Value { + json!({ + "provider": "acpx", + "driver": "acpx_runtime", + "providerVersion": self.provider_version, + "agent": self.agent, + "model": self.model, + "requestedModel": self.model, + "executionKind": "local_process", + "acpProtocolVersion": 1, + "agentServerPackage": self.agent_server_package, + "agentServerVersion": self.agent_server_version, + "agentRuntimePackage": self.agent_runtime_package, + "agentRuntimeVersion": self.agent_runtime_version, + "providerSessionId": identity.map(|value| value.agent_session_id.as_str()), + "acpxRecordId": identity.map(|value| value.acpx_record_id.as_str()), + "permissionMode": self.permission_mode, + }) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AcpxDurableState { + schema: String, + lifecycle: String, + descriptor: AcpxProviderDescriptor, + tool_set: AuthorizedToolSet, + #[serde(default)] + identity: Option, + #[serde(default)] + active_turn_id: Option, + #[serde(default)] + semantic_result: Option, + #[serde(default)] + pending_events: VecDeque, + #[serde(default = "initial_event_sequence")] + next_event_sequence: u64, +} + +impl AcpxDurableState { + fn new(descriptor: AcpxProviderDescriptor, tool_set: AuthorizedToolSet) -> Self { + Self { + schema: ACPX_PROVIDER_STATE_SCHEMA.to_owned(), + lifecycle: "prepared".to_owned(), + descriptor, + tool_set, + identity: None, + active_turn_id: None, + semantic_result: None, + pending_events: VecDeque::new(), + next_event_sequence: initial_event_sequence(), + } + } + + fn validate(&self, context: &AcpxEventProjectionContext) -> Result<(), DurableRunnerError> { + self.descriptor.validate(context)?; + let mut ids = HashSet::new(); + if self.schema != ACPX_PROVIDER_STATE_SCHEMA + || !matches!( + self.lifecycle.as_str(), + "prepared" + | "session_open" + | "turn_starting" + | "turn_active" + | "suspended" + | "closed" + ) + || self.next_event_sequence == 0 + || self.pending_events.len() > MAX_PENDING_EVENTS + || self.pending_events.iter().any(|event| { + event_sequence(&event.executor_event_id) + .is_none_or(|sequence| sequence >= self.next_event_sequence) + || !ids.insert(event.executor_event_id.as_str()) + || event.event_type.is_empty() + || !event.payload.is_object() + }) + || (matches!(self.lifecycle.as_str(), "turn_starting" | "turn_active") + != self.active_turn_id.is_some()) + || self + .semantic_result + .as_ref() + .is_some_and(|result| !result.is_object()) + || (self.identity.is_none() + && !matches!(self.lifecycle.as_str(), "prepared" | "closed")) + { + return Err(DurableRunnerError::invalid( + "ACPX durable provider state is malformed or inconsistent", + )); + } + if let Some(identity) = self.identity.as_ref() { + identity + .validate() + .map_err(|error| DurableRunnerError::invalid(error.to_string()))?; + if identity.profile_digest != self.descriptor.command_digest { + return Err(DurableRunnerError::invalid( + "ACPX durable identity no longer matches its qualified profile digest", + )); + } + } + Ok(()) + } + + fn push(&mut self, event: NormalizedProviderEvent) -> Result<(), DurableRunnerError> { + if self.pending_events.len() >= MAX_PENDING_EVENTS { + return Err(DurableRunnerError::invalid( + "ACPX provider event backlog exceeds its durable limit", + )); + } + let sequence = self.next_event_sequence; + self.next_event_sequence = sequence + .checked_add(1) + .ok_or_else(|| DurableRunnerError::invalid("ACPX event sequence exhausted"))?; + self.pending_events.push_back(PolledEvent { + executor_event_id: event_id(sequence), + event_type: event.event_type, + priority: event.priority, + payload: event.payload, + }); + Ok(()) + } +} + +pub struct AcpxCommandExecutor { + state_dir: PathBuf, + context: AcpxEventProjectionContext, + state: Option, + session: Option, + restore_checked: bool, +} + +impl AcpxCommandExecutor { + pub fn with_runner_config(state_dir: impl Into, config: &DurableRunnerConfig) -> Self { + Self { + state_dir: state_dir.into(), + context: AcpxEventProjectionContext { + run_id: config.run_id.clone(), + normalized_session_id: config.normalized_session_id.clone(), + turn_id: config.turn_id.clone(), + item_id: config.item_id.clone(), + }, + state: None, + session: None, + restore_checked: false, + } + } + + pub fn state_path(&self) -> PathBuf { + self.state_dir.join(ACPX_PROVIDER_STATE_FILE) + } + + fn restore(&mut self) -> Result<(), DurableRunnerError> { + if self.restore_checked { + return Ok(()); + } + self.restore_checked = true; + let path = self.state_path(); + let mut file = match open_private_regular_file(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(DurableRunnerError::invalid(format!( + "failed to open private ACPX provider state: {error}" + ))) + } + }; + let length = file + .metadata() + .map_err(|error| { + DurableRunnerError::invalid(format!( + "failed to inspect ACPX provider state: {error}" + )) + })? + .len(); + if length > MAX_PROVIDER_STATE_BYTES { + return Err(DurableRunnerError::invalid( + "ACPX provider state exceeds the 16 MiB limit", + )); + } + let mut bytes = Vec::with_capacity(length as usize); + file.read_to_end(&mut bytes).map_err(|error| { + DurableRunnerError::invalid(format!("failed to read ACPX provider state: {error}")) + })?; + let state: AcpxDurableState = serde_json::from_slice(&bytes).map_err(|error| { + DurableRunnerError::invalid(format!("ACPX provider state is malformed: {error}")) + })?; + state.validate(&self.context)?; + self.state = Some(state); + self.restore_session_if_needed() + } + + fn restore_session_if_needed(&mut self) -> Result<(), DurableRunnerError> { + if self.session.is_some() { + return Ok(()); + } + let Some(state) = self.state.as_ref() else { + return Ok(()); + }; + if !matches!( + state.lifecycle.as_str(), + "session_open" | "turn_starting" | "turn_active" | "suspended" + ) { + return Ok(()); + } + let unsafe_active = matches!(state.lifecycle.as_str(), "turn_starting" | "turn_active"); + let previous_turn = state.active_turn_id.clone(); + let session = self.start_session(true)?; + if unsafe_active { + let mut session = session; + let shutdown_failed = session + .shutdown("fail-closed durable recovery of an active ACPX turn") + .is_err(); + let state = self + .state + .as_mut() + .expect("ACPX state remains available during recovery"); + state.lifecycle = "closed".to_owned(); + state.active_turn_id = None; + state.push(NormalizedProviderEvent { + event_type: "turn.failed".to_owned(), + priority: EventPriority::P0, + payload: json!({ + "provider": "acpx", + "providerTurnId": previous_turn, + "status": "failed", + "providerTerminalObserved": false, + "code": "acpx_active_turn_recovery_closed", + "providerShutdownFailed": shutdown_failed, + }), + })?; + state.push(NormalizedProviderEvent { + event_type: "run.terminal".to_owned(), + priority: EventPriority::P0, + payload: json!({ + "status": "failed", + "runTerminalState": "failed", + "reportedWorkDisposition": "unknown", + "provider": "acpx", + }), + })?; + self.save_state()?; + return Ok(()); + } + let identity = session.identity().clone(); + let process_id = session.process_id(); + let state = self + .state + .as_mut() + .expect("ACPX state remains available during recovery"); + state.lifecycle = "session_open".to_owned(); + state.push(NormalizedProviderEvent { + event_type: "session.resumed".to_owned(), + priority: EventPriority::P0, + payload: session_event_payload(&state.descriptor, &identity, process_id), + })?; + self.session = Some(session); + self.save_state() + } + + fn start_session(&self, recovering: bool) -> Result { + let state = self + .state + .as_ref() + .ok_or_else(|| DurableRunnerError::invalid("ACPX provider has not been prepared"))?; + let expected = recovering.then(|| state.identity.clone()).flatten(); + let config = state + .descriptor + .session_config(state.tool_set.clone(), expected)?; + let mut session = AcpxProviderSession::start(&config).map_err(|error| { + DurableRunnerError::invalid(format!("failed to start ACPX provider: {error}")) + })?; + if session.identity().profile_digest != state.descriptor.command_digest { + let _ = session.shutdown("qualified ACPX profile digest mismatch"); + return Err(DurableRunnerError::invalid( + "ACPX provider identity did not attest the qualified command digest", + )); + } + Ok(session) + } + + fn save_state(&self) -> Result<(), DurableRunnerError> { + let state = self + .state + .as_ref() + .ok_or_else(|| DurableRunnerError::invalid("ACPX provider state is unavailable"))?; + state.validate(&self.context)?; + secure_directory(&self.state_dir, "provider state")?; + let path = self.state_path(); + let bytes = serde_json::to_vec_pretty(state).map_err(|error| { + DurableRunnerError::invalid(format!("failed to serialize ACPX state: {error}")) + })?; + if bytes.len() as u64 > MAX_PROVIDER_STATE_BYTES { + return Err(DurableRunnerError::invalid( + "ACPX provider state exceeds the 16 MiB limit", + )); + } + let (temporary, mut file) = create_private_temporary_file(&path)?; + let result = (|| -> std::io::Result<()> { + file.write_all(&bytes)?; + file.sync_all()?; + drop(file); + fs::rename(&temporary, &path)?; + #[cfg(unix)] + File::open(&self.state_dir)?.sync_all()?; + Ok(()) + })(); + if let Err(error) = result { + let _ = fs::remove_file(&temporary); + return Err(DurableRunnerError::invalid(format!( + "failed to atomically replace ACPX provider state: {error}" + ))); + } + #[cfg(unix)] + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).map_err(|error| { + DurableRunnerError::invalid(format!("failed to protect ACPX provider state: {error}")) + })?; + Ok(()) + } + + fn prepare(&mut self, payload: &Value) -> Result { + let descriptor: AcpxProviderDescriptor = serde_json::from_value( + payload + .get("provider") + .cloned() + .ok_or_else(|| DurableRunnerError::invalid("run.prepare requires provider"))?, + ) + .map_err(|error| { + DurableRunnerError::invalid(format!("run.prepare ACPX provider is invalid: {error}")) + })?; + descriptor.validate(&self.context)?; + let tool_set = authorized_tool_set(payload)?; + if let Some(state) = self.state.as_ref() { + if state.descriptor != descriptor || state.tool_set != tool_set { + return Err(DurableRunnerError::invalid( + "ACPX provider or authorized tool contract changed across the durable run", + )); + } + if state.lifecycle == "closed" { + return Err(DurableRunnerError::invalid( + "ACPX provider session is already closed", + )); + } + } else { + self.state = Some(AcpxDurableState::new(descriptor, tool_set)); + self.save_state()?; + } + Ok(CommandExecution::result(json!({ + "status": "prepared", + "provider": "acpx", + "driver": "acpx_runtime", + }))) + } + + fn open_session(&mut self) -> Result { + if self.session.is_none() { + let recovering = self + .state + .as_ref() + .and_then(|state| state.identity.as_ref()) + .is_some(); + self.session = Some(self.start_session(recovering)?); + } + let session = self + .session + .as_ref() + .expect("ACPX session exists after successful start"); + let identity = session.identity().clone(); + let process_id = session.process_id(); + let resumed = self + .state + .as_ref() + .and_then(|state| state.identity.as_ref()) + .is_some(); + let state = self + .state + .as_mut() + .ok_or_else(|| DurableRunnerError::invalid("ACPX provider has not been prepared"))?; + state.identity = Some(identity.clone()); + state.active_turn_id = None; + state.lifecycle = "session_open".to_owned(); + let payload = session_event_payload(&state.descriptor, &identity, process_id); + self.save_state()?; + Ok(CommandExecution { + result: json!({ + "status": if resumed { "resumed" } else { "started" }, + "provider": "acpx", + "driver": "acpx_runtime", + "providerVersion": "0.13.1", + "providerSessionId": identity.acpx_record_id, + "sessionId": identity.agent_session_id, + "processId": process_id, + }), + events: vec![( + if resumed { + "session.resumed" + } else { + "session.started" + } + .to_owned(), + EventPriority::P0, + payload, + )], + }) + } + + fn start_turn(&mut self, payload: &Value) -> Result { + let text = payload + .get("text") + .and_then(Value::as_str) + .ok_or_else(|| DurableRunnerError::invalid("turn.start payload.text is required"))?; + if self.session.is_none() { + return Err(DurableRunnerError::invalid("ACPX session is not open")); + } + { + let state = self + .state + .as_mut() + .ok_or_else(|| DurableRunnerError::invalid("ACPX provider is not prepared"))?; + if state.lifecycle != "session_open" { + return Err(DurableRunnerError::invalid( + "ACPX provider cannot start a turn in its current lifecycle", + )); + } + state.active_turn_id = Some(self.context.turn_id.clone()); + state.semantic_result = None; + state.lifecycle = "turn_starting".to_owned(); + } + self.save_state()?; + let working_directory = self + .state + .as_ref() + .map(|state| PathBuf::from(&state.descriptor.cwd)) + .expect("ACPX state exists before turn start"); + if let Err(error) = self + .session + .as_mut() + .expect("ACPX session exists before turn start") + .start_turn(&self.context.turn_id, text, &working_directory) + { + let state = self + .state + .as_mut() + .expect("ACPX state remains available after failed turn start"); + state.lifecycle = "closed".to_owned(); + state.active_turn_id = None; + self.session = None; + self.save_state()?; + return Err(DurableRunnerError::invalid(format!( + "ACPX turn start failed closed: {error}" + ))); + } + let state = self + .state + .as_mut() + .expect("ACPX state exists after turn start"); + state.lifecycle = "turn_active".to_owned(); + self.save_state()?; + Ok(CommandExecution { + result: json!({"status": "accepted", "providerTurnId": self.context.turn_id}), + events: vec![( + "turn.started".to_owned(), + EventPriority::P0, + json!({ + "provider": "acpx", + "providerTurnId": self.context.turn_id, + "status": "inProgress", + "turn": {"id": self.context.turn_id, "status": "inProgress"}, + }), + )], + }) + } + + fn interrupt_turn(&mut self, reason: &str) -> Result { + let turn_id = self + .state + .as_ref() + .and_then(|state| state.active_turn_id.clone()); + let Some(turn_id) = turn_id else { + return Ok(CommandExecution::result(json!({ + "status": "already_settled", + "reason": reason, + }))); + }; + self.session + .as_mut() + .ok_or_else(|| DurableRunnerError::invalid("ACPX session is unavailable"))? + .interrupt_turn(&turn_id, reason) + .map_err(|error| { + DurableRunnerError::invalid(format!("ACPX turn interrupt failed: {error}")) + })?; + Ok(CommandExecution::result(json!({ + "status": "interrupt_requested", + "providerTurnId": turn_id, + "reason": reason, + }))) + } + + fn resolve_request(&mut self, payload: &Value) -> Result { + let request_id = payload + .get("requestId") + .and_then(Value::as_str) + .ok_or_else(|| DurableRunnerError::invalid("request.resolve requires requestId"))?; + let response = payload + .get("response") + .ok_or_else(|| DurableRunnerError::invalid("request.resolve requires response"))?; + let turn_id = self + .state + .as_ref() + .and_then(|state| state.active_turn_id.clone()) + .ok_or_else(|| DurableRunnerError::invalid("ACPX provider has no active turn"))?; + self.session + .as_mut() + .ok_or_else(|| DurableRunnerError::invalid("ACPX session is unavailable"))? + .resolve_input( + request_id, + &turn_id, + &json!({"action": "submit", "response": response}), + ) + .map_err(|error| { + DurableRunnerError::invalid(format!("ACPX runtime response failed: {error}")) + })?; + Ok(CommandExecution { + result: json!({"status": "delivered", "requestId": request_id}), + events: vec![( + "runtime_request.resolved".to_owned(), + EventPriority::P0, + json!({"provider": "acpx", "requestId": request_id, "status": "delivered"}), + )], + }) + } + + fn deliver_tool_result( + &mut self, + payload: &Value, + ) -> Result { + let result: ToolResult = serde_json::from_value(payload.clone()).map_err(|error| { + DurableRunnerError::invalid(format!("semantic tool result is invalid: {error}")) + })?; + self.session + .as_mut() + .ok_or_else(|| DurableRunnerError::invalid("ACPX session is unavailable"))? + .deliver_tool_result(&result) + .map_err(|error| { + DurableRunnerError::invalid(format!( + "failed to return semantic tool result to ACPX: {error}" + )) + })?; + Ok(CommandExecution::result(json!({ + "status": "delivered", + "callId": result.call_id, + }))) + } + + fn snapshot(&self) -> Result { + let state = self + .state + .as_ref() + .ok_or_else(|| DurableRunnerError::invalid("ACPX provider has not been prepared"))?; + Ok(CommandExecution::result(json!({ + "status": state.lifecycle, + "provider": "acpx", + "driver": "acpx_runtime", + "providerSessionId": state.identity.as_ref().map(|value| value.acpx_record_id.as_str()), + "activeProviderTurnId": state.active_turn_id, + }))) + } + + fn close_session(&mut self, reason: &str) -> Result { + if let Some(session) = self.session.as_mut() { + session.shutdown(reason).map_err(|error| { + DurableRunnerError::invalid(format!("failed to stop ACPX provider: {error}")) + })?; + } + self.session = None; + let state = self + .state + .as_mut() + .ok_or_else(|| DurableRunnerError::invalid("ACPX provider is not prepared"))?; + state.lifecycle = "closed".to_owned(); + state.active_turn_id = None; + let provider_session_id = state + .identity + .as_ref() + .map(|value| value.acpx_record_id.clone()); + self.save_state()?; + Ok(CommandExecution { + result: json!({"status": "closed", "providerSessionId": provider_session_id}), + events: vec![( + "session.closed".to_owned(), + EventPriority::P0, + json!({"provider": "acpx", "providerSessionId": provider_session_id}), + )], + }) + } + + fn suspend(&mut self) -> Result { + if let Some(session) = self.session.as_mut() { + let identity = session.suspend("runner.suspend").map_err(|error| { + DurableRunnerError::invalid(format!("failed to suspend ACPX provider: {error}")) + })?; + let state = self + .state + .as_mut() + .expect("ACPX state exists while suspending provider"); + state.identity = Some(identity); + state.lifecycle = "suspended".to_owned(); + state.active_turn_id = None; + self.session = None; + self.save_state()?; + } + Ok(CommandExecution::result(json!({"status": "completed"}))) + } + + fn poll_provider(&mut self) -> Result<(), DurableRunnerError> { + self.restore()?; + if self + .state + .as_ref() + .is_some_and(|state| !state.pending_events.is_empty()) + || self.session.is_none() + { + return Ok(()); + } + for _ in 0..MAX_EVENTS_PER_POLL { + let events = self + .session + .as_mut() + .expect("ACPX session remains available while polling") + .poll_event(Duration::from_millis(1)) + .map_err(|error| { + DurableRunnerError::invalid(format!("ACPX provider failed: {error}")) + })?; + let Some(events) = events else { break }; + for event in events { + let normalized = project_acpx_state_event(&self.context, &event) + .map_err(|error| DurableRunnerError::invalid(error.to_string()))?; + let terminal = normalized.iter().find_map(|event| { + matches!( + event.event_type.as_str(), + "turn.completed" | "turn.failed" | "turn.cancelled" | "turn.interrupted" + ) + .then(|| event.event_type.clone()) + }); + let state = self + .state + .as_mut() + .expect("ACPX state remains available while polling"); + for event in normalized { + if event.event_type == "run.result.proposed" { + state.semantic_result = Some(event.payload.clone()); + } + state.push(event)?; + } + if let Some(event_type) = terminal { + state.active_turn_id = None; + state.lifecycle = "session_open".to_owned(); + let status = match event_type.as_str() { + "turn.completed" => "succeeded", + "turn.cancelled" => "cancelled", + "turn.interrupted" => "interrupted", + _ => "failed", + }; + let disposition = state + .semantic_result + .as_ref() + .and_then(|result| result.get("reportedWorkDisposition")) + .and_then(Value::as_str) + .unwrap_or(if status == "succeeded" { + "done" + } else { + "needs_review" + }); + state.push(NormalizedProviderEvent { + event_type: "run.terminal".to_owned(), + priority: EventPriority::P0, + payload: json!({ + "status": status, + "runTerminalState": status, + "reportedWorkDisposition": disposition, + "provider": "acpx", + }), + })?; + } + } + self.save_state()?; + } + Ok(()) + } +} + +impl CommandExecutor for AcpxCommandExecutor { + fn execute(&mut self, command: &Command) -> Result { + self.restore()?; + match command.command_type.as_str() { + "run.prepare" => self.prepare(&command.payload), + "run.attach" => { + if self.state.is_none() && command.payload.get("provider").is_some() { + self.prepare(&command.payload)?; + } + let mut execution = self.open_session()?; + execution.events.push(( + "run.attached".to_owned(), + EventPriority::P0, + json!({"provider": "acpx"}), + )); + Ok(execution) + } + "session.open" => self.open_session(), + "turn.start" => self.start_turn(&command.payload), + "turn.steer" => Ok(CommandExecution::result(json!({ + "status": "rejected", + "code": "provider_command_unavailable", + "message": "ACPX does not support steering an active turn", + }))), + "turn.interrupt" | "turn.stop" | "run.cancel" => { + self.interrupt_turn(&command.command_type) + } + "request.resolve" => self.resolve_request(&command.payload), + "semantic_tool.result" => self.deliver_tool_result(&command.payload), + "session.snapshot" => self.snapshot(), + "session.close" | "session.destroy" => self.close_session(&command.command_type), + "runner.suspend" => self.suspend(), + "runner.shutdown" => { + if self.state.is_some() { + self.close_session("runner.shutdown")?; + } + Ok(CommandExecution::result(json!({"status": "completed"}))) + } + "runner.drain" => Ok(CommandExecution::result(json!({"status": "completed"}))), + _ => Ok(CommandExecution::result(json!({ + "status": "rejected", + "code": "provider_command_unavailable", + "message": "the ACPX provider does not implement this command", + }))), + } + } + + fn poll_events(&mut self) -> Result, DurableRunnerError> { + self.poll_provider()?; + Ok(self + .state + .as_ref() + .into_iter() + .flat_map(|state| state.pending_events.iter().take(MAX_EVENTS_PER_POLL)) + .cloned() + .collect()) + } + + fn acknowledge_events(&mut self, count: usize) -> Result<(), DurableRunnerError> { + if count == 0 { + return Ok(()); + } + let state = self + .state + .as_mut() + .ok_or_else(|| DurableRunnerError::invalid("ACPX provider state is unavailable"))?; + if count > state.pending_events.len() { + return Err(DurableRunnerError::invalid( + "ACPX event acknowledgement exceeded the pending prefix", + )); + } + state.pending_events.drain(..count); + self.save_state() + } + + fn shutdown(&mut self) -> Result<(), DurableRunnerError> { + if let Some(session) = self.session.as_mut() { + session + .shutdown("runner process shutdown") + .map_err(|error| { + DurableRunnerError::invalid(format!("failed to stop ACPX provider: {error}")) + })?; + } + self.session = None; + Ok(()) + } +} + +fn authorized_tool_set(payload: &Value) -> Result { + if let Some(value) = payload.get("authorizedTools") { + return serde_json::from_value(value.clone()).map_err(|error| { + DurableRunnerError::invalid(format!("run.prepare authorizedTools is invalid: {error}")) + }); + } + let operations = Vec::new(); + let catalog_digest = authorized_tool_catalog_digest(&operations).map_err(|error| { + DurableRunnerError::invalid(format!("empty authorized tool set is invalid: {error}")) + })?; + Ok(AuthorizedToolSet { + schema: TOOL_SET_SCHEMA.to_owned(), + schema_version: 1, + catalog_digest, + operations, + }) +} + +fn session_event_payload( + descriptor: &AcpxProviderDescriptor, + identity: &AcpxProviderSessionIdentity, + process_id: u32, +) -> Value { + json!({ + "provider": "acpx", + "driver": "acpx_runtime", + "providerDescriptor": descriptor.public_descriptor(Some(identity)), + "runtimeIdentity": { + "executionKind": "local_process", + "processId": process_id, + "providerSessionId": identity.agent_session_id, + }, + "providerIdentity": identity, + "threadId": identity.acpx_record_id, + "providerSessionId": identity.acpx_record_id, + "sessionId": identity.agent_session_id, + "providerAccountSessionId": identity.agent_session_id, + "processId": process_id, + }) +} + +fn secure_directory(path: &Path, label: &str) -> Result<(), DurableRunnerError> { + let mut builder = DirBuilder::new(); + #[cfg(unix)] + builder.mode(0o700); + match builder.create(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(DurableRunnerError::invalid(format!( + "failed to create {label} directory: {error}" + ))) + } + } + verify_private_directory(path).map_err(|error| { + DurableRunnerError::invalid(format!("{label} directory is not private: {error}")) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn context() -> AcpxEventProjectionContext { + AcpxEventProjectionContext { + 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(), + } + } + + fn descriptor(agent: &str) -> Value { + let (model, package, version, digest) = if agent == "claude" { + ( + "claude-sonnet-5", + "@agentclientprotocol/claude-agent-acp", + "0.70.0", + "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", + ) + } else { + ( + "gpt-5.6-sol", + "@agentclientprotocol/codex-acp", + "1.6.2", + "sha256:94049b3e3c3aee87de62703786e4fa81d031d7bd979f99bdf516d84f28791a79", + ) + }; + json!({ + "kind": "acpx", + "provider": "acpx", + "driver": "acpx_runtime", + "providerVersion": "0.13.1", + "agent": agent, + "model": model, + "acpxVersion": "0.13.1", + "agentServerPackage": package, + "agentServerVersion": version, + "agentRuntimePackage": null, + "agentRuntimeVersion": null, + "commandDigest": digest, + "sidecarCommand": "/qualified/node", + "sidecarArgs": ["/qualified/acpx-sidecar.js"], + "runtimeDirectory": "/runtime/acpx", + "normalizedSessionId": "session-1", + "runId": "run-1", + "cwd": "/workspace", + "instructions": "Do the work.", + "permissionMode": "approve-reads", + "permissionModePinned": true, + "runtimeContext": null, + }) + } + + #[test] + fn admits_only_exact_qualified_claude_and_codex_descriptors() { + for agent in ["claude", "codex"] { + let descriptor: AcpxProviderDescriptor = + serde_json::from_value(descriptor(agent)).unwrap(); + descriptor.validate(&context()).unwrap(); + } + let mut drifted = descriptor("codex"); + drifted["commandDigest"] = json!(format!("sha256:{}", "a".repeat(64))); + let drifted: AcpxProviderDescriptor = serde_json::from_value(drifted).unwrap(); + assert!(drifted.validate(&context()).is_err()); + } + + #[test] + fn rejects_pi_before_process_launch() { + let mut pi = descriptor("codex"); + pi["agent"] = json!("pi"); + let pi: AcpxProviderDescriptor = serde_json::from_value(pi).unwrap(); + assert!(pi.validate(&context()).is_err()); + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs index 03da74667c..793cc2c2bb 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs @@ -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) => { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs index 591417df52..c037207d04 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs @@ -82,12 +82,54 @@ pub struct AcpxSidecarTransport { impl AcpxSidecarTransport { pub fn start(config: &AcpxSidecarTransportConfig) -> Result { + Self::start_with_environment_keys(config, &[]) + } + + pub fn start_for_agent( + config: &AcpxSidecarTransportConfig, + agent: &str, + ) -> Result { + 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 { 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, diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs index 230dd374bd..70053c7310 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs @@ -20,6 +20,11 @@ fn run() -> Result<(), Box> { .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> { .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> { | "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> { 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", diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs index acca704dd9..75bd23cae8 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs @@ -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())) } diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs index c3d85a7e1b..e6a9367725 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs @@ -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::>(); 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::>(); 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( diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs index 8af533bacc..2252dde13b 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs @@ -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; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/native_provider_backend.rs new file mode 100644 index 0000000000..c6f50f9949 --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/native_provider_backend.rs @@ -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 { + match self { + Self::LocalFacade(executor) => executor.execute(command), + Self::Acpx(executor) => executor.execute(command), + } + } + + fn poll_events(&mut self) -> Result, 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, + recovery_checked: bool, +} + +impl NativeProviderCommandExecutor { + pub fn with_runner_config(state_dir: impl Into, 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 { + 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, 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(()) + } + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs b/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs index 7d2d058f00..3e4f2866ef 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs @@ -201,6 +201,16 @@ impl SupervisedProcess { args: &[String], shutdown_grace: Duration, max_line_bytes: usize, + ) -> Result { + 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 { 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); } diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs index 5a30ebae98..0b34552365 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs @@ -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 Vec::new() }, + "evidenceRefs": if succeeded { vec![evidence_ref.as_str()] } else { Vec::<&str>::new() }, }) }) .collect::>(); @@ -272,7 +278,7 @@ fn terminal_events(state: &CodexProviderState, event_type: &str) -> Vec::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::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 Vec 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::>(); 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 { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs index 47c6b9042f..d83dc54969 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs @@ -296,8 +296,8 @@ fn project_runtime_request_origin(origin: Option<&Value>) -> Result 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(); +} diff --git a/packages/paperclip-runner/src/backends/codex-native-backend.ts b/packages/paperclip-runner/src/backends/codex-native-backend.ts index 88a7c83bf1..e0646eeb60 100644 --- a/packages/paperclip-runner/src/backends/codex-native-backend.ts +++ b/packages/paperclip-runner/src/backends/codex-native-backend.ts @@ -29,33 +29,70 @@ export interface CodexNativeSessionBackendOptions { }) => Promise; } -/** - * 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); +} diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts index 2791c6602d..52385a5eb7 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -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", diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.ts b/packages/paperclip-runner/src/backends/native-backend-factory.ts index 75b24edca4..48c32c4c2d 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.ts @@ -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( diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index dc1a9d9aa7..e30bdd3190 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -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); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 819b4104a4..a15f73ca83 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -870,6 +870,12 @@ export function createCapabilityRunnerdProviderEnvironment(input: { return environment; } +export function resolveRunnerdAcpxPermissionMode( + configured: CapabilityRunnerdCodexTransportOptions["acpxPermissionMode"], +): NonNullable { + 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, } diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 0cfbb0e66c..5d1ece56f6 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -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 () => { diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index d71fc2d493..fb6da3f455 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -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(); diff --git a/server/src/__tests__/agent-adapter-validation-routes.test.ts b/server/src/__tests__/agent-adapter-validation-routes.test.ts index 737ad8c24b..a12f9a9bb4 100644 --- a/server/src/__tests__/agent-adapter-validation-routes.test.ts +++ b/server/src/__tests__/agent-adapter-validation-routes.test.ts @@ -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", }); }); diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index d0b0606fe8..b23a397adb 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -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({ diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index f548c64a77..a7e5214fc1 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -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, 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; + 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" } }, }, ], }), diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 471849479c..5030ec10ac 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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, ): 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, ) { + if (adapterType === "paperclip_runner") { + assertFreshPaperclipRunnerProvider(adapterType, adapterConfig); + return; + } if (adapterType !== "opencode_local") return; try { requireOpenCodeModelId(adapterConfig.model); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 0199667e31..3bdeffea25 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -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, ) { 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; } diff --git a/server/src/services/heartbeat-runner-provider-config.test.ts b/server/src/services/heartbeat-runner-provider-config.test.ts new file mode 100644 index 0000000000..0b80b98aa0 --- /dev/null +++ b/server/src/services/heartbeat-runner-provider-config.test.ts @@ -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"); + }); +}); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 87e8fe2bb3..f879cef7b9 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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: { diff --git a/server/src/services/native-runtime/native-codex-runner.test.ts b/server/src/services/native-runtime/native-codex-runner.test.ts index 1eb6aec0e0..0de6992dd8 100644 --- a/server/src/services/native-runtime/native-codex-runner.test.ts +++ b/server/src/services/native-runtime/native-codex-runner.test.ts @@ -55,6 +55,7 @@ describe("buildNativeRunnerPreparePayload", () => { }, })).toMatchObject({ provider: { + kind: "codex", provider: "codex", driver: "codex_app_server", providerSessionId: "thread-1", diff --git a/server/src/services/native-runtime/native-codex-runner.ts b/server/src/services/native-runtime/native-codex-runner.ts index b48a35986b..a6ac483ee6 100644 --- a/server/src/services/native-runtime/native-codex-runner.ts +++ b/server/src/services/native-runtime/native-codex-runner.ts @@ -37,6 +37,7 @@ export function buildNativeRunnerPreparePayload( ): Record { return { provider: { + kind: "codex", provider: "codex", driver: "codex_app_server", providerVersion: input.providerLaunch?.providerVersion ?? "codex-app-server-v1", diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index f1a1889a72..d0f02521e7 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -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; }; +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 & { returning: () => Promise>; }; - 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 }> = []; + const updates: Array<{ table: unknown; values: Record }> = + []; 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", + }), + ); + }); }); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 9263ba5016..da04207286 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -2719,7 +2719,7 @@ export async function executePaperclipNativeSession(input: { processGroupId: number | null; startedAt: string; }) => Promise; - /** 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; @@ -2750,9 +2750,21 @@ export async function executePaperclipNativeSession(input: { }, ) => Promise; }): Promise { - 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; }): Promise { 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( diff --git a/server/src/services/native-runtime/provider-profile.ts b/server/src/services/native-runtime/provider-profile.ts new file mode 100644 index 0000000000..b5260dff6d --- /dev/null +++ b/server/src/services/native-runtime/provider-profile.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : {}; +} + +function optionalString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function assertPermissionMode( + provider: PaperclipRunnerProvider, + config: Record, +): 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", + }; +} diff --git a/server/src/services/native-runtime/runtime-mode.test.ts b/server/src/services/native-runtime/runtime-mode.test.ts index 892fccdb0f..bf70ef0dc4 100644 --- a/server/src/services/native-runtime/runtime-mode.test.ts +++ b/server/src/services/native-runtime/runtime-mode.test.ts @@ -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" })); diff --git a/server/src/services/native-runtime/runtime-mode.ts b/server/src/services/native-runtime/runtime-mode.ts index 48c474a7b8..7c8b4acd92 100644 --- a/server/src/services/native-runtime/runtime-mode.ts +++ b/server/src/services/native-runtime/runtime-mode.ts @@ -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).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", }; } diff --git a/ui/src/adapters/codex-local/config-fields.test.tsx b/ui/src/adapters/codex-local/config-fields.test.tsx index 0a28bbc340..c6b6d44586 100644 --- a/ui/src/adapters/codex-local/config-fields.test.tsx +++ b/ui/src/adapters/codex-local/config-fields.test.tsx @@ -25,20 +25,47 @@ function renderRunner(config: Record): 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="">'); + expect(html).toContain(''); + 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(''); + expect(html).toContain(''); + 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(''); + expect(html).toContain(''); + expect(html).toContain("Codex via ACPX"); + expect(html).not.toContain("Pi via ACPX"); + expect(html).toContain(''); + }); + it("falls back to the fail-closed Codex permission mode", () => { const html = renderRunner({ codexPermissionMode: "unrestricted" }); diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index 5a9490c5ea..f2163b9407 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -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({ } {runnerManaged && ( - - { + 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"); + } + } + }} + > + + + + + )} + {runnerManaged && runnerProvider === "acpx" && ( + + )} {runnerManaged && ( {isCreate ? (