返回 CodeWhale
tests.rs
根目录 / crates / tui / src / tools / shell / tests.rs
1 use super::*;
2
3 use crate::tools::spec::ToolContext;
4 use serde_json::{Value, json};
5 use tempfile::tempdir;
6
7 #[cfg(windows)]
8 use windows::Win32::Foundation::{DUPLICATE_HANDLE_OPTIONS, DuplicateHandle, HANDLE};
9 #[cfg(windows)]
10 use windows::Win32::System::Threading::GetCurrentProcess;
11
12 // `env_lock` serializes tests that mutate the process environment.
13 #[cfg(any(unix, windows))]
14 use std::sync::{Mutex, OnceLock};
15
16 #[cfg(any(unix, windows))]
17 fn env_lock() -> &'static Mutex<()> {
18 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
19 LOCK.get_or_init(|| Mutex::new(()))
20 }
21
22 const BACKGROUND_COMPLETION_WAIT_MS: u64 = 30_000;
23
24 #[test]
25 fn deleted_saved_workspace_reports_path_and_recovery_before_spawn() {
26 let workspace = tempdir().expect("workspace");
27 let stale = workspace.path().join("deleted-session-workspace");
28 let mut manager = ShellManager::new(stale.clone());
29
30 let error = manager
31 .execute("echo should-not-run", None, 1_000, false)
32 .expect_err("missing saved workspace must fail before shell spawn");
33 let message = error.to_string();
34 assert!(message.contains("saved session workspace is unavailable"));
35 assert!(message.contains(&stale.display().to_string()));
36 assert!(message.contains("working_dir") || message.contains("cwd"));
37 assert!(message.contains("resume/fork"));
38 }
39
40 #[test]
41 fn explicit_missing_working_dir_is_not_misreported_as_session_corruption() {
42 let workspace = tempdir().expect("workspace");
43 let missing = workspace.path().join("explicit-missing");
44 let mut manager = ShellManager::new(workspace.path().to_path_buf());
45
46 let error = manager
47 .execute("echo should-not-run", missing.to_str(), 1_000, false)
48 .expect_err("missing explicit cwd must fail before shell spawn");
49 let message = error.to_string();
50 assert!(message.contains("requested working directory is unavailable"));
51 assert!(message.contains(&missing.display().to_string()));
52 assert!(!message.contains("saved session workspace"));
53 }
54
55 #[cfg(not(target_env = "ohos"))]
56 #[test]
57 fn pty_exit_status_preserves_high_windows_code_losslessly() {
58 let raw = 0xC000_0005;
59 let status = ShellExitStatus::from_pty(portable_pty::ExitStatus::with_exit_code(raw));
60
61 assert!(!status.success);
62 assert_eq!(status.code, Some(i64::from(raw)));
63 assert_eq!(
64 exit_code_label(status.code),
65 "exit code 3221225477 (0xC0000005)"
66 );
67 assert_eq!(exit_code_hex(status.code).as_deref(), Some("0xC0000005"));
68 }
69
70 #[cfg(not(target_env = "ohos"))]
71 #[test]
72 fn ordinary_pty_exit_status_keeps_concise_label() {
73 let status = ShellExitStatus::from_pty(portable_pty::ExitStatus::with_exit_code(127));
74
75 assert_eq!(status.code, Some(127));
76 assert_eq!(exit_code_label(status.code), "exit code 127");
77 assert_eq!(exit_code_hex(status.code), None);
78 }
79
80 #[cfg(windows)]
81 #[test]
82 fn std_windows_exit_status_reinterprets_signed_dword() {
83 assert_eq!(std_exit_code_i64(0xC000_0005_u32 as i32), 0xC000_0005);
84 }
85
86 #[cfg(windows)]
87 const JOB_OBJECT_QUERY_ACCESS: u32 = 0x0004;
88
89 #[cfg(windows)]
90 fn duplicate_job_without_terminate_access(job: WindowsJob) -> WindowsJob {
91 let process = unsafe { GetCurrentProcess() };
92 let mut limited_handle = HANDLE::default();
93
94 unsafe {
95 DuplicateHandle(
96 process,
97 job.handle,
98 process,
99 &mut limited_handle,
100 JOB_OBJECT_QUERY_ACCESS,
101 false,
102 DUPLICATE_HANDLE_OPTIONS(0),
103 )
104 .expect("duplicate job handle without terminate access");
105 }
106
107 drop(job);
108 WindowsJob {
109 handle: limited_handle,
110 }
111 }
112
113 fn echo_command(message: &str) -> String {
114 format!("echo {message}")
115 }
116
117 fn sleep_command(seconds: u64) -> String {
118 let dispatcher = crate::shell_dispatcher::global_dispatcher();
119 if dispatcher.kind().is_powershell() {
120 return format!("Start-Sleep -Seconds {seconds}");
121 }
122 #[cfg(windows)]
123 {
124 let ping_count = seconds.saturating_add(1);
125 format!("ping 127.0.0.1 -n {ping_count} > NUL")
126 }
127 #[cfg(not(windows))]
128 {
129 format!("sleep {seconds}")
130 }
131 }
132
133 fn sleep_then_echo_command(seconds: u64, message: &str) -> String {
134 let dispatcher = crate::shell_dispatcher::global_dispatcher();
135 if dispatcher.kind().is_powershell() {
136 return format!("Start-Sleep -Seconds {seconds}; echo {message}");
137 }
138 #[cfg(windows)]
139 {
140 let ping_count = seconds.saturating_add(1);
141 format!("ping 127.0.0.1 -n {ping_count} > NUL && echo {message}")
142 }
143 #[cfg(not(windows))]
144 {
145 format!("sleep {seconds} && echo {message}")
146 }
147 }
148
149 fn echo_stdin_command() -> String {
150 let dispatcher = crate::shell_dispatcher::global_dispatcher();
151 if dispatcher.kind().is_powershell() {
152 return "[Console]::In.ReadToEnd()".to_string();
153 }
154 #[cfg(windows)]
155 {
156 "more".to_string()
157 }
158 #[cfg(not(windows))]
159 {
160 "cat".to_string()
161 }
162 }
163
164 fn network_restricted_context(tmp: &std::path::Path) -> ToolContext {
165 ToolContext::new(tmp)
166 .with_elevated_sandbox_policy(ExecutionSandboxPolicy::WorkspaceWrite {
167 writable_roots: vec![tmp.to_path_buf()],
168 network_access: false,
169 exclude_tmpdir: false,
170 exclude_slash_tmp: false,
171 })
172 .with_shell_network_denied_hint(
173 "Shell command blocked: Plan mode runs shell commands in a network-restricted sandbox.",
174 )
175 }
176
177 fn failed_network_shell_result(stdout: &str, stderr: &str) -> ShellResult {
178 ShellResult {
179 task_id: None,
180 status: ShellStatus::Failed,
181 exit_code: Some(6),
182 stdout: stdout.to_string(),
183 stderr: stderr.to_string(),
184 duration_ms: 25,
185 stdout_len: stdout.len(),
186 stderr_len: stderr.len(),
187 stdout_omitted: 0,
188 stderr_omitted: 0,
189 stdout_truncated: false,
190 stderr_truncated: false,
191 sandboxed: true,
192 sandbox_type: Some("seatbelt".to_string()),
193 sandbox_denied: false,
194 }
195 }
196
197 #[cfg(unix)]
198 const SHELL_DESCENDANT_HELPER_ENV: &str = "CODEWHALE_SHELL_DESCENDANT_HELPER";
199 #[cfg(unix)]
200 const SHELL_DESCENDANT_PID_FILE_ENV: &str = "CODEWHALE_SHELL_DESCENDANT_PID_FILE";
201
202 #[cfg(unix)]
203 #[test]
204 fn shell_descendant_helper_process() {
205 if std::env::var(SHELL_DESCENDANT_HELPER_ENV).ok().as_deref() != Some("1") {
206 return;
207 }
208 let pid_file =
209 PathBuf::from(std::env::var(SHELL_DESCENDANT_PID_FILE_ENV).expect("descendant pid file"));
210 let mut child = Command::new("sleep")
211 .arg("30")
212 .spawn()
213 .expect("spawn cheap descendant");
214 std::fs::write(pid_file, child.id().to_string()).expect("write descendant pid");
215 std::thread::sleep(Duration::from_secs(30));
216 let _ = child.wait();
217 }
218
219 #[cfg(unix)]
220 fn wait_for_shell_pid_file(path: &Path) -> libc::pid_t {
221 let deadline = Instant::now() + Duration::from_secs(5);
222 loop {
223 if let Ok(raw) = std::fs::read_to_string(path)
224 && let Ok(pid) = raw.trim().parse()
225 {
226 return pid;
227 }
228 assert!(
229 Instant::now() < deadline,
230 "descendant pid file never appeared"
231 );
232 std::thread::sleep(Duration::from_millis(25));
233 }
234 }
235
236 #[cfg(unix)]
237 fn wait_for_shell_pid_exit(pid: libc::pid_t) -> bool {
238 let deadline = Instant::now() + Duration::from_secs(2);
239 loop {
240 if unsafe { libc::kill(pid, 0) } != 0
241 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH)
242 {
243 return true;
244 }
245 if Instant::now() >= deadline {
246 return false;
247 }
248 std::thread::sleep(Duration::from_millis(25));
249 }
250 }
251
252 fn wait_for_completed_shell(manager: &mut ShellManager, task_id: &str) -> ShellResult {
253 let deadline = Instant::now() + Duration::from_millis(BACKGROUND_COMPLETION_WAIT_MS);
254
255 loop {
256 let result = manager
257 .get_output(task_id, true, 1_000)
258 .expect("get_output");
259 if result.status != ShellStatus::Running || Instant::now() >= deadline {
260 return result;
261 }
262 std::thread::sleep(Duration::from_millis(50));
263 }
264 }
265
266 #[test]
267 fn shell_owner_registers_before_spawn_and_silent_work_stays_live() {
268 let work = crate::work_graph::new_shared_work_runtime(
269 crate::tools::todo::new_shared_todo_list(),
270 crate::tools::plan::new_shared_plan_state(),
271 );
272 let lifecycle = ShellWorkLifecycle {
273 work: work.clone(),
274 session_id: "shell-session".to_string(),
275 };
276
277 {
278 let _guard = ShellSpawnIntentGuard::new(
279 Some(lifecycle.clone()),
280 "shell_spawn_failure",
281 "missing-program",
282 )
283 .expect("register spawn intent");
284 }
285 lifecycle
286 .register("shell_silent", "sleep 30")
287 .expect("register silent shell");
288 lifecycle
289 .observe("shell_silent", &ShellStatus::Running, 1, 0)
290 .expect("live owner observation");
291 lifecycle
292 .observe("shell_silent", &ShellStatus::Running, 2, 512)
293 .expect("growing output observation");
294
295 let graph = work
296 .capture(Some("shell-session"))
297 .expect("capture")
298 .expect("graph")
299 .graph;
300 let operation = |external: &str| {
301 graph.nodes.iter().find(|node| {
302 node.binding
303 .as_ref()
304 .is_some_and(|binding| binding.external == external)
305 })
306 };
307 assert_eq!(
308 operation("shell:shell_spawn_failure").map(|node| node.state),
309 Some(crate::work_graph::NodeState::Failed),
310 "dropping an armed spawn guard must terminalize pre-spawn failure"
311 );
312 let silent = operation("shell:shell_silent").expect("silent shell operation");
313 assert_eq!(silent.state, crate::work_graph::NodeState::Active);
314 let observation = silent
315 .binding
316 .as_ref()
317 .and_then(|binding| binding.last_observation.as_ref())
318 .expect("last shell observation");
319 assert_eq!(observation.seq, 2);
320 assert_eq!(
321 observation
322 .output
323 .as_ref()
324 .and_then(crate::work_graph::EvidenceRef::raw_bytes),
325 Some(512)
326 );
327 }
328
329 #[test]
330 fn exec_shell_parallel_flags_are_input_aware() {
331 let tool = BashTool::new("Bash");
332 let readonly = json!({"command": "git status -s"});
333 assert!(tool.supports_parallel_for(&readonly));
334 assert!(tool.is_read_only_for(&readonly));
335 assert_eq!(
336 tool.approval_requirement_for(&readonly),
337 ApprovalRequirement::Auto
338 );
339
340 let bash_readonly = json!({"command": "bash -lc 'rg TODO crates/tui/src/tools'"});
341 assert!(tool.supports_parallel_for(&bash_readonly));
342 assert!(tool.is_read_only_for(&bash_readonly));
343 assert_eq!(
344 tool.approval_requirement_for(&bash_readonly),
345 ApprovalRequirement::Auto
346 );
347
348 for input in [
349 json!({"command": "fd -e rs ."}),
350 json!({"command": "fd -H --type f src"}),
351 json!({"command": "git grep TODO crates/tui/src/tools"}),
352 json!({"command": "bash -lc 'fd -e toml .'"}),
353 json!({"command": "bash -lc 'git grep TODO crates/tui/src/tools'"}),
354 ] {
355 assert!(tool.supports_parallel_for(&input), "{input:?}");
356 assert!(tool.is_read_only_for(&input), "{input:?}");
357 assert_eq!(
358 tool.approval_requirement_for(&input),
359 ApprovalRequirement::Auto,
360 "{input:?}"
361 );
362 }
363
364 for input in [
365 json!({"command": "git status -s", "background": true}),
366 json!({"command": "git status -s", "stdin": ""}),
367 json!({"command": "cargo build"}),
368 json!({"command": "bash -lc 'rg TODO crates | head'"}),
369 json!({"command": "fd -x ./pwn.sh"}),
370 json!({"command": "fd --exec ./pwn.sh"}),
371 json!({"command": "fd -uHtx ./pwn.sh"}),
372 json!({"command": "rg --pre /tmp/evil.sh needle ."}),
373 json!({"command": "git grep -O needle"}),
374 json!({"command": "git grep -nO needle"}),
375 ] {
376 assert!(!tool.supports_parallel_for(&input), "{input:?}");
377 assert!(!tool.is_read_only_for(&input), "{input:?}");
378 assert_eq!(
379 tool.approval_requirement_for(&input),
380 ApprovalRequirement::Required,
381 "{input:?}"
382 );
383 }
384
385 assert!(tool.starts_detached_for(&json!({
386 "command": "cargo check --workspace",
387 "background": true
388 })));
389 assert!(tool.starts_detached_for(&json!({
390 "command": "cargo test -p codewhale-tui --bins",
391 "tty": true
392 })));
393 assert!(!tool.starts_detached_for(&json!({
394 "command": "cargo check --workspace"
395 })));
396 assert!(!tool.starts_detached_for(&json!({
397 "command": "cargo check --workspace",
398 "background": true,
399 "interactive": true
400 })));
401 }
402
403 #[test]
404 fn exec_shell_interact_requires_approval() {
405 let tool = BashTool::alias("exec_shell_interact", "interact");
406 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Required);
407 assert!(
408 tool.capabilities()
409 .contains(&ToolCapability::RequiresApproval)
410 );
411 }
412
413 #[tokio::test]
414 async fn read_only_shell_policy_blocks_non_readonly_commands() {
415 let tmp = tempdir().expect("tempdir");
416 let ctx = ToolContext::new(tmp.path())
417 .with_shell_policy(crate::worker_profile::ShellPolicy::ReadOnly);
418 let tool = BashTool::new("Bash");
419
420 let result = tool
421 .execute(json!({"command": "cargo build"}), &ctx)
422 .await
423 .expect("execute");
424 assert!(!result.success);
425 assert!(result.content.contains("read-only shell policy"));
426
427 let result = tool
428 .execute(
429 json!({"command": "git status -s", "background": true}),
430 &ctx,
431 )
432 .await
433 .expect("execute");
434 assert!(!result.success);
435 assert!(result.content.contains("read-only shell policy"));
436 }
437
438 #[tokio::test]
439 async fn read_only_shell_policy_allows_readonly_inspection() {
440 let tmp = tempdir().expect("tempdir");
441 let ctx = ToolContext::new(tmp.path())
442 .with_shell_policy(crate::worker_profile::ShellPolicy::ReadOnly);
443
444 let result = BashTool::new("Bash")
445 .execute(json!({"command": "pwd"}), &ctx)
446 .await
447 .expect("execute");
448
449 assert!(
450 result.success,
451 "unexpected shell failure: {}",
452 result.content
453 );
454 assert_eq!(
455 result
456 .metadata
457 .as_ref()
458 .and_then(|metadata| metadata.get("status"))
459 .and_then(Value::as_str),
460 Some("Completed")
461 );
462 }
463
464 #[tokio::test]
465 async fn exec_shell_multiline_block_explains_allow_shell_boundary() {
466 let tmp = tempdir().expect("tempdir");
467 let ctx = ToolContext::new(tmp.path());
468
469 let result = BashTool::new("Bash")
470 .execute(
471 json!({"command": "python3 -c \"print(1)\nprint(2)\""}),
472 &ctx,
473 )
474 .await
475 .expect("execute");
476
477 assert!(!result.success);
478 assert!(result.content.contains("Command contains multiple lines"));
479 assert!(
480 result
481 .content
482 .contains("allow_shell=true exposes shell tools"),
483 "{}",
484 result.content
485 );
486 assert!(
487 result
488 .content
489 .contains("Write multiline scripts to a file first"),
490 "{}",
491 result.content
492 );
493 assert!(
494 result.content.contains("task_shell_start"),
495 "{}",
496 result.content
497 );
498 }
499
500 #[test]
501 fn exec_shell_wait_schema_defaults_to_nonblocking_snapshot() {
502 let schema = BashTool::alias("exec_shell_wait", "wait").input_schema();
503 assert!(schema["properties"]["wait"].is_object());
504 assert!(
505 BashTool::alias("exec_shell_wait", "wait")
506 .description()
507 .contains("wait")
508 );
509 }
510
511 #[tokio::test]
512 async fn exec_shell_wait_without_wait_arg_returns_snapshot() {
513 let tmp = tempdir().expect("tempdir");
514 let ctx = ToolContext::new(tmp.path());
515 let start_result = BashTool::new("Bash")
516 .execute(
517 json!({"command": sleep_command(2), "background": true}),
518 &ctx,
519 )
520 .await
521 .expect("start background");
522 let task_id = start_result
523 .metadata
524 .as_ref()
525 .and_then(|metadata| metadata.get("task_id"))
526 .and_then(Value::as_str)
527 .expect("task id")
528 .to_string();
529
530 let started = Instant::now();
531 let wait_result = BashTool::alias("exec_shell_wait", "wait")
532 .execute(json!({"task_id": task_id, "timeout_ms": 5_000}), &ctx)
533 .await
534 .expect("wait snapshot");
535
536 assert!(
537 started.elapsed() < Duration::from_millis(1_000),
538 "default wait path should return a snapshot instead of blocking"
539 );
540 assert_eq!(
541 wait_result
542 .metadata
543 .as_ref()
544 .and_then(|metadata| metadata.get("status"))
545 .and_then(Value::as_str),
546 Some("Running")
547 );
548 }
549
550 #[tokio::test]
551 async fn background_start_advertises_task_status_completion() {
552 let tmp = tempdir().expect("tempdir");
553 let ctx = ToolContext::new(tmp.path());
554 let result = BashTool::new("Bash")
555 .execute(
556 json!({"command": sleep_command(1), "background": true}),
557 &ctx,
558 )
559 .await
560 .expect("start background");
561
562 assert!(result.content.contains("completion is delivered"));
563 let metadata = result.metadata.as_ref().expect("metadata");
564 assert_eq!(
565 metadata
566 .get("auto_resume_on_completion")
567 .and_then(Value::as_bool),
568 Some(true)
569 );
570 assert_eq!(
571 metadata.get("completion_surface").and_then(Value::as_str),
572 Some("runtime_event_and_task_status")
573 );
574 assert_eq!(
575 metadata.get("background_policy").and_then(Value::as_str),
576 Some("nonblocking")
577 );
578 }
579
580 #[tokio::test]
581 async fn background_shell_job_carries_subagent_owner() {
582 let tmp = tempdir().expect("tempdir");
583 let ctx = ToolContext::new(tmp.path()).with_owner_agent("agent_owner", "verifier");
584 let result = BashTool::new("Bash")
585 .execute(
586 json!({"command": sleep_command(2), "background": true}),
587 &ctx,
588 )
589 .await
590 .expect("start owned background shell");
591
592 let metadata = result.metadata.as_ref().expect("metadata");
593 assert_eq!(
594 metadata.get("owner_agent_id").and_then(Value::as_str),
595 Some("agent_owner")
596 );
597 assert_eq!(
598 metadata.get("owner_agent_name").and_then(Value::as_str),
599 Some("verifier")
600 );
601 let task_id = metadata
602 .get("task_id")
603 .and_then(Value::as_str)
604 .expect("task id")
605 .to_string();
606
607 {
608 let mut manager = ctx.shell_manager.lock().expect("shell manager");
609 let snapshot = manager
610 .list_jobs()
611 .into_iter()
612 .find(|job| job.id == task_id)
613 .expect("owned shell job snapshot");
614 assert_eq!(snapshot.owner_agent_id.as_deref(), Some("agent_owner"));
615 assert_eq!(snapshot.owner_agent_name.as_deref(), Some("verifier"));
616 let owners = manager.running_owner_agent_ids();
617 assert_eq!(owners, vec!["agent_owner".to_string()]);
618 }
619
620 BashTool::alias("exec_shell_cancel", "cancel")
621 .execute(json!({"task_id": task_id}), &ctx)
622 .await
623 .expect("cancel owned background shell");
624 }
625
626 #[tokio::test]
627 async fn drain_finished_jobs_reports_once() {
628 let tmp = tempdir().expect("tempdir");
629 let ctx = ToolContext::new(tmp.path());
630 let result = BashTool::new("Bash")
631 .execute(
632 json!({"command": echo_command("drain-finished-once"), "background": true}),
633 &ctx,
634 )
635 .await
636 .expect("start background");
637 let task_id = result
638 .metadata
639 .as_ref()
640 .and_then(|metadata| metadata.get("task_id"))
641 .and_then(Value::as_str)
642 .expect("task id")
643 .to_string();
644
645 let mut manager = ctx.shell_manager.lock().expect("shell manager");
646 assert!(manager.may_have_undelivered_completion());
647 assert!(
648 manager.may_have_undelivered_completion(),
649 "read-only detection must not consume the pending completion"
650 );
651 let completed = wait_for_completed_shell(&mut manager, &task_id);
652 assert_ne!(completed.status, ShellStatus::Running);
653 assert!(manager.may_have_undelivered_completion());
654
655 let first = manager
656 .drain_finished_jobs_with_evidence()
657 .into_iter()
658 .map(|completion| completion.event)
659 .collect::<Vec<_>>();
660 assert_eq!(first.len(), 1);
661 assert_eq!(first[0].task_id, task_id);
662 assert_eq!(first[0].status, ShellStatus::Completed);
663 assert!(first[0].stdout_tail.contains("drain-finished-once"));
664
665 let second = manager.drain_finished_jobs_with_evidence();
666 assert!(second.is_empty(), "completion should be reported only once");
667 assert!(!manager.may_have_undelivered_completion());
668 }
669
670 #[test]
671 fn completion_evidence_preserves_arbitrary_stream_bytes() {
672 use base64::Engine as _;
673
674 let stdout = vec![b'o', 0, 0xff, b'k'];
675 let stderr = vec![0xfe, b'e', b'r', b'r'];
676 let evidence = ShellCompletionEvidence {
677 event: ShellCompletionEvent {
678 task_id: "shell_binary".to_string(),
679 command: "binary-output".to_string(),
680 status: ShellStatus::Completed,
681 exit_code: Some(0),
682 duration_ms: 17,
683 stdout_tail: String::new(),
684 stderr_tail: String::new(),
685 stdout_len: stdout.len(),
686 stderr_len: stderr.len(),
687 evidence_ref: None,
688 linked_task_id: None,
689 owner_agent_id: None,
690 owner_agent_name: None,
691 },
692 stdout: stdout.clone(),
693 stderr: stderr.clone(),
694 };
695
696 let payload: serde_json::Value =
697 serde_json::from_slice(&evidence.artifact_bytes()).expect("evidence JSON");
698 assert_eq!(payload["stdout"]["encoding"], "base64");
699 assert_eq!(payload["stderr"]["encoding"], "base64");
700 let decoded_stdout = base64::engine::general_purpose::STANDARD
701 .decode(payload["stdout"]["content"].as_str().expect("stdout data"))
702 .expect("decode stdout");
703 let decoded_stderr = base64::engine::general_purpose::STANDARD
704 .decode(payload["stderr"]["content"].as_str().expect("stderr data"))
705 .expect("decode stderr");
706 assert_eq!(decoded_stdout, stdout);
707 assert_eq!(decoded_stderr, stderr);
708 }
709
710 #[test]
711 #[cfg(unix)]
712 fn shell_execution_scrubs_parent_env_and_keeps_explicit_env() {
713 let _guard = env_lock().lock().expect("env lock");
714 let previous = std::env::var_os("DEEPSEEK_CHILD_ENV_SHELL_SECRET");
715 unsafe {
716 std::env::set_var("DEEPSEEK_CHILD_ENV_SHELL_SECRET", "parent-secret");
717 }
718
719 let tmp = tempdir().expect("tempdir");
720 let mut manager = ShellManager::new(tmp.path().to_path_buf());
721 let mut extra = std::collections::HashMap::new();
722 extra.insert(
723 "DEEPSEEK_CHILD_ENV_EXPLICIT".to_string(),
724 "explicit-value".to_string(),
725 );
726
727 let result = manager
728 .execute_with_options_env(
729 "sh -c 'printf \"%s\\n%s\\n\" \"${DEEPSEEK_CHILD_ENV_SHELL_SECRET-unset}\" \"${DEEPSEEK_CHILD_ENV_EXPLICIT-unset}\"'",
730 None,
731 5000,
732 false,
733 None,
734 false,
735 None,
736 extra,
737 )
738 .expect("execute");
739
740 match previous {
741 Some(value) => unsafe {
742 std::env::set_var("DEEPSEEK_CHILD_ENV_SHELL_SECRET", value);
743 },
744 None => unsafe {
745 std::env::remove_var("DEEPSEEK_CHILD_ENV_SHELL_SECRET");
746 },
747 }
748
749 assert_eq!(result.status, ShellStatus::Completed);
750 assert_eq!(result.stdout, "unset\nexplicit-value\n");
751 }
752
753 #[test]
754 #[cfg(windows)]
755 fn shell_execution_preserves_custom_windows_sdk_root_env() {
756 let _guard = env_lock().lock().expect("env lock");
757 let previous_sdk = std::env::var_os("BIMRV_SDK_ROOT");
758 let previous_secret = std::env::var_os("MY_SECRET_ROOT");
759 unsafe {
760 std::env::set_var("BIMRV_SDK_ROOT", r"F:\Lib\BimRv27.5");
761 std::env::set_var("MY_SECRET_ROOT", r"F:\Secrets");
762 }
763
764 let tmp = tempdir().expect("tempdir");
765 let mut manager = ShellManager::new(tmp.path().to_path_buf());
766 let command = if crate::shell_dispatcher::global_dispatcher()
767 .kind()
768 .is_powershell()
769 {
770 r#"[Console]::WriteLine($env:BIMRV_SDK_ROOT); if ($null -eq $env:MY_SECRET_ROOT) { [Console]::WriteLine("secret-unset") } else { [Console]::WriteLine("secret-set") }"#
771 .to_string()
772 } else {
773 r#"echo %BIMRV_SDK_ROOT% & if defined MY_SECRET_ROOT (echo secret-set) else (echo secret-unset)"#
774 .to_string()
775 };
776
777 let result = manager
778 .execute(&command, None, 5000, false)
779 .expect("execute");
780
781 unsafe {
782 match previous_sdk {
783 Some(value) => std::env::set_var("BIMRV_SDK_ROOT", value),
784 None => std::env::remove_var("BIMRV_SDK_ROOT"),
785 }
786 match previous_secret {
787 Some(value) => std::env::set_var("MY_SECRET_ROOT", value),
788 None => std::env::remove_var("MY_SECRET_ROOT"),
789 }
790 }
791
792 assert_eq!(result.status, ShellStatus::Completed);
793 assert!(
794 result.stdout.contains(r"F:\Lib\BimRv27.5"),
795 "custom SDK root should reach exec_shell stdout: {:?}",
796 result
797 );
798 assert!(
799 result.stdout.contains("secret-unset"),
800 "secret-like env should stay scrubbed: {:?}",
801 result
802 );
803 }
804
805 #[test]
806 fn test_sync_execution() {
807 let tmp = tempdir().expect("tempdir");
808 let mut manager = ShellManager::new(tmp.path().to_path_buf());
809
810 let result = manager
811 .execute(&echo_command("hello"), None, 5000, false)
812 .expect("execute");
813
814 assert_eq!(result.status, ShellStatus::Completed);
815 assert!(result.stdout.contains("hello"));
816 assert!(result.task_id.is_none());
817 }
818
819 #[test]
820 fn test_background_execution() {
821 let tmp = tempdir().expect("tempdir");
822 let mut manager = ShellManager::new(tmp.path().to_path_buf());
823
824 let result = manager
825 .execute(&sleep_then_echo_command(1, "done"), None, 5000, true)
826 .expect("execute");
827
828 assert_eq!(result.status, ShellStatus::Running);
829 assert!(result.task_id.is_some());
830
831 let task_id = result
832 .task_id
833 .expect("background execution should return task_id");
834
835 let final_result = wait_for_completed_shell(&mut manager, &task_id);
836
837 assert_eq!(final_result.status, ShellStatus::Completed);
838 assert!(final_result.stdout.contains("done"));
839 }
840
841 #[test]
842 fn test_timeout() {
843 let tmp = tempdir().expect("tempdir");
844 let mut manager = ShellManager::new(tmp.path().to_path_buf());
845
846 let result = manager
847 .execute(&sleep_command(10), None, 1000, false)
848 .expect("execute");
849
850 assert_eq!(result.status, ShellStatus::TimedOut);
851 }
852
853 #[test]
854 fn test_kill() {
855 let tmp = tempdir().expect("tempdir");
856 let mut manager = ShellManager::new(tmp.path().to_path_buf());
857
858 let result = manager
859 .execute(&sleep_command(60), None, 5000, true)
860 .expect("execute");
861
862 let task_id = result
863 .task_id
864 .expect("background execution should return task_id");
865
866 // Kill it
867 let killed = manager.kill(&task_id).expect("kill");
868 assert_eq!(killed.status, ShellStatus::Killed);
869 }
870
871 #[test]
872 fn test_write_stdin_streams_output() {
873 let tmp = tempdir().expect("tempdir");
874 let mut manager = ShellManager::new(tmp.path().to_path_buf());
875
876 let result = manager
877 .execute_with_options(&echo_stdin_command(), None, 5000, true, None, false, None)
878 .expect("execute");
879
880 let task_id = result
881 .task_id
882 .expect("background execution should return task_id");
883
884 manager
885 .write_stdin(&task_id, "hello\n", true)
886 .expect("write stdin");
887
888 let delta = manager
889 .get_output_delta(&task_id, true, 5000)
890 .expect("get_output_delta");
891
892 assert!(delta.result.stdout.contains("hello"));
893
894 let delta2 = manager
895 .get_output_delta(&task_id, false, 0)
896 .expect("get_output_delta");
897 assert!(delta2.result.stdout.is_empty());
898 }
899
900 #[test]
901 #[cfg(all(unix, not(target_env = "ohos")))]
902 fn background_tty_command_has_controlling_terminal() {
903 let tmp = tempdir().expect("tempdir");
904 let mut manager = ShellManager::new(tmp.path().to_path_buf());
905
906 let result = manager
907 .execute_with_options(
908 "sh -c 'exec 3<>/dev/tty && printf tty-ok && exec 3>&-'",
909 None,
910 5000,
911 true,
912 None,
913 true,
914 Some(ExecutionSandboxPolicy::DangerFullAccess),
915 )
916 .expect("execute tty command");
917
918 let task_id = result
919 .task_id
920 .expect("background tty execution should return task_id");
921
922 let done = manager
923 .get_output(&task_id, true, 10_000)
924 .expect("get tty command output");
925
926 assert_eq!(done.status, ShellStatus::Completed);
927 assert_eq!(done.exit_code, Some(0));
928 assert!(
929 done.stdout.contains("tty-ok"),
930 "tty output should confirm /dev/tty opened; got {done:?}"
931 );
932 }
933
934 #[test]
935 fn test_job_list_poll_cancel_and_stale_snapshot() {
936 let tmp = tempdir().expect("tempdir");
937 let mut manager = ShellManager::new(tmp.path().to_path_buf());
938
939 let started = manager
940 .execute(&sleep_then_echo_command(1, "done"), None, 5000, true)
941 .expect("execute");
942 let task_id = started.task_id.expect("task id");
943 manager
944 .tag_linked_task(&task_id, Some("task_123".to_string()))
945 .expect("tag linked task");
946
947 let running = manager.list_jobs();
948 let job = running
949 .iter()
950 .find(|job| job.id == task_id)
951 .expect("running job");
952 assert_eq!(job.status, ShellStatus::Running);
953 assert_eq!(job.linked_task_id.as_deref(), Some("task_123"));
954 assert!(job.command.contains("done"));
955 assert_eq!(job.cwd, tmp.path());
956
957 let completed = manager
958 .poll_delta(&task_id, true, 5000)
959 .expect("poll delta");
960 assert_eq!(completed.result.status, ShellStatus::Completed);
961 assert!(completed.result.stdout.contains("done"));
962
963 let detail = manager.inspect_job(&task_id).expect("inspect");
964 assert!(detail.stdout.contains("done"));
965 assert_eq!(detail.snapshot.status, ShellStatus::Completed);
966
967 manager.remember_stale_job(
968 "shell_stale",
969 "cargo test",
970 tmp.path().to_path_buf(),
971 Some("task_old".to_string()),
972 );
973 let stale = manager
974 .list_jobs()
975 .into_iter()
976 .find(|job| job.id == "shell_stale")
977 .expect("stale job");
978 assert!(stale.stale);
979 assert_eq!(stale.linked_task_id.as_deref(), Some("task_old"));
980 }
981
982 #[test]
983 fn running_job_snapshot_marks_no_output_stale_after_threshold() {
984 let tmp = tempdir().expect("tempdir");
985 let mut manager = ShellManager::new(tmp.path().to_path_buf());
986
987 let started = manager
988 .execute(&sleep_command(5), None, 5000, true)
989 .expect("execute");
990 let task_id = started.task_id.expect("task id");
991
992 {
993 let shell = manager.processes.get_mut(&task_id).expect("live shell");
994 shell.last_output_at = Instant::now() - STALE_NO_OUTPUT_AFTER - Duration::from_millis(1);
995 }
996
997 let job = manager
998 .list_jobs()
999 .into_iter()
1000 .find(|job| job.id == task_id)
1001 .expect("running job");
1002
1003 assert_eq!(job.status, ShellStatus::Running);
1004 assert!(job.stale, "silent running job should be marked stale");
1005 assert!(
1006 job.elapsed_since_output_ms
1007 .is_some_and(|elapsed| elapsed >= STALE_NO_OUTPUT_AFTER.as_millis() as u64),
1008 "elapsed no-output time should be exposed: {job:?}"
1009 );
1010 }
1011
1012 #[test]
1013 fn running_job_snapshot_keeps_recent_no_output_fresh() {
1014 let tmp = tempdir().expect("tempdir");
1015 let mut manager = ShellManager::new(tmp.path().to_path_buf());
1016
1017 let started = manager
1018 .execute(&sleep_command(5), None, 5000, true)
1019 .expect("execute");
1020 let task_id = started.task_id.expect("task id");
1021
1022 let job = manager
1023 .list_jobs()
1024 .into_iter()
1025 .find(|job| job.id == task_id)
1026 .expect("running job");
1027
1028 assert_eq!(job.status, ShellStatus::Running);
1029 assert!(!job.stale, "fresh running job should not start stale");
1030 assert!(job.elapsed_since_output_ms.is_some());
1031 }
1032
1033 #[test]
1034 fn test_job_cancel_updates_completion_state() {
1035 let tmp = tempdir().expect("tempdir");
1036 let mut manager = ShellManager::new(tmp.path().to_path_buf());
1037
1038 let started = manager
1039 .execute(&sleep_command(60), None, 5000, true)
1040 .expect("execute");
1041 let task_id = started.task_id.expect("task id");
1042
1043 let killed = manager.kill(&task_id).expect("kill");
1044 assert_eq!(killed.status, ShellStatus::Killed);
1045 let job = manager.inspect_job(&task_id).expect("inspect");
1046 assert_eq!(job.snapshot.status, ShellStatus::Killed);
1047 assert!(!job.snapshot.stdin_available);
1048 }
1049
1050 #[test]
1051 fn test_output_truncation() {
1052 let long_output = "x".repeat(50_000);
1053 let (truncated, _meta) = truncate_with_meta(&long_output);
1054
1055 assert!(truncated.len() < long_output.len());
1056 assert!(truncated.contains("truncated"));
1057 }
1058
1059 #[test]
1060 fn test_truncate_with_meta_reports_omission_counts() {
1061 let long_output = format!("line1\nline2\n{}", "x".repeat(60_000));
1062 let (truncated, meta) = truncate_with_meta(&long_output);
1063
1064 assert!(meta.truncated);
1065 assert!(meta.original_len >= long_output.len());
1066 assert!(meta.omitted > 0);
1067 assert!(truncated.contains("bytes omitted"));
1068 }
1069
1070 #[test]
1071 fn network_restricted_hint_detects_silent_curl_failure() {
1072 let tmp = tempdir().expect("tempdir");
1073 let ctx = network_restricted_context(tmp.path());
1074 let result = failed_network_shell_result("000", "");
1075
1076 let hint = shell_network_restricted_hint(
1077 &ctx,
1078 "curl -s -o /dev/null -w '%{http_code}' https://api.github.com",
1079 &result,
1080 )
1081 .expect("network-restricted hint");
1082
1083 assert!(hint.contains("Plan mode"));
1084 }
1085
1086 #[test]
1087 fn sandbox_denied_hint_names_the_effective_posture() {
1088 // DGF-02: an approved write blocked by a read-only sandbox must come
1089 // back naming the sandbox as the blocker, never as a bare failure.
1090 let tmp = tempdir().expect("tempdir");
1091 let ctx =
1092 ToolContext::new(tmp.path()).with_elevated_sandbox_policy(ExecutionSandboxPolicy::ReadOnly);
1093 let mut result =
1094 failed_network_shell_result("", "sh: cannot create out.txt: Operation not permitted");
1095 result.sandbox_denied = true;
1096
1097 let hint = shell_sandbox_denied_hint(&ctx, &result).expect("sandbox-denied hint");
1098
1099 assert!(hint.contains("read-only"), "{hint}");
1100 assert!(hint.contains("approval"), "{hint}");
1101 }
1102
1103 #[test]
1104 fn sandbox_denied_hint_absent_without_denial_or_policy() {
1105 let tmp = tempdir().expect("tempdir");
1106 let ctx =
1107 ToolContext::new(tmp.path()).with_elevated_sandbox_policy(ExecutionSandboxPolicy::ReadOnly);
1108 let undenied = failed_network_shell_result("", "No such file or directory");
1109 assert!(shell_sandbox_denied_hint(&ctx, &undenied).is_none());
1110
1111 let mut denied = failed_network_shell_result("", "");
1112 denied.sandbox_denied = true;
1113 let no_policy_ctx = ToolContext::new(tmp.path());
1114 assert!(shell_sandbox_denied_hint(&no_policy_ctx, &denied).is_none());
1115 }
1116
1117 #[test]
1118 fn shell_delta_result_surfaces_sandbox_denied_hint() {
1119 let tmp = tempdir().expect("tempdir");
1120 let ctx =
1121 ToolContext::new(tmp.path()).with_elevated_sandbox_policy(ExecutionSandboxPolicy::ReadOnly);
1122 let mut result = failed_network_shell_result("", "Operation not permitted");
1123 result.sandbox_denied = true;
1124
1125 let tool_result = build_shell_delta_tool_result(
1126 ShellDeltaResult {
1127 command: "touch out.txt".to_string(),
1128 result,
1129 stdout_total_len: 0,
1130 stderr_total_len: 0,
1131 },
1132 &ctx,
1133 );
1134
1135 assert!(
1136 tool_result
1137 .content
1138 .contains("The execution sandbox blocked this command"),
1139 "{}",
1140 tool_result.content
1141 );
1142 let metadata = tool_result.metadata.expect("metadata");
1143 assert!(metadata.get("sandbox_denied_hint").is_some());
1144 }
1145
1146 #[test]
1147 fn network_restricted_hint_ignores_local_failures() {
1148 let tmp = tempdir().expect("tempdir");
1149 let ctx = network_restricted_context(tmp.path());
1150 let result = failed_network_shell_result("", "No such file or directory");
1151
1152 assert!(shell_network_restricted_hint(&ctx, "cat missing.txt", &result).is_none());
1153 }
1154
1155 #[test]
1156 fn shell_delta_result_surfaces_network_restricted_hint() {
1157 let tmp = tempdir().expect("tempdir");
1158 let ctx = network_restricted_context(tmp.path());
1159 let result = failed_network_shell_result("000", "");
1160
1161 let tool_result = build_shell_delta_tool_result(
1162 ShellDeltaResult {
1163 command: "gh issue list".to_string(),
1164 result,
1165 stdout_total_len: 3,
1166 stderr_total_len: 0,
1167 },
1168 &ctx,
1169 );
1170
1171 assert!(!tool_result.success);
1172 assert!(tool_result.content.starts_with("Shell command blocked"));
1173 let metadata = tool_result.metadata.expect("metadata");
1174 assert_eq!(
1175 metadata
1176 .get("sandbox_network_restricted")
1177 .and_then(Value::as_bool),
1178 Some(true)
1179 );
1180 }
1181
1182 #[test]
1183 fn shell_delta_result_exposes_lossless_high_exit_code_and_hex() {
1184 let tmp = tempdir().expect("tempdir");
1185 let ctx = ToolContext::new(tmp.path());
1186 let mut result = failed_network_shell_result("", "");
1187 result.exit_code = Some(0xC000_0005);
1188
1189 let tool_result = build_shell_delta_tool_result(
1190 ShellDeltaResult {
1191 command: "echo probe".to_string(),
1192 result,
1193 stdout_total_len: 0,
1194 stderr_total_len: 0,
1195 },
1196 &ctx,
1197 );
1198
1199 assert!(
1200 tool_result
1201 .content
1202 .contains("exit code 3221225477 (0xC0000005)"),
1203 "{}",
1204 tool_result.content
1205 );
1206 let metadata = tool_result.metadata.expect("metadata");
1207 assert_eq!(metadata["exit_code"], json!(3221225477_i64));
1208 assert_eq!(metadata["exit_code_hex"], json!("0xC0000005"));
1209 }
1210
1211 #[test]
1212 fn shell_delta_result_surfaces_elapsed_time_in_content() {
1213 let tmp = tempdir().expect("tempdir");
1214 let ctx = ToolContext::new(tmp.path());
1215 let mut result = failed_network_shell_result("", "");
1216 result.status = ShellStatus::Running;
1217 result.duration_ms = 42_500;
1218 result.task_id = Some("shell-7".to_string());
1219
1220 let tool_result = build_shell_delta_tool_result(
1221 ShellDeltaResult {
1222 command: "cargo test --workspace".to_string(),
1223 result,
1224 stdout_total_len: 0,
1225 stderr_total_len: 0,
1226 },
1227 &ctx,
1228 );
1229
1230 assert!(
1231 tool_result
1232 .content
1233 .starts_with("Task shell-7 still running after 42.5 s."),
1234 "{}",
1235 tool_result.content
1236 );
1237 }
1238
1239 #[test]
1240 fn shell_delta_timing_line_omits_task_id_when_unknown() {
1241 let tmp = tempdir().expect("tempdir");
1242 let ctx = ToolContext::new(tmp.path());
1243 // failed_network_shell_result: ShellStatus::Failed, duration_ms: 25, task_id: None.
1244 let result = failed_network_shell_result("", "");
1245
1246 let tool_result = build_shell_delta_tool_result(
1247 ShellDeltaResult {
1248 command: "echo probe".to_string(),
1249 result,
1250 stdout_total_len: 0,
1251 stderr_total_len: 0,
1252 },
1253 &ctx,
1254 );
1255
1256 assert!(
1257 tool_result.content.starts_with("Task failed after 25 ms."),
1258 "{}",
1259 tool_result.content
1260 );
1261 }
1262
1263 #[test]
1264 fn shell_delta_timing_line_phrases_cover_terminal_statuses() {
1265 let tmp = tempdir().expect("tempdir");
1266 let ctx = ToolContext::new(tmp.path());
1267 for (status, phrase) in [
1268 (ShellStatus::Completed, "completed"),
1269 (ShellStatus::Killed, "killed"),
1270 (ShellStatus::TimedOut, "timed out"),
1271 ] {
1272 let mut result = failed_network_shell_result("", "");
1273 result.status = status;
1274 result.duration_ms = 5_000;
1275 let tool_result = build_shell_delta_tool_result(
1276 ShellDeltaResult {
1277 command: "echo probe".to_string(),
1278 result,
1279 stdout_total_len: 0,
1280 stderr_total_len: 0,
1281 },
1282 &ctx,
1283 );
1284 assert!(
1285 tool_result
1286 .content
1287 .starts_with(&format!("Task {phrase} after 5 s.")),
1288 "{}",
1289 tool_result.content
1290 );
1291 }
1292 }
1293
1294 #[test]
1295 fn shell_delta_timing_line_handles_zero_duration() {
1296 let tmp = tempdir().expect("tempdir");
1297 let ctx = ToolContext::new(tmp.path());
1298 let mut result = failed_network_shell_result("", "");
1299 result.status = ShellStatus::Completed;
1300 result.duration_ms = 0;
1301 let tool_result = build_shell_delta_tool_result(
1302 ShellDeltaResult {
1303 command: "echo probe".to_string(),
1304 result,
1305 stdout_total_len: 0,
1306 stderr_total_len: 0,
1307 },
1308 &ctx,
1309 );
1310 assert!(
1311 tool_result
1312 .content
1313 .starts_with("Task completed after 0 ms."),
1314 "{}",
1315 tool_result.content
1316 );
1317 }
1318
1319 #[test]
1320 fn shell_delta_timing_line_sits_below_network_hint() {
1321 let tmp = tempdir().expect("tempdir");
1322 let ctx = network_restricted_context(tmp.path());
1323 let result = failed_network_shell_result("000", "");
1324 let tool_result = build_shell_delta_tool_result(
1325 ShellDeltaResult {
1326 command: "gh issue list".to_string(),
1327 result,
1328 stdout_total_len: 3,
1329 stderr_total_len: 0,
1330 },
1331 &ctx,
1332 );
1333 let content = tool_result.content;
1334 let hint_pos = content.find("Shell command blocked").expect("hint present");
1335 let timing_pos = content
1336 .find("failed after 25 ms")
1337 .expect("timing line present");
1338 assert!(
1339 hint_pos < timing_pos,
1340 "hint must precede timing line: {content}"
1341 );
1342 }
1343
1344 #[test]
1345 fn shell_delta_result_includes_cargo_failure_summary() {
1346 let tmp = tempdir().expect("tempdir");
1347 let ctx = ToolContext::new(tmp.path());
1348 let result = ShellResult {
1349 task_id: None,
1350 status: ShellStatus::Failed,
1351 exit_code: Some(101),
1352 stdout: "running 1 test\ntest tests::fails ... FAILED\n\nfailures:\n\n---- tests::fails stdout ----\nthread 'tests::fails' panicked at src/lib.rs:7:9:\nboom\n\ntest result: FAILED. 0 passed; 1 failed; 0 ignored; finished in 0.00s\n".to_string(),
1353 stderr: "error: test failed, to rerun pass `--lib`".to_string(),
1354 duration_ms: 12,
1355 stdout_len: 0,
1356 stderr_len: 0,
1357 stdout_omitted: 0,
1358 stderr_omitted: 0,
1359 stdout_truncated: false,
1360 stderr_truncated: false,
1361 sandboxed: false,
1362 sandbox_type: None,
1363 sandbox_denied: false,
1364 };
1365
1366 let tool_result = build_shell_delta_tool_result(
1367 ShellDeltaResult {
1368 command: "cargo test".to_string(),
1369 result,
1370 stdout_total_len: 0,
1371 stderr_total_len: 0,
1372 },
1373 &ctx,
1374 );
1375
1376 let metadata = tool_result.metadata.expect("metadata");
1377 assert_eq!(
1378 metadata["cargo_failure_summary"]["kind"],
1379 json!("test_failure")
1380 );
1381 assert!(
1382 metadata["cargo_failure_summary"]["summary"]
1383 .as_str()
1384 .unwrap()
1385 .contains("Failing tests: tests::fails")
1386 );
1387 assert!(
1388 metadata["summary"]
1389 .as_str()
1390 .unwrap()
1391 .contains("error: test failed")
1392 );
1393 }
1394
1395 #[test]
1396 fn shell_delta_result_keeps_existing_summary_for_generic_cargo_failure() {
1397 let tmp = tempdir().expect("tempdir");
1398 let ctx = ToolContext::new(tmp.path());
1399 let result = ShellResult {
1400 task_id: None,
1401 status: ShellStatus::Failed,
1402 exit_code: Some(1),
1403 stdout: "build failed".to_string(),
1404 stderr: "command failed without structured cargo diagnostics".to_string(),
1405 duration_ms: 12,
1406 stdout_len: 0,
1407 stderr_len: 0,
1408 stdout_omitted: 0,
1409 stderr_omitted: 0,
1410 stdout_truncated: false,
1411 stderr_truncated: false,
1412 sandboxed: false,
1413 sandbox_type: None,
1414 sandbox_denied: false,
1415 };
1416
1417 let tool_result = build_shell_delta_tool_result(
1418 ShellDeltaResult {
1419 command: "cargo test".to_string(),
1420 result,
1421 stdout_total_len: 0,
1422 stderr_total_len: 0,
1423 },
1424 &ctx,
1425 );
1426
1427 let metadata = tool_result.metadata.expect("metadata");
1428 assert!(metadata.get("cargo_failure_summary").is_none());
1429 assert_eq!(
1430 metadata["summary"],
1431 json!("command failed without structured cargo diagnostics")
1432 );
1433 }
1434
1435 #[test]
1436 fn shell_delta_result_surfaces_python_build_dependency_hint() {
1437 let tmp = tempdir().expect("tempdir");
1438 let ctx = ToolContext::new(tmp.path());
1439 let result = ShellResult {
1440 task_id: None,
1441 status: ShellStatus::Failed,
1442 exit_code: Some(1),
1443 stdout: String::new(),
1444 stderr: "running build_ext\nModuleNotFoundError: No module named 'setuptools'\n"
1445 .to_string(),
1446 duration_ms: 12,
1447 stdout_len: 0,
1448 stderr_len: 72,
1449 stdout_omitted: 0,
1450 stderr_omitted: 0,
1451 stdout_truncated: false,
1452 stderr_truncated: false,
1453 sandboxed: false,
1454 sandbox_type: None,
1455 sandbox_denied: false,
1456 };
1457
1458 let tool_result = build_shell_delta_tool_result(
1459 ShellDeltaResult {
1460 command: "python setup.py build_ext --inplace".to_string(),
1461 result,
1462 stdout_total_len: 0,
1463 stderr_total_len: 72,
1464 },
1465 &ctx,
1466 );
1467
1468 assert!(!tool_result.success);
1469 assert!(
1470 tool_result
1471 .content
1472 .starts_with("Python build dependency missing")
1473 );
1474 let metadata = tool_result.metadata.expect("metadata");
1475 assert_eq!(
1476 metadata["python_build_dependency_hint"]["kind"],
1477 json!("missing_setuptools")
1478 );
1479 assert!(
1480 metadata["python_build_dependency_hint"]["hint"]
1481 .as_str()
1482 .unwrap()
1483 .contains("setuptools")
1484 );
1485 }
1486
1487 #[test]
1488 fn test_summarize_output_strips_truncation_note() {
1489 let long_output = "x".repeat(60_000);
1490 let (truncated, _meta) = truncate_with_meta(&long_output);
1491 let summary = summarize_output(&truncated);
1492 assert!(!summary.contains("Output truncated at"));
1493 }
1494
1495 #[tokio::test]
1496 async fn test_exec_shell_metadata_includes_summaries() {
1497 let tmp = tempdir().expect("tempdir");
1498 let ctx = ToolContext::new(tmp.path());
1499 let tool = BashTool::new("Bash");
1500
1501 let result = tool
1502 .execute(json!({"command": echo_command("hello")}), &ctx)
1503 .await
1504 .expect("execute");
1505 assert!(result.success);
1506
1507 let meta = result.metadata.expect("metadata");
1508 let summary = meta
1509 .get("summary")
1510 .and_then(Value::as_str)
1511 .unwrap_or_default()
1512 .to_string();
1513 assert!(summary.contains("hello"));
1514 assert!(meta.get("stdout_len").is_some());
1515 assert!(meta.get("stdout_truncated").is_some());
1516 }
1517
1518 #[cfg(not(windows))]
1519 #[tokio::test]
1520 async fn test_exec_shell_combined_output_uses_single_stream() {
1521 let tmp = tempdir().expect("tempdir");
1522 let ctx = ToolContext::new(tmp.path());
1523 let tool = BashTool::new("Bash");
1524 let command = "printf 'out\\n'; printf 'err\\n' >&2";
1525
1526 let result = tool
1527 .execute(json!({"command": command, "combined_output": true}), &ctx)
1528 .await
1529 .expect("execute");
1530 assert!(result.success, "{}", result.content);
1531 assert!(result.content.contains("out"), "{}", result.content);
1532 assert!(result.content.contains("err"), "{}", result.content);
1533
1534 let meta = result.metadata.expect("metadata");
1535 assert_eq!(
1536 meta.get("combined_output").and_then(Value::as_bool),
1537 Some(true)
1538 );
1539 }
1540
1541 #[tokio::test]
1542 async fn test_exec_shell_foreground_timeout_guides_background_rerun() {
1543 let tmp = tempdir().expect("tempdir");
1544 let ctx = ToolContext::new(tmp.path());
1545 let tool = BashTool::new("Bash");
1546
1547 let result = tool
1548 .execute(
1549 json!({
1550 "command": sleep_command(10),
1551 "timeout_ms": 1000
1552 }),
1553 &ctx,
1554 )
1555 .await
1556 .expect("execute");
1557
1558 assert!(!result.success);
1559 // The rerun instruction has to be spelled in the canonical action form:
1560 // `exec_shell` / `task_shell_start` are not both dispatchable, and the
1561 // model can only reach the shell through `Bash`.
1562 assert!(
1563 result
1564 .content
1565 .contains("Bash action=\"run\" background=true")
1566 );
1567 assert!(result.content.contains("Bash action=\"wait\""));
1568 assert!(!result.content.contains("exec_shell"));
1569 assert!(result.content.contains("process killed"));
1570 let meta = result.metadata.expect("metadata");
1571 assert_eq!(meta.get("status").and_then(Value::as_str), Some("TimedOut"));
1572 let recovery = meta
1573 .get("foreground_timeout_recovery")
1574 .expect("timeout recovery metadata");
1575 assert_eq!(
1576 recovery
1577 .get("rerun_as")
1578 .and_then(|rerun| rerun.get("background"))
1579 .and_then(Value::as_bool),
1580 Some(true)
1581 );
1582 assert_eq!(
1583 recovery
1584 .get("rerun_as")
1585 .and_then(|rerun| rerun.get("tool"))
1586 .and_then(Value::as_str),
1587 Some("Bash")
1588 );
1589 let hint = recovery
1590 .get("hint")
1591 .and_then(Value::as_str)
1592 .unwrap_or_default();
1593 assert!(hint.contains("Bash action=\"wait\""), "{hint}");
1594 assert!(!hint.contains("exec_shell"), "{hint}");
1595 // The structured tool list is read by the model too; it must not hand
1596 // over names the registry does not resolve.
1597 let recommended = recovery.to_string();
1598 assert!(!recommended.contains("exec_shell"), "{recommended}");
1599 }
1600
1601 #[test]
1602 fn test_exec_shell_schema_guides_gt_five_second_work_to_background() {
1603 let schema = BashTool::new("Bash").input_schema();
1604 let description = schema["properties"]["background"]["description"]
1605 .as_str()
1606 .expect("background description");
1607 assert!(description.contains(">5 seconds"), "{description}");
1608 }
1609
1610 #[tokio::test]
1611 async fn test_exec_shell_foreground_cancel_kills_process() {
1612 let tmp = tempdir().expect("tempdir");
1613 let cancel_token = tokio_util::sync::CancellationToken::new();
1614 let ctx = ToolContext::new(tmp.path()).with_cancel_token(cancel_token.clone());
1615 let command = sleep_command(30);
1616
1617 let task = tokio::spawn(async move {
1618 BashTool::new("Bash")
1619 .execute(
1620 json!({
1621 "command": command,
1622 "timeout_ms": 600_000
1623 }),
1624 &ctx,
1625 )
1626 .await
1627 .expect("execute")
1628 });
1629
1630 tokio::time::sleep(Duration::from_millis(150)).await;
1631 cancel_token.cancel();
1632
1633 let result = tokio::time::timeout(Duration::from_secs(5), task)
1634 .await
1635 .expect("foreground shell should observe cancellation")
1636 .expect("task should not panic");
1637
1638 assert!(!result.success);
1639 assert!(result.content.contains("Command canceled"));
1640 let meta = result.metadata.expect("metadata");
1641 assert_eq!(meta.get("status").and_then(Value::as_str), Some("Killed"));
1642 assert_eq!(meta.get("canceled").and_then(Value::as_bool), Some(true));
1643 }
1644
1645 #[tokio::test]
1646 async fn test_exec_shell_foreground_can_move_to_background() {
1647 let tmp = tempdir().expect("tempdir");
1648 let ctx = ToolContext::new(tmp.path());
1649 let shell_manager = ctx.shell_manager.clone();
1650 let command = sleep_command(30);
1651 let task_ctx = ctx.clone();
1652
1653 let task = tokio::spawn(async move {
1654 BashTool::new("Bash")
1655 .execute(
1656 json!({
1657 "command": command,
1658 "timeout_ms": 600_000
1659 }),
1660 &task_ctx,
1661 )
1662 .await
1663 .expect("execute")
1664 });
1665
1666 tokio::time::sleep(Duration::from_millis(150)).await;
1667 shell_manager
1668 .lock()
1669 .expect("shell manager lock")
1670 .request_foreground_background();
1671
1672 let result = tokio::time::timeout(Duration::from_secs(5), task)
1673 .await
1674 .expect("foreground shell should detach")
1675 .expect("task should not panic");
1676
1677 assert!(result.success);
1678 assert!(
1679 result
1680 .content
1681 .contains("Foreground shell wait moved to /jobs")
1682 );
1683 // The detach message points the model at the wait action for early
1684 // output, and hands over the task_id it needs to make that call.
1685 assert!(
1686 result.content.contains("Bash action=\"wait\""),
1687 "{}",
1688 result.content
1689 );
1690 assert!(result.content.contains("task_id="), "{}", result.content);
1691 assert!(!result.content.contains("exec_shell"), "{}", result.content);
1692
1693 let meta = result.metadata.expect("metadata");
1694 assert_eq!(meta.get("status").and_then(Value::as_str), Some("Running"));
1695 assert_eq!(
1696 meta.get("backgrounded").and_then(Value::as_bool),
1697 Some(true)
1698 );
1699 let task_id = meta
1700 .get("task_id")
1701 .and_then(Value::as_str)
1702 .expect("task id")
1703 .to_string();
1704
1705 let mut manager = shell_manager.lock().expect("shell manager lock");
1706 let job = manager.inspect_job(&task_id).expect("inspect job");
1707 assert_eq!(job.snapshot.status, ShellStatus::Running);
1708 let killed = manager.kill(&task_id).expect("kill");
1709 assert_eq!(killed.status, ShellStatus::Killed);
1710 }
1711
1712 #[tokio::test]
1713 async fn test_exec_shell_wait_cancel_leaves_background_process_running() {
1714 let tmp = tempdir().expect("tempdir");
1715 let cancel_token = tokio_util::sync::CancellationToken::new();
1716 let ctx = ToolContext::new(tmp.path()).with_cancel_token(cancel_token.clone());
1717 let shell_manager = ctx.shell_manager.clone();
1718 let started = shell_manager
1719 .lock()
1720 .expect("shell manager lock")
1721 .execute(&sleep_command(30), None, 600_000, true)
1722 .expect("execute");
1723 let task_id = started.task_id.expect("task id");
1724 let wait_task_id = task_id.clone();
1725 let task_ctx = ctx.clone();
1726
1727 let task = tokio::spawn(async move {
1728 BashTool::alias("exec_shell_wait", "wait")
1729 .execute(
1730 json!({
1731 "task_id": wait_task_id,
1732 "wait": true,
1733 "timeout_ms": 600_000
1734 }),
1735 &task_ctx,
1736 )
1737 .await
1738 .expect("wait")
1739 });
1740
1741 tokio::time::sleep(Duration::from_millis(150)).await;
1742 cancel_token.cancel();
1743
1744 let result = tokio::time::timeout(Duration::from_secs(5), task)
1745 .await
1746 .expect("wait should observe cancellation")
1747 .expect("task should not panic");
1748
1749 assert!(result.success);
1750 assert!(result.content.contains("still running"));
1751 let meta = result.metadata.expect("metadata");
1752 assert_eq!(meta.get("status").and_then(Value::as_str), Some("Running"));
1753 assert_eq!(
1754 meta.get("wait_canceled").and_then(Value::as_bool),
1755 Some(true)
1756 );
1757
1758 let mut manager = shell_manager.lock().expect("shell manager lock");
1759 let job = manager.inspect_job(&task_id).expect("inspect job");
1760 assert_eq!(job.snapshot.status, ShellStatus::Running);
1761 let killed = manager.kill(&task_id).expect("kill");
1762 assert_eq!(killed.status, ShellStatus::Killed);
1763 }
1764
1765 #[tokio::test]
1766 async fn test_completed_background_shell_releases_process_handles() {
1767 let tmp = tempdir().expect("tempdir");
1768 let ctx = ToolContext::new(tmp.path());
1769 let shell_manager = ctx.shell_manager.clone();
1770 let started = shell_manager
1771 .lock()
1772 .expect("shell manager lock")
1773 .execute(&echo_command("done"), None, 600_000, true)
1774 .expect("execute");
1775 let task_id = started.task_id.expect("task id");
1776
1777 let result = BashTool::alias("exec_shell_wait", "wait")
1778 .execute(
1779 json!({
1780 "task_id": task_id.clone(),
1781 "wait": true,
1782 "timeout_ms": BACKGROUND_COMPLETION_WAIT_MS
1783 }),
1784 &ctx,
1785 )
1786 .await
1787 .expect("wait");
1788
1789 assert!(result.success);
1790 let mut manager = shell_manager.lock().expect("shell manager lock");
1791 let result = wait_for_completed_shell(&mut manager, &task_id);
1792 assert_eq!(result.status, ShellStatus::Completed);
1793 let shell = manager.processes.get_mut(&task_id).expect("tracked shell");
1794 shell.poll();
1795 assert_eq!(shell.status, ShellStatus::Completed);
1796 assert!(shell.stdin.is_none());
1797 assert!(shell.child.is_none());
1798 assert!(shell.stdout_thread.is_none());
1799 assert!(shell.stderr_thread.is_none());
1800 }
1801
1802 #[cfg(unix)]
1803 #[tokio::test]
1804 async fn exec_shell_cancel_kills_descendant_process_group() {
1805 let tmp = tempdir().expect("tempdir");
1806 let pid_file = tmp.path().join("descendant.pid");
1807 let test_binary = std::env::current_exe().expect("current test binary");
1808 let command = format!(
1809 "{} --exact {} --nocapture",
1810 shell_words::quote(&test_binary.display().to_string()),
1811 shell_words::quote("tools::shell::tests::shell_descendant_helper_process"),
1812 );
1813 let ctx = ToolContext::new(tmp.path());
1814 let mut env = std::collections::HashMap::new();
1815 env.insert(SHELL_DESCENDANT_HELPER_ENV.to_string(), "1".to_string());
1816 env.insert(
1817 SHELL_DESCENDANT_PID_FILE_ENV.to_string(),
1818 pid_file.display().to_string(),
1819 );
1820 let started = ctx
1821 .shell_manager
1822 .lock()
1823 .expect("shell manager")
1824 .execute_with_options_env(&command, None, 60_000, true, None, false, None, env)
1825 .expect("start descendant tree");
1826 let task_id = started.task_id.expect("task id");
1827 let descendant = wait_for_shell_pid_file(&pid_file);
1828
1829 let result = BashTool::alias("exec_shell_cancel", "cancel")
1830 .execute(json!({"task_id": task_id}), &ctx)
1831 .await
1832 .expect("cancel process group");
1833 assert!(result.success);
1834 assert!(
1835 wait_for_shell_pid_exit(descendant),
1836 "descendant {descendant} survived shell process-group cancellation"
1837 );
1838 }
1839
1840 #[tokio::test]
1841 async fn test_exec_shell_cancel_tool_kills_background_process() {
1842 let tmp = tempdir().expect("tempdir");
1843 let ctx = ToolContext::new(tmp.path());
1844 let shell_manager = ctx.shell_manager.clone();
1845 let started = shell_manager
1846 .lock()
1847 .expect("shell manager lock")
1848 .execute(&sleep_command(30), None, 600_000, true)
1849 .expect("execute");
1850 let task_id = started.task_id.expect("task id");
1851
1852 let result = BashTool::alias("exec_shell_cancel", "cancel")
1853 .execute(json!({ "task_id": task_id }), &ctx)
1854 .await
1855 .expect("cancel");
1856
1857 assert!(result.success);
1858 assert!(result.content.contains("Canceled background command"));
1859 let meta = result.metadata.expect("metadata");
1860 assert_eq!(meta.get("status").and_then(Value::as_str), Some("Killed"));
1861
1862 let task_id = meta
1863 .get("task_id")
1864 .and_then(Value::as_str)
1865 .expect("task id");
1866 let mut manager = shell_manager.lock().expect("shell manager lock");
1867 let job = manager.inspect_job(task_id).expect("inspect job");
1868 assert_eq!(job.snapshot.status, ShellStatus::Killed);
1869 }
1870
1871 #[tokio::test]
1872 async fn test_exec_shell_cancel_tool_can_kill_all_running_processes() {
1873 let tmp = tempdir().expect("tempdir");
1874 let ctx = ToolContext::new(tmp.path());
1875 let shell_manager = ctx.shell_manager.clone();
1876 let first = shell_manager
1877 .lock()
1878 .expect("shell manager lock")
1879 .execute(&sleep_command(30), None, 600_000, true)
1880 .expect("execute first")
1881 .task_id
1882 .expect("first task id");
1883 let second = shell_manager
1884 .lock()
1885 .expect("shell manager lock")
1886 .execute(&sleep_command(30), None, 600_000, true)
1887 .expect("execute second")
1888 .task_id
1889 .expect("second task id");
1890
1891 let result = BashTool::alias("exec_shell_cancel", "cancel")
1892 .execute(json!({ "all": true }), &ctx)
1893 .await
1894 .expect("cancel all");
1895
1896 assert!(result.success);
1897 let meta = result.metadata.expect("metadata");
1898 assert_eq!(meta.get("status").and_then(Value::as_str), Some("Killed"));
1899 assert_eq!(meta.get("canceled").and_then(Value::as_u64), Some(2));
1900
1901 let mut manager = shell_manager.lock().expect("shell manager lock");
1902 let first_job = manager.inspect_job(&first).expect("inspect first");
1903 let second_job = manager.inspect_job(&second).expect("inspect second");
1904 assert_eq!(first_job.snapshot.status, ShellStatus::Killed);
1905 assert_eq!(second_job.snapshot.status, ShellStatus::Killed);
1906 }
1907
1908 fn make_failed_result(stderr: &str) -> ShellResult {
1909 ShellResult {
1910 task_id: None,
1911 status: ShellStatus::Failed,
1912 exit_code: Some(1),
1913 stdout: String::new(),
1914 stderr: stderr.to_string(),
1915 duration_ms: 0,
1916 stdout_len: 0,
1917 stderr_len: stderr.len(),
1918 stdout_omitted: 0,
1919 stderr_omitted: 0,
1920 stdout_truncated: false,
1921 sandboxed: false,
1922 sandbox_type: None,
1923 sandbox_denied: false,
1924 stderr_truncated: false,
1925 }
1926 }
1927
1928 #[test]
1929 fn test_macos_provenance_detected_by_activity_time_message() {
1930 let result = make_failed_result(
1931 "failed to update builder last activity time: open \
1932 /Users/user/.docker/buildx/activity/.tmp-abc: operation not permitted",
1933 );
1934 assert!(looks_like_macos_provenance_failure(&result));
1935 }
1936
1937 #[test]
1938 fn test_macos_provenance_detected_by_activity_path_and_eperm() {
1939 let result = make_failed_result(
1940 "error: open /home/user/.docker/buildx/activity/foo: operation not permitted",
1941 );
1942 assert!(looks_like_macos_provenance_failure(&result));
1943 }
1944
1945 #[test]
1946 fn test_macos_provenance_not_triggered_on_success() {
1947 let mut result = make_failed_result(
1948 "failed to update builder last activity time: open \
1949 /Users/user/.docker/buildx/activity/.tmp-abc: operation not permitted",
1950 );
1951 result.status = ShellStatus::Completed;
1952 result.exit_code = Some(0);
1953 assert!(!looks_like_macos_provenance_failure(&result));
1954 }
1955
1956 #[test]
1957 fn test_macos_provenance_not_triggered_on_unrelated_eperm() {
1958 let result = make_failed_result("open /some/other/path: operation not permitted");
1959 assert!(!looks_like_macos_provenance_failure(&result));
1960 }
1961
1962 // Regression test for #828: shell spawns an orphaned background subprocess
1963 // (simulating `nohup curl`) that keeps the pipe write-end open after the shell
1964 // exits. collect_output() must not block indefinitely — it kills the whole
1965 // process group first, allowing reader threads to get EOF and exit.
1966 #[cfg(unix)]
1967 #[test]
1968 fn test_orphaned_subprocess_does_not_block_collect_output() {
1969 let tmp = tempdir().expect("tempdir");
1970 let mut manager = ShellManager::new(tmp.path().to_path_buf());
1971
1972 // sh spawns `sleep 100 &` and exits; the sleep subprocess inherits the
1973 // pipe write-ends and would keep reader threads blocked without the fix.
1974 let result = manager
1975 .execute("sh -c 'sleep 100 &'", None, 5000, true)
1976 .expect("execute");
1977 let task_id = result.task_id.expect("task id");
1978
1979 // Drive to completion with a tight timeout — must not hang.
1980 let done = manager
1981 .get_output(&task_id, true, 3000)
1982 .expect("get_output must complete, not hang");
1983 assert_eq!(done.status, ShellStatus::Completed);
1984 }
1985
1986 #[cfg(unix)]
1987 #[test]
1988 fn foreground_shell_does_not_block_on_orphaned_subprocess_pipe() {
1989 let tmp = tempdir().expect("tempdir");
1990 let mut manager = ShellManager::new(tmp.path().to_path_buf());
1991
1992 let started = std::time::Instant::now();
1993 let result = manager
1994 .execute("sh -c 'sleep 100 &'", None, 5000, false)
1995 .expect("foreground execute must complete, not hang");
1996
1997 assert!(
1998 started.elapsed() < std::time::Duration::from_secs(4),
1999 "foreground execute blocked on descendant pipe handles"
2000 );
2001 assert_eq!(result.status, ShellStatus::Completed);
2002 }
2003
2004 // Windows equivalent of the orphaned pipe-handle regression. `cmd /c start /b`
2005 // launches a descendant process that inherits stdout/stderr and outlives the
2006 // shell. Job-object cleanup must terminate that descendant before reader-thread
2007 // joins, otherwise get_output() blocks until ping exits.
2008 #[cfg(windows)]
2009 #[test]
2010 fn background_collection_does_not_block_on_detached_descendant_pipe() {
2011 let tmp = tempdir().expect("tempdir");
2012 let mut manager = ShellManager::new(tmp.path().to_path_buf());
2013
2014 let result = manager
2015 .execute(
2016 r#"cmd /c start "" /b ping 127.0.0.1 -n 4"#,
2017 None,
2018 5000,
2019 true,
2020 )
2021 .expect("execute");
2022 let task_id = result.task_id.expect("task id");
2023
2024 let started = std::time::Instant::now();
2025 let done = manager
2026 .get_output(&task_id, true, 3000)
2027 .expect("get_output must complete, not hang");
2028
2029 assert!(
2030 started.elapsed() < std::time::Duration::from_secs(6),
2031 "get_output blocked on descendant pipe handles"
2032 );
2033 assert_eq!(done.status, ShellStatus::Completed);
2034 }
2035
2036 #[cfg(windows)]
2037 #[test]
2038 fn windows_job_terminate_denied_falls_back_to_child_kill() {
2039 let mut child = Command::new("ping")
2040 .args(["127.0.0.1", "-n", "20"])
2041 .stdin(Stdio::null())
2042 .stdout(Stdio::null())
2043 .stderr(Stdio::null())
2044 .spawn()
2045 .expect("spawn ping");
2046
2047 let job = WindowsJob::attach_to_child(&child).expect("attach job");
2048 let limited_job = duplicate_job_without_terminate_access(job);
2049
2050 assert!(
2051 limited_job.terminate().is_err(),
2052 "limited job handle should not allow TerminateJobObject"
2053 );
2054
2055 terminate_child_and_close_windows_job(Some(limited_job), &mut child)
2056 .expect("fallback child kill");
2057
2058 let status = child
2059 .wait_timeout(std::time::Duration::from_secs(3))
2060 .expect("wait after fallback kill");
2061 assert!(
2062 status.is_some(),
2063 "fallback child kill should terminate child"
2064 );
2065 }
2066
2067 #[cfg(windows)]
2068 #[test]
2069 fn windows_job_close_releases_foreground_reader_threads_when_terminate_denied() {
2070 let mut child = Command::new("ping")
2071 .args(["127.0.0.1", "-n", "8"])
2072 .stdin(Stdio::null())
2073 .stdout(Stdio::piped())
2074 .stderr(Stdio::piped())
2075 .spawn()
2076 .expect("spawn ping");
2077
2078 let job = WindowsJob::attach_to_child(&child).expect("attach job");
2079 let limited_job = duplicate_job_without_terminate_access(job);
2080 assert!(
2081 limited_job.terminate().is_err(),
2082 "limited job handle should not allow TerminateJobObject"
2083 );
2084
2085 let stdout_handle = child.stdout.take().expect("stdout pipe");
2086 let stderr_handle = child.stderr.take().expect("stderr pipe");
2087 let stdout_thread = std::thread::spawn(move || {
2088 let mut reader = stdout_handle;
2089 let mut buf = Vec::new();
2090 let _ = reader.read_to_end(&mut buf);
2091 buf
2092 });
2093 let stderr_thread = std::thread::spawn(move || {
2094 let mut reader = stderr_handle;
2095 let mut buf = Vec::new();
2096 let _ = reader.read_to_end(&mut buf);
2097 buf
2098 });
2099
2100 let started = std::time::Instant::now();
2101 terminate_and_close_windows_job(Some(limited_job));
2102 let _ = stdout_thread.join().unwrap_or_default();
2103 let _ = stderr_thread.join().unwrap_or_default();
2104 let status = child
2105 .wait_timeout(std::time::Duration::from_secs(3))
2106 .expect("wait after kill-on-close");
2107
2108 assert!(
2109 started.elapsed() < std::time::Duration::from_secs(4),
2110 "reader joins waited for natural descendant exit instead of kill-on-close"
2111 );
2112 assert!(status.is_some(), "kill-on-close should terminate child");
2113 }
2114
2115 #[cfg(windows)]
2116 #[test]
2117 fn windows_job_kill_on_close_releases_reader_threads_when_terminate_denied() {
2118 let tmp = tempdir().expect("tempdir");
2119 let mut manager = ShellManager::new(tmp.path().to_path_buf());
2120
2121 let result = manager
2122 .execute(
2123 r#"cmd /c start "" /b ping 127.0.0.1 -n 8"#,
2124 None,
2125 5000,
2126 true,
2127 )
2128 .expect("execute");
2129 let task_id = result.task_id.expect("task id");
2130
2131 {
2132 let shell = manager
2133 .processes
2134 .get_mut(&task_id)
2135 .expect("background shell");
2136 let job = shell.windows_job.take().expect("windows job attached");
2137 let limited_job = duplicate_job_without_terminate_access(job);
2138 assert!(
2139 limited_job.terminate().is_err(),
2140 "limited job handle should not allow TerminateJobObject"
2141 );
2142 shell.windows_job = Some(limited_job);
2143 }
2144
2145 let started = std::time::Instant::now();
2146 let done = manager
2147 .get_output(&task_id, true, 3000)
2148 .expect("get_output must complete via kill-on-close fallback");
2149
2150 assert!(
2151 started.elapsed() < std::time::Duration::from_secs(4),
2152 "get_output waited for natural descendant exit instead of kill-on-close"
2153 );
2154 assert_eq!(done.status, ShellStatus::Completed);
2155 }
2156
2157 #[cfg(windows)]
2158 #[test]
2159 fn killed_shell_does_not_wait_for_blocked_reader_threads() {
2160 let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
2161 let stdout_thread = std::thread::spawn(move || {
2162 let _ = release_rx.recv();
2163 });
2164 let now = std::time::Instant::now();
2165 let mut shell = BackgroundShell {
2166 id: "killed-reader".to_string(),
2167 command: "test".to_string(),
2168 working_dir: std::path::PathBuf::from("."),
2169 status: ShellStatus::Killed,
2170 exit_code: None,
2171 started_at: now,
2172 last_output_at: now,
2173 last_observed_output_len: 0,
2174 sandbox_type: SandboxType::None,
2175 linked_task_id: None,
2176 owner_agent: None,
2177 stdout_buffer: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
2178 stderr_buffer: None,
2179 heavy_permit: None,
2180 stdout_cursor: 0,
2181 stderr_cursor: 0,
2182 completion_reported: false,
2183 stdin: None,
2184 child: None,
2185 windows_job: None,
2186 stdout_thread: Some(stdout_thread),
2187 stderr_thread: None,
2188 work_lifecycle: None,
2189 lifecycle_seq: 0,
2190 last_lifecycle_status: None,
2191 last_lifecycle_bytes: 0,
2192 };
2193
2194 let started = std::time::Instant::now();
2195 shell.collect_output();
2196
2197 assert!(
2198 started.elapsed() < std::time::Duration::from_secs(1),
2199 "killed shell must not synchronously join a blocked reader"
2200 );
2201 release_tx.send(()).expect("release detached reader");
2202 }
2203
2204 #[test]
2205 fn test_list_jobs_cleans_up_completed_old_processes() {
2206 let tmp = tempdir().expect("tempdir");
2207 let mut manager = ShellManager::new(tmp.path().to_path_buf());
2208
2209 let bg = manager
2210 .execute(&echo_command("bg"), None, 5000, true)
2211 .expect("execute bg");
2212 let bg_id = bg.task_id.expect("bg task id");
2213 manager.get_output(&bg_id, true, 3000).expect("bg done");
2214
2215 // Both the completed job and any tracking state should be present.
2216 assert!(!manager.processes.is_empty());
2217
2218 // cleanup(ZERO) removes all completed processes immediately.
2219 manager.cleanup(Duration::ZERO);
2220 assert!(
2221 manager.processes.is_empty(),
2222 "completed processes should be evicted by cleanup"
2223 );
2224 }
2225
2226 /// Regression for #1691: a `git commit -m "feat: complete sub-pages"` shell
2227 /// command must reach the OS shell with its quoted message intact (one argv
2228 /// slot), never split into `feat:` / `complete` / `sub-pages"`.
2229 #[test]
2230 fn issue_1691_quoted_commit_message_round_trips() {
2231 let cmd = r#"git commit -m "feat: complete sub-pages""#;
2232 let spec = CommandSpec::shell(
2233 cmd,
2234 std::path::PathBuf::from("/tmp"),
2235 Duration::from_secs(5),
2236 );
2237
2238 let dispatcher = crate::shell_dispatcher::global_dispatcher();
2239 // The whole command (with quotes) is a single argv entry. The actual
2240 // shell binary can vary by platform — and the dispatcher may wrap the
2241 // payload (encoding prefix, exit-code capture) — but the payload itself
2242 // must stay intact in ONE shell arg. We never split the command string
2243 // ourselves. This single-line ASCII command never takes the PowerShell
2244 // temp `-File` path, so the payload stays on the argv.
2245 assert_eq!(spec.program, dispatcher.kind().binary());
2246 let carriers = spec
2247 .args
2248 .iter()
2249 .filter(|arg| arg.contains(r#""feat: complete sub-pages""#))
2250 .count();
2251 assert_eq!(carriers, 1, "args: {:?}", spec.args);
2252 assert!(
2253 !spec
2254 .args
2255 .iter()
2256 .any(|arg| arg == "feat:" || arg == "complete" || arg == "sub-pages\""),
2257 "args: {:?}",
2258 spec.args
2259 );
2260 assert_eq!(spec.display_command(), cmd);
2261
2262 let mut built = Command::new(&spec.program);
2263 push_shell_args(&mut built, &spec.program, &spec.args);
2264 let got: Vec<String> = built
2265 .get_args()
2266 .map(|a| a.to_string_lossy().into_owned())
2267 .collect();
2268 assert_eq!(got, spec.args);
2269 }
2270
2271 /// When no `cwd` is provided, the shell should run in `context.workspace`,
2272 /// not in the ShellManager's default_workspace. This ensures sub-agents in
2273 /// worktrees run commands in the worktree directory rather than the parent.
2274 ///
2275 /// Without the `context.workspace` default (stashed): runs in sm_dir → FAILS
2276 /// With the `context.workspace` default (unstashed): runs in ctx_dir → PASSES
2277 #[tokio::test]
2278 async fn default_cwd_uses_context_workspace_not_shell_manager_default() {
2279 let ctx_dir = tempdir().expect("ctx tempdir");
2280 let sm_dir = tempdir().expect("sm tempdir");
2281
2282 // Create distinct dirs — write a marker in each so we can tell them apart.
2283 std::fs::write(ctx_dir.path().join("I_AM_CTX_DIR"), "").unwrap();
2284 std::fs::write(sm_dir.path().join("I_AM_SM_DIR"), "").unwrap();
2285
2286 // ToolContext whose workspace is ctx_dir...
2287 let ctx = ToolContext::new(ctx_dir.path())
2288 // ...but whose ShellManager's default_workspace is sm_dir.
2289 .with_shell_manager(new_shared_shell_manager(sm_dir.path().to_path_buf()));
2290
2291 // Assert directory identity through marker files instead of comparing the
2292 // shell's printed path. PowerShell and `canonicalize` can spell the same
2293 // Windows path differently (for example, with a verbatim-path prefix).
2294 let command = if cfg!(windows) {
2295 "if (Test-Path -LiteralPath 'I_AM_CTX_DIR') { Write-Output 'context-workspace' } elseif (Test-Path -LiteralPath 'I_AM_SM_DIR') { Write-Output 'manager-workspace' } else { Write-Output 'missing-workspace' }"
2296 } else {
2297 "if [ -f I_AM_CTX_DIR ]; then printf 'context-workspace'; elif [ -f I_AM_SM_DIR ]; then printf 'manager-workspace'; else printf 'missing-workspace'; fi"
2298 };
2299 let result = BashTool::new("Bash")
2300 .execute(json!({"command": command}), &ctx)
2301 .await
2302 .expect("shell execute");
2303 assert!(result.success, "command failed: {:?}", result.content);
2304
2305 assert!(
2306 result
2307 .content
2308 .lines()
2309 .any(|line| line.trim() == "context-workspace"),
2310 "expected context.workspace marker, but shell reported: {:?}",
2311 result.content
2312 );
2313 }
2314
2315 // ── Kill-path overshoot regression tests (FINISH-0.9.4 #52 multiplier 2) ─────
2316 //
2317 // The foreground Bash kill path must return at ~timeout + a small bounded
2318 // grace, even when the command ignores SIGTERM or a descendant escapes the
2319 // process group while holding the output pipe open. Before the fix, an
2320 // escaped descendant wedged the blocking reader-thread join inside kill()
2321 // until the descendant exited on its own (observed: ~180s past a 120s
2322 // timeout in the wild).
2323
2324 #[cfg(unix)]
2325 const SHELL_SIGTERM_HELPER_ENV: &str = "CODEWHALE_SHELL_SIGTERM_HELPER";
2326 #[cfg(unix)]
2327 const SHELL_ESCAPE_HELPER_ENV: &str = "CODEWHALE_SHELL_ESCAPE_HELPER";
2328 #[cfg(unix)]
2329 const SHELL_ESCAPED_GRANDCHILD_ENV: &str = "CODEWHALE_SHELL_ESCAPED_GRANDCHILD";
2330
2331 /// Helper role: ignore SIGTERM and idle. Runs as the shell's direct child
2332 /// (same process group), so only the SIGKILL escalation can stop it.
2333 #[cfg(unix)]
2334 #[test]
2335 fn shell_sigterm_ignoring_helper_process() {
2336 if std::env::var(SHELL_SIGTERM_HELPER_ENV).ok().as_deref() != Some("1") {
2337 return;
2338 }
2339 unsafe {
2340 libc::signal(libc::SIGTERM, libc::SIG_IGN);
2341 }
2342 let pid_file = PathBuf::from(
2343 std::env::var(SHELL_DESCENDANT_PID_FILE_ENV).expect("sigterm helper pid file"),
2344 );
2345 std::fs::write(pid_file, std::process::id().to_string()).expect("write sigterm helper pid");
2346 loop {
2347 std::thread::sleep(Duration::from_millis(10));
2348 }
2349 }
2350
2351 /// Helper role: spawn a grandchild in its OWN process group (escaping the
2352 /// shell's group) that inherits the output pipe, then exit immediately. The
2353 /// wrapper shell keeps running (`sleep` after `&`), so the job stays Running
2354 /// while the escaped grandchild holds the reader thread's pipe open.
2355 #[cfg(unix)]
2356 // The grandchild deliberately outlives this helper and is never wait()ed on —
2357 // escaping reaping is exactly what the regression exercises; the test reaps
2358 // it directly via SIGKILL at the end.
2359 #[allow(clippy::zombie_processes)]
2360 #[test]
2361 fn shell_group_escape_helper_process() {
2362 if std::env::var(SHELL_ESCAPE_HELPER_ENV).ok().as_deref() != Some("1") {
2363 return;
2364 }
2365 let test_binary = std::env::current_exe().expect("current test binary");
2366 let pid_file = std::env::var(SHELL_DESCENDANT_PID_FILE_ENV).expect("escape pid file");
2367 let mut cmd = Command::new(test_binary);
2368 cmd.arg("--exact")
2369 .arg("tools::shell::tests::shell_escaped_grandchild_helper_process")
2370 .arg("--nocapture")
2371 .env(SHELL_ESCAPED_GRANDCHILD_ENV, "1")
2372 .env(SHELL_DESCENDANT_PID_FILE_ENV, pid_file);
2373 // A distinct process group is enough to escape `kill(-wrapper_pgid)`;
2374 // stdout/stderr are inherited, so the grandchild keeps the pipe open.
2375 #[cfg(unix)]
2376 cmd.process_group(0);
2377 let _child = cmd.spawn().expect("spawn escaped grandchild");
2378 }
2379
2380 /// Helper role: the escaped grandchild — ignores SIGTERM, reports its pid,
2381 /// then idles (holding the inherited output pipe open the whole time).
2382 #[cfg(unix)]
2383 #[test]
2384 fn shell_escaped_grandchild_helper_process() {
2385 if std::env::var(SHELL_ESCAPED_GRANDCHILD_ENV).ok().as_deref() != Some("1") {
2386 return;
2387 }
2388 unsafe {
2389 libc::signal(libc::SIGTERM, libc::SIG_IGN);
2390 }
2391 let pid_file =
2392 PathBuf::from(std::env::var(SHELL_DESCENDANT_PID_FILE_ENV).expect("grandchild pid file"));
2393 std::fs::write(pid_file, std::process::id().to_string()).expect("write grandchild pid");
2394 std::thread::sleep(Duration::from_secs(30));
2395 }
2396
2397 /// Required regression: a foreground command that ignores SIGTERM must be
2398 /// dead and the tool must have returned within timeout + a small grace
2399 /// (2s timeout, assert wall < 10s).
2400 #[cfg(unix)]
2401 #[tokio::test]
2402 async fn foreground_timeout_kills_sigterm_ignoring_command_within_grace() {
2403 let tmp = tempdir().expect("tempdir");
2404 let pid_file = tmp.path().join("sigterm-helper.pid");
2405 let test_binary = std::env::current_exe().expect("current test binary");
2406 let command = format!(
2407 "{SHELL_SIGTERM_HELPER_ENV}=1 {SHELL_DESCENDANT_PID_FILE_ENV}={} exec {} --exact {} --nocapture",
2408 shell_words::quote(&pid_file.display().to_string()),
2409 shell_words::quote(&test_binary.display().to_string()),
2410 shell_words::quote("tools::shell::tests::shell_sigterm_ignoring_helper_process"),
2411 );
2412 let ctx = ToolContext::new(tmp.path());
2413
2414 let started = Instant::now();
2415 let result = BashTool::new("Bash")
2416 .execute(json!({"command": command, "timeout_ms": 2_000}), &ctx)
2417 .await
2418 .expect("execute");
2419 let wall = started.elapsed();
2420
2421 assert!(!result.success);
2422 let meta = result.metadata.expect("metadata");
2423 assert_eq!(meta.get("status").and_then(Value::as_str), Some("TimedOut"));
2424 assert!(
2425 wall < Duration::from_secs(10),
2426 "kill path overshot the 2s timeout: wall {wall:?}"
2427 );
2428 let helper_pid = wait_for_shell_pid_file(&pid_file);
2429 assert!(
2430 wait_for_shell_pid_exit(helper_pid),
2431 "SIGTERM-ignoring helper {helper_pid} survived the timeout kill"
2432 );
2433 }
2434
2435 /// Regression for the ~180s kill-path overshoot: a descendant that escaped
2436 /// the process group keeps the output pipe open after the group is killed.
2437 /// kill() must still return within a bounded grace instead of blocking on
2438 /// the reader-thread join until the descendant exits on its own.
2439 #[cfg(unix)]
2440 #[tokio::test]
2441 async fn kill_returns_promptly_when_escaped_descendant_holds_pipe_open() {
2442 let tmp = tempdir().expect("tempdir");
2443 let pid_file = tmp.path().join("escaped-grandchild.pid");
2444 let test_binary = std::env::current_exe().expect("current test binary");
2445 let command = format!(
2446 "{SHELL_ESCAPE_HELPER_ENV}=1 {SHELL_DESCENDANT_PID_FILE_ENV}={} {} --exact {} --nocapture & sleep 60",
2447 shell_words::quote(&pid_file.display().to_string()),
2448 shell_words::quote(&test_binary.display().to_string()),
2449 shell_words::quote("tools::shell::tests::shell_group_escape_helper_process"),
2450 );
2451 let mut manager = ShellManager::new(tmp.path().to_path_buf());
2452 let started_bg = manager
2453 .execute(&command, None, 600_000, true)
2454 .expect("start wrapper");
2455 let task_id = started_bg.task_id.expect("task id");
2456 let grandchild = wait_for_shell_pid_file(&pid_file);
2457
2458 let started = Instant::now();
2459 let killed = manager.kill(&task_id).expect("kill");
2460 let wall = started.elapsed();
2461
2462 assert_eq!(killed.status, ShellStatus::Killed);
2463 assert!(
2464 wall < Duration::from_secs(10),
2465 "kill blocked {wall:?} on a reader wedged by an escaped descendant"
2466 );
2467
2468 // Cleanup: the escaped grandchild is out of reach of the group kill by
2469 // construction; reap it directly so the test does not leak a sleeper.
2470 unsafe {
2471 libc::kill(grandchild, libc::SIGKILL);
2472 }
2473 assert!(wait_for_shell_pid_exit(grandchild));
2474 }
2475
2476 /// `Bash` was the only action wrapper whose catch-all fell through to its most
2477 /// dangerous branch: an unrecognised action ran the command instead.
2478 #[tokio::test]
2479 async fn unknown_bash_action_is_refused_instead_of_running_the_command() {
2480 let workspace = tempdir().expect("workspace");
2481 let context = ToolContext::new(workspace.path().to_path_buf());
2482 let marker = workspace.path().join("should-not-exist");
2483
2484 let error = BashTool::new("Bash")
2485 .execute(
2486 json!({
2487 "action": "kill",
2488 "command": format!("touch {}", marker.display()),
2489 }),
2490 &context,
2491 )
2492 .await
2493 .expect_err("unknown action must be refused");
2494
2495 let message = error.to_string();
2496 assert!(message.contains("Unknown Bash action"), "{message}");
2497 assert!(message.contains("kill"), "{message}");
2498 assert!(
2499 message.contains("run, wait, interact, cancel"),
2500 "must name the actions that dispatch: {message}"
2501 );
2502 assert!(!marker.exists(), "the command must not have run");
2503 }
2504
2505 /// The same hole one type down. `and_then(as_str).unwrap_or("run")` read a
2506 /// non-string `action` as absent and fell through to the branch that executes
2507 /// arbitrary code, so `Bash{action: 3, command: "…"}` ran the command. `File`,
2508 /// `Git`, `Web`, and `Run` all refuse a non-string action; the tool that runs
2509 /// shell commands must not be the lenient one.
2510 #[tokio::test]
2511 async fn non_string_bash_action_is_refused_instead_of_running_the_command() {
2512 let workspace = tempdir().expect("workspace");
2513 let context = ToolContext::new(workspace.path().to_path_buf());
2514
2515 for action in [json!(3), json!(true), json!(["run"]), json!({"run": true})] {
2516 let marker = workspace.path().join(format!("marker-{action}"));
2517 let error = BashTool::new("Bash")
2518 .execute(
2519 json!({
2520 "action": action,
2521 "command": format!("touch {}", marker.display()),
2522 }),
2523 &context,
2524 )
2525 .await
2526 .expect_err("a non-string action must be refused");
2527
2528 let message = error.to_string();
2529 assert!(
2530 message.contains("'action'"),
2531 "must name the parameter: {message}"
2532 );
2533 assert!(
2534 message.contains("must be a string"),
2535 "must name the expected type: {message}"
2536 );
2537 assert!(!marker.exists(), "the command must not have run: {action}");
2538 }
2539 }
2540
2541 /// The same hole for the data fields (2026-08-04 review). A non-string
2542 /// `stdin` was silently dropped — the command ran with NO stdin and reported
2543 /// success, the silent-drop failure this lane exists to close. A non-string
2544 /// `cwd` silently ran in the workspace default. And a numeric `task_id` was
2545 /// reported as "missing", steering the model's retry the wrong way.
2546 #[tokio::test]
2547 async fn wrongly_typed_stdin_cwd_and_task_id_are_refused_not_dropped() {
2548 let workspace = tempdir().expect("workspace");
2549 let context = ToolContext::new(workspace.path().to_path_buf());
2550
2551 let marker = workspace.path().join("stdin-marker");
2552 let error = BashTool::new("Bash")
2553 .execute(
2554 json!({
2555 "command": format!("touch {}", marker.display()),
2556 "stdin": 12345,
2557 }),
2558 &context,
2559 )
2560 .await
2561 .expect_err("non-string stdin must be refused, never silently dropped");
2562 let message = error.to_string();
2563 assert!(message.contains("'stdin'"), "names the field: {message}");
2564 assert!(
2565 message.contains("must be a string"),
2566 "names the expected type: {message}"
2567 );
2568 assert!(!marker.exists(), "the command must not have run");
2569
2570 let error = BashTool::new("Bash")
2571 .execute(json!({ "command": "pwd", "cwd": 123 }), &context)
2572 .await
2573 .expect_err("non-string cwd must be refused, never defaulted");
2574 assert!(error.to_string().contains("'cwd'"), "{error}");
2575
2576 let error = BashTool::new("Bash")
2577 .execute(json!({ "action": "wait", "task_id": 42 }), &context)
2578 .await
2579 .expect_err("non-string task_id is a type error");
2580 let message = error.to_string();
2581 assert!(
2582 message.contains("'task_id'") && message.contains("must be a string"),
2583 "a supplied-but-mistyped task_id must not read as missing: {message}"
2584 );
2585 }
2586
2587 /// `null` is the wire spelling of absence, and `action` documents a `run`
2588 /// default — so the strictness above must not swallow the default.
2589 #[tokio::test]
2590 async fn absent_or_null_bash_action_still_defaults_to_run() {
2591 let workspace = tempdir().expect("workspace");
2592 let context = ToolContext::new(workspace.path().to_path_buf());
2593
2594 for input in [
2595 json!({"command": "echo defaulted"}),
2596 json!({"action": null, "command": "echo defaulted"}),
2597 ] {
2598 let result = BashTool::new("Bash")
2599 .execute(input.clone(), &context)
2600 .await
2601 .unwrap_or_else(|err| panic!("{input} must still run: {err}"));
2602 assert!(result.success, "{input}: {}", result.content);
2603 assert!(result.content.contains("defaulted"), "{}", result.content);
2604 }
2605 }
2606
2607 /// Negative case for the strictness above: every legitimate action still
2608 /// dispatches to its own handler rather than the action refusal. `wait`,
2609 /// `interact`, and `cancel` are checked by the error they raise *after*
2610 /// dispatch (a missing/unknown task), which only their own handlers produce.
2611 #[tokio::test]
2612 async fn every_valid_bash_action_still_dispatches() {
2613 let workspace = tempdir().expect("workspace");
2614 let context = ToolContext::new(workspace.path().to_path_buf());
2615 let tool = BashTool::new("Bash");
2616
2617 let ran = tool
2618 .execute(
2619 json!({"action": "run", "command": "echo dispatched"}),
2620 &context,
2621 )
2622 .await
2623 .expect("action=run must dispatch");
2624 assert!(ran.success, "{}", ran.content);
2625
2626 for input in [
2627 json!({"action": "wait", "task_id": "no-such-task"}),
2628 json!({"action": "interact", "task_id": "no-such-task", "stdin": "y\n"}),
2629 json!({"action": "cancel", "task_id": "no-such-task"}),
2630 ] {
2631 let outcome = tool.execute(input.clone(), &context).await;
2632 let message = match outcome {
2633 Ok(result) => result.content,
2634 Err(err) => err.to_string(),
2635 };
2636 assert!(
2637 !message.contains("Unknown Bash action") && !message.contains("must be a string"),
2638 "{input} must reach its own handler, got: {message}"
2639 );
2640 }
2641
2642 // `cancel` with `all` needs no task at all and must stay a success.
2643 let cancelled = tool
2644 .execute(json!({"action": "cancel", "all": true}), &context)
2645 .await
2646 .expect("action=cancel all=true must dispatch");
2647 assert!(cancelled.success, "{}", cancelled.content);
2648 }
2649
2650 /// The stdin aliases were real but undocumented: a model that wrote `input`
2651 /// or `data` got them honoured with nothing in the schema saying so, and a
2652 /// maintainer reading the schema would have removed them as dead. Advertise
2653 /// them, and hold every spelling to the same behavior.
2654 #[tokio::test]
2655 async fn every_advertised_stdin_spelling_reaches_the_command() {
2656 let workspace = tempdir().expect("workspace");
2657 let context = ToolContext::new(workspace.path().to_path_buf());
2658 let schema = BashTool::new("Bash").input_schema();
2659
2660 // `cat` is Unix-only; the dispatcher runs PowerShell or `cmd` on Windows,
2661 // where it is either absent or an alias for `Get-Content`, which reads a
2662 // file and not stdin. Ask for this platform's echo-stdin spelling — the
2663 // same helper `test_write_stdin_streams_output` uses.
2664 let echo_stdin = echo_stdin_command();
2665 for spelling in ["stdin", "input", "data"] {
2666 assert!(
2667 schema["properties"][spelling].is_object(),
2668 "`{spelling}` is honoured at runtime and must be advertised"
2669 );
2670 let result = BashTool::new("Bash")
2671 .execute(
2672 json!({"command": echo_stdin, spelling: "PIPED_THROUGH_ALIAS\n"}),
2673 &context,
2674 )
2675 .await
2676 .unwrap_or_else(|err| panic!("`{spelling}` must deliver stdin: {err}"));
2677 assert!(
2678 result.content.contains("PIPED_THROUGH_ALIAS"),
2679 "`{spelling}` did not reach the command: {}",
2680 result.content
2681 );
2682 }
2683
2684 // `id` is the same undocumented shape one parameter over.
2685 assert!(
2686 schema["properties"]["id"].is_object(),
2687 "`id` is accepted for `task_id` at runtime and must be advertised"
2688 );
2689 assert!(
2690 schema["properties"]["task_id"]["description"]
2691 .as_str()
2692 .is_some_and(|text| text.contains("`id`")),
2693 "task_id must name its alias"
2694 );
2695 }
2696
2697 /// The schema declared no `required` key at all, so `Bash{}` — no command, no
2698 /// task — was schema-valid for the tool that runs shell commands. What is
2699 /// required is per-action, so it is spelled as root `anyOf` required groups,
2700 /// the same shape `finance` and `apply_patch` already use.
2701 #[test]
2702 fn bash_schema_declares_what_each_action_requires() {
2703 let schema = BashTool::new("Bash").input_schema();
2704 let groups: Vec<Vec<String>> = schema["anyOf"]
2705 .as_array()
2706 .expect("root anyOf required groups")
2707 .iter()
2708 .map(|group| {
2709 group["required"]
2710 .as_array()
2711 .expect("required group")
2712 .iter()
2713 .map(|name| name.as_str().expect("required name").to_string())
2714 .collect()
2715 })
2716 .collect();
2717
2718 for expected in [["command"], ["task_id"], ["id"], ["all"]] {
2719 assert!(
2720 groups.iter().any(|group| group.as_slice() == expected),
2721 "missing required group {expected:?} in {groups:?}"
2722 );
2723 }
2724 // A required name the same schema does not advertise would be
2725 // unsatisfiable: the model could not learn what to send.
2726 for group in &groups {
2727 for name in group {
2728 assert!(
2729 schema["properties"][name].is_object(),
2730 "`{name}` is required but not advertised"
2731 );
2732 }
2733 }
2734 }
2735
2736 /// A root `anyOf` is not portable to every provider, so prove the fallback
2737 /// the sanitizer promises: Responses/xAI drop root composition, and the
2738 /// constraint has to survive as a description note rather than vanishing.
2739 #[test]
2740 fn bash_required_groups_survive_a_provider_that_drops_root_composition() {
2741 let mut schema = BashTool::new("Bash").input_schema();
2742 let note = crate::tools::schema_sanitize::sanitize_for_responses(&mut schema)
2743 .expect("dropped required groups must be restated for the model");
2744
2745 assert!(note.contains("At least one"), "{note}");
2746 for name in ["`command`", "`task_id`", "`id`", "`all`"] {
2747 assert!(note.contains(name), "note must name {name}: {note}");
2748 }
2749 assert_eq!(schema["type"], "object");
2750 assert!(schema.get("anyOf").is_none(), "root anyOf must be removed");
2751 assert!(schema["properties"]["command"].is_object());
2752 }
2753
2754 /// Every hint in this file has to name a tool the model can actually call.
2755 /// `exec_shell` / `exec_shell_wait` were retired in v0.9.3.
2756 #[test]
2757 fn shell_recovery_hints_name_only_dispatchable_tools() {
2758 assert!(!FOREGROUND_TIMEOUT_RECOVERY_HINT.contains("exec_shell"));
2759 assert!(FOREGROUND_TIMEOUT_RECOVERY_HINT.contains("Bash"));
2760 assert!(FOREGROUND_TIMEOUT_RECOVERY_HINT.contains("action=\"wait\""));
2761 }
2762
2763 /// One documented default hid three real ones: `wait` uses 30s and
2764 /// `interact` 1s, so a model omitting `timeout_ms` on `wait` got a quarter of
2765 /// the timeout the schema promised.
2766 #[test]
2767 fn timeout_ms_description_covers_every_action_default() {
2768 let schema = BashTool::new("Bash").input_schema();
2769 let description = schema["properties"]["timeout_ms"]["description"]
2770 .as_str()
2771 .expect("timeout_ms description");
2772
2773 for expected in ["120000", "600000", "30000", "1000"] {
2774 assert!(
2775 description.contains(expected),
2776 "missing {expected}: {description}"
2777 );
2778 }
2779 }
2780
2780 lines RUST