From 56ef7c2b2cb5373c710337b09534f254852833bf Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 5 Oct 2026 21:26:28 -0500 Subject: [PATCH] Use provider-neutral modes in Runner contracts Carry opaque mode identifiers through native execution, ACPX sidecar and Rust recovery. Delegate supported values and acknowledgement to provider adapters. Preserve exact historical plan waits and fence incompatible pre-release sessions. Co-Authored-By: Paperclip --- .../runner-cursor-capabilities.md | 10 +- .../2026-10-03-cursor-production-readiness.md | 39 +++++ .../runner-core/src/acpx_provider_backend.rs | 65 +++++---- .../src/acpx_provider_checkpoint.rs | 10 +- .../runner-core/src/acpx_provider_session.rs | 138 +++++++++--------- .../runner-core/src/bin/fake-acpx-sidecar.rs | 2 +- .../tests/acpx_provider_checkpoint.rs | 22 +-- .../tests/acpx_provider_resolutions.rs | 4 +- .../tests/acpx_provider_session.rs | 4 +- .../tests/acpx_provider_suspend.rs | 2 +- .../runner-core/tests/acpx_provider_turns.rs | 2 +- .../src/backends/codex-acpx-native-backend.ts | 4 +- .../backends/native-backend-factory.test.ts | 2 +- .../src/cli/acpx-runtime-sidecar.ts | 15 +- .../src/contracts/harness-driver.ts | 4 +- .../src/contracts/native-execution.test.ts | 20 +++ .../src/contracts/native-execution.ts | 15 +- .../src/contracts/provider-mode.ts | 12 ++ .../src/drivers/acpx/codex-acpx-driver.ts | 9 +- .../acpx/codex-runtime-adapter.test.ts | 4 +- .../src/drivers/acpx/codex-runtime-adapter.ts | 31 ++-- .../src/drivers/acpx/cursor-mode.test.ts | 8 +- .../src/drivers/acpx/cursor-mode.ts | 6 +- .../src/drivers/acpx/provider-mode.test.ts | 31 ++++ .../src/drivers/acpx/provider-mode.ts | 39 +++++ .../src/drivers/acpx/recovery-identity.ts | 28 ++-- .../src/drivers/acpx/runtime-host.test.ts | 12 +- .../src/drivers/acpx/runtime-host.ts | 16 +- .../src/drivers/acpx/sidecar-protocol.ts | 4 +- .../codex-app-server-driver.recovery.test.ts | 4 +- .../src/drivers/codex/codex-driver-values.ts | 9 +- packages/paperclip-runner/src/index.ts | 1 + .../src/live/runnerd-codex-transport.ts | 13 +- .../native-cursor-plan-wait.test.ts | 45 +++++- .../native-runtime/native-cursor-plan-wait.ts | 13 +- .../native-runtime/native-execution-input.ts | 4 +- .../native-session-executor.test.ts | 12 +- .../native-runtime/native-session-executor.ts | 28 ++-- server/src/vendor/paperclip-runner/index.ts | 1 + tests/runner-e2e/cursor-native-flow.ts | 2 +- 40 files changed, 444 insertions(+), 246 deletions(-) create mode 100644 packages/paperclip-runner/src/contracts/provider-mode.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/provider-mode.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/provider-mode.ts diff --git a/doc/architecture/runner-cursor-capabilities.md b/doc/architecture/runner-cursor-capabilities.md index 97baa65b1dc..c8998c9e4fc 100644 --- a/doc/architecture/runner-cursor-capabilities.md +++ b/doc/architecture/runner-cursor-capabilities.md @@ -443,9 +443,13 @@ are still required before qualification. ## Cursor profile v5: admitted native session modes (2026-09-29) The explicit `acpxSessionMode` configuration selects `agent` (the default), -`plan`, or `ask` for Cursor. The provider/sidecar field is `cursorMode`; it is -separate from ACPX's persistent/oneshot session lifecycle. Other providers reject -this field. Mode is included in the immutable session key and recovery identity; +`plan`, or `ask` for Cursor. Historical v5 qualification snapshots used +`cursorMode`; those saved results retain their original identities. The current +production port uses the generic provider/sidecar `mode` identifier, separate from ACPX's persistent/oneshot session lifecycle. Shared +TypeScript/Rust transport and recovery code treat it as an opaque bounded string. +Each provider adapter owns supported values, defaults, native translation and +acknowledgement; currently only the Cursor adapter qualifies configurable modes. +Mode is included in the immutable session key and recovery identity; a missing or changed mode cannot reopen an existing v5 session. Admission checks the pinned native `session/new` or `session/load` response's diff --git a/doc/plans/2026-10-03-cursor-production-readiness.md b/doc/plans/2026-10-03-cursor-production-readiness.md index 4e2b14368a3..09ed3a8073e 100644 --- a/doc/plans/2026-10-03-cursor-production-readiness.md +++ b/doc/plans/2026-10-03-cursor-production-readiness.md @@ -591,3 +591,42 @@ production admission; assemble and verify the final package/image combination; run the real installed task smoke without qualification overrides; then prepare the PR for production review. Native AskQuestion and complete per-run dollar accounting remain excluded. Production merge/deployment remains a separate action. + + +## Provider-neutral mode boundary (2026-10-05) + +The pre-release implementation put `CursorMode` / `cursorMode` into shared Rust, +native execution, sidecar and recovery contracts. The production port now carries +an opaque bounded `mode` identifier through those contracts. Rust does not define +Cursor's Agent/Plan/Ask vocabulary or enforce Cursor-only mode selection. +The provider mode capability registry delegates supported values, defaults, native +translation and acknowledgement to the Cursor adapter. Other adapters must qualify +mode support before registering it; unsupported selections still fail before a +provider prompt. Product configuration remains `acpxSessionMode` with Agent as the +Cursor default. Session lifecycle and permission policy remain separate controls. + +Exact mode equality remains part of the immutable session key, observed identity, +warm continuation, suspension checkpoint and controller recovery checks. Generic +transport tests also cover non-Cursor identifiers such as `architect` and +`custom/build`. Missing or changed observed modes cannot recover an existing +mode-bound session. The pre-release `cursorMode` input and checkpoint format is +fenced rather than silently converted or defaulted. Only inspection of an already +committed accepted-plan wait may read its old field. That path verifies the original +unchanged acceptance proof and preserves task history; it cannot admit a new wait +from an old field. A regression covers both preservation and attempted rewriting. + +Validation of the updated source: recursive typecheck, full build, all Rust Runner +checks (646 passing test invocations, including conformance/replay repeats), 254 +focused Runner TypeScript tests and 584 focused server tests passed. The complete +Runner TypeScript run passed 67 package contracts and 2,587 Vitest cases, with one +failure and ten skips. Its sole failure selected a preserved pre-release daemon +from the old three-platform staging directory: that daemon expected OpenCode +1.18.32, while current source expects 1.18.34. The historical platform set and +manifest were preserved separately; the current-source daemon remained staged. +The affected case and all 15 ACPX transport cases passed on a focused repeat +against that daemon. The original failed run remains retained. New-head PR checks +and review must complete before merge. No new paid provider cells were run for this architecture change. +Historical live qualification, image, package and review identities above remain +historical evidence and are not relabeled as certification of this new source. +Release assembly must build matching daemon, sidecar and controller artifacts. +Auto-merge is disabled while the updated boundary receives review. diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 6b65b2d3864..041e0dea439 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -14,7 +14,7 @@ use sha2::{Digest, Sha256}; use crate::acpx_provider_session::{ AcpxPermissionMode, AcpxProviderRuntimePolicy, AcpxProviderSession, AcpxProviderSessionConfig, - AcpxProviderSessionIdentity, AcpxTurnControlCapabilities, CursorMode, + AcpxProviderSessionIdentity, AcpxTurnControlCapabilities, }; use crate::acpx_sidecar_transport::AcpxSidecarTransportConfig; #[cfg(test)] @@ -136,7 +136,7 @@ struct AcpxProviderDescriptor { instructions: String, permission_mode: AcpxPermissionMode, #[serde(default, skip_serializing_if = "Option::is_none")] - cursor_mode: Option, + mode: Option, permission_mode_pinned: bool, #[serde(default)] provider_policy: Option, @@ -248,7 +248,11 @@ impl AcpxProviderDescriptor { || self.model.trim().is_empty() || self.model.len() > 240 || self.model.contains('\0') - || ((self.agent == "cursor") != self.cursor_mode.is_some()) + || self.mode.as_ref().is_some_and(|mode| { + mode.trim().is_empty() + || mode.chars().count() > 240 + || mode.chars().any(char::is_control) + }) || (matches!(self.agent.as_str(), "pi" | "cursor" | "copilot") && self.provider_policy.is_none()) || self.agent_server_package != expected.1 @@ -344,7 +348,7 @@ impl AcpxProviderDescriptor { normalized_session_id: self.normalized_session_id.clone(), working_directory: PathBuf::from(&self.cwd), permission_mode: self.permission_mode, - cursor_mode: self.cursor_mode, + mode: self.mode.clone(), permission_mode_pinned: self.permission_mode_pinned, provider_policy: self.provider_policy.clone(), system_instructions: self.instructions.clone(), @@ -445,8 +449,8 @@ impl AcpxProviderDescriptor { "acpxRecordId": identity.map(|value| value.acpx_record_id.as_str()), "permissionMode": self.permission_mode, }); - if let Some(mode) = self.cursor_mode { - descriptor["cursorMode"] = json!(mode); + if let Some(mode) = self.mode.as_deref() { + descriptor["mode"] = json!(mode); } descriptor } @@ -579,10 +583,10 @@ impl AcpxDurableState { .validate() .map_err(|error| DurableRunnerError::invalid(error.to_string()))?; if identity.profile_digest != self.descriptor.command_digest - || identity.cursor_mode != self.descriptor.cursor_mode + || identity.mode != self.descriptor.mode { return Err(DurableRunnerError::invalid( - "ACPX durable identity no longer matches its qualified profile or Cursor mode", + "ACPX durable identity no longer matches its qualified profile or provider mode", )); } } @@ -2377,7 +2381,7 @@ mod tests { "runtimeContext": null, }); if agent == "cursor" { - value["cursorMode"] = json!("agent"); + value["mode"] = json!("agent"); value["providerPolicy"] = json!({"readOnly": true}); } value @@ -2549,7 +2553,7 @@ mod tests { requested_model: descriptor.model.clone(), effective_model: descriptor.model.clone(), permission_mode: Some(descriptor.permission_mode), - cursor_mode: descriptor.cursor_mode, + mode: descriptor.mode.clone(), provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], }; let operations = Vec::new(); @@ -2799,7 +2803,7 @@ mod tests { requested_model: "gpt-5.6-sol".to_owned(), effective_model: "gpt-5.6-sol".to_owned(), permission_mode: Some(AcpxPermissionMode::ApproveReads), - cursor_mode: None, + mode: None, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], }; @@ -2859,7 +2863,7 @@ mod tests { assert!(missing.validate(&context()).is_err()); value["providerPolicy"] = json!({"readOnly":true}); if agent == "cursor" { - value["cursorMode"] = json!("agent"); + value["mode"] = json!("agent"); } let valid: AcpxProviderDescriptor = serde_json::from_value(value.clone()).unwrap(); valid.validate(&context()).unwrap(); @@ -2910,7 +2914,7 @@ mod tests { serde_json::from_value(previous_contract).unwrap(); assert!(previous_contract.validate(&context()).is_err()); if agent == "cursor" { - assert_eq!(valid.public_descriptor(None)["cursorMode"], json!("agent")); + assert_eq!(valid.public_descriptor(None)["mode"], json!("agent")); let mut previous_v4 = value.clone(); previous_v4["commandDigest"] = json!( "sha256:b1440d559ebc4eef5c7a582f1c81fc153270cfbafa1731a8ee76d83713bdf61b" @@ -2919,19 +2923,28 @@ mod tests { serde_json::from_value(previous_v4).unwrap(); assert!(previous_v4.validate(&context()).is_err()); let mut missing = value.clone(); - missing.as_object_mut().unwrap().remove("cursorMode"); + missing.as_object_mut().unwrap().remove("mode"); let missing: AcpxProviderDescriptor = serde_json::from_value(missing).unwrap(); - assert!(missing.validate(&context()).is_err()); - let mut unknown = value.clone(); - unknown["cursorMode"] = json!("autopilot"); - assert!(serde_json::from_value::(unknown).is_err()); + missing.validate(&context()).unwrap(); + let mut opaque = value.clone(); + opaque["mode"] = json!("architect"); + serde_json::from_value::(opaque) + .unwrap() + .validate(&context()) + .unwrap(); + let mut invalid = value.clone(); + invalid["mode"] = json!(""); + assert!(serde_json::from_value::(invalid) + .unwrap() + .validate(&context()) + .is_err()); } else { - assert!(valid.public_descriptor(None).get("cursorMode").is_none()); + assert!(valid.public_descriptor(None).get("mode").is_none()); let mut wrong_agent = value.clone(); - wrong_agent["cursorMode"] = json!("plan"); - let wrong_agent: AcpxProviderDescriptor = + wrong_agent["mode"] = json!("plan"); + let other_agent: AcpxProviderDescriptor = serde_json::from_value(wrong_agent).unwrap(); - assert!(wrong_agent.validate(&context()).is_err()); + other_agent.validate(&context()).unwrap(); } for field in ["model", "agentServerVersion", "commandDigest"] { @@ -3061,7 +3074,7 @@ mod tests { requested_model: original_descriptor.model.clone(), effective_model: original_descriptor.model.clone(), permission_mode: Some(original_descriptor.permission_mode), - cursor_mode: original_descriptor.cursor_mode, + mode: original_descriptor.mode.clone(), provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], }; let operations = Vec::new(); @@ -3282,7 +3295,7 @@ mod tests { requested_model: original_descriptor.model.clone(), effective_model: original_descriptor.model.clone(), permission_mode: Some(original_descriptor.permission_mode), - cursor_mode: original_descriptor.cursor_mode, + mode: original_descriptor.mode.clone(), provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], }; let operations = Vec::new(); @@ -3529,7 +3542,7 @@ mod tests { requested_model: descriptor.model.clone(), effective_model: descriptor.model.clone(), permission_mode: Some(descriptor.permission_mode), - cursor_mode: descriptor.cursor_mode, + mode: descriptor.mode.clone(), provider_lifetime_fence_candidates, }; let operations = Vec::new(); @@ -3701,7 +3714,7 @@ mod tests { requested_model: provider_descriptor.model.clone(), effective_model: provider_descriptor.model.clone(), permission_mode: Some(provider_descriptor.permission_mode), - cursor_mode: provider_descriptor.cursor_mode, + mode: provider_descriptor.mode.clone(), provider_lifetime_fence_candidates, }); state.provider_exit_unconfirmed = true; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_checkpoint.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_checkpoint.rs index ac9cdd83f77..b34deeed7ea 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_checkpoint.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_checkpoint.rs @@ -10,7 +10,7 @@ use std::os::unix::fs::DirBuilderExt; use serde::{Deserialize, Serialize}; use crate::acpx_provider_session::{ - AcpxPermissionMode, AcpxProviderSessionConfig, AcpxProviderSessionIdentity, CursorMode, + AcpxPermissionMode, AcpxProviderSessionConfig, AcpxProviderSessionIdentity, }; use crate::durable::{ create_private_temporary_file, open_private_regular_file, verify_private_directory, @@ -49,7 +49,7 @@ struct PersistedAcpxProviderSessionIdentity { effective_model: String, permission_mode: AcpxPermissionMode, #[serde(default, skip_serializing_if = "Option::is_none")] - cursor_mode: Option, + mode: Option, provider_lifetime_fence_candidates: [u16; 3], } @@ -72,7 +72,7 @@ impl PersistedAcpxProviderSessionIdentity { requested_model: identity.requested_model, effective_model: identity.effective_model, permission_mode, - cursor_mode: identity.cursor_mode, + mode: identity.mode, provider_lifetime_fence_candidates: identity.provider_lifetime_fence_candidates, }) } @@ -89,7 +89,7 @@ impl PersistedAcpxProviderSessionIdentity { requested_model: self.requested_model.clone(), effective_model: self.effective_model.clone(), permission_mode: Some(self.permission_mode), - cursor_mode: self.cursor_mode, + mode: self.mode.clone(), provider_lifetime_fence_candidates: self.provider_lifetime_fence_candidates, } } @@ -110,7 +110,7 @@ impl AcpxSuspensionCheckpoint { || identity.requested_model != config.model || identity.effective_model != config.model || identity.permission_mode != Some(config.permission_mode) - || identity.cursor_mode != config.cursor_mode + || identity.mode != config.mode || config .expected_identity .as_ref() 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 60f2684ddb4..f4b82443f02 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 @@ -35,16 +35,8 @@ pub enum AcpxPermissionMode { DenyAll, } -#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum CursorMode { - Agent, - Plan, - Ask, -} - #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AcpxProviderSessionIdentity { pub kind: String, pub normalized_session_id: String, @@ -58,7 +50,7 @@ pub struct AcpxProviderSessionIdentity { #[serde(default)] pub permission_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub cursor_mode: Option, + pub mode: Option, pub provider_lifetime_fence_candidates: [u16; 3], } @@ -79,7 +71,7 @@ pub struct AcpxProviderSessionConfig { pub normalized_session_id: String, pub working_directory: PathBuf, pub permission_mode: AcpxPermissionMode, - pub cursor_mode: Option, + pub mode: Option, pub permission_mode_pinned: bool, pub provider_policy: Option, pub system_instructions: String, @@ -110,10 +102,8 @@ impl AcpxProviderSessionConfig { ))); } validate_text(&self.model, MAX_MODEL_CHARS, "ACPX model")?; - if (self.agent == "cursor") != self.cursor_mode.is_some() { - return Err(LocalRunnerError::invalid( - "ACPX Cursor mode must be explicit for Cursor and absent for other agents", - )); + if let Some(mode) = self.mode.as_deref() { + validate_text(mode, MAX_ID_CHARS, "ACPX provider mode")?; } if matches!(self.agent.as_str(), "pi" | "cursor" | "copilot") && self.provider_policy.is_none() @@ -186,7 +176,7 @@ impl AcpxProviderSessionConfig { || expected_identity.requested_model != self.model || expected_identity.effective_model != self.model || expected_identity.permission_mode != Some(self.permission_mode) - || expected_identity.cursor_mode != self.cursor_mode + || expected_identity.mode != self.mode { return Err(LocalRunnerError::invalid( "ACPX expected identity conflicts with the requested session", @@ -204,6 +194,9 @@ impl AcpxProviderSessionIdentity { "ACPX session identity kind is invalid", )); } + if let Some(mode) = self.mode.as_deref() { + validate_text(mode, MAX_ID_CHARS, "ACPX provider mode")?; + } for (value, label) in [ (&self.normalized_session_id, "normalized session"), (&self.acpx_record_id, "record"), @@ -1266,8 +1259,8 @@ fn session_open_params(config: &AcpxProviderSessionConfig, sidecar_tools: &[Valu "tools": &sidecar_tools, "expectedIdentity": config.expected_identity, }); - if let Some(mode) = config.cursor_mode { - params["cursorMode"] = json!(mode); + if let Some(mode) = config.mode.as_deref() { + params["mode"] = json!(mode); } params } @@ -1378,7 +1371,7 @@ fn verify_open_response( || identity.requested_model != config.model || identity.effective_model != config.model || identity.permission_mode != Some(config.permission_mode) - || identity.cursor_mode != config.cursor_mode + || identity.mode != config.mode || config .expected_identity .as_ref() @@ -1603,7 +1596,7 @@ mod permission_mode_tests { } #[cfg(test)] -mod cursor_mode_tests { +mod mode_tests { use super::*; fn config() -> AcpxProviderSessionConfig { @@ -1624,7 +1617,7 @@ mod cursor_mode_tests { normalized_session_id: "session-1".to_owned(), working_directory: std::env::temp_dir(), permission_mode: AcpxPermissionMode::ApproveReads, - cursor_mode: Some(CursorMode::Plan), + mode: Some("plan".to_owned()), permission_mode_pinned: true, provider_policy: Some(AcpxProviderRuntimePolicy { read_only: false }), system_instructions: String::new(), @@ -1650,76 +1643,81 @@ mod cursor_mode_tests { requested_model: "explicit-model".to_owned(), effective_model: "explicit-model".to_owned(), permission_mode: Some(AcpxPermissionMode::ApproveReads), - cursor_mode: Some(CursorMode::Plan), + mode: Some("plan".to_owned()), provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], } } #[test] - fn cursor_mode_is_closed_and_not_defaulted_in_rust() { - for (name, mode) in [ - ("agent", CursorMode::Agent), - ("plan", CursorMode::Plan), - ("ask", CursorMode::Ask), - ] { - assert_eq!( - serde_json::from_value::(json!(name)).unwrap(), - mode - ); - assert_eq!(serde_json::to_value(mode).unwrap(), json!(name)); - } - for value in [json!("Agent"), json!("autopilot"), json!(null), json!(1)] { - assert!(serde_json::from_value::(value).is_err()); - } + fn mode_is_opaque_bounded_and_not_defaulted_in_rust() { let mut config = config(); - config.validate().unwrap(); - config.cursor_mode = None; - assert!(config.validate().is_err()); - config.agent = "copilot".to_owned(); - config.cursor_mode = Some(CursorMode::Agent); - assert!(config.validate().is_err()); - config.cursor_mode = None; - config.validate().unwrap(); + // Generic transport accepts ids beyond Cursor's vocabulary and leaves + // capability checks and native translation to the provider adapter. + for agent in ["cursor", "copilot"] { + config.agent = agent.to_owned(); + for mode in [ + None, + Some("architect".to_owned()), + Some("custom/build".to_owned()), + ] { + config.mode = mode; + config.validate().unwrap(); + } + } + for invalid in [ + "".to_owned(), + " ".to_owned(), + "x".repeat(241), + "plan\0".to_owned(), + ] { + config.mode = Some(invalid.clone()); + assert!(config.validate().is_err()); + let mut identity = identity(); + identity.mode = Some(invalid); + assert!(identity.validate().is_err()); + } + for invalid in [json!(1), json!({})] { + let mut value = serde_json::to_value(identity()).unwrap(); + value["mode"] = invalid; + assert!(serde_json::from_value::(value).is_err()); + } } #[test] - fn open_wire_and_identity_bind_exact_cursor_mode() { + fn open_wire_and_identity_bind_exact_mode() { let mut config = config(); - for mode in [CursorMode::Agent, CursorMode::Plan, CursorMode::Ask] { - config.cursor_mode = Some(mode); - assert_eq!(session_open_params(&config, &[])["cursorMode"], json!(mode)); + for mode in [ + "agent".to_owned(), + "architect".to_owned(), + "custom/build".to_owned(), + ] { + config.mode = Some(mode.clone()); + assert_eq!(session_open_params(&config, &[])["mode"], json!(mode)); let mut identity = identity(); - identity.cursor_mode = Some(mode); + identity.mode = Some(mode.clone()); let response = json!({"sidecarPid": 100, "status": {}, "identity": identity}); assert_eq!( - verify_open_response(&response, 100, &config) - .unwrap() - .cursor_mode, - Some(mode) + verify_open_response(&response, 100, &config).unwrap().mode, + Some(mode.clone()) ); for wrong in [ None, - Some(CursorMode::Agent), - Some(CursorMode::Plan), - Some(CursorMode::Ask), + Some("agent".to_owned()), + Some("plan".to_owned()), + Some("ask".to_owned()), ] { - if wrong == Some(mode) { + if wrong == Some(mode.clone()) { continue; } let mut changed = response.clone(); - changed["identity"]["cursorMode"] = json!(wrong); + changed["identity"]["mode"] = json!(wrong); assert!(verify_open_response(&changed, 100, &config).is_err()); } } config.agent = "copilot".to_owned(); - config.cursor_mode = None; - assert!(session_open_params(&config, &[]) - .get("cursorMode") - .is_none()); + config.mode = None; + assert!(session_open_params(&config, &[]).get("mode").is_none()); let mut other = identity(); - other.cursor_mode = None; - assert!(serde_json::to_value(&other) - .unwrap() - .get("cursorMode") - .is_none()); + other.mode = None; + assert!(serde_json::to_value(&other).unwrap().get("mode").is_none()); let response = json!({"sidecarPid": 100, "status": {}, "identity": other}); verify_open_response(&response, 100, &config).unwrap(); } @@ -1729,10 +1727,10 @@ mod cursor_mode_tests { let identity = identity(); config.expected_identity = Some(identity.clone()); config.validate().unwrap(); - config.cursor_mode = Some(CursorMode::Ask); + config.mode = Some("ask".to_owned()); assert!(config.validate().is_err()); let mut changed = identity.clone(); - changed.cursor_mode = Some(CursorMode::Agent); + changed.mode = Some("agent".to_owned()); assert!(verify_suspend_response( &json!({"suspended": true, "identity": changed}), &identity 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 b20eb4a61f6..1961d13761c 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 @@ -803,7 +803,7 @@ fn bootstrap_success( "requestedModel": model, "effectiveModel": if mode == "bootstrap-wrong-model" { "wrong-model" } else { model }, "permissionMode": params.get("permissionMode"), - "cursorMode": params.get("cursorMode"), + "mode": params.get("mode"), "providerLifetimeFenceCandidates": [60001, 60002, 60003], }, "status": {}, diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs index a1886bd720c..4c8a488e805 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs @@ -50,7 +50,7 @@ fn config(directory: &std::path::Path) -> AcpxProviderSessionConfig { normalized_session_id: "session-1".to_owned(), working_directory: directory.to_owned(), permission_mode: AcpxPermissionMode::ApproveReads, - cursor_mode: None, + mode: None, permission_mode_pinned: true, provider_policy: None, system_instructions: "Complete the supplied task.".to_owned(), @@ -77,7 +77,7 @@ fn identity() -> AcpxProviderSessionIdentity { requested_model: "gpt-5.6-sol".to_owned(), effective_model: "gpt-5.6-sol".to_owned(), permission_mode: Some(AcpxPermissionMode::ApproveReads), - cursor_mode: None, + mode: None, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], } } @@ -261,32 +261,32 @@ fn rejects_an_existing_runner_state_directory_that_is_not_private() { } #[test] -fn cursor_mode_round_trips_checkpoint_and_rejects_changed_or_missing_recovery_mode() { - use paperclip_runner_core::acpx_provider_session::{AcpxProviderRuntimePolicy, CursorMode}; +fn mode_round_trips_checkpoint_and_rejects_changed_or_missing_recovery_mode() { + use paperclip_runner_core::acpx_provider_session::AcpxProviderRuntimePolicy; let directory = temporary_directory("cursor-mode"); let mut config = config(&directory); config.agent = "cursor".to_owned(); - config.cursor_mode = Some(CursorMode::Plan); + config.mode = Some("plan".to_owned()); config.provider_policy = Some(AcpxProviderRuntimePolicy { read_only: false }); let mut identity = identity(); - identity.cursor_mode = Some(CursorMode::Plan); + identity.mode = Some("plan".to_owned()); let checkpoint = AcpxSuspensionCheckpoint::from_suspension(&config, identity.clone()).unwrap(); let wire = serde_json::to_value(&checkpoint).unwrap(); - assert_eq!(wire["identity"]["cursorMode"], json!("plan")); + assert_eq!(wire["identity"]["mode"], json!("plan")); let recovered: AcpxSuspensionCheckpoint = serde_json::from_value(wire.clone()).unwrap(); assert_eq!(recovered.admit_recovery(&config).unwrap(), identity); - for mode in [None, Some(CursorMode::Agent), Some(CursorMode::Ask)] { + for mode in [None, Some("agent".to_owned()), Some("ask".to_owned())] { let mut changed = config.clone(); - changed.cursor_mode = mode; + changed.mode = mode.clone(); assert!(recovered.admit_recovery(&changed).is_err()); let mut changed = wire.clone(); - changed["identity"]["cursorMode"] = json!(mode); + changed["identity"]["mode"] = json!(mode); let changed: AcpxSuspensionCheckpoint = serde_json::from_value(changed).unwrap(); assert!(changed.admit_recovery(&config).is_err()); } let mut other = config.clone(); other.agent = "copilot".to_owned(); - other.cursor_mode = None; + other.mode = None; assert!(recovered.admit_recovery(&other).is_err()); fs::remove_dir_all(directory).unwrap(); } diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_resolutions.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_resolutions.rs index 3dd0bbceeb7..3fbf5458c53 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_resolutions.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_resolutions.rs @@ -49,7 +49,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig { normalized_session_id: "session-1".to_owned(), working_directory: std::env::temp_dir(), permission_mode: AcpxPermissionMode::ApproveReads, - cursor_mode: None, + mode: None, permission_mode_pinned: true, provider_policy: None, system_instructions: "Complete the supplied task.".to_owned(), @@ -318,7 +318,7 @@ fn permission_origin_is_bound_to_the_admitted_connection_and_survives_projection } .to_owned(); if agent == "cursor" { - cfg.cursor_mode = Some(paperclip_runner_core::acpx_provider_session::CursorMode::Agent); + cfg.mode = Some("agent".to_owned()); } cfg.provider_policy = Some( paperclip_runner_core::acpx_provider_session::AcpxProviderRuntimePolicy { diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs index f4c593ffe44..c5b91ed1a73 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs @@ -44,7 +44,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig { normalized_session_id: "session-1".to_owned(), working_directory: std::env::temp_dir(), permission_mode: AcpxPermissionMode::ApproveReads, - cursor_mode: None, + mode: None, permission_mode_pinned: true, provider_policy: None, system_instructions: "Complete the supplied task.".to_owned(), @@ -66,7 +66,7 @@ fn expected_identity() -> AcpxProviderSessionIdentity { requested_model: "gpt-5.6-sol".to_owned(), effective_model: "gpt-5.6-sol".to_owned(), permission_mode: Some(AcpxPermissionMode::ApproveReads), - cursor_mode: None, + mode: None, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_suspend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_suspend.rs index 809c08808c3..3af110c46a3 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_suspend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_suspend.rs @@ -25,7 +25,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig { normalized_session_id: "session-1".to_owned(), working_directory: std::env::temp_dir(), permission_mode: AcpxPermissionMode::ApproveReads, - cursor_mode: None, + mode: None, permission_mode_pinned: true, provider_policy: None, system_instructions: "Complete the supplied task.".to_owned(), diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_turns.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_turns.rs index a9b68e07625..5b138a2867f 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_turns.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_turns.rs @@ -54,7 +54,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig { normalized_session_id: "session-1".to_owned(), working_directory: std::env::temp_dir(), permission_mode: AcpxPermissionMode::ApproveReads, - cursor_mode: None, + mode: None, permission_mode_pinned: true, provider_policy: if mode.starts_with("controls") { Some( diff --git a/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts b/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts index 824088b29e8..03180671a29 100644 --- a/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts +++ b/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts @@ -14,7 +14,7 @@ import { export interface CodexAcpxNativeSessionBackendOptions extends Omit< CodexAcpxDriverOptions, - "model" | "permissionMode" | "cursorMode" | "systemInstructions" | "providerPolicy" | "runtimeContext" + "model" | "permissionMode" | "mode" | "systemInstructions" | "providerPolicy" | "runtimeContext" > {} export type AcpxNativeSessionBackendOptions = @@ -72,7 +72,7 @@ export function createAcpxNativeSessionBackend( agent: input.provider.agent, model: input.provider.model, permissionMode: input.provider.permissionMode ?? "approve-reads", - cursorMode: input.provider.cursorMode, + mode: input.provider.mode, systemInstructions, runtimeContext: "runtimeContext" in input ? input.runtimeContext : null, providerPolicy: { readOnly: "executionMode" in input && input.executionMode === "plan" }, 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 26c5f63459f..2293b947a00 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -544,7 +544,7 @@ describe("native backend factory", () => { const input = acpxExecution(); if (input.provider.kind !== "acpx") throw new Error("invalid fixture"); const model = "explicit-cursor-model"; - Object.assign(input.provider, { agent: "cursor", model, cursorMode: "agent", profile: resolveQualifiedAcpxProfile("cursor", model) }); + Object.assign(input.provider, { agent: "cursor", model, mode: "agent", profile: resolveQualifiedAcpxProfile("cursor", model) }); const backend = createNativeSessionBackend(input, { acpxRuntimeDirectory: "/runtime", acpxEnvironment: { CURSOR_API_KEY: "explicit-fixture" } }); await expect(backend.descriptor()).resolves.toMatchObject({ name: "acpx_runtime", version: "0.13.1" }); }); diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index 50ccb8eb4b3..d894511614d 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { parseProviderMode } from "../contracts/provider-mode.js"; import { cursorPlanToolIdentity, cursorToolIdentity } from "../drivers/acpx/cursor-plan-tool-identity.js"; import { createHash } from "node:crypto"; import { createInterface } from "node:readline"; @@ -289,7 +290,7 @@ async function dispatch( clientCapabilities: acpxProfileClientCapabilities(params.agent), model: params.model, permissionMode: params.permissionMode, - cursorMode: params.cursorMode, + mode: params.mode, providerPolicy: params.providerPolicy, systemInstructions: params.systemInstructions, runtimeContext: params.runtimeContext, @@ -1190,9 +1191,10 @@ function safeOutput(value: unknown): Record { function parseOpenParams( value: Record, ): AcpxSidecarOpenParams { + const fields = new Set(["runtimeDirectory", "normalizedSessionId", "workingDirectory", "agent", "model", "permissionMode", "mode", "permissionModePinned", "providerPolicy", "systemInstructions", "runtimeContext", "tools", "providerSessionKey", "expectedIdentity"]); + if (Object.keys(value).some(key => !fields.has(key))) throw new Error("ACPX open parameters include an unsupported field"); const agent = requireQualifiedAgent(value.agent); const model = requiredText(value.model, "model"); - if (value.cursorMode !== undefined && agent !== "cursor") throw new Error("cursorMode is supported only for Cursor"); resolveQualifiedAcpxProfile(agent, model); if ( value.providerSessionKey !== undefined && @@ -1212,7 +1214,7 @@ function parseOpenParams( agent, model, permissionMode: requiredPermissionMode(value.permissionMode), - ...(agent === "cursor" ? { cursorMode: requiredCursorMode(value.cursorMode === undefined ? "agent" : value.cursorMode) } : {}), + ...(value.mode === undefined ? {} : { mode: parseProviderMode(value.mode) }), permissionModePinned: value.permissionModePinned === true, ...(value.providerPolicy == null ? {} : { providerPolicy: parseProviderPolicy(value.providerPolicy) }), systemInstructions: boundedText( @@ -1282,7 +1284,7 @@ function parseExpectedIdentity(value: unknown): AcpxExpectedSessionIdentity { ...(input.permissionMode === undefined ? {} : { permissionMode: requiredPermissionMode(input.permissionMode) }), - ...(input.cursorMode === undefined ? {} : { cursorMode: requiredCursorMode(input.cursorMode) }), + ...(input.mode === undefined ? {} : { mode: parseProviderMode(input.mode) }), providerLifetimeFenceCandidates: requiredFenceCandidates( input.providerLifetimeFenceCandidates, ), @@ -1307,11 +1309,6 @@ function requiredFenceCandidates( return Object.freeze([...value]) as readonly [number, number, number]; } -function requiredCursorMode(value: unknown): "agent" | "plan" | "ask" { - if (value === "agent" || value === "plan" || value === "ask") return value; - throw new Error("cursorMode must be agent, plan, or ask"); -} - function requiredPermissionMode( value: unknown, ): AcpxSidecarOpenParams["permissionMode"] { diff --git a/packages/paperclip-runner/src/contracts/harness-driver.ts b/packages/paperclip-runner/src/contracts/harness-driver.ts index 9be6d782feb..ee04ce4352b 100644 --- a/packages/paperclip-runner/src/contracts/harness-driver.ts +++ b/packages/paperclip-runner/src/contracts/harness-driver.ts @@ -454,8 +454,8 @@ export interface AcpxSessionIdentity { effectiveModel: string; /** Missing on legacy snapshots; those used the historical approve-reads behavior. */ permissionMode?: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; - /** Effective native mode; required on current Cursor session identities. */ - cursorMode?: "agent" | "plan" | "ask"; + /** Effective provider mode identifier, bound to the session identity. */ + mode?: string; providerLifetimeFenceCandidates: readonly [number, number, number]; } diff --git a/packages/paperclip-runner/src/contracts/native-execution.test.ts b/packages/paperclip-runner/src/contracts/native-execution.test.ts index a2d3a30c8c5..05644579b8e 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -483,6 +483,26 @@ describe("native task context ownership", () => { }); } + it("carries an opaque provider mode without a vendor restriction and fences obsolete field names", () => { + const current = currentInput(); + const provider = { + kind: "acpx", agent: "codex", model: "gpt-5.6-sol", permissionMode: "approve-all", mode: "architect", + profile: { driverKind: "acpx_runtime", protocolVersion: 1, acpxVersion: "0.13.1", agent: "codex", agentProfileVersion: 3, + agentServerPackage: "@agentclientprotocol/codex-acp", agentServerVersion: "1.6.2", + agentRuntimePackage: "@openai/codex", agentRuntimeVersion: "0.160.0", commandDigest: `sha256:${"a".repeat(64)}` }, + }; + const value = { ...current, session: { ...current.session, driverKind: "acpx_runtime" }, provider }; + const parsed = parseNativeExecutionInput(value); + expect(parsed.provider).toMatchObject({ agent: "codex", mode: "architect" }); + expect(parseNativeExecutionInput(parsed)).toEqual(parsed); + expect(JSON.stringify(buildNativeModelEnvelope(parsed))).not.toContain("architect"); + for (const mode of [null, 1, "", " ", "x".repeat(241), "plan\0", "plan\n"]) { + expect(() => parseNativeExecutionInput({ ...value, provider: { ...provider, mode } })).toThrow(/provider.mode/); + } + const { mode: _mode, ...withoutMode } = provider; + expect(() => parseNativeExecutionInput({ ...value, provider: { ...withoutMode, cursorMode: "plan" } })).toThrow(/input.provider/); + }); + it.each([ ["v4", "paperclip.native-execution-input.v4", "paperclip.native-model-envelope.v2"], ["v5", NATIVE_EXECUTION_INPUT_SCHEMA, "paperclip.native-model-envelope.v3"], diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index bb5fc12b8c3..1504f1c311b 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -1,3 +1,4 @@ +import { isProviderMode } from "./provider-mode.js"; import { createHash } from "node:crypto"; import type { PrpStructuredRunResult, PrpTerminalState } from "../protocol/replay-contract.js"; import { explicitTaskSkillNames, parseNativeRuntimeContext, type NativeRuntimeContextSnapshot } from "./runtime-context.js"; @@ -84,7 +85,6 @@ export type NativeAcpxAgent = "pi" | "claude" | "codex" | "grok" | "cursor" | "c export type NativeCodexApprovalPolicy = "never" | "on-request" | "untrusted"; export type NativeOpenCodePermissionMode = "allow" | "ask" | "deny"; export type NativeAcpxPermissionMode = "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; -export type NativeCursorMode = "agent" | "plan" | "ask"; export interface NativeAcpxProfileSnapshot { driverKind: "acpx_runtime"; @@ -124,7 +124,7 @@ export type NativeProviderConfig = agent: NativeAcpxAgent; model: string; permissionMode?: NativeAcpxPermissionMode; - cursorMode?: NativeCursorMode; + mode?: string; /** Present only in persisted v1-v3 inputs. */ permissionPolicy?: "interactive"; profile: NativeAcpxProfileSnapshot; @@ -139,7 +139,7 @@ export type NativeProviderConfigV4 = agent: NativeAcpxAgent; model: string; permissionMode: NativeAcpxPermissionMode; - cursorMode?: NativeCursorMode; + mode?: string; profile: NativeAcpxProfileSnapshot; }; @@ -453,7 +453,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput : provider.kind === "aws_agentcore" ? ["kind", "model", "agentCoreProfile", "maxEstimatedSessionCostUsd", "invocationLimits"] : provider.kind === "acpx" - ? ["kind", "agent", "model", isV4 ? "permissionMode" : "permissionPolicy", "profile", ...(isV4 ? ["cursorMode"] : [])] + ? ["kind", "agent", "model", isV4 ? "permissionMode" : "permissionPolicy", "profile", ...(isV4 ? ["mode"] : [])] : provider.kind === "codex" && isV4 ? ["kind", "model", "approvalPolicy", ...(isV5 ? ["reasoningEffort"] : [])] : provider.kind === "opencode" && isV4 @@ -573,9 +573,8 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput invocationLimits: { maxIterations, maxOutputTokens, timeoutSeconds }, }; } else if (provider.kind === "acpx") { - if (provider.cursorMode !== undefined && (provider.agent !== "cursor" - || (provider.cursorMode !== "agent" && provider.cursorMode !== "plan" && provider.cursorMode !== "ask"))) { - throw new NativeExecutionInputError("input.provider.cursorMode must be agent, plan, or ask and is supported only for Cursor"); + if (provider.mode !== undefined && !isProviderMode(provider.mode)) { + throw new NativeExecutionInputError("input.provider.mode must be a bounded nonempty provider mode identifier"); } if (providerModel === null) { throw new NativeExecutionInputError("input.provider.model is required for acpx"); @@ -625,7 +624,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput ...(isV4 ? { permissionMode: provider.permissionMode as NativeAcpxPermissionMode } : { permissionPolicy: "interactive" as const }), - ...(provider.cursorMode === undefined ? {} : { cursorMode: provider.cursorMode as NativeCursorMode }), + ...(provider.mode === undefined ? {} : { mode: provider.mode as string }), profile: { driverKind: "acpx_runtime", protocolVersion: 1, diff --git a/packages/paperclip-runner/src/contracts/provider-mode.ts b/packages/paperclip-runner/src/contracts/provider-mode.ts new file mode 100644 index 00000000000..d4a8d6e5952 --- /dev/null +++ b/packages/paperclip-runner/src/contracts/provider-mode.ts @@ -0,0 +1,12 @@ +/** Provider mode identifiers are opaque to the runner. Adapters own their + * supported values, defaults, native translation, and acknowledgement. */ +export function isProviderMode(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0 + && Array.from(value).length <= 240 && !/[\u0000-\u001f\u007f-\u009f]/.test(value); +} + +export function parseProviderMode(value: unknown): string | undefined { + if (value === undefined) return undefined; + if (!isProviderMode(value)) throw new Error("Provider mode must be a nonempty identifier of at most 240 characters without control characters"); + return value; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts index 2f1f885fffc..7eb07288a44 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -1,3 +1,4 @@ +import { isProviderMode } from "../../contracts/provider-mode.js"; import { cursorPlanToolIdentity, cursorToolExecutionId } from "./cursor-plan-tool-identity.js"; import { createCursorToolEvidence, type CursorToolEvidence } from "./cursor-tool-evidence.js"; import { requireAcpxResponseDelivery } from "./response-delivery.js"; @@ -143,7 +144,7 @@ export interface CodexAcpxDriverOptions { runtimeDirectory: string; model: string; permissionMode?: NativeAcpxPermissionMode; - cursorMode?: "agent" | "plan" | "ask"; + mode?: string; providerPolicy?: { readOnly: boolean }; runtimeContext?: OpenAcpxRuntimeHostOptions["runtimeContext"]; systemInstructions?: string; @@ -457,7 +458,7 @@ export class CodexAcpxDriver implements HarnessDriver { clientCapabilities: acpxProfileClientCapabilities(this.#options.agent ?? "codex"), model: this.#options.model, permissionMode: this.#options.permissionMode ?? "approve-all", - cursorMode: this.#options.cursorMode, + mode: this.#options.mode, providerPolicy: this.#options.providerPolicy, runtimeContext: this.#options.runtimeContext, systemInstructions: this.#options.systemInstructions, @@ -1274,7 +1275,7 @@ class CodexAcpxSession implements HarnessSession { requestedModel: identity.requestedModel, effectiveModel: identity.effectiveModel, permissionMode: identity.permissionMode, - ...(identity.cursorMode === undefined ? {} : { cursorMode: identity.cursorMode }), + ...(identity.mode === undefined ? {} : { mode: identity.mode }), providerLifetimeFenceCandidates: identity.providerLifetimeFenceCandidates, }, @@ -2120,7 +2121,7 @@ function validateRecoverySnapshot(snapshot: PersistedHarnessSession): void { !["approve-all", "approve-paperclip", "approve-reads", "deny-all"].includes( identity.permissionMode, )) || - (identity.cursorMode !== undefined && !["agent", "plan", "ask"].includes(identity.cursorMode)) || + (identity.mode !== undefined && !isProviderMode(identity.mode)) || !validProviderLifetimeFenceCandidates( identity.providerLifetimeFenceCandidates, ) diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts index 772161f82bb..dbeba649296 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts @@ -129,7 +129,7 @@ describe("Codex ACPX runtime adapter", () => { }); it.each(["plan", "ask"] as const)("admission explicitly selects %s and renews temporary command authority", async selected => { const runtime = fakeRuntime(); const options = openOptions(fakeCommand()); - options.profile = { ...options.profile, agent: "cursor" }; options.cursorMode = selected; + options.profile = { ...options.profile, agent: "cursor" }; options.mode = selected; options.refreshConsumedCommand = vi.fn(async () => undefined); let created!: AcpRuntimeOptions; const binding = cursorInstructionBinding(options.systemInstructions); @@ -148,7 +148,7 @@ describe("Codex ACPX runtime adapter", () => { }); expect(vi.mocked(runtime.setConfigOption).mock.calls.map(([call]) => [call.key, call.value])).toEqual([["model", options.profile.reportedModelId], ["mode", selected]]); expect(options.refreshConsumedCommand).toHaveBeenCalledTimes(2); - expect(await port.identity()).toMatchObject({ cursorMode: selected }); + expect(await port.identity()).toMatchObject({ mode: selected }); expect(runtime.startTurn).not.toHaveBeenCalled(); await port.close({ reason: "mode fixture cleanup" }); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts index a238e7222f5..088d098c1f7 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -31,7 +31,7 @@ import type { AcpxModelStatus } from "./model-verification.js"; import { AcpxApprovalRequiredError, decideAcpxPermission } from "./permission-policy.js"; import { ACPX_CAPABILITY_PROFILES } from "./capability-profiles.js"; import { admitCursorInstructions, createCursorInstructionAdmission } from "./cursor-instructions.js"; -import { createCursorModeAdmission, resolveCursorSessionMode } from "./cursor-mode.js"; +import { createAcpxModeBinding } from "./provider-mode.js"; const VERIFIED_COMMAND_SENTINEL = "paperclip-verified-acpx-command"; const DEFAULT_RUNTIME_CLOSE_TIMEOUT_MS = 2_000; @@ -300,17 +300,16 @@ export async function openQualifiedAcpxRuntime( ); }; const commandLaunches = { count: 0, refreshConsumedCommand: options.refreshConsumedCommand }; - const selectedCursorMode = resolveCursorSessionMode(options.profile.agent, options.cursorMode); - const cursorMode = selectedCursorMode ? createCursorModeAdmission(selectedCursorMode) : null; + const modeBinding = createAcpxModeBinding(options.profile.agent, options.mode); const cursorInstructions = options.profile.agent === "cursor" ? createCursorInstructionAdmission(options.systemInstructions) : null; const runtimeOptions: GoalAwareAcpRuntimeOptions = { cwd: options.cwd, - ...(cursorInstructions && cursorMode ? { protocolGuardFactory: () => { - const instructions = cursorInstructions.createGuard(); - const mode = cursorMode.createGuard(); - return (direction: "inbound" | "outbound", message: unknown) => { instructions(direction, message); mode(direction, message); }; + ...(cursorInstructions || modeBinding ? { protocolGuardFactory: () => { + const instructions = cursorInstructions?.createGuard(); + const mode = modeBinding?.createGuard(); + return (direction: "inbound" | "outbound", message: unknown) => { instructions?.(direction, message); mode?.(direction, message); }; } } : {}), sessionStore, agentRegistry: createRegistry({ @@ -473,11 +472,11 @@ export async function openQualifiedAcpxRuntime( }, }), ); - const handshake = (cursorInstructions + const handshake = (cursorInstructions || modeBinding ? ensuredSession.then(async (ensuredHandle) => { handle = ensuredHandle; options.signal?.throwIfAborted(); - await admitCursorInstructions(cursorInstructions, { + if (cursorInstructions) await admitCursorInstructions(cursorInstructions, { providerSpawned: commandLaunches.count > 0, load: async () => { // A fresh ACPX manager may reuse a saved record without spawning. @@ -498,11 +497,11 @@ export async function openQualifiedAcpxRuntime( await commandLaunches.refreshConsumedCommand?.(); }, }); - if (cursorMode && !cursorMode.isReady()) { - if (!runtime.setConfigOption) throw new Error("Cursor mode admission requires native mode configuration"); - await runtime.setConfigOption({ handle: ensuredHandle, key: "mode", value: selectedCursorMode! }); - cursorInstructions.assertReady(); - cursorMode.assertReady(); + if (modeBinding && !modeBinding.isReady()) { + if (!runtime.setConfigOption) throw new Error("Provider mode admission requires native mode configuration"); + await runtime.setConfigOption({ handle: ensuredHandle, key: modeBinding.configKey, value: modeBinding.selectedMode }); + cursorInstructions?.assertReady(); + modeBinding.assertReady(); await children.verifyLifetimeOwnership(); options.signal?.throwIfAborted(); await commandLaunches.refreshConsumedCommand?.(); @@ -522,7 +521,7 @@ export async function openQualifiedAcpxRuntime( ? await raceRuntimeHandshakeWithAbort(boundedHandshake, options.signal) : await boundedHandshake; cursorInstructions?.assertReady(); - cursorMode?.assertReady(); + modeBinding?.assertReady(); // A provider can answer only after the verified sentinel is armed, but do // not admit the session until the owner has observed that exact handoff. await children.verifyLifetimeOwnership(); @@ -577,7 +576,7 @@ export async function openQualifiedAcpxRuntime( return runtimePort( runtime, handle, - { ...requireIdentity(handle), ...(selectedCursorMode ? { cursorMode: selectedCursorMode } : {}) }, + { ...requireIdentity(handle), ...(modeBinding ? { mode: modeBinding.selectedMode } : {}) }, baseStore, children, runtimeCloseTimeoutMs, diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-mode.test.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-mode.test.ts index 34b7fcb986b..1ed85b6b545 100644 --- a/packages/paperclip-runner/src/drivers/acpx/cursor-mode.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-mode.test.ts @@ -12,11 +12,9 @@ function opened(expected: "agent" | "plan" | "ask", initial = expected) { } describe("Cursor native mode admission", () => { - it("defaults only Cursor and rejects unadmitted aliases and other providers", () => { - expect(resolveCursorSessionMode("cursor", undefined)).toBe("agent"); - expect(resolveCursorSessionMode("codex", undefined)).toBeUndefined(); - for (const mode of ["code", "architect", "search", "chat", "", null]) expect(() => resolveCursorSessionMode("cursor", mode)).toThrow(); - expect(() => resolveCursorSessionMode("pi", "agent")).toThrow(/only supported/); + it("defaults Cursor and rejects unadmitted aliases", () => { + expect(resolveCursorSessionMode( undefined)).toBe("agent"); + for (const mode of ["code", "architect", "search", "chat", "", null]) expect(() => resolveCursorSessionMode( mode)).toThrow(); }); it.each(["agent", "plan", "ask"] as const)("admits %s only after correlated native acknowledgements", expected => { const { admission, guard } = opened(expected); diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-mode.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-mode.ts index 80b5de0189e..2213405be29 100644 --- a/packages/paperclip-runner/src/drivers/acpx/cursor-mode.ts +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-mode.ts @@ -1,10 +1,6 @@ export type CursorSessionMode = "agent" | "plan" | "ask"; -export function resolveCursorSessionMode(agent: string, mode: unknown): CursorSessionMode | undefined { - if (agent !== "cursor") { - if (mode !== undefined) throw new Error("Cursor session mode is only supported by Cursor"); - return undefined; - } +export function resolveCursorSessionMode(mode: unknown): CursorSessionMode { if (mode === undefined) return "agent"; if (mode !== "agent" && mode !== "plan" && mode !== "ask") throw new Error("Invalid Cursor session mode"); return mode; diff --git a/packages/paperclip-runner/src/drivers/acpx/provider-mode.test.ts b/packages/paperclip-runner/src/drivers/acpx/provider-mode.test.ts new file mode 100644 index 00000000000..64135ddcd26 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/provider-mode.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { parseProviderMode } from "../../contracts/provider-mode.js"; +import { createAcpxModeBinding, resolveAcpxProviderMode } from "./provider-mode.js"; + +describe("provider-neutral mode transport and adapter admission", () => { + it.each(["agent", "plan", "ask", "architect", "custom/build"])("carries opaque mode %s without a vendor enum", mode => { + expect(parseProviderMode(mode)).toBe(mode); + }); + it.each([null, 1, {}, "", " ", "x".repeat(241), "plan\0", "plan\n", "plan\u0085"])("rejects invalid mode %j", mode => { + expect(() => parseProviderMode(mode)).toThrow(/Provider mode/); + }); + it("does not invent a mode for a provider without a mode adapter", () => { + expect(parseProviderMode(undefined)).toBeUndefined(); + expect(resolveAcpxProviderMode("codex", undefined)).toBeUndefined(); + expect(createAcpxModeBinding("codex", undefined)).toBeNull(); + expect(() => resolveAcpxProviderMode("codex", "architect")).toThrow(/does not support configurable/); + }); + it("lets Cursor own its default, vocabulary, and native admission", () => { + expect(resolveAcpxProviderMode("cursor", undefined)).toBe("agent"); + expect(() => resolveAcpxProviderMode("cursor", "architect")).toThrow(/Invalid Cursor session mode/); + const binding = createAcpxModeBinding("cursor", "plan")!; + expect(binding.selectedMode).toBe("plan"); + expect(binding.configKey).toBe("mode"); + expect(binding.assertReady).toThrow(/native mode/); + const guard = binding.createGuard(); + guard("outbound", { id: 1, method: "session/new", params: {} }); + guard("inbound", { id: 1, result: { sessionId: "native", modes: { currentModeId: "plan" }, configOptions: [{ id: "mode", currentValue: "plan" }] } }); + binding.assertReady(); + expect(binding.isReady()).toBe(true); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/provider-mode.ts b/packages/paperclip-runner/src/drivers/acpx/provider-mode.ts new file mode 100644 index 00000000000..42b5c1c2a47 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/provider-mode.ts @@ -0,0 +1,39 @@ +import { parseProviderMode } from "../../contracts/provider-mode.js"; +import { createCursorModeAdmission, resolveCursorSessionMode } from "./cursor-mode.js"; + +interface ModeAdmission { + assertReady(): void; + isReady(): boolean; + createGuard(): (direction: "inbound" | "outbound", value: unknown) => void; +} + +interface ProviderModeAdapter { + resolve(mode: unknown): string; + admit(mode: string): ModeAdmission; + configKey: string; +} + +// This is an adapter capability registry, not a transport enum. An adapter +// must qualify mode selection and native acknowledgement before registering. +const adapters: Readonly> = { + cursor: { + resolve: resolveCursorSessionMode, + admit: mode => createCursorModeAdmission(resolveCursorSessionMode(mode)), + configKey: "mode", + }, +}; + +export function resolveAcpxProviderMode(agent: string, mode: unknown): string | undefined { + const identifier = parseProviderMode(mode); + const adapter = adapters[agent]; + if (adapter) return adapter.resolve(identifier); + if (identifier !== undefined) throw new Error(`ACPX ${agent} does not support configurable native modes`); + return undefined; +} + +export function createAcpxModeBinding(agent: string, mode: unknown) { + const selectedMode = resolveAcpxProviderMode(agent, mode); + if (selectedMode === undefined) return null; + const adapter = adapters[agent]!; + return { selectedMode, configKey: adapter.configKey, ...adapter.admit(selectedMode) }; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts index b6ec96b0d96..5a3f6c0172f 100644 --- a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts @@ -1,4 +1,4 @@ -import { resolveCursorSessionMode, type CursorSessionMode } from "./cursor-mode.js"; +import { parseProviderMode } from "../../contracts/provider-mode.js"; import { createHash } from "node:crypto"; import { realpath, stat } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; @@ -20,7 +20,7 @@ export interface AcpxRecoveryBinding { requestedModel: string; effectiveModel: string; permissionMode: NativeAcpxPermissionMode; - cursorMode?: CursorSessionMode; + mode?: string; profileSessionKey: string; } @@ -35,7 +35,7 @@ export interface AcpxIdentityRecord { requestedModel: string; effectiveModel: string; permissionMode: NativeAcpxPermissionMode; - cursorMode?: CursorSessionMode; + mode?: string; providerLifetimeFenceCandidates: readonly [number, number, number]; } @@ -46,11 +46,11 @@ export async function createAcpxRecoveryBinding(input: { profile: QualifiedAcpxProfile; requestedModel: string; permissionMode: NativeAcpxPermissionMode; - cursorMode?: CursorSessionMode; + mode?: string; providerPolicy?: { readOnly: boolean; readRoots?: readonly string[]; protectedPaths?: readonly string[] }; }): Promise { validateIdentity(input.normalizedSessionId, "normalized session"); - const cursorMode = resolveCursorSessionMode(input.profile.agent, input.cursorMode); + const mode = parseProviderMode(input.mode); if (input.providerPolicy !== undefined && typeof input.providerPolicy.readOnly !== "boolean") throw new Error("ACPX recovery requires a valid task execution policy"); if (input.requestedModel !== input.profile.qualificationModel) { throw new Error("ACPX recovery requested an unqualified model"); @@ -94,7 +94,7 @@ export async function createAcpxRecoveryBinding(input: { requestedModel: input.requestedModel, profileDigest, permissionMode: input.permissionMode, - ...(cursorMode ? { cursorMode } : {}), + ...(mode ? { mode } : {}), }), ).replace("sha256:", "paperclip-"); return { @@ -107,7 +107,7 @@ export async function createAcpxRecoveryBinding(input: { requestedModel: input.requestedModel, effectiveModel: input.requestedModel, permissionMode: input.permissionMode, - ...(cursorMode ? { cursorMode } : {}), + ...(mode ? { mode } : {}), profileSessionKey, }; } @@ -128,7 +128,7 @@ export function createAcpxIdentityRecord( requestedModel: binding.requestedModel, effectiveModel: binding.effectiveModel, permissionMode: binding.permissionMode, - ...(binding.cursorMode ? { cursorMode: binding.cursorMode } : {}), + ...(binding.mode ? { mode: binding.mode } : {}), providerLifetimeFenceCandidates: Object.freeze([ ...expected.providerLifetimeFenceCandidates, ]) as readonly [number, number, number], @@ -154,7 +154,7 @@ export function acpxProviderSessionIdentity( requestedModel: record.requestedModel, effectiveModel: record.effectiveModel, permissionMode: record.permissionMode, - ...(record.cursorMode ? { cursorMode: record.cursorMode } : {}), + ...(record.mode ? { mode: record.mode } : {}), providerLifetimeFenceCandidates: record.providerLifetimeFenceCandidates, }; verifyExpectedAcpxIdentity(identity, binding, record); @@ -180,7 +180,7 @@ export function verifyExpectedAcpxIdentity( expected.requestedModel !== binding.requestedModel || expected.effectiveModel !== binding.effectiveModel || expected.permissionMode !== binding.permissionMode || - expected.cursorMode !== binding.cursorMode + expected.mode !== binding.mode ) { throw new Error( "ACPX recovery identity conflicts with the immutable session configuration", @@ -199,7 +199,7 @@ export function verifyExpectedAcpxIdentity( record.requestedModel !== binding.requestedModel || record.effectiveModel !== binding.effectiveModel || record.permissionMode !== binding.permissionMode || - record.cursorMode !== binding.cursorMode || + record.mode !== binding.mode || !sameFenceCandidates( record.providerLifetimeFenceCandidates, expected.providerLifetimeFenceCandidates, @@ -224,7 +224,7 @@ function parsePersistedRecord(value: unknown): AcpxIdentityRecord { "requestedModel", "effectiveModel", "permissionMode", - "cursorMode", + "mode", "providerLifetimeFenceCandidates", ]); return validatedRecord(record); @@ -251,7 +251,7 @@ function validatedRecord(value: Record): AcpxIdentityRecord { if (!isPermissionMode(value.permissionMode)) { throw new Error("ACPX identity permission mode is invalid"); } - if (value.cursorMode !== undefined) resolveCursorSessionMode("cursor", value.cursorMode); + if (value.mode !== undefined) parseProviderMode(value.mode); validateFenceCandidates(value.providerLifetimeFenceCandidates); return value as unknown as AcpxIdentityRecord; } @@ -280,7 +280,7 @@ function validateExpected(expected: AcpxExpectedSessionIdentity): void { ) { throw new Error("Expected ACPX permission mode is invalid"); } - if (expected.cursorMode !== undefined) resolveCursorSessionMode("cursor", expected.cursorMode); + if (expected.mode !== undefined) parseProviderMode(expected.mode); validateFenceCandidates(expected.providerLifetimeFenceCandidates); } diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts index 5ffb48ce5ae..e5825e3b38d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -198,13 +198,13 @@ describe("ACPX runtime host", () => { it.each([undefined, "plan", "ask"] as const)("requires observed Cursor mode %s in the host identity", async selected => { const fixture = await hostFixture(); const mode = selected ?? "agent"; - const options = { ...fixture.options, agent: "cursor" as const, model: "explicit-test-model", cursorMode: selected, permissionMode: "approve-all" as const, environment: { CURSOR_API_KEY: "test" } }; + const options = { ...fixture.options, agent: "cursor" as const, model: "explicit-test-model", mode: selected, permissionMode: "approve-all" as const, environment: { CURSOR_API_KEY: "test" } }; const openRuntime = vi.fn(async (launch: AcpxRuntimePortOpenOptions) => { - expect(launch.cursorMode).toBe(mode); - return runtimePort({ getStatus: async () => ({ models: { currentModelId: options.model } }), identity: async () => ({ acpxRecordId: "record-1", backendSessionId: "backend-1", agentSessionId: "agent-1", cursorMode: mode }) }); + expect(launch.mode).toBe(mode); + return runtimePort({ getStatus: async () => ({ models: { currentModelId: options.model } }), identity: async () => ({ acpxRecordId: "record-1", backendSessionId: "backend-1", agentSessionId: "agent-1", mode: mode }) }); }); const host = await AcpxRuntimeHost.open(options, fixture.dependencies({ openRuntime })); - expect(host.identity().cursorMode).toBe(mode); + expect(host.identity().mode).toBe(mode); await host.close({ reason: "mode test complete" }); }); @@ -213,10 +213,10 @@ describe("ACPX runtime host", () => { const port = runtimePort({ getStatus: async () => ({ models: { currentModelId: "explicit-test-model" } }) }); const openRuntime = vi.fn(async () => port); const options = { ...fixture.options, agent: "cursor" as const, model: "explicit-test-model", permissionMode: "approve-all" as const, environment: { CURSOR_API_KEY: "test" } }; - await expect(AcpxRuntimeHost.open(options, fixture.dependencies({ openRuntime }))).rejects.toThrow(/Cursor mode does not match/); + await expect(AcpxRuntimeHost.open(options, fixture.dependencies({ openRuntime }))).rejects.toThrow(/Provider mode does not match/); expect(port.close).toHaveBeenCalled(); openRuntime.mockClear(); - await expect(AcpxRuntimeHost.open({ ...options, agent: "codex", model: "gpt-5.6-sol", cursorMode: "plan" }, fixture.dependencies({ openRuntime }))).rejects.toThrow(/only supported/); + await expect(AcpxRuntimeHost.open({ ...options, agent: "codex", model: "gpt-5.6-sol", mode: "plan" }, fixture.dependencies({ openRuntime }))).rejects.toThrow(/does not support configurable/); expect(openRuntime).not.toHaveBeenCalled(); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index 937d0815b98..c1230c9394d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -1,5 +1,5 @@ import { withAcpxTurnCancellation } from "./turn-cancellation.js"; -import { resolveCursorSessionMode, type CursorSessionMode } from "./cursor-mode.js"; +import { resolveAcpxProviderMode } from "./provider-mode.js"; import { dirname, join } from "node:path"; import { bindAcpxAgentFiles } from "./agent-files-binding.js"; import { assertAcpxProfileEnvironment, assertAcpxProfileWorkspace, classifyAcpxProfileError, verifyAcpxProfileInstallation } from "./profile-installation.js"; @@ -78,7 +78,7 @@ const ACPX_ADMISSION_CLEANUP_RETRY_DELAY_MS = 10; const ACPX_ADMISSION_CLEANUP_RESCHEDULE_MS = 1_000; export interface AcpxRuntimePortIdentity { - cursorMode?: CursorSessionMode; + mode?: string; acpxRecordId: string; backendSessionId: string; agentSessionId: string; @@ -155,7 +155,7 @@ export interface AcpxRuntimePortOpenOptions { stateDirectory: string; providerSessionKey: string; permissionMode: NativeAcpxPermissionMode; - cursorMode?: CursorSessionMode; + mode?: string; permissionPolicy: ReturnType; launchEnvironment: Readonly; /** Kernel credential-home quorum inherited by the provider sentinel. */ @@ -229,7 +229,7 @@ export interface OpenAcpxRuntimeHostOptions { agent: QualifiedAcpxAgent; model: string; permissionMode: NativeAcpxPermissionMode; - cursorMode?: CursorSessionMode; + mode?: string; systemInstructions?: string; runtimeContext?: NativeRuntimeContextSnapshot | null; environment?: NodeJS.ProcessEnv; @@ -339,7 +339,7 @@ export class AcpxRuntimeHost { profile, requestedModel: options.model, permissionMode: options.permissionMode, - cursorMode: resolveCursorSessionMode(options.agent, options.cursorMode), + mode: resolveAcpxProviderMode(options.agent, options.mode), ...(["cursor", "copilot", "pi"].includes(options.agent) && options.providerPolicy !== undefined ? { providerPolicy: options.providerPolicy } : {}), }), @@ -559,7 +559,7 @@ export class AcpxRuntimeHost { stateDirectory: sandbox.stateDirectory, providerSessionKey: binding.profileSessionKey, permissionMode: binding.permissionMode, - cursorMode: binding.cursorMode, + mode: binding.mode, permissionPolicy: acpxRuntimePermissionPolicy( binding.permissionMode, ), @@ -617,8 +617,8 @@ export class AcpxRuntimeHost { ), dependencies.retainAdmissionCleanup, ); - if (runtimeIdentity.cursorMode !== binding.cursorMode) { - throw new Error("ACPX runtime Cursor mode does not match the admitted session configuration"); + if (runtimeIdentity.mode !== binding.mode) { + throw new Error("ACPX runtime Provider mode does not match the admitted session configuration"); } const observedIdentity: AcpxExpectedSessionIdentity = { kind: "acpx", diff --git a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts index 6ff7fa153fd..6e457d35e2e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts +++ b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts @@ -45,7 +45,7 @@ export interface AcpxSidecarOpenParams { agent: QualifiedAcpxAgent; model: string; permissionMode: NativeAcpxPermissionMode; - cursorMode?: "agent" | "plan" | "ask"; + mode?: string; permissionModePinned: boolean; providerPolicy?: { readOnly: boolean }; systemInstructions: string; @@ -66,7 +66,7 @@ export interface AcpxExpectedSessionIdentity { requestedModel: string; effectiveModel: string; permissionMode?: NativeAcpxPermissionMode; - cursorMode?: "agent" | "plan" | "ask"; + mode?: string; providerLifetimeFenceCandidates: readonly [number, number, number]; } diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.recovery.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.recovery.test.ts index a3dbd6302f9..dd18298fa6b 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.recovery.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.recovery.test.ts @@ -194,11 +194,11 @@ describe("Codex app-server Codex driver", () => { }, ); - it.each([undefined, "agent", "plan", "ask"] as const)("persists and verifies the tagged runnerd provider identity including mode %s on recovery", async (cursorMode) => { + it.each([undefined, "agent", "plan", "ask"] as const)("persists and verifies the tagged runnerd provider identity including mode %s on recovery", async (mode) => { const providerIdentity = { kind: "acpx", normalizedSessionId: "normalized-tagged-recovery", - ...(cursorMode === undefined ? {} : { cursorMode }), + ...(mode === undefined ? {} : { mode }), acpxRecordId: "acpx-record-1", backendSessionId: "backend-session-1", agentSessionId: "agent-session-1", diff --git a/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts b/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts index 7bf5a954b13..fe52e695aa0 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts @@ -1,3 +1,4 @@ +import { isProviderMode } from "../../contracts/provider-mode.js"; import type { PersistedHarnessProviderIdentity } from "../../contracts/harness-driver.js"; import type { NativeUserMessage } from "../../contracts/types.js"; import { @@ -78,9 +79,9 @@ export function parseProviderIdentity( "ACPX provider identity contains an invalid permission mode", ); } - const cursorMode = identity.cursorMode; - if (cursorMode !== undefined && cursorMode !== "agent" && cursorMode !== "plan" && cursorMode !== "ask") { - throw new Error("ACPX provider identity contains an invalid Cursor mode"); + const mode = identity.mode; + if (mode !== undefined && !isProviderMode(mode)) { + throw new Error("ACPX provider identity contains an invalid provider mode"); } const fenceCandidates = identity.providerLifetimeFenceCandidates; if ( @@ -107,7 +108,7 @@ export function parseProviderIdentity( requestedModel: identity.requestedModel as string, effectiveModel: identity.effectiveModel as string, ...(permissionMode === undefined ? {} : { permissionMode }), - ...(cursorMode === undefined ? {} : { cursorMode }), + ...(mode === undefined ? {} : { mode }), providerLifetimeFenceCandidates: fenceCandidates as [ number, number, diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index 9bc7f2af132..dab5f017581 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -6,6 +6,7 @@ export * from "./contracts/durable-recovery.js"; export * from "./contracts/harness-driver.js"; export * from "./contracts/local-runner.js"; export * from "./contracts/native-execution.js"; +export * from "./contracts/provider-mode.js"; export * from "./contracts/native-session-backend.js"; export * from "./contracts/question-set.js"; export * from "./contracts/runtime-context.js"; diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index fc6d902d8aa..c9c0ff8bfff 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -1,3 +1,4 @@ +import { resolveAcpxProviderMode } from "../drivers/acpx/provider-mode.js"; import { isAcpxCanonicalInputMethod } from "../drivers/acpx/profile-extensions.js"; import { RunnerdTraceFrameIndex } from "./runnerd-trace-frame-index.js"; import { waitForWarmAttachmentReadiness } from "./warm-attachment-readiness.js"; @@ -1157,7 +1158,7 @@ export interface CapabilityRunnerdCodexTransportOptions { /** Explicit evaluation-only candidate selection, never derived from persisted session input. */ acpxCandidateProfile?: "pi" | "cursor" | "copilot"; acpxPermissionMode?: NativeAcpxPermissionMode; - acpxCursorMode?: "agent" | "plan" | "ask"; + acpxMode?: string; acpxPermissionModePinned?: boolean; acpxSidecarPath?: string; /** SHA-256 verified by the provider-pack authority before runner startup. */ @@ -3511,10 +3512,10 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { readonly #bridgedRuntimeInputs = new Map(); constructor(readonly options: CapabilityRunnerdCodexTransportOptions) { - if (options.acpxCursorMode !== undefined && (options.provider !== "acpx" || options.acpxAgent !== "cursor" - || !["agent", "plan", "ask"].includes(options.acpxCursorMode))) { - throw new Error("acpxCursorMode must be agent, plan, or ask and is supported only for Cursor"); + if (options.acpxMode !== undefined && options.provider !== "acpx") { + throw new Error("acpxMode requires the ACPX provider"); } + if (options.provider === "acpx") resolveAcpxProviderMode(options.acpxAgent ?? "codex", options.acpxMode); if (options.adoptExistingRunner && !options.stateDirectory?.trim()) { throw new Error("native_adopted_runner_state_directory_required"); } @@ -4669,6 +4670,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { ); } } + const selectedAcpxMode = provider === "acpx" + ? resolveAcpxProviderMode(acpxProfile!.agent, this.options.acpxMode) : undefined; const completionContract = record(params.completionContract); const runAttachTemplate = { authorizedTools: this.#authorizedTools, @@ -4701,7 +4704,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { cwd: String(params.cwd ?? tmpdir()), instructions: baseInstructions, providerPolicy: { readOnly: params.permissions === "paperclip-runner-workspace-read-only" }, - ...(acpxProfile!.agent === "cursor" ? { cursorMode: this.options.acpxCursorMode ?? "agent" } : {}), + ...(selectedAcpxMode === undefined ? {} : { mode: selectedAcpxMode }), permissionMode: resolveRunnerdAcpxPermissionMode( this.options.acpxPermissionMode, ), diff --git a/server/src/services/native-runtime/native-cursor-plan-wait.test.ts b/server/src/services/native-runtime/native-cursor-plan-wait.test.ts index b9b21f6256a..d02ca7f82cd 100644 --- a/server/src/services/native-runtime/native-cursor-plan-wait.test.ts +++ b/server/src/services/native-runtime/native-cursor-plan-wait.test.ts @@ -4,7 +4,7 @@ import { resolveQualifiedAcpxProfile, validatePrpStructuredRunResult } from "../ import { buildQuestionResponseDeliveryEnvelope } from "../question-response-delivery.js"; import { NATIVE_COMPLETION_CONTRACT_SCHEMA, NATIVE_COMPLETION_POLICY_VERSION } from "./completion-contracts.js"; import { nativeSha256 } from "./canonical.js"; -import { nativeCursorPlanWaitFromFacts, type CursorPlanWaitFacts } from "./native-cursor-plan-wait.js"; +import { hasCommittedNativeCursorPlanWait, nativeCursorPlanWaitFromFacts, type CursorPlanWaitFacts } from "./native-cursor-plan-wait.js"; function fixture(): CursorPlanWaitFacts { const b = { companyId: "company", issueId: "issue", agentId: "agent", runId: "run" }; @@ -24,7 +24,7 @@ function fixture(): CursorPlanWaitFacts { }; return { binding: b, - run: { id: b.runId, companyId: b.companyId, agentId: b.agentId, nativeIssueId: b.issueId, runtimeMode: "native", runnerInstanceId: "instance", status: "running", completionContractId: "contract", completionContractSha256: contractSha, runnerProfileJson: { nativeExecutionInput: { binding: b, provider: { kind: "acpx", agent: "cursor", cursorMode: "plan", model: "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", profile: resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]") }, session: { normalizedSessionId: "session" }, completionContract: { id: "contract", sha256: contractSha, contract } } } }, + run: { id: b.runId, companyId: b.companyId, agentId: b.agentId, nativeIssueId: b.issueId, runtimeMode: "native", runnerInstanceId: "instance", status: "running", completionContractId: "contract", completionContractSha256: contractSha, runnerProfileJson: { nativeExecutionInput: { binding: b, provider: { kind: "acpx", agent: "cursor", mode: "plan", model: "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", profile: resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]") }, session: { normalizedSessionId: "session" }, completionContract: { id: "contract", sha256: contractSha, contract } } } }, contract: { id: "contract", ...contractMetadata, canonicalSha256: contractSha, contractJson: contract }, events: [ event(275, "runtime_request.created", { request: { schema: "paperclip.runtime_request.v2", status: "pending", type: "input", requestKind: "runtime", requestId: "request", turnId: "turn", itemId: "native-plan-tool", origin: { provider: "cursor", method: "cursor/create_plan", adapter: "acpx-runtime-sidecar" }, input } }), @@ -100,6 +100,45 @@ describe("accepted Cursor plan passive-wait authority", () => { f.events.unshift(structuredClone(turnStart)); resequence(f); expect(nativeCursorPlanWaitFromFacts(f)).toBeNull(); }); + it("preserves an exact committed wait using the pre-release field without admitting new legacy waits", async () => { + const f = fixture(); + f.run.status = "succeeded"; + const proof = nativeCursorPlanWaitFromFacts(f)!; + const admission = (f.run.runnerProfileJson as any).nativeExecutionInput; + admission.provider.cursorMode = admission.provider.mode; + delete admission.provider.mode; + expect(nativeCursorPlanWaitFromFacts(f)).toBeNull(); + const payload = (type: string) => (f.events.find(e => e.eventType === type)!.payload as any).prpEvent; + const { interaction, delivery } = f.interactions[0]!; + const toolBinding = { toolExecutionId: proof.source.toolExecutionId, toolLifecycleSha256: proof.source.toolLifecycleSha256 }; + const source = { ...proof.source, authoritySha256: nativeSha256({ admission, contract: f.contract, + created: payload("runtime_request.created"), resolved: payload("runtime_request.resolved"), terminal: payload("turn.completed"), + interaction, delivery, resolvedAt: interaction.resolvedAt!.toISOString(), acknowledgedAt: delivery.acknowledgedAt!.toISOString(), toolBinding }) }; + const resultJson = { result: proof.result, terminal: { schema: "paperclip.prp.terminal.v1", runTerminalState: "succeeded", turnTerminalState: "completed", reportedWorkDisposition: "yielded" } }; + const canonicalSha256 = nativeSha256(resultJson); + const db = () => { + const rows = [ + [{ decision: { decisionJson: { cursorPlanWait: source, cursorPlanWaitResult: { resultId: "result", resultSha256: canonicalSha256 } } }, + result: { id: "result", canonicalSha256, completionContractId: source.contractId, turnId: source.turnId, resultJson } }], + [{ run: f.run, contract: f.contract }], f.events, f.interactions, + [{ ...f.run, createdAt: new Date(0) }], [interaction], [], [], + ]; + return { select: () => { + const result = rows.shift(); + const query: any = { from: () => query, innerJoin: () => query, where: () => query, orderBy: () => query, limit: () => Promise.resolve(result) }; + return query; + } } as never; + }; + expect(await hasCommittedNativeCursorPlanWait(db(), f.binding)).toBe(true); + // History is immutable: neither changing the old mode nor rewriting its + // field name may preserve the original acceptance authority. + admission.provider.cursorMode = "agent"; + expect(await hasCommittedNativeCursorPlanWait(db(), f.binding)).toBe(false); + delete admission.provider.cursorMode; + admission.provider.mode = "plan"; + expect(await hasCommittedNativeCursorPlanWait(db(), f.binding)).toBe(false); + }); + it("records the accepted revision and explicitly unfinished Plan-mode continuation", () => { const value = nativeCursorPlanWaitFromFacts(fixture()); expect(value?.source).toMatchObject({ requestId: "request", planRevision: `plan-${"a".repeat(64)}`, terminalEventId: "instance:304", toolExecutionId: "native-plan-tool" }); @@ -179,7 +218,7 @@ describe("accepted Cursor plan passive-wait authority", () => { ["wrong task", (f: CursorPlanWaitFacts) => { f.run.nativeIssueId = "other"; }], ["failed run", (f: CursorPlanWaitFacts) => { f.run.status = "failed"; }], ["cancelled run", (f: CursorPlanWaitFacts) => { f.run.status = "cancelled"; }], - ["Agent mode", (f: CursorPlanWaitFacts) => { (f.run.runnerProfileJson as any).nativeExecutionInput.provider.cursorMode = "agent"; }], + ["Agent mode", (f: CursorPlanWaitFacts) => { (f.run.runnerProfileJson as any).nativeExecutionInput.provider.mode = "agent"; }], ["stale profile", (f: CursorPlanWaitFacts) => { (f.run.runnerProfileJson as any).nativeExecutionInput.provider.profile = { ...resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]"), commandDigest: "old" }; }], ["changed contract policy", (f: CursorPlanWaitFacts) => { f.contract.policyVersion = "tampered-policy"; }], ["changed completion authority", (f: CursorPlanWaitFacts) => { f.contract.completionAuthority = "server_arbiter"; }], diff --git a/server/src/services/native-runtime/native-cursor-plan-wait.ts b/server/src/services/native-runtime/native-cursor-plan-wait.ts index b8fc138a546..0da1d726239 100644 --- a/server/src/services/native-runtime/native-cursor-plan-wait.ts +++ b/server/src/services/native-runtime/native-cursor-plan-wait.ts @@ -57,6 +57,13 @@ function isHistoricalUnboundWait(source?: NativeCursorPlanWaitSource): boolean { return source !== undefined && source.toolExecutionId === undefined && source.toolLifecycleSha256 === undefined; } +// Pre-release records used a vendor-specific field. Only a previously committed +// wait may read it; the unmodified admission remains bound to its original hash. +function isPlanMode(provider: Record, committed: boolean): boolean { + if (provider.mode !== undefined) return provider.mode === "plan" && provider.cursorMode === undefined; + return committed && provider.cursorMode === "plan"; +} + /** Only committed native request/answer/normal-terminal facts can create this passive wait. */ export function nativeCursorPlanWaitFromFacts(facts: CursorPlanWaitFacts) { return cursorPlanWaitFromFacts(facts); @@ -75,7 +82,7 @@ function cursorPlanWaitFromFacts(facts: CursorPlanWaitFacts, committedSource?: N if (!["agent", "driverKind", "protocolVersion", "acpxVersion", "commandDigest", "agentProfileVersion", "agentServerPackage", "agentServerVersion", "agentRuntimePackage", "agentRuntimeVersion"].every(key => profile[key] === record(expected)[key])) return null; } if (run.id !== b.runId || run.companyId !== b.companyId || run.agentId !== b.agentId || run.nativeIssueId !== b.issueId || run.runtimeMode !== "native" || !["running", "succeeded"].includes(run.status) || - !Object.entries(b).every(([key, value]) => record(admission.binding)[key] === value) || provider.kind !== "acpx" || provider.agent !== "cursor" || provider.cursorMode !== "plan" || typeof provider.model !== "string" || !provider.model.trim() || + !Object.entries(b).every(([key, value]) => record(admission.binding)[key] === value) || provider.kind !== "acpx" || provider.agent !== "cursor" || !isPlanMode(provider, committedSource !== undefined) || typeof provider.model !== "string" || !provider.model.trim() || record(admission.completionContract).id !== contract.id || record(admission.completionContract).sha256 !== contract.canonicalSha256 || nativeCompletionContractSha256(contract) !== contract.canonicalSha256 || run.completionContractId !== contract.id || run.completionContractSha256 !== contract.canonicalSha256 || !same(contract.contractJson, record(admission.completionContract).contract)) return null; const sessionId = record(admission.session).normalizedSessionId; if (typeof sessionId !== "string" || !sessionId || facts.events.length === 0 || facts.events.length > MAX_CONTROL_EVENTS + MAX_PROGRESS_EVENTS) return null; @@ -187,7 +194,9 @@ async function readCursorPlanWaitProof(db: Db, binding: Binding, locked: boolean .innerJoin(issues, and(eq(issues.id, binding.issueId), eq(issues.companyId, binding.companyId), eq(issues.assigneeAgentId, binding.agentId))) .where(and(eq(heartbeatRuns.id, binding.runId), eq(heartbeatRuns.companyId, binding.companyId), eq(heartbeatRuns.agentId, binding.agentId), eq(heartbeatRuns.nativeIssueId, binding.issueId))).limit(1); const [row] = await (locked ? q.for("share", { noWait: true }) : q); - if (!row || record(record(row.run.runnerProfileJson).nativeExecutionInput).provider?.agent !== "cursor" || record(record(row.run.runnerProfileJson).nativeExecutionInput).provider?.cursorMode !== "plan") return null; + if (!row) return null; + const provider = record(record(record(row.run.runnerProfileJson).nativeExecutionInput).provider); + if (provider.agent !== "cursor" || !isPlanMode(provider, committedSource !== undefined)) return null; const eventTypes = isHistoricalUnboundWait(committedSource) ? LEGACY_EVENT_TYPES : EVENT_TYPES; const eventLimit = MAX_CONTROL_EVENTS + (isHistoricalUnboundWait(committedSource) ? 0 : MAX_PROGRESS_EVENTS) + 1; const eqs = db.select().from(heartbeatRunEvents).where(and(eq(heartbeatRunEvents.companyId, binding.companyId), eq(heartbeatRunEvents.runId, binding.runId), inArray(heartbeatRunEvents.eventType, eventTypes))).orderBy(asc(heartbeatRunEvents.seq)).limit(eventLimit); diff --git a/server/src/services/native-runtime/native-execution-input.ts b/server/src/services/native-runtime/native-execution-input.ts index 430c8d3e86f..a9bc3632417 100644 --- a/server/src/services/native-runtime/native-execution-input.ts +++ b/server/src/services/native-runtime/native-execution-input.ts @@ -104,7 +104,7 @@ export function buildNativeExecutionInput(input: { if (input.issue.workMode !== "standard" && input.issue.workMode !== "planning" && input.issue.workMode !== "ask") { throw new Error("native_execution_input_invalid: issue work mode must be standard, planning, or ask"); } - const cursorMode = resolvePaperclipRunnerCursorMode(input.provider, input.acpxAgent, input.acpxSessionMode); + const mode = resolvePaperclipRunnerCursorMode(input.provider, input.acpxAgent, input.acpxSessionMode); const executionMode = input.executionMode ?? (input.issue.workMode === "planning" ? "plan" : "default"); const acpxProfile = input.provider === "acpx" @@ -266,7 +266,7 @@ export function buildNativeExecutionInput(input: { agent: acpxProfile!.agent, model: input.model, permissionMode: input.acpxPermissionMode ?? "approve-all", - ...(cursorMode === undefined ? {} : { cursorMode }), + ...(mode === undefined ? {} : { mode }), profile: { driverKind: acpxProfile!.driverKind, protocolVersion: acpxProfile!.protocolVersion, 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 691a7a66501..2fd5e2bc2d1 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -1745,30 +1745,30 @@ describe("split durable provider checkpoint identity", () => { }, }) as unknown as NativeExecutionInputV1; - it.each(["agent", "plan", "ask"] as const)("requires both persisted Cursor mode bindings for %s recovery", mode => { + it.each(["agent", "plan", "ask", "architect"] as const)("requires both persisted provider mode bindings for %s recovery", mode => { const profileDigest = `sha256:${"a".repeat(64)}`; - const input = execution({ kind: "acpx", agent: "cursor", model: "explicit-model", permissionMode: "approve-all", cursorMode: mode }, "acpx_runtime"); + const input = execution({ kind: "acpx", agent: "cursor", model: "explicit-model", permissionMode: "approve-all", mode: mode }, "acpx_runtime"); const identity = { kind: "acpx", normalizedSessionId: "native-session", acpxRecordId: "record", backendSessionId: "backend", agentSessionId: "agent-session", profileDigest, workspaceDigest: `sha256:${"b".repeat(64)}`, requestedModel: "explicit-model", - effectiveModel: "explicit-model", permissionMode: "approve-all", cursorMode: mode, + effectiveModel: "explicit-model", permissionMode: "approve-all", mode: mode, providerLifetimeFenceCandidates: [53001, 53002, 53003], }; const state = { schema: "paperclip.runner.acpx-provider-state.v3", lifecycle: "suspended", activeTurnId: null, providerExitUnconfirmed: false, descriptor: { kind: "acpx", provider: "acpx", driver: "acpx_runtime", agent: "cursor", - model: "explicit-model", commandDigest: profileDigest, normalizedSessionId: "native-session", cursorMode: mode }, + model: "explicit-model", commandDigest: profileDigest, normalizedSessionId: "native-session", mode: mode }, identity, }; expect(providerSessionIdentityFromDurableProviderState({ execution: input, providerState: state })).toMatchObject({ providerSessionIdentity: identity }); for (const field of ["descriptor", "identity"] as const) { for (const wrong of [undefined, mode === "agent" ? "plan" : "agent"]) { const changed = structuredClone(state); - (changed[field] as Record).cursorMode = wrong; + (changed[field] as Record).mode = wrong; expect(providerSessionIdentityFromDurableProviderState({ execution: input, providerState: changed })).toEqual({ providerSessionId: null, providerBackendSessionId: null, providerSessionIdentity: null }); - expect(durableProviderCheckpointFailureReason(input, changed)).toBe("cursor_mode_binding"); + expect(durableProviderCheckpointFailureReason(input, changed)).toBe("mode_binding"); } } const unsettled = { ...state, providerExitUnconfirmed: true, privateProviderData: "secret-canary" }; diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 6694b617e7b..5473f0cb12e 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -1,3 +1,4 @@ +import { isProviderMode } from "../../vendor/paperclip-runner/index.js"; import { bundledRemoteProviderPackManifestPath, bundledRemoteRunnerBinary } from "../../vendor/paperclip-runner/index.js"; import { nativeRetryCancellationEligible, rethrowNativeCancellationLockConflict, assertCancellationRequest, cancellationIntentId as callerCancellationIntentId, cancellationRequestId } from "./native-cancellation-request.js"; import { readNativeCursorPlanWait } from "./native-cursor-plan-wait.js"; @@ -5496,7 +5497,7 @@ export function providerSessionIdentityFromDurableProviderState(input: { identity.requestedModel !== expectedModel || identity.effectiveModel !== expectedModel || identity.permissionMode !== input.execution.provider.permissionMode || - !acpxRecoveryCursorModeMatches(input.execution.provider, descriptor.cursorMode, identity.cursorMode) || + !acpxRecoveryModeMatches(input.execution.provider, descriptor.mode, identity.mode) || !["approve-all", "approve-paperclip", "approve-reads", "deny-all"].includes( String(identity.permissionMode), ) || @@ -5589,22 +5590,19 @@ export function durableProviderCheckpointFailureReason(execution: NativeExecutio if (state.lifecycle !== "suspended") return "provider_not_suspended"; if (state.providerExitUnconfirmed !== false) return "provider_exit_unconfirmed"; if (state.activeTurnId !== null) return "provider_turn_unsettled"; - if (!acpxRecoveryCursorModeMatches(execution.provider, record(state.descriptor).cursorMode, record(state.identity).cursorMode)) return "cursor_mode_binding"; + if (!acpxRecoveryModeMatches(execution.provider, record(state.descriptor).mode, record(state.identity).mode)) return "mode_binding"; return "identity_binding"; } // Recovery consumes observed identities; it must never apply the fresh-config -// default to a missing persisted mode or allow another provider to carry it. -function acpxRecoveryCursorModeMatches( +// default to a missing persisted mode. Provider capability checks belong to admission. +function acpxRecoveryModeMatches( provider: NativeExecutionInput["provider"], ...observedModes: unknown[] ): boolean { - const expected = record(provider).cursorMode; - if (provider.kind === "acpx" && provider.agent === "cursor") { - return (expected === "agent" || expected === "plan" || expected === "ask") - && observedModes.every(mode => mode === expected); - } - return expected === undefined && observedModes.every(mode => mode === undefined); + const expected = record(provider).mode; + return (expected === undefined || isProviderMode(expected)) + && observedModes.every(mode => mode === expected); } export function providerSessionIdentityTransitionIsAllowed(input: { @@ -5612,10 +5610,10 @@ export function providerSessionIdentityTransitionIsAllowed(input: { previous: unknown; current: unknown; }): boolean { - if (input.execution.provider.kind === "acpx" && !acpxRecoveryCursorModeMatches( + if (input.execution.provider.kind === "acpx" && !acpxRecoveryModeMatches( input.execution.provider, - record(record(input.previous).providerSessionIdentity).cursorMode, - record(record(input.current).providerSessionIdentity).cursorMode, + record(record(input.previous).providerSessionIdentity).mode, + record(record(input.current).providerSessionIdentity).mode, )) return false; if (canonicalJson(input.previous) === canonicalJson(input.current)) { return true; @@ -8440,7 +8438,7 @@ async function executePaperclipNativeSessionWithinScope( if (terminalEvent.eventType !== "turn.completed") return null; const governedWait = await resolvePendingGovernedWait(); if (governedWait) return governedWait; - if (input.execution.provider.kind === "acpx" && input.execution.provider.agent === "cursor" && input.execution.provider.cursorMode === "plan") { + if (input.execution.provider.kind === "acpx" && input.execution.provider.agent === "cursor" && input.execution.provider.mode === "plan") { const planWait = await readNativeCursorPlanWait(input.db, input.execution.binding); if (planWait?.source.terminalEventId === terminalEvent.sourceEventId) return planWait.result; } @@ -12589,7 +12587,7 @@ async function createRunnerdBackendWithinSessionClaim( // Read only the server operator environment, never agent/runtime env. acpxCandidateProfile: resolveAcpxQualification(input.execution.provider, process.env), acpxPermissionMode: input.execution.provider.permissionMode, - acpxCursorMode: input.execution.provider.cursorMode, + acpxMode: input.execution.provider.mode, acpxPermissionModePinned: input.execution.schema === "paperclip.native-execution-input.v4" || input.execution.schema === "paperclip.native-execution-input.v5", diff --git a/server/src/vendor/paperclip-runner/index.ts b/server/src/vendor/paperclip-runner/index.ts index 5679d345b8d..79651c69a4e 100644 --- a/server/src/vendor/paperclip-runner/index.ts +++ b/server/src/vendor/paperclip-runner/index.ts @@ -117,6 +117,7 @@ export const parseCodexTurnDiff = runner.parseCodexTurnDiff; export const parseHarnessRuntimeRequestResolution = runner.parseHarnessRuntimeRequestResolution; export const parseNativeExecutionInput = runner.parseNativeExecutionInput; +export const isProviderMode = runner.isProviderMode; export const parseNativeRuntimeContext = runner.parseNativeRuntimeContext; export const parsePaperclipQuestionSet = runner.parsePaperclipQuestionSet; export const parsePaperclipQuestionResponse = diff --git a/tests/runner-e2e/cursor-native-flow.ts b/tests/runner-e2e/cursor-native-flow.ts index d432a1af3aa..284830cd86c 100644 --- a/tests/runner-e2e/cursor-native-flow.ts +++ b/tests/runner-e2e/cursor-native-flow.ts @@ -34,7 +34,7 @@ export function hasCursorAcceptedPlanWait(state: { issue: Row; runs: Row[]; inte if (state.runs.length !== 1 || state.issue.status !== "in_progress" || state.interactions.some(card => card.status === "pending")) return false; const run = state.runs[0]!; return run.status === "succeeded" && run.runtimeMode === "native" && run.nativeIssueId === state.issue.id - && run.runnerProfileJson?.nativeExecutionInput?.provider?.cursorMode === "plan" + && run.runnerProfileJson?.nativeExecutionInput?.provider?.mode === "plan" && run.resultJson?.finalizationPhase === "committed" && run.resultJson?.finalizationReasonCode === "native_plan_accepted_waiting_for_continuation" && run.resultJson?.authoritativeDecision === "in_progress";