feat(runner): normalize ACPX provider events (#12416)
## 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
This commit is contained in:
parent
fe2ddfad2b
commit
7bb6cebeae
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<AcpxEventPayload, LocalRunner
|
|||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| LocalRunnerError::invalid("ACPX runtime event omitted its type"))?;
|
||||
let mut tool_operation = None;
|
||||
let kind = match runtime_type {
|
||||
"text_delta" => {
|
||||
bounded_required_text(payload, "text", MAX_RUNTIME_TEXT_CHARS, "runtime text")?;
|
||||
|
|
@ -233,7 +237,7 @@ fn decode_runtime_event(payload: &Value) -> Result<AcpxEventPayload, LocalRunner
|
|||
}
|
||||
"tool_call" => {
|
||||
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<AcpxEventPayload, LocalRunner
|
|||
bounded_optional_text(location, "path", 4_000, "runtime tool path")?;
|
||||
}
|
||||
}
|
||||
tool_operation = Some(match payload.get("toolOperation") {
|
||||
Some(Value::String(operation)) => {
|
||||
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<AcpxEventPayload, LocalRunner
|
|||
};
|
||||
Ok(AcpxEventPayload::Runtime {
|
||||
kind,
|
||||
tool_operation,
|
||||
payload: sanitize_value(payload),
|
||||
})
|
||||
}
|
||||
|
||||
fn admitted_tool_operation(operation: &str) -> 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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Normali
|
|||
events
|
||||
}
|
||||
|
||||
/// Converts an already scope-checked and payload-validated ACPX runtime event
|
||||
/// into provider-neutral PRP activity. Operational events such as semantic
|
||||
/// results and turn completion remain owned by the stateful provider adapter.
|
||||
/// That adapter also suppresses repeated reasoning-start boundaries in a turn.
|
||||
pub fn normalize_acpx_runtime_event(
|
||||
kind: AcpxRuntimeEventKind,
|
||||
payload: &Value,
|
||||
tool_operation: Option<&str>,
|
||||
fallback_item_id: &str,
|
||||
turn_id: &str,
|
||||
provider_requests: u64,
|
||||
) -> Vec<NormalizedProviderEvent> {
|
||||
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<NormalizedProviderEvent> {
|
||||
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::<Vec<_>>();
|
||||
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<NormalizedProviderEvent> {
|
||||
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<NormalizedProviderEvent> {
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<paperclip_runner_core::provider_events::NormalizedProviderEvent> {
|
||||
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());
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> {
|
|||
});
|
||||
}
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
|
|
@ -9,19 +17,28 @@ function record(value: unknown): Record<string, unknown> {
|
|||
: {};
|
||||
}
|
||||
|
||||
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<Record<string, unknown>> {
|
||||
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 } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
||||
throw new Error("ACPX sidecar value limit must be a positive integer");
|
||||
}
|
||||
const omitted = (reason: string): Record<string, unknown> => {
|
||||
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<string, unknown>;
|
||||
}
|
||||
}
|
||||
} 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<string, unknown>)
|
||||
: { 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<string, unknown>;
|
||||
for (const [key, candidate] of Object.entries(
|
||||
value as Record<string, unknown>,
|
||||
)) {
|
||||
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";
|
||||
|
|
|
|||
Loading…
Reference in New Issue