| 1 | //! Original real-host structcopy assertions relocated unchanged across the new boundary. |
| 2 | use super::contract::structcopy_host::observe_path_roots; |
| 3 | use super::groups::session::structcopy::{self, *}; |
| 4 | use crate::commands::CommandResult; |
| 5 | use crate::config::Config; |
| 6 | use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs}; |
| 7 | use crate::tui::app::App; |
| 8 | use crate::tui::app::TuiOptions; |
| 9 | use crate::tui::clipboard::ClipboardHandler; |
| 10 | use codewhale_localization::{Locale, MessageId, tr}; |
| 11 | use codewhale_models::Role; |
| 12 | use codewhale_models::{ContentBlock, Message}; |
| 13 | use codewhale_models::{ImageUrlContent, ToolCaller}; |
| 14 | use serde_json::{Value, json}; |
| 15 | use std::path::Path; |
| 16 | use tempfile::TempDir; |
| 17 | |
| 18 | fn test_app(tmpdir: &TempDir) -> App { |
| 19 | let options = TuiOptions { |
| 20 | skills_dir: tmpdir.path().join("skills"), |
| 21 | memory_path: tmpdir.path().join("memory.md"), |
| 22 | notes_path: tmpdir.path().join("notes.txt"), |
| 23 | mcp_config_path: tmpdir.path().join("mcp.json"), |
| 24 | ..crate::test_support::test_tui_options(tmpdir.path()) |
| 25 | }; |
| 26 | let mut app = App::new(options, &Config::default()); |
| 27 | app.ui_locale = Locale::En; |
| 28 | app |
| 29 | } |
| 30 | |
| 31 | fn stdout_json(result: &CommandResult) -> String { |
| 32 | assert!(!result.is_error, "{:?}", result.message); |
| 33 | result.message.clone().expect("stdout payload") |
| 34 | } |
| 35 | |
| 36 | fn parsed(json: &str) -> Value { |
| 37 | serde_json::from_str(json).expect("structcopy output must be valid JSON") |
| 38 | } |
| 39 | |
| 40 | fn seed_transcript(app: &mut App) { |
| 41 | app.api_messages = std::sync::Arc::new(vec![ |
| 42 | Message { |
| 43 | role: Role::User, |
| 44 | content: vec![ContentBlock::Text { |
| 45 | text: "please run the fetch".to_string(), |
| 46 | cache_control: None, |
| 47 | }], |
| 48 | }, |
| 49 | Message { |
| 50 | role: Role::Assistant, |
| 51 | content: vec![ |
| 52 | ContentBlock::Thinking { |
| 53 | thinking: "private chain of thought".to_string(), |
| 54 | signature: Some("signature-secret".to_string()), |
| 55 | state: None, |
| 56 | }, |
| 57 | ContentBlock::ToolUse { |
| 58 | execution_id: None, |
| 59 | id: "call-7".to_string(), |
| 60 | name: "fetch_url".to_string(), |
| 61 | input: json!({ |
| 62 | "url": "https://alice:hunter2@example.com/path?token=abc123&ok=1#frag", |
| 63 | "api_key": "literal-api-secret", |
| 64 | }), |
| 65 | caller: Some(ToolCaller { |
| 66 | caller_type: "code_execution_20250825".to_string(), |
| 67 | tool_id: None, |
| 68 | }), |
| 69 | thought_signature: None, |
| 70 | }, |
| 71 | ], |
| 72 | }, |
| 73 | Message { |
| 74 | role: Role::User, |
| 75 | content: vec![ContentBlock::ToolResult { |
| 76 | execution_id: None, |
| 77 | tool_use_id: "call-7".to_string(), |
| 78 | content: "Authorization: Bearer result-secret-token\nfetch ok".to_string(), |
| 79 | is_error: Some(false), |
| 80 | content_blocks: None, |
| 81 | }], |
| 82 | }, |
| 83 | ]); |
| 84 | } |
| 85 | |
| 86 | #[test] |
| 87 | fn turn_copy_projects_one_item_and_redacts() { |
| 88 | let tmpdir = TempDir::new().expect("tempdir"); |
| 89 | let mut app = test_app(&tmpdir); |
| 90 | seed_transcript(&mut app); |
| 91 | |
| 92 | let json = stdout_json(&execute_structcopy(&mut app, Some("turn 2 stdout"))); |
| 93 | let value = parsed(&json); |
| 94 | assert_eq!(value["receipt"]["schema"], json!(SCHEMA_ID)); |
| 95 | assert_eq!(value["receipt"]["kind"], json!("turn")); |
| 96 | assert_eq!(value["receipt"]["selector"], json!(2)); |
| 97 | assert_eq!(value["object"]["role"], json!("assistant")); |
| 98 | let content = value["object"]["content"].as_array().expect("content"); |
| 99 | assert_eq!(content[0]["type"], json!("thinking")); |
| 100 | assert!(content[0].get("thinking").is_none()); |
| 101 | assert_eq!( |
| 102 | content[0]["omission_code"], |
| 103 | json!("internal_reasoning_and_signature") |
| 104 | ); |
| 105 | assert_eq!(content[1]["type"], json!("tool_use")); |
| 106 | assert_eq!(content[1]["caller_type"], json!("code_execution_20250825")); |
| 107 | for forbidden in [ |
| 108 | "private chain of thought", |
| 109 | "signature-secret", |
| 110 | "literal-api-secret", |
| 111 | "hunter2", |
| 112 | "abc123", |
| 113 | "frag", |
| 114 | ] { |
| 115 | assert!(!json.contains(forbidden), "leaked {forbidden:?}: {json}"); |
| 116 | } |
| 117 | // URL userinfo/query/fragment are stripped outright. |
| 118 | assert!(json.contains("https://example.com/path"), "{json}"); |
| 119 | assert!(json.contains(SENSITIVE_VALUE_REDACTION_MARKER), "{json}"); |
| 120 | for prose in [ |
| 121 | "internal context omitted", |
| 122 | "internal reasoning and signature omitted", |
| 123 | "inline or local image payload omitted", |
| 124 | "[redacted private key]", |
| 125 | "Bearer [redacted]", |
| 126 | "[redacted token]", |
| 127 | "[redacted]", |
| 128 | ] { |
| 129 | assert!(!json.contains(prose), "prose marker {prose:?}: {json}"); |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | #[test] |
| 134 | fn generated_omissions_are_language_neutral_codes() { |
| 135 | let tmpdir = TempDir::new().expect("tempdir"); |
| 136 | let mut app = test_app(&tmpdir); |
| 137 | app.api_messages = std::sync::Arc::new(vec![ |
| 138 | Message { |
| 139 | role: Role::System, |
| 140 | content: vec![ContentBlock::Text { |
| 141 | text: "must not be copied".to_string(), |
| 142 | cache_control: None, |
| 143 | }], |
| 144 | }, |
| 145 | Message { |
| 146 | role: Role::Assistant, |
| 147 | content: vec![ContentBlock::ImageUrl { |
| 148 | image_url: ImageUrlContent { |
| 149 | url: "data:image/png;base64,private".to_string(), |
| 150 | }, |
| 151 | }], |
| 152 | }, |
| 153 | ]); |
| 154 | |
| 155 | let internal = parsed(&stdout_json(&execute_structcopy( |
| 156 | &mut app, |
| 157 | Some("turn 1 stdout"), |
| 158 | ))); |
| 159 | assert_eq!( |
| 160 | internal["object"]["omission_code"], |
| 161 | json!("internal_context") |
| 162 | ); |
| 163 | assert!(internal["object"].get("omitted").is_none()); |
| 164 | |
| 165 | let image = parsed(&stdout_json(&execute_structcopy( |
| 166 | &mut app, |
| 167 | Some("turn 2 stdout"), |
| 168 | ))); |
| 169 | assert_eq!( |
| 170 | image["object"]["content"][0]["omission_code"], |
| 171 | json!("inline_or_local_image_payload") |
| 172 | ); |
| 173 | assert!(image["object"]["content"][0].get("omitted").is_none()); |
| 174 | |
| 175 | let english = stdout_json(&execute_structcopy(&mut app, Some("turn 2 stdout"))); |
| 176 | app.ui_locale = Locale::ZhHans; |
| 177 | let chinese_ui = stdout_json(&execute_structcopy(&mut app, Some("turn 2 stdout"))); |
| 178 | assert_eq!( |
| 179 | english, chinese_ui, |
| 180 | "machine payload must not vary with the UI locale" |
| 181 | ); |
| 182 | } |
| 183 | |
| 184 | #[test] |
| 185 | fn tool_copy_pairs_call_and_result() { |
| 186 | let tmpdir = TempDir::new().expect("tempdir"); |
| 187 | let mut app = test_app(&tmpdir); |
| 188 | seed_transcript(&mut app); |
| 189 | |
| 190 | let json = stdout_json(&execute_structcopy(&mut app, Some("tool call-7 stdout"))); |
| 191 | let value = parsed(&json); |
| 192 | assert_eq!(value["receipt"]["kind"], json!("tool")); |
| 193 | assert_eq!(value["receipt"]["selector"], json!("call-7")); |
| 194 | assert_eq!(value["object"]["name"], json!("fetch_url")); |
| 195 | assert_eq!(value["object"]["result"]["found"], json!(true)); |
| 196 | assert_eq!(value["object"]["result"]["is_error"], json!(false)); |
| 197 | assert!(!json.contains("result-secret-token"), "{json}"); |
| 198 | |
| 199 | // A call without a result is honest, not fabricated. |
| 200 | app.api_messages_mut()[1] |
| 201 | .content |
| 202 | .push(ContentBlock::ToolUse { |
| 203 | execution_id: None, |
| 204 | id: "call-lonely".to_string(), |
| 205 | name: "view_image".to_string(), |
| 206 | input: json!({}), |
| 207 | caller: None, |
| 208 | thought_signature: None, |
| 209 | }); |
| 210 | let json = stdout_json(&execute_structcopy( |
| 211 | &mut app, |
| 212 | Some("tool call-lonely stdout"), |
| 213 | )); |
| 214 | let value = parsed(&json); |
| 215 | assert_eq!(value["object"]["result"]["found"], json!(false)); |
| 216 | } |
| 217 | |
| 218 | /// An unknown `Option<bool>` must serialize as JSON `null`. Collapsing it |
| 219 | /// to `false` would assert an outcome nothing observed. |
| 220 | #[test] |
| 221 | fn unknown_optional_booleans_stay_null_and_are_not_dropped() { |
| 222 | assert_eq!(optional_bool(None), Value::Null); |
| 223 | assert_eq!(optional_bool(Some(false)), Value::Bool(false)); |
| 224 | assert_eq!(optional_bool(Some(true)), Value::Bool(true)); |
| 225 | |
| 226 | let tmpdir = TempDir::new().expect("tempdir"); |
| 227 | let mut app = test_app(&tmpdir); |
| 228 | app.api_messages = std::sync::Arc::new(vec![ |
| 229 | Message { |
| 230 | role: Role::Assistant, |
| 231 | content: vec![ContentBlock::ToolUse { |
| 232 | execution_id: None, |
| 233 | id: "call-unknown".to_string(), |
| 234 | name: "exec_command".to_string(), |
| 235 | input: json!({}), |
| 236 | // No caller recorded: also an unknown, also null. |
| 237 | caller: None, |
| 238 | thought_signature: None, |
| 239 | }], |
| 240 | }, |
| 241 | Message { |
| 242 | role: Role::User, |
| 243 | content: vec![ContentBlock::ToolResult { |
| 244 | execution_id: None, |
| 245 | tool_use_id: "call-unknown".to_string(), |
| 246 | content: "no error flag was recorded".to_string(), |
| 247 | is_error: None, |
| 248 | content_blocks: None, |
| 249 | }], |
| 250 | }, |
| 251 | ]); |
| 252 | |
| 253 | // Tool-pair projection. |
| 254 | let json = stdout_json(&execute_structcopy( |
| 255 | &mut app, |
| 256 | Some("tool call-unknown stdout"), |
| 257 | )); |
| 258 | let value = parsed(&json); |
| 259 | let result = value["object"]["result"].as_object().expect("result"); |
| 260 | assert!( |
| 261 | result.contains_key("is_error"), |
| 262 | "the unknown flag must be present, not dropped: {json}" |
| 263 | ); |
| 264 | assert_eq!(result["is_error"], Value::Null); |
| 265 | assert_ne!(result["is_error"], json!(false)); |
| 266 | |
| 267 | // Turn projection of the same result block, plus the unknown caller. |
| 268 | let json = stdout_json(&execute_structcopy(&mut app, Some("turn 2 stdout"))); |
| 269 | let value = parsed(&json); |
| 270 | let block = &value["object"]["content"][0]; |
| 271 | assert!( |
| 272 | block.as_object().expect("block").contains_key("is_error"), |
| 273 | "{json}" |
| 274 | ); |
| 275 | assert_eq!(block["is_error"], Value::Null); |
| 276 | |
| 277 | let json = stdout_json(&execute_structcopy(&mut app, Some("turn 1 stdout"))); |
| 278 | let value = parsed(&json); |
| 279 | let block = &value["object"]["content"][0]; |
| 280 | assert!( |
| 281 | block |
| 282 | .as_object() |
| 283 | .expect("block") |
| 284 | .contains_key("caller_type"), |
| 285 | "{json}" |
| 286 | ); |
| 287 | assert_eq!(block["caller_type"], Value::Null); |
| 288 | } |
| 289 | |
| 290 | #[test] |
| 291 | fn plan_copy_snapshots_current_plan() { |
| 292 | let tmpdir = TempDir::new().expect("tempdir"); |
| 293 | let mut app = test_app(&tmpdir); |
| 294 | { |
| 295 | let mut state = app.plan_state.try_lock().expect("plan lock"); |
| 296 | state.update(UpdatePlanArgs { |
| 297 | title: Some("Ship structcopy".to_string()), |
| 298 | plan: vec![ |
| 299 | PlanItemArg { |
| 300 | step: "Read seams".to_string(), |
| 301 | status: StepStatus::Completed, |
| 302 | }, |
| 303 | PlanItemArg { |
| 304 | step: "Copy exactly one object".to_string(), |
| 305 | status: StepStatus::InProgress, |
| 306 | }, |
| 307 | ], |
| 308 | ..Default::default() |
| 309 | }); |
| 310 | } |
| 311 | |
| 312 | let json = stdout_json(&execute_structcopy(&mut app, Some("plan stdout"))); |
| 313 | let value = parsed(&json); |
| 314 | assert_eq!(value["receipt"]["kind"], json!("plan")); |
| 315 | assert_eq!(value["object"]["title"], json!("Ship structcopy")); |
| 316 | let items = value["object"]["items"].as_array().expect("items"); |
| 317 | assert_eq!(items.len(), 2); |
| 318 | assert_eq!(items[1]["status"], json!("in_progress")); |
| 319 | } |
| 320 | |
| 321 | #[test] |
| 322 | fn workflow_copy_projects_existing_run_without_side_effects() { |
| 323 | let tmpdir = TempDir::new().expect("tempdir"); |
| 324 | let mut app = test_app(&tmpdir); |
| 325 | app.current_session_id = Some("structcopy-workflow-test-session".to_string()); |
| 326 | |
| 327 | // Unknown run, no state: honest error, and the read must not create |
| 328 | // the workflow journal on disk. |
| 329 | let missing = execute_structcopy(&mut app, Some("workflow nope stdout")); |
| 330 | assert!(missing.is_error); |
| 331 | assert!( |
| 332 | missing |
| 333 | .message |
| 334 | .as_deref() |
| 335 | .unwrap_or_default() |
| 336 | .contains("unavailable"), |
| 337 | "{:?}", |
| 338 | missing.message |
| 339 | ); |
| 340 | assert!( |
| 341 | !tmpdir.path().join(".codewhale").exists(), |
| 342 | "read-only copy must not create the workflow journal" |
| 343 | ); |
| 344 | |
| 345 | crate::tools::workflow::structcopy_test_seed_run( |
| 346 | tmpdir.path(), |
| 347 | "structcopy-test-run-alpha", |
| 348 | app.current_session_id |
| 349 | .as_deref() |
| 350 | .expect("test session identity"), |
| 351 | ); |
| 352 | let json = stdout_json(&execute_structcopy( |
| 353 | &mut app, |
| 354 | Some("workflow structcopy-test-run-alpha stdout"), |
| 355 | )); |
| 356 | let value = parsed(&json); |
| 357 | assert_eq!(value["receipt"]["kind"], json!("workflow")); |
| 358 | assert_eq!( |
| 359 | value["object"]["run_id"], |
| 360 | json!("structcopy-test-run-alpha") |
| 361 | ); |
| 362 | assert_eq!(value["object"]["status"], json!("running")); |
| 363 | assert_eq!(value["object"]["leaf_count"], Value::Null); |
| 364 | assert_eq!(value["object"]["branch_count"], Value::Null); |
| 365 | assert_eq!(value["object"]["control_count"], Value::Null); |
| 366 | assert!( |
| 367 | value["object"].get("source_path").is_none(), |
| 368 | "filesystem paths must not leave the projection: {json}" |
| 369 | ); |
| 370 | |
| 371 | let unknown = execute_structcopy(&mut app, Some("workflow nope stdout")); |
| 372 | assert!(unknown.is_error); |
| 373 | let message = unknown.message.as_deref().unwrap_or_default(); |
| 374 | assert!(message.contains("unavailable"), "{message}"); |
| 375 | assert!( |
| 376 | !message.contains("structcopy-test-run-alpha"), |
| 377 | "unavailable errors must not enumerate private run ids: {message}" |
| 378 | ); |
| 379 | } |
| 380 | |
| 381 | #[test] |
| 382 | fn unavailable_selectors_are_reported_not_fabricated() { |
| 383 | let tmpdir = TempDir::new().expect("tempdir"); |
| 384 | let mut app = test_app(&tmpdir); |
| 385 | |
| 386 | let empty_turn = execute_structcopy(&mut app, Some("turn 1 stdout")); |
| 387 | assert!(empty_turn.is_error); |
| 388 | assert!( |
| 389 | empty_turn |
| 390 | .message |
| 391 | .as_deref() |
| 392 | .unwrap_or_default() |
| 393 | .contains("unavailable"), |
| 394 | "{:?}", |
| 395 | empty_turn.message |
| 396 | ); |
| 397 | |
| 398 | let empty_plan = execute_structcopy(&mut app, Some("plan stdout")); |
| 399 | assert!(empty_plan.is_error); |
| 400 | assert!( |
| 401 | empty_plan |
| 402 | .message |
| 403 | .as_deref() |
| 404 | .unwrap_or_default() |
| 405 | .contains("unavailable"), |
| 406 | "{:?}", |
| 407 | empty_plan.message |
| 408 | ); |
| 409 | |
| 410 | seed_transcript(&mut app); |
| 411 | let out_of_range = execute_structcopy(&mut app, Some("turn 99 stdout")); |
| 412 | assert!(out_of_range.is_error); |
| 413 | assert!( |
| 414 | out_of_range |
| 415 | .message |
| 416 | .as_deref() |
| 417 | .unwrap_or_default() |
| 418 | .contains("unavailable"), |
| 419 | "{:?}", |
| 420 | out_of_range.message |
| 421 | ); |
| 422 | |
| 423 | let missing_tool = execute_structcopy(&mut app, Some("tool call-nope stdout")); |
| 424 | assert!(missing_tool.is_error); |
| 425 | assert!( |
| 426 | missing_tool |
| 427 | .message |
| 428 | .as_deref() |
| 429 | .unwrap_or_default() |
| 430 | .contains("unavailable"), |
| 431 | "{:?}", |
| 432 | missing_tool.message |
| 433 | ); |
| 434 | |
| 435 | for bad in [ |
| 436 | None, |
| 437 | Some(""), |
| 438 | Some("turn 0"), |
| 439 | Some("turn x"), |
| 440 | Some("turn -1"), |
| 441 | Some("turn 99999999999999999999999999"), |
| 442 | Some("plan extra"), |
| 443 | Some("tool"), |
| 444 | Some("workflow"), |
| 445 | Some("stdout"), |
| 446 | Some(" "), |
| 447 | ] { |
| 448 | let result = execute_structcopy(&mut app, bad); |
| 449 | assert!(result.is_error, "{bad:?}: {:?}", result.message); |
| 450 | } |
| 451 | } |
| 452 | |
| 453 | #[test] |
| 454 | fn command_feedback_uses_the_active_locale() { |
| 455 | let tmpdir = TempDir::new().expect("tempdir"); |
| 456 | let mut app = test_app(&tmpdir); |
| 457 | app.ui_locale = Locale::ZhHans; |
| 458 | |
| 459 | let invalid = execute_structcopy(&mut app, Some("unknown")); |
| 460 | assert!(invalid.is_error); |
| 461 | let expected = tr(Locale::ZhHans, MessageId::CmdStructcopyUsageError) |
| 462 | .replace("{usage}", COMMAND_INFO.usage); |
| 463 | assert!( |
| 464 | invalid |
| 465 | .message |
| 466 | .as_deref() |
| 467 | .is_some_and(|message| message.ends_with(&expected)), |
| 468 | "{:?}", |
| 469 | invalid.message |
| 470 | ); |
| 471 | |
| 472 | let unavailable = execute_structcopy(&mut app, Some("plan stdout")); |
| 473 | assert!(unavailable.is_error); |
| 474 | let expected = tr(Locale::ZhHans, MessageId::CmdStructcopyUnavailable).replace( |
| 475 | "{kind}", |
| 476 | &tr(Locale::ZhHans, MessageId::CmdStructcopyKindPlan), |
| 477 | ); |
| 478 | assert!( |
| 479 | unavailable |
| 480 | .message |
| 481 | .as_deref() |
| 482 | .is_some_and(|message| message.ends_with(&expected)), |
| 483 | "{:?}", |
| 484 | unavailable.message |
| 485 | ); |
| 486 | } |
| 487 | |
| 488 | /// An unavailable selector is never echoed. An available selector is |
| 489 | /// scrubbed and bounded in both the receipt and copied object. |
| 490 | #[test] |
| 491 | fn hostile_selectors_are_redacted_and_bounded_everywhere() { |
| 492 | let tmpdir = TempDir::new().expect("tempdir"); |
| 493 | let mut app = test_app(&tmpdir); |
| 494 | let workspace = tmpdir.path().to_string_lossy().into_owned(); |
| 495 | seed_transcript(&mut app); |
| 496 | |
| 497 | // Unavailable selector: no attacker-influenced bytes are echoed. |
| 498 | let hostile = format!( |
| 499 | "\u{1b}[31mred\u{1b}[0m-Bearer-abcdef1234567890-https://u:p@evil.test/x?k=v#f-{workspace}-{}", |
| 500 | "A".repeat(4096) |
| 501 | ); |
| 502 | let result = execute_structcopy(&mut app, Some(&format!("tool {hostile} stdout"))); |
| 503 | assert!(result.is_error); |
| 504 | let message = result.message.as_deref().unwrap_or_default(); |
| 505 | assert!(message.len() < 400, "status message unbounded: {message}"); |
| 506 | for forbidden in [ |
| 507 | "\u{1b}[31m", |
| 508 | "abcdef1234567890", |
| 509 | "u:p@evil.test", |
| 510 | "k=v", |
| 511 | workspace.as_str(), |
| 512 | ] { |
| 513 | assert!( |
| 514 | !message.contains(forbidden), |
| 515 | "leaked {forbidden:?}: {message}" |
| 516 | ); |
| 517 | } |
| 518 | assert!(!message.contains('\n'), "status label must be one line"); |
| 519 | assert!(message.contains("unavailable"), "{message}"); |
| 520 | |
| 521 | // Receipt path: a long but *available* selector is bounded too. |
| 522 | let long_id = format!("call-{}", "z".repeat(4096)); |
| 523 | app.api_messages_mut()[1] |
| 524 | .content |
| 525 | .push(ContentBlock::ToolUse { |
| 526 | execution_id: None, |
| 527 | id: long_id.clone(), |
| 528 | name: "exec_command".to_string(), |
| 529 | input: json!({}), |
| 530 | caller: None, |
| 531 | thought_signature: None, |
| 532 | }); |
| 533 | let json = stdout_json(&execute_structcopy( |
| 534 | &mut app, |
| 535 | Some(&format!("tool {long_id} stdout")), |
| 536 | )); |
| 537 | let value = parsed(&json); |
| 538 | let selector = value["receipt"]["selector"].as_str().expect("selector"); |
| 539 | assert!( |
| 540 | selector.len() <= MAX_SELECTOR_BYTES, |
| 541 | "selector {} bytes exceeds the {MAX_SELECTOR_BYTES}-byte cap", |
| 542 | selector.len() |
| 543 | ); |
| 544 | assert!(selector.ends_with('…'), "{selector}"); |
| 545 | |
| 546 | // Composer selectors cannot contain a whitespace-delimited `Bearer` |
| 547 | // header, so delimiter-shaped bearer tokens are scrubbed too. |
| 548 | for bearer_id in [ |
| 549 | "call-Bearer-abcdef1234567890", |
| 550 | "call-Bearer=zyxwvutsrqponmlk", |
| 551 | ] { |
| 552 | app.api_messages_mut()[1] |
| 553 | .content |
| 554 | .push(ContentBlock::ToolUse { |
| 555 | execution_id: None, |
| 556 | id: bearer_id.to_string(), |
| 557 | name: "exec_command".to_string(), |
| 558 | input: json!({}), |
| 559 | caller: None, |
| 560 | thought_signature: None, |
| 561 | }); |
| 562 | let json = stdout_json(&execute_structcopy( |
| 563 | &mut app, |
| 564 | Some(&format!("tool {bearer_id} stdout")), |
| 565 | )); |
| 566 | assert!(!json.contains("abcdef1234567890"), "{json}"); |
| 567 | assert!(!json.contains("zyxwvutsrqponmlk"), "{json}"); |
| 568 | assert!(json.contains(BEARER_REDACTION_MARKER), "{json}"); |
| 569 | } |
| 570 | } |
| 571 | |
| 572 | /// Object keys are attacker-influenced too (a model can name a tool-input |
| 573 | /// field anything). Keys must be sanitized, bounded, and de-collided |
| 574 | /// deterministically without dropping a value. |
| 575 | #[test] |
| 576 | fn hostile_object_keys_are_scrubbed_bounded_and_deduped_deterministically() { |
| 577 | let tmpdir = TempDir::new().expect("tempdir"); |
| 578 | let mut app = test_app(&tmpdir); |
| 579 | let workspace = tmpdir.path().to_string_lossy().into_owned(); |
| 580 | |
| 581 | // Three keys that collapse onto the same bounded form, one key with |
| 582 | // ANSI + newlines, and one key carrying a workspace path. |
| 583 | let long_a = format!("k{}A", "x".repeat(MAX_KEY_BYTES)); |
| 584 | let long_b = format!("k{}B", "x".repeat(MAX_KEY_BYTES)); |
| 585 | let long_c = format!("k{}C", "x".repeat(MAX_KEY_BYTES)); |
| 586 | let input = json!({ |
| 587 | long_a.clone(): 1, |
| 588 | long_b.clone(): 2, |
| 589 | long_c.clone(): 3, |
| 590 | // Not a credential-shaped name: a key ending in `key` is redacted by |
| 591 | // the shared vocabulary, which other tests cover. |
| 592 | "\u{1b}[31mansi\u{1b}[0m\nlabel": 4, |
| 593 | format!("at {workspace}/src"): 5, |
| 594 | }); |
| 595 | app.api_messages = std::sync::Arc::new(vec![Message { |
| 596 | role: Role::Assistant, |
| 597 | content: vec![ContentBlock::ToolUse { |
| 598 | execution_id: None, |
| 599 | id: "call-keys".to_string(), |
| 600 | name: "exec_command".to_string(), |
| 601 | input, |
| 602 | caller: None, |
| 603 | thought_signature: None, |
| 604 | }], |
| 605 | }]); |
| 606 | |
| 607 | let first = stdout_json(&execute_structcopy(&mut app, Some("tool call-keys stdout"))); |
| 608 | let second = stdout_json(&execute_structcopy(&mut app, Some("tool call-keys stdout"))); |
| 609 | assert_eq!( |
| 610 | first, second, |
| 611 | "key collision handling must be deterministic" |
| 612 | ); |
| 613 | |
| 614 | let value = parsed(&first); |
| 615 | let object = value["object"]["input"].as_object().expect("input"); |
| 616 | // No value is lost to a collision. |
| 617 | assert_eq!(object.len(), 5, "{object:?}"); |
| 618 | let mut values: Vec<u64> = object |
| 619 | .values() |
| 620 | .map(|item| item.as_u64().expect("number")) |
| 621 | .collect(); |
| 622 | values.sort_unstable(); |
| 623 | assert_eq!(values, vec![1, 2, 3, 4, 5]); |
| 624 | |
| 625 | for key in object.keys() { |
| 626 | assert!( |
| 627 | key.len() <= MAX_KEY_BYTES, |
| 628 | "key {} bytes exceeds the {MAX_KEY_BYTES}-byte cap", |
| 629 | key.len() |
| 630 | ); |
| 631 | assert!(!key.contains('\u{1b}'), "ANSI survived in key {key:?}"); |
| 632 | assert!(!key.contains('\n'), "newline survived in key {key:?}"); |
| 633 | assert!(!key.contains(&workspace), "workspace path in key {key:?}"); |
| 634 | } |
| 635 | assert!( |
| 636 | object.keys().any(|key| key.contains("<workspace>")), |
| 637 | "{object:?}" |
| 638 | ); |
| 639 | |
| 640 | let counts = &value["receipt"]["counts"]; |
| 641 | assert_eq!( |
| 642 | counts["object_keys_original"], |
| 643 | counts["object_keys_retained"] |
| 644 | ); |
| 645 | assert_eq!(counts["object_keys_truncated"], json!(3)); |
| 646 | assert!( |
| 647 | counts["object_keys_deduped"].as_u64().expect("deduped") >= 2, |
| 648 | "{counts}" |
| 649 | ); |
| 650 | let reasons = value["receipt"]["reasons"].as_array().expect("reasons"); |
| 651 | assert!( |
| 652 | reasons.contains(&json!("object_key_bytes_cap")), |
| 653 | "{reasons:?}" |
| 654 | ); |
| 655 | assert!( |
| 656 | reasons.contains(&json!("object_key_collision")), |
| 657 | "{reasons:?}" |
| 658 | ); |
| 659 | } |
| 660 | |
| 661 | #[test] |
| 662 | fn sensitive_keys_are_classified_after_control_and_ansi_normalization() { |
| 663 | let tmpdir = TempDir::new().expect("tempdir"); |
| 664 | let mut app = test_app(&tmpdir); |
| 665 | app.api_messages = std::sync::Arc::new(vec![Message { |
| 666 | role: Role::Assistant, |
| 667 | content: vec![ContentBlock::ToolUse { |
| 668 | execution_id: None, |
| 669 | id: "call-obfuscated-keys".to_string(), |
| 670 | name: "exec_command".to_string(), |
| 671 | input: json!({ |
| 672 | "api\u{1b}[31m_key": "plain-value-that-must-not-leak", |
| 673 | "pass\u{7}word": "another-plain-value-that-must-not-leak", |
| 674 | }), |
| 675 | caller: None, |
| 676 | thought_signature: None, |
| 677 | }], |
| 678 | }]); |
| 679 | |
| 680 | let json = stdout_json(&execute_structcopy( |
| 681 | &mut app, |
| 682 | Some("tool call-obfuscated-keys stdout"), |
| 683 | )); |
| 684 | assert!(!json.contains("plain-value-that-must-not-leak"), "{json}"); |
| 685 | assert!( |
| 686 | !json.contains("another-plain-value-that-must-not-leak"), |
| 687 | "{json}" |
| 688 | ); |
| 689 | let value = parsed(&json); |
| 690 | assert_eq!( |
| 691 | value["object"]["input"]["api_key"], |
| 692 | json!(SENSITIVE_VALUE_REDACTION_MARKER) |
| 693 | ); |
| 694 | assert_eq!( |
| 695 | value["object"]["input"]["password"], |
| 696 | json!(SENSITIVE_VALUE_REDACTION_MARKER) |
| 697 | ); |
| 698 | } |
| 699 | |
| 700 | #[test] |
| 701 | fn collision_suffix_reserve_reports_its_own_truncation() { |
| 702 | let tmpdir = TempDir::new().expect("tempdir"); |
| 703 | let mut app = test_app(&tmpdir); |
| 704 | let exact = "x".repeat(MAX_KEY_BYTES); |
| 705 | let same_after_flatten = format!("{exact}\n"); |
| 706 | app.api_messages = std::sync::Arc::new(vec![Message { |
| 707 | role: Role::Assistant, |
| 708 | content: vec![ContentBlock::ToolUse { |
| 709 | execution_id: None, |
| 710 | id: "call-reserve".to_string(), |
| 711 | name: "exec_command".to_string(), |
| 712 | input: json!({exact: 1, same_after_flatten: 2}), |
| 713 | caller: None, |
| 714 | thought_signature: None, |
| 715 | }], |
| 716 | }]); |
| 717 | |
| 718 | let json = stdout_json(&execute_structcopy( |
| 719 | &mut app, |
| 720 | Some("tool call-reserve stdout"), |
| 721 | )); |
| 722 | let value = parsed(&json); |
| 723 | let input = value["object"]["input"].as_object().expect("input"); |
| 724 | assert_eq!(input.len(), 2); |
| 725 | assert!(input.keys().all(|key| key.len() <= MAX_KEY_BYTES)); |
| 726 | let counts = &value["receipt"]["counts"]; |
| 727 | assert_eq!(counts["object_keys_deduped"], json!(1)); |
| 728 | assert_eq!(counts["object_keys_truncated"], json!(1)); |
| 729 | let reasons = value["receipt"]["reasons"].as_array().expect("reasons"); |
| 730 | assert!( |
| 731 | reasons.contains(&json!("object_key_collision")), |
| 732 | "{reasons:?}" |
| 733 | ); |
| 734 | assert!( |
| 735 | reasons.contains(&json!("object_key_bytes_cap")), |
| 736 | "{reasons:?}" |
| 737 | ); |
| 738 | } |
| 739 | |
| 740 | #[test] |
| 741 | fn output_is_deterministic_with_recursively_sorted_keys() { |
| 742 | let tmpdir = TempDir::new().expect("tempdir"); |
| 743 | let mut app = test_app(&tmpdir); |
| 744 | seed_transcript(&mut app); |
| 745 | |
| 746 | let first = stdout_json(&execute_structcopy(&mut app, Some("tool call-7 stdout"))); |
| 747 | let second = stdout_json(&execute_structcopy(&mut app, Some("tool call-7 stdout"))); |
| 748 | assert_eq!(first, second, "output must be byte-for-byte deterministic"); |
| 749 | |
| 750 | let value = parsed(&first); |
| 751 | let top: Vec<&str> = value |
| 752 | .as_object() |
| 753 | .expect("object") |
| 754 | .keys() |
| 755 | .map(String::as_str) |
| 756 | .collect(); |
| 757 | assert_eq!(top, ["object", "receipt"]); |
| 758 | let receipt: Vec<&String> = value["receipt"] |
| 759 | .as_object() |
| 760 | .expect("receipt") |
| 761 | .keys() |
| 762 | .collect(); |
| 763 | let mut sorted = receipt.clone(); |
| 764 | sorted.sort(); |
| 765 | assert_eq!(receipt, sorted, "receipt keys must be sorted"); |
| 766 | let counts: Vec<&String> = value["receipt"]["counts"] |
| 767 | .as_object() |
| 768 | .expect("counts") |
| 769 | .keys() |
| 770 | .collect(); |
| 771 | let mut sorted_counts = counts.clone(); |
| 772 | sorted_counts.sort(); |
| 773 | assert_eq!(counts, sorted_counts, "counts keys must be sorted"); |
| 774 | let object: Vec<&String> = value["object"] |
| 775 | .as_object() |
| 776 | .expect("object") |
| 777 | .keys() |
| 778 | .collect(); |
| 779 | let mut sorted_object = object.clone(); |
| 780 | sorted_object.sort(); |
| 781 | assert_eq!(object, sorted_object, "object keys must be sorted"); |
| 782 | } |
| 783 | |
| 784 | #[test] |
| 785 | fn hostile_content_is_redacted_before_serialization() { |
| 786 | let tmpdir = TempDir::new().expect("tempdir"); |
| 787 | let mut app = test_app(&tmpdir); |
| 788 | let workspace = tmpdir.path().to_string_lossy().into_owned(); |
| 789 | app.api_messages = std::sync::Arc::new(vec![Message { |
| 790 | role: Role::User, |
| 791 | content: vec![ContentBlock::Text { |
| 792 | text: format!( |
| 793 | "escaped \\\"api_key\\\": \\\"sk-escapedsecret99\\\"\n\ |
| 794 | bearer: Bearer abcdef1234567890\n\ |
| 795 | jwt eyJhbGciOiJIUzI1NiIsFAKE.eyJGQUtFIjoiZml4dHVyZSJ9.FAKEFIXTURESIGNATUREnotasecret000\n\ |
| 796 | url https://bob:s3cret@example.com/deep?session_token=xyz&ok=1#section\n\ |
| 797 | path {workspace}/src/main.rs" |
| 798 | ), |
| 799 | cache_control: None, |
| 800 | }], |
| 801 | }]); |
| 802 | |
| 803 | let json = stdout_json(&execute_structcopy(&mut app, Some("turn 1 stdout"))); |
| 804 | for forbidden in [ |
| 805 | "sk-escapedsecret99", |
| 806 | "abcdef1234567890", |
| 807 | "eyJhbGciOiJIUzI1NiIs", |
| 808 | "s3cret", |
| 809 | "session_token=xyz", |
| 810 | "section", |
| 811 | workspace.as_str(), |
| 812 | ] { |
| 813 | assert!(!json.contains(forbidden), "leaked {forbidden:?}: {json}"); |
| 814 | } |
| 815 | assert!(json.contains("https://example.com/deep"), "{json}"); |
| 816 | assert!(json.contains("<workspace>/src/main.rs"), "{json}"); |
| 817 | assert!(parsed(&json).is_object()); |
| 818 | } |
| 819 | |
| 820 | /// Workspace/home paths retain useful labels. Every other absolute POSIX, |
| 821 | /// drive-letter, and UNC path is removed from copied values. |
| 822 | #[test] |
| 823 | fn path_labels_preserve_known_roots_and_scrub_every_other_absolute_path() { |
| 824 | let tmpdir = TempDir::new().expect("tempdir"); |
| 825 | let workspace = tmpdir.path().to_path_buf(); |
| 826 | let labels = PathLabels::new(&observe_path_roots(&workspace)); |
| 827 | let literal = workspace.to_string_lossy().into_owned(); |
| 828 | |
| 829 | let folded = labels.apply(&format!("open {literal}/src/main.rs now")); |
| 830 | assert_eq!(folded, "open <workspace>/src/main.rs now"); |
| 831 | assert!(!folded.contains(&literal)); |
| 832 | assert_eq!( |
| 833 | scrub_string(&format!("open {literal}/src/main.rs now"), &labels), |
| 834 | "open <workspace>/src/main.rs now" |
| 835 | ); |
| 836 | |
| 837 | // The canonical form folds too (macOS /var -> /private/var). |
| 838 | if let Ok(canonical) = workspace.canonicalize() { |
| 839 | let canonical = canonical.to_string_lossy().into_owned(); |
| 840 | let folded = labels.apply(&format!("open {canonical}/src/main.rs")); |
| 841 | assert_eq!(folded, "open <workspace>/src/main.rs"); |
| 842 | } |
| 843 | |
| 844 | // Repeated occurrences all fold, not just the first. |
| 845 | let folded = labels.apply(&format!("{literal}/a and {literal}/b")); |
| 846 | assert_eq!(folded, "<workspace>/a and <workspace>/b"); |
| 847 | |
| 848 | // Prefix folding itself only handles known roots; the composed scrub |
| 849 | // removes every foreign absolute path before serialization. |
| 850 | let foreign = "/opt/other/place/file.txt"; |
| 851 | assert_eq!(labels.apply(foreign), foreign); |
| 852 | assert_eq!(scrub_string(foreign, &labels), PATH_OMISSION_MARKER); |
| 853 | assert_eq!( |
| 854 | scrub_string(r"C:\Users\customer\secret.txt", &labels), |
| 855 | PATH_OMISSION_MARKER |
| 856 | ); |
| 857 | assert_eq!( |
| 858 | scrub_string(r"\\server\private\customer.txt", &labels), |
| 859 | PATH_OMISSION_MARKER |
| 860 | ); |
| 861 | let spaced = scrub_string( |
| 862 | "open /Volumes/Client Name/private file.txt then continue\nsecond line", |
| 863 | &labels, |
| 864 | ); |
| 865 | assert_eq!(spaced, format!("open {PATH_OMISSION_MARKER}\nsecond line")); |
| 866 | |
| 867 | // A workspace nested inside $HOME folds to <workspace>, not <home>. |
| 868 | if let Some(home) = std::env::var_os("HOME") { |
| 869 | let home = home.to_string_lossy().into_owned(); |
| 870 | if home.len() > 3 { |
| 871 | let nested = |
| 872 | PathLabels::new(&observe_path_roots(Path::new(&format!("{home}/nested/ws")))); |
| 873 | let folded = nested.apply(&format!("{home}/nested/ws/src")); |
| 874 | assert_eq!(folded, "<workspace>/src"); |
| 875 | assert_eq!( |
| 876 | nested.apply(&format!("{home}/elsewhere")), |
| 877 | "<home>/elsewhere" |
| 878 | ); |
| 879 | assert_eq!( |
| 880 | scrub_string(&format!("{home}/elsewhere/file.rs"), &nested), |
| 881 | "<home>/elsewhere/file.rs" |
| 882 | ); |
| 883 | } |
| 884 | } |
| 885 | } |
| 886 | |
| 887 | #[test] |
| 888 | fn absolute_paths_are_scrubbed_from_values_keys_and_selectors() { |
| 889 | let tmpdir = TempDir::new().expect("tempdir"); |
| 890 | let mut app = test_app(&tmpdir); |
| 891 | let call_id = "call=/opt/customer/private-id"; |
| 892 | app.api_messages = std::sync::Arc::new(vec![Message { |
| 893 | role: Role::Assistant, |
| 894 | content: vec![ContentBlock::ToolUse { |
| 895 | execution_id: None, |
| 896 | id: call_id.to_string(), |
| 897 | name: "exec_command".to_string(), |
| 898 | input: json!({ |
| 899 | "/Volumes/ClientSecret/source.rs": "open C:\\Users\\customer\\secret.txt", |
| 900 | "unc": r"\\server\private\customer.txt", |
| 901 | }), |
| 902 | caller: None, |
| 903 | thought_signature: None, |
| 904 | }], |
| 905 | }]); |
| 906 | |
| 907 | let json = stdout_json(&execute_structcopy( |
| 908 | &mut app, |
| 909 | Some(&format!("tool {call_id} stdout")), |
| 910 | )); |
| 911 | for forbidden in [ |
| 912 | "/opt/customer/private-id", |
| 913 | "/Volumes/ClientSecret/source.rs", |
| 914 | r"C:\Users\customer\secret.txt", |
| 915 | r"\\server\private\customer.txt", |
| 916 | "ClientSecret", |
| 917 | "customer", |
| 918 | ] { |
| 919 | assert!(!json.contains(forbidden), "leaked {forbidden:?}: {json}"); |
| 920 | } |
| 921 | assert!(json.contains(PATH_OMISSION_MARKER), "{json}"); |
| 922 | } |
| 923 | |
| 924 | #[test] |
| 925 | fn string_bytes_cap_truncates_grapheme_safely() { |
| 926 | let tmpdir = TempDir::new().expect("tempdir"); |
| 927 | let mut app = test_app(&tmpdir); |
| 928 | app.api_messages = std::sync::Arc::new(vec![Message { |
| 929 | role: Role::User, |
| 930 | content: vec![ContentBlock::Text { |
| 931 | text: "emoji cluster test: 👨👩👧👦🏳️🌈 repeated many times over".repeat(20), |
| 932 | cache_control: None, |
| 933 | }], |
| 934 | }]); |
| 935 | let caps = Caps { |
| 936 | max_string_bytes: 40, |
| 937 | ..DEFAULT_CAPS |
| 938 | }; |
| 939 | let json = render_copy(&mut app, &CopyKind::Turn(1), &caps).expect("render"); |
| 940 | let value = parsed(&json); |
| 941 | let text = value["object"]["content"][0]["text"] |
| 942 | .as_str() |
| 943 | .expect("text"); |
| 944 | assert!(text.ends_with('…'), "{text}"); |
| 945 | assert!(text.len() <= 40, "{} bytes", text.len()); |
| 946 | assert_eq!(value["receipt"]["counts"]["strings_truncated"], json!(1)); |
| 947 | assert_eq!(value["receipt"]["reasons"], json!(["string_bytes_cap"])); |
| 948 | let original = value["receipt"]["counts"]["string_bytes_original"] |
| 949 | .as_u64() |
| 950 | .expect("original"); |
| 951 | let retained = value["receipt"]["counts"]["string_bytes_retained"] |
| 952 | .as_u64() |
| 953 | .expect("retained"); |
| 954 | assert!(original > retained); |
| 955 | } |
| 956 | |
| 957 | /// A cap below the ellipsis's own 3 bytes has no representable |
| 958 | /// "truncated" form. It must stay in-bounds and stay honest rather than |
| 959 | /// panic, overflow, or emit partial content. |
| 960 | #[test] |
| 961 | fn string_cap_below_the_ellipsis_is_safe() { |
| 962 | for max_bytes in 0..=4usize { |
| 963 | for text in ["", "a", "ab", "abc", "abcd", "é", "👨👩👧👦", "héllo wörld"] |
| 964 | { |
| 965 | let (out, truncated) = truncate_string_grapheme_safe(text, max_bytes); |
| 966 | assert!( |
| 967 | out.len() <= max_bytes.max(text.len()), |
| 968 | "cap {max_bytes} text {text:?} -> {out:?}" |
| 969 | ); |
| 970 | if text.len() <= max_bytes { |
| 971 | assert!(!truncated); |
| 972 | assert_eq!(out, text); |
| 973 | } else { |
| 974 | assert!(truncated, "cap {max_bytes} text {text:?}"); |
| 975 | assert!( |
| 976 | out.len() <= max_bytes, |
| 977 | "cap {max_bytes} text {text:?} -> {} bytes", |
| 978 | out.len() |
| 979 | ); |
| 980 | if max_bytes < 3 { |
| 981 | assert!( |
| 982 | out.is_empty(), |
| 983 | "no partial content may escape below the marker size: {out:?}" |
| 984 | ); |
| 985 | } else { |
| 986 | assert!(out.ends_with('…'), "cap {max_bytes} -> {out:?}"); |
| 987 | } |
| 988 | } |
| 989 | assert!(std::str::from_utf8(out.as_bytes()).is_ok()); |
| 990 | } |
| 991 | } |
| 992 | |
| 993 | // End to end: the whole pipeline survives a sub-ellipsis cap and the |
| 994 | // receipt still reports the truncation. |
| 995 | let tmpdir = TempDir::new().expect("tempdir"); |
| 996 | let mut app = test_app(&tmpdir); |
| 997 | app.api_messages = std::sync::Arc::new(vec![Message { |
| 998 | role: Role::User, |
| 999 | content: vec![ContentBlock::Text { |
| 1000 | text: "a longer body that cannot fit".to_string(), |
| 1001 | cache_control: None, |
| 1002 | }], |
| 1003 | }]); |
| 1004 | let caps = Caps { |
| 1005 | max_string_bytes: 1, |
| 1006 | ..DEFAULT_CAPS |
| 1007 | }; |
| 1008 | let json = render_copy(&mut app, &CopyKind::Turn(1), &caps).expect("render"); |
| 1009 | let value = parsed(&json); |
| 1010 | assert_eq!(value["object"]["content"][0]["text"], json!("")); |
| 1011 | assert!( |
| 1012 | value["receipt"]["counts"]["strings_truncated"] |
| 1013 | .as_u64() |
| 1014 | .expect("truncated") |
| 1015 | >= 1 |
| 1016 | ); |
| 1017 | } |
| 1018 | |
| 1019 | #[test] |
| 1020 | fn array_items_cap_counts_original_and_retained_exactly() { |
| 1021 | let tmpdir = TempDir::new().expect("tempdir"); |
| 1022 | let mut app = test_app(&tmpdir); |
| 1023 | { |
| 1024 | let mut state = app.plan_state.try_lock().expect("plan lock"); |
| 1025 | state.update(UpdatePlanArgs { |
| 1026 | plan: (0..10) |
| 1027 | .map(|index| PlanItemArg { |
| 1028 | step: format!("step {index}"), |
| 1029 | status: StepStatus::Pending, |
| 1030 | }) |
| 1031 | .collect(), |
| 1032 | ..Default::default() |
| 1033 | }); |
| 1034 | } |
| 1035 | let caps = Caps { |
| 1036 | max_array_items: 3, |
| 1037 | ..DEFAULT_CAPS |
| 1038 | }; |
| 1039 | let json = render_copy(&mut app, &CopyKind::Plan, &caps).expect("render"); |
| 1040 | let value = parsed(&json); |
| 1041 | assert_eq!(value["object"]["items"].as_array().expect("items").len(), 3); |
| 1042 | assert_eq!( |
| 1043 | value["receipt"]["counts"]["array_items_original"], |
| 1044 | json!(10) |
| 1045 | ); |
| 1046 | assert_eq!(value["receipt"]["counts"]["array_items_retained"], json!(3)); |
| 1047 | assert_eq!(value["receipt"]["reasons"], json!(["array_items_cap"])); |
| 1048 | } |
| 1049 | |
| 1050 | #[test] |
| 1051 | fn depth_cap_omits_deep_subtrees() { |
| 1052 | let tmpdir = TempDir::new().expect("tempdir"); |
| 1053 | let mut app = test_app(&tmpdir); |
| 1054 | app.api_messages = std::sync::Arc::new(vec![Message { |
| 1055 | role: Role::Assistant, |
| 1056 | content: vec![ContentBlock::ToolUse { |
| 1057 | execution_id: None, |
| 1058 | id: "call-deep".to_string(), |
| 1059 | name: "exec_command".to_string(), |
| 1060 | input: json!({"a": {"b": {"c": {"d": {"e": "too deep"}}}}}), |
| 1061 | caller: None, |
| 1062 | thought_signature: None, |
| 1063 | }], |
| 1064 | }]); |
| 1065 | let caps = Caps { |
| 1066 | max_depth: 3, |
| 1067 | ..DEFAULT_CAPS |
| 1068 | }; |
| 1069 | let json = |
| 1070 | render_copy(&mut app, &CopyKind::Tool("call-deep".to_string()), &caps).expect("render"); |
| 1071 | let value = parsed(&json); |
| 1072 | assert!(json.contains(DEPTH_OMISSION_MARKER), "{json}"); |
| 1073 | assert!(!json.contains("too deep"), "{json}"); |
| 1074 | let omissions = value["receipt"]["counts"]["depth_omissions"] |
| 1075 | .as_u64() |
| 1076 | .expect("omissions"); |
| 1077 | assert!(omissions >= 1, "{omissions}"); |
| 1078 | assert!( |
| 1079 | value["receipt"]["reasons"] |
| 1080 | .as_array() |
| 1081 | .expect("reasons") |
| 1082 | .contains(&json!("depth_cap")) |
| 1083 | ); |
| 1084 | } |
| 1085 | |
| 1086 | /// The original counts describe the full redacted tree; the retained |
| 1087 | /// counts describe exactly what was emitted, marker strings included. |
| 1088 | /// Both must be checkable against the artifact itself. |
| 1089 | #[test] |
| 1090 | fn counts_stay_exact_across_a_depth_omission() { |
| 1091 | let tmpdir = TempDir::new().expect("tempdir"); |
| 1092 | let mut app = test_app(&tmpdir); |
| 1093 | // Two strings and two array items live below the depth cut, plus one |
| 1094 | // string and one array item above it. |
| 1095 | app.api_messages = std::sync::Arc::new(vec![Message { |
| 1096 | role: Role::Assistant, |
| 1097 | content: vec![ContentBlock::ToolUse { |
| 1098 | execution_id: None, |
| 1099 | id: "call-counts".to_string(), |
| 1100 | name: "exec_command".to_string(), |
| 1101 | input: json!({ |
| 1102 | "shallow": ["kept"], |
| 1103 | "deep": {"one": {"two": ["cut-a", "cut-b"]}}, |
| 1104 | }), |
| 1105 | caller: None, |
| 1106 | thought_signature: None, |
| 1107 | }], |
| 1108 | }]); |
| 1109 | let caps = Caps { |
| 1110 | max_depth: 3, |
| 1111 | ..DEFAULT_CAPS |
| 1112 | }; |
| 1113 | let json = |
| 1114 | render_copy(&mut app, &CopyKind::Tool("call-counts".to_string()), &caps).expect("render"); |
| 1115 | let value = parsed(&json); |
| 1116 | let counts = &value["receipt"]["counts"]; |
| 1117 | |
| 1118 | // Independently recount the emitted object and compare. |
| 1119 | let mut emitted = BoundStats::default(); |
| 1120 | collect_original_counts(&value["object"], &mut emitted); |
| 1121 | assert_eq!( |
| 1122 | counts["strings_retained"].as_u64().expect("retained"), |
| 1123 | emitted.strings_total, |
| 1124 | "retained string count must match the emitted artifact: {json}" |
| 1125 | ); |
| 1126 | assert_eq!( |
| 1127 | counts["string_bytes_retained"] |
| 1128 | .as_u64() |
| 1129 | .expect("retained bytes"), |
| 1130 | emitted.string_bytes_original, |
| 1131 | "retained bytes must include the depth marker: {json}" |
| 1132 | ); |
| 1133 | assert_eq!( |
| 1134 | counts["array_items_retained"].as_u64().expect("items"), |
| 1135 | emitted.array_items_original, |
| 1136 | "{json}" |
| 1137 | ); |
| 1138 | |
| 1139 | // Originals cover the *whole* tree, including the omitted subtree. |
| 1140 | assert!( |
| 1141 | counts["strings_total"].as_u64().expect("total") |
| 1142 | > counts["strings_retained"].as_u64().expect("retained"), |
| 1143 | "originals must count strings under the depth cut: {counts}" |
| 1144 | ); |
| 1145 | assert!( |
| 1146 | counts["array_items_original"].as_u64().expect("original") |
| 1147 | > counts["array_items_retained"].as_u64().expect("retained"), |
| 1148 | "originals must count array items under the depth cut: {counts}" |
| 1149 | ); |
| 1150 | assert_eq!(counts["depth_omissions"], json!(1)); |
| 1151 | assert!( |
| 1152 | counts["object_keys_original"] |
| 1153 | .as_u64() |
| 1154 | .expect("original keys") |
| 1155 | > counts["object_keys_retained"] |
| 1156 | .as_u64() |
| 1157 | .expect("retained keys"), |
| 1158 | "keys under the depth cut must be original-only: {counts}" |
| 1159 | ); |
| 1160 | } |
| 1161 | |
| 1162 | #[test] |
| 1163 | fn omitted_key_transformations_do_not_claim_emitted_reasons() { |
| 1164 | let tmpdir = TempDir::new().expect("tempdir"); |
| 1165 | let mut app = test_app(&tmpdir); |
| 1166 | let long_a = format!("{}A", "private-key-name-".repeat(32)); |
| 1167 | let long_b = format!("{}B", "private-key-name-".repeat(32)); |
| 1168 | app.api_messages = std::sync::Arc::new(vec![Message { |
| 1169 | role: Role::Assistant, |
| 1170 | content: vec![ContentBlock::ToolUse { |
| 1171 | execution_id: None, |
| 1172 | id: "call-deep-keys".to_string(), |
| 1173 | name: "exec_command".to_string(), |
| 1174 | input: json!({"deep": {"one": {long_a: 1, long_b: 2}}}), |
| 1175 | caller: None, |
| 1176 | thought_signature: None, |
| 1177 | }], |
| 1178 | }]); |
| 1179 | let caps = Caps { |
| 1180 | max_depth: 3, |
| 1181 | ..DEFAULT_CAPS |
| 1182 | }; |
| 1183 | let json = render_copy( |
| 1184 | &mut app, |
| 1185 | &CopyKind::Tool("call-deep-keys".to_string()), |
| 1186 | &caps, |
| 1187 | ) |
| 1188 | .expect("render"); |
| 1189 | let value = parsed(&json); |
| 1190 | let counts = &value["receipt"]["counts"]; |
| 1191 | assert!( |
| 1192 | counts["object_keys_original"].as_u64().expect("original") |
| 1193 | > counts["object_keys_retained"].as_u64().expect("retained"), |
| 1194 | "{counts}" |
| 1195 | ); |
| 1196 | assert_eq!(counts["object_keys_truncated"], json!(0)); |
| 1197 | assert_eq!(counts["object_keys_deduped"], json!(0)); |
| 1198 | let reasons = value["receipt"]["reasons"].as_array().expect("reasons"); |
| 1199 | assert!( |
| 1200 | !reasons.contains(&json!("object_key_bytes_cap")), |
| 1201 | "{reasons:?}" |
| 1202 | ); |
| 1203 | assert!( |
| 1204 | !reasons.contains(&json!("object_key_collision")), |
| 1205 | "{reasons:?}" |
| 1206 | ); |
| 1207 | } |
| 1208 | |
| 1209 | #[test] |
| 1210 | fn output_bytes_cap_omits_payload_then_fails_closed() { |
| 1211 | let tmpdir = TempDir::new().expect("tempdir"); |
| 1212 | let mut app = test_app(&tmpdir); |
| 1213 | { |
| 1214 | let mut state = app.plan_state.try_lock().expect("plan lock"); |
| 1215 | state.update(UpdatePlanArgs { |
| 1216 | title: Some("large plan".to_string()), |
| 1217 | plan: (0..60) |
| 1218 | .map(|index| PlanItemArg { |
| 1219 | step: format!("step {index}: {}", "padding ".repeat(40)), |
| 1220 | status: StepStatus::Pending, |
| 1221 | }) |
| 1222 | .collect(), |
| 1223 | ..Default::default() |
| 1224 | }); |
| 1225 | } |
| 1226 | |
| 1227 | // Tight byte cap: payload must be omitted while the receipt survives. |
| 1228 | let caps = Caps { |
| 1229 | max_output_bytes: 2 * 1024, |
| 1230 | ..DEFAULT_CAPS |
| 1231 | }; |
| 1232 | let json = render_copy(&mut app, &CopyKind::Plan, &caps).expect("render"); |
| 1233 | assert!(json.len() <= 2 * 1024, "{} bytes", json.len()); |
| 1234 | let value = parsed(&json); |
| 1235 | assert_eq!(value["object"], Value::Null); |
| 1236 | let reasons = value["receipt"]["reasons"].as_array().expect("reasons"); |
| 1237 | assert!( |
| 1238 | reasons.contains(&json!("payload_omitted_output_bytes_cap")), |
| 1239 | "{reasons:?}" |
| 1240 | ); |
| 1241 | // Nothing was emitted, so no retained counter and no bounding reason |
| 1242 | // may claim otherwise. |
| 1243 | for retained in [ |
| 1244 | "array_items_retained", |
| 1245 | "string_bytes_retained", |
| 1246 | "strings_retained", |
| 1247 | "strings_truncated", |
| 1248 | "depth_omissions", |
| 1249 | "object_keys_retained", |
| 1250 | "object_keys_truncated", |
| 1251 | "object_keys_deduped", |
| 1252 | ] { |
| 1253 | assert_eq!( |
| 1254 | value["receipt"]["counts"][retained], |
| 1255 | json!(0), |
| 1256 | "{retained} must be zero when nothing was emitted: {json}" |
| 1257 | ); |
| 1258 | } |
| 1259 | assert_eq!(reasons.len(), 1, "{reasons:?}"); |
| 1260 | assert_eq!( |
| 1261 | value["receipt"]["counts"]["array_items_original"], |
| 1262 | json!(60) |
| 1263 | ); |
| 1264 | assert!( |
| 1265 | value["receipt"]["counts"]["object_keys_original"] |
| 1266 | .as_u64() |
| 1267 | .expect("original keys") |
| 1268 | > 0 |
| 1269 | ); |
| 1270 | |
| 1271 | // Below the metadata floor the command fails closed and emits nothing. |
| 1272 | let tiny = Caps { |
| 1273 | max_output_bytes: 64, |
| 1274 | ..DEFAULT_CAPS |
| 1275 | }; |
| 1276 | let err = render_copy(&mut app, &CopyKind::Plan, &tiny).expect_err("must fail closed"); |
| 1277 | assert!(err.contains("refusing to emit"), "{err}"); |
| 1278 | let result = execute_structcopy(&mut app, Some("plan stdout")); |
| 1279 | assert!(!result.is_error, "default caps fit: {:?}", result.message); |
| 1280 | } |
| 1281 | |
| 1282 | /// When the byte cap forces tighter caps than the declared contract, the |
| 1283 | /// receipt must say so instead of advertising caps that never ran. |
| 1284 | #[test] |
| 1285 | fn receipt_reports_the_caps_that_actually_ran() { |
| 1286 | let tmpdir = TempDir::new().expect("tempdir"); |
| 1287 | let mut app = test_app(&tmpdir); |
| 1288 | { |
| 1289 | let mut state = app.plan_state.try_lock().expect("plan lock"); |
| 1290 | state.update(UpdatePlanArgs { |
| 1291 | title: Some("padded plan".to_string()), |
| 1292 | plan: (0..40) |
| 1293 | .map(|index| PlanItemArg { |
| 1294 | step: format!("step {index}: {}", "padding ".repeat(30)), |
| 1295 | status: StepStatus::Pending, |
| 1296 | }) |
| 1297 | .collect(), |
| 1298 | ..Default::default() |
| 1299 | }); |
| 1300 | } |
| 1301 | let caps = Caps { |
| 1302 | max_output_bytes: 6 * 1024, |
| 1303 | ..DEFAULT_CAPS |
| 1304 | }; |
| 1305 | let json = render_copy(&mut app, &CopyKind::Plan, &caps).expect("render"); |
| 1306 | let value = parsed(&json); |
| 1307 | assert_eq!( |
| 1308 | value["receipt"]["caps"]["max_output_bytes"], |
| 1309 | json!(6 * 1024) |
| 1310 | ); |
| 1311 | let applied = &value["receipt"]["applied_caps"]; |
| 1312 | assert!( |
| 1313 | applied["max_array_items"].as_u64().expect("items") <= DEFAULT_CAPS.max_array_items as u64 |
| 1314 | ); |
| 1315 | if applied != &value["receipt"]["caps"] { |
| 1316 | assert!( |
| 1317 | value["receipt"]["reasons"] |
| 1318 | .as_array() |
| 1319 | .expect("reasons") |
| 1320 | .contains(&json!("caps_tightened_output_bytes_cap")), |
| 1321 | "{json}" |
| 1322 | ); |
| 1323 | } |
| 1324 | |
| 1325 | // The unconstrained case declares no tightening. |
| 1326 | let json = stdout_json(&execute_structcopy(&mut app, Some("plan stdout"))); |
| 1327 | let value = parsed(&json); |
| 1328 | assert_eq!(value["receipt"]["applied_caps"], value["receipt"]["caps"]); |
| 1329 | assert!( |
| 1330 | !value["receipt"]["reasons"] |
| 1331 | .as_array() |
| 1332 | .expect("reasons") |
| 1333 | .contains(&json!("caps_tightened_output_bytes_cap")) |
| 1334 | ); |
| 1335 | } |
| 1336 | |
| 1337 | #[test] |
| 1338 | fn clipboard_is_default_and_stdout_is_explicit() { |
| 1339 | let tmpdir = TempDir::new().expect("tempdir"); |
| 1340 | let mut app = test_app(&tmpdir); |
| 1341 | seed_transcript(&mut app); |
| 1342 | |
| 1343 | // Default: clipboard target; the payload never appears in the message. |
| 1344 | let default = execute_structcopy(&mut app, Some("turn 1")); |
| 1345 | assert!(!default.is_error, "{:?}", default.message); |
| 1346 | let message = default.message.as_deref().unwrap_or_default(); |
| 1347 | assert!(message.contains("handed to the clipboard"), "{message}"); |
| 1348 | // The receipt must not overclaim delivery. |
| 1349 | assert!( |
| 1350 | !message.contains("copied to the local clipboard"), |
| 1351 | "{message}" |
| 1352 | ); |
| 1353 | assert!(!message.contains("\"receipt\""), "{message}"); |
| 1354 | let payload = app |
| 1355 | .clipboard |
| 1356 | .last_written_text() |
| 1357 | .expect("clipboard payload"); |
| 1358 | assert!(payload.contains("\"receipt\"")); |
| 1359 | |
| 1360 | // Explicit stdout: payload in the message, clipboard untouched. |
| 1361 | let mut app = test_app(&tmpdir); |
| 1362 | seed_transcript(&mut app); |
| 1363 | let stdout = execute_structcopy(&mut app, Some("turn 1 stdout")); |
| 1364 | assert!( |
| 1365 | stdout |
| 1366 | .message |
| 1367 | .as_deref() |
| 1368 | .unwrap_or_default() |
| 1369 | .contains("\"receipt\"") |
| 1370 | ); |
| 1371 | assert!(app.clipboard.last_written_text().is_none()); |
| 1372 | } |
| 1373 | |
| 1374 | /// The terminal-client path queues a background write; the message must |
| 1375 | /// not claim the copy landed, and must not claim a transport the session |
| 1376 | /// does not have. |
| 1377 | #[test] |
| 1378 | fn terminal_client_receipt_says_queued_not_delivered() { |
| 1379 | let tmpdir = TempDir::new().expect("tempdir"); |
| 1380 | let mut app = test_app(&tmpdir); |
| 1381 | seed_transcript(&mut app); |
| 1382 | // SSH with no display: the write goes to the terminal writer, as in |
| 1383 | // production, and the receipt follows the transport that took it. |
| 1384 | app.clipboard = ClipboardHandler::terminal_only_for_test(); |
| 1385 | assert!(app.clipboard.requires_terminal_paste()); |
| 1386 | |
| 1387 | let result = execute_structcopy(&mut app, Some("turn 1")); |
| 1388 | assert!(!result.is_error, "{:?}", result.message); |
| 1389 | let message = result.message.as_deref().unwrap_or_default(); |
| 1390 | assert!(message.contains("queued"), "{message}"); |
| 1391 | assert!(message.contains("not confirmed"), "{message}"); |
| 1392 | assert!( |
| 1393 | !message.contains("copied to"), |
| 1394 | "must not claim delivery: {message}" |
| 1395 | ); |
| 1396 | } |
| 1397 | |
| 1398 | #[test] |
| 1399 | fn clipboard_failure_is_honest_and_suggests_stdout() { |
| 1400 | let tmpdir = TempDir::new().expect("tempdir"); |
| 1401 | let mut app = test_app(&tmpdir); |
| 1402 | seed_transcript(&mut app); |
| 1403 | app.clipboard = ClipboardHandler::unavailable_for_test(false); |
| 1404 | |
| 1405 | let failed = execute_structcopy(&mut app, Some("turn 1")); |
| 1406 | assert!(failed.is_error); |
| 1407 | let message = failed.message.as_deref().unwrap_or_default(); |
| 1408 | assert!(message.contains("Nothing was written"), "{message}"); |
| 1409 | assert!(message.contains("stdout"), "{message}"); |
| 1410 | assert!(app.clipboard.last_written_text().is_none()); |
| 1411 | } |
| 1412 | |
| 1413 | #[test] |
| 1414 | fn copy_does_not_mutate_session_state() { |
| 1415 | let tmpdir = TempDir::new().expect("tempdir"); |
| 1416 | let mut app = test_app(&tmpdir); |
| 1417 | seed_transcript(&mut app); |
| 1418 | { |
| 1419 | let mut state = app.plan_state.try_lock().expect("plan lock"); |
| 1420 | state.update(UpdatePlanArgs { |
| 1421 | title: Some("immutable".to_string()), |
| 1422 | ..Default::default() |
| 1423 | }); |
| 1424 | } |
| 1425 | let plan_before = app.plan_state.try_lock().expect("plan lock").snapshot(); |
| 1426 | let messages_before = app.api_messages.clone(); |
| 1427 | let history_before = app.history.len(); |
| 1428 | let work_before = app.work_state_snapshot().expect("Work snapshot"); |
| 1429 | |
| 1430 | for arg in [ |
| 1431 | "turn 1 stdout", |
| 1432 | "turn 2", |
| 1433 | "tool call-7 stdout", |
| 1434 | "plan stdout", |
| 1435 | "turn 99 stdout", |
| 1436 | "tool call-nope stdout", |
| 1437 | "workflow nope stdout", |
| 1438 | ] { |
| 1439 | let _ = execute_structcopy(&mut app, Some(arg)); |
| 1440 | } |
| 1441 | |
| 1442 | assert_eq!(app.api_messages, messages_before); |
| 1443 | assert_eq!(app.history.len(), history_before); |
| 1444 | assert_eq!( |
| 1445 | app.plan_state.try_lock().expect("plan lock").snapshot(), |
| 1446 | plan_before |
| 1447 | ); |
| 1448 | assert_eq!( |
| 1449 | app.work_state_snapshot().expect("Work snapshot after copy"), |
| 1450 | work_before, |
| 1451 | "structcopy must not mutate Work" |
| 1452 | ); |
| 1453 | } |
| 1454 | |
| 1455 | #[test] |
| 1456 | fn structcopy_is_registered_human_only_and_absent_from_model_catalog() { |
| 1457 | // Registered as a human slash command. |
| 1458 | assert!( |
| 1459 | crate::commands::command_infos() |
| 1460 | .iter() |
| 1461 | .any(|info| info.name == "structcopy"), |
| 1462 | "structcopy must be a registered slash command" |
| 1463 | ); |
| 1464 | |
| 1465 | // Never a model-visible tool: neither in the native tool catalog nor |
| 1466 | // in the legacy tool registry surface sent to providers. |
| 1467 | assert!( |
| 1468 | !crate::core::engine::default_active_native_tool_names().contains(&"structcopy"), |
| 1469 | "structcopy must not be a native tool" |
| 1470 | ); |
| 1471 | let tmpdir = TempDir::new().expect("tempdir"); |
| 1472 | let context = crate::tools::spec::ToolContext::new(tmpdir.path().to_path_buf()); |
| 1473 | let registry = crate::tools::ToolRegistryBuilder::new() |
| 1474 | .with_file_tools() |
| 1475 | .with_read_only_file_tools() |
| 1476 | .with_shell_tools() |
| 1477 | .with_search_tools() |
| 1478 | .with_git_tools() |
| 1479 | .with_git_history_tools() |
| 1480 | .with_diagnostics_tool() |
| 1481 | .with_skill_tools() |
| 1482 | .with_validation_tools() |
| 1483 | .with_project_tools() |
| 1484 | .with_test_runner_tool() |
| 1485 | .with_tool_result_retrieval_tool() |
| 1486 | .with_web_tools() |
| 1487 | .with_finance_tool() |
| 1488 | .build(context); |
| 1489 | let names: Vec<String> = registry |
| 1490 | .to_api_tools() |
| 1491 | .iter() |
| 1492 | .map(|tool| tool.name.clone()) |
| 1493 | .collect(); |
| 1494 | assert!( |
| 1495 | !names.is_empty(), |
| 1496 | "builder surface must register model tools for this contract to be meaningful" |
| 1497 | ); |
| 1498 | assert!( |
| 1499 | !names.iter().any(|name| name.contains("structcopy")), |
| 1500 | "no model tool may reference structcopy: {names:?}" |
| 1501 | ); |
| 1502 | } |
| 1503 | |
| 1504 | fn execute_structcopy(app: &mut App, arg: Option<&str>) -> CommandResult { |
| 1505 | let mut bundle = app.command_contexts(); |
| 1506 | super::contract::structcopy_host::host_result(structcopy::execute_structcopy( |
| 1507 | bundle.contexts(CAPABILITIES), |
| 1508 | arg, |
| 1509 | )) |
| 1510 | } |
| 1511 | |
| 1512 | fn render_copy(app: &mut App, kind: &CopyKind, caps: &Caps) -> Result<String, String> { |
| 1513 | let mut bundle = app.command_contexts(); |
| 1514 | let parts = bundle.contexts(CAPABILITIES).into_parts(); |
| 1515 | structcopy::render_copy( |
| 1516 | parts.structcopy.unwrap(), |
| 1517 | parts.presentation.unwrap(), |
| 1518 | kind, |
| 1519 | caps, |
| 1520 | ) |
| 1521 | } |
| 1522 | |
| 1523 | #[test] |
| 1524 | fn tool_copy_selects_execution_when_provider_reuses_wire_id() { |
| 1525 | let tmpdir = TempDir::new().expect("tempdir"); |
| 1526 | let mut app = test_app(&tmpdir); |
| 1527 | app.api_messages = std::sync::Arc::new( |
| 1528 | serde_json::from_value(json!([ |
| 1529 | {"role":"assistant", "content":[{"type":"tool_use", "id":"wire", |
| 1530 | "execution_id":"first", "name":"read_file", "input":{"path":"first.txt"}}]}, |
| 1531 | {"role":"user", "content":[{"type":"tool_result", "tool_use_id":"wire", |
| 1532 | "execution_id":"first", "content":"first output"}]}, |
| 1533 | {"role":"assistant", "content":[{"type":"tool_use", "id":"wire", |
| 1534 | "execution_id":"second", "name":"read_file", "input":{"path":"second.txt"}}]}, |
| 1535 | {"role":"user", "content":[{"type":"tool_result", "tool_use_id":"wire", |
| 1536 | "execution_id":"second", "content":"second output"}]} |
| 1537 | ])) |
| 1538 | .expect("transcript"), |
| 1539 | ); |
| 1540 | let before = serde_json::to_value(app.api_messages.as_ref()).expect("transcript"); |
| 1541 | for (selector, path, output) in [ |
| 1542 | ("first", "first.txt", "first output"), |
| 1543 | ("second", "second.txt", "second output"), |
| 1544 | ] { |
| 1545 | let value = parsed(&stdout_json(&execute_structcopy( |
| 1546 | &mut app, |
| 1547 | Some(&format!("tool {selector} stdout")), |
| 1548 | ))); |
| 1549 | assert_eq!(value["receipt"]["selector"], selector); |
| 1550 | assert_eq!(value["object"]["call_id"], selector); |
| 1551 | assert_eq!(value["object"]["input"]["path"], path); |
| 1552 | assert_eq!(value["object"]["result"]["content"], output); |
| 1553 | } |
| 1554 | assert!(execute_structcopy(&mut app, Some("tool wire stdout")).is_error); |
| 1555 | assert_eq!( |
| 1556 | serde_json::to_value(app.api_messages.as_ref()).unwrap(), |
| 1557 | before |
| 1558 | ); |
| 1559 | } |
| 1560 | |
| 1561 | #[test] |
| 1562 | fn tool_copy_refuses_ambiguous_or_inconsistent_identity() { |
| 1563 | let tmpdir = TempDir::new().expect("tempdir"); |
| 1564 | let mut app = test_app(&tmpdir); |
| 1565 | let call = |execution_id: Option<&str>, provider: &str| { |
| 1566 | json!({ |
| 1567 | "type":"tool_use", "id":provider, "execution_id":execution_id, |
| 1568 | "name":"read_file", "input":{"path":"selected.txt"} |
| 1569 | }) |
| 1570 | }; |
| 1571 | let result = |execution_id: Option<&str>, provider: &str| { |
| 1572 | json!({ |
| 1573 | "type":"tool_result", "tool_use_id":provider, "execution_id":execution_id, |
| 1574 | "content":"must not borrow this output" |
| 1575 | }) |
| 1576 | }; |
| 1577 | for (label, selector, calls, results, unavailable) in [ |
| 1578 | ( |
| 1579 | "duplicate local calls", |
| 1580 | "exec", |
| 1581 | vec![call(Some("exec"), "wire"), call(Some("exec"), "wire")], |
| 1582 | vec![result(Some("exec"), "wire")], |
| 1583 | true, |
| 1584 | ), |
| 1585 | ( |
| 1586 | "duplicate legacy calls", |
| 1587 | "wire", |
| 1588 | vec![call(None, "wire"), call(None, "wire")], |
| 1589 | vec![result(None, "wire")], |
| 1590 | true, |
| 1591 | ), |
| 1592 | ( |
| 1593 | "duplicate local results", |
| 1594 | "exec", |
| 1595 | vec![call(Some("exec"), "wire")], |
| 1596 | vec![result(Some("exec"), "wire"), result(Some("exec"), "wire")], |
| 1597 | true, |
| 1598 | ), |
| 1599 | ( |
| 1600 | "duplicate legacy results", |
| 1601 | "wire", |
| 1602 | vec![call(None, "wire")], |
| 1603 | vec![result(None, "wire"), result(None, "wire")], |
| 1604 | true, |
| 1605 | ), |
| 1606 | ( |
| 1607 | "wrong provider", |
| 1608 | "exec", |
| 1609 | vec![call(Some("exec"), "wire")], |
| 1610 | vec![result(Some("exec"), "other")], |
| 1611 | true, |
| 1612 | ), |
| 1613 | ( |
| 1614 | "empty local identity", |
| 1615 | "wire", |
| 1616 | vec![call(Some(""), "wire")], |
| 1617 | vec![result(Some(""), "wire")], |
| 1618 | true, |
| 1619 | ), |
| 1620 | ( |
| 1621 | "colliding domains", |
| 1622 | "exec", |
| 1623 | vec![call(Some("exec"), "wire"), call(None, "exec")], |
| 1624 | vec![result(Some("exec"), "wire"), result(None, "exec")], |
| 1625 | true, |
| 1626 | ), |
| 1627 | ( |
| 1628 | "no local fallback", |
| 1629 | "exec", |
| 1630 | vec![call(Some("exec"), "wire")], |
| 1631 | vec![result(Some("wrong"), "wire"), result(None, "wire")], |
| 1632 | false, |
| 1633 | ), |
| 1634 | ( |
| 1635 | "no legacy fallback", |
| 1636 | "wire", |
| 1637 | vec![call(None, "wire")], |
| 1638 | vec![result(Some("wire"), "wire")], |
| 1639 | false, |
| 1640 | ), |
| 1641 | ] { |
| 1642 | app.api_messages = std::sync::Arc::new( |
| 1643 | serde_json::from_value(json!([ |
| 1644 | {"role":"assistant", "content":calls}, {"role":"user", "content":results} |
| 1645 | ])) |
| 1646 | .expect("transcript"), |
| 1647 | ); |
| 1648 | let before = serde_json::to_value(app.api_messages.as_ref()).unwrap(); |
| 1649 | let copied = execute_structcopy(&mut app, Some(&format!("tool {selector} stdout"))); |
| 1650 | assert_eq!(copied.is_error, unavailable, "{label}"); |
| 1651 | if !unavailable { |
| 1652 | let value = parsed(&stdout_json(&copied)); |
| 1653 | assert_eq!(value["object"]["result"]["found"], false, "{label}"); |
| 1654 | } |
| 1655 | assert!( |
| 1656 | !copied |
| 1657 | .message |
| 1658 | .as_deref() |
| 1659 | .unwrap_or_default() |
| 1660 | .contains("must not borrow"), |
| 1661 | "{label}" |
| 1662 | ); |
| 1663 | assert_eq!( |
| 1664 | serde_json::to_value(app.api_messages.as_ref()).unwrap(), |
| 1665 | before, |
| 1666 | "{label}" |
| 1667 | ); |
| 1668 | } |
| 1669 | } |
| 1670 |