fix(runner): bind Cursor mode across Rust session recovery

Require explicit Cursor modes, preserve them through open and checkpoints, and reject mode drift. Bind the current Cursor v5 identity while rejecting historical v4.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-29 16:24:09 -05:00
1 parent 4af314a055
commit 5013666821
8 files changed
+274 -23

No files matched your search

@@ -14,7 +14,7 @@ use sha2::{Digest, Sha256};
use crate::acpx_provider_session::{
AcpxPermissionMode, AcpxProviderRuntimePolicy, AcpxProviderSession, AcpxProviderSessionConfig,
AcpxProviderSessionIdentity, AcpxTurnControlCapabilities,
AcpxProviderSessionIdentity, AcpxTurnControlCapabilities, CursorMode,
};
use crate::acpx_sidecar_transport::AcpxSidecarTransportConfig;
#[cfg(test)]
@@ -135,6 +135,8 @@ struct AcpxProviderDescriptor {
#[serde(default)]
instructions: String,
permission_mode: AcpxPermissionMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
cursor_mode: Option<CursorMode>,
permission_mode_pinned: bool,
#[serde(default)]
provider_policy: Option<AcpxProviderRuntimePolicy>,
@@ -213,7 +215,7 @@ impl AcpxProviderDescriptor {
"2026.09.26-dd393fe",
None,
None,
"sha256:b1440d559ebc4eef5c7a582f1c81fc153270cfbafa1731a8ee76d83713bdf61b",
"sha256:aa8c0b2b84786982bcd06b7634bf95be6f2bc42bb8def48a8751dc50cf739b6b",
),
"copilot" => (
self.model.as_str(),
@@ -246,6 +248,7 @@ impl AcpxProviderDescriptor {
|| self.model.trim().is_empty()
|| self.model.len() > 240
|| self.model.contains('\0')
|| ((self.agent == "cursor") != self.cursor_mode.is_some())
|| (matches!(self.agent.as_str(), "pi" | "cursor" | "copilot")
&& self.provider_policy.is_none())
|| self.agent_server_package != expected.1
@@ -341,6 +344,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,
permission_mode_pinned: self.permission_mode_pinned,
provider_policy: self.provider_policy.clone(),
system_instructions: self.instructions.clone(),
@@ -424,7 +428,7 @@ impl AcpxProviderDescriptor {
}
fn public_descriptor(&self, identity: Option<&AcpxProviderSessionIdentity>) -> Value {
json!({
let mut descriptor = json!({
"provider": "acpx",
"driver": "acpx_runtime",
"providerVersion": self.provider_version,
@@ -440,7 +444,11 @@ impl AcpxProviderDescriptor {
"providerSessionId": identity.map(|value| value.agent_session_id.as_str()),
"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);
}
descriptor
}
}
@@ -570,9 +578,11 @@ impl AcpxDurableState {
identity
.validate()
.map_err(|error| DurableRunnerError::invalid(error.to_string()))?;
if identity.profile_digest != self.descriptor.command_digest {
if identity.profile_digest != self.descriptor.command_digest
|| identity.cursor_mode != self.descriptor.cursor_mode
{
return Err(DurableRunnerError::invalid(
"ACPX durable identity no longer matches its qualified profile digest",
"ACPX durable identity no longer matches its qualified profile or Cursor mode",
));
}
}
@@ -2510,6 +2520,7 @@ mod tests {
requested_model: descriptor.model.clone(),
effective_model: descriptor.model.clone(),
permission_mode: Some(descriptor.permission_mode),
cursor_mode: descriptor.cursor_mode,
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
};
let operations = Vec::new();
@@ -2759,6 +2770,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,
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
};
@@ -2782,7 +2794,7 @@ mod tests {
"cursor",
"cursor-agent",
"2026.09.26-dd393fe",
"sha256:b1440d559ebc4eef5c7a582f1c81fc153270cfbafa1731a8ee76d83713bdf61b",
"sha256:aa8c0b2b84786982bcd06b7634bf95be6f2bc42bb8def48a8751dc50cf739b6b",
None,
None,
"explicit-model",
@@ -2817,14 +2829,42 @@ mod tests {
let missing: AcpxProviderDescriptor = serde_json::from_value(value.clone()).unwrap();
assert!(missing.validate(&context()).is_err());
value["providerPolicy"] = json!({"readOnly":true});
if agent == "cursor" {
value["cursorMode"] = json!("agent");
}
let valid: AcpxProviderDescriptor = serde_json::from_value(value.clone()).unwrap();
valid.validate(&context()).unwrap();
if agent == "cursor" {
assert_eq!(valid.public_descriptor(None)["cursorMode"], json!("agent"));
let mut previous_v4 = value.clone();
previous_v4["commandDigest"] = json!(
"sha256:b1440d559ebc4eef5c7a582f1c81fc153270cfbafa1731a8ee76d83713bdf61b"
);
let previous_v4: AcpxProviderDescriptor =
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");
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::<AcpxProviderDescriptor>(unknown).is_err());
} else {
assert!(valid.public_descriptor(None).get("cursorMode").is_none());
let mut wrong_agent = value.clone();
wrong_agent["cursorMode"] = json!("plan");
let wrong_agent: AcpxProviderDescriptor =
serde_json::from_value(wrong_agent).unwrap();
assert!(wrong_agent.validate(&context()).is_err());
}
if agent == "copilot" {
let mut previous_v4 = value.clone();
previous_v4["commandDigest"] = json!(
"sha256:a119e436b4ad13ee70e1f58bedad4a0f20761b5fd982b752981f0cb1bc65f797"
);
let previous_v4: AcpxProviderDescriptor = serde_json::from_value(previous_v4).unwrap();
let previous_v4: AcpxProviderDescriptor =
serde_json::from_value(previous_v4).unwrap();
assert!(previous_v4.validate(&context()).is_err());
let mut legacy = value.clone();
legacy["commandDigest"] = json!(
@@ -2979,6 +3019,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,
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
};
let operations = Vec::new();
@@ -3305,6 +3346,7 @@ mod tests {
requested_model: descriptor.model.clone(),
effective_model: descriptor.model.clone(),
permission_mode: Some(descriptor.permission_mode),
cursor_mode: descriptor.cursor_mode,
provider_lifetime_fence_candidates,
};
let operations = Vec::new();
@@ -3476,6 +3518,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,
provider_lifetime_fence_candidates,
});
state.provider_exit_unconfirmed = true;
@@ -10,7 +10,7 @@ use std::os::unix::fs::DirBuilderExt;
use serde::{Deserialize, Serialize};
use crate::acpx_provider_session::{
AcpxPermissionMode, AcpxProviderSessionConfig, AcpxProviderSessionIdentity,
AcpxPermissionMode, AcpxProviderSessionConfig, AcpxProviderSessionIdentity, CursorMode,
};
use crate::durable::{
create_private_temporary_file, open_private_regular_file, verify_private_directory,
@@ -48,6 +48,8 @@ struct PersistedAcpxProviderSessionIdentity {
requested_model: String,
effective_model: String,
permission_mode: AcpxPermissionMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
cursor_mode: Option<CursorMode>,
provider_lifetime_fence_candidates: [u16; 3],
}
@@ -70,6 +72,7 @@ impl PersistedAcpxProviderSessionIdentity {
requested_model: identity.requested_model,
effective_model: identity.effective_model,
permission_mode,
cursor_mode: identity.cursor_mode,
provider_lifetime_fence_candidates: identity.provider_lifetime_fence_candidates,
})
}
@@ -86,6 +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,
provider_lifetime_fence_candidates: self.provider_lifetime_fence_candidates,
}
}
@@ -106,6 +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
|| config
.expected_identity
.as_ref()
@@ -34,6 +34,14 @@ 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")]
pub struct AcpxProviderSessionIdentity {
@@ -48,6 +56,8 @@ pub struct AcpxProviderSessionIdentity {
pub effective_model: String,
#[serde(default)]
pub permission_mode: Option<AcpxPermissionMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor_mode: Option<CursorMode>,
pub provider_lifetime_fence_candidates: [u16; 3],
}
@@ -68,6 +78,7 @@ pub struct AcpxProviderSessionConfig {
pub normalized_session_id: String,
pub working_directory: PathBuf,
pub permission_mode: AcpxPermissionMode,
pub cursor_mode: Option<CursorMode>,
pub permission_mode_pinned: bool,
pub provider_policy: Option<AcpxProviderRuntimePolicy>,
pub system_instructions: String,
@@ -98,6 +109,11 @@ 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 matches!(self.agent.as_str(), "pi" | "cursor" | "copilot")
&& self.provider_policy.is_none()
{
@@ -169,6 +185,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
{
return Err(LocalRunnerError::invalid(
"ACPX expected identity conflicts with the requested session",
@@ -1188,6 +1205,27 @@ impl Drop for AcpxProviderSession {
}
}
fn session_open_params(config: &AcpxProviderSessionConfig, sidecar_tools: &[Value]) -> Value {
let mut params = json!({
"runtimeDirectory": config.runtime_directory,
"normalizedSessionId": config.normalized_session_id,
"workingDirectory": config.working_directory,
"agent": config.agent,
"model": config.model,
"permissionMode": config.permission_mode,
"permissionModePinned": config.permission_mode_pinned,
"providerPolicy": config.provider_policy,
"systemInstructions": config.system_instructions,
"runtimeContext": config.runtime_context,
"tools": &sidecar_tools,
"expectedIdentity": config.expected_identity,
});
if let Some(mode) = config.cursor_mode {
params["cursorMode"] = json!(mode);
}
params
}
fn bootstrap(
transport: &mut AcpxSidecarTransport,
config: &AcpxProviderSessionConfig,
@@ -1208,20 +1246,7 @@ fn bootstrap(
let opened = transport.request(
GeneratedAcpxSidecarCommand::SessionOpen,
json!({
"runtimeDirectory": config.runtime_directory,
"normalizedSessionId": config.normalized_session_id,
"workingDirectory": config.working_directory,
"agent": config.agent,
"model": config.model,
"permissionMode": config.permission_mode,
"permissionModePinned": config.permission_mode_pinned,
"providerPolicy": config.provider_policy,
"systemInstructions": config.system_instructions,
"runtimeContext": config.runtime_context,
"tools": &sidecar_tools,
"expectedIdentity": config.expected_identity,
}),
session_open_params(config, &sidecar_tools),
)?;
let identity = verify_open_response(&opened, transport.process_id(), config)?;
let turn_controls = verified_turn_controls(opened.get("turnControls"), &config.agent)?;
@@ -1307,6 +1332,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
|| config
.expected_identity
.as_ref()
@@ -1529,3 +1555,142 @@ mod permission_mode_tests {
);
}
}
#[cfg(test)]
mod cursor_mode_tests {
use super::*;
fn config() -> AcpxProviderSessionConfig {
let operations = Vec::new();
AcpxProviderSessionConfig {
transport: AcpxSidecarTransportConfig {
command: std::env::current_exe().unwrap(),
args: Vec::new(),
verified_launch: None,
request_timeout: Duration::from_secs(1),
shutdown_grace: Duration::from_millis(100),
},
agent: "cursor".to_owned(),
model: "explicit-model".to_owned(),
run_id: "run-1".to_owned(),
catalog_revision: 1,
runtime_directory: std::env::temp_dir(),
normalized_session_id: "session-1".to_owned(),
working_directory: std::env::temp_dir(),
permission_mode: AcpxPermissionMode::ApproveReads,
cursor_mode: Some(CursorMode::Plan),
permission_mode_pinned: true,
provider_policy: Some(AcpxProviderRuntimePolicy { read_only: false }),
system_instructions: String::new(),
runtime_context: Value::Null,
tool_set: AuthorizedToolSet {
schema: TOOL_SET_SCHEMA.to_owned(),
schema_version: 1,
catalog_digest: authorized_tool_catalog_digest(&operations).unwrap(),
operations,
},
expected_identity: None,
}
}
fn identity() -> AcpxProviderSessionIdentity {
AcpxProviderSessionIdentity {
kind: "acpx".to_owned(),
normalized_session_id: "session-1".to_owned(),
acpx_record_id: "record-1".to_owned(),
backend_session_id: "backend-1".to_owned(),
agent_session_id: "agent-1".to_owned(),
profile_digest: format!("sha256:{}", "1".repeat(64)),
workspace_digest: format!("sha256:{}", "2".repeat(64)),
requested_model: "explicit-model".to_owned(),
effective_model: "explicit-model".to_owned(),
permission_mode: Some(AcpxPermissionMode::ApproveReads),
cursor_mode: Some(CursorMode::Plan),
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::<CursorMode>(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::<CursorMode>(value).is_err());
}
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();
}
#[test]
fn open_wire_and_identity_bind_exact_cursor_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));
let mut identity = identity();
identity.cursor_mode = Some(mode);
let response = json!({"sidecarPid": 100, "status": {}, "identity": identity});
assert_eq!(
verify_open_response(&response, 100, &config)
.unwrap()
.cursor_mode,
Some(mode)
);
for wrong in [
None,
Some(CursorMode::Agent),
Some(CursorMode::Plan),
Some(CursorMode::Ask),
] {
if wrong == Some(mode) {
continue;
}
let mut changed = response.clone();
changed["identity"]["cursorMode"] = 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());
let mut other = identity();
other.cursor_mode = None;
assert!(serde_json::to_value(&other)
.unwrap()
.get("cursorMode")
.is_none());
let response = json!({"sidecarPid": 100, "status": {}, "identity": other});
verify_open_response(&response, 100, &config).unwrap();
}
#[test]
fn warm_reopen_and_suspension_reject_mode_changes() {
let mut config = config();
let identity = identity();
config.expected_identity = Some(identity.clone());
config.validate().unwrap();
config.cursor_mode = Some(CursorMode::Ask);
assert!(config.validate().is_err());
let mut changed = identity.clone();
changed.cursor_mode = Some(CursorMode::Agent);
assert!(verify_suspend_response(
&json!({"suspended": true, "identity": changed}),
&identity
)
.is_err());
}
}
@@ -50,6 +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,
permission_mode_pinned: true,
provider_policy: None,
system_instructions: "Complete the supplied task.".to_owned(),
@@ -76,6 +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,
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
}
}
@@ -257,3 +259,34 @@ fn rejects_an_existing_runner_state_directory_that_is_not_private() {
);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn cursor_mode_round_trips_checkpoint_and_rejects_changed_or_missing_recovery_mode() {
use paperclip_runner_core::acpx_provider_session::{AcpxProviderRuntimePolicy, CursorMode};
let directory = temporary_directory("cursor-mode");
let mut config = config(&directory);
config.agent = "cursor".to_owned();
config.cursor_mode = Some(CursorMode::Plan);
config.provider_policy = Some(AcpxProviderRuntimePolicy { read_only: false });
let mut identity = identity();
identity.cursor_mode = Some(CursorMode::Plan);
let checkpoint = AcpxSuspensionCheckpoint::from_suspension(&config, identity.clone()).unwrap();
let wire = serde_json::to_value(&checkpoint).unwrap();
assert_eq!(wire["identity"]["cursorMode"], 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)] {
let mut changed = config.clone();
changed.cursor_mode = mode;
assert!(recovered.admit_recovery(&changed).is_err());
let mut changed = wire.clone();
changed["identity"]["cursorMode"] = 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;
assert!(recovered.admit_recovery(&other).is_err());
fs::remove_dir_all(directory).unwrap();
}
@@ -49,6 +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,
permission_mode_pinned: true,
provider_policy: None,
system_instructions: "Complete the supplied task.".to_owned(),
@@ -44,6 +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,
permission_mode_pinned: true,
provider_policy: None,
system_instructions: "Complete the supplied task.".to_owned(),
@@ -65,6 +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,
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
}
}
@@ -25,6 +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,
permission_mode_pinned: true,
provider_policy: None,
system_instructions: "Complete the supplied task.".to_owned(),
@@ -54,6 +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,
permission_mode_pinned: true,
provider_policy: if mode.starts_with("controls") {
Some(