diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 83f951dbfd..4d73ff93e6 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -65,6 +65,11 @@ also requires its optional or mandatory run and turn scope to match the active execution. Process and diagnostic events can remain global. All operational, tool, input, permission, and terminal events require the exact active binding. +A package-local payload boundary decodes events only after that scope check. It +validates control identities, terminal status, question sets, and the admitted +runtime event types and bounded fields. It redacts diagnostic and retained +event values again before they can enter provider state. + Run the complete contract gate with: ```sh diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_event_payload.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_event_payload.rs new file mode 100644 index 0000000000..8584d4ff21 --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_event_payload.rs @@ -0,0 +1,468 @@ +use std::collections::BTreeSet; + +use serde_json::Value; + +use crate::acpx_event_scope::AcpxEventScope; +use crate::acpx_sidecar_transport::AcpxSidecarEvent; +use crate::durable::{redact_text, sanitize_value}; +use crate::generated_acpx_sidecar_contract::GeneratedAcpxSidecarEventType; +use crate::local_runner::LocalRunnerError; + +const MAX_EVENT_PAYLOAD_BYTES: usize = 256 * 1024; +const MAX_ID_CHARS: usize = 160; +const MAX_RUNTIME_TEXT_CHARS: usize = 64 * 1024; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AcpxRuntimeEventKind { + TextDelta, + Thinking, + Plan, + Status, + ToolCall, + SemanticResult, + ProviderNotice, + Error, + Done, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AcpxTurnStatus { + Completed, + Failed, + Cancelled, + Interrupted, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum AcpxEventPayload { + Runtime { + kind: AcpxRuntimeEventKind, + payload: Value, + }, + PermissionRequested { + request_id: String, + kind: String, + title: String, + details: Value, + }, + InputRequested { + request_id: String, + question_set: Value, + origin: Option, + }, + ToolCalled { + call_id: String, + operation_id: String, + input: Value, + }, + TurnTerminal { + status: AcpxTurnStatus, + error: Option, + }, + Process { + details: Value, + }, + Diagnostic { + code: String, + message: String, + }, +} + +/// Validates event authority before it decodes the bounded sidecar payload. +pub fn decode_acpx_event( + scope: &AcpxEventScope, + event: &AcpxSidecarEvent, +) -> Result { + scope.validate_event(event)?; + let payload_bytes = serde_json::to_vec(&event.payload).map_err(|error| { + LocalRunnerError::invalid(format!("ACPX event payload is invalid: {error}")) + })?; + if payload_bytes.len() > MAX_EVENT_PAYLOAD_BYTES { + return Err(LocalRunnerError::invalid( + "ACPX event payload exceeds the 256 KiB admission limit", + )); + } + + match event.event_type { + GeneratedAcpxSidecarEventType::RuntimeEvent => decode_runtime_event(&event.payload), + GeneratedAcpxSidecarEventType::RuntimePermissionRequested => { + Ok(AcpxEventPayload::PermissionRequested { + request_id: required_id(&event.payload, "requestId", "permission request")?, + kind: bounded_optional_text(&event.payload, "kind", 160, "permission kind")? + .map(|value| redact_text(&value)) + .unwrap_or_else(|| "permission".to_owned()), + title: bounded_optional_text(&event.payload, "title", 4_000, "permission title")? + .map(|value| redact_text(&value)) + .unwrap_or_else(|| "ACP permission request".to_owned()), + details: sanitize_value(&event.payload), + }) + } + GeneratedAcpxSidecarEventType::RuntimeInputRequested => { + let question_set = event.payload.get("questionSet").cloned().ok_or_else(|| { + LocalRunnerError::invalid("ACPX input request omitted its question set") + })?; + validate_question_set(&question_set)?; + let question_set = sanitize_question_set(question_set); + let origin = optional_object(&event.payload, "origin", "input request origin")?; + Ok(AcpxEventPayload::InputRequested { + request_id: required_id(&event.payload, "requestId", "input request")?, + question_set, + origin: origin.map(|value| sanitize_value(&value)), + }) + } + GeneratedAcpxSidecarEventType::RuntimeToolCalled => { + let input = event.payload.get("input").cloned().unwrap_or(Value::Null); + if !input.is_object() { + return Err(LocalRunnerError::invalid( + "ACPX tool call input must be an object", + )); + } + Ok(AcpxEventPayload::ToolCalled { + call_id: required_id(&event.payload, "callId", "tool call")?, + operation_id: required_id(&event.payload, "operationId", "tool operation")?, + input: sanitize_value(&input), + }) + } + GeneratedAcpxSidecarEventType::RuntimeTurnTerminal => { + let status = match event.payload.get("status").and_then(Value::as_str) { + Some("completed") => AcpxTurnStatus::Completed, + Some("failed") => AcpxTurnStatus::Failed, + Some("cancelled" | "canceled") => AcpxTurnStatus::Cancelled, + Some("interrupted") => AcpxTurnStatus::Interrupted, + _ => { + return Err(LocalRunnerError::invalid( + "ACPX terminal event has an unsupported status", + )) + } + }; + let error = optional_object(&event.payload, "error", "terminal error")?; + Ok(AcpxEventPayload::TurnTerminal { + status, + error: error.map(|value| sanitize_value(&value)), + }) + } + GeneratedAcpxSidecarEventType::RuntimeProcess => Ok(AcpxEventPayload::Process { + details: sanitize_value(&event.payload), + }), + GeneratedAcpxSidecarEventType::RuntimeDiagnostic => { + let code = required_id(&event.payload, "code", "diagnostic code")?; + let message = event + .payload + .get("message") + .and_then(Value::as_str) + .ok_or_else(|| LocalRunnerError::invalid("ACPX diagnostic omitted its message"))?; + if message.chars().count() > 8_192 { + return Err(LocalRunnerError::invalid( + "ACPX diagnostic message exceeds its bound", + )); + } + Ok(AcpxEventPayload::Diagnostic { + code, + message: redact_text(message), + }) + } + } +} + +fn sanitize_question_set(mut value: Value) -> Value { + let Some(question_set) = value.as_object_mut() else { + return value; + }; + redact_object_text(question_set, &["title", "description", "submitLabel"]); + if let Some(questions) = question_set + .get_mut("questions") + .and_then(Value::as_array_mut) + { + for question in questions { + let Some(question) = question.as_object_mut() else { + continue; + }; + // IDs, answer modes, and validation patterns are protocol values: + // changing them would break response correlation or semantics. + redact_object_text(question, &["header", "prompt", "helpText"]); + if let Some(options) = question.get_mut("options").and_then(Value::as_array_mut) { + for option in options { + if let Some(option) = option.as_object_mut() { + redact_object_text(option, &["label", "description"]); + } + } + } + if let Some(custom_answer) = question + .get_mut("customAnswer") + .and_then(Value::as_object_mut) + { + redact_object_text(custom_answer, &["label", "placeholder"]); + } + } + } + value +} + +fn redact_object_text(object: &mut serde_json::Map, keys: &[&str]) { + for key in keys { + let Some(text) = object.get(*key).and_then(Value::as_str) else { + continue; + }; + let redacted = redact_text(text); + object.insert((*key).to_owned(), Value::String(redacted)); + } +} + +fn decode_runtime_event(payload: &Value) -> Result { + let runtime_type = payload + .get("type") + .and_then(Value::as_str) + .ok_or_else(|| LocalRunnerError::invalid("ACPX runtime event omitted its type"))?; + let kind = match runtime_type { + "text_delta" => { + bounded_required_text(payload, "text", MAX_RUNTIME_TEXT_CHARS, "runtime text")?; + AcpxRuntimeEventKind::TextDelta + } + "thinking" => { + bounded_required_text(payload, "text", MAX_RUNTIME_TEXT_CHARS, "runtime thought")?; + AcpxRuntimeEventKind::Thinking + } + "plan" => { + validate_plan(payload)?; + AcpxRuntimeEventKind::Plan + } + "status" => { + bounded_optional_text(payload, "tag", 160, "runtime status tag")?; + bounded_optional_text(payload, "text", 4_000, "runtime status text")?; + AcpxRuntimeEventKind::Status + } + "tool_call" => { + bounded_optional_text(payload, "toolCallId", 240, "runtime tool call id")?; + bounded_optional_text(payload, "title", 4_000, "runtime tool title")?; + bounded_optional_text(payload, "status", 100, "runtime tool status")?; + if let Some(locations) = optional_array(payload, "locations", "runtime tool locations")? + { + if locations.len() > 2_000 { + return Err(LocalRunnerError::invalid( + "ACPX runtime tool locations exceed their bound", + )); + } + for location in locations { + if !location.is_object() { + return Err(LocalRunnerError::invalid( + "ACPX runtime tool location must be an object", + )); + } + bounded_optional_text(location, "path", 4_000, "runtime tool path")?; + } + } + AcpxRuntimeEventKind::ToolCall + } + "semantic_result" => { + required_id(payload, "callId", "semantic result call")?; + required_id(payload, "operationId", "semantic result operation")?; + if !payload.get("result").is_some_and(Value::is_object) { + return Err(LocalRunnerError::invalid( + "ACPX semantic result must contain an object result", + )); + } + AcpxRuntimeEventKind::SemanticResult + } + "provider_notice" => { + bounded_nonempty_text(payload, "category", 160, "provider notice category")?; + bounded_nonempty_text(payload, "summary", 4_000, "provider notice summary")?; + AcpxRuntimeEventKind::ProviderNotice + } + "error" => { + bounded_optional_text(payload, "code", 160, "runtime error code")?; + bounded_nonempty_text(payload, "message", 8_192, "runtime error message")?; + AcpxRuntimeEventKind::Error + } + "done" => { + bounded_optional_text(payload, "stopReason", 160, "runtime stop reason")?; + AcpxRuntimeEventKind::Done + } + _ => { + return Err(LocalRunnerError::invalid( + "ACPX runtime event type is not admitted", + )) + } + }; + Ok(AcpxEventPayload::Runtime { + kind, + payload: sanitize_value(payload), + }) +} + +fn validate_plan(payload: &Value) -> Result<(), LocalRunnerError> { + let entries = payload + .get("entries") + .and_then(Value::as_array) + .ok_or_else(|| LocalRunnerError::invalid("ACPX runtime plan must contain entries"))?; + if entries.len() > 256 { + return Err(LocalRunnerError::invalid( + "ACPX runtime plan exceeds 256 entries", + )); + } + for entry in entries { + bounded_nonempty_text(entry, "content", 4_000, "runtime plan content")?; + if !matches!( + entry.get("status").and_then(Value::as_str), + Some("pending" | "in_progress" | "completed") + ) { + return Err(LocalRunnerError::invalid( + "ACPX runtime plan contains an unsupported status", + )); + } + } + Ok(()) +} + +fn validate_question_set(value: &Value) -> Result<(), LocalRunnerError> { + let schema: Value = serde_json::from_str(include_str!( + "../../../../protocol/schemas/question-set.schema.json" + )) + .map_err(|_| LocalRunnerError::invalid("embedded question-set schema is invalid"))?; + let validator = jsonschema::validator_for(&schema) + .map_err(|_| LocalRunnerError::invalid("embedded question-set schema cannot compile"))?; + if !validator.is_valid(value) { + return Err(LocalRunnerError::invalid( + "ACPX input request failed the Paperclip question-set schema", + )); + } + let mut ids = BTreeSet::new(); + for question in value + .get("questions") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let id = question + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| LocalRunnerError::invalid("ACPX input question omitted its id"))?; + if !ids.insert(id) { + return Err(LocalRunnerError::invalid( + "ACPX input question ids must be unique", + )); + } + let mut option_ids = BTreeSet::new(); + for option in question + .get("options") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let option_id = option + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| LocalRunnerError::invalid("ACPX input option omitted its id"))?; + if !option_ids.insert(option_id) { + return Err(LocalRunnerError::invalid( + "ACPX input option ids must be unique within one question", + )); + } + } + } + Ok(()) +} + +fn required_id(value: &Value, key: &str, label: &str) -> Result { + let id = value + .get(key) + .and_then(Value::as_str) + .ok_or_else(|| LocalRunnerError::invalid(format!("ACPX {label} omitted its identity")))?; + if id.is_empty() || id.chars().count() > MAX_ID_CHARS || id.chars().any(char::is_control) { + return Err(LocalRunnerError::invalid(format!( + "ACPX {label} identity is invalid" + ))); + } + Ok(id.to_owned()) +} + +fn bounded_required_text<'a>( + value: &'a Value, + key: &str, + max_chars: usize, + label: &str, +) -> Result<&'a str, LocalRunnerError> { + let text = value + .get(key) + .and_then(Value::as_str) + .ok_or_else(|| LocalRunnerError::invalid(format!("ACPX {label} is required")))?; + if text.chars().count() > max_chars { + return Err(LocalRunnerError::invalid(format!( + "ACPX {label} exceeds its bound" + ))); + } + Ok(text) +} + +fn bounded_nonempty_text<'a>( + value: &'a Value, + key: &str, + max_chars: usize, + label: &str, +) -> Result<&'a str, LocalRunnerError> { + let text = bounded_required_text(value, key, max_chars, label)?; + if text.trim().is_empty() { + return Err(LocalRunnerError::invalid(format!( + "ACPX {label} must not be empty" + ))); + } + Ok(text) +} + +fn bounded_optional_text( + value: &Value, + key: &str, + max_chars: usize, + label: &str, +) -> Result, LocalRunnerError> { + let Some(field) = value.get(key) else { + return Ok(None); + }; + if field.is_null() { + return Ok(None); + } + let text = field + .as_str() + .ok_or_else(|| LocalRunnerError::invalid(format!("ACPX {label} must be text")))?; + if text.chars().count() > max_chars { + return Err(LocalRunnerError::invalid(format!( + "ACPX {label} exceeds its bound" + ))); + } + Ok(Some(text.to_owned())) +} + +fn optional_object( + value: &Value, + key: &str, + label: &str, +) -> Result, LocalRunnerError> { + let Some(field) = value.get(key) else { + return Ok(None); + }; + if field.is_null() { + return Ok(None); + } + if !field.is_object() { + return Err(LocalRunnerError::invalid(format!( + "ACPX {label} must be an object" + ))); + } + Ok(Some(field.clone())) +} + +fn optional_array<'a>( + value: &'a Value, + key: &str, + label: &str, +) -> Result>, LocalRunnerError> { + let Some(field) = value.get(key) else { + return Ok(None); + }; + if field.is_null() { + return Ok(None); + } + field + .as_array() + .map(Some) + .ok_or_else(|| LocalRunnerError::invalid(format!("ACPX {label} must be an array"))) +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs index 2afcc4b45c..1c1f937281 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs @@ -1,5 +1,6 @@ #![forbid(unsafe_code)] +pub mod acpx_event_payload; pub mod acpx_event_scope; pub mod acpx_sidecar_transport; pub mod codex_provider; diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_event_payload.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_event_payload.rs new file mode 100644 index 0000000000..e2835b47b9 --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_event_payload.rs @@ -0,0 +1,332 @@ +use paperclip_runner_core::acpx_event_payload::{ + decode_acpx_event, AcpxEventPayload, AcpxRuntimeEventKind, AcpxTurnStatus, +}; +use paperclip_runner_core::acpx_event_scope::AcpxEventScope; +use paperclip_runner_core::acpx_sidecar_transport::AcpxSidecarEvent; +use paperclip_runner_core::generated_acpx_sidecar_contract::GeneratedAcpxSidecarEventType; +use serde_json::{json, Value}; + +fn event(event_type: GeneratedAcpxSidecarEventType, payload: Value) -> AcpxSidecarEvent { + AcpxSidecarEvent { + sequence: 1, + event_type, + run_id: Some("run-1".to_owned()), + turn_id: Some("turn-1".to_owned()), + payload, + } +} + +fn active_scope() -> AcpxEventScope { + let mut scope = AcpxEventScope::new("run-1").unwrap(); + scope.bind_turn("turn-1").unwrap(); + scope +} + +#[test] +fn decodes_every_admitted_runtime_event_shape() { + let scope = active_scope(); + let cases = [ + ( + json!({"type": "text_delta", "text": "Hello"}), + AcpxRuntimeEventKind::TextDelta, + ), + ( + json!({"type": "thinking", "text": "Inspect"}), + AcpxRuntimeEventKind::Thinking, + ), + ( + json!({"type": "plan", "entries": [{"content": "Inspect", "status": "pending"}]}), + AcpxRuntimeEventKind::Plan, + ), + ( + json!({"type": "status", "tag": "usage_update", "text": "Working"}), + AcpxRuntimeEventKind::Status, + ), + ( + json!({"type": "tool_call", "toolCallId": "tool-1", "status": "completed", "locations": []}), + AcpxRuntimeEventKind::ToolCall, + ), + ( + json!({"type": "semantic_result", "callId": "call-1", "operationId": "paperclip_finish", "result": {"ok": true}}), + AcpxRuntimeEventKind::SemanticResult, + ), + ( + json!({"type": "provider_notice", "category": "provider_update", "summary": "Update"}), + AcpxRuntimeEventKind::ProviderNotice, + ), + ( + json!({"type": "error", "code": "provider_error", "message": "Failed"}), + AcpxRuntimeEventKind::Error, + ), + ( + json!({"type": "done", "stopReason": "end_turn"}), + AcpxRuntimeEventKind::Done, + ), + ]; + + for (payload, expected_kind) in cases { + let decoded = decode_acpx_event( + &scope, + &event(GeneratedAcpxSidecarEventType::RuntimeEvent, payload), + ) + .unwrap(); + assert!(matches!( + decoded, + AcpxEventPayload::Runtime { kind, .. } if kind == expected_kind + )); + } +} + +#[test] +fn rejects_unclassified_and_malformed_runtime_payloads() { + let scope = active_scope(); + for payload in [ + json!({"type": "future_event"}), + json!({"type": "text_delta", "text": "x".repeat(65_537)}), + json!({"type": "plan", "entries": [{"content": "Inspect", "status": "blocked"}]}), + json!({"type": "semantic_result", "callId": "call-1", "operationId": "finish", "result": "not-an-object"}), + json!({"type": "tool_call", "locations": vec![json!({}); 2_001]}), + json!({"type": "tool_call", "locations": {"path": "file.txt"}}), + json!({"type": "provider_notice", "category": "", "summary": "Update"}), + ] { + assert!(decode_acpx_event( + &scope, + &event(GeneratedAcpxSidecarEventType::RuntimeEvent, payload), + ) + .is_err()); + } +} + +#[test] +fn decodes_tool_and_permission_requests_after_scope_validation() { + let scope = active_scope(); + let tool = decode_acpx_event( + &scope, + &event( + GeneratedAcpxSidecarEventType::RuntimeToolCalled, + json!({ + "callId": "call-1", + "operationId": "get_issue", + "input": {"issueId": "issue-1", "apiToken": "secret-value"}, + }), + ), + ) + .unwrap(); + assert!(matches!( + tool, + AcpxEventPayload::ToolCalled { call_id, operation_id, input } + if call_id == "call-1" + && operation_id == "get_issue" + && input["apiToken"] == "[REDACTED]" + )); + + let permission = decode_acpx_event( + &scope, + &event( + GeneratedAcpxSidecarEventType::RuntimePermissionRequested, + json!({ + "requestId": "permission-1", + "kind": "write", + "title": "Authorization: Bearer permission-secret" + }), + ), + ) + .unwrap(); + assert!(matches!( + permission, + AcpxEventPayload::PermissionRequested { request_id, kind, title, details } + if request_id == "permission-1" + && kind == "write" + && title.contains("REDACTED") + && !title.contains("permission-secret") + && details["title"].as_str().is_some_and(|value| value.contains("REDACTED")) + )); +} + +#[test] +fn validates_question_sets_and_rejects_duplicate_question_ids() { + let scope = active_scope(); + let question = json!({ + "id": "choice", + "prompt": "Choose one", + "required": true, + "answerMode": "single_select", + "options": [{"id": "one", "label": "One"}], + }); + let valid = decode_acpx_event( + &scope, + &event( + GeneratedAcpxSidecarEventType::RuntimeInputRequested, + json!({ + "requestId": "input-1", + "questionSet": {"schema": "paperclip.question_set.v1", "questions": [question.clone()]}, + "origin": {"provider": "codex"}, + }), + ), + ) + .unwrap(); + assert!(matches!( + valid, + AcpxEventPayload::InputRequested { request_id, .. } if request_id == "input-1" + )); + + let sensitive = decode_acpx_event( + &scope, + &event( + GeneratedAcpxSidecarEventType::RuntimeInputRequested, + json!({ + "requestId": "input-sensitive", + "questionSet": { + "schema": "paperclip.question_set.v1", + "title": "token=question-set-secret", + "description": "authorization: question-description-secret", + "submitLabel": "secret=submit-label-secret", + "questions": [{ + "id": "token=stable-question-id", + "header": "token=question-header-secret", + "prompt": "password=question-prompt-secret", + "helpText": "api_key=question-help-secret", + "required": true, + "answerMode": "single_select", + "options": [{ + "id": "token=stable-option-id", + "label": "bearer option-label-secret", + "description": "ticket=option-description-secret" + }], + "customAnswer": { + "enabled": true, + "label": "token=custom-label-secret", + "placeholder": "secret=custom-placeholder-secret" + }, + "textValidation": {"pattern": "^token=protocol-value$"} + }] + } + }), + ), + ) + .unwrap(); + let AcpxEventPayload::InputRequested { question_set, .. } = sensitive else { + panic!("input request must retain its question set"); + }; + let retained = question_set.to_string(); + assert!(!retained.contains("question-set-secret")); + assert!(!retained.contains("question-prompt-secret")); + assert!(!retained.contains("option-label-secret")); + assert!(!retained.contains("custom-placeholder-secret")); + assert_eq!( + question_set["questions"][0]["id"], + "token=stable-question-id" + ); + assert_eq!( + question_set["questions"][0]["options"][0]["id"], + "token=stable-option-id" + ); + assert_eq!( + question_set["questions"][0]["textValidation"]["pattern"], + "^token=protocol-value$" + ); + + let duplicate = event( + GeneratedAcpxSidecarEventType::RuntimeInputRequested, + json!({ + "requestId": "input-2", + "questionSet": {"schema": "paperclip.question_set.v1", "questions": [question.clone(), question]}, + }), + ); + assert!(decode_acpx_event(&scope, &duplicate) + .unwrap_err() + .to_string() + .contains("must be unique")); + + let duplicate_options = event( + GeneratedAcpxSidecarEventType::RuntimeInputRequested, + json!({ + "requestId": "input-3", + "questionSet": { + "schema": "paperclip.question_set.v1", + "questions": [{ + "id": "choice", + "prompt": "Choose one", + "required": true, + "answerMode": "single_select", + "options": [ + {"id": "same", "label": "One"}, + {"id": "same", "label": "Two"}, + ], + }], + }, + }), + ); + assert!(decode_acpx_event(&scope, &duplicate_options) + .unwrap_err() + .to_string() + .contains("option ids must be unique")); +} + +#[test] +fn decodes_terminal_and_redacts_diagnostic_payloads() { + let scope = active_scope(); + let terminal = decode_acpx_event( + &scope, + &event( + GeneratedAcpxSidecarEventType::RuntimeTurnTerminal, + json!({"status": "cancelled", "error": {"authorization": "Bearer secret"}}), + ), + ) + .unwrap(); + assert!(matches!( + terminal, + AcpxEventPayload::TurnTerminal { status: AcpxTurnStatus::Cancelled, error: Some(error) } + if error["authorization"] == "[REDACTED]" + )); + + let no_error = decode_acpx_event( + &scope, + &event( + GeneratedAcpxSidecarEventType::RuntimeTurnTerminal, + json!({"status": "completed", "error": null}), + ), + ) + .unwrap(); + assert!(matches!( + no_error, + AcpxEventPayload::TurnTerminal { + status: AcpxTurnStatus::Completed, + error: None + } + )); + + let mut diagnostic = event( + GeneratedAcpxSidecarEventType::RuntimeDiagnostic, + json!({"code": "provider_warning", "message": "Authorization: Bearer secret"}), + ); + diagnostic.run_id = None; + diagnostic.turn_id = None; + let decoded = decode_acpx_event(&scope, &diagnostic).unwrap(); + assert!(matches!( + decoded, + AcpxEventPayload::Diagnostic { message, .. } + if message == "[REDACTED diagnostic containing a sensitive marker]" + )); +} + +#[test] +fn rejects_payloads_before_decoding_when_scope_or_size_is_invalid() { + let scope = active_scope(); + let mut wrong_run = event( + GeneratedAcpxSidecarEventType::RuntimeToolCalled, + json!({"malformed": true}), + ); + wrong_run.run_id = Some("run-2".to_owned()); + let error = decode_acpx_event(&scope, &wrong_run).unwrap_err(); + assert!(error.to_string().contains("stale run")); + + let mut oversized = event( + GeneratedAcpxSidecarEventType::RuntimeProcess, + json!({"output": "x".repeat(256 * 1024)}), + ); + oversized.run_id = None; + oversized.turn_id = None; + let error = decode_acpx_event(&scope, &oversized).unwrap_err(); + assert!(error.to_string().contains("256 KiB")); +}