From 7bb6cebeae727a16c205bb80b5c2b9e92ea6b5fa Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:52:41 -0500 Subject: [PATCH] feat(runner): normalize ACPX provider events (#12416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The Rust runner now admits ACPX sidecar frames only after transport, scope, and payload validation > - Valid payloads still contain provider-native runtime event shapes > - Provider-native shapes must not cross the PRP boundary or diverge from direct Codex task activity > - This pull request maps the display-safe runtime subset into existing provider-neutral event families > - Stateful semantic-result, terminal, and reasoning-deduplication behavior remains reserved for the later provider adapter > - The benefit is a reviewable normalization boundary without selecting ACPX in production ## Linked Issues or Issue Description Refs #12415 Refs #12414 ## What Changed - Normalize validated ACPX text, reasoning, plan, status, tool, notice, and error updates into existing PRP activity families. - Keep reasoning contents private while preserving a reasoning activity boundary. - Map plan entries, usage, review-mode status, and tool lifecycle into bounded canonical payloads. - Generate one shared ACPX sidecar event/classification contract for TypeScript and Rust, with ASCII-only classification parity and bounded kind/title fields. - Preserve authoritative tool-call identity and classification even when the aggregate native event exceeds the generic frame budget. - Resolve display-only tool targets within the workspace under the provider host's path semantics; reject raw, unmarked, absolute, parent-traversing, URL-shaped, and unsafe drive-shaped values. - Redact and digest retained tool output with the existing durable policy. - Ignore provider inventory status updates that have no user-facing activity. - Leave semantic results and `done` updates to the stateful adapter so durable receipts and terminal events are not duplicated. - Add cross-language and Rust coverage for every mapping family, classifier parity, privacy, unsafe paths, redaction, bounded titles/kinds, and oversized tool-call preservation. - Document the normalization and display-path authorization boundary. - Do not change dependencies, lockfiles, workflows, runnerd selection, server behavior, UI, or migrations. ## Verification - Replay base: `fe2ddfad2b5cb604b3244492257db0e6aec11d47` (`master` after #12415 merged). - Exact replay head: `b7f5588bf6e8e0f946ffa8869a3204c344808418`. - Stable patch ID: `fda62c7c20afc5ef9c75d07f163a466db82efabd`, identical to the prepared six-commit delta plus the focused oversized-tool-call review fix. - The exact delta is 14 files, 1,714 additions, and 60 deletions, all in `packages/paperclip-runner`; it contains no lockfile, workflow, server, UI, or migration change. - Focused protocol-generation, TypeScript sidecar, Rust normalization, package, repository, security, and Greptile checks: **PASSED** on the replayed exact head. Full CI run `33361437835` completed 23/23 jobs successfully, Greptile is exact-head 5/5, all security checks pass, and no review threads remain unresolved. - No local test result is claimed. GitHub Actions is the authoritative verification environment for this replayed revision. ## Risks - This code controls what provider activity is retained and displayed, so malformed native values must not bypass the earlier decoder. - The function contract requires an already scope-checked and payload-validated runtime event; the future adapter must preserve that order. - Tool classification and identity are security-relevant authorization inputs and remain explicit even when optional aggregate display data is dropped for bounds. - Repeated reasoning chunks require stateful suppression. This mapper exposes a privacy-safe start boundary and the later adapter owns per-turn deduplication. - Semantic results and terminal authority intentionally produce no activity here; the later adapter must commit them through the durable operational paths. - The package exports new generated and Rust normalization surfaces, but no production path invokes them in this pull request. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex with GPT-5.6, agentic reasoning, tool use, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- packages/paperclip-runner/README.md | 13 + .../runner-core/src/acpx_event_payload.rs | 34 +- .../src/generated_acpx_sidecar_contract.rs | 23 + .../crates/runner-core/src/provider_events.rs | 430 +++++++++++++++++ .../runner-core/tests/acpx_event_payload.rs | 72 +++ .../runner-core/tests/acpx_provider_events.rs | 454 ++++++++++++++++++ .../generate-acpx-sidecar-contract.mjs | 40 +- .../src/cli/acpx-runtime-sidecar.ts | 42 +- .../src/cli/acpx-sidecar-locations.test.ts | 282 +++++++++-- .../src/cli/acpx-sidecar-locations.ts | 102 +++- .../acpx/generated-sidecar-contract.test.ts | 24 + .../acpx/generated-sidecar-contract.ts | 38 ++ .../src/drivers/acpx/sidecar-protocol.test.ts | 93 ++++ .../src/drivers/acpx/sidecar-protocol.ts | 127 ++++- 14 files changed, 1714 insertions(+), 60 deletions(-) create mode 100644 packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_events.rs create mode 100644 packages/paperclip-runner/src/drivers/acpx/generated-sidecar-contract.test.ts diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 4d73ff93e6..a055804283 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -70,6 +70,19 @@ 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. +Validated ACPX runtime events normalize into the same provider-neutral activity +families as the direct Codex transport. Reasoning contents stay private. Tool +targets are resolved within the workspace under the provider host's path +semantics and receive a versioned sidecar boundary marker before becoming +bounded, display-only PRP safe paths. Raw or unmarked provider locations fail +closed. URI-scheme and Windows drive-shaped values require a separate sidecar +attestation backed by an existing in-workspace entry or, for a not-yet-created +edit target, an existing in-workspace parent. This preserves real POSIX colon +filenames without treating arbitrary URI text as a path. Windows separators +are canonicalized, and consumers must not reinterpret the display value as +file-access authority. Operational semantic-result and terminal events remain +reserved for the stateful adapter rather than being duplicated. + 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 index 8584d4ff21..d420acf8b5 100644 --- 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 @@ -5,7 +5,9 @@ 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::generated_acpx_sidecar_contract::{ + classify_generated_acpx_tool_operation, GeneratedAcpxSidecarEventType, +}; use crate::local_runner::LocalRunnerError; const MAX_EVENT_PAYLOAD_BYTES: usize = 256 * 1024; @@ -37,6 +39,7 @@ pub enum AcpxTurnStatus { pub enum AcpxEventPayload { Runtime { kind: AcpxRuntimeEventKind, + tool_operation: Option<&'static str>, payload: Value, }, PermissionRequested { @@ -213,6 +216,7 @@ fn decode_runtime_event(payload: &Value) -> Result { bounded_required_text(payload, "text", MAX_RUNTIME_TEXT_CHARS, "runtime text")?; @@ -233,7 +237,7 @@ fn decode_runtime_event(payload: &Value) -> Result { bounded_optional_text(payload, "toolCallId", 240, "runtime tool call id")?; - bounded_optional_text(payload, "title", 4_000, "runtime tool title")?; + let title = 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")? { @@ -251,6 +255,23 @@ fn decode_runtime_event(payload: &Value) -> Result { + bounded_optional_text(payload, "kind", 4_000, "runtime tool kind")?; + admitted_tool_operation(operation).ok_or_else(|| { + LocalRunnerError::invalid("ACPX runtime tool operation is not admitted") + })? + } + Some(_) => { + return Err(LocalRunnerError::invalid( + "ACPX runtime tool operation must be text", + )) + } + None => classify_generated_acpx_tool_operation( + payload.get("kind").and_then(Value::as_str).unwrap_or(""), + title.as_deref().unwrap_or(""), + ), + }); AcpxRuntimeEventKind::ToolCall } "semantic_result" => { @@ -285,10 +306,19 @@ fn decode_runtime_event(payload: &Value) -> Result Option<&'static str> { + if operation == "unknown" { + return Some("unknown"); + } + let classified = classify_generated_acpx_tool_operation(operation, ""); + (classified == operation).then_some(classified) +} + fn validate_plan(payload: &Value) -> Result<(), LocalRunnerError> { let entries = payload .get("entries") diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_sidecar_contract.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_sidecar_contract.rs index 46f8fc0b18..044976a066 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_sidecar_contract.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_sidecar_contract.rs @@ -2,6 +2,29 @@ pub const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION: u64 = 2; +pub const GENERATED_ACPX_TOOL_OPERATION_PRECEDENCE: &[(&str, &[&str])] = &[ + ("edit", &["edit", "write", "patch"]), + ("read", &["read"]), + ("search", &["search", "grep", "find"]), + ("list", &["list", "glob"]), +]; + +pub fn classify_generated_acpx_tool_operation(kind: &str, title: &str) -> &'static str { + let kind = kind.to_ascii_lowercase(); + let title = title.to_ascii_lowercase(); + let candidate = if kind.is_empty() { &title } else { &kind }; + for (operation, tokens) in GENERATED_ACPX_TOOL_OPERATION_PRECEDENCE { + if tokens.iter().any(|token| candidate.contains(token)) { + return operation; + } + } + if candidate.is_empty() { + "unknown" + } else { + "execute" + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum GeneratedAcpxSidecarCommand { Initialize, diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs index 801763b8bc..844dd22867 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs @@ -1,6 +1,7 @@ use serde_json::{json, Value}; use sha2::{Digest, Sha256}; +use crate::acpx_event_payload::AcpxRuntimeEventKind; use crate::durable::{redact_text, EventPriority}; const MAX_TEXT_CHARS: usize = 4_000; @@ -344,10 +345,439 @@ pub fn normalize_codex_notification(method: &str, params: &Value) -> Vec, + fallback_item_id: &str, + turn_id: &str, + provider_requests: u64, +) -> Vec { + let item_id = stable_id( + match kind { + AcpxRuntimeEventKind::ToolCall => string(payload.get("toolCallId")), + AcpxRuntimeEventKind::Plan => turn_id, + _ => string(payload.get("messageId")), + }, + fallback_item_id, + ); + match kind { + AcpxRuntimeEventKind::TextDelta => vec![NormalizedProviderEvent { + event_type: "item.delta".to_owned(), + priority: EventPriority::P2, + payload: json!({ + "provider": "acpx", + "itemId": item_id, + "kind": "agentMessage", + "channel": "progress", + "providerMethod": "runtime.event", + "text": bounded_text(string(payload.get("text")), MAX_TEXT_CHARS), + }), + }], + AcpxRuntimeEventKind::Thinking => vec![NormalizedProviderEvent { + event_type: "item.started".to_owned(), + priority: EventPriority::P2, + payload: json!({ + "provider": "acpx", + "itemId": item_id, + "kind": "reasoning", + "status": "running", + "channel": "detail", + "text": Value::Null, + }), + }], + AcpxRuntimeEventKind::Plan => normalize_acpx_plan(payload, &item_id), + AcpxRuntimeEventKind::Status => { + normalize_acpx_status(payload, &item_id, turn_id, provider_requests) + } + AcpxRuntimeEventKind::ToolCall => { + normalize_acpx_tool_call(payload, &item_id, tool_operation.unwrap_or("unknown")) + } + AcpxRuntimeEventKind::ProviderNotice => vec![acpx_notice( + &item_id, + string(payload.get("severity")), + string(payload.get("category")), + string(payload.get("summary")), + false, + )], + AcpxRuntimeEventKind::Error => vec![acpx_notice( + &item_id, + "error", + string(payload.get("code")), + string(payload.get("message")), + true, + )], + AcpxRuntimeEventKind::SemanticResult | AcpxRuntimeEventKind::Done => Vec::new(), + } +} + +fn normalize_acpx_plan(payload: &Value, plan_id: &str) -> Vec { + let steps = payload + .get("entries") + .and_then(Value::as_array) + .into_iter() + .flatten() + .take(256) + .enumerate() + .filter_map(|(index, entry)| { + let body = bounded_text(string(entry.get("content")), MAX_TEXT_CHARS); + if body.trim().is_empty() { + return None; + } + Some(json!({ + "stepId": format!("step-{}", index + 1), + "body": body, + "status": match string(entry.get("status")) { + "inProgress" | "in_progress" => "in_progress", + "completed" => "completed", + "blocked" | "failed" | "error" => "blocked", + _ => "pending", + }, + })) + }) + .collect::>(); + let complete = !steps.is_empty() + && steps + .iter() + .all(|step| step.get("status").and_then(Value::as_str) == Some("completed")); + vec![NormalizedProviderEvent { + event_type: "plan.updated".to_owned(), + priority: EventPriority::P1, + payload: json!({ + "schema": "paperclip.plan.updated.v1", + "planId": plan_id, + "revision": 1, + "explanation": Value::Null, + "steps": steps, + "complete": complete, + "syncStatus": "not_applicable", + "documentRevision": Value::Null, + }), + }] +} + +fn normalize_acpx_status( + payload: &Value, + item_id: &str, + turn_id: &str, + provider_requests: u64, +) -> Vec { + let tag = string(payload.get("tag")); + if tag == "usage_update" { + let breakdown = payload.get("breakdown").unwrap_or(&Value::Null); + let usage = json!({ + "inputTokens": nonnegative_u64(breakdown.get("inputTokens")), + "outputTokens": nonnegative_u64(breakdown.get("outputTokens")), + "cacheReadTokens": nonnegative_u64( + breakdown + .get("cachedReadTokens") + .or_else(|| breakdown.get("cacheReadTokens")), + ), + "cacheWriteTokens": nonnegative_u64( + breakdown + .get("cachedWriteTokens") + .or_else(|| breakdown.get("cacheWriteTokens")), + ), + "activeSeconds": 0.0, + "requests": provider_requests, + "providerCostUsd": payload + .pointer("/cost/amount") + .and_then(Value::as_f64) + .filter(|value| value.is_finite() && *value >= 0.0) + .unwrap_or(0.0), + }); + return vec![NormalizedProviderEvent { + event_type: "usage.reported".to_owned(), + priority: EventPriority::P0, + payload: json!({ + "provider": "acpx", + "model": payload + .get("model") + .and_then(Value::as_str) + .map(|value| bounded_text(value, 240)), + "providerSessionId": Value::Null, + "providerRequestId": Value::Null, + "cumulative": usage, + "runDelta": usage, + }), + }]; + } + if tag == "current_mode_update" { + let status = string(payload.get("text")); + return vec![NormalizedProviderEvent { + event_type: "review.mode.changed".to_owned(), + priority: EventPriority::P1, + payload: json!({ + "schema": "paperclip.review.mode_changed.v1", + "reviewId": stable_id(turn_id, item_id), + "state": if status.to_ascii_lowercase().contains("review") + || status.to_ascii_lowercase().contains("plan") + { + "entered" + } else { + "exited" + }, + "scope": if status.is_empty() { + Value::Null + } else { + Value::String(bounded_text(status, MAX_TEXT_CHARS)) + }, + }), + }]; + } + if matches!( + tag, + "available_commands_update" | "config_option_update" | "session_info_update" + ) { + return Vec::new(); + } + vec![acpx_notice( + item_id, + "info", + tag, + string(payload.get("text")), + false, + )] +} + +fn normalize_acpx_tool_call( + payload: &Value, + item_id: &str, + operation: &str, +) -> Vec { + let native_status = string(payload.get("status")); + let status = provider_status(native_status, native_status == "completed"); + let terminal = status != "running"; + let raw_title = string(payload.get("title")); + let title = bounded_text(raw_title, 240); + let output = match payload.get("rawOutput").or_else(|| payload.get("output")) { + Some(Value::String(value)) => value.clone(), + Some(value) => serde_json::to_string(value).unwrap_or_default(), + None => String::new(), + }; + let mut normalized = json!({ + "schema": "paperclip.tool.execution.v1", + "executionId": item_id, + "transport": "builtin", + "operation": operation, + "name": if title.is_empty() { Value::Null } else { Value::String(title) }, + "target": safe_acpx_location(payload.pointer("/locations/0"), operation == "edit"), + "namespace": Value::Null, + "readOnly": matches!(operation, "read" | "search" | "list"), + "status": status, + "durationMs": Value::Null, + "exitCode": Value::Null, + "progress": if terminal { + Value::Null + } else { + payload + .get("text") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(|value| Value::String(bounded_text(value, MAX_TEXT_CHARS))) + .unwrap_or(Value::Null) + }, + }); + if let (Some(object), Value::Object(output)) = + (normalized.as_object_mut(), bounded_output(&output)) + { + object.extend(output); + } + vec![NormalizedProviderEvent { + event_type: if terminal { + "tool.execution.completed" + } else if string(payload.get("tag")) == "tool_call" { + "tool.execution.started" + } else { + "tool.execution.progressed" + } + .to_owned(), + priority: if terminal { + EventPriority::P1 + } else { + EventPriority::P2 + }, + payload: normalized, + }] +} + +fn acpx_notice( + item_id: &str, + severity: &str, + category: &str, + summary: &str, + user_actionable: bool, +) -> NormalizedProviderEvent { + NormalizedProviderEvent { + event_type: "provider.notice.recorded".to_owned(), + priority: if severity == "error" { + EventPriority::P0 + } else { + EventPriority::P1 + }, + payload: json!({ + "schema": "paperclip.provider.notice.v1", + "noticeId": item_id, + "severity": match severity { + "error" => "error", + "warning" => "warning", + _ => "info", + }, + "category": stable_id(category, "acpx_provider_update"), + "scope": "turn", + "recoverable": severity != "error", + "userActionable": user_actionable, + "summary": if summary.trim().is_empty() { + "The qualified ACP agent emitted a provider update.".to_owned() + } else { + bounded_text(summary, MAX_TEXT_CHARS) + }, + }), + } +} + +fn nonnegative_u64(value: Option<&Value>) -> u64 { + value.and_then(Value::as_u64).unwrap_or(0) +} + +fn safe_acpx_location(value: Option<&Value>, allow_create_target: bool) -> Value { + let Some(value) = value else { + return Value::Null; + }; + // Only the pinned sidecar may attest that it resolved this value within + // the workspace under the provider host's path semantics. Ambiguous URI + // scheme, Windows drive, and leading-backslash shapes additionally require + // proof that the sidecar resolved an existing workspace entry as POSIX filename data, + // or that an edit's not-yet-created target has an in-workspace parent. + if value.get("pathBoundary").and_then(Value::as_str) + != Some("paperclip.workspace_relative_display.v2") + { + return Value::Null; + } + let raw_path = value + .get("path") + .and_then(Value::as_str) + .unwrap_or_default(); + if raw_path.is_empty() + || raw_path.starts_with('/') + || raw_path.contains('\0') + || raw_path.split('/').any(|segment| segment == "..") + { + return Value::Null; + } + let requires_entry_attestation = raw_path.starts_with('\\') + || has_windows_drive_prefix(raw_path) + || has_rfc_uri_scheme_prefix(raw_path); + if requires_entry_attestation { + let attestation = value.get("pathAttestation").and_then(Value::as_str); + if attestation != Some("paperclip.workspace_entry.v1") + && !(allow_create_target && attestation == Some("paperclip.workspace_create_target.v1")) + { + return Value::Null; + } + } + Value::String(raw_path.chars().take(4_000).collect()) +} + +fn has_windows_drive_prefix(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' +} + +fn has_rfc_uri_scheme_prefix(value: &str) -> bool { + let Some((scheme, _rest)) = value.split_once(':') else { + return false; + }; + let mut characters = scheme.chars(); + characters + .next() + .is_some_and(|first| first.is_ascii_alphabetic()) + && characters.all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.') + }) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn enforces_the_declared_safe_path_contract() { + for location in [ + "/absolute/path", + r"\server\share", + r"C:\secret", + "../secret", + "src/../../secret", + r"foo\..\bar", + r"https:\host\secret", + "https://example.test/private", + "https:example.test/private", + "file:secret.txt", + "bad\0name", + ] { + assert_eq!( + safe_acpx_location(Some(&json!({"path": location})), false), + Value::Null + ); + } + assert_eq!( + safe_acpx_location(Some(&json!({"uri": "https://example.test/private"})), false,), + Value::Null, + ); + for location in [ + "/absolute/path", + "../secret", + "src/../../secret", + "custom:payload", + "urn:isbn:9780131103627", + r"C:Users\alice\secret.txt", + "D:relative.txt", + "bad\0name", + ] { + assert_eq!( + safe_acpx_location( + Some(&json!({ + "path": location, + "pathBoundary": "paperclip.workspace_relative_display.v2" + })), + false, + ), + Value::Null, + ); + } + } + + #[test] + fn preserves_valid_posix_display_characters() { + for location in [ + "src:main.rs", + "foo:bar/baz", + "src:/main.rs", + "a:/foo", + "A:b/file.txt", + r"folder\literal", + r"foo\..\bar", + "reports/100%/summary.txt", + ] { + assert_eq!( + safe_acpx_location( + Some(&json!({ + "path": location, + "pathBoundary": "paperclip.workspace_relative_display.v2", + "pathAttestation": "paperclip.workspace_entry.v1" + })), + false, + ), + Value::String(location.to_owned()), + ); + } + } + #[test] fn maps_codex_plan_without_retaining_native_envelope() { let events = normalize_codex_notification( 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 index e2835b47b9..8112fc2e32 100644 --- 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 @@ -77,6 +77,78 @@ fn decodes_every_admitted_runtime_event_shape() { } } +#[test] +fn admits_sidecar_replacement_scalars_in_tool_fields() { + let scope = active_scope(); + let kind = format!("{}\u{fffd}WRITE", "x".repeat(240)); + let decoded = decode_acpx_event( + &scope, + &event( + GeneratedAcpxSidecarEventType::RuntimeEvent, + json!({ + "type": "tool_call", + "toolCallId": "tool-\u{fffd}", + "kind": kind.clone(), + "status": "pend\u{fffd}ing", + "title": "Wri\u{fffd}te", + "locations": [], + }), + ), + ) + .expect("the sidecar's Unicode replacement values must remain admissible"); + + assert!(matches!( + decoded, + AcpxEventPayload::Runtime { + kind: AcpxRuntimeEventKind::ToolCall, + tool_operation: Some("edit"), + payload, + } if payload["toolCallId"].as_str() == Some("tool-\u{fffd}") + && payload["kind"].as_str() == Some(kind.as_str()) + && payload["status"].as_str() == Some("pend\u{fffd}ing") + )); +} + +#[test] +fn retains_full_kind_classification_from_a_bounded_sidecar_frame() { + let scope = active_scope(); + let decoded = decode_acpx_event( + &scope, + &event( + GeneratedAcpxSidecarEventType::RuntimeEvent, + json!({ + "type": "tool_call", + "toolCallId": "tool-oversized-kind", + "kind": "x".repeat(4_000), + "toolOperation": "edit", + "status": "pending", + "title": "Provider tool", + "locations": [], + }), + ), + ) + .expect("a bounded tool frame with sidecar classification must remain admissible"); + + assert!(matches!( + decoded, + AcpxEventPayload::Runtime { + kind: AcpxRuntimeEventKind::ToolCall, + tool_operation: Some("edit"), + payload, + } if payload["type"] == "tool_call" + && payload["kind"].as_str().is_some_and(|value| value.chars().count() == 4_000) + )); + + let invalid = event( + GeneratedAcpxSidecarEventType::RuntimeEvent, + json!({"type": "tool_call", "toolOperation": "write"}), + ); + assert!(decode_acpx_event(&scope, &invalid) + .unwrap_err() + .to_string() + .contains("tool operation is not admitted")); +} + #[test] fn rejects_unclassified_and_malformed_runtime_payloads() { let scope = active_scope(); diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_events.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_events.rs new file mode 100644 index 0000000000..e0a27fbee0 --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_events.rs @@ -0,0 +1,454 @@ +use paperclip_runner_core::acpx_event_payload::{ + decode_acpx_event, AcpxEventPayload, AcpxRuntimeEventKind, +}; +use paperclip_runner_core::acpx_event_scope::AcpxEventScope; +use paperclip_runner_core::acpx_sidecar_transport::AcpxSidecarEvent; +use paperclip_runner_core::durable::EventPriority; +use paperclip_runner_core::generated_acpx_sidecar_contract::{ + classify_generated_acpx_tool_operation, GeneratedAcpxSidecarEventType, +}; +use paperclip_runner_core::provider_events::normalize_acpx_runtime_event; +use serde_json::json; + +fn normalize( + kind: AcpxRuntimeEventKind, + payload: serde_json::Value, +) -> Vec { + let operation = (kind == AcpxRuntimeEventKind::ToolCall).then(|| { + classify_generated_acpx_tool_operation( + payload + .get("kind") + .and_then(serde_json::Value::as_str) + .unwrap_or(""), + payload + .get("title") + .and_then(serde_json::Value::as_str) + .unwrap_or(""), + ) + }); + normalize_acpx_runtime_event(kind, &payload, operation, "event-7", "turn-1", 3) +} + +#[test] +fn generated_tool_classification_uses_ascii_case_mapping_for_kind_and_title() { + assert_eq!( + classify_generated_acpx_tool_operation("ſearch", ""), + "execute" + ); + assert_eq!( + classify_generated_acpx_tool_operation("", "ſearch"), + "execute" + ); + assert_eq!( + classify_generated_acpx_tool_operation("SEARCH", ""), + "search" + ); + assert_eq!(classify_generated_acpx_tool_operation("", "WRITE"), "edit"); + assert_eq!( + classify_generated_acpx_tool_operation(&format!("{}WRITE", "x".repeat(240)), ""), + "edit" + ); +} + +#[test] +fn preserves_tool_operation_authority_across_payload_sanitization() { + for payload in [ + json!({ + "type":"tool_call", + "toolCallId":"tool-long-kind", + "kind":format!("{}write", "x".repeat(4_097)), + "title":"Long kind", + "status":"pending", + "locations":[{ + "path":"src:new.rs", + "pathBoundary":"paperclip.workspace_relative_display.v2", + "pathAttestation":"paperclip.workspace_create_target.v1" + }] + }), + json!({ + "type":"tool_call", + "toolCallId":"tool-multibyte-title", + "kind":"", + "title":format!("{}write", "é".repeat(2_049)), + "status":"pending", + "locations":[{ + "path":"src:new.rs", + "pathBoundary":"paperclip.workspace_relative_display.v2", + "pathAttestation":"paperclip.workspace_create_target.v1" + }] + }), + ] { + let mut scope = AcpxEventScope::new("run-1").unwrap(); + scope.bind_turn("turn-1").unwrap(); + let decoded = decode_acpx_event( + &scope, + &AcpxSidecarEvent { + sequence: 1, + event_type: GeneratedAcpxSidecarEventType::RuntimeEvent, + run_id: Some("run-1".to_owned()), + turn_id: Some("turn-1".to_owned()), + payload, + }, + ) + .unwrap(); + let AcpxEventPayload::Runtime { + kind, + tool_operation, + payload, + } = decoded + else { + panic!("runtime event must decode as runtime payload"); + }; + let events = + normalize_acpx_runtime_event(kind, &payload, tool_operation, "event-7", "turn-1", 3); + assert_eq!(events[0].payload["operation"], "edit"); + assert_eq!(events[0].payload["target"], "src:new.rs"); + assert!(events[0].payload["name"].as_str().unwrap().chars().count() <= 240); + } +} + +#[test] +fn maps_text_and_thinking_without_exposing_reasoning() { + let text = normalize( + AcpxRuntimeEventKind::TextDelta, + json!({"type":"text_delta","messageId":"message-1","text":"Working"}), + ); + assert_eq!(text[0].event_type, "item.delta"); + assert_eq!(text[0].payload["itemId"], "message-1"); + assert_eq!(text[0].payload["text"], "Working"); + assert_eq!(text[0].priority, EventPriority::P2); + + let thinking = normalize( + AcpxRuntimeEventKind::Thinking, + json!({"type":"thinking","text":"private chain of thought"}), + ); + assert_eq!(thinking[0].event_type, "item.started"); + assert_eq!(thinking[0].payload["kind"], "reasoning"); + assert_eq!(thinking[0].payload["text"], serde_json::Value::Null); + assert!(!thinking[0].payload.to_string().contains("private chain")); +} + +#[test] +fn maps_bounded_plan_and_completion_state() { + let events = normalize( + AcpxRuntimeEventKind::Plan, + json!({ + "type":"plan", + "entries":[ + {"content":"Inspect", "status":"in_progress"}, + {"content":"Ship", "status":"completed"} + ] + }), + ); + assert_eq!(events[0].event_type, "plan.updated"); + assert_eq!(events[0].payload["planId"], "turn-1"); + assert_eq!(events[0].payload["steps"][0]["status"], "in_progress"); + assert_eq!(events[0].payload["complete"], false); + assert_eq!(events[0].priority, EventPriority::P1); +} + +#[test] +fn maps_usage_and_review_status_but_ignores_inventory_updates() { + let usage = normalize( + AcpxRuntimeEventKind::Status, + json!({ + "type":"status", + "tag":"usage_update", + "breakdown":{"inputTokens":12,"outputTokens":4,"cachedReadTokens":2}, + "cost":{"amount":0.25} + }), + ); + assert_eq!(usage[0].event_type, "usage.reported"); + assert_eq!(usage[0].payload["cumulative"]["inputTokens"], 12); + assert_eq!(usage[0].payload["cumulative"]["requests"], 3); + assert_eq!(usage[0].priority, EventPriority::P0); + + let review = normalize( + AcpxRuntimeEventKind::Status, + json!({"type":"status","tag":"current_mode_update","text":"review mode"}), + ); + assert_eq!(review[0].event_type, "review.mode.changed"); + assert_eq!(review[0].payload["state"], "entered"); + + assert!(normalize( + AcpxRuntimeEventKind::Status, + json!({"type":"status","tag":"available_commands_update"}), + ) + .is_empty()); +} + +#[test] +fn maps_tool_lifecycle_and_preserves_safe_display_paths() { + let started = normalize( + AcpxRuntimeEventKind::ToolCall, + json!({ + "type":"tool_call", + "tag":"tool_call", + "toolCallId":"tool-1", + "kind":"read", + "title":"Read file", + "status":"pending", + "locations":[{ + "path":"src/main.rs", + "pathBoundary":"paperclip.workspace_relative_display.v2" + }], + "text":"Opening" + }), + ); + assert_eq!(started[0].event_type, "tool.execution.started"); + assert_eq!(started[0].payload["operation"], "read"); + assert_eq!(started[0].payload["target"], "src/main.rs"); + assert_eq!(started[0].payload["readOnly"], true); + + let completed = normalize( + AcpxRuntimeEventKind::ToolCall, + json!({ + "type":"tool_call", + "tag":"tool_call_update", + "toolCallId":"tool-1", + "kind":"read", + "status":"completed", + "locations":[{"path":"../../secret"}], + "rawOutput":"Authorization: Bearer top-secret" + }), + ); + assert_eq!(completed[0].event_type, "tool.execution.completed"); + assert_eq!(completed[0].payload["target"], serde_json::Value::Null); + assert_eq!(completed[0].payload["outputTruncated"], true); + assert!(!completed[0].payload.to_string().contains("top-secret")); + + for display_path in [ + "src:main.rs", + "foo:bar/baz", + "src:/main.rs", + "a:/foo", + "A:b/file.txt", + r"\notes.md", + r"folder\literal", + r"foo\..\bar", + "reports/100%/summary.txt", + ] { + let display = normalize( + AcpxRuntimeEventKind::ToolCall, + json!({ + "type":"tool_call", + "tag":"tool_call_update", + "toolCallId":"tool-display", + "kind":"read", + "status":"completed", + "locations":[{ + "path":display_path, + "pathBoundary":"paperclip.workspace_relative_display.v2", + "pathAttestation":"paperclip.workspace_entry.v1" + }] + }), + ); + assert_eq!(display[0].payload["target"], display_path); + } + + for unsafe_path in [ + r"\server\share", + r"C:\secret", + r"https:\host\secret", + "https://example.test/private", + "https:example.test/private", + "file:secret.txt", + "custom:payload", + "urn:isbn:9780131103627", + "tel:+15555550100", + r"C:Users\alice\secret.txt", + "D:relative.txt", + ] { + let rejected = normalize( + AcpxRuntimeEventKind::ToolCall, + json!({ + "type":"tool_call", + "tag":"tool_call_update", + "toolCallId":"tool-unsafe-display", + "kind":"read", + "status":"completed", + "locations":[{ + "path":unsafe_path, + "pathBoundary":"paperclip.workspace_relative_display.v2" + }] + }), + ); + assert_eq!(rejected[0].payload["target"], serde_json::Value::Null); + } + + let create_target = normalize( + AcpxRuntimeEventKind::ToolCall, + json!({ + "type":"tool_call", + "tag":"tool_call", + "toolCallId":"tool-create-display", + "kind":"edit", + "status":"pending", + "locations":[{ + "path":"src:new.rs", + "pathBoundary":"paperclip.workspace_relative_display.v2", + "pathAttestation":"paperclip.workspace_create_target.v1" + }] + }), + ); + assert_eq!(create_target[0].payload["target"], "src:new.rs"); + + for compound_kind in ["read_write", "search_write"] { + let compound_create_target = normalize( + AcpxRuntimeEventKind::ToolCall, + json!({ + "type":"tool_call", + "tag":"tool_call", + "toolCallId":format!("tool-{compound_kind}-display"), + "kind":compound_kind, + "status":"pending", + "locations":[{ + "path":"src:new.rs", + "pathBoundary":"paperclip.workspace_relative_display.v2", + "pathAttestation":"paperclip.workspace_create_target.v1" + }] + }), + ); + assert_eq!(compound_create_target[0].payload["operation"], "edit"); + assert_eq!(compound_create_target[0].payload["readOnly"], false); + assert_eq!(compound_create_target[0].payload["target"], "src:new.rs"); + } + + let long_edit_kind = format!("{}write", "x".repeat(240)); + let long_kind_create_target = normalize( + AcpxRuntimeEventKind::ToolCall, + json!({ + "type":"tool_call", + "tag":"tool_call", + "toolCallId":"tool-long-edit-display", + "kind":long_edit_kind, + "status":"pending", + "locations":[{ + "path":"src:new.rs", + "pathBoundary":"paperclip.workspace_relative_display.v2", + "pathAttestation":"paperclip.workspace_create_target.v1" + }] + }), + ); + assert_eq!(long_kind_create_target[0].payload["operation"], "edit"); + assert_eq!(long_kind_create_target[0].payload["readOnly"], false); + assert_eq!(long_kind_create_target[0].payload["target"], "src:new.rs"); + + let long_edit_title = format!("{}write", "x".repeat(240)); + let long_title_create_target = normalize( + AcpxRuntimeEventKind::ToolCall, + json!({ + "type":"tool_call", + "tag":"tool_call", + "toolCallId":"tool-long-title-edit-display", + "kind":"", + "title":long_edit_title, + "status":"pending", + "locations":[{ + "path":"src:new.rs", + "pathBoundary":"paperclip.workspace_relative_display.v2", + "pathAttestation":"paperclip.workspace_create_target.v1" + }] + }), + ); + assert_eq!(long_title_create_target[0].payload["operation"], "edit"); + assert_eq!(long_title_create_target[0].payload["readOnly"], false); + assert_eq!(long_title_create_target[0].payload["target"], "src:new.rs"); + + // The sidecar classifies and emits the same 4,000-character title. A + // mutation token outside that transport boundary cannot authorize a create + // target that runner-core would see only as an execute operation. + let bounded_non_edit_title = "x".repeat(4_000); + let overlong_title_create_target = normalize( + AcpxRuntimeEventKind::ToolCall, + json!({ + "type":"tool_call", + "tag":"tool_call", + "toolCallId":"tool-overlong-title-display", + "kind":"", + "title":bounded_non_edit_title, + "status":"pending", + "locations":[{ + "path":"src:new.rs", + "pathBoundary":"paperclip.workspace_relative_display.v2", + "pathAttestation":"paperclip.workspace_create_target.v1" + }] + }), + ); + assert_eq!( + overlong_title_create_target[0].payload["operation"], + "execute" + ); + assert_eq!(overlong_title_create_target[0].payload["readOnly"], false); + assert_eq!( + overlong_title_create_target[0].payload["target"], + serde_json::Value::Null + ); + + let create_attestation_on_read = normalize( + AcpxRuntimeEventKind::ToolCall, + json!({ + "type":"tool_call", + "tag":"tool_call", + "toolCallId":"tool-read-create-display", + "kind":"read", + "status":"pending", + "locations":[{ + "path":"src:new.rs", + "pathBoundary":"paperclip.workspace_relative_display.v2", + "pathAttestation":"paperclip.workspace_create_target.v1" + }] + }), + ); + assert_eq!( + create_attestation_on_read[0].payload["target"], + serde_json::Value::Null + ); + + let uri_only = normalize( + AcpxRuntimeEventKind::ToolCall, + json!({ + "type":"tool_call", + "tag":"tool_call_update", + "toolCallId":"tool-uri", + "kind":"read", + "status":"completed", + "locations":[{"uri":"https://example.test/private"}] + }), + ); + assert_eq!(uri_only[0].payload["target"], serde_json::Value::Null); +} + +#[test] +fn maps_provider_notices_and_errors_with_stable_fields() { + let notice = normalize( + AcpxRuntimeEventKind::ProviderNotice, + json!({ + "type":"provider_notice", + "severity":"warning", + "category":"rate limit", + "summary":"Retrying" + }), + ); + assert_eq!(notice[0].event_type, "provider.notice.recorded"); + assert_eq!(notice[0].payload["severity"], "warning"); + assert_eq!(notice[0].payload["category"], "rate-limit"); + + let error = normalize( + AcpxRuntimeEventKind::Error, + json!({"type":"error","code":"provider/failure","message":"Stopped"}), + ); + assert_eq!(error[0].payload["severity"], "error"); + assert_eq!(error[0].payload["userActionable"], true); + assert_eq!(error[0].priority, EventPriority::P0); +} + +#[test] +fn leaves_operational_semantic_and_terminal_events_to_the_adapter() { + assert!(normalize( + AcpxRuntimeEventKind::SemanticResult, + json!({"type":"semantic_result","callId":"call-1","result":{}}), + ) + .is_empty()); + assert!(normalize(AcpxRuntimeEventKind::Done, json!({"type":"done"})).is_empty()); +} diff --git a/packages/paperclip-runner/scripts/generate-acpx-sidecar-contract.mjs b/packages/paperclip-runner/scripts/generate-acpx-sidecar-contract.mjs index 9f3141df9c..def605c494 100644 --- a/packages/paperclip-runner/scripts/generate-acpx-sidecar-contract.mjs +++ b/packages/paperclip-runner/scripts/generate-acpx-sidecar-contract.mjs @@ -13,6 +13,14 @@ const schema = JSON.parse( ); const commands = schema.$defs.command.enum; const events = schema.$defs.eventType.enum; +// Mutation wins for compound provider kinds so create-target attestation and +// the normalized readOnly flag cannot disagree across the sidecar boundary. +const toolOperationPrecedence = [ + ["edit", ["edit", "write", "patch"]], + ["read", ["read"]], + ["search", ["search", "grep", "find"]], + ["list", ["list", "glob"]], +]; const protocolVersion = readAcpxSidecarProtocolVersion(schema); const quote = (value) => JSON.stringify(value); const rustVariant = (value) => @@ -21,11 +29,37 @@ const rustVariant = (value) => .map((part) => part[0].toUpperCase() + part.slice(1)) .join(""); -const typescript = `// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.\n\nexport const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION = ${protocolVersion} as const;\nexport const GENERATED_ACPX_SIDECAR_COMMANDS = [\n${commands.map((value) => ` ${quote(value)},`).join("\n")}\n] as const;\nexport type GeneratedAcpxSidecarCommand =\n (typeof GENERATED_ACPX_SIDECAR_COMMANDS)[number];\n\nexport const GENERATED_ACPX_SIDECAR_EVENT_TYPES = [\n${events.map((value) => ` ${quote(value)},`).join("\n")}\n] as const;\nexport type GeneratedAcpxSidecarEventType =\n (typeof GENERATED_ACPX_SIDECAR_EVENT_TYPES)[number];\n`; -const rust = `// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.\n\npub const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION: u64 = ${protocolVersion};\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\npub enum GeneratedAcpxSidecarCommand {\n${commands.map((value) => ` ${rustVariant(value)},`).join("\n")}\n}\n\nimpl GeneratedAcpxSidecarCommand {\n pub const fn as_str(self) -> &'static str {\n match self {\n${commands.map((value) => ` Self::${rustVariant(value)} => ${quote(value)},`).join("\n")}\n }\n }\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize)]\npub enum GeneratedAcpxSidecarEventType {\n${events.map((value) => ` #[serde(rename = ${quote(value)})]\n ${rustVariant(value)},`).join("\n")}\n}\n`; +const typescript = `// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.\n\nexport const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION = ${protocolVersion} as const;\nexport const GENERATED_ACPX_SIDECAR_COMMANDS = [\n${commands.map((value) => ` ${quote(value)},`).join("\n")}\n] as const;\nexport type GeneratedAcpxSidecarCommand =\n (typeof GENERATED_ACPX_SIDECAR_COMMANDS)[number];\n\nexport const GENERATED_ACPX_SIDECAR_EVENT_TYPES = [\n${events.map((value) => ` ${quote(value)},`).join("\n")}\n] as const;\nexport type GeneratedAcpxSidecarEventType =\n (typeof GENERATED_ACPX_SIDECAR_EVENT_TYPES)[number];\n\nexport type GeneratedAcpxToolOperation =\n | "read"\n | "search"\n | "list"\n | "edit"\n | "execute"\n | "unknown";\n\nexport const GENERATED_ACPX_TOOL_OPERATION_PRECEDENCE = [\n${toolOperationPrecedence.map(([operation, tokens]) => ` { operation: ${quote(operation)}, tokens: [${tokens.map(quote).join(", ")}] },`).join("\n")}\n] as const;\n\nexport function classifyGeneratedAcpxToolOperation(\n toolKind: unknown,\n toolTitle: unknown,\n): GeneratedAcpxToolOperation {\n const candidate =\n typeof toolKind === "string" && toolKind\n ? toolKind\n : typeof toolTitle === "string"\n ? toolTitle\n : "";\n const normalized = candidate.slice(0, 240).toLowerCase();\n for (const { operation, tokens } of GENERATED_ACPX_TOOL_OPERATION_PRECEDENCE) {\n if (tokens.some((token) => normalized.includes(token))) return operation;\n }\n return normalized ? "execute" : "unknown";\n}\n`; +const rust = `// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.\n\npub const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION: u64 = ${protocolVersion};\n\npub const GENERATED_ACPX_TOOL_OPERATION_PRECEDENCE: &[(&str, &[&str])] = &[\n${toolOperationPrecedence.map(([operation, tokens]) => ` (${quote(operation)}, &[${tokens.map(quote).join(", ")}]),`).join("\n")}\n];\n\npub fn classify_generated_acpx_tool_operation(kind: &str, title: &str) -> &'static str {\n let kind = kind.to_ascii_lowercase();\n let title = title.to_ascii_lowercase();\n let candidate = if kind.is_empty() { &title } else { &kind };\n for (operation, tokens) in GENERATED_ACPX_TOOL_OPERATION_PRECEDENCE {\n if tokens.iter().any(|token| candidate.contains(token)) {\n return operation;\n }\n }\n if candidate.is_empty() {\n "unknown"\n } else {\n "execute"\n }\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\npub enum GeneratedAcpxSidecarCommand {\n${commands.map((value) => ` ${rustVariant(value)},`).join("\n")}\n}\n\nimpl GeneratedAcpxSidecarCommand {\n pub const fn as_str(self) -> &'static str {\n match self {\n${commands.map((value) => ` Self::${rustVariant(value)} => ${quote(value)},`).join("\n")}\n }\n }\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize)]\npub enum GeneratedAcpxSidecarEventType {\n${events.map((value) => ` #[serde(rename = ${quote(value)})]\n ${rustVariant(value)},`).join("\n")}\n}\n`; + +// Classification must inspect the same complete provider value in both +// languages. Display-field truncation happens only after operation selection. +const typescriptWithAsciiLowercase = typescript.replace( + "export function classifyGeneratedAcpxToolOperation(", + `function lowercaseGeneratedAcpxAscii(value: string): string { + return value.replace(/[A-Z]/g, (character) => + String.fromCharCode(character.charCodeAt(0) + 32), + ); +} + +export function classifyGeneratedAcpxToolOperation(`, +); +if (typescriptWithAsciiLowercase === typescript) { + throw new Error("generated TypeScript ACPX classifier export drifted"); +} +const typescriptClassifier = typescriptWithAsciiLowercase.replace( + "candidate.slice(0, 240).toLowerCase()", + "lowercaseGeneratedAcpxAscii(candidate)", +); +if (typescriptClassifier === typescriptWithAsciiLowercase) { + throw new Error("generated TypeScript ACPX classifier template drifted"); +} const outputs = [ - [resolve(root, "src/drivers/acpx/generated-sidecar-contract.ts"), typescript], + [ + resolve(root, "src/drivers/acpx/generated-sidecar-contract.ts"), + typescriptClassifier, + ], [ resolve( root, diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index 74653c8ee6..12371e19ea 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -31,10 +31,13 @@ import { import { ACPX_SIDECAR_MAX_FRAME_BYTES, ACPX_SIDECAR_PROTOCOL_VERSION, + boundedSidecarText, boundedSidecarValue, + frameAcpxToolClassification, parseAcpxSidecarRequest, record, sanitizeAcpxPlanEntries, + stringifyAcpxSidecarFrame, text, type AcpxExpectedSessionIdentity, type AcpxSidecarEvent, @@ -662,20 +665,47 @@ function sanitizeRuntimeEvent(event: AcpRuntimeEvent): Record { }); } if (event.type === "tool_call") { + // Classification and the consumer must see the same title bytes. In + // particular, a mutation token beyond the transport bound must not grant a + // create-target attestation that runner-core cannot independently verify. + const toolTitle = + typeof event.title === "string" + ? boundedSidecarText(event.title, 4_000) + : null; + // Classify the complete provider kind before retaining its bounded display + // prefix. The canonical operation keeps runner-core and the location + // attestation decision aligned even when the mutation token is outside the + // retained prefix. + const toolClassification = frameAcpxToolClassification( + event.kind, + toolTitle, + ); + const toolCallIdentity = { + type: "tool_call", + toolCallId: + typeof event.toolCallId === "string" + ? boundedSidecarText(event.toolCallId, 240) + : null, + status: + typeof event.status === "string" + ? boundedSidecarText(event.status, 100) + : null, + title: toolTitle, + ...toolClassification, + }; return boundedSidecarValue( { - type: "tool_call", - toolCallId: event.toolCallId?.slice(0, 240) ?? null, - status: event.status?.slice(0, 100) ?? null, - title: event.title?.slice(0, 4_000) ?? null, - kind: event.kind ?? null, + ...toolCallIdentity, locations: safeAcpxLocations( event.locations, openParams?.workingDirectory, + event.kind, + toolTitle, ), ...safeOutput(event.rawOutput), }, 128 * 1024, + toolCallIdentity, ); } if (event.type === "error") { @@ -939,7 +969,7 @@ function response( } function writeFrame(value: AcpxSidecarEvent | AcpxSidecarResponse): void { - const line = JSON.stringify(value); + const line = stringifyAcpxSidecarFrame(value); if (Buffer.byteLength(line) > ACPX_SIDECAR_MAX_FRAME_BYTES) { process.stderr.write("[paperclip-acpx-sidecar] output_frame_too_large\n"); return; diff --git a/packages/paperclip-runner/src/cli/acpx-sidecar-locations.test.ts b/packages/paperclip-runner/src/cli/acpx-sidecar-locations.test.ts index 122cbb6873..605dc7cb10 100644 --- a/packages/paperclip-runner/src/cli/acpx-sidecar-locations.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-sidecar-locations.test.ts @@ -1,55 +1,259 @@ +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve, sep } from "node:path"; + import { describe, expect, it } from "vitest"; import { safeAcpxLocations } from "./acpx-sidecar-locations.js"; describe("ACPX sidecar locations", () => { - it("preserves valid host-relative display names without admitting escape", () => { + it.runIf(sep === "/")( + "preserves valid host-relative display names without admitting escape", + () => { + const workspace = mkdtempSync( + join(tmpdir(), "paperclip-acpx-locations-"), + ); + writeFileSync(join(workspace, "src:main.ts"), ""); + mkdirSync(join(workspace, "a:")); + writeFileSync(join(workspace, "a:", "foo"), ""); + writeFileSync(join(workspace, "custom:payload"), ""); + try { + expect( + safeAcpxLocations( + [ + { path: "src/main.ts", line: 4 }, + { path: "src:main.ts" }, + { path: String.raw`folder\literal` }, + { path: "a:/foo" }, + { path: "custom:payload" }, + { path: String.raw`foo\..\bar` }, + { path: "reports/100%/summary.txt" }, + { path: "../outside.txt" }, + { path: "/etc/passwd" }, + { uri: "https://example.test/private" }, + { path: "bad\0name" }, + ], + workspace, + ), + ).toEqual([ + { + path: "src/main.ts", + line: 4, + pathBoundary: "paperclip.workspace_relative_display.v2", + }, + { + path: "src:main.ts", + line: null, + pathBoundary: "paperclip.workspace_relative_display.v2", + pathAttestation: "paperclip.workspace_entry.v1", + }, + { + path: String.raw`folder\literal`, + line: null, + pathBoundary: "paperclip.workspace_relative_display.v2", + }, + { + path: "a:/foo", + line: null, + pathBoundary: "paperclip.workspace_relative_display.v2", + pathAttestation: "paperclip.workspace_entry.v1", + }, + { + path: "custom:payload", + line: null, + pathBoundary: "paperclip.workspace_relative_display.v2", + pathAttestation: "paperclip.workspace_entry.v1", + }, + { + path: String.raw`foo\..\bar`, + line: null, + pathBoundary: "paperclip.workspace_relative_display.v2", + }, + { + path: "reports/100%/summary.txt", + line: null, + pathBoundary: "paperclip.workspace_relative_display.v2", + }, + ]); + } finally { + rmSync(workspace, { force: true, recursive: true }); + } + }, + ); + + it.runIf(sep === "/")( + "attests a leading backslash as valid POSIX filename data", + () => { + const workspace = mkdtempSync( + join(tmpdir(), "paperclip-acpx-locations-"), + ); + writeFileSync(join(workspace, String.raw`\notes.md`), ""); + try { + expect( + safeAcpxLocations([{ path: String.raw`\notes.md` }], workspace), + ).toEqual([ + { + path: String.raw`\notes.md`, + line: null, + pathBoundary: "paperclip.workspace_relative_display.v2", + pathAttestation: "paperclip.workspace_entry.v1", + }, + ]); + } finally { + rmSync(workspace, { force: true, recursive: true }); + } + }, + ); + + it("rejects URI and foreign-host syntax before attaching the boundary", () => { expect( safeAcpxLocations( [ - { path: "src/main.ts", line: 4 }, - { path: "reports/100%/summary.txt" }, - { path: "../outside.txt" }, - { path: "/etc/passwd" }, - { uri: "https://example.test/private" }, - { path: "bad\0name" }, + { path: String.raw`C:\Users\alice\secret.txt` }, + { path: String.raw`\\server\share\secret.txt` }, + { path: String.raw`https:\host\secret` }, + { path: "https://host/secret" }, + { path: "file:secret.txt" }, + { path: "s3:bucket/key" }, + { path: "custom:payload" }, + { path: "urn:isbn:9780131103627" }, + { path: "tel:+15555550100" }, + { path: String.raw`C:Users\alice\secret.txt` }, + { path: "D:relative.txt" }, ], - "/workspace/project", + tmpdir(), ), - ).toEqual([ - { - path: "src/main.ts", - line: 4, - pathBoundary: "paperclip.workspace_relative_display.v1", - }, - { - path: "reports/100%/summary.txt", - line: null, - pathBoundary: "paperclip.workspace_relative_display.v1", - }, - ]); + ).toEqual([]); }); - it.runIf(process.platform !== "win32")( - "preserves POSIX literal colon and backslash filename characters", + it.runIf(sep === "/")( + "attests scheme-shaped targets after relative and absolute normalization", () => { - expect( - safeAcpxLocations( - [{ path: "src:main.ts" }, { path: String.raw`folder\literal` }], - "/workspace/project", - ), - ).toEqual([ - { - path: "src:main.ts", - line: null, - pathBoundary: "paperclip.workspace_relative_display.v1", - }, - { - path: String.raw`folder\literal`, - line: null, - pathBoundary: "paperclip.workspace_relative_display.v1", - }, - ]); + const workspace = mkdtempSync( + join(tmpdir(), "paperclip-acpx-locations-"), + ); + const entry = join(workspace, "src:main.ts"); + writeFileSync(entry, ""); + try { + expect( + safeAcpxLocations( + [{ path: "./src:main.ts" }, { path: resolve(entry) }], + workspace, + ), + ).toEqual([ + { + path: "src:main.ts", + line: null, + pathBoundary: "paperclip.workspace_relative_display.v2", + pathAttestation: "paperclip.workspace_entry.v1", + }, + { + path: "src:main.ts", + line: null, + pathBoundary: "paperclip.workspace_relative_display.v2", + pathAttestation: "paperclip.workspace_entry.v1", + }, + ]); + } finally { + rmSync(workspace, { force: true, recursive: true }); + } + }, + ); + + it.runIf(sep === "/")( + "attests missing edit targets without weakening symlink containment", + () => { + const root = mkdtempSync(join(tmpdir(), "paperclip-acpx-locations-")); + const workspace = join(root, "workspace"); + const outside = join(root, "outside"); + mkdirSync(workspace); + mkdirSync(outside); + symlinkSync(outside, join(workspace, "src:")); + symlinkSync( + join(workspace, "missing"), + join(workspace, "dangling:new.ts"), + ); + try { + expect( + safeAcpxLocations( + [ + { path: "src:new.ts", line: 8 }, + { path: "src:/outside.ts" }, + { path: "dangling:new.ts" }, + { path: "missing:/nested.ts" }, + ], + workspace, + "edit", + ), + ).toEqual([ + { + path: "src:new.ts", + line: 8, + pathBoundary: "paperclip.workspace_relative_display.v2", + pathAttestation: "paperclip.workspace_create_target.v1", + }, + ]); + expect( + safeAcpxLocations([{ path: "src:new.ts" }], workspace, "read"), + ).toEqual([]); + expect( + safeAcpxLocations( + [{ path: "src:new.ts" }], + workspace, + undefined, + "Write", + ), + ).toHaveLength(1); + for (const compoundKind of ["read_write", "search_write"]) { + expect( + safeAcpxLocations( + [{ path: "src:new.ts" }], + workspace, + compoundKind, + ), + ).toEqual([ + { + path: "src:new.ts", + line: null, + pathBoundary: "paperclip.workspace_relative_display.v2", + pathAttestation: "paperclip.workspace_create_target.v1", + }, + ]); + } + expect( + safeAcpxLocations( + [{ path: "src:new.ts" }], + workspace, + `${"x".repeat(240)}write`, + ), + ).toEqual([ + { + path: "src:new.ts", + line: null, + pathBoundary: "paperclip.workspace_relative_display.v2", + pathAttestation: "paperclip.workspace_create_target.v1", + }, + ]); + // The runtime sidecar sends and classifies this same bounded title. A + // mutation token beyond the transport boundary must not authorize the + // otherwise missing create target. + expect( + safeAcpxLocations( + [{ path: "src:new.ts" }], + workspace, + undefined, + `${"x".repeat(4_000)}write`.slice(0, 4_000), + ), + ).toEqual([]); + } finally { + rmSync(root, { force: true, recursive: true }); + } }, ); diff --git a/packages/paperclip-runner/src/cli/acpx-sidecar-locations.ts b/packages/paperclip-runner/src/cli/acpx-sidecar-locations.ts index 3a4cc3011f..c0ee70d297 100644 --- a/packages/paperclip-runner/src/cli/acpx-sidecar-locations.ts +++ b/packages/paperclip-runner/src/cli/acpx-sidecar-locations.ts @@ -1,7 +1,15 @@ -import { isAbsolute, relative, resolve, sep } from "node:path"; +import { lstatSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; + +import { classifyGeneratedAcpxToolOperation } from "../drivers/acpx/generated-sidecar-contract.js"; export const ACPX_WORKSPACE_RELATIVE_DISPLAY_BOUNDARY = - "paperclip.workspace_relative_display.v1"; + "paperclip.workspace_relative_display.v2"; +export const ACPX_WORKSPACE_ENTRY_ATTESTATION = "paperclip.workspace_entry.v1"; +export const ACPX_WORKSPACE_CREATE_TARGET_ATTESTATION = + "paperclip.workspace_create_target.v1"; +const RFC_URI_SCHEME_PREFIX = /^[A-Za-z][A-Za-z0-9+.-]*:/u; +const WINDOWS_DRIVE_PREFIX = /^[A-Za-z]:/u; function record(value: unknown): Record { return value !== null && typeof value === "object" && !Array.isArray(value) @@ -9,19 +17,28 @@ function record(value: unknown): Record { : {}; } +function permitsCreateTarget(toolKind: unknown, toolTitle: unknown): boolean { + return classifyGeneratedAcpxToolOperation(toolKind, toolTitle) === "edit"; +} + /** * Converts provider paths to workspace-relative display targets using the - * sidecar host's path semantics. A URI is not a path. Windows separators are - * canonicalized for PRP; POSIX backslashes and colons remain literal filename - * characters. Consumers must treat the result as display data, never as an - * authorization to access a file. + * sidecar host's path semantics. URI-scheme and Windows drive-shaped values + * are ambiguous on POSIX, so they require a real, in-workspace filesystem + * entry before the sidecar may attest them as filename data. Leading + * backslashes likewise require native-host attestation because they are rooted + * syntax on Windows but valid filename data on POSIX. Consumers must + * treat the result as display data, never as file-access authorization. */ export function safeAcpxLocations( locations: readonly unknown[] | null | undefined, workingDirectory: string | null | undefined, + toolKind?: unknown, + toolTitle?: unknown, ): Array> { if (!workingDirectory) return []; const cwd = resolve(workingDirectory); + let canonicalCwd: string | null | undefined; return (locations ?? []).slice(0, 2_000).flatMap((location) => { const candidate = record(location); const rawPath = typeof candidate.path === "string" ? candidate.path : ""; @@ -38,11 +55,84 @@ export function safeAcpxLocations( ) { return []; } + // Classify the value we actually emit as well as the provider's spelling. + // Dot-relative and absolute paths can normalize to an ambiguous display + // target even though their raw forms did not begin with an ambiguous shape. + const requiresEntryAttestation = + rawPath.startsWith("\\") || + portable.startsWith("\\") || + WINDOWS_DRIVE_PREFIX.test(rawPath) || + RFC_URI_SCHEME_PREFIX.test(rawPath) || + WINDOWS_DRIVE_PREFIX.test(portable) || + RFC_URI_SCHEME_PREFIX.test(portable); + let pathAttestation: string | undefined; + if (requiresEntryAttestation) { + if (canonicalCwd === undefined) { + try { + canonicalCwd = realpathSync(cwd); + } catch { + canonicalCwd = null; + } + } + if (canonicalCwd === null) return []; + + let entryExists = true; + try { + lstatSync(absolute); + } catch (error) { + entryExists = false; + if ( + !permitsCreateTarget(toolKind, toolTitle) || + !( + error !== null && + typeof error === "object" && + "code" in error && + error.code === "ENOENT" + ) + ) { + return []; + } + } + + if (entryExists) { + try { + const canonicalEntry = realpathSync(absolute); + const entryRelative = relative(canonicalCwd, canonicalEntry); + if ( + !entryRelative || + isAbsolute(entryRelative) || + entryRelative.split(sep).some((segment) => segment === "..") + ) { + return []; + } + pathAttestation = ACPX_WORKSPACE_ENTRY_ATTESTATION; + } catch { + // An existing but unresolved entry includes dangling links. It must + // not be downgraded to a create target by an ENOENT from realpath. + return []; + } + } else { + try { + const canonicalParent = realpathSync(dirname(absolute)); + const parentRelative = relative(canonicalCwd, canonicalParent); + if ( + isAbsolute(parentRelative) || + parentRelative.split(sep).some((segment) => segment === "..") + ) { + return []; + } + pathAttestation = ACPX_WORKSPACE_CREATE_TARGET_ATTESTATION; + } catch { + return []; + } + } + } return [ { path: [...portable].slice(0, 4_000).join(""), line: candidate.line ?? null, pathBoundary: ACPX_WORKSPACE_RELATIVE_DISPLAY_BOUNDARY, + ...(pathAttestation ? { pathAttestation } : {}), }, ]; }); diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-sidecar-contract.test.ts b/packages/paperclip-runner/src/drivers/acpx/generated-sidecar-contract.test.ts new file mode 100644 index 0000000000..9fa368bc62 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/generated-sidecar-contract.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { classifyGeneratedAcpxToolOperation } from "./generated-sidecar-contract.js"; + +describe("generated ACPX sidecar tool classification", () => { + it("uses ASCII case mapping for both provider kinds and fallback titles", () => { + expect(classifyGeneratedAcpxToolOperation("ſearch", undefined)).toBe( + "execute", + ); + expect(classifyGeneratedAcpxToolOperation(undefined, "ſearch")).toBe( + "execute", + ); + expect(classifyGeneratedAcpxToolOperation("SEARCH", undefined)).toBe( + "search", + ); + expect(classifyGeneratedAcpxToolOperation(undefined, "WRITE")).toBe("edit"); + }); + + it("continues classifying the complete provider value", () => { + expect( + classifyGeneratedAcpxToolOperation(`${"x".repeat(240)}WRITE`, undefined), + ).toBe("edit"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-sidecar-contract.ts b/packages/paperclip-runner/src/drivers/acpx/generated-sidecar-contract.ts index eed7190584..ca0106ffea 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-sidecar-contract.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-sidecar-contract.ts @@ -29,3 +29,41 @@ export const GENERATED_ACPX_SIDECAR_EVENT_TYPES = [ ] as const; export type GeneratedAcpxSidecarEventType = (typeof GENERATED_ACPX_SIDECAR_EVENT_TYPES)[number]; + +export type GeneratedAcpxToolOperation = + | "read" + | "search" + | "list" + | "edit" + | "execute" + | "unknown"; + +export const GENERATED_ACPX_TOOL_OPERATION_PRECEDENCE = [ + { operation: "edit", tokens: ["edit", "write", "patch"] }, + { operation: "read", tokens: ["read"] }, + { operation: "search", tokens: ["search", "grep", "find"] }, + { operation: "list", tokens: ["list", "glob"] }, +] as const; + +function lowercaseGeneratedAcpxAscii(value: string): string { + return value.replace(/[A-Z]/g, (character) => + String.fromCharCode(character.charCodeAt(0) + 32), + ); +} + +export function classifyGeneratedAcpxToolOperation( + toolKind: unknown, + toolTitle: unknown, +): GeneratedAcpxToolOperation { + const candidate = + typeof toolKind === "string" && toolKind + ? toolKind + : typeof toolTitle === "string" + ? toolTitle + : ""; + const normalized = lowercaseGeneratedAcpxAscii(candidate); + for (const { operation, tokens } of GENERATED_ACPX_TOOL_OPERATION_PRECEDENCE) { + if (tokens.some((token) => normalized.includes(token))) return operation; + } + return normalized ? "execute" : "unknown"; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.test.ts b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.test.ts index eff7054af1..3f22325228 100644 --- a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.test.ts @@ -2,9 +2,13 @@ import { describe, expect, it } from "vitest"; import { ACPX_SIDECAR_MAX_FRAME_BYTES, + boundedSidecarText, boundedSidecarValue, + frameAcpxToolClassification, parseAcpxSidecarRequest, + safeSidecarText, sanitizeAcpxPlanEntries, + stringifyAcpxSidecarFrame, } from "./sidecar-protocol.js"; describe("ACPX sidecar request parsing", () => { @@ -77,6 +81,95 @@ describe("ACPX sidecar request parsing", () => { reason: "object_required", }); }); + + it("bounds tool text on Unicode scalar boundaries", () => { + const prefix = "x".repeat(3_999); + const title = boundedSidecarText(`${prefix}🚀write`, 4_000); + + expect([...title]).toHaveLength(4_000); + expect(title).toBe(`${prefix}🚀`); + expect( + boundedSidecarValue({ type: "tool_call", title }, 128 * 1024), + ).toEqual({ type: "tool_call", title }); + expect(boundedSidecarText(`safe\ud83d`, 10)).toBe("safe\uFFFD"); + }); + + it("classifies an oversized tool kind before retaining its bounded frame value", () => { + const classification = frameAcpxToolClassification( + `${"x".repeat(128 * 1024)}\ud800WRITE`, + "Provider tool", + ); + const payload = boundedSidecarValue( + { + type: "tool_call", + toolCallId: "tool-oversized-kind", + title: "Provider tool", + ...classification, + locations: [], + }, + 128 * 1024, + ); + + expect(payload).toMatchObject({ + type: "tool_call", + kind: "x".repeat(4_000), + toolOperation: "edit", + }); + expect(payload).not.toHaveProperty("omitted"); + expect(stringifyAcpxSidecarFrame(payload)).not.toMatch( + /\\ud[89ab][0-9a-f]{2}|\\ud[c-f][0-9a-f]{2}/iu, + ); + }); + + it("preserves a bounded tool-call identity when aggregate fields overflow", () => { + const identity = { + type: "tool_call", + toolCallId: "tool-aggregate-overflow", + title: "Write", + kind: "write", + toolOperation: "edit", + }; + + expect( + boundedSidecarValue( + { ...identity, locations: [{ path: "x".repeat(140 * 1024) }] }, + 128 * 1024, + identity, + ), + ).toEqual({ + ...identity, + omitted: true, + reason: "payload_limit", + }); + }); + + it("emits only Rust-decodable Unicode scalar values in sidecar frames", () => { + const frame = stringifyAcpxSidecarFrame({ + payload: { + toolCallId: `call-\ud800`, + kind: `wr\udfffite`, + status: `pend\ud800ing`, + input: { + [`nested-\ud800`]: { [`result-\udfff`]: "retained" }, + }, + }, + }); + + expect(frame).not.toMatch(/\\ud[89ab][0-9a-f]{2}|\\ud[c-f][0-9a-f]{2}/iu); + expect(JSON.parse(frame)).toEqual({ + payload: { + toolCallId: "call-\uFFFD", + kind: "wr\uFFFDite", + status: "pend\uFFFDing", + input: { + "nested-\uFFFD": { "result-\uFFFD": "retained" }, + }, + }, + }); + expect(safeSidecarText(`read\ud83d🚀\udc00write`)).toBe( + "read\uFFFD🚀\uFFFDwrite", + ); + }); }); describe("ACPX sidecar structured plans", () => { diff --git a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts index 912d076a98..95c1f831de 100644 --- a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts +++ b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts @@ -2,8 +2,10 @@ import type { QualifiedAcpxAgent } from "./qualified-profiles.js"; import type { NativeRuntimeContextSnapshot } from "../../contracts/runtime-context.js"; import type { NativeAcpxPermissionMode } from "../../contracts/native-execution.js"; import { + classifyGeneratedAcpxToolOperation, GENERATED_ACPX_SIDECAR_COMMANDS, GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION, + type GeneratedAcpxToolOperation, type GeneratedAcpxSidecarCommand, type GeneratedAcpxSidecarEventType, } from "./generated-sidecar-contract.js"; @@ -114,26 +116,143 @@ export function parseAcpxSidecarRequest(value: unknown): AcpxSidecarRequest { export function boundedSidecarValue( value: unknown, maxBytes = 64 * 1024, + overflowFallback?: Record, ): Record { if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { throw new Error("ACPX sidecar value limit must be a positive integer"); } + const omitted = (reason: string): Record => { + const marker = { omitted: true, reason }; + if (overflowFallback === undefined) return marker; + try { + const serialized = stringifyAcpxSidecarFrame({ + ...overflowFallback, + ...marker, + }); + if (Buffer.byteLength(serialized) <= maxBytes) { + const parsed = JSON.parse(serialized); + if (typeof parsed === "object" && parsed !== null) { + return parsed as Record; + } + } + } catch { + // Fall through to the bounded type-less marker when even the caller's + // minimal identity cannot be serialized safely. + } + return marker; + }; try { - const serialized = JSON.stringify(value); + const serialized = stringifyAcpxSidecarFrame(value); if (!serialized || Buffer.byteLength(serialized) > maxBytes) { - return { omitted: true, reason: "payload_limit" }; + return omitted("payload_limit"); } const parsed = JSON.parse(serialized); return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Record) - : { omitted: true, reason: "object_required" }; + : omitted("object_required"); } catch { - return { omitted: true, reason: "serialization_failed" }; + return omitted("serialization_failed"); } } +/** + * Replaces isolated UTF-16 surrogates with a Unicode scalar value. JavaScript + * can retain those code units and JSON.stringify emits them as escapes, while + * Rust's serde_json correctly rejects them as invalid JSON strings. + */ +export function safeSidecarText(value: string): string { + const safe: string[] = []; + for (const codePoint of value) safe.push(safeSidecarCodePoint(codePoint)); + return safe.join(""); +} + +/** + * Serializes a frame without emitting string values or property names that + * Rust cannot decode. The initial round trip preserves JSON.stringify's + * ordinary toJSON, omission, and number semantics before keys are rebuilt. + */ +export function stringifyAcpxSidecarFrame(value: unknown): string { + const serialized = JSON.stringify(value); + if (!serialized) throw new Error("ACPX sidecar frame is not serializable"); + return JSON.stringify(safeSidecarJsonValue(JSON.parse(serialized))); +} + +function safeSidecarJsonValue(value: unknown): unknown { + if (typeof value === "string") return safeSidecarText(value); + if (Array.isArray(value)) return value.map(safeSidecarJsonValue); + if (value === null || typeof value !== "object") return value; + + const safe = Object.create(null) as Record; + for (const [key, candidate] of Object.entries( + value as Record, + )) { + const safeKey = safeSidecarText(key); + if (Object.hasOwn(safe, safeKey)) { + throw new Error( + "ACPX sidecar frame has colliding Unicode property names", + ); + } + safe[safeKey] = safeSidecarJsonValue(candidate); + } + return safe; +} + +/** + * Bounds provider text by Unicode scalar count so the emitted UTF-8 frame and + * runner-core's `str::chars` admission check observe the same value. Provider + * strings may also contain an isolated UTF-16 surrogate; replace it rather + * than emitting a JSON escape that Rust cannot decode as a string. + */ +export function boundedSidecarText( + value: string, + maxCodePoints: number, +): string { + if (!Number.isSafeInteger(maxCodePoints) || maxCodePoints < 0) { + throw new Error("ACPX sidecar text limit must be a non-negative integer"); + } + const bounded: string[] = []; + for (const codePoint of value) { + if (bounded.length >= maxCodePoints) break; + bounded.push(safeSidecarCodePoint(codePoint)); + } + return bounded.join(""); +} + +/** + * Classifies the complete provider value before retaining a bounded display + * copy. `toolOperation` is the sidecar's classification authority when the + * provider token lies beyond the retained prefix; older frames can continue + * to be classified from `kind` and `title` by runner-core. + */ +export function frameAcpxToolClassification( + toolKind: unknown, + boundedToolTitle: unknown, +): { + kind: string | null; + toolOperation: GeneratedAcpxToolOperation; +} { + const toolOperation = classifyGeneratedAcpxToolOperation( + toolKind, + boundedToolTitle, + ); + return { + kind: + typeof toolKind === "string" + ? boundedSidecarText(toolKind, 4_000) + : null, + toolOperation, + }; +} + +function safeSidecarCodePoint(codePoint: string): string { + const codeUnit = codePoint.charCodeAt(0); + return codePoint.length === 1 && codeUnit >= 0xd800 && codeUnit <= 0xdfff + ? "\uFFFD" + : codePoint; +} + export function sanitizeAcpxPlanEntries(value: unknown): Array<{ content: string; status: "pending" | "in_progress" | "completed";