返回 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 shell_catalog_guidance_matches_execution() {
26 let tool = LowercaseBashTool;
27 let schema = tool.input_schema();
28 let command = schema["properties"]["command"]["description"]
29 .as_str()
30 .unwrap();
31 let dispatcher = crate::shell_dispatcher::global_dispatcher();
32 assert!(command.contains(dispatcher.kind().binary()));
33 assert!(
34 !tool.description().contains(command),
35 "the command's interpreter guidance must appear once in each request"
36 );
37 assert_eq!(tool.name(), "bash");
38 assert!(tool.model_visible());
39 assert!(!command.contains("action=run"));
40 assert!(!tool.description().contains("background=true"));
41 assert!(
42 tool.description().contains(
43 "In Ask, after a sandbox denial, retry the exact command once with sandbox_permissions (the narrowest wider mode that suffices) and a one-sentence justification; the approval prompt asks the user."
44 ),
45 "foreground guidance must preserve the sandbox retry and approval contract"
46 );
47 let legacy = BashTool::new("Bash");
48 assert!(!legacy.model_visible());
49 assert!(legacy.description().contains(command));
50 assert!(legacy.description().contains("background=true"));
51 assert!(legacy.description().contains("wait=false"));
52 let readonly = BashTool::read_only("Bash");
53 assert!(readonly.description().contains("never through a shell"));
54 assert!(
55 !readonly
56 .input_schema()
57 .to_string()
58 .contains("Actual execution shell")
59 );
60 let alias = BashTool::alias("exec_shell", "run");
61 assert_eq!(alias.description(), legacy.description());
62 let workspace = tempdir().unwrap();
63 let mut registry = crate::tools::ToolRegistry::new(ToolContext::new(workspace.path()));
64 registry.register(std::sync::Arc::new(BashTool::new("Bash")));
65 registry.register(std::sync::Arc::new(LowercaseBashTool));
66 let catalog = registry.to_api_tools();
67 assert_eq!(catalog.len(), 1);
68 assert_eq!(catalog[0].name, "bash");
69 assert_eq!(catalog[0].description, tool.description());
70 assert_eq!(
71 catalog[0].input_schema["properties"]["command"]["description"],
72 command
73 );
74 }
75
76 #[test]
77 #[ignore = "Exports model-visible shell fixtures for opt-in live model evaluation"]
78 fn export_shell_guidance_eval_fixture() {
79 let path = std::env::var_os("SHELL_GUIDANCE_FIXTURE").expect("SHELL_GUIDANCE_FIXTURE");
80 let workspace = tempdir().unwrap();
81 let mut registry = crate::tools::ToolRegistry::new(ToolContext::new(workspace.path()));
82 registry.register(std::sync::Arc::new(BashTool::new("Bash")));
83 registry.register(std::sync::Arc::new(LowercaseBashTool));
84 let catalog = registry.to_api_tools();
85 let tool = catalog.iter().find(|tool| tool.name == "bash").unwrap();
86 let fixture = json!({
87 "name": tool.name,
88 "description": tool.description,
89 "input_schema": tool.input_schema,
90 "shell": crate::shell_dispatcher::global_dispatcher().kind().binary(),
91 });
92 std::fs::write(path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap();
93 }
94
95 #[test]
96 fn lowercase_bash_schema_is_small_contract() {
97 let schema = LowercaseBashTool.input_schema();
98 assert_eq!(schema["required"], json!(["command"]));
99 assert_eq!(schema["additionalProperties"], false);
100 assert_eq!(
101 schema["properties"]
102 .as_object()
103 .expect("properties")
104 .keys()
105 .cloned()
106 .collect::<std::collections::BTreeSet<_>>(),
107 [
108 "command",
109 "justification",
110 "read_only",
111 "sandbox_permissions",
112 "timeout"
113 ]
114 .into_iter()
115 .map(str::to_string)
116 .collect()
117 );
118 assert!(!BashTool::new("Bash").model_visible());
119 }
120
121 #[test]
122 fn lowercase_bash_description_matches_the_timeout_it_actually_applies() {
123 use super::{
124 CONTRACT_BASH_FOREGROUND_DEFAULT_TIMEOUT_MS, contract_bash_legacy_input,
125 contract_bash_timeout_ms,
126 };
127
128 // `bash {command}` with no `timeout` translates to a legacy input carrying
129 // no `timeout_ms`, and the contract delegate then bounds the foreground run
130 // at the 120 s default and kills the process there.
131 let translated =
132 contract_bash_legacy_input(&json!({"command": "sleep 600"})).expect("translated input");
133 assert!(
134 translated.get("timeout_ms").is_none(),
135 "omitting `timeout` must not synthesise one during translation: {translated}"
136 );
137 assert_eq!(
138 contract_bash_timeout_ms(true, None, false, false),
139 Some(CONTRACT_BASH_FOREGROUND_DEFAULT_TIMEOUT_MS)
140 );
141
142 // The tool description is the only place the model learns this. It used to
143 // say "when omitted there is no default timeout", so a model running a
144 // four-minute build had every reason not to pass a timeout, and got the
145 // process killed at two minutes anyway.
146 let description = LowercaseBashTool.description();
147 assert!(
148 !description.contains("no default timeout"),
149 "description contradicts the applied default: {description}"
150 );
151 let default_seconds = CONTRACT_BASH_FOREGROUND_DEFAULT_TIMEOUT_MS / 1_000;
152 assert!(
153 description.contains(&format!("{default_seconds} seconds")),
154 "description must name the default it applies: {description}"
155 );
156 let schema = LowercaseBashTool.input_schema();
157 let timeout_doc = schema["properties"]["timeout"]["description"]
158 .as_str()
159 .expect("timeout description");
160 assert!(
161 !timeout_doc.contains("no default timeout"),
162 "schema contradicts the applied default: {timeout_doc}"
163 );
164 assert!(
165 timeout_doc.contains(&format!("{default_seconds} seconds")),
166 "schema must name the default it applies: {timeout_doc}"
167 );
168 }
169
170 #[test]
171 fn contract_bash_foreground_without_a_timeout_is_bounded_not_endless() {
172 use super::{
173 BASH_MAX_TIMEOUT_MS, CONTRACT_BASH_FOREGROUND_DEFAULT_TIMEOUT_MS, contract_bash_timeout_ms,
174 };
175
176 // The reported hang: `bash` in the foreground with no timeout. It used to
177 // resolve to BASH_MAX_TIMEOUT_MS (~24.8 days), so an unauthenticated CLI
178 // waiting on a prompt held the turn open indefinitely. It now takes the
179 // default the tool's own schema advertises, which is what arms the
180 // kill-and-rerun-in-background recovery.
181 assert_eq!(
182 contract_bash_timeout_ms(true, None, false, false),
183 Some(CONTRACT_BASH_FOREGROUND_DEFAULT_TIMEOUT_MS)
184 );
185 const { assert!(CONTRACT_BASH_FOREGROUND_DEFAULT_TIMEOUT_MS < BASH_MAX_TIMEOUT_MS) };
186
187 // An explicit request still wins, including one far above the default:
188 // long foreground work stays possible when the model asks for it.
189 assert_eq!(
190 contract_bash_timeout_ms(true, Some(1_800_000), false, false),
191 Some(1_800_000)
192 );
193 assert_eq!(
194 contract_bash_timeout_ms(true, Some(5), false, false),
195 Some(5)
196 );
197
198 // Background and interactive runs are meant to outlive the call, so they
199 // keep "no timeout" and are never bounded by the foreground default.
200 assert_eq!(contract_bash_timeout_ms(true, None, true, false), None);
201 assert_eq!(contract_bash_timeout_ms(true, None, false, true), None);
202
203 // The standalone Bash tools already resolve their own default upstream;
204 // this helper must not second-guess the value they pass in.
205 assert_eq!(
206 contract_bash_timeout_ms(false, Some(120_000), false, false),
207 Some(120_000)
208 );
209 }
210
211 #[cfg(all(unix, not(target_env = "ohos")))]
212 #[test]
213 fn inherited_interactive_terminal_fails_closed_before_spawn() {
214 let workspace = tempdir().expect("workspace");
215 let mut manager = ShellManager::new(workspace.path().to_path_buf());
216 let err = manager
217 .execute_interactive_with_policy_env("codew", None, 10_000, None, HashMap::new())
218 .expect_err("Unix inherited-terminal takeover must not spawn");
219 let message = err.to_string();
220 assert!(message.contains("foreground TTY ownership"), "{message}");
221 assert!(message.contains("background: true, tty: true"), "{message}");
222 assert!(message.contains("action: \"interact\""), "{message}");
223 assert!(message.contains("task_id"), "{message}");
224 assert!(message.contains("terminal/run"), "{message}");
225 assert!(message.contains("terminal/send"), "{message}");
226 }
227
228 #[cfg(all(unix, target_env = "ohos"))]
229 #[test]
230 fn inherited_interactive_terminal_offers_only_ohos_recovery_paths() {
231 let workspace = tempdir().expect("workspace");
232 let mut manager = ShellManager::new(workspace.path().to_path_buf());
233 let err = manager
234 .execute_interactive_with_policy_env("codew", None, 10_000, None, HashMap::new())
235 .expect_err("OHOS inherited-terminal takeover must not spawn");
236 let message = err.to_string();
237 assert!(message.contains("foreground TTY ownership"), "{message}");
238 assert!(message.contains("new terminal"), "{message}");
239 assert!(message.contains("omit `interactive: true`"), "{message}");
240 assert!(!message.contains("background: true"), "{message}");
241 assert!(!message.contains("terminal/run"), "{message}");
242 }
243
244 #[test]
245 fn contract_bash_nonzero_is_an_error_with_status_after_output() {
246 let error = finish_contract_bash_result(
247 ShellResult {
248 task_id: None,
249 status: ShellStatus::Failed,
250 exit_code: Some(7),
251 stdout: "before".to_string(),
252 stderr: String::new(),
253 duration_ms: 1,
254 stdout_len: 6,
255 stderr_len: 0,
256 stdout_omitted: 0,
257 stderr_omitted: 0,
258 stdout_truncated: false,
259 stderr_truncated: false,
260 sandboxed: false,
261 sandbox_type: None,
262 sandbox_denied: false,
263 },
264 None,
265 &ToolContext::new("."),
266 )
267 .expect_err("nonzero must be a failed tool call");
268 assert!(
269 error
270 .to_string()
271 .ends_with("before\n\nCommand exited with code 7")
272 );
273 }
274
275 #[cfg(unix)]
276 #[tokio::test]
277 async fn lowercase_bash_returns_one_ordered_stream() {
278 let workspace = tempdir().expect("workspace");
279 let context = ToolContext::new(workspace.path());
280 let result = LowercaseBashTool
281 .execute(
282 json!({"command": "printf out-1; printf err-2 >&2; printf out-3"}),
283 &context,
284 )
285 .await
286 .expect("bash");
287 assert_eq!(result.content, "out-1err-2out-3");
288 }
289
290 #[cfg(unix)]
291 #[tokio::test]
292 async fn lowercase_bash_keeps_raw_command_under_readonly_policy() {
293 let workspace = tempdir().expect("workspace");
294 let context = ToolContext::new(workspace.path())
295 .with_shell_policy(crate::worker_profile::ShellPolicy::ReadOnly);
296 let result = LowercaseBashTool
297 .execute(json!({"command": "pwd"}), &context)
298 .await
299 .expect("read-only bash");
300 assert_eq!(
301 result.content.trim(),
302 workspace
303 .path()
304 .canonicalize()
305 .expect("canonical workspace")
306 .display()
307 .to_string()
308 );
309 }
310
311 #[tokio::test]
312 async fn lowercase_bash_readonly_refusal_names_work_mode() {
313 let workspace = tempdir().expect("workspace");
314 let context = ToolContext::new(workspace.path())
315 .with_shell_policy(crate::worker_profile::ShellPolicy::ReadOnly);
316 let result = LowercaseBashTool
317 .execute(json!({"command": "touch blocked-by-plan"}), &context)
318 .await
319 .expect("policy refusal is a normal tool result");
320
321 assert!(!result.success);
322 assert!(result.content.contains("Work mode (`/mode work`)"));
323 assert!(!result.content.contains("Act mode"));
324 assert!(!workspace.path().join("blocked-by-plan").exists());
325 }
326
327 /// Regression for the wedge that took out the owner's own session under swap
328 /// exhaustion: the lowercase `bash` spill file could not be created (full temp
329 /// volume), every call — including `echo ok` — failed with the harness-internal
330 /// "Failed to create streaming shell output", and nothing recovered. Spill
331 /// failure must be soft: the command still runs, the tail is still returned,
332 /// the next call still works, and no job state leaks.
333 #[cfg(unix)]
334 #[tokio::test]
335 async fn lowercase_bash_survives_spill_file_failure_and_stays_usable() {
336 let workspace = tempdir().expect("workspace");
337 let context = ToolContext::new(workspace.path());
338 let missing_spill_dir = workspace.path().join("no-such-temp-volume");
339 assert!(!missing_spill_dir.exists());
340 context
341 .shell_manager
342 .lock()
343 .expect("shell manager")
344 .set_output_spill_dir_for_test(Some(missing_spill_dir.clone()));
345
346 // (ii) the command runs and (i) no harness-internal error leaks.
347 let first = LowercaseBashTool
348 .execute(json!({"command": echo_command("ok")}), &context)
349 .await
350 .expect("bash runs even when the spill file cannot be created");
351 assert!(first.success, "{}", first.content);
352 assert_eq!(first.content.trim(), "ok");
353 assert!(
354 !first
355 .content
356 .contains("Failed to create streaming shell output")
357 );
358
359 // The next call must work too — the whole point of the fix.
360 let second = LowercaseBashTool
361 .execute(json!({"command": echo_command("still-ok")}), &context)
362 .await
363 .expect("second bash call after a spill failure");
364 assert!(second.success, "{}", second.content);
365 assert_eq!(second.content.trim(), "still-ok");
366
367 // Output past the bound is still delivered, and the notice explains why the
368 // full-output path is missing instead of pointing at a file that was never
369 // written.
370 let long = LowercaseBashTool
371 .execute(
372 json!({"command": "i=0; while [ $i -lt 2100 ]; do echo line-$i; i=$((i+1)); done"}),
373 &context,
374 )
375 .await
376 .expect("long bash output without a spill file");
377 assert!(long.success, "{}", long.content);
378 assert!(long.content.contains("line-2099"));
379 assert!(
380 long.content.contains("Full output was not persisted:"),
381 "{}",
382 long.content
383 );
384 assert!(!long.content.contains("Full output: "));
385
386 // (iii) no leaked session state: nothing is still running or unowned-pending.
387 let mut manager = context.shell_manager.lock().expect("shell manager");
388 let running = manager
389 .list_jobs()
390 .into_iter()
391 .filter(|job| job.status == ShellStatus::Running)
392 .count();
393 assert_eq!(running, 0, "no shell job may be left running");
394 assert!(
395 !missing_spill_dir.exists(),
396 "fail-soft must not create the dir"
397 );
398 }
399
400 /// A spawn/stream failure caused by host exhaustion must reach the model as
401 /// an actionable message (cause chain + likely reason + retry), never as a
402 /// bare harness-internal context string.
403 #[test]
404 fn shell_execution_failure_names_resource_exhaustion_and_says_retry() {
405 let error = anyhow::Error::from(std::io::Error::from(std::io::ErrorKind::StorageFull))
406 .context("Failed to open PTY");
407 let message = shell_execution_failed_message(&error);
408 assert!(
409 message.starts_with("Shell execution failed: Failed to open PTY"),
410 "{message}"
411 );
412 assert!(
413 message.contains("Likely host resource exhaustion"),
414 "{message}"
415 );
416 assert!(message.contains("disk"), "{message}");
417 assert!(message.contains("retry"), "{message}");
418 assert!(message.contains("still usable"), "{message}");
419
420 #[cfg(unix)]
421 {
422 let error = anyhow::Error::from(std::io::Error::from_raw_os_error(libc::EMFILE))
423 .context("Failed to spawn PTY command: echo ok");
424 let message = shell_execution_failed_message(&error);
425 assert!(message.contains("file descriptors"), "{message}");
426 assert!(message.contains("echo ok"), "{message}");
427 }
428
429 let plain = anyhow::anyhow!("working directory does not exist");
430 let message = shell_execution_failed_message(&plain);
431 assert_eq!(
432 message,
433 "Shell execution failed: working directory does not exist"
434 );
435 }
436
437 #[tokio::test]
438 async fn lowercase_bash_timeout_uses_seconds_and_fails() {
439 let workspace = tempdir().expect("workspace");
440 let context = ToolContext::new(workspace.path());
441 let error = LowercaseBashTool
442 .execute(
443 json!({"command": sleep_command(2), "timeout": 0.01}),
444 &context,
445 )
446 .await
447 .expect_err("timeout must fail");
448 assert!(
449 error
450 .to_string()
451 .contains("Command timed out after 0.01 seconds"),
452 "{error}"
453 );
454 }
455
456 fn execute_shell(
457 manager: &mut ShellManager,
458 command: &str,
459 working_dir: Option<&str>,
460 timeout_ms: u64,
461 background: bool,
462 ) -> Result<ShellResult> {
463 manager.execute_with_options_env_for_session(
464 command,
465 working_dir,
466 timeout_ms,
467 background,
468 None,
469 false,
470 None,
471 HashMap::new(),
472 "workspace",
473 )
474 }
475
476 #[test]
477 fn deleted_saved_workspace_reports_path_and_recovery_before_spawn() {
478 let workspace = tempdir().expect("workspace");
479 let stale = workspace.path().join("deleted-session-workspace");
480 let mut manager = ShellManager::new(stale.clone());
481
482 let error = execute_shell(&mut manager, "echo should-not-run", None, 1_000, false)
483 .expect_err("missing saved workspace must fail before shell spawn");
484 let message = error.to_string();
485 assert!(message.contains("saved session workspace is unavailable"));
486 assert!(message.contains(&stale.display().to_string()));
487 assert!(message.contains("working_dir") || message.contains("cwd"));
488 assert!(message.contains("resume/fork"));
489 }
490
491 #[test]
492 fn explicit_missing_working_dir_is_not_misreported_as_session_corruption() {
493 let workspace = tempdir().expect("workspace");
494 let missing = workspace.path().join("explicit-missing");
495 let mut manager = ShellManager::new(workspace.path().to_path_buf());
496
497 let error = execute_shell(
498 &mut manager,
499 "echo should-not-run",
500 missing.to_str(),
501 1_000,
502 false,
503 )
504 .expect_err("missing explicit cwd must fail before shell spawn");
505 let message = error.to_string();
506 assert!(message.contains("requested working directory is unavailable"));
507 assert!(message.contains(&missing.display().to_string()));
508 assert!(!message.contains("saved session workspace"));
509 }
510
511 #[cfg(not(target_env = "ohos"))]
512 #[test]
513 fn pty_exit_status_preserves_high_windows_code_losslessly() {
514 let raw = 0xC000_0005;
515 let status = ShellExitStatus::from_pty(portable_pty::ExitStatus::with_exit_code(raw));
516
517 assert!(!status.success);
518 assert_eq!(status.code, Some(i64::from(raw)));
519 assert_eq!(
520 exit_code_label(status.code),
521 "exit code 3221225477 (0xC0000005)"
522 );
523 assert_eq!(exit_code_hex(status.code).as_deref(), Some("0xC0000005"));
524 }
525
526 #[cfg(not(target_env = "ohos"))]
527 #[test]
528 fn ordinary_pty_exit_status_keeps_concise_label() {
529 let status = ShellExitStatus::from_pty(portable_pty::ExitStatus::with_exit_code(127));
530
531 assert_eq!(status.code, Some(127));
532 assert_eq!(exit_code_label(status.code), "exit code 127");
533 assert_eq!(exit_code_hex(status.code), None);
534 }
535
536 #[cfg(windows)]
537 #[test]
538 fn std_windows_exit_status_reinterprets_signed_dword() {
539 assert_eq!(std_exit_code_i64(0xC000_0005_u32 as i32), 0xC000_0005);
540 }
541
542 #[cfg(windows)]
543 const JOB_OBJECT_QUERY_ACCESS: u32 = 0x0004;
544
545 #[cfg(windows)]
546 fn duplicate_job_without_terminate_access(job: WindowsJob) -> WindowsJob {
547 let process = unsafe { GetCurrentProcess() };
548 let mut limited_handle = HANDLE::default();
549
550 unsafe {
551 DuplicateHandle(
552 process,
553 job.handle,
554 process,
555 &mut limited_handle,
556 JOB_OBJECT_QUERY_ACCESS,
557 false,
558 DUPLICATE_HANDLE_OPTIONS(0),
559 )
560 .expect("duplicate job handle without terminate access");
561 }
562
563 drop(job);
564 WindowsJob {
565 handle: limited_handle,
566 }
567 }
568
569 fn echo_command(message: &str) -> String {
570 format!("echo {message}")
571 }
572
573 fn sleep_command(seconds: u64) -> String {
574 let dispatcher = crate::shell_dispatcher::global_dispatcher();
575 if dispatcher.kind().is_powershell() {
576 return format!("Start-Sleep -Seconds {seconds}");
577 }
578 #[cfg(windows)]
579 {
580 let ping_count = seconds.saturating_add(1);
581 format!("ping 127.0.0.1 -n {ping_count} > NUL")
582 }
583 #[cfg(not(windows))]
584 {
585 format!("sleep {seconds}")
586 }
587 }
588
589 fn sleep_then_echo_command(seconds: u64, message: &str) -> String {
590 let dispatcher = crate::shell_dispatcher::global_dispatcher();
591 if dispatcher.kind().is_powershell() {
592 return format!("Start-Sleep -Seconds {seconds}; echo {message}");
593 }
594 #[cfg(windows)]
595 {
596 let ping_count = seconds.saturating_add(1);
597 format!("ping 127.0.0.1 -n {ping_count} > NUL && echo {message}")
598 }
599 #[cfg(not(windows))]
600 {
601 format!("sleep {seconds} && echo {message}")
602 }
603 }
604
605 fn echo_stdin_command() -> String {
606 let dispatcher = crate::shell_dispatcher::global_dispatcher();
607 if dispatcher.kind().is_powershell() {
608 return "[Console]::In.ReadToEnd()".to_string();
609 }
610 #[cfg(windows)]
611 {
612 "more".to_string()
613 }
614 #[cfg(not(windows))]
615 {
616 "cat".to_string()
617 }
618 }
619
620 fn network_restricted_context(tmp: &std::path::Path) -> ToolContext {
621 ToolContext::new(tmp)
622 .with_elevated_sandbox_policy(ExecutionSandboxPolicy::WorkspaceWrite {
623 writable_roots: vec![tmp.to_path_buf()],
624 network_access: false,
625 exclude_tmpdir: false,
626 exclude_slash_tmp: false,
627 })
628 .with_shell_network_denied_hint(
629 "Shell command blocked: Plan mode runs shell commands in a network-restricted sandbox.",
630 )
631 }
632
633 fn failed_network_shell_result(stdout: &str, stderr: &str) -> ShellResult {
634 ShellResult {
635 task_id: None,
636 status: ShellStatus::Failed,
637 exit_code: Some(6),
638 stdout: stdout.to_string(),
639 stderr: stderr.to_string(),
640 duration_ms: 25,
641 stdout_len: stdout.len(),
642 stderr_len: stderr.len(),
643 stdout_omitted: 0,
644 stderr_omitted: 0,
645 stdout_truncated: false,
646 stderr_truncated: false,
647 sandboxed: true,
648 sandbox_type: Some("seatbelt".to_string()),
649 sandbox_denied: false,
650 }
651 }
652
653 #[cfg(unix)]
654 const SHELL_DESCENDANT_HELPER_ENV: &str = "CODEWHALE_SHELL_DESCENDANT_HELPER";
655 #[cfg(unix)]
656 const SHELL_DESCENDANT_PID_FILE_ENV: &str = "CODEWHALE_SHELL_DESCENDANT_PID_FILE";
657
658 #[cfg(unix)]
659 #[test]
660 fn shell_descendant_helper_process() {
661 if std::env::var(SHELL_DESCENDANT_HELPER_ENV).ok().as_deref() != Some("1") {
662 return;
663 }
664 let pid_file =
665 PathBuf::from(std::env::var(SHELL_DESCENDANT_PID_FILE_ENV).expect("descendant pid file"));
666 let mut child = Command::new("sleep")
667 .arg("30")
668 .spawn()
669 .expect("spawn cheap descendant");
670 std::fs::write(pid_file, child.id().to_string()).expect("write descendant pid");
671 std::thread::sleep(Duration::from_secs(30));
672 let _ = child.wait();
673 }
674
675 #[cfg(unix)]
676 fn wait_for_shell_pid_file(path: &Path) -> libc::pid_t {
677 let deadline = Instant::now() + Duration::from_secs(5);
678 loop {
679 if let Ok(raw) = std::fs::read_to_string(path)
680 && let Ok(pid) = raw.trim().parse()
681 {
682 return pid;
683 }
684 assert!(
685 Instant::now() < deadline,
686 "descendant pid file never appeared"
687 );
688 std::thread::sleep(Duration::from_millis(25));
689 }
690 }
691
692 #[cfg(unix)]
693 fn wait_for_shell_pid_exit(pid: libc::pid_t) -> bool {
694 let deadline = Instant::now() + Duration::from_secs(2);
695 loop {
696 if unsafe { libc::kill(pid, 0) } != 0
697 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH)
698 {
699 return true;
700 }
701 if Instant::now() >= deadline {
702 return false;
703 }
704 std::thread::sleep(Duration::from_millis(25));
705 }
706 }
707
708 fn wait_for_completed_shell(manager: &mut ShellManager, task_id: &str) -> ShellResult {
709 let deadline = Instant::now() + Duration::from_millis(BACKGROUND_COMPLETION_WAIT_MS);
710
711 loop {
712 let result = manager
713 .get_output(task_id, true, 1_000)
714 .expect("get_output");
715 if result.status != ShellStatus::Running || Instant::now() >= deadline {
716 return result;
717 }
718 std::thread::sleep(Duration::from_millis(50));
719 }
720 }
721
722 #[test]
723 fn shell_owner_registers_before_spawn_and_silent_work_stays_live() {
724 let work = crate::work_graph::new_shared_work_runtime(
725 crate::tools::todo::new_shared_todo_list(),
726 crate::tools::plan::new_shared_plan_state(),
727 );
728 let lifecycle = ShellWorkLifecycle {
729 work: work.clone(),
730 session_id: "shell-session".to_string(),
731 };
732
733 {
734 let _guard = ShellSpawnIntentGuard::new(
735 Some(lifecycle.clone()),
736 "shell_spawn_failure",
737 "missing-program",
738 );
739 }
740 lifecycle
741 .register("shell_silent", "sleep 30")
742 .expect("register silent shell");
743 lifecycle
744 .observe("shell_silent", &ShellStatus::Running, 1, 0)
745 .expect("live owner observation");
746 lifecycle
747 .observe("shell_silent", &ShellStatus::Running, 2, 512)
748 .expect("growing output observation");
749
750 let graph = work
751 .capture(Some("shell-session"))
752 .expect("capture")
753 .expect("graph")
754 .graph;
755 let operation = |external: &str| {
756 graph.nodes.iter().find(|node| {
757 node.binding
758 .as_ref()
759 .is_some_and(|binding| binding.external == external)
760 })
761 };
762 assert_eq!(
763 operation("shell:shell_spawn_failure").map(|node| node.state),
764 Some(crate::work_graph::NodeState::Failed),
765 "dropping an armed spawn guard must terminalize pre-spawn failure"
766 );
767 let silent = operation("shell:shell_silent").expect("silent shell operation");
768 assert_eq!(silent.state, crate::work_graph::NodeState::Active);
769 let observation = silent
770 .binding
771 .as_ref()
772 .and_then(|binding| binding.last_observation.as_ref())
773 .expect("last shell observation");
774 assert_eq!(observation.seq, 2);
775 assert_eq!(
776 observation
777 .output
778 .as_ref()
779 .and_then(crate::work_graph::EvidenceRef::raw_bytes),
780 Some(512)
781 );
782 }
783
784 #[test]
785 fn exec_shell_parallel_flags_are_input_aware() {
786 let tool = BashTool::new("Bash");
787 let readonly = json!({"command": "git status -s"});
788 assert!(tool.supports_parallel_for(&readonly));
789 assert!(tool.is_read_only_for(&readonly));
790 assert_eq!(
791 tool.approval_requirement_for(&readonly),
792 ApprovalRequirement::Auto
793 );
794
795 for input in [
796 json!({"command": "fd -e rs ."}),
797 json!({"command": "fd -H --type f src"}),
798 json!({"command": "git grep TODO crates/tui/src/tools"}),
799 json!({"action": "run", "command": "gh issue list --limit 10"}),
800 json!({"action": "run", "command": "gh issue view 5287"}),
801 ] {
802 assert!(tool.supports_parallel_for(&input), "{input:?}");
803 assert!(tool.is_read_only_for(&input), "{input:?}");
804 assert_eq!(
805 tool.approval_requirement_for(&input),
806 ApprovalRequirement::Auto,
807 "{input:?}"
808 );
809 }
810
811 for input in [
812 json!({"command": "git status -s", "background": true}),
813 json!({"command": "git status -s", "background": "false"}),
814 json!({"command": "git status -s", "stdin": ""}),
815 json!({"action": "wait", "command": "pwd", "task_id": "shell_1"}),
816 json!({"action": "interact", "command": "pwd", "task_id": "shell_1"}),
817 json!({"action": "cancel", "command": "pwd", "task_id": "shell_1"}),
818 json!({"action": 3, "command": "pwd"}),
819 json!({"command": "pwd", "unexpected": true}),
820 json!({"command": "cargo build"}),
821 json!({"command": "bash -lc 'git status'"}),
822 json!({"command": "sh -c 'rg TODO crates'"}),
823 json!({"command": "PAGER=./pwn.sh git log"}),
824 json!({"command": "GH_PAGER=./pwn.sh gh issue view 5287"}),
825 json!({"command": "rg ${9:---pre=./repo-script} needle ."}),
826 json!({"command": "rg ${9:---hostname-bin=./repo-script} needle ."}),
827 json!({"command": "fd ${9:---exec} ./repo-script"}),
828 json!({"command": "rg $PATTERN ."}),
829 json!({"command": "rg *.rs ."}),
830 json!({"command": "bash -lc 'rg TODO crates | head'"}),
831 json!({"command": "fd -x ./pwn.sh"}),
832 json!({"command": "fd --exec ./pwn.sh"}),
833 json!({"command": "fd -uHtx ./pwn.sh"}),
834 json!({"command": "rg --pre /tmp/evil.sh needle ."}),
835 json!({"command": "rg --hostname-bin ./repo-script --hyperlink-format=file://{host}{path} needle ."}),
836 json!({"command": "rg --search-zip needle ."}),
837 json!({"command": "rg -z needle ."}),
838 json!({"command": "git grep -O needle"}),
839 json!({"command": "git grep -nO needle"}),
840 json!({"command": "git grep --textconv needle"}),
841 json!({"command": "git diff --ext-diff HEAD"}),
842 json!({"command": "git diff --textconv HEAD"}),
843 json!({"command": "git log --show-signature -1"}),
844 json!({"command": "git show --format=%GS HEAD"}),
845 json!({"command": "gh issue close 5287"}),
846 json!({"command": "gh issue view 5287 > issue.txt"}),
847 json!({"command": "gh pr checks 42 --watch"}),
848 json!({"command": "gh issue view 5287 -R git.example.com/o/r"}),
849 ] {
850 assert!(!tool.supports_parallel_for(&input), "{input:?}");
851 assert!(!tool.is_read_only_for(&input), "{input:?}");
852 assert_eq!(
853 tool.approval_requirement_for(&input),
854 ApprovalRequirement::Required,
855 "{input:?}"
856 );
857 }
858
859 assert!(tool.starts_detached_for(&json!({
860 "command": "cargo check --workspace",
861 "background": true
862 })));
863 assert!(tool.starts_detached_for(&json!({
864 "command": "cargo test -p codewhale-tui --bins",
865 "tty": true
866 })));
867 assert!(!tool.starts_detached_for(&json!({
868 "command": "cargo check --workspace"
869 })));
870 assert!(!tool.starts_detached_for(&json!({
871 "command": "cargo check --workspace",
872 "background": true,
873 "interactive": true
874 })));
875 }
876
877 #[tokio::test]
878 async fn readonly_shell_refuses_raw_string_external_backend() {
879 struct Backend(std::sync::atomic::AtomicBool);
880 #[async_trait::async_trait]
881 impl crate::sandbox::backend::SandboxBackend for Backend {
882 fn kind(&self) -> crate::sandbox::backend::SandboxKind {
883 crate::sandbox::backend::SandboxKind::OpenSandbox
884 }
885 async fn exec(
886 &self,
887 _cmd: &str,
888 _env: &std::collections::HashMap<String, String>,
889 ) -> anyhow::Result<crate::sandbox::backend::SandboxOutput> {
890 self.0.store(true, std::sync::atomic::Ordering::SeqCst);
891 Ok(crate::sandbox::backend::SandboxOutput {
892 stdout: String::new(),
893 stderr: String::new(),
894 exit_code: 0,
895 })
896 }
897 }
898
899 let tmp = tempdir().expect("tempdir");
900 let backend = std::sync::Arc::new(Backend(std::sync::atomic::AtomicBool::new(false)));
901 let mut context = ToolContext::new(tmp.path().to_path_buf())
902 .with_shell_policy(crate::worker_profile::ShellPolicy::ReadOnly);
903 context.sandbox_backend = Some(backend.clone());
904 let error = BashTool::read_only("Bash")
905 .execute(json!({"action": "run", "command": "pwd"}), &context)
906 .await
907 .expect_err("raw-string backend must not receive a classifier-approved argv")
908 .to_string();
909 assert!(error.contains("raw command string"), "{error}");
910 assert!(!backend.0.load(std::sync::atomic::Ordering::SeqCst));
911 }
912
913 #[tokio::test]
914 async fn lowercase_bash_refuses_non_streaming_external_backend() {
915 struct Backend(std::sync::atomic::AtomicBool);
916 #[async_trait::async_trait]
917 impl crate::sandbox::backend::SandboxBackend for Backend {
918 fn kind(&self) -> crate::sandbox::backend::SandboxKind {
919 crate::sandbox::backend::SandboxKind::OpenSandbox
920 }
921 async fn exec(
922 &self,
923 _cmd: &str,
924 _env: &std::collections::HashMap<String, String>,
925 ) -> anyhow::Result<crate::sandbox::backend::SandboxOutput> {
926 self.0.store(true, std::sync::atomic::Ordering::SeqCst);
927 unreachable!("lowercase bash must fail before external dispatch")
928 }
929 }
930
931 let workspace = tempdir().expect("workspace");
932 let backend = std::sync::Arc::new(Backend(std::sync::atomic::AtomicBool::new(false)));
933 let mut context = ToolContext::new(workspace.path());
934 context.sandbox_backend = Some(backend.clone());
935 let error = LowercaseBashTool
936 .execute(json!({"command": "pwd", "timeout": 1}), &context)
937 .await
938 .expect_err("non-streaming backend must be rejected");
939 assert!(error.to_string().contains("combined streaming output"));
940 assert!(!backend.0.load(std::sync::atomic::Ordering::SeqCst));
941 }
942
943 #[test]
944 fn readonly_argv_is_shell_free_and_disables_git_helpers() {
945 let (program, args) = hardened_readonly_argv("git show HEAD").expect("argv");
946 assert_eq!(program, "git");
947 assert_eq!(
948 &args[..4],
949 [
950 "show",
951 "--no-ext-diff",
952 "--no-textconv",
953 "--no-show-signature"
954 ]
955 );
956 assert_eq!(args.last().map(String::as_str), Some("HEAD"));
957
958 let (program, args) = hardened_readonly_argv("rg $PATTERN .").expect("literal argv");
959 assert_eq!(program, "rg");
960 assert_eq!(args, ["$PATTERN", "."]);
961 }
962
963 #[cfg(any(unix, windows))]
964 #[test]
965 fn readonly_program_resolution_ignores_workspace_shadow_executables() {
966 let workspace = tempdir().expect("workspace");
967 let trusted = tempdir().expect("trusted bin");
968 let path = std::env::join_paths([workspace.path(), trusted.path()]).expect("test PATH");
969
970 for program in ["git", "gh", "rg"] {
971 let file = if cfg!(windows) {
972 format!("{program}.exe")
973 } else {
974 program.to_string()
975 };
976 for directory in [workspace.path(), trusted.path()] {
977 let executable = directory.join(&file);
978 std::fs::write(&executable, b"fixture").expect("fixture executable");
979 #[cfg(unix)]
980 {
981 use std::os::unix::fs::PermissionsExt as _;
982 let mut permissions = executable.metadata().unwrap().permissions();
983 permissions.set_mode(0o755);
984 std::fs::set_permissions(&executable, permissions).unwrap();
985 }
986 }
987 let resolved =
988 resolve_readonly_program_from_path(program, workspace.path(), &path).expect("resolved");
989 assert_eq!(resolved, trusted.path().join(file).canonicalize().unwrap());
990 assert!(resolved.is_absolute() && !resolved.starts_with(workspace.path()));
991 }
992 }
993
994 #[test]
995 fn readonly_child_env_removes_git_and_github_redirects() {
996 let mut command = std::process::Command::new("unused");
997 let redirects = [
998 "GIT_DIR",
999 "GIT_COMMON_DIR",
1000 "GIT_EXEC_PATH",
1001 "GIT_OBJECT_DIRECTORY",
1002 "GIT_SSH_COMMAND",
1003 "GH_CONFIG_DIR",
1004 "GH_OTHER_PATH",
1005 ];
1006 for key in redirects {
1007 command.env(key, "outside");
1008 }
1009 command.env(READONLY_ENV_MARKER, "1");
1010 let env = HashMap::from([(READONLY_ENV_MARKER.to_string(), "1".to_string())]);
1011 remove_readonly_redirect_env(&mut command, &env);
1012 for key in redirects {
1013 assert!(
1014 command
1015 .get_envs()
1016 .any(|(name, value)| name == std::ffi::OsStr::new(key) && value.is_none()),
1017 "{key} must be removed from the child environment"
1018 );
1019 }
1020 assert!(
1021 command
1022 .get_envs()
1023 .any(|(name, value)| name == READONLY_ENV_MARKER && value.is_none())
1024 );
1025 }
1026
1027 #[test]
1028 fn readonly_operands_are_workspace_bounded_and_symlink_aware() {
1029 let workspace = tempdir().expect("workspace");
1030 let outside = tempdir().expect("outside");
1031 std::fs::write(workspace.path().join("inside.txt"), "inside").expect("inside file");
1032 std::fs::write(outside.path().join("secret.txt"), "secret").expect("outside file");
1033
1034 enforce_readonly_workspace_operands("cat inside.txt", workspace.path(), workspace.path())
1035 .expect("in-workspace operand");
1036 let inside_absolute = workspace
1037 .path()
1038 .join("inside.txt")
1039 .canonicalize()
1040 .expect("canonical inside file");
1041 enforce_readonly_workspace_operands(
1042 &format!("cat {}", inside_absolute.display()),
1043 workspace.path(),
1044 workspace.path(),
1045 )
1046 .expect("absolute in-workspace operand");
1047
1048 let outside_absolute = outside
1049 .path()
1050 .join("secret.txt")
1051 .canonicalize()
1052 .expect("canonical outside file");
1053 let error = enforce_readonly_workspace_operands(
1054 &format!("cat {}", outside_absolute.display()),
1055 workspace.path(),
1056 workspace.path(),
1057 )
1058 .expect_err("absolute outside operand must fail")
1059 .to_string();
1060 assert!(error.contains("operand.outside_workspace"), "{error}");
1061
1062 for command in [
1063 "cat ../secret.txt",
1064 "cat ~/.ssh/id_rsa",
1065 "cat /rooted-current-drive.txt",
1066 "cat C:secret",
1067 r"cat C:\secret",
1068 r"cat \\server\share\secret",
1069 ] {
1070 let error =
1071 enforce_readonly_workspace_operands(command, workspace.path(), workspace.path())
1072 .expect_err("out-of-workspace operand must fail")
1073 .to_string();
1074 assert!(error.contains("inside the workspace"), "{command}: {error}");
1075 }
1076
1077 #[cfg(unix)]
1078 {
1079 std::os::unix::fs::symlink(
1080 outside.path().join("secret.txt"),
1081 workspace.path().join("secret-link"),
1082 )
1083 .expect("outside symlink");
1084 let error = enforce_readonly_workspace_operands(
1085 "cat secret-link",
1086 workspace.path(),
1087 workspace.path(),
1088 )
1089 .expect_err("symlink escape must fail")
1090 .to_string();
1091 assert!(error.contains("resolves outside"), "{error}");
1092
1093 let subdir = workspace.path().join("subdir");
1094 std::fs::create_dir(&subdir).expect("subdir");
1095 std::os::unix::fs::symlink(
1096 outside.path().join("secret.txt"),
1097 subdir.join("secret-link"),
1098 )
1099 .expect("cwd-relative outside symlink");
1100 enforce_readonly_workspace_operands("cat secret-link", workspace.path(), &subdir)
1101 .expect_err("operands must resolve relative to the effective cwd");
1102 }
1103 }
1104
1105 #[test]
1106 fn windows_verbatim_and_drive_operands_survive_posix_split() {
1107 // `shell_words` splits with POSIX backslash-escaping, which silently eats
1108 // the separators of Windows absolute paths (`C:\Users\...` becomes
1109 // `C:Users...`) and mangles the `\\?\` verbatim prefix before operand
1110 // classification can see it. The protection doubles those backslashes so
1111 // the splitter round-trips the real path (the `\\?\` cases that the
1112 // verbatim strip alone could never reach).
1113 for (raw, expected) in [
1114 (r"\\?\C:\Users\foo\inside.txt", r"C:\Users\foo\inside.txt"),
1115 (r"C:\Users\foo\inside.txt", r"C:\Users\foo\inside.txt"),
1116 (r"\\server\share\secret", r"\\server\share\secret"),
1117 (r"\\.\device\path", r"\\.\device\path"),
1118 ] {
1119 let protected = normalize_windows_command_paths(&format!("cat {raw}"));
1120 let argv = shell_words::split(&protected).expect("split must succeed");
1121 assert_eq!(
1122 argv,
1123 vec!["cat".to_string(), expected.to_string()],
1124 "{raw} must survive the POSIX split"
1125 );
1126 }
1127 }
1128
1129 #[test]
1130 fn windows_path_protection_leaves_other_words_untouched() {
1131 // POSIX escapes, drive-relative spellings, and plain relative operands
1132 // are not Windows absolute paths and must round-trip unchanged.
1133 assert_eq!(
1134 normalize_windows_command_paths("echo a\\ b && cat inside.txt"),
1135 "echo a\\ b && cat inside.txt"
1136 );
1137 assert_eq!(
1138 normalize_windows_command_paths("cat C:secret"),
1139 "cat C:secret"
1140 );
1141 assert_eq!(
1142 normalize_windows_command_paths("cat inside.txt"),
1143 "cat inside.txt"
1144 );
1145 }
1146
1147 #[test]
1148 fn readonly_github_shell_calls_obey_the_host_network_policy_before_spawn() {
1149 let tmp = tempdir().expect("tempdir");
1150 let context = |default| {
1151 ToolContext::new(tmp.path()).with_network_policy(
1152 crate::network_policy::NetworkPolicyDecider::new(
1153 crate::network_policy::NetworkPolicy {
1154 default,
1155 ..crate::network_policy::NetworkPolicy::default()
1156 },
1157 None,
1158 ),
1159 )
1160 };
1161
1162 let allow = context(crate::network_policy::DecisionToml::Allow);
1163 enforce_readonly_github_network_policy("gh issue view 5287", &allow)
1164 .expect("allowed github.com policy");
1165
1166 let deny = context(crate::network_policy::DecisionToml::Deny);
1167 let denied = enforce_readonly_github_network_policy("gh issue list", &deny)
1168 .expect_err("deny must stop before spawning gh")
1169 .to_string();
1170 assert!(denied.contains("blocked by the active network policy"));
1171 enforce_readonly_github_network_policy("git status", &deny)
1172 .expect("local reads do not consult the network policy");
1173
1174 let prompt = context(crate::network_policy::DecisionToml::Prompt);
1175 let prompted = enforce_readonly_github_network_policy("gh issue view 5287", &prompt)
1176 .expect_err("headless Scout cannot prompt interactively")
1177 .to_string();
1178 assert!(prompted.contains("requires network approval"));
1179 }
1180
1181 #[test]
1182 fn exec_shell_interact_requires_approval() {
1183 let tool = BashTool::alias("exec_shell_interact", "interact");
1184 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Required);
1185 assert!(
1186 tool.capabilities()
1187 .contains(&ToolCapability::RequiresApproval)
1188 );
1189 }
1190
1191 #[tokio::test]
1192 async fn read_only_shell_policy_blocks_non_readonly_commands() {
1193 let tmp = tempdir().expect("tempdir");
1194 let ctx = ToolContext::new(tmp.path())
1195 .with_shell_policy(crate::worker_profile::ShellPolicy::ReadOnly);
1196 let tool = BashTool::new("Bash");
1197
1198 let result = tool
1199 .execute(json!({"command": "cargo build"}), &ctx)
1200 .await
1201 .expect("execute");
1202 assert!(!result.success);
1203 assert!(result.content.contains("read-only shell policy"));
1204
1205 let result = tool
1206 .execute(
1207 json!({"command": "git status -s", "background": true}),
1208 &ctx,
1209 )
1210 .await
1211 .expect("execute");
1212 assert!(!result.success);
1213 assert!(result.content.contains("read-only shell policy"));
1214
1215 for command in [
1216 "git --config-env=core.fsmonitor=SHELL status",
1217 "git -cdiff.foo.textconv=./repo-script diff HEAD",
1218 "rg -f/etc/passwd needle .",
1219 ] {
1220 let result = tool
1221 .execute(json!({"command": command}), &ctx)
1222 .await
1223 .expect("classifier refusal");
1224 assert!(!result.success, "{command}: {}", result.content);
1225 assert!(
1226 result.content.contains("read-only shell policy"),
1227 "{command}"
1228 );
1229 }
1230 }
1231
1232 #[tokio::test]
1233 async fn read_only_refusal_names_child_alternatives_instead_of_mode_switch() {
1234 // #6298: a child has no `/mode` to switch to — a refusal that tells it to
1235 // switch modes is a dead end beside an available absurd path. The child
1236 // branch must name the child's own alternatives and the escalation path.
1237 let tmp = tempdir().expect("tempdir");
1238 let child_ctx = ToolContext::new(tmp.path())
1239 .with_shell_policy(crate::worker_profile::ShellPolicy::ReadOnly)
1240 .with_owner_agent("agent_child", "child");
1241 let tool = BashTool::new("Bash");
1242 let result = tool
1243 .execute(json!({"command": "cargo build"}), &child_ctx)
1244 .await
1245 .expect("execute");
1246 assert!(!result.success);
1247 assert!(result.content.contains("read-only shell policy"));
1248 assert!(result.content.contains("read_file"));
1249 assert!(
1250 result
1251 .content
1252 .contains("report the blocked probe to the parent")
1253 );
1254 assert!(
1255 !result.content.contains("/mode work"),
1256 "child must never be told to switch modes: {}",
1257 result.content
1258 );
1259
1260 let parent_ctx = ToolContext::new(tmp.path())
1261 .with_shell_policy(crate::worker_profile::ShellPolicy::ReadOnly);
1262 let result = tool
1263 .execute(json!({"command": "cargo build"}), &parent_ctx)
1264 .await
1265 .expect("execute");
1266 assert!(!result.success);
1267 assert!(result.content.contains("/mode work"));
1268 }
1269
1270 #[cfg(unix)]
1271 #[tokio::test]
1272 async fn read_only_shell_resolves_operands_from_the_effective_cwd() {
1273 let workspace = tempdir().expect("workspace");
1274 let outside = tempdir().expect("outside");
1275 let subdir = workspace.path().join("subdir");
1276 std::fs::create_dir(&subdir).expect("subdir");
1277 std::fs::write(outside.path().join("secret"), "secret").expect("outside secret");
1278 std::os::unix::fs::symlink(outside.path().join("secret"), subdir.join("secret-link"))
1279 .expect("symlink");
1280 let ctx = ToolContext::new(workspace.path())
1281 .with_shell_policy(crate::worker_profile::ShellPolicy::ReadOnly);
1282 let error = BashTool::new("Bash")
1283 .execute(
1284 json!({"action": "run", "command": "cat secret-link", "cwd": "subdir"}),
1285 &ctx,
1286 )
1287 .await
1288 .expect_err("cwd-relative symlink escape must fail before spawn")
1289 .to_string();
1290 assert!(error.contains("resolves outside"), "{error}");
1291 }
1292
1293 #[cfg(unix)]
1294 #[tokio::test]
1295 async fn read_only_shell_skips_shell_env_hooks() {
1296 let tmp = tempdir().expect("tempdir");
1297 let marker = tmp.path().join("hook-ran");
1298 let hook = crate::hooks::Hook::new(
1299 crate::hooks::HookEvent::ShellEnv,
1300 &format!("printf hit > '{}'", marker.display()),
1301 );
1302 let executor = crate::hooks::HookExecutor::new(
1303 crate::hooks::HooksConfig {
1304 enabled: true,
1305 hooks: vec![hook],
1306 ..crate::hooks::HooksConfig::default()
1307 },
1308 tmp.path().to_path_buf(),
1309 );
1310 let mut context = ToolContext::new(tmp.path())
1311 .with_shell_policy(crate::worker_profile::ShellPolicy::ReadOnly);
1312 context.runtime.hook_executor = Some(std::sync::Arc::new(executor));
1313
1314 let result = BashTool::read_only("Bash")
1315 .execute(json!({"command": "pwd"}), &context)
1316 .await
1317 .expect("read-only inspection");
1318 assert!(result.success, "{}", result.content);
1319 assert!(!marker.exists(), "shell_env hook must not run for ReadOnly");
1320 }
1321
1322 #[tokio::test]
1323 async fn read_only_shell_policy_allows_readonly_inspection() {
1324 let tmp = tempdir().expect("tempdir");
1325 let ctx = ToolContext::new(tmp.path())
1326 .with_shell_policy(crate::worker_profile::ShellPolicy::ReadOnly);
1327
1328 let result = BashTool::new("Bash")
1329 .execute(json!({"command": "pwd"}), &ctx)
1330 .await
1331 .expect("execute");
1332
1333 assert!(
1334 result.success,
1335 "unexpected shell failure: {}",
1336 result.content
1337 );
1338 assert_eq!(
1339 result
1340 .metadata
1341 .as_ref()
1342 .and_then(|metadata| metadata.get("status"))
1343 .and_then(Value::as_str),
1344 Some("Completed")
1345 );
1346 }
1347
1348 #[tokio::test]
1349 async fn exec_shell_multiline_block_explains_allow_shell_boundary() {
1350 let tmp = tempdir().expect("tempdir");
1351 let ctx = ToolContext::new(tmp.path());
1352
1353 let result = BashTool::new("Bash")
1354 .execute(
1355 json!({"command": "python3 -c \"print(1)\nprint(2)\""}),
1356 &ctx,
1357 )
1358 .await
1359 .expect("execute");
1360
1361 assert!(!result.success);
1362 assert!(result.content.contains("Command contains multiple lines"));
1363 assert!(
1364 result
1365 .content
1366 .contains("allow_shell=true exposes shell tools"),
1367 "{}",
1368 result.content
1369 );
1370 assert!(
1371 result
1372 .content
1373 .contains("Write multiline scripts to a file first"),
1374 "{}",
1375 result.content
1376 );
1377 assert!(
1378 result.content.contains("task_shell_start"),
1379 "{}",
1380 result.content
1381 );
1382 }
1383
1384 #[test]
1385 fn exec_shell_wait_schema_defaults_to_blocking() {
1386 let schema = BashTool::alias("exec_shell_wait", "wait").input_schema();
1387 assert!(
1388 schema["properties"]["wait"]["description"]
1389 .as_str()
1390 .is_some_and(|description| description.contains("default: true"))
1391 );
1392 assert!(
1393 BashTool::alias("exec_shell_wait", "wait")
1394 .description()
1395 .contains("wait")
1396 );
1397 }
1398
1399 #[tokio::test]
1400 async fn exec_shell_wait_without_wait_arg_blocks_until_completion() {
1401 let tmp = tempdir().expect("tempdir");
1402 let ctx = ToolContext::new(tmp.path());
1403 let start_result = BashTool::new("Bash")
1404 .execute(
1405 json!({"command": sleep_command(1), "background": true}),
1406 &ctx,
1407 )
1408 .await
1409 .expect("start background");
1410 let task_id = start_result
1411 .metadata
1412 .as_ref()
1413 .and_then(|metadata| metadata.get("task_id"))
1414 .and_then(Value::as_str)
1415 .expect("task id")
1416 .to_string();
1417
1418 let wait_result = BashTool::new("Bash")
1419 .execute(
1420 json!({"action": "wait", "task_id": task_id, "timeout_ms": 5_000}),
1421 &ctx,
1422 )
1423 .await
1424 .expect("wait for completion");
1425
1426 assert_eq!(
1427 wait_result
1428 .metadata
1429 .as_ref()
1430 .and_then(|metadata| metadata.get("status"))
1431 .and_then(Value::as_str),
1432 Some("Completed")
1433 );
1434 }
1435
1436 #[tokio::test]
1437 async fn exec_shell_wait_false_returns_nonblocking_snapshot() {
1438 let tmp = tempdir().expect("tempdir");
1439 let ctx = ToolContext::new(tmp.path());
1440 let start_result = BashTool::new("Bash")
1441 .execute(
1442 json!({"command": sleep_command(2), "background": true}),
1443 &ctx,
1444 )
1445 .await
1446 .expect("start background");
1447 let task_id = start_result
1448 .metadata
1449 .as_ref()
1450 .and_then(|metadata| metadata.get("task_id"))
1451 .and_then(Value::as_str)
1452 .expect("task id")
1453 .to_string();
1454
1455 let started = Instant::now();
1456 let wait_result = BashTool::new("Bash")
1457 .execute(
1458 json!({"action": "wait", "task_id": task_id, "timeout_ms": 5_000, "wait": false}),
1459 &ctx,
1460 )
1461 .await
1462 .expect("poll snapshot");
1463
1464 assert!(
1465 started.elapsed() < Duration::from_millis(1_000),
1466 "wait=false should return a snapshot without blocking"
1467 );
1468 assert_eq!(
1469 wait_result
1470 .metadata
1471 .as_ref()
1472 .and_then(|metadata| metadata.get("status"))
1473 .and_then(Value::as_str),
1474 Some("Running")
1475 );
1476 }
1477
1478 #[tokio::test]
1479 async fn exec_shell_wait_without_wait_arg_returns_running_at_timeout() {
1480 let tmp = tempdir().expect("tempdir");
1481 let ctx = ToolContext::new(tmp.path());
1482 let start_result = BashTool::new("Bash")
1483 .execute(
1484 json!({"command": sleep_command(5), "background": true}),
1485 &ctx,
1486 )
1487 .await
1488 .expect("start background");
1489 let task_id = start_result
1490 .metadata
1491 .as_ref()
1492 .and_then(|metadata| metadata.get("task_id"))
1493 .and_then(Value::as_str)
1494 .expect("task id")
1495 .to_string();
1496
1497 let started = Instant::now();
1498 let result = BashTool::new("Bash")
1499 .execute(
1500 json!({"action": "wait", "task_id": task_id, "timeout_ms": 1_000}),
1501 &ctx,
1502 )
1503 .await
1504 .expect("bounded wait");
1505 assert!(started.elapsed() >= Duration::from_millis(900));
1506 assert!(started.elapsed() < Duration::from_secs(3));
1507 assert_eq!(
1508 result
1509 .metadata
1510 .as_ref()
1511 .and_then(|metadata| metadata.get("status"))
1512 .and_then(Value::as_str),
1513 Some("Running")
1514 );
1515
1516 BashTool::new("Bash")
1517 .execute(json!({"action": "cancel", "task_id": task_id}), &ctx)
1518 .await
1519 .expect("cancel background");
1520 }
1521
1522 #[tokio::test]
1523 async fn exec_shell_wait_many_until_any_returns_when_the_first_task_settles() {
1524 let tmp = tempdir().expect("tempdir");
1525 let ctx = ToolContext::new(tmp.path());
1526 let short = BashTool::new("Bash")
1527 .execute(
1528 json!({"command": sleep_command(1), "background": true}),
1529 &ctx,
1530 )
1531 .await
1532 .expect("start short");
1533 let long = BashTool::new("Bash")
1534 .execute(
1535 json!({"command": sleep_command(30), "background": true}),
1536 &ctx,
1537 )
1538 .await
1539 .expect("start long");
1540 let short_id = short
1541 .metadata
1542 .as_ref()
1543 .and_then(|m| m.get("task_id"))
1544 .and_then(Value::as_str)
1545 .expect("short id")
1546 .to_string();
1547 let long_id = long
1548 .metadata
1549 .as_ref()
1550 .and_then(|m| m.get("task_id"))
1551 .and_then(Value::as_str)
1552 .expect("long id")
1553 .to_string();
1554
1555 let started = Instant::now();
1556 let result = BashTool::new("Bash")
1557 .execute(
1558 json!({
1559 "action": "wait",
1560 "task_ids": [short_id, long_id],
1561 "until": "any",
1562 "timeout_ms": 8_000
1563 }),
1564 &ctx,
1565 )
1566 .await
1567 .expect("multi wait");
1568
1569 assert!(
1570 started.elapsed() < Duration::from_secs(5),
1571 "until=any must not wait for the long task"
1572 );
1573 let statuses = result
1574 .metadata
1575 .as_ref()
1576 .expect("metadata")
1577 .get("statuses")
1578 .expect("statuses");
1579 assert_eq!(statuses[short_id.as_str()], "Completed");
1580 assert_eq!(statuses[long_id.as_str()], "Running");
1581 assert_eq!(
1582 result.metadata.as_ref().and_then(|m| m.get("until")),
1583 Some(&json!("any"))
1584 );
1585 assert_eq!(
1586 result.metadata.as_ref().and_then(|m| m.get("timed_out")),
1587 Some(&json!(false))
1588 );
1589 }
1590
1591 #[tokio::test]
1592 async fn exec_shell_wait_many_all_reports_timeout_and_still_running() {
1593 let tmp = tempdir().expect("tempdir");
1594 let ctx = ToolContext::new(tmp.path());
1595 let first = BashTool::new("Bash")
1596 .execute(
1597 json!({"command": sleep_command(30), "background": true}),
1598 &ctx,
1599 )
1600 .await
1601 .expect("start first");
1602 let second = BashTool::new("Bash")
1603 .execute(
1604 json!({"command": sleep_command(30), "background": true}),
1605 &ctx,
1606 )
1607 .await
1608 .expect("start second");
1609 let first_id = first
1610 .metadata
1611 .as_ref()
1612 .and_then(|m| m.get("task_id"))
1613 .and_then(Value::as_str)
1614 .expect("first id")
1615 .to_string();
1616 let second_id = second
1617 .metadata
1618 .as_ref()
1619 .and_then(|m| m.get("task_id"))
1620 .and_then(Value::as_str)
1621 .expect("second id")
1622 .to_string();
1623
1624 let result = BashTool::new("Bash")
1625 .execute(
1626 json!({ "action": "wait", "task_ids": [first_id, second_id], "timeout_ms": 1_500 }),
1627 &ctx,
1628 )
1629 .await
1630 .expect("multi wait times out");
1631 assert_eq!(
1632 result.metadata.as_ref().and_then(|m| m.get("timed_out")),
1633 Some(&json!(true))
1634 );
1635 assert!(result.content.contains("still running"));
1636 let statuses = result
1637 .metadata
1638 .as_ref()
1639 .expect("metadata")
1640 .get("statuses")
1641 .expect("statuses");
1642 assert_eq!(statuses[first_id.as_str()], "Running");
1643 assert_eq!(statuses[second_id.as_str()], "Running");
1644 }
1645
1646 #[tokio::test]
1647 async fn exec_shell_wait_many_rejects_an_unknown_task_id() {
1648 let tmp = tempdir().expect("tempdir");
1649 let ctx = ToolContext::new(tmp.path());
1650 let err = BashTool::new("Bash")
1651 .execute(
1652 json!({ "action": "wait", "task_ids": ["nope-1", "nope-2"], "wait": false }),
1653 &ctx,
1654 )
1655 .await
1656 .expect_err("unknown ids must fail the same way as the single-task path");
1657 assert!(err.to_string().contains("nope-1"), "{err}");
1658 }
1659
1660 #[tokio::test]
1661 async fn background_start_advertises_task_status_completion() {
1662 let tmp = tempdir().expect("tempdir");
1663 let ctx = ToolContext::new(tmp.path());
1664 let result = BashTool::new("Bash")
1665 .execute(
1666 json!({"command": sleep_command(1), "background": true}),
1667 &ctx,
1668 )
1669 .await
1670 .expect("start background");
1671 assert!(result.content.contains("completion is delivered"));
1672 assert!(result.content.contains("session exits") && result.content.contains("persist=true"));
1673 let metadata = result.metadata.as_ref().expect("metadata");
1674 assert_eq!(
1675 metadata
1676 .get("auto_resume_on_completion")
1677 .and_then(Value::as_bool),
1678 Some(true)
1679 );
1680 assert_eq!(
1681 metadata.get("completion_surface").and_then(Value::as_str),
1682 Some("runtime_event_and_task_status")
1683 );
1684 assert_eq!(
1685 metadata.get("background_policy").and_then(Value::as_str),
1686 Some("nonblocking")
1687 );
1688 }
1689
1690 #[tokio::test]
1691 async fn background_shell_job_preserves_origin_identity() {
1692 let tmp = tempdir().expect("tempdir");
1693 let ctx = ToolContext::new(tmp.path())
1694 .with_origin_turn_id("turn-origin")
1695 .with_origin_tool_call_id("tool-origin")
1696 .with_owner_agent("agent_owner", "verifier");
1697 let result = BashTool::new("Bash")
1698 .execute(
1699 json!({"command": sleep_command(2), "background": true}),
1700 &ctx,
1701 )
1702 .await
1703 .expect("start owned background shell");
1704
1705 let metadata = result.metadata.as_ref().expect("metadata");
1706 assert_eq!(
1707 metadata.get("owner_agent_id").and_then(Value::as_str),
1708 Some("agent_owner")
1709 );
1710 assert_eq!(
1711 metadata.get("owner_agent_name").and_then(Value::as_str),
1712 Some("verifier")
1713 );
1714 assert!(
1715 result
1716 .content
1717 .contains("not injected into the parent model"),
1718 "owned background work must describe its real completion route: {}",
1719 result.content
1720 );
1721 assert!(result.content.contains("Bash action=\"wait\""));
1722 assert_eq!(
1723 metadata
1724 .get("auto_resume_on_completion")
1725 .and_then(Value::as_bool),
1726 Some(false)
1727 );
1728 assert_eq!(
1729 metadata.get("completion_surface").and_then(Value::as_str),
1730 Some("task_status_and_explicit_wait")
1731 );
1732 let task_id = metadata
1733 .get("task_id")
1734 .and_then(Value::as_str)
1735 .expect("task id")
1736 .to_string();
1737
1738 {
1739 let mut manager = ctx.shell_manager.lock().expect("shell manager");
1740 let snapshot = manager
1741 .list_jobs()
1742 .into_iter()
1743 .find(|job| job.id == task_id)
1744 .expect("owned shell job snapshot");
1745 assert_eq!(snapshot.owner_agent_id.as_deref(), Some("agent_owner"));
1746 assert_eq!(snapshot.owner_agent_name.as_deref(), Some("verifier"));
1747 assert_eq!(snapshot.origin_tool_call_id.as_deref(), Some("tool-origin"));
1748 assert_eq!(snapshot.origin_turn_id.as_deref(), Some("turn-origin"));
1749 let mut legacy_json = serde_json::to_value(&snapshot).expect("serialize snapshot");
1750 let legacy_object = legacy_json.as_object_mut().expect("snapshot object");
1751 legacy_object.remove("origin_tool_call_id");
1752 legacy_object.remove("origin_turn_id");
1753 let legacy_snapshot: ShellJobSnapshot =
1754 serde_json::from_value(legacy_json).expect("deserialize legacy snapshot");
1755 assert_eq!(legacy_snapshot.origin_tool_call_id, None);
1756 assert_eq!(legacy_snapshot.origin_turn_id, None);
1757 let owners = manager.running_owner_agent_ids();
1758 assert_eq!(owners, vec!["agent_owner".to_string()]);
1759 }
1760
1761 BashTool::alias("exec_shell_cancel", "cancel")
1762 .execute(json!({"task_id": task_id}), &ctx)
1763 .await
1764 .expect("cancel owned background shell");
1765 }
1766
1767 #[tokio::test]
1768 async fn drain_finished_jobs_reports_once() {
1769 let tmp = tempdir().expect("tempdir");
1770 let ctx = ToolContext::new(tmp.path())
1771 .with_origin_turn_id("turn-origin")
1772 .with_origin_tool_call_id("tool-origin");
1773 let result = BashTool::new("Bash")
1774 .execute(
1775 json!({"command": echo_command("drain-finished-once"), "background": true}),
1776 &ctx,
1777 )
1778 .await
1779 .expect("start background");
1780 let task_id = result
1781 .metadata
1782 .as_ref()
1783 .and_then(|metadata| metadata.get("task_id"))
1784 .and_then(Value::as_str)
1785 .expect("task id")
1786 .to_string();
1787
1788 let mut manager = ctx.shell_manager.lock().expect("shell manager");
1789 assert!(manager.may_have_undelivered_completion());
1790 assert!(
1791 manager.may_have_undelivered_completion(),
1792 "read-only detection must not consume the pending completion"
1793 );
1794 let completed = wait_for_completed_shell(&mut manager, &task_id);
1795 assert_ne!(completed.status, ShellStatus::Running);
1796 assert!(manager.may_have_undelivered_completion());
1797
1798 let first = manager
1799 .drain_finished_jobs_with_evidence()
1800 .into_iter()
1801 .map(|completion| completion.event)
1802 .collect::<Vec<_>>();
1803 assert_eq!(first.len(), 1);
1804 assert_eq!(first[0].task_id, task_id);
1805 assert_eq!(first[0].status, ShellStatus::Completed);
1806 assert!(first[0].stdout_tail.contains("drain-finished-once"));
1807 assert_eq!(first[0].origin_tool_call_id.as_deref(), Some("tool-origin"));
1808 assert_eq!(first[0].origin_turn_id.as_deref(), Some("turn-origin"));
1809 let mut legacy_json = serde_json::to_value(&first[0]).expect("serialize completion");
1810 let legacy_object = legacy_json.as_object_mut().expect("completion object");
1811 legacy_object.remove("origin_tool_call_id");
1812 legacy_object.remove("origin_turn_id");
1813 let legacy_completion: ShellCompletionEvent =
1814 serde_json::from_value(legacy_json).expect("deserialize legacy completion");
1815 assert_eq!(legacy_completion.origin_tool_call_id, None);
1816 assert_eq!(legacy_completion.origin_turn_id, None);
1817
1818 let second = manager.drain_finished_jobs_with_evidence();
1819 assert!(second.is_empty(), "completion should be reported only once");
1820 assert!(!manager.may_have_undelivered_completion());
1821 }
1822
1823 #[tokio::test]
1824 async fn background_job_is_hidden_from_replacement_session_and_resumes_once_for_owner() {
1825 let tmp = tempdir().expect("tempdir");
1826 let ctx_a = ToolContext::new(tmp.path()).with_state_namespace("session-a");
1827 let ctx_b = ctx_a.clone().with_state_namespace("session-b");
1828 let result = BashTool::new("Bash")
1829 .execute(
1830 json!({"command": echo_command("owned-by-a"), "background": true}),
1831 &ctx_a,
1832 )
1833 .await
1834 .expect("start A background job");
1835 let task_id = result
1836 .metadata
1837 .as_ref()
1838 .and_then(|metadata| metadata.get("task_id"))
1839 .and_then(Value::as_str)
1840 .expect("task id")
1841 .to_string();
1842
1843 let mut manager = ctx_b.shell_manager.lock().expect("shell manager");
1844 assert!(manager.list_jobs_for_session("session-b").is_empty());
1845 assert!(
1846 manager
1847 .inspect_job_for_session("session-b", &task_id)
1848 .is_err()
1849 );
1850 assert!(
1851 manager
1852 .write_stdin_for_session("session-b", &task_id, "foreign", false)
1853 .is_err()
1854 );
1855 assert!(manager.kill_for_session("session-b", &task_id).is_err());
1856
1857 let completed = wait_for_completed_shell(&mut manager, &task_id);
1858 assert_ne!(completed.status, ShellStatus::Running);
1859 let owned = manager
1860 .list_jobs_for_session("session-a")
1861 .into_iter()
1862 .find(|job| job.id == task_id)
1863 .expect("A job remains visible to A");
1864 assert_eq!(owned.owner_session_id, "session-a");
1865 assert!(
1866 manager
1867 .drain_finished_jobs_with_evidence_for_session("session-b")
1868 .is_empty(),
1869 "B must not claim A's completion"
1870 );
1871 assert!(manager.has_finished_unreported_jobs_for_session("session-a"));
1872 let first = manager.drain_finished_jobs_with_evidence_for_session("session-a");
1873 assert_eq!(first.len(), 1);
1874 assert_eq!(first[0].event.owner_session_id, "session-a");
1875 assert!(first[0].event.stdout_tail.contains("owned-by-a"));
1876 assert!(
1877 manager
1878 .drain_finished_jobs_with_evidence_for_session("session-a")
1879 .is_empty(),
1880 "A completion is delivered exactly once"
1881 );
1882 }
1883
1884 #[test]
1885 fn completion_evidence_preserves_arbitrary_stream_bytes() {
1886 use base64::Engine as _;
1887
1888 let stdout = vec![b'o', 0, 0xff, b'k'];
1889 let stderr = vec![0xfe, b'e', b'r', b'r'];
1890 let evidence = ShellCompletionEvidence {
1891 event: ShellCompletionEvent {
1892 task_id: "shell_binary".to_string(),
1893 command: "binary-output".to_string(),
1894 status: ShellStatus::Completed,
1895 exit_code: Some(0),
1896 duration_ms: 17,
1897 stdout_tail: String::new(),
1898 stderr_tail: String::new(),
1899 stdout_len: stdout.len(),
1900 stderr_len: stderr.len(),
1901 evidence_ref: None,
1902 linked_task_id: None,
1903 owner_agent_id: None,
1904 owner_agent_name: None,
1905 origin_tool_call_id: Some("tool-origin".to_string()),
1906 origin_turn_id: Some("turn-origin".to_string()),
1907 owner_session_id: "session-test".to_string(),
1908 },
1909 stdout: stdout.clone(),
1910 stderr: stderr.clone(),
1911 stdout_omitted: 0,
1912 stderr_omitted: 0,
1913 };
1914
1915 let payload: serde_json::Value =
1916 serde_json::from_slice(&evidence.artifact_bytes()).expect("evidence JSON");
1917 assert_eq!(payload["stdout"]["encoding"], "base64");
1918 assert_eq!(payload["stderr"]["encoding"], "base64");
1919 assert_eq!(payload["origin_tool_call_id"], "tool-origin");
1920 assert_eq!(payload["origin_turn_id"], "turn-origin");
1921 let decoded_stdout = base64::engine::general_purpose::STANDARD
1922 .decode(payload["stdout"]["content"].as_str().expect("stdout data"))
1923 .expect("decode stdout");
1924 let decoded_stderr = base64::engine::general_purpose::STANDARD
1925 .decode(payload["stderr"]["content"].as_str().expect("stderr data"))
1926 .expect("decode stderr");
1927 assert_eq!(decoded_stdout, stdout);
1928 assert_eq!(decoded_stderr, stderr);
1929 }
1930
1931 #[test]
1932 #[cfg(unix)]
1933 fn shell_execution_scrubs_parent_env_and_keeps_explicit_env() {
1934 let _guard = env_lock().lock().expect("env lock");
1935 let previous = std::env::var_os("DEEPSEEK_CHILD_ENV_SHELL_SECRET");
1936 unsafe {
1937 std::env::set_var("DEEPSEEK_CHILD_ENV_SHELL_SECRET", "parent-secret");
1938 }
1939
1940 let tmp = tempdir().expect("tempdir");
1941 let mut manager = ShellManager::new(tmp.path().to_path_buf());
1942 let mut extra = std::collections::HashMap::new();
1943 extra.insert(
1944 "DEEPSEEK_CHILD_ENV_EXPLICIT".to_string(),
1945 "explicit-value".to_string(),
1946 );
1947
1948 let result = manager
1949 .execute_with_options_env(
1950 "sh -c 'printf \"%s\\n%s\\n\" \"${DEEPSEEK_CHILD_ENV_SHELL_SECRET-unset}\" \"${DEEPSEEK_CHILD_ENV_EXPLICIT-unset}\"'",
1951 None,
1952 5000,
1953 false,
1954 None,
1955 false,
1956 None,
1957 extra,
1958 )
1959 .expect("execute");
1960
1961 match previous {
1962 Some(value) => unsafe {
1963 std::env::set_var("DEEPSEEK_CHILD_ENV_SHELL_SECRET", value);
1964 },
1965 None => unsafe {
1966 std::env::remove_var("DEEPSEEK_CHILD_ENV_SHELL_SECRET");
1967 },
1968 }
1969
1970 assert_eq!(result.status, ShellStatus::Completed);
1971 assert_eq!(result.stdout, "unset\nexplicit-value\n");
1972 }
1973
1974 #[test]
1975 #[cfg(windows)]
1976 fn shell_execution_preserves_custom_windows_sdk_root_env() {
1977 let _guard = env_lock().lock().expect("env lock");
1978 let previous_sdk = std::env::var_os("BIMRV_SDK_ROOT");
1979 let previous_secret = std::env::var_os("MY_SECRET_ROOT");
1980 unsafe {
1981 std::env::set_var("BIMRV_SDK_ROOT", r"F:\Lib\BimRv27.5");
1982 std::env::set_var("MY_SECRET_ROOT", r"F:\Secrets");
1983 }
1984
1985 let tmp = tempdir().expect("tempdir");
1986 let mut manager = ShellManager::new(tmp.path().to_path_buf());
1987 let command = if crate::shell_dispatcher::global_dispatcher()
1988 .kind()
1989 .is_powershell()
1990 {
1991 r#"[Console]::WriteLine($env:BIMRV_SDK_ROOT); if ($null -eq $env:MY_SECRET_ROOT) { [Console]::WriteLine("secret-unset") } else { [Console]::WriteLine("secret-set") }"#
1992 .to_string()
1993 } else {
1994 r#"echo %BIMRV_SDK_ROOT% & if defined MY_SECRET_ROOT (echo secret-set) else (echo secret-unset)"#
1995 .to_string()
1996 };
1997
1998 let result = execute_shell(&mut manager, &command, None, 5000, false).expect("execute");
1999
2000 unsafe {
2001 match previous_sdk {
2002 Some(value) => std::env::set_var("BIMRV_SDK_ROOT", value),
2003 None => std::env::remove_var("BIMRV_SDK_ROOT"),
2004 }
2005 match previous_secret {
2006 Some(value) => std::env::set_var("MY_SECRET_ROOT", value),
2007 None => std::env::remove_var("MY_SECRET_ROOT"),
2008 }
2009 }
2010
2011 assert_eq!(result.status, ShellStatus::Completed);
2012 assert!(
2013 result.stdout.contains(r"F:\Lib\BimRv27.5"),
2014 "custom SDK root should reach exec_shell stdout: {:?}",
2015 result
2016 );
2017 assert!(
2018 result.stdout.contains("secret-unset"),
2019 "secret-like env should stay scrubbed: {:?}",
2020 result
2021 );
2022 }
2023
2024 #[test]
2025 fn test_sync_execution() {
2026 let tmp = tempdir().expect("tempdir");
2027 let mut manager = ShellManager::new(tmp.path().to_path_buf());
2028
2029 let result =
2030 execute_shell(&mut manager, &echo_command("hello"), None, 5000, false).expect("execute");
2031
2032 assert_eq!(result.status, ShellStatus::Completed);
2033 assert!(result.stdout.contains("hello"));
2034 assert!(result.task_id.is_none());
2035 }
2036
2037 #[test]
2038 fn test_background_execution() {
2039 let tmp = tempdir().expect("tempdir");
2040 let mut manager = ShellManager::new(tmp.path().to_path_buf());
2041
2042 let result = execute_shell(
2043 &mut manager,
2044 &sleep_then_echo_command(1, "done"),
2045 None,
2046 5000,
2047 true,
2048 )
2049 .expect("execute");
2050
2051 assert_eq!(result.status, ShellStatus::Running);
2052 assert!(result.task_id.is_some());
2053
2054 let task_id = result
2055 .task_id
2056 .expect("background execution should return task_id");
2057
2058 let final_result = wait_for_completed_shell(&mut manager, &task_id);
2059
2060 assert_eq!(final_result.status, ShellStatus::Completed);
2061 assert!(final_result.stdout.contains("done"));
2062 }
2063
2064 #[test]
2065 fn test_timeout() {
2066 let tmp = tempdir().expect("tempdir");
2067 let mut manager = ShellManager::new(tmp.path().to_path_buf());
2068
2069 let result =
2070 execute_shell(&mut manager, &sleep_command(10), None, 1000, false).expect("execute");
2071
2072 assert_eq!(result.status, ShellStatus::TimedOut);
2073 }
2074
2075 #[test]
2076 fn test_kill() {
2077 let tmp = tempdir().expect("tempdir");
2078 let mut manager = ShellManager::new(tmp.path().to_path_buf());
2079
2080 let result =
2081 execute_shell(&mut manager, &sleep_command(60), None, 5000, true).expect("execute");
2082
2083 let task_id = result
2084 .task_id
2085 .expect("background execution should return task_id");
2086
2087 // Kill it
2088 let killed = manager.kill(&task_id).expect("kill");
2089 assert_eq!(killed.status, ShellStatus::Killed);
2090 }
2091
2092 #[test]
2093 fn test_write_stdin_streams_output() {
2094 let tmp = tempdir().expect("tempdir");
2095 let mut manager = ShellManager::new(tmp.path().to_path_buf());
2096
2097 let result = manager
2098 .execute_with_options_env(
2099 &echo_stdin_command(),
2100 None,
2101 5000,
2102 true,
2103 None,
2104 false,
2105 None,
2106 HashMap::new(),
2107 )
2108 .expect("execute");
2109
2110 let task_id = result
2111 .task_id
2112 .expect("background execution should return task_id");
2113
2114 manager
2115 .write_stdin(&task_id, "hello\n", true)
2116 .expect("write stdin");
2117
2118 let delta = manager
2119 .get_output_delta(&task_id, true, 5000)
2120 .expect("get_output_delta");
2121
2122 assert!(delta.result.stdout.contains("hello"));
2123
2124 let delta2 = manager
2125 .get_output_delta(&task_id, false, 0)
2126 .expect("get_output_delta");
2127 assert!(delta2.result.stdout.is_empty());
2128 }
2129
2130 #[test]
2131 #[cfg(all(unix, not(target_env = "ohos")))]
2132 fn background_tty_command_has_controlling_terminal() {
2133 let tmp = tempdir().expect("tempdir");
2134 let mut manager = ShellManager::new(tmp.path().to_path_buf());
2135
2136 let result = manager
2137 .execute_with_options_env(
2138 "sh -c 'exec 3<>/dev/tty && printf tty-ok && exec 3>&-'",
2139 None,
2140 5000,
2141 true,
2142 None,
2143 true,
2144 Some(ExecutionSandboxPolicy::DangerFullAccess),
2145 HashMap::new(),
2146 )
2147 .expect("execute tty command");
2148
2149 let task_id = result
2150 .task_id
2151 .expect("background tty execution should return task_id");
2152
2153 let done = manager
2154 .get_output(&task_id, true, 10_000)
2155 .expect("get tty command output");
2156
2157 assert_eq!(done.status, ShellStatus::Completed);
2158 assert_eq!(done.exit_code, Some(0));
2159 assert!(
2160 done.stdout.contains("tty-ok"),
2161 "tty output should confirm /dev/tty opened; got {done:?}"
2162 );
2163 }
2164
2165 #[test]
2166 fn test_job_list_poll_cancel_and_stale_snapshot() {
2167 let tmp = tempdir().expect("tempdir");
2168 let mut manager = ShellManager::new(tmp.path().to_path_buf());
2169
2170 let started = execute_shell(
2171 &mut manager,
2172 &sleep_then_echo_command(1, "done"),
2173 None,
2174 5000,
2175 true,
2176 )
2177 .expect("execute");
2178 let task_id = started.task_id.expect("task id");
2179 manager
2180 .tag_linked_task(&task_id, Some("task_123".to_string()))
2181 .expect("tag linked task");
2182
2183 let running = manager.list_jobs();
2184 let job = running
2185 .iter()
2186 .find(|job| job.id == task_id)
2187 .expect("running job");
2188 assert_eq!(job.status, ShellStatus::Running);
2189 assert_eq!(job.linked_task_id.as_deref(), Some("task_123"));
2190 assert!(job.command.contains("done"));
2191 assert_eq!(job.cwd, tmp.path());
2192
2193 let completed = manager
2194 .poll_delta(&task_id, true, 5000)
2195 .expect("poll delta");
2196 assert_eq!(completed.result.status, ShellStatus::Completed);
2197 assert!(completed.result.stdout.contains("done"));
2198
2199 let detail = manager.inspect_job(&task_id).expect("inspect");
2200 assert!(detail.stdout.contains("done"));
2201 assert_eq!(detail.snapshot.status, ShellStatus::Completed);
2202
2203 manager.remember_stale_job(
2204 "shell_stale",
2205 "cargo test",
2206 tmp.path().to_path_buf(),
2207 Some("task_old".to_string()),
2208 );
2209 let stale = manager
2210 .list_jobs()
2211 .into_iter()
2212 .find(|job| job.id == "shell_stale")
2213 .expect("stale job");
2214 assert!(stale.stale);
2215 assert_eq!(stale.linked_task_id.as_deref(), Some("task_old"));
2216 }
2217
2218 #[test]
2219 fn running_job_snapshot_marks_no_output_stale_after_threshold() {
2220 let tmp = tempdir().expect("tempdir");
2221 let mut manager = ShellManager::new(tmp.path().to_path_buf());
2222
2223 let started =
2224 execute_shell(&mut manager, &sleep_command(5), None, 5000, true).expect("execute");
2225 let task_id = started.task_id.expect("task id");
2226
2227 {
2228 let shell = manager.processes.get_mut(&task_id).expect("live shell");
2229 shell.last_output_at = Instant::now() - STALE_NO_OUTPUT_AFTER - Duration::from_millis(1);
2230 }
2231
2232 let job = manager
2233 .list_jobs()
2234 .into_iter()
2235 .find(|job| job.id == task_id)
2236 .expect("running job");
2237
2238 assert_eq!(job.status, ShellStatus::Running);
2239 assert!(job.stale, "silent running job should be marked stale");
2240 assert!(
2241 job.elapsed_since_output_ms
2242 .is_some_and(|elapsed| elapsed >= STALE_NO_OUTPUT_AFTER.as_millis() as u64),
2243 "elapsed no-output time should be exposed: {job:?}"
2244 );
2245 }
2246
2247 #[test]
2248 fn running_job_snapshot_keeps_recent_no_output_fresh() {
2249 let tmp = tempdir().expect("tempdir");
2250 let mut manager = ShellManager::new(tmp.path().to_path_buf());
2251
2252 let started =
2253 execute_shell(&mut manager, &sleep_command(5), None, 5000, true).expect("execute");
2254 let task_id = started.task_id.expect("task id");
2255
2256 let job = manager
2257 .list_jobs()
2258 .into_iter()
2259 .find(|job| job.id == task_id)
2260 .expect("running job");
2261
2262 assert_eq!(job.status, ShellStatus::Running);
2263 assert!(!job.stale, "fresh running job should not start stale");
2264 assert!(job.elapsed_since_output_ms.is_some());
2265 }
2266
2267 #[test]
2268 fn test_job_cancel_updates_completion_state() {
2269 let tmp = tempdir().expect("tempdir");
2270 let mut manager = ShellManager::new(tmp.path().to_path_buf());
2271
2272 let started =
2273 execute_shell(&mut manager, &sleep_command(60), None, 5000, true).expect("execute");
2274 let task_id = started.task_id.expect("task id");
2275
2276 let killed = manager.kill(&task_id).expect("kill");
2277 assert_eq!(killed.status, ShellStatus::Killed);
2278 let job = manager.inspect_job(&task_id).expect("inspect");
2279 assert_eq!(job.snapshot.status, ShellStatus::Killed);
2280 assert!(!job.snapshot.stdin_available);
2281 }
2282
2283 #[test]
2284 fn test_output_truncation() {
2285 let long_output = "x".repeat(50_000);
2286 let (truncated, _meta) = truncate_with_meta(&long_output);
2287
2288 assert!(truncated.len() < long_output.len());
2289 assert!(truncated.contains("truncated"));
2290 }
2291
2292 #[test]
2293 fn test_truncate_with_meta_reports_omission_counts() {
2294 let long_output = format!("line1\nline2\n{}", "x".repeat(60_000));
2295 let (truncated, meta) = truncate_with_meta(&long_output);
2296
2297 assert!(meta.truncated);
2298 assert!(meta.original_len >= long_output.len());
2299 assert!(meta.omitted > 0);
2300 assert!(truncated.contains("bytes omitted"));
2301 }
2302
2303 #[test]
2304 fn network_restricted_hint_detects_silent_curl_failure() {
2305 let tmp = tempdir().expect("tempdir");
2306 let ctx = network_restricted_context(tmp.path());
2307 let result = failed_network_shell_result("000", "");
2308
2309 let hint = shell_network_restricted_hint(
2310 &ctx,
2311 "curl -s -o /dev/null -w '%{http_code}' https://api.github.com",
2312 &result,
2313 )
2314 .expect("network-restricted hint");
2315
2316 assert!(hint.contains("Plan mode"));
2317 }
2318
2319 #[test]
2320 fn sandbox_denied_hint_names_the_effective_posture() {
2321 // DGF-02: an approved write blocked by a read-only sandbox must come
2322 // back naming the sandbox as the blocker, never as a bare failure.
2323 let tmp = tempdir().expect("tempdir");
2324 let ctx =
2325 ToolContext::new(tmp.path()).with_elevated_sandbox_policy(ExecutionSandboxPolicy::ReadOnly);
2326 let mut result =
2327 failed_network_shell_result("", "sh: cannot create out.txt: Operation not permitted");
2328 result.sandbox_denied = true;
2329
2330 let hint = shell_sandbox_denied_hint(&ctx, &result).expect("sandbox-denied hint");
2331
2332 assert!(hint.contains("read-only"), "{hint}");
2333 assert!(hint.contains("Ask-only escalation"), "{hint}");
2334 assert!(
2335 hint.contains("retry this exact command once with sandbox_permissions"),
2336 "{hint}"
2337 );
2338 assert!(hint.contains("justification"), "{hint}");
2339 }
2340
2341 #[test]
2342 fn contract_bash_denial_surfaces_the_escalation_shape() {
2343 let tmp = tempdir().expect("tempdir");
2344 let ctx =
2345 ToolContext::new(tmp.path()).with_elevated_sandbox_policy(ExecutionSandboxPolicy::ReadOnly);
2346 let mut result = failed_network_shell_result("", "Operation not permitted");
2347 result.sandbox_denied = true;
2348
2349 let error = finish_contract_bash_result(result, None, &ctx)
2350 .expect_err("sandbox denial is a failed call");
2351
2352 assert!(
2353 error
2354 .to_string()
2355 .contains("retry this exact command once with sandbox_permissions"),
2356 "{error}"
2357 );
2358 }
2359
2360 #[test]
2361 fn sandbox_denied_hint_absent_without_denial_or_policy() {
2362 let tmp = tempdir().expect("tempdir");
2363 let ctx =
2364 ToolContext::new(tmp.path()).with_elevated_sandbox_policy(ExecutionSandboxPolicy::ReadOnly);
2365 let undenied = failed_network_shell_result("", "No such file or directory");
2366 assert!(shell_sandbox_denied_hint(&ctx, &undenied).is_none());
2367
2368 let mut denied = failed_network_shell_result("", "");
2369 denied.sandbox_denied = true;
2370 let no_policy_ctx = ToolContext::new(tmp.path());
2371 assert!(shell_sandbox_denied_hint(&no_policy_ctx, &denied).is_none());
2372 }
2373
2374 #[test]
2375 fn shell_delta_result_surfaces_sandbox_denied_hint() {
2376 let tmp = tempdir().expect("tempdir");
2377 let ctx =
2378 ToolContext::new(tmp.path()).with_elevated_sandbox_policy(ExecutionSandboxPolicy::ReadOnly);
2379 let mut result = failed_network_shell_result("", "Operation not permitted");
2380 result.sandbox_denied = true;
2381
2382 let tool_result = build_shell_delta_tool_result(
2383 ShellDeltaResult {
2384 command: "touch out.txt".to_string(),
2385 result,
2386 stdout_total_len: 0,
2387 stderr_total_len: 0,
2388 },
2389 &ctx,
2390 );
2391
2392 assert!(
2393 tool_result
2394 .content
2395 .contains("The execution sandbox blocked this command"),
2396 "{}",
2397 tool_result.content
2398 );
2399 let metadata = tool_result.metadata.expect("metadata");
2400 assert!(metadata.get("sandbox_denied_hint").is_some());
2401 }
2402
2403 #[test]
2404 fn network_restricted_hint_ignores_local_failures() {
2405 let tmp = tempdir().expect("tempdir");
2406 let ctx = network_restricted_context(tmp.path());
2407 let result = failed_network_shell_result("", "No such file or directory");
2408
2409 assert!(shell_network_restricted_hint(&ctx, "cat missing.txt", &result).is_none());
2410 }
2411
2412 #[test]
2413 fn shell_delta_result_surfaces_network_restricted_hint() {
2414 let tmp = tempdir().expect("tempdir");
2415 let ctx = network_restricted_context(tmp.path());
2416 let result = failed_network_shell_result("000", "");
2417
2418 let tool_result = build_shell_delta_tool_result(
2419 ShellDeltaResult {
2420 command: "gh issue list".to_string(),
2421 result,
2422 stdout_total_len: 3,
2423 stderr_total_len: 0,
2424 },
2425 &ctx,
2426 );
2427
2428 assert!(!tool_result.success);
2429 assert!(tool_result.content.starts_with("Shell command blocked"));
2430 let metadata = tool_result.metadata.expect("metadata");
2431 assert_eq!(
2432 metadata
2433 .get("sandbox_network_restricted")
2434 .and_then(Value::as_bool),
2435 Some(true)
2436 );
2437 }
2438
2439 #[test]
2440 fn shell_delta_result_exposes_lossless_high_exit_code_and_hex() {
2441 let tmp = tempdir().expect("tempdir");
2442 let ctx = ToolContext::new(tmp.path());
2443 let mut result = failed_network_shell_result("", "");
2444 result.exit_code = Some(0xC000_0005);
2445
2446 let tool_result = build_shell_delta_tool_result(
2447 ShellDeltaResult {
2448 command: "echo probe".to_string(),
2449 result,
2450 stdout_total_len: 0,
2451 stderr_total_len: 0,
2452 },
2453 &ctx,
2454 );
2455
2456 assert!(
2457 tool_result
2458 .content
2459 .contains("exit code 3221225477 (0xC0000005)"),
2460 "{}",
2461 tool_result.content
2462 );
2463 let metadata = tool_result.metadata.expect("metadata");
2464 assert_eq!(metadata["exit_code"], json!(3221225477_i64));
2465 assert_eq!(metadata["exit_code_hex"], json!("0xC0000005"));
2466 }
2467
2468 #[test]
2469 fn shell_delta_result_surfaces_elapsed_time_in_content() {
2470 let tmp = tempdir().expect("tempdir");
2471 let ctx = ToolContext::new(tmp.path());
2472 let mut result = failed_network_shell_result("", "");
2473 result.status = ShellStatus::Running;
2474 result.duration_ms = 42_500;
2475 result.task_id = Some("shell-7".to_string());
2476
2477 let tool_result = build_shell_delta_tool_result(
2478 ShellDeltaResult {
2479 command: "cargo test --workspace".to_string(),
2480 result,
2481 stdout_total_len: 0,
2482 stderr_total_len: 0,
2483 },
2484 &ctx,
2485 );
2486
2487 assert!(
2488 tool_result
2489 .content
2490 .starts_with("Task shell-7 still running after 42.5 s."),
2491 "{}",
2492 tool_result.content
2493 );
2494 }
2495
2496 #[test]
2497 fn shell_delta_timing_line_omits_task_id_when_unknown() {
2498 let tmp = tempdir().expect("tempdir");
2499 let ctx = ToolContext::new(tmp.path());
2500 // failed_network_shell_result: ShellStatus::Failed, duration_ms: 25, task_id: None.
2501 let result = failed_network_shell_result("", "");
2502
2503 let tool_result = build_shell_delta_tool_result(
2504 ShellDeltaResult {
2505 command: "echo probe".to_string(),
2506 result,
2507 stdout_total_len: 0,
2508 stderr_total_len: 0,
2509 },
2510 &ctx,
2511 );
2512
2513 assert!(
2514 tool_result.content.starts_with("Task failed after 25 ms."),
2515 "{}",
2516 tool_result.content
2517 );
2518 }
2519
2520 #[test]
2521 fn shell_delta_timing_line_phrases_cover_terminal_statuses() {
2522 let tmp = tempdir().expect("tempdir");
2523 let ctx = ToolContext::new(tmp.path());
2524 for (status, phrase) in [
2525 (ShellStatus::Completed, "completed"),
2526 (ShellStatus::Killed, "killed"),
2527 (ShellStatus::TimedOut, "timed out"),
2528 ] {
2529 let mut result = failed_network_shell_result("", "");
2530 result.status = status;
2531 result.duration_ms = 5_000;
2532 let tool_result = build_shell_delta_tool_result(
2533 ShellDeltaResult {
2534 command: "echo probe".to_string(),
2535 result,
2536 stdout_total_len: 0,
2537 stderr_total_len: 0,
2538 },
2539 &ctx,
2540 );
2541 assert!(
2542 tool_result
2543 .content
2544 .starts_with(&format!("Task {phrase} after 5 s.")),
2545 "{}",
2546 tool_result.content
2547 );
2548 }
2549 }
2550
2551 #[test]
2552 fn shell_delta_timing_line_handles_zero_duration() {
2553 let tmp = tempdir().expect("tempdir");
2554 let ctx = ToolContext::new(tmp.path());
2555 let mut result = failed_network_shell_result("", "");
2556 result.status = ShellStatus::Completed;
2557 result.duration_ms = 0;
2558 let tool_result = build_shell_delta_tool_result(
2559 ShellDeltaResult {
2560 command: "echo probe".to_string(),
2561 result,
2562 stdout_total_len: 0,
2563 stderr_total_len: 0,
2564 },
2565 &ctx,
2566 );
2567 assert!(
2568 tool_result
2569 .content
2570 .starts_with("Task completed after 0 ms."),
2571 "{}",
2572 tool_result.content
2573 );
2574 }
2575
2576 #[test]
2577 fn shell_delta_timing_line_sits_below_network_hint() {
2578 let tmp = tempdir().expect("tempdir");
2579 let ctx = network_restricted_context(tmp.path());
2580 let result = failed_network_shell_result("000", "");
2581 let tool_result = build_shell_delta_tool_result(
2582 ShellDeltaResult {
2583 command: "gh issue list".to_string(),
2584 result,
2585 stdout_total_len: 3,
2586 stderr_total_len: 0,
2587 },
2588 &ctx,
2589 );
2590 let content = tool_result.content;
2591 let hint_pos = content.find("Shell command blocked").expect("hint present");
2592 let timing_pos = content
2593 .find("failed after 25 ms")
2594 .expect("timing line present");
2595 assert!(
2596 hint_pos < timing_pos,
2597 "hint must precede timing line: {content}"
2598 );
2599 }
2600
2601 #[test]
2602 fn shell_delta_result_includes_cargo_failure_summary() {
2603 let tmp = tempdir().expect("tempdir");
2604 let ctx = ToolContext::new(tmp.path());
2605 let result = ShellResult {
2606 task_id: None,
2607 status: ShellStatus::Failed,
2608 exit_code: Some(101),
2609 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(),
2610 stderr: "error: test failed, to rerun pass `--lib`".to_string(),
2611 duration_ms: 12,
2612 stdout_len: 0,
2613 stderr_len: 0,
2614 stdout_omitted: 0,
2615 stderr_omitted: 0,
2616 stdout_truncated: false,
2617 stderr_truncated: false,
2618 sandboxed: false,
2619 sandbox_type: None,
2620 sandbox_denied: false,
2621 };
2622
2623 let tool_result = build_shell_delta_tool_result(
2624 ShellDeltaResult {
2625 command: "cargo test".to_string(),
2626 result,
2627 stdout_total_len: 0,
2628 stderr_total_len: 0,
2629 },
2630 &ctx,
2631 );
2632
2633 let metadata = tool_result.metadata.expect("metadata");
2634 assert_eq!(
2635 metadata["cargo_failure_summary"]["kind"],
2636 json!("test_failure")
2637 );
2638 assert!(
2639 metadata["cargo_failure_summary"]["summary"]
2640 .as_str()
2641 .unwrap()
2642 .contains("Failing tests: tests::fails")
2643 );
2644 assert!(
2645 metadata["summary"]
2646 .as_str()
2647 .unwrap()
2648 .contains("error: test failed")
2649 );
2650 }
2651
2652 #[test]
2653 fn shell_delta_result_keeps_existing_summary_for_generic_cargo_failure() {
2654 let tmp = tempdir().expect("tempdir");
2655 let ctx = ToolContext::new(tmp.path());
2656 let result = ShellResult {
2657 task_id: None,
2658 status: ShellStatus::Failed,
2659 exit_code: Some(1),
2660 stdout: "build failed".to_string(),
2661 stderr: "command failed without structured cargo diagnostics".to_string(),
2662 duration_ms: 12,
2663 stdout_len: 0,
2664 stderr_len: 0,
2665 stdout_omitted: 0,
2666 stderr_omitted: 0,
2667 stdout_truncated: false,
2668 stderr_truncated: false,
2669 sandboxed: false,
2670 sandbox_type: None,
2671 sandbox_denied: false,
2672 };
2673
2674 let tool_result = build_shell_delta_tool_result(
2675 ShellDeltaResult {
2676 command: "cargo test".to_string(),
2677 result,
2678 stdout_total_len: 0,
2679 stderr_total_len: 0,
2680 },
2681 &ctx,
2682 );
2683
2684 let metadata = tool_result.metadata.expect("metadata");
2685 assert!(metadata.get("cargo_failure_summary").is_none());
2686 assert_eq!(
2687 metadata["summary"],
2688 json!("command failed without structured cargo diagnostics")
2689 );
2690 }
2691
2692 #[test]
2693 fn shell_delta_result_surfaces_python_build_dependency_hint() {
2694 let tmp = tempdir().expect("tempdir");
2695 let ctx = ToolContext::new(tmp.path());
2696 let result = ShellResult {
2697 task_id: None,
2698 status: ShellStatus::Failed,
2699 exit_code: Some(1),
2700 stdout: String::new(),
2701 stderr: "running build_ext\nModuleNotFoundError: No module named 'setuptools'\n"
2702 .to_string(),
2703 duration_ms: 12,
2704 stdout_len: 0,
2705 stderr_len: 72,
2706 stdout_omitted: 0,
2707 stderr_omitted: 0,
2708 stdout_truncated: false,
2709 stderr_truncated: false,
2710 sandboxed: false,
2711 sandbox_type: None,
2712 sandbox_denied: false,
2713 };
2714
2715 let tool_result = build_shell_delta_tool_result(
2716 ShellDeltaResult {
2717 command: "python setup.py build_ext --inplace".to_string(),
2718 result,
2719 stdout_total_len: 0,
2720 stderr_total_len: 72,
2721 },
2722 &ctx,
2723 );
2724
2725 assert!(!tool_result.success);
2726 assert!(
2727 tool_result
2728 .content
2729 .starts_with("Python build dependency missing")
2730 );
2731 let metadata = tool_result.metadata.expect("metadata");
2732 assert_eq!(
2733 metadata["python_build_dependency_hint"]["kind"],
2734 json!("missing_setuptools")
2735 );
2736 assert!(
2737 metadata["python_build_dependency_hint"]["hint"]
2738 .as_str()
2739 .unwrap()
2740 .contains("setuptools")
2741 );
2742 }
2743
2744 #[test]
2745 fn test_summarize_output_strips_truncation_note() {
2746 let long_output = "x".repeat(60_000);
2747 let (truncated, _meta) = truncate_with_meta(&long_output);
2748 let summary = summarize_output(&truncated);
2749 assert!(!summary.contains("Output truncated at"));
2750 }
2751
2752 #[tokio::test]
2753 async fn test_exec_shell_metadata_includes_summaries() {
2754 let tmp = tempdir().expect("tempdir");
2755 let ctx = ToolContext::new(tmp.path());
2756 let tool = BashTool::new("Bash");
2757
2758 let result = tool
2759 .execute(json!({"command": echo_command("hello")}), &ctx)
2760 .await
2761 .expect("execute");
2762 assert!(result.success);
2763
2764 let meta = result.metadata.expect("metadata");
2765 let summary = meta
2766 .get("summary")
2767 .and_then(Value::as_str)
2768 .unwrap_or_default()
2769 .to_string();
2770 assert!(summary.contains("hello"));
2771 assert!(meta.get("stdout_len").is_some());
2772 assert!(meta.get("stdout_truncated").is_some());
2773 }
2774
2775 #[cfg(not(windows))]
2776 #[tokio::test]
2777 async fn test_exec_shell_combined_output_uses_single_stream() {
2778 let tmp = tempdir().expect("tempdir");
2779 let ctx = ToolContext::new(tmp.path());
2780 let tool = BashTool::new("Bash");
2781 let command = "printf 'out\\n'; printf 'err\\n' >&2";
2782
2783 let result = tool
2784 .execute(json!({"command": command, "combined_output": true}), &ctx)
2785 .await
2786 .expect("execute");
2787 assert!(result.success, "{}", result.content);
2788 assert!(result.content.contains("out"), "{}", result.content);
2789 assert!(result.content.contains("err"), "{}", result.content);
2790
2791 let meta = result.metadata.expect("metadata");
2792 assert_eq!(
2793 meta.get("combined_output").and_then(Value::as_bool),
2794 Some(true)
2795 );
2796 }
2797
2798 #[tokio::test]
2799 async fn test_exec_shell_foreground_timeout_guides_background_rerun() {
2800 let tmp = tempdir().expect("tempdir");
2801 let ctx = ToolContext::new(tmp.path());
2802 let tool = BashTool::new("Bash");
2803
2804 let result = tool
2805 .execute(
2806 json!({
2807 "command": sleep_command(10),
2808 "timeout_ms": 1000
2809 }),
2810 &ctx,
2811 )
2812 .await
2813 .expect("execute");
2814
2815 assert!(!result.success);
2816 // The rerun instruction has to be spelled in the canonical action form:
2817 // `exec_shell` / `task_shell_start` are not both dispatchable, and the
2818 // model can only reach the shell through `Bash`.
2819 assert!(
2820 result
2821 .content
2822 .contains("Bash action=\"run\" background=true")
2823 );
2824 assert!(result.content.contains("Bash action=\"wait\""));
2825 assert!(!result.content.contains("exec_shell"));
2826 assert!(result.content.contains("process killed"));
2827 let meta = result.metadata.expect("metadata");
2828 assert_eq!(meta.get("status").and_then(Value::as_str), Some("TimedOut"));
2829 let recovery = meta
2830 .get("foreground_timeout_recovery")
2831 .expect("timeout recovery metadata");
2832 assert_eq!(
2833 recovery
2834 .get("rerun_as")
2835 .and_then(|rerun| rerun.get("background"))
2836 .and_then(Value::as_bool),
2837 Some(true)
2838 );
2839 assert_eq!(
2840 recovery
2841 .get("rerun_as")
2842 .and_then(|rerun| rerun.get("tool"))
2843 .and_then(Value::as_str),
2844 Some("Bash")
2845 );
2846 let hint = recovery
2847 .get("hint")
2848 .and_then(Value::as_str)
2849 .unwrap_or_default();
2850 assert!(hint.contains("Bash action=\"wait\""), "{hint}");
2851 assert!(!hint.contains("exec_shell"), "{hint}");
2852 // The structured tool list is read by the model too; it must not hand
2853 // over names the registry does not resolve.
2854 let recommended = recovery.to_string();
2855 assert!(!recommended.contains("exec_shell"), "{recommended}");
2856 }
2857
2858 #[test]
2859 fn background_schema_distinguishes_temporary_jobs_from_persistent_services() {
2860 let schema = BashTool::new("Bash").input_schema();
2861 let d = schema["properties"]["background"]["description"]
2862 .as_str()
2863 .expect("background description");
2864 assert!(d.contains("killed") && d.contains("background:true") && d.contains("persist:true"));
2865 }
2866
2867 #[tokio::test]
2868 async fn test_exec_shell_foreground_cancel_kills_process() {
2869 let tmp = tempdir().expect("tempdir");
2870 let cancel_token = tokio_util::sync::CancellationToken::new();
2871 let ctx = ToolContext::new(tmp.path()).with_cancel_token(cancel_token.clone());
2872 let command = sleep_command(30);
2873
2874 let task = tokio::spawn(async move {
2875 BashTool::new("Bash")
2876 .execute(
2877 json!({
2878 "command": command,
2879 "timeout_ms": 600_000
2880 }),
2881 &ctx,
2882 )
2883 .await
2884 .expect("execute")
2885 });
2886
2887 tokio::time::sleep(Duration::from_millis(150)).await;
2888 cancel_token.cancel();
2889
2890 let result = tokio::time::timeout(Duration::from_secs(5), task)
2891 .await
2892 .expect("foreground shell should observe cancellation")
2893 .expect("task should not panic");
2894
2895 assert!(!result.success);
2896 assert!(result.content.contains("Command canceled"));
2897 let meta = result.metadata.expect("metadata");
2898 assert_eq!(meta.get("status").and_then(Value::as_str), Some("Killed"));
2899 assert_eq!(meta.get("canceled").and_then(Value::as_bool), Some(true));
2900 }
2901
2902 #[tokio::test]
2903 async fn test_exec_shell_foreground_can_move_to_background() {
2904 let tmp = tempdir().expect("tempdir");
2905 let ctx = ToolContext::new(tmp.path());
2906 let shell_manager = ctx.shell_manager.clone();
2907 let command = sleep_command(30);
2908 let task_ctx = ctx.clone();
2909
2910 let task = tokio::spawn(async move {
2911 BashTool::new("Bash")
2912 .execute(
2913 json!({
2914 "command": command,
2915 "timeout_ms": 600_000
2916 }),
2917 &task_ctx,
2918 )
2919 .await
2920 .expect("execute")
2921 });
2922
2923 tokio::time::sleep(Duration::from_millis(150)).await;
2924 shell_manager
2925 .lock()
2926 .expect("shell manager lock")
2927 .request_foreground_background();
2928
2929 let result = tokio::time::timeout(Duration::from_secs(5), task)
2930 .await
2931 .expect("foreground shell should detach")
2932 .expect("task should not panic");
2933
2934 assert!(result.success);
2935 assert!(
2936 result
2937 .content
2938 .contains("Foreground shell wait moved to /jobs")
2939 );
2940 // The detach message points the model at the wait action for early
2941 // output, and hands over the task_id it needs to make that call.
2942 assert!(
2943 result.content.contains("Bash action=\"wait\""),
2944 "{}",
2945 result.content
2946 );
2947 assert!(result.content.contains("task_id="), "{}", result.content);
2948 assert!(!result.content.contains("exec_shell"), "{}", result.content);
2949
2950 let meta = result.metadata.expect("metadata");
2951 assert_eq!(meta.get("status").and_then(Value::as_str), Some("Running"));
2952 assert_eq!(
2953 meta.get("backgrounded").and_then(Value::as_bool),
2954 Some(true)
2955 );
2956 let task_id = meta
2957 .get("task_id")
2958 .and_then(Value::as_str)
2959 .expect("task id")
2960 .to_string();
2961
2962 let mut manager = shell_manager.lock().expect("shell manager lock");
2963 let job = manager.inspect_job(&task_id).expect("inspect job");
2964 assert_eq!(job.snapshot.status, ShellStatus::Running);
2965 let killed = manager.kill(&task_id).expect("kill");
2966 assert_eq!(killed.status, ShellStatus::Killed);
2967 }
2968
2969 #[cfg(unix)]
2970 #[tokio::test]
2971 async fn dropped_foreground_wait_kills_descendants_and_retains_unreceived_output() {
2972 let tmp = tempdir().unwrap();
2973 let pid_file = tmp.path().join("descendant.pid");
2974 let command = format!(
2975 "printf 'retained-before-drop\\n'; printf '%{}s\\n' x; \
2976 CODEWHALE_SHELL_DESCENDANT_HELPER=1 \
2977 CODEWHALE_SHELL_DESCENDANT_PID_FILE={} {} --exact \
2978 tools::shell::tests::shell_descendant_helper_process --nocapture",
2979 RAW_STREAM_SETTLED_TAIL_BYTES + 1024,
2980 shell_words::quote(&pid_file.display().to_string()),
2981 shell_words::quote(&std::env::current_exe().unwrap().display().to_string()),
2982 );
2983 let ctx = ToolContext::new(tmp.path()).with_state_namespace("foreground-drop".to_string());
2984 let manager = ctx.shell_manager.clone();
2985 let task_ctx = ctx.clone();
2986 let task = tokio::spawn(async move {
2987 BashTool::new("Bash")
2988 .execute(
2989 json!({"command": command, "timeout_ms": 600_000}),
2990 &task_ctx,
2991 )
2992 .await
2993 });
2994 let descendant = tokio::time::timeout(Duration::from_secs(10), async {
2995 loop {
2996 if let Ok(raw) = std::fs::read_to_string(&pid_file)
2997 && let Ok(pid) = raw.trim().parse::<libc::pid_t>()
2998 {
2999 break pid;
3000 }
3001 tokio::time::sleep(Duration::from_millis(10)).await;
3002 }
3003 })
3004 .await
3005 .expect("descendant must start");
3006 task.abort();
3007 assert!(task.await.unwrap_err().is_cancelled());
3008 assert!(
3009 wait_for_shell_pid_exit(descendant),
3010 "dropping the wait must stop its descendant"
3011 );
3012 let mut manager = manager.lock().unwrap();
3013 let jobs = manager.list_jobs_for_session("foreground-drop");
3014 assert_eq!(jobs.len(), 1);
3015 assert_eq!(jobs[0].status, ShellStatus::Killed);
3016 let detail = manager
3017 .inspect_job(&jobs[0].id)
3018 .expect("inspect cancelled job");
3019 assert!(detail.stdout.contains("retained-before-drop"));
3020 assert!(detail.stdout.len() > RAW_STREAM_SETTLED_TAIL_BYTES);
3021 assert!(!manager.has_finished_unreported_jobs_for_session("foreground-drop"));
3022 }
3023
3024 #[tokio::test]
3025 async fn lowercase_bash_foreground_detach_is_a_successful_running_receipt() {
3026 let tmp = tempdir().expect("tempdir");
3027 let ctx = ToolContext::new(tmp.path());
3028 let shell_manager = ctx.shell_manager.clone();
3029 let command = sleep_command(30);
3030 let task_ctx = ctx.clone();
3031
3032 let task = tokio::spawn(async move {
3033 LowercaseBashTool
3034 .execute(json!({"command": command}), &task_ctx)
3035 .await
3036 .expect("execute")
3037 });
3038
3039 tokio::time::sleep(Duration::from_millis(150)).await;
3040 shell_manager
3041 .lock()
3042 .expect("shell manager lock")
3043 .request_foreground_background();
3044
3045 let result = tokio::time::timeout(Duration::from_secs(5), task)
3046 .await
3047 .expect("foreground shell should detach")
3048 .expect("task should not panic");
3049
3050 assert!(result.success, "{}", result.content);
3051 assert!(
3052 result.content.contains("moved to /jobs"),
3053 "{}",
3054 result.content
3055 );
3056 assert!(!result.content.contains("code -1"), "{}", result.content);
3057 let metadata = result.metadata.expect("metadata");
3058 assert_eq!(metadata["status"], "Running");
3059 assert_eq!(metadata["backgrounded"], true);
3060 let task_id = metadata["task_id"].as_str().expect("task id");
3061
3062 let mut manager = shell_manager.lock().expect("shell manager lock");
3063 let job = manager.inspect_job(task_id).expect("inspect job");
3064 assert_eq!(job.snapshot.status, ShellStatus::Running);
3065 manager.kill(task_id).expect("kill test job");
3066 }
3067
3068 #[tokio::test]
3069 async fn test_exec_shell_wait_cancel_leaves_background_process_running() {
3070 let tmp = tempdir().expect("tempdir");
3071 let cancel_token = tokio_util::sync::CancellationToken::new();
3072 let ctx = ToolContext::new(tmp.path()).with_cancel_token(cancel_token.clone());
3073 let shell_manager = ctx.shell_manager.clone();
3074 let started = execute_shell(
3075 &mut shell_manager.lock().expect("shell manager lock"),
3076 &sleep_command(30),
3077 None,
3078 600_000,
3079 true,
3080 )
3081 .expect("execute");
3082 let task_id = started.task_id.expect("task id");
3083 let wait_task_id = task_id.clone();
3084 let task_ctx = ctx.clone();
3085
3086 let task = tokio::spawn(async move {
3087 BashTool::new("Bash")
3088 .execute(
3089 json!({
3090 "action": "wait",
3091 "task_id": wait_task_id,
3092 "timeout_ms": 600_000
3093 }),
3094 &task_ctx,
3095 )
3096 .await
3097 .expect("wait")
3098 });
3099
3100 tokio::time::sleep(Duration::from_millis(150)).await;
3101 cancel_token.cancel();
3102
3103 let result = tokio::time::timeout(Duration::from_secs(5), task)
3104 .await
3105 .expect("wait should observe cancellation")
3106 .expect("task should not panic");
3107
3108 assert!(result.success);
3109 assert!(result.content.contains("still running"));
3110 let meta = result.metadata.expect("metadata");
3111 assert_eq!(meta.get("status").and_then(Value::as_str), Some("Running"));
3112 assert_eq!(
3113 meta.get("wait_canceled").and_then(Value::as_bool),
3114 Some(true)
3115 );
3116
3117 let mut manager = shell_manager.lock().expect("shell manager lock");
3118 let job = manager.inspect_job(&task_id).expect("inspect job");
3119 assert_eq!(job.snapshot.status, ShellStatus::Running);
3120 let killed = manager.kill(&task_id).expect("kill");
3121 assert_eq!(killed.status, ShellStatus::Killed);
3122 }
3123
3124 #[tokio::test]
3125 async fn test_completed_background_shell_releases_process_handles() {
3126 let tmp = tempdir().expect("tempdir");
3127 let ctx = ToolContext::new(tmp.path());
3128 let shell_manager = ctx.shell_manager.clone();
3129 let started = execute_shell(
3130 &mut shell_manager.lock().expect("shell manager lock"),
3131 &echo_command("done"),
3132 None,
3133 600_000,
3134 true,
3135 )
3136 .expect("execute");
3137 let task_id = started.task_id.expect("task id");
3138
3139 let result = BashTool::alias("exec_shell_wait", "wait")
3140 .execute(
3141 json!({
3142 "task_id": task_id.clone(),
3143 "wait": true,
3144 "timeout_ms": BACKGROUND_COMPLETION_WAIT_MS
3145 }),
3146 &ctx,
3147 )
3148 .await
3149 .expect("wait");
3150
3151 assert!(result.success);
3152 let mut manager = shell_manager.lock().expect("shell manager lock");
3153 let result = wait_for_completed_shell(&mut manager, &task_id);
3154 assert_eq!(result.status, ShellStatus::Completed);
3155 let shell = manager.processes.get_mut(&task_id).expect("tracked shell");
3156 shell.poll();
3157 assert_eq!(shell.status, ShellStatus::Completed);
3158 assert!(shell.stdin.is_none());
3159 assert!(shell.child.is_none());
3160 assert!(shell.stdout_thread.is_none());
3161 assert!(shell.stderr_thread.is_none());
3162 }
3163
3164 #[cfg(unix)]
3165 #[tokio::test]
3166 async fn exec_shell_cancel_kills_descendant_process_group() {
3167 let tmp = tempdir().expect("tempdir");
3168 let pid_file = tmp.path().join("descendant.pid");
3169 let test_binary = std::env::current_exe().expect("current test binary");
3170 let command = format!(
3171 "{} --exact {} --nocapture",
3172 shell_words::quote(&test_binary.display().to_string()),
3173 shell_words::quote("tools::shell::tests::shell_descendant_helper_process"),
3174 );
3175 let ctx = ToolContext::new(tmp.path());
3176 let mut env = std::collections::HashMap::new();
3177 env.insert(SHELL_DESCENDANT_HELPER_ENV.to_string(), "1".to_string());
3178 env.insert(
3179 SHELL_DESCENDANT_PID_FILE_ENV.to_string(),
3180 pid_file.display().to_string(),
3181 );
3182 let started = ctx
3183 .shell_manager
3184 .lock()
3185 .expect("shell manager")
3186 .execute_with_options_env_for_session(
3187 &command,
3188 None,
3189 60_000,
3190 true,
3191 None,
3192 false,
3193 None,
3194 env,
3195 &ctx.state_namespace,
3196 )
3197 .expect("start descendant tree");
3198 let task_id = started.task_id.expect("task id");
3199 let descendant = wait_for_shell_pid_file(&pid_file);
3200
3201 let result = BashTool::alias("exec_shell_cancel", "cancel")
3202 .execute(json!({"task_id": task_id}), &ctx)
3203 .await
3204 .expect("cancel process group");
3205 assert!(result.success);
3206 assert!(
3207 wait_for_shell_pid_exit(descendant),
3208 "descendant {descendant} survived shell process-group cancellation"
3209 );
3210 }
3211
3212 #[tokio::test]
3213 async fn test_exec_shell_cancel_tool_kills_background_process() {
3214 let tmp = tempdir().expect("tempdir");
3215 let ctx = ToolContext::new(tmp.path());
3216 let shell_manager = ctx.shell_manager.clone();
3217 let started = execute_shell(
3218 &mut shell_manager.lock().expect("shell manager lock"),
3219 &sleep_command(30),
3220 None,
3221 600_000,
3222 true,
3223 )
3224 .expect("execute");
3225 let task_id = started.task_id.expect("task id");
3226
3227 let result = BashTool::alias("exec_shell_cancel", "cancel")
3228 .execute(json!({ "task_id": task_id }), &ctx)
3229 .await
3230 .expect("cancel");
3231
3232 assert!(result.success);
3233 assert!(result.content.contains("Canceled background command"));
3234 let meta = result.metadata.expect("metadata");
3235 assert_eq!(meta.get("status").and_then(Value::as_str), Some("Killed"));
3236
3237 let task_id = meta
3238 .get("task_id")
3239 .and_then(Value::as_str)
3240 .expect("task id");
3241 let mut manager = shell_manager.lock().expect("shell manager lock");
3242 let job = manager.inspect_job(task_id).expect("inspect job");
3243 assert_eq!(job.snapshot.status, ShellStatus::Killed);
3244 }
3245
3246 #[tokio::test]
3247 async fn test_exec_shell_cancel_tool_can_kill_all_running_processes() {
3248 let tmp = tempdir().expect("tempdir");
3249 let ctx = ToolContext::new(tmp.path());
3250 let shell_manager = ctx.shell_manager.clone();
3251 let first = execute_shell(
3252 &mut shell_manager.lock().expect("shell manager lock"),
3253 &sleep_command(30),
3254 None,
3255 600_000,
3256 true,
3257 )
3258 .expect("execute first")
3259 .task_id
3260 .expect("first task id");
3261 let second = execute_shell(
3262 &mut shell_manager.lock().expect("shell manager lock"),
3263 &sleep_command(30),
3264 None,
3265 600_000,
3266 true,
3267 )
3268 .expect("execute second")
3269 .task_id
3270 .expect("second task id");
3271
3272 let result = BashTool::alias("exec_shell_cancel", "cancel")
3273 .execute(json!({ "all": true }), &ctx)
3274 .await
3275 .expect("cancel all");
3276
3277 assert!(result.success);
3278 let meta = result.metadata.expect("metadata");
3279 assert_eq!(meta.get("status").and_then(Value::as_str), Some("Killed"));
3280 assert_eq!(meta.get("canceled").and_then(Value::as_u64), Some(2));
3281
3282 let mut manager = shell_manager.lock().expect("shell manager lock");
3283 let first_job = manager.inspect_job(&first).expect("inspect first");
3284 let second_job = manager.inspect_job(&second).expect("inspect second");
3285 assert_eq!(first_job.snapshot.status, ShellStatus::Killed);
3286 assert_eq!(second_job.snapshot.status, ShellStatus::Killed);
3287 }
3288
3289 fn make_failed_result(stderr: &str) -> ShellResult {
3290 ShellResult {
3291 task_id: None,
3292 status: ShellStatus::Failed,
3293 exit_code: Some(1),
3294 stdout: String::new(),
3295 stderr: stderr.to_string(),
3296 duration_ms: 0,
3297 stdout_len: 0,
3298 stderr_len: stderr.len(),
3299 stdout_omitted: 0,
3300 stderr_omitted: 0,
3301 stdout_truncated: false,
3302 sandboxed: false,
3303 sandbox_type: None,
3304 sandbox_denied: false,
3305 stderr_truncated: false,
3306 }
3307 }
3308
3309 #[test]
3310 fn test_macos_provenance_detected_by_activity_time_message() {
3311 let result = make_failed_result(
3312 "failed to update builder last activity time: open \
3313 /Users/user/.docker/buildx/activity/.tmp-abc: operation not permitted",
3314 );
3315 assert!(looks_like_macos_provenance_failure(&result));
3316 }
3317
3318 #[test]
3319 fn test_macos_provenance_detected_by_activity_path_and_eperm() {
3320 let result = make_failed_result(
3321 "error: open /home/user/.docker/buildx/activity/foo: operation not permitted",
3322 );
3323 assert!(looks_like_macos_provenance_failure(&result));
3324 }
3325
3326 #[test]
3327 fn test_macos_provenance_not_triggered_on_success() {
3328 let mut result = make_failed_result(
3329 "failed to update builder last activity time: open \
3330 /Users/user/.docker/buildx/activity/.tmp-abc: operation not permitted",
3331 );
3332 result.status = ShellStatus::Completed;
3333 result.exit_code = Some(0);
3334 assert!(!looks_like_macos_provenance_failure(&result));
3335 }
3336
3337 #[test]
3338 fn test_macos_provenance_not_triggered_on_unrelated_eperm() {
3339 let result = make_failed_result("open /some/other/path: operation not permitted");
3340 assert!(!looks_like_macos_provenance_failure(&result));
3341 }
3342
3343 // Regression test for #828: shell spawns an orphaned background subprocess
3344 // (simulating `nohup curl`) that keeps the pipe write-end open after the shell
3345 // exits. collect_output() must not block indefinitely — it kills the whole
3346 // process group first, allowing reader threads to get EOF and exit.
3347 #[cfg(unix)]
3348 #[test]
3349 fn test_orphaned_subprocess_does_not_block_collect_output() {
3350 let tmp = tempdir().expect("tempdir");
3351 let mut manager = ShellManager::new(tmp.path().to_path_buf());
3352
3353 // sh spawns `sleep 100 &` and exits; the sleep subprocess inherits the
3354 // pipe write-ends and would keep reader threads blocked without the fix.
3355 let result =
3356 execute_shell(&mut manager, "sh -c 'sleep 100 &'", None, 5000, true).expect("execute");
3357 let task_id = result.task_id.expect("task id");
3358
3359 // Drive to completion with a tight timeout — must not hang.
3360 let done = manager
3361 .get_output(&task_id, true, 3000)
3362 .expect("get_output must complete, not hang");
3363 assert_eq!(done.status, ShellStatus::Completed);
3364 }
3365
3366 #[cfg(unix)]
3367 #[test]
3368 fn foreground_shell_does_not_block_on_orphaned_subprocess_pipe() {
3369 let tmp = tempdir().expect("tempdir");
3370 let mut manager = ShellManager::new(tmp.path().to_path_buf());
3371
3372 let started = std::time::Instant::now();
3373 let result = execute_shell(&mut manager, "sh -c 'sleep 100 &'", None, 5000, false)
3374 .expect("foreground execute must complete, not hang");
3375
3376 assert!(
3377 started.elapsed() < std::time::Duration::from_secs(4),
3378 "foreground execute blocked on descendant pipe handles"
3379 );
3380 assert_eq!(result.status, ShellStatus::Completed);
3381 }
3382
3383 // Windows equivalent of the orphaned pipe-handle regression. `cmd /c start /b`
3384 // launches a descendant process that inherits stdout/stderr and outlives the
3385 // shell. Job-object cleanup must terminate that descendant before reader-thread
3386 // joins, otherwise get_output() blocks until ping exits.
3387 #[cfg(windows)]
3388 #[test]
3389 fn background_collection_does_not_block_on_detached_descendant_pipe() {
3390 let tmp = tempdir().expect("tempdir");
3391 let mut manager = ShellManager::new(tmp.path().to_path_buf());
3392
3393 let result = execute_shell(
3394 &mut manager,
3395 r#"cmd /c start "" /b ping 127.0.0.1 -n 4"#,
3396 None,
3397 5000,
3398 true,
3399 )
3400 .expect("execute");
3401 let task_id = result.task_id.expect("task id");
3402
3403 let started = std::time::Instant::now();
3404 let done = manager
3405 .get_output(&task_id, true, 3000)
3406 .expect("get_output must complete, not hang");
3407
3408 assert!(
3409 started.elapsed() < std::time::Duration::from_secs(6),
3410 "get_output blocked on descendant pipe handles"
3411 );
3412 assert_eq!(done.status, ShellStatus::Completed);
3413 }
3414
3415 #[cfg(windows)]
3416 #[test]
3417 fn windows_job_terminate_denied_falls_back_to_child_kill() {
3418 let mut child = Command::new("ping")
3419 .args(["127.0.0.1", "-n", "20"])
3420 .stdin(Stdio::null())
3421 .stdout(Stdio::null())
3422 .stderr(Stdio::null())
3423 .spawn()
3424 .expect("spawn ping");
3425
3426 let job = WindowsJob::attach_to_child(&child).expect("attach job");
3427 let limited_job = duplicate_job_without_terminate_access(job);
3428
3429 assert!(
3430 limited_job.terminate().is_err(),
3431 "limited job handle should not allow TerminateJobObject"
3432 );
3433
3434 terminate_child_and_close_windows_job(Some(limited_job), &mut child)
3435 .expect("fallback child kill");
3436
3437 let status = child
3438 .wait_timeout(std::time::Duration::from_secs(3))
3439 .expect("wait after fallback kill");
3440 assert!(
3441 status.is_some(),
3442 "fallback child kill should terminate child"
3443 );
3444 }
3445
3446 #[cfg(windows)]
3447 #[test]
3448 fn windows_job_close_releases_foreground_reader_threads_when_terminate_denied() {
3449 let mut child = Command::new("ping")
3450 .args(["127.0.0.1", "-n", "8"])
3451 .stdin(Stdio::null())
3452 .stdout(Stdio::piped())
3453 .stderr(Stdio::piped())
3454 .spawn()
3455 .expect("spawn ping");
3456
3457 let job = WindowsJob::attach_to_child(&child).expect("attach job");
3458 let limited_job = duplicate_job_without_terminate_access(job);
3459 assert!(
3460 limited_job.terminate().is_err(),
3461 "limited job handle should not allow TerminateJobObject"
3462 );
3463
3464 let stdout_handle = child.stdout.take().expect("stdout pipe");
3465 let stderr_handle = child.stderr.take().expect("stderr pipe");
3466 let stdout_thread = std::thread::spawn(move || {
3467 let mut reader = stdout_handle;
3468 let mut buf = Vec::new();
3469 let _ = reader.read_to_end(&mut buf);
3470 buf
3471 });
3472 let stderr_thread = std::thread::spawn(move || {
3473 let mut reader = stderr_handle;
3474 let mut buf = Vec::new();
3475 let _ = reader.read_to_end(&mut buf);
3476 buf
3477 });
3478
3479 let started = std::time::Instant::now();
3480 terminate_and_close_windows_job(Some(limited_job));
3481 let _ = stdout_thread.join().unwrap_or_default();
3482 let _ = stderr_thread.join().unwrap_or_default();
3483 let status = child
3484 .wait_timeout(std::time::Duration::from_secs(3))
3485 .expect("wait after kill-on-close");
3486
3487 assert!(
3488 started.elapsed() < std::time::Duration::from_secs(4),
3489 "reader joins waited for natural descendant exit instead of kill-on-close"
3490 );
3491 assert!(status.is_some(), "kill-on-close should terminate child");
3492 }
3493
3494 #[cfg(windows)]
3495 #[test]
3496 fn windows_job_kill_on_close_releases_reader_threads_when_terminate_denied() {
3497 let tmp = tempdir().expect("tempdir");
3498 let mut manager = ShellManager::new(tmp.path().to_path_buf());
3499
3500 let result = execute_shell(
3501 &mut manager,
3502 r#"cmd /c start "" /b ping 127.0.0.1 -n 8"#,
3503 None,
3504 5000,
3505 true,
3506 )
3507 .expect("execute");
3508 let task_id = result.task_id.expect("task id");
3509
3510 {
3511 let shell = manager
3512 .processes
3513 .get_mut(&task_id)
3514 .expect("background shell");
3515 let job = shell.windows_job.take().expect("windows job attached");
3516 let limited_job = duplicate_job_without_terminate_access(job);
3517 assert!(
3518 limited_job.terminate().is_err(),
3519 "limited job handle should not allow TerminateJobObject"
3520 );
3521 shell.windows_job = Some(limited_job);
3522 }
3523
3524 let started = std::time::Instant::now();
3525 let done = manager
3526 .get_output(&task_id, true, 3000)
3527 .expect("get_output must complete via kill-on-close fallback");
3528
3529 assert!(
3530 started.elapsed() < std::time::Duration::from_secs(4),
3531 "get_output waited for natural descendant exit instead of kill-on-close"
3532 );
3533 assert_eq!(done.status, ShellStatus::Completed);
3534 }
3535
3536 #[cfg(windows)]
3537 #[test]
3538 fn killed_shell_does_not_wait_for_blocked_reader_threads() {
3539 let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
3540 let stdout_thread = std::thread::spawn(move || {
3541 let _ = release_rx.recv();
3542 });
3543 let now = std::time::Instant::now();
3544 let mut shell = BackgroundShell {
3545 id: "killed-reader".to_string(),
3546 owner_session_id: "windows-test-session".to_string(),
3547 command: "test".to_string(),
3548 working_dir: std::path::PathBuf::from("."),
3549 status: ShellStatus::Killed,
3550 exit_code: None,
3551 started_at: now,
3552 finished_at: Some(now),
3553 last_output_at: now,
3554 last_observed_output_len: 0,
3555 sandbox_type: SandboxType::None,
3556 ownership: ShellOwnership::Managed,
3557 linked_task_id: None,
3558 owner_agent: None,
3559 origin_tool_call_id: None,
3560 origin_turn_id: None,
3561 stdout_buffer: super::new_shared_raw_output(),
3562 stderr_buffer: None,
3563 heavy_permit: None,
3564 stdout_cursor: 0,
3565 stderr_cursor: 0,
3566 completion_reported: false,
3567 bounded_output: None,
3568 stdin: None,
3569 pty_master: None,
3570 terminal_size: None,
3571 child: None,
3572 windows_job: None,
3573 stdout_thread: Some(stdout_thread),
3574 stderr_thread: None,
3575 work_lifecycle: None,
3576 lifecycle_seq: 0,
3577 last_lifecycle_status: None,
3578 last_lifecycle_bytes: 0,
3579 };
3580
3581 let started = std::time::Instant::now();
3582 shell.collect_output();
3583
3584 assert!(
3585 started.elapsed() < std::time::Duration::from_secs(1),
3586 "killed shell must not synchronously join a blocked reader"
3587 );
3588 release_tx.send(()).expect("release detached reader");
3589 }
3590
3591 #[test]
3592 fn test_list_jobs_cleans_up_completed_old_processes() {
3593 let tmp = tempdir().expect("tempdir");
3594 let mut manager = ShellManager::new(tmp.path().to_path_buf());
3595
3596 let bg =
3597 execute_shell(&mut manager, &echo_command("bg"), None, 5000, true).expect("execute bg");
3598 let bg_id = bg.task_id.expect("bg task id");
3599 manager.get_output(&bg_id, true, 3000).expect("bg done");
3600
3601 // Both the completed job and any tracking state should be present.
3602 assert!(!manager.processes.is_empty());
3603
3604 // cleanup(ZERO) removes all completed processes immediately.
3605 manager.cleanup(Duration::ZERO);
3606 assert!(
3607 manager.processes.is_empty(),
3608 "completed processes should be evicted by cleanup"
3609 );
3610 }
3611
3612 /// Regression for #1691: a `git commit -m "feat: complete sub-pages"` shell
3613 /// command must reach the OS shell with its quoted message intact (one argv
3614 /// slot), never split into `feat:` / `complete` / `sub-pages"`.
3615 #[test]
3616 fn issue_1691_quoted_commit_message_round_trips() {
3617 let cmd = r#"git commit -m "feat: complete sub-pages""#;
3618 let spec = CommandSpec::shell(
3619 cmd,
3620 std::path::PathBuf::from("/tmp"),
3621 Duration::from_secs(5),
3622 );
3623
3624 let dispatcher = crate::shell_dispatcher::global_dispatcher();
3625 // The whole command (with quotes) is a single argv entry. The actual
3626 // shell binary can vary by platform — and the dispatcher may wrap the
3627 // payload (encoding prefix, exit-code capture) — but the payload itself
3628 // must stay intact in ONE shell arg. We never split the command string
3629 // ourselves. This single-line ASCII command never takes the PowerShell
3630 // temp `-File` path, so the payload stays on the argv.
3631 assert_eq!(spec.program, dispatcher.kind().binary());
3632 let carriers = spec
3633 .args
3634 .iter()
3635 .filter(|arg| arg.contains(r#""feat: complete sub-pages""#))
3636 .count();
3637 assert_eq!(carriers, 1, "args: {:?}", spec.args);
3638 assert!(
3639 !spec
3640 .args
3641 .iter()
3642 .any(|arg| arg == "feat:" || arg == "complete" || arg == "sub-pages\""),
3643 "args: {:?}",
3644 spec.args
3645 );
3646 assert_eq!(spec.display_command(), cmd);
3647
3648 let mut built = Command::new(&spec.program);
3649 push_shell_args(&mut built, &spec.program, &spec.args);
3650 let got: Vec<String> = built
3651 .get_args()
3652 .map(|a| a.to_string_lossy().into_owned())
3653 .collect();
3654 assert_eq!(got, spec.args);
3655 }
3656
3657 /// When no `cwd` is provided, the shell should run in `context.workspace`,
3658 /// not in the ShellManager's default_workspace. This ensures sub-agents in
3659 /// worktrees run commands in the worktree directory rather than the parent.
3660 ///
3661 /// Without the `context.workspace` default (stashed): runs in sm_dir → FAILS
3662 /// With the `context.workspace` default (unstashed): runs in ctx_dir → PASSES
3663 #[tokio::test]
3664 async fn default_cwd_uses_context_workspace_not_shell_manager_default() {
3665 let ctx_dir = tempdir().expect("ctx tempdir");
3666 let sm_dir = tempdir().expect("sm tempdir");
3667
3668 // Create distinct dirs — write a marker in each so we can tell them apart.
3669 std::fs::write(ctx_dir.path().join("I_AM_CTX_DIR"), "").unwrap();
3670 std::fs::write(sm_dir.path().join("I_AM_SM_DIR"), "").unwrap();
3671
3672 // ToolContext whose workspace is ctx_dir...
3673 let ctx = ToolContext::new(ctx_dir.path())
3674 // ...but whose ShellManager's default_workspace is sm_dir.
3675 .with_shell_manager(new_shared_shell_manager(sm_dir.path().to_path_buf()));
3676
3677 // Assert directory identity through marker files instead of comparing the
3678 // shell's printed path. PowerShell and `canonicalize` can spell the same
3679 // Windows path differently (for example, with a verbatim-path prefix).
3680 let command = if cfg!(windows) {
3681 "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' }"
3682 } else {
3683 "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"
3684 };
3685 let result = BashTool::new("Bash")
3686 .execute(json!({"command": command}), &ctx)
3687 .await
3688 .expect("shell execute");
3689 assert!(result.success, "command failed: {:?}", result.content);
3690
3691 assert!(
3692 result
3693 .content
3694 .lines()
3695 .any(|line| line.trim() == "context-workspace"),
3696 "expected context.workspace marker, but shell reported: {:?}",
3697 result.content
3698 );
3699 }
3700
3701 // ── Kill-path overshoot regression tests (FINISH-0.9.4 #52 multiplier 2) ─────
3702 //
3703 // The foreground Bash kill path must return at ~timeout + a small bounded
3704 // grace, even when the command ignores SIGTERM or a descendant escapes the
3705 // process group while holding the output pipe open. Before the fix, an
3706 // escaped descendant wedged the blocking reader-thread join inside kill()
3707 // until the descendant exited on its own (observed: ~180s past a 120s
3708 // timeout in the wild).
3709
3710 #[cfg(unix)]
3711 const SHELL_SIGTERM_HELPER_ENV: &str = "CODEWHALE_SHELL_SIGTERM_HELPER";
3712 #[cfg(unix)]
3713 const SHELL_ESCAPE_HELPER_ENV: &str = "CODEWHALE_SHELL_ESCAPE_HELPER";
3714 #[cfg(unix)]
3715 const SHELL_ESCAPED_GRANDCHILD_ENV: &str = "CODEWHALE_SHELL_ESCAPED_GRANDCHILD";
3716
3717 /// Helper role: ignore SIGTERM and idle. Runs as the shell's direct child
3718 /// (same process group), so only the SIGKILL escalation can stop it.
3719 #[cfg(unix)]
3720 #[test]
3721 fn shell_sigterm_ignoring_helper_process() {
3722 if std::env::var(SHELL_SIGTERM_HELPER_ENV).ok().as_deref() != Some("1") {
3723 return;
3724 }
3725 unsafe {
3726 libc::signal(libc::SIGTERM, libc::SIG_IGN);
3727 }
3728 let pid_file = PathBuf::from(
3729 std::env::var(SHELL_DESCENDANT_PID_FILE_ENV).expect("sigterm helper pid file"),
3730 );
3731 std::fs::write(pid_file, std::process::id().to_string()).expect("write sigterm helper pid");
3732 loop {
3733 std::thread::sleep(Duration::from_millis(10));
3734 }
3735 }
3736
3737 /// Helper role: spawn a grandchild in its OWN process group (escaping the
3738 /// shell's group) that inherits the output pipe, then exit immediately. The
3739 /// wrapper shell keeps running (`sleep` after `&`), so the job stays Running
3740 /// while the escaped grandchild holds the reader thread's pipe open.
3741 #[cfg(unix)]
3742 // The grandchild deliberately outlives this helper and is never wait()ed on —
3743 // escaping reaping is exactly what the regression exercises; the test reaps
3744 // it directly via SIGKILL at the end.
3745 #[allow(clippy::zombie_processes)]
3746 #[test]
3747 fn shell_group_escape_helper_process() {
3748 if std::env::var(SHELL_ESCAPE_HELPER_ENV).ok().as_deref() != Some("1") {
3749 return;
3750 }
3751 let test_binary = std::env::current_exe().expect("current test binary");
3752 let pid_file = std::env::var(SHELL_DESCENDANT_PID_FILE_ENV).expect("escape pid file");
3753 let mut cmd = Command::new(test_binary);
3754 cmd.arg("--exact")
3755 .arg("tools::shell::tests::shell_escaped_grandchild_helper_process")
3756 .arg("--nocapture")
3757 .env(SHELL_ESCAPED_GRANDCHILD_ENV, "1")
3758 .env(SHELL_DESCENDANT_PID_FILE_ENV, pid_file);
3759 // A distinct process group is enough to escape `kill(-wrapper_pgid)`;
3760 // stdout/stderr are inherited, so the grandchild keeps the pipe open.
3761 #[cfg(unix)]
3762 cmd.process_group(0);
3763 let _child = cmd.spawn().expect("spawn escaped grandchild");
3764 }
3765
3766 /// Helper role: the escaped grandchild — ignores SIGTERM, reports its pid,
3767 /// then idles (holding the inherited output pipe open the whole time).
3768 #[cfg(unix)]
3769 #[test]
3770 fn shell_escaped_grandchild_helper_process() {
3771 if std::env::var(SHELL_ESCAPED_GRANDCHILD_ENV).ok().as_deref() != Some("1") {
3772 return;
3773 }
3774 unsafe {
3775 libc::signal(libc::SIGTERM, libc::SIG_IGN);
3776 }
3777 let pid_file =
3778 PathBuf::from(std::env::var(SHELL_DESCENDANT_PID_FILE_ENV).expect("grandchild pid file"));
3779 std::fs::write(pid_file, std::process::id().to_string()).expect("write grandchild pid");
3780 std::thread::sleep(Duration::from_secs(30));
3781 }
3782
3783 /// Required regression: a foreground command that ignores SIGTERM must be
3784 /// dead and the tool must have returned within timeout + a small grace
3785 /// (2s timeout, assert wall < 10s).
3786 #[cfg(unix)]
3787 #[tokio::test]
3788 async fn foreground_timeout_kills_sigterm_ignoring_command_within_grace() {
3789 let tmp = tempdir().expect("tempdir");
3790 let pid_file = tmp.path().join("sigterm-helper.pid");
3791 let test_binary = std::env::current_exe().expect("current test binary");
3792 let command = format!(
3793 "{SHELL_SIGTERM_HELPER_ENV}=1 {SHELL_DESCENDANT_PID_FILE_ENV}={} exec {} --exact {} --nocapture",
3794 shell_words::quote(&pid_file.display().to_string()),
3795 shell_words::quote(&test_binary.display().to_string()),
3796 shell_words::quote("tools::shell::tests::shell_sigterm_ignoring_helper_process"),
3797 );
3798 let ctx = ToolContext::new(tmp.path());
3799
3800 let started = Instant::now();
3801 let result = BashTool::new("Bash")
3802 .execute(json!({"command": command, "timeout_ms": 2_000}), &ctx)
3803 .await
3804 .expect("execute");
3805 let wall = started.elapsed();
3806
3807 assert!(!result.success);
3808 let meta = result.metadata.expect("metadata");
3809 assert_eq!(meta.get("status").and_then(Value::as_str), Some("TimedOut"));
3810 assert!(
3811 wall < Duration::from_secs(10),
3812 "kill path overshot the 2s timeout: wall {wall:?}"
3813 );
3814 let helper_pid = wait_for_shell_pid_file(&pid_file);
3815 assert!(
3816 wait_for_shell_pid_exit(helper_pid),
3817 "SIGTERM-ignoring helper {helper_pid} survived the timeout kill"
3818 );
3819 }
3820
3821 /// Regression for the ~180s kill-path overshoot: a descendant that escaped
3822 /// the process group keeps the output pipe open after the group is killed.
3823 /// kill() must still return within a bounded grace instead of blocking on
3824 /// the reader-thread join until the descendant exits on its own.
3825 #[cfg(unix)]
3826 #[tokio::test]
3827 async fn kill_returns_promptly_when_escaped_descendant_holds_pipe_open() {
3828 let tmp = tempdir().expect("tempdir");
3829 let pid_file = tmp.path().join("escaped-grandchild.pid");
3830 let test_binary = std::env::current_exe().expect("current test binary");
3831 let command = format!(
3832 "{SHELL_ESCAPE_HELPER_ENV}=1 {SHELL_DESCENDANT_PID_FILE_ENV}={} {} --exact {} --nocapture & sleep 60",
3833 shell_words::quote(&pid_file.display().to_string()),
3834 shell_words::quote(&test_binary.display().to_string()),
3835 shell_words::quote("tools::shell::tests::shell_group_escape_helper_process"),
3836 );
3837 let mut manager = ShellManager::new(tmp.path().to_path_buf());
3838 let started_bg =
3839 execute_shell(&mut manager, &command, None, 600_000, true).expect("start wrapper");
3840 let task_id = started_bg.task_id.expect("task id");
3841 let grandchild = wait_for_shell_pid_file(&pid_file);
3842
3843 let started = Instant::now();
3844 let killed = manager.kill(&task_id).expect("kill");
3845 let wall = started.elapsed();
3846
3847 assert_eq!(killed.status, ShellStatus::Killed);
3848 assert!(
3849 wall < Duration::from_secs(10),
3850 "kill blocked {wall:?} on a reader wedged by an escaped descendant"
3851 );
3852
3853 // Cleanup: the escaped grandchild is out of reach of the group kill by
3854 // construction; reap it directly so the test does not leak a sleeper.
3855 unsafe {
3856 libc::kill(grandchild, libc::SIGKILL);
3857 }
3858 assert!(wait_for_shell_pid_exit(grandchild));
3859 }
3860
3861 /// `Bash` was the only action wrapper whose catch-all fell through to its most
3862 /// dangerous branch: an unrecognised action ran the command instead.
3863 #[tokio::test]
3864 async fn unknown_bash_action_is_refused_instead_of_running_the_command() {
3865 let workspace = tempdir().expect("workspace");
3866 let context = ToolContext::new(workspace.path().to_path_buf());
3867 let marker = workspace.path().join("should-not-exist");
3868
3869 let error = BashTool::new("Bash")
3870 .execute(
3871 json!({
3872 "action": "kill",
3873 "command": format!("touch {}", marker.display()),
3874 }),
3875 &context,
3876 )
3877 .await
3878 .expect_err("unknown action must be refused");
3879
3880 let message = error.to_string();
3881 assert!(message.contains("Unknown Bash action"), "{message}");
3882 assert!(message.contains("kill"), "{message}");
3883 assert!(
3884 message.contains("run, wait, interact, cancel"),
3885 "must name the actions that dispatch: {message}"
3886 );
3887 assert!(!marker.exists(), "the command must not have run");
3888 }
3889
3890 /// A NUL byte cannot cross the `exec` boundary: `Command` panics on it.
3891 /// Refuse with the byte offset before anything spawns (#5529).
3892 #[tokio::test]
3893 async fn nul_byte_in_shell_command_is_refused_before_spawn() {
3894 let workspace = tempdir().expect("workspace");
3895 let context = ToolContext::new(workspace.path().to_path_buf());
3896 let marker = workspace.path().join("should-not-exist");
3897
3898 let error = BashTool::new("Bash")
3899 .execute(
3900 json!({
3901 "command": format!("echo hi\0; touch {}", marker.display()),
3902 }),
3903 &context,
3904 )
3905 .await
3906 .expect_err("NUL byte must be refused");
3907
3908 let message = error.to_string();
3909 assert!(message.contains("NUL byte"), "{message}");
3910 assert!(message.contains("byte offset 7"), "{message}");
3911 assert!(!marker.exists(), "the command must not have run");
3912 }
3913
3914 /// `cwd` crosses the same boundary via `current_dir`, so the guard covers it
3915 /// too (#5529).
3916 #[tokio::test]
3917 async fn nul_byte_in_shell_cwd_is_refused_before_spawn() {
3918 let workspace = tempdir().expect("workspace");
3919 let context = ToolContext::new(workspace.path().to_path_buf());
3920
3921 let error = BashTool::new("Bash")
3922 .execute(
3923 json!({
3924 "command": "echo hi",
3925 "cwd": "sub\0dir",
3926 }),
3927 &context,
3928 )
3929 .await
3930 .expect_err("NUL byte must be refused");
3931
3932 let message = error.to_string();
3933 assert!(message.contains("NUL byte"), "{message}");
3934 assert!(message.contains("cwd"), "{message}");
3935 assert!(message.contains("byte offset 3"), "{message}");
3936 }
3937
3938 /// The same hole one type down. `and_then(as_str).unwrap_or("run")` read a
3939 /// non-string `action` as absent and fell through to the branch that executes
3940 /// arbitrary code, so `Bash{action: 3, command: "…"}` ran the command. `File`,
3941 /// `Git`, `Web`, and `Run` all refuse a non-string action; the tool that runs
3942 /// shell commands must not be the lenient one.
3943 #[tokio::test]
3944 async fn non_string_bash_action_is_refused_instead_of_running_the_command() {
3945 let workspace = tempdir().expect("workspace");
3946 let context = ToolContext::new(workspace.path().to_path_buf());
3947
3948 for action in [json!(3), json!(true), json!(["run"]), json!({"run": true})] {
3949 let marker = workspace.path().join(format!("marker-{action}"));
3950 let error = BashTool::new("Bash")
3951 .execute(
3952 json!({
3953 "action": action,
3954 "command": format!("touch {}", marker.display()),
3955 }),
3956 &context,
3957 )
3958 .await
3959 .expect_err("a non-string action must be refused");
3960
3961 let message = error.to_string();
3962 assert!(
3963 message.contains("'action'"),
3964 "must name the parameter: {message}"
3965 );
3966 assert!(
3967 message.contains("must be a string"),
3968 "must name the expected type: {message}"
3969 );
3970 assert!(!marker.exists(), "the command must not have run: {action}");
3971 }
3972 }
3973
3974 /// The same hole for the data fields (2026-08-04 review). A non-string
3975 /// `stdin` was silently dropped — the command ran with NO stdin and reported
3976 /// success, the silent-drop failure this lane exists to close. A non-string
3977 /// `cwd` silently ran in the workspace default. And a numeric `task_id` was
3978 /// reported as "missing", steering the model's retry the wrong way.
3979 #[tokio::test]
3980 async fn wrongly_typed_stdin_cwd_and_task_id_are_refused_not_dropped() {
3981 let workspace = tempdir().expect("workspace");
3982 let context = ToolContext::new(workspace.path().to_path_buf());
3983
3984 let marker = workspace.path().join("stdin-marker");
3985 let error = BashTool::new("Bash")
3986 .execute(
3987 json!({
3988 "command": format!("touch {}", marker.display()),
3989 "stdin": 12345,
3990 }),
3991 &context,
3992 )
3993 .await
3994 .expect_err("non-string stdin must be refused, never silently dropped");
3995 let message = error.to_string();
3996 assert!(message.contains("'stdin'"), "names the field: {message}");
3997 assert!(
3998 message.contains("must be a string"),
3999 "names the expected type: {message}"
4000 );
4001 assert!(!marker.exists(), "the command must not have run");
4002
4003 let error = BashTool::new("Bash")
4004 .execute(json!({ "command": "pwd", "cwd": 123 }), &context)
4005 .await
4006 .expect_err("non-string cwd must be refused, never defaulted");
4007 assert!(error.to_string().contains("'cwd'"), "{error}");
4008
4009 let error = BashTool::new("Bash")
4010 .execute(json!({ "action": "wait", "task_id": 42 }), &context)
4011 .await
4012 .expect_err("non-string task_id is a type error");
4013 let message = error.to_string();
4014 assert!(
4015 message.contains("'task_id'") && message.contains("must be a string"),
4016 "a supplied-but-mistyped task_id must not read as missing: {message}"
4017 );
4018 }
4019
4020 /// `null` is the wire spelling of absence, and `action` documents a `run`
4021 /// default — so the strictness above must not swallow the default.
4022 #[tokio::test]
4023 async fn absent_or_null_bash_action_still_defaults_to_run() {
4024 let workspace = tempdir().expect("workspace");
4025 let context = ToolContext::new(workspace.path().to_path_buf());
4026
4027 for input in [
4028 json!({"command": "echo defaulted"}),
4029 json!({"action": null, "command": "echo defaulted"}),
4030 ] {
4031 let result = BashTool::new("Bash")
4032 .execute(input.clone(), &context)
4033 .await
4034 .unwrap_or_else(|err| panic!("{input} must still run: {err}"));
4035 assert!(result.success, "{input}: {}", result.content);
4036 assert!(result.content.contains("defaulted"), "{}", result.content);
4037 }
4038 }
4039
4040 /// Negative case for the strictness above: every legitimate action still
4041 /// dispatches to its own handler rather than the action refusal. `wait`,
4042 /// `interact`, and `cancel` are checked by the error they raise *after*
4043 /// dispatch (a missing/unknown task), which only their own handlers produce.
4044 #[tokio::test]
4045 async fn every_valid_bash_action_still_dispatches() {
4046 let workspace = tempdir().expect("workspace");
4047 let context = ToolContext::new(workspace.path().to_path_buf());
4048 let tool = BashTool::new("Bash");
4049
4050 let ran = tool
4051 .execute(
4052 json!({"action": "run", "command": "echo dispatched"}),
4053 &context,
4054 )
4055 .await
4056 .expect("action=run must dispatch");
4057 assert!(ran.success, "{}", ran.content);
4058
4059 for input in [
4060 json!({"action": "wait", "task_id": "no-such-task"}),
4061 json!({"action": "interact", "task_id": "no-such-task", "stdin": "y\n"}),
4062 json!({"action": "cancel", "task_id": "no-such-task"}),
4063 ] {
4064 let outcome = tool.execute(input.clone(), &context).await;
4065 let message = match outcome {
4066 Ok(result) => result.content,
4067 Err(err) => err.to_string(),
4068 };
4069 assert!(
4070 !message.contains("Unknown Bash action") && !message.contains("must be a string"),
4071 "{input} must reach its own handler, got: {message}"
4072 );
4073 }
4074
4075 // `cancel` with `all` needs no task at all and must stay a success.
4076 let cancelled = tool
4077 .execute(json!({"action": "cancel", "all": true}), &context)
4078 .await
4079 .expect("action=cancel all=true must dispatch");
4080 assert!(cancelled.success, "{}", cancelled.content);
4081 }
4082
4083 /// The stdin aliases were real but undocumented: a model that wrote `input`
4084 /// or `data` got them honoured with nothing in the schema saying so, and a
4085 /// maintainer reading the schema would have removed them as dead. Advertise
4086 /// them, and hold every spelling to the same behavior.
4087 #[tokio::test]
4088 async fn every_advertised_stdin_spelling_reaches_the_command() {
4089 let workspace = tempdir().expect("workspace");
4090 let context = ToolContext::new(workspace.path().to_path_buf());
4091 let schema = BashTool::new("Bash").input_schema();
4092
4093 // `cat` is Unix-only; the dispatcher runs PowerShell or `cmd` on Windows,
4094 // where it is either absent or an alias for `Get-Content`, which reads a
4095 // file and not stdin. Ask for this platform's echo-stdin spelling — the
4096 // same helper `test_write_stdin_streams_output` uses.
4097 let echo_stdin = echo_stdin_command();
4098 for spelling in ["stdin", "input", "data"] {
4099 assert!(
4100 schema["properties"][spelling].is_object(),
4101 "`{spelling}` is honoured at runtime and must be advertised"
4102 );
4103 let result = BashTool::new("Bash")
4104 .execute(
4105 json!({"command": echo_stdin, spelling: "PIPED_THROUGH_ALIAS\n"}),
4106 &context,
4107 )
4108 .await
4109 .unwrap_or_else(|err| panic!("`{spelling}` must deliver stdin: {err}"));
4110 assert!(
4111 result.content.contains("PIPED_THROUGH_ALIAS"),
4112 "`{spelling}` did not reach the command: {}",
4113 result.content
4114 );
4115 }
4116
4117 // `id` is the same undocumented shape one parameter over.
4118 assert!(
4119 schema["properties"]["id"].is_object(),
4120 "`id` is accepted for `task_id` at runtime and must be advertised"
4121 );
4122 assert!(
4123 schema["properties"]["task_id"]["description"]
4124 .as_str()
4125 .is_some_and(|text| text.contains("`id`")),
4126 "task_id must name its alias"
4127 );
4128 }
4129
4130 /// The schema declared no `required` key at all, so `Bash{}` — no command, no
4131 /// task — was schema-valid for the tool that runs shell commands. What is
4132 /// required is per-action, so it is spelled as root `anyOf` required groups,
4133 /// the same shape `finance` and `apply_patch` already use.
4134 #[test]
4135 fn bash_schema_declares_what_each_action_requires() {
4136 let schema = BashTool::new("Bash").input_schema();
4137 let groups: Vec<Vec<String>> = schema["anyOf"]
4138 .as_array()
4139 .expect("root anyOf required groups")
4140 .iter()
4141 .map(|group| {
4142 group["required"]
4143 .as_array()
4144 .expect("required group")
4145 .iter()
4146 .map(|name| name.as_str().expect("required name").to_string())
4147 .collect()
4148 })
4149 .collect();
4150
4151 for expected in [["command"], ["task_id"], ["id"], ["all"]] {
4152 assert!(
4153 groups.iter().any(|group| group.as_slice() == expected),
4154 "missing required group {expected:?} in {groups:?}"
4155 );
4156 }
4157 // A required name the same schema does not advertise would be
4158 // unsatisfiable: the model could not learn what to send.
4159 for group in &groups {
4160 for name in group {
4161 assert!(
4162 schema["properties"][name].is_object(),
4163 "`{name}` is required but not advertised"
4164 );
4165 }
4166 }
4167 }
4168
4169 /// A root `anyOf` is not portable to every provider, so prove the fallback
4170 /// the sanitizer promises: Responses/xAI drop root composition, and the
4171 /// constraint has to survive as a description note rather than vanishing.
4172 #[test]
4173 fn bash_required_groups_survive_a_provider_that_drops_root_composition() {
4174 let mut schema = BashTool::new("Bash").input_schema();
4175 let note = crate::tools::schema_sanitize::sanitize_for_responses(&mut schema)
4176 .expect("dropped required groups must be restated for the model");
4177
4178 assert!(note.contains("At least one"), "{note}");
4179 for name in ["`command`", "`task_id`", "`id`", "`all`"] {
4180 assert!(note.contains(name), "note must name {name}: {note}");
4181 }
4182 assert_eq!(schema["type"], "object");
4183 assert!(schema.get("anyOf").is_none(), "root anyOf must be removed");
4184 assert!(schema["properties"]["command"].is_object());
4185 }
4186
4187 /// Every hint in this file has to name a tool the model can actually call.
4188 /// `exec_shell` / `exec_shell_wait` were retired in v0.9.3.
4189 #[test]
4190 fn shell_recovery_hints_name_only_dispatchable_tools() {
4191 assert!(!FOREGROUND_TIMEOUT_RECOVERY_HINT.contains("exec_shell"));
4192 assert!(FOREGROUND_TIMEOUT_RECOVERY_HINT.contains("Bash"));
4193 assert!(FOREGROUND_TIMEOUT_RECOVERY_HINT.contains("action=\"wait\""));
4194 }
4195
4196 /// One documented default hid three real ones: `wait` uses 30s and
4197 /// `interact` 1s, so a model omitting `timeout_ms` on `wait` got a quarter of
4198 /// the timeout the schema promised.
4199 #[test]
4200 fn timeout_ms_description_covers_every_action_default() {
4201 let schema = BashTool::new("Bash").input_schema();
4202 let description = schema["properties"]["timeout_ms"]["description"]
4203 .as_str()
4204 .expect("timeout_ms description");
4205
4206 for expected in ["120000", "600000", "30000", "1000"] {
4207 assert!(
4208 description.contains(expected),
4209 "missing {expected}: {description}"
4210 );
4211 }
4212 }
4213
4214 #[cfg(unix)]
4215 fn authorized_persistent_service_context(workspace: &Path) -> ToolContext {
4216 let mut context = ToolContext::new(workspace.to_path_buf())
4217 .with_elevated_sandbox_policy(ExecutionSandboxPolicy::DangerFullAccess);
4218 context.persist_services_enabled = true;
4219 context.tool_authority = None;
4220 context.shell_policy = ShellPolicy::Full;
4221 context.auto_approve = true;
4222 context
4223 }
4224
4225 #[cfg(unix)]
4226 fn persistent_service_test_lock() -> &'static tokio::sync::Mutex<()> {
4227 static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
4228 LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
4229 }
4230
4231 #[cfg(unix)]
4232 #[tokio::test]
4233 async fn persistent_service_requires_explicit_headless_exec_authority() {
4234 let workspace = tempdir().expect("workspace");
4235 let context = ToolContext::new(workspace.path().to_path_buf());
4236
4237 let error = BashTool::new("Bash")
4238 .execute(
4239 json!({
4240 "action": "run",
4241 "command": "sleep 30",
4242 "background": true,
4243 "persist": true,
4244 }),
4245 &context,
4246 )
4247 .await
4248 .expect_err("ordinary tool contexts must reject ownership transfer");
4249
4250 assert!(error.to_string().contains("real headless `codewhale exec`"));
4251 }
4252
4253 #[cfg(unix)]
4254 #[tokio::test]
4255 async fn committed_persistent_service_survives_manager_drop_and_reports_identity() {
4256 let _guard = persistent_service_test_lock().lock().await;
4257 let workspace = tempdir().expect("workspace");
4258 let marker = workspace.path().join("persistent-service-finished");
4259 let context = authorized_persistent_service_context(workspace.path());
4260
4261 let result = BashTool::new("Bash")
4262 .execute(
4263 json!({
4264 "action": "run",
4265 "command": format!("sleep 1; printf released > '{}'", marker.display()),
4266 "background": true,
4267 "persist": true,
4268 }),
4269 &context,
4270 )
4271 .await
4272 .expect("stage persistent service");
4273 assert!(result.success, "{result:?}");
4274 assert_eq!(
4275 result
4276 .metadata
4277 .as_ref()
4278 .and_then(|metadata| metadata["ownership"].as_str()),
4279 Some("managed_pending_exec_success")
4280 );
4281 let task_id = result.metadata.as_ref().unwrap()["task_id"]
4282 .as_str()
4283 .expect("task id")
4284 .to_string();
4285
4286 let receipts = context
4287 .shell_manager
4288 .lock()
4289 .expect("shell manager")
4290 .commit_persistent_services()
4291 .expect("commit persistent service");
4292 assert_eq!(receipts.len(), 1);
4293 assert_eq!(receipts[0].task_id, task_id);
4294 assert_eq!(receipts[0].process_group_id, receipts[0].pid);
4295 assert_eq!(receipts[0].ownership, "external");
4296
4297 drop(context);
4298 let deadline = Instant::now() + Duration::from_secs(5);
4299 while !marker.exists() && Instant::now() < deadline {
4300 std::thread::sleep(Duration::from_millis(25));
4301 }
4302 assert!(
4303 marker.exists(),
4304 "released service must survive Codewhale manager teardown"
4305 );
4306 }
4307
4308 #[cfg(unix)]
4309 #[tokio::test]
4310 async fn signal_cleanup_kills_staged_persistent_service_group() {
4311 let _guard = persistent_service_test_lock().lock().await;
4312 let workspace = tempdir().expect("workspace");
4313 let context = authorized_persistent_service_context(workspace.path());
4314
4315 let result = BashTool::new("Bash")
4316 .execute(
4317 json!({
4318 "action": "run",
4319 "command": "sleep 30",
4320 "background": true,
4321 "persist": true,
4322 }),
4323 &context,
4324 )
4325 .await
4326 .expect("stage persistent service");
4327 let task_id = result.metadata.as_ref().unwrap()["task_id"]
4328 .as_str()
4329 .expect("task id")
4330 .to_string();
4331 let pid = context
4332 .shell_manager
4333 .lock()
4334 .expect("shell manager")
4335 .processes[&task_id]
4336 .child
4337 .as_ref()
4338 .and_then(ShellChild::process_id)
4339 .expect("persistent process id");
4340
4341 abort_pending_persistent_process_groups_for_exit();
4342 let pid = i32::try_from(pid).expect("pid fits pid_t");
4343 let deadline = Instant::now() + Duration::from_secs(2);
4344 let status = loop {
4345 let mut status = 0;
4346 // SAFETY: `pid` is the direct child owned by this test's manager; the
4347 // nonblocking wait only reaps that exact process.
4348 let waited = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) };
4349 if waited == pid {
4350 break status;
4351 }
4352 assert!(
4353 Instant::now() < deadline,
4354 "signal cleanup must kill the staged service process group"
4355 );
4356 std::thread::sleep(Duration::from_millis(25));
4357 };
4358 assert!(libc::WIFSIGNALED(status));
4359 assert_eq!(libc::WTERMSIG(status), libc::SIGKILL);
4360 }
4361
4362 // === #5472: in-memory retention must be bounded ===
4363
4364 /// ~1.1 MB on stdout, fast: 30,000 lines of 37 bytes.
4365 #[cfg(unix)]
4366 fn chatty_command() -> String {
4367 "yes 0123456789abcdefghijklmnopqrstuvwxyz | head -n 30000".to_string()
4368 }
4369
4370 /// The finding-1 regression: before this bound, a single uppercase `Bash` call
4371 /// left its entire stdout resident in `ShellManager.processes` until the 1 h
4372 /// `cleanup`, and only if the user happened to open the jobs panel.
4373 #[cfg(unix)]
4374 #[tokio::test]
4375 async fn foreground_bash_releases_its_output_once_the_result_is_returned() {
4376 let tmp = tempdir().expect("tempdir");
4377 let ctx = ToolContext::new(tmp.path());
4378 let result = BashTool::new("Bash")
4379 .execute(json!({"command": chatty_command()}), &ctx)
4380 .await
4381 .expect("run chatty foreground command");
4382
4383 // The result itself is unaffected: still the same 30 KB truncation.
4384 assert!(
4385 result
4386 .content
4387 .contains("0123456789abcdefghijklmnopqrstuvwxyz")
4388 );
4389
4390 let manager = ctx.shell_manager.lock().expect("shell manager");
4391 let retained = manager.retained_output_bytes_total();
4392 assert!(
4393 retained <= RAW_STREAM_SETTLED_TAIL_BYTES * 2,
4394 "a finished foreground call must not keep its full stdout resident: \
4395 {retained} bytes still held (bound {})",
4396 RAW_STREAM_SETTLED_TAIL_BYTES * 2
4397 );
4398 }
4399
4400 /// Releasing memory must not rewrite history: the job panel keeps reporting how
4401 /// much the command actually printed.
4402 #[cfg(unix)]
4403 #[tokio::test]
4404 async fn released_output_still_reports_the_real_stream_length() {
4405 let tmp = tempdir().expect("tempdir");
4406 let ctx = ToolContext::new(tmp.path());
4407 BashTool::new("Bash")
4408 .execute(json!({"command": chatty_command()}), &ctx)
4409 .await
4410 .expect("run chatty foreground command");
4411
4412 let mut manager = ctx.shell_manager.lock().expect("shell manager");
4413 let jobs = manager.list_jobs();
4414 let job = jobs.first().expect("the finished job is still listed");
4415 assert!(
4416 job.stdout_len >= 1_000_000,
4417 "stdout_len must stay honest after release, got {}",
4418 job.stdout_len
4419 );
4420 assert!(
4421 !job.stdout_tail.is_empty(),
4422 "a diagnostic tail must survive the release"
4423 );
4424 }
4425
4426 /// A background job's bytes become a durable session artifact at drain time;
4427 /// keeping a second copy in the manager afterwards is the pure-waste term.
4428 #[cfg(unix)]
4429 #[tokio::test]
4430 async fn draining_completion_evidence_releases_the_retained_copy() {
4431 let tmp = tempdir().expect("tempdir");
4432 let ctx = ToolContext::new(tmp.path());
4433 let started = BashTool::new("Bash")
4434 .execute(
4435 json!({"command": chatty_command(), "background": true}),
4436 &ctx,
4437 )
4438 .await
4439 .expect("start background");
4440 let task_id = started
4441 .metadata
4442 .as_ref()
4443 .and_then(|metadata| metadata.get("task_id"))
4444 .and_then(Value::as_str)
4445 .expect("task id")
4446 .to_string();
4447
4448 let mut manager = ctx.shell_manager.lock().expect("shell manager");
4449 let completed = wait_for_completed_shell(&mut manager, &task_id);
4450 assert_ne!(completed.status, ShellStatus::Running);
4451
4452 let evidence = manager.drain_finished_jobs_with_evidence();
4453 assert_eq!(evidence.len(), 1);
4454 assert!(
4455 evidence[0].event.stdout_len >= 1_000_000,
4456 "the event still reports the full length"
4457 );
4458 let payload: serde_json::Value =
4459 serde_json::from_slice(&evidence[0].artifact_bytes()).expect("evidence JSON");
4460 assert!(
4461 payload["stdout"]["content"]
4462 .as_str()
4463 .expect("stdout content")
4464 .len()
4465 >= 1_000_000,
4466 "the artifact carries the exact bytes; only the manager's copy is dropped"
4467 );
4468
4469 let retained = manager.retained_output_bytes_total();
4470 assert!(
4471 retained <= RAW_STREAM_SETTLED_TAIL_BYTES * 2,
4472 "{retained} bytes still held after the evidence was published"
4473 );
4474 }
4475
4476 /// Age was the only bound, and it only ran from `list_jobs()`. Hundreds of
4477 /// finished records inside one hour were all retained.
4478 #[test]
4479 fn cleanup_bounds_finished_records_by_count() {
4480 let tmp = tempdir().expect("tempdir");
4481 let mut manager = ShellManager::new(tmp.path().to_path_buf());
4482 let seeded_count = MAX_FINISHED_SHELL_RECORDS + 40;
4483 for index in 0..seeded_count {
4484 // Give every fixture a deterministic ordering while keeping all of
4485 // them far younger than the age ceiling. Lower ids are older.
4486 manager.seed_finished_record_for_test(
4487 format!("record-{index}"),
4488 Duration::from_millis((seeded_count - index) as u64),
4489 );
4490 }
4491 manager.cleanup(FINISHED_SHELL_MAX_AGE);
4492 assert_eq!(manager.tracked_job_count(), MAX_FINISHED_SHELL_RECORDS);
4493 assert!(
4494 manager.inspect_job("record-39").is_err(),
4495 "the oldest overflow record must be evicted"
4496 );
4497 assert!(
4498 manager.inspect_job("record-40").is_ok(),
4499 "the first record inside the cap must survive"
4500 );
4501 assert!(
4502 manager
4503 .inspect_job(&format!("record-{}", seeded_count - 1))
4504 .is_ok(),
4505 "the newest record must survive"
4506 );
4507 }
4508
4509 /// #5478 nit: `/jobs` reported `2m 07s` for a 12-second command, because
4510 /// elapsed was `started_at.elapsed()` even after the job finished. A completed
4511 /// job reports the duration it finished with.
4512 #[test]
4513 fn a_finished_job_reports_its_duration_not_a_growing_elapsed() {
4514 let tmp = tempdir().expect("tempdir");
4515 let mut manager = ShellManager::new(tmp.path().to_path_buf());
4516 let result = manager
4517 .execute_with_options_env(
4518 &echo_command("frozen-elapsed"),
4519 None,
4520 10_000,
4521 true,
4522 None,
4523 false,
4524 None,
4525 std::collections::HashMap::new(),
4526 )
4527 .expect("spawn");
4528 let task_id = result.task_id.expect("task id");
4529 let completed = wait_for_completed_shell(&mut manager, &task_id);
4530 assert_ne!(completed.status, ShellStatus::Running);
4531
4532 let first = manager
4533 .list_jobs()
4534 .into_iter()
4535 .find(|job| job.id == task_id)
4536 .expect("job listed")
4537 .elapsed_ms;
4538
4539 std::thread::sleep(Duration::from_millis(400));
4540
4541 let second = manager
4542 .list_jobs()
4543 .into_iter()
4544 .find(|job| job.id == task_id)
4545 .expect("job still listed")
4546 .elapsed_ms;
4547
4548 assert_eq!(
4549 first, second,
4550 "a finished job's elapsed must stop moving; it read {first}ms then {second}ms"
4551 );
4552 assert!(
4553 second < 10_000,
4554 "the frozen value must be the real duration, not the timeout: {second}ms"
4555 );
4556 }
4557
4558 #[cfg(unix)]
4559 #[tokio::test]
4560 async fn readonly_pipeline_preserves_arguments_and_disables_git_helpers() {
4561 const PROBE: &str = "CODEWHALE_TEST_READONLY_PIPELINE_SHELL";
4562 if std::env::var_os(PROBE).is_none() {
4563 // The dispatcher is process-pinned. Exercise both the supported shell
4564 // and the fail-closed POSIX fallback without inheriting the CI shell.
4565 for shell in ["/bin/bash", "/bin/sh"] {
4566 let output = std::process::Command::new(std::env::current_exe().unwrap())
4567 .args([
4568 "--exact",
4569 "tools::shell::tests::readonly_pipeline_preserves_arguments_and_disables_git_helpers",
4570 "--test-threads=1",
4571 ])
4572 .env(PROBE, shell)
4573 .env("SHELL", shell)
4574 .output()
4575 .unwrap();
4576 assert!(
4577 output.status.success(),
4578 "read-only pipeline probe failed ({shell})\n{}\n{}",
4579 String::from_utf8_lossy(&output.stdout),
4580 String::from_utf8_lossy(&output.stderr)
4581 );
4582 }
4583 return;
4584 }
4585 let workspace = tempdir().unwrap();
4586 let outside = tempdir().unwrap();
4587 let sentinel = outside.path().join("secret");
4588 std::fs::write(&sentinel, "private-marker\n").unwrap();
4589 std::os::unix::fs::symlink(&sentinel, workspace.path().join("linked-secret")).unwrap();
4590 std::fs::write(workspace.path().join("input.txt"), "hello\n").unwrap();
4591 for name in ["-i", "-e", "-f", "--output=changed", "-o"] {
4592 std::fs::write(workspace.path().join(name), "option-shaped filename\n").unwrap();
4593 }
4594 let ctx = ToolContext::new(workspace.path())
4595 .with_shell_policy(crate::worker_profile::ShellPolicy::ReadOnly);
4596 let tool = BashTool::new("Bash");
4597 for command in ["sort * | cat", "sed -n 1p * | cat", "cat * | cat"] {
4598 let result = tool
4599 .execute(json!({"command": command}), &ctx)
4600 .await
4601 .unwrap();
4602 assert!(
4603 !result.success,
4604 "literal wildcard has no matching operand: {command}"
4605 );
4606 assert!(!result.content.contains("private-marker"));
4607 assert_eq!(
4608 std::fs::read_to_string(&sentinel).unwrap(),
4609 "private-marker\n"
4610 );
4611 assert!(!workspace.path().join("changed").exists());
4612 }
4613 let ordinary = tool
4614 .execute(json!({"command": "cat input.txt | wc -l"}), &ctx)
4615 .await
4616 .unwrap();
4617 if std::env::var(PROBE).as_deref() == Ok("/bin/sh") {
4618 assert!(!ordinary.success);
4619 assert!(
4620 ordinary
4621 .content
4622 .contains("read-only pipelines require bash or zsh")
4623 );
4624 return;
4625 }
4626 assert!(ordinary.success, "{}", ordinary.content);
4627 assert!(
4628 tool.execute(json!({"command": "cat linked-secret | cat"}), &ctx)
4629 .await
4630 .is_err()
4631 );
4632 let pipeline = hardened_readonly_pipeline("git show HEAD | cat", workspace.path()).unwrap();
4633 assert!(pipeline.contains("--no-ext-diff"));
4634 assert!(pipeline.contains("--no-textconv"));
4635 assert!(pipeline.contains("--no-show-signature"));
4636 }
4637
4638 #[tokio::test]
4639 async fn readonly_sed_extra_options_never_mutate_files() {
4640 let workspace = tempdir().unwrap();
4641 let source = workspace.path().join("input.txt");
4642 std::fs::write(&source, "first\nsecond\n").unwrap();
4643 let ctx = ToolContext::new(workspace.path())
4644 .with_shell_policy(crate::worker_profile::ShellPolicy::ReadOnly);
4645 for command in [
4646 "sed -n 1p input.txt -i",
4647 "sed -n 1p -i.bak input.txt",
4648 "sed -n 1p -e 1e input.txt",
4649 "sed -n 1p -f script input.txt",
4650 ] {
4651 let result = BashTool::new("Bash")
4652 .execute(json!({"command": command}), &ctx)
4653 .await
4654 .unwrap();
4655 assert!(!result.success, "{command}");
4656 assert!(result.content.contains("read-only shell policy"));
4657 assert_eq!(std::fs::read_to_string(&source).unwrap(), "first\nsecond\n");
4658 }
4659 let result = BashTool::new("Bash")
4660 .execute(json!({"command": "sed -n 1p input.txt"}), &ctx)
4661 .await
4662 .unwrap();
4663 assert!(result.success, "{}", result.content);
4664 }
4665
4666 /// A transiently busy Work-graph must not veto the command.
4667 ///
4668 /// `register_operation` acquires the To-do/Plan locks with a short try-lock
4669 /// spin. Before this guard existed, a shell call landing in that window failed
4670 /// outright with "To-do state is busy; operation was not registered" — observed
4671 /// twice in one live session, each time right after another tool call. The
4672 /// registration is the same bookkeeping whose `observe` half is already
4673 /// best-effort, so a busy state now degrades to an unbound run.
4674 #[tokio::test]
4675 async fn busy_work_graph_degrades_the_spawn_intent_instead_of_failing_it() {
4676 use crate::tools::plan::new_shared_plan_state;
4677 use crate::tools::todo::new_shared_todo_list;
4678 use crate::work_graph::new_shared_work_runtime;
4679
4680 let todos = new_shared_todo_list();
4681 let plan = new_shared_plan_state();
4682 let lifecycle = || ShellWorkLifecycle {
4683 work: new_shared_work_runtime(todos.clone(), plan.clone()),
4684 session_id: "session-test".to_string(),
4685 };
4686
4687 // Control: with the graph free, the intent binds.
4688 let bound = ShellSpawnIntentGuard::new(Some(lifecycle()), "shell_free", "echo hi");
4689 assert!(
4690 bound.lifecycle.is_some(),
4691 "a free work-graph must bind the spawn intent"
4692 );
4693
4694 // Busy: hold the To-do lock so the try-lock spin cannot win.
4695 let _held = todos.lock().await;
4696 // The raw register call still reports the busy state — this is exactly what
4697 // used to propagate out of the spawn path and fail the command.
4698 assert!(
4699 lifecycle()
4700 .register("shell_busy_direct", "echo hi")
4701 .is_err(),
4702 "the raw register call must observe the held lock as busy"
4703 );
4704 let busy = ShellSpawnIntentGuard::new(Some(lifecycle()), "shell_busy", "echo hi");
4705 assert!(
4706 busy.lifecycle.is_none(),
4707 "a busy work-graph must degrade to an unbound guard, not fail the spawn"
4708 );
4709 }
4710
4711 #[test]
4712 fn pty_dimensions_reject_zero_and_unbounded_grid() {
4713 assert_eq!(
4714 PtyDimensions::default(),
4715 PtyDimensions { rows: 24, cols: 80 }
4716 );
4717 for size in [
4718 PtyDimensions { rows: 0, cols: 80 },
4719 PtyDimensions { rows: 24, cols: 0 },
4720 PtyDimensions {
4721 rows: 1001,
4722 cols: 80,
4723 },
4724 PtyDimensions {
4725 rows: 24,
4726 cols: u16::MAX,
4727 },
4728 ] {
4729 assert!(size.validate().is_err());
4730 }
4731 assert!(
4732 PtyDimensions {
4733 rows: 1000,
4734 cols: 1000
4735 }
4736 .validate()
4737 .is_ok()
4738 );
4739 }
4740
4741 #[test]
4742 #[cfg(not(target_env = "ohos"))]
4743 fn pty_stdin_preserves_bytes_and_reports_flush_failure() {
4744 struct FlushFailure(Arc<Mutex<Vec<u8>>>);
4745 impl Write for FlushFailure {
4746 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
4747 self.0.lock().unwrap().extend_from_slice(bytes);
4748 Ok(bytes.len())
4749 }
4750 fn flush(&mut self) -> io::Result<()> {
4751 Err(io::Error::new(
4752 io::ErrorKind::BrokenPipe,
4753 "fixture flush failure",
4754 ))
4755 }
4756 }
4757 let tmp = tempdir().unwrap();
4758 let mut manager = ShellManager::new(tmp.path().to_path_buf());
4759 manager.seed_finished_record_for_test("input-fixture", Duration::ZERO);
4760 let bytes = Arc::new(Mutex::new(Vec::new()));
4761 manager.processes.get_mut("input-fixture").unwrap().stdin =
4762 Some(StdinWriter::Pty(Box::new(FlushFailure(bytes.clone()))));
4763 let input = b"\0\xff\x1b[A\x03";
4764 let error = manager
4765 .write_stdin_bytes("input-fixture", input, false)
4766 .unwrap_err();
4767 assert!(error.to_string().contains("flush"));
4768 assert_eq!(bytes.lock().unwrap().as_slice(), input);
4769 }
4770
4771 #[test]
4772 #[cfg(all(unix, not(target_env = "ohos")))]
4773 fn pty_resize_updates_live_terminal_and_rejects_finished_or_pipe_jobs() {
4774 let tmp = tempdir().unwrap();
4775 let mut manager = ShellManager::new(tmp.path().to_path_buf());
4776 let launched = manager
4777 .execute_with_options_env(
4778 "stty -echo; printf ready; while IFS= read -r line; do stty size; done",
4779 None,
4780 5000,
4781 true,
4782 None,
4783 true,
4784 Some(ExecutionSandboxPolicy::DangerFullAccess),
4785 HashMap::new(),
4786 )
4787 .unwrap();
4788 let id = launched.task_id.unwrap();
4789 assert_eq!(
4790 manager.job_terminal_size(&id),
4791 Some(PtyDimensions::default())
4792 );
4793 let deadline = Instant::now() + Duration::from_secs(5);
4794 loop {
4795 let chunk = manager
4796 .read_output_chunk(&id, ShellOutputStream::Stdout, 0, 4096, 50)
4797 .unwrap();
4798 if chunk.bytes.windows(5).any(|bytes| bytes == b"ready") {
4799 break;
4800 }
4801 assert!(Instant::now() < deadline, "PTY did not become ready");
4802 }
4803 let size = PtyDimensions {
4804 rows: 37,
4805 cols: 111,
4806 };
4807 manager.resize_pty(&id, size).unwrap();
4808 assert_eq!(manager.job_terminal_size(&id), Some(size));
4809 assert!(
4810 manager
4811 .resize_pty(&id, PtyDimensions { rows: 0, cols: 1 })
4812 .is_err()
4813 );
4814 assert_eq!(manager.job_terminal_size(&id), Some(size));
4815 manager.write_stdin_bytes(&id, b"size\n", false).unwrap();
4816 loop {
4817 let chunk = manager
4818 .read_output_chunk(&id, ShellOutputStream::Stdout, 0, 4096, 0)
4819 .unwrap();
4820 if String::from_utf8_lossy(&chunk.bytes).contains("37 111") {
4821 break;
4822 }
4823 assert!(
4824 Instant::now() < deadline,
4825 "actual terminal size did not change"
4826 );
4827 std::thread::sleep(Duration::from_millis(20));
4828 }
4829 manager.kill(&id).unwrap();
4830 assert!(manager.resize_pty(&id, size).is_err());
4831 assert!(manager.processes[&id].pty_master.is_none());
4832 let pipe = manager
4833 .execute_with_options_env(
4834 "cat",
4835 None,
4836 5000,
4837 true,
4838 None,
4839 false,
4840 Some(ExecutionSandboxPolicy::DangerFullAccess),
4841 HashMap::new(),
4842 )
4843 .unwrap()
4844 .task_id
4845 .unwrap();
4846 assert!(manager.resize_pty(&pipe, size).is_err());
4847 manager.kill(&pipe).unwrap();
4848 }
4849
4850 #[test]
4851 #[cfg(all(unix, not(target_env = "ohos")))]
4852 fn pty_raw_stdin_roundtrips_nul_and_non_utf8_bytes() {
4853 let tmp = tempdir().unwrap();
4854 let mut manager = ShellManager::new(tmp.path().to_path_buf());
4855 let input = b"\0\xff\x1b[A\x03\n";
4856 let command = format!(
4857 "stty raw -echo; printf ready; dd bs=1 count={} 2>/dev/null",
4858 input.len()
4859 );
4860 let launched = manager
4861 .execute_with_options_env(
4862 &command,
4863 None,
4864 5000,
4865 true,
4866 None,
4867 true,
4868 Some(ExecutionSandboxPolicy::DangerFullAccess),
4869 HashMap::new(),
4870 )
4871 .unwrap();
4872 let id = launched.task_id.unwrap();
4873 let deadline = Instant::now() + Duration::from_secs(5);
4874 loop {
4875 let chunk = manager
4876 .read_output_chunk(&id, ShellOutputStream::Stdout, 0, 4096, 0)
4877 .unwrap();
4878 if chunk.bytes == b"ready" {
4879 break;
4880 }
4881 assert!(Instant::now() < deadline, "raw PTY did not become ready");
4882 std::thread::sleep(Duration::from_millis(20));
4883 }
4884 manager.write_stdin_bytes(&id, input, false).unwrap();
4885 loop {
4886 let chunk = manager
4887 .read_output_chunk(&id, ShellOutputStream::Stdout, 5, 4096, 0)
4888 .unwrap();
4889 if chunk.status != ShellStatus::Running {
4890 assert_eq!(chunk.bytes, input);
4891 assert_eq!(chunk.next_offset, 5 + input.len());
4892 break;
4893 }
4894 assert!(Instant::now() < deadline, "raw PTY did not finish");
4895 std::thread::sleep(Duration::from_millis(20));
4896 }
4897 }
4898
4898 lines RUST