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";