返回 CodeWhale
session_structcopy_regression_tests.rs
根目录 / crates / tui / src / commands / session_structcopy_regression_tests.rs
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
1670 lines RUST