diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index fedf71d0eb..447a4892ba 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -111,6 +111,9 @@ validate retained identity and schema, require the exact sidecar acknowledgement, and only then clear pending local state. Codex permission requests violate its pinned sidecar policy and terminate the session fail closed. +Safe suspension is available only with no active turn or pending request. The +sidecar must return the exact persistent session identity before runnerd +terminates the local process. Run the complete contract gate with: 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 34330e5149..486a7121ec 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 @@ -592,6 +592,50 @@ impl AcpxProviderSession { Ok(()) } + pub fn suspend( + &mut self, + reason: &str, + ) -> Result { + self.ensure_open()?; + if self.state.active_turn_id().is_some() || self.state.has_pending_requests() { + return Err(LocalRunnerError::invalid( + "ACPX provider session is not at a safe suspension point", + )); + } + let response = match self.transport.request( + GeneratedAcpxSidecarCommand::SessionSuspend, + json!({"reason":bounded_reason(reason)}), + ) { + Ok(response) => response, + Err(error) => return Err(self.fail_closed(error)), + }; + let identity = response + .get("identity") + .cloned() + .ok_or_else(|| LocalRunnerError::invalid("ACPX suspension omitted its identity")) + .and_then(|value| { + serde_json::from_value::(value).map_err(|error| { + LocalRunnerError::invalid(format!( + "ACPX suspension identity is invalid: {error}" + )) + }) + }); + let identity = match identity { + Ok(identity) => identity, + Err(error) => return Err(self.fail_closed(error)), + }; + if response.get("suspended").and_then(Value::as_bool) != Some(true) + || identity != self.identity + { + return Err(self.fail_closed(LocalRunnerError::invalid( + "ACPX sidecar did not confirm the exact suspended session", + ))); + } + self.closed = true; + self.terminate_transport()?; + Ok(identity) + } + pub fn shutdown(&mut self, reason: &str) -> Result<(), LocalRunnerError> { if self.closed { return self.terminate_transport(); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_state.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_state.rs index 78b547f049..8ad2058d7f 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_state.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_state.rs @@ -150,6 +150,12 @@ impl AcpxProviderState { .rotate_settled_turn_identities_after_provider_restart() } + pub fn has_pending_requests(&self) -> bool { + !self.pending_tools.is_empty() + || !self.pending_permissions.is_empty() + || !self.pending_inputs.is_empty() + } + pub fn begin_turn(&mut self, turn_id: impl Into) -> Result<(), LocalRunnerError> { if self.scope.active_turn_id().is_some() || !self.pending_tools.is_empty() 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 0d4feec4a4..f9e7632a32 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 @@ -110,7 +110,11 @@ fn run() -> Result<(), Box> { | "turns-permission" | "resolutions" | "resolutions-error-redaction" - | "resolutions-wrong-ack" => { + | "resolutions-wrong-ack" + | "suspend" + | "suspend-wrong-ack" + | "suspend-wrong-identity" + | "suspend-missing-identity" => { write_json(&mut stdout, &bootstrap_success(id, command, &request, mode))?; let params = request.get("params").unwrap_or(&Value::Null); let turn_id = params @@ -571,10 +575,10 @@ fn bootstrap_success(id: u64, command: &str, request: &Value, mode: &str) -> Val }), "turn.cancel" => json!({"cancelled":mode != "turns-wrong-cancel"}), "session.suspend" => json!({ - "suspended":true, - "identity": { + "suspended":mode != "suspend-wrong-ack", + "identity": if mode == "suspend-missing-identity" { Value::Null } else { json!({ "kind": "acpx", - "normalizedSessionId": "session-1", + "normalizedSessionId": if mode == "suspend-wrong-identity" { "another-session" } else { "session-1" }, "acpxRecordId": "record-1", "backendSessionId": "backend-1", "agentSessionId": "agent-1", @@ -583,7 +587,7 @@ fn bootstrap_success(id: u64, command: &str, request: &Value, mode: &str) -> Val "requestedModel": "gpt-5.6-sol", "effectiveModel": "gpt-5.6-sol", "permissionMode": "approve-reads", - }, + })}, }), "tool.resolve" => json!({ "resolved":if mode == "resolutions-error-redaction" { 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 new file mode 100644 index 0000000000..444ee6ddd9 --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_suspend.rs @@ -0,0 +1,74 @@ +use std::path::PathBuf; +use std::time::Duration; + +use paperclip_runner_core::acpx_provider_session::{ + AcpxPermissionMode, AcpxProviderSession, AcpxProviderSessionConfig, +}; +use paperclip_runner_core::acpx_sidecar_transport::AcpxSidecarTransportConfig; +use paperclip_runner_core::provider_bridge::{authorized_tool_catalog_digest, AuthorizedToolSet}; + +fn config(mode: &str) -> AcpxProviderSessionConfig { + let operations = Vec::new(); + AcpxProviderSessionConfig { + transport: AcpxSidecarTransportConfig { + command: PathBuf::from(env!("CARGO_BIN_EXE_fake-acpx-sidecar")), + args: vec!["--mode".to_owned(), mode.to_owned()], + request_timeout: Duration::from_secs(1), + shutdown_grace: Duration::from_millis(100), + }, + agent: "codex".to_owned(), + model: "gpt-5.6-sol".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, + permission_mode_pinned: true, + system_instructions: "Complete the supplied task.".to_owned(), + tool_set: AuthorizedToolSet { + schema: "paperclip.runner.authorized-tools.v1".to_owned(), + schema_version: 1, + catalog_digest: authorized_tool_catalog_digest(&operations).unwrap(), + operations, + }, + expected_identity: None, + } +} + +#[test] +fn suspends_only_after_the_exact_persistent_identity_is_confirmed() { + let mut session = AcpxProviderSession::start(&config("suspend")).unwrap(); + let expected = session.identity().clone(); + + assert_eq!(session.suspend("worker restart").unwrap(), expected); + assert!(session.shutdown("already suspended").is_ok()); +} + +#[test] +fn rejects_suspension_during_an_active_turn_without_closing_the_session() { + let mut session = AcpxProviderSession::start(&config("suspend")).unwrap(); + session + .start_turn("turn-1", "Please help", &std::env::temp_dir()) + .unwrap(); + + let error = session.suspend("too early").unwrap_err().to_string(); + assert!(error.contains("safe suspension point"), "{error}"); + assert_eq!(session.state().active_turn_id(), Some("turn-1")); + session.shutdown("test complete").unwrap(); +} + +#[test] +fn fails_closed_when_the_suspension_acknowledgement_does_not_match() { + for mode in ["suspend-wrong-ack", "suspend-wrong-identity"] { + let mut session = AcpxProviderSession::start(&config(mode)).unwrap(); + let error = session.suspend("worker restart").unwrap_err().to_string(); + assert!(error.contains("exact suspended session"), "{mode}: {error}"); + assert!(session.shutdown("already closed").is_ok()); + } + + let mut session = AcpxProviderSession::start(&config("suspend-missing-identity")).unwrap(); + let error = session.suspend("worker restart").unwrap_err().to_string(); + assert!(error.contains("identity is invalid"), "{error}"); + assert!(session.shutdown("already closed").is_ok()); +}