1use super::truncate::{self, TruncationOptions, TruncationResult};
11use super::{AgentTool, AgentToolResult, ProgressCallback, ToolContext, ToolError};
12use async_trait::async_trait;
13use serde_json::{json, Value};
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17use tokio::io::AsyncReadExt;
18use tokio::process::Command;
19use tokio::sync::oneshot;
20
21const BLOCKED_ENV_VARS: &[&str] = &[
24 "LD_PRELOAD",
25 "LD_LIBRARY_PATH",
26 "DYLD_INSERT_LIBRARIES",
27 "DYLD_LIBRARY_PATH",
28 "DYLD_FRAMEWORK_PATH",
29 "PATH",
30 "HOME",
31 "IFS",
32 "SHELL",
33 "USER",
34 "LOGNAME",
35 "PYTHONPATH",
36 "NODE_PATH",
37 "RUBYLIB",
38 "PERL5LIB",
39 "CLASSPATH",
40 "JAVA_TOOL_OPTIONS",
41 "MallocNanoZone",
42 "MallocSpaceEfficient",
43];
44
45fn is_dangerous_command(command: &str) -> Option<String> {
49 let cmd_lower = command.to_lowercase();
50 let mut warnings: Vec<String> = Vec::new();
51
52 if cmd_lower.contains("| sh") || cmd_lower.contains("| bash") || cmd_lower.contains("| zsh") {
54 warnings.push("pipe to shell".to_string());
55 }
56
57 if command.contains("/etc/passwd") || command.contains("/etc/shadow") {
59 warnings.push("access to sensitive authentication files".to_string());
60 }
61 if command.contains("id_rsa") || command.contains("id_ed25519") || command.contains(".ssh/") {
62 warnings.push("access to SSH private keys/directory".to_string());
63 }
64
65 if (cmd_lower.contains("curl") || cmd_lower.contains("wget")) && cmd_lower.contains("| nc") {
67 warnings.push("possible network exfiltration (pipe to netcat)".to_string());
68 }
69 if command.contains("/dev/tcp/") || command.contains("/dev/udp/") {
70 warnings.push("possible network exfiltration via /dev/tcp|udp".to_string());
71 }
72
73 if cmd_lower.starts_with("sudo ")
75 || cmd_lower.contains("\nsudo ")
76 || cmd_lower.contains("&&sudo ")
77 {
78 warnings.push("sudo detected (privilege escalation)".to_string());
79 }
80 if cmd_lower.contains("su -") || cmd_lower.contains("su root") {
81 warnings.push("user switch to privileged account".to_string());
82 }
83
84 if cmd_lower.contains(":(){ :|:& };") || cmd_lower.contains("fork bomb") {
86 warnings.push("fork bomb pattern detected".to_string());
87 }
88 if command.contains(":(){") && command.contains(":|:&") {
90 warnings.push("fork bomb pattern detected".to_string());
91 }
92
93 let system_write_patterns: &[(&str, &str)] = &[
95 ("> /etc/", "/etc/"),
96 (">> /etc/", "/etc/"),
97 ("> /boot/", "/boot/"),
98 (">> /boot/", "/boot/"),
99 ("> /sys/", "/sys/"),
100 (">> /sys/", "/sys/"),
101 ("> /proc/", "/proc/"),
102 (">> /proc/", "/proc/"),
103 ];
104 for (pattern, dir) in system_write_patterns {
105 if cmd_lower.contains(pattern) {
106 warnings.push(format!("write to system directory {}", dir));
107 break;
108 }
109 }
110
111 if warnings.is_empty() {
112 None
113 } else {
114 Some(format!(
115 "⚠️ SECURITY WARNING: {}",
116 warnings
117 .iter()
118 .map(|s| s.as_str())
119 .collect::<Vec<_>>()
120 .join(", ")
121 ))
122 }
123}
124
125fn validate_cwd(dir: &str, workspace: Option<&Path>) -> Result<PathBuf, String> {
128 let path = Path::new(dir);
129
130 if path.components().any(|c| c.as_os_str() == "..") {
132 return Err("Path traversal (..) not allowed in working directory".to_string());
133 }
134
135 if !path.exists() {
136 return Err(format!("Working directory does not exist: {}", dir));
137 }
138
139 if let Some(workspace_root) = workspace {
141 let canonical_cwd = path
143 .canonicalize()
144 .map_err(|e| format!("Failed to resolve working directory: {}", e))?;
145 let canonical_workspace = workspace_root
146 .canonicalize()
147 .map_err(|e| format!("Failed to resolve workspace directory: {}", e))?;
148
149 if !canonical_cwd.starts_with(&canonical_workspace) {
150 return Err(format!(
151 "Working directory '{}' is outside the allowed workspace '{}'",
152 canonical_cwd.display(),
153 canonical_workspace.display()
154 ));
155 }
156
157 return Ok(canonical_cwd);
158 }
159
160 Ok(path.to_path_buf())
162}
163
164const DEFAULT_TIMEOUT_SECS: u64 = 120;
166
167pub struct BashTool {
169 root_dir: Option<PathBuf>,
170 progress_callback: Arc<std::sync::Mutex<Option<ProgressCallback>>>,
171}
172
173impl BashTool {
174 pub fn new() -> Self {
176 Self {
177 root_dir: None,
178 progress_callback: Arc::new(std::sync::Mutex::new(None)),
179 }
180 }
181
182 pub fn with_cwd(cwd: PathBuf) -> Self {
184 Self {
185 root_dir: Some(cwd),
186 progress_callback: Arc::new(std::sync::Mutex::new(None)),
187 }
188 }
189
190 fn format_duration(duration: Duration) -> String {
192 let secs = duration.as_secs();
193 let millis = duration.subsec_millis();
194 if secs >= 60 {
195 let mins = secs / 60;
196 let remain_secs = secs % 60;
197 format!(
198 "{}m {:.1}s",
199 mins,
200 remain_secs as f64 + millis as f64 / 1000.0
201 )
202 } else {
203 format!("{:.1}s", secs as f64 + millis as f64 / 1000.0)
204 }
205 }
206
207 fn build_output(
209 truncation: &TruncationResult,
210 elapsed: Duration,
211 exit_code: Option<i32>,
212 ) -> String {
213 let mut output = truncation.content.clone();
214
215 if truncation.truncated {
217 let notice = match truncation.truncated_by {
218 truncate::TruncatedBy::Lines => format!(
219 "\n\n[Truncated: showing {} of {} lines. {} bytes remaining]",
220 truncation.output_lines,
221 truncation.total_lines,
222 truncate::format_bytes(
223 truncation
224 .total_bytes
225 .saturating_sub(truncation.output_bytes)
226 )
227 ),
228 truncate::TruncatedBy::Bytes => format!(
229 "\n\n[Truncated: {} lines shown ({} byte limit). Total was {} lines, {}]",
230 truncation.output_lines,
231 truncate::format_bytes(truncate::DEFAULT_MAX_BYTES),
232 truncation.total_lines,
233 truncate::format_bytes(truncation.total_bytes)
234 ),
235 truncate::TruncatedBy::None => String::new(),
236 };
237 output.push_str(¬ice);
238 }
239
240 if let Some(code) = exit_code {
242 if code != 0 {
243 output.push_str(&format!("\n\nCommand exited with code {}", code));
244 }
245 }
246
247 output.push_str(&format!("\n\nTook {}", Self::format_duration(elapsed)));
249
250 output
251 }
252
253 async fn wait_with_timeout_and_signal(
255 child: &mut tokio::process::Child,
256 timeout: u64,
257 signal: &mut Option<oneshot::Receiver<()>>,
258 ) -> Result<std::process::ExitStatus, String> {
259 let timeout_duration = Duration::from_secs(timeout);
260
261 tokio::select! {
262 status = child.wait() => {
263 status.map_err(|e| format!("Failed to wait for process: {}", e))
264 }
265 _ = tokio::time::sleep(timeout_duration) => {
266 Self::kill_process_group(child).await;
267 Err(format!("Command timed out after {} seconds", timeout))
268 }
269 _ = async {
270 match signal {
271 Some(rx) => { let _ = rx.await; }
272 None => std::future::pending::<()>().await,
273 }
274 } => {
275 Self::kill_process_group(child).await;
276 Err("Command aborted".to_string())
277 }
278 }
279 }
280
281 fn build_shell_command(
283 command: &str,
284 work_dir: &Option<String>,
285 env: Option<&serde_json::Map<String, Value>>,
286 ) -> Command {
287 let mut cmd = Command::new("sh");
288 cmd.arg("-c")
289 .arg(command)
290 .stdout(std::process::Stdio::piped())
291 .stderr(std::process::Stdio::piped())
292 .process_group(0);
293
294 if let Some(ref dir) = work_dir {
295 cmd.current_dir(dir);
296 }
297
298 if let Some(env_map) = env {
299 for (key, val) in env_map {
300 if BLOCKED_ENV_VARS
301 .iter()
302 .any(|blocked| blocked.eq_ignore_ascii_case(key))
303 {
304 continue;
305 }
306 if let Some(val_str) = val.as_str() {
307 cmd.env(key, val_str);
308 }
309 }
310 }
311
312 cmd
313 }
314
315 async fn kill_process_group(child: &mut tokio::process::Child) {
317 #[cfg(unix)]
318 {
319 if let Some(pid) = child.id() {
320 let pgid = -(pid as i32);
321 unsafe {
325 libc::kill(pgid, libc::SIGKILL);
326 }
327 }
328 }
329 let _ = child.kill().await;
330 let _ = child.wait().await;
331 }
332
333 fn format_error_output(
335 stdout_str: &str,
336 stderr_str: &str,
337 error_msg: &str,
338 elapsed: Duration,
339 ) -> String {
340 let mut output = String::new();
341 if !stdout_str.is_empty() {
342 output.push_str(stdout_str);
343 }
344 if !stderr_str.is_empty() {
345 if !output.is_empty() {
346 output.push('\n');
347 }
348 output.push_str(stderr_str);
349 }
350
351 if !output.is_empty() {
352 let truncation = truncate::truncate_head(&output, &TruncationOptions::default());
353 output = truncation.content;
354 }
355
356 output.push_str(&format!("\n\n{}", error_msg));
357 output.push_str(&format!("\nTook {}", Self::format_duration(elapsed)));
358 output
359 }
360
361 async fn run_command(
363 root_dir: &Path,
364 command: &str,
365 cwd: Option<&str>,
366 env: Option<&serde_json::Map<String, Value>>,
367 timeout_secs: Option<u64>,
368 progress_cb: &Option<ProgressCallback>,
369 mut signal: Option<oneshot::Receiver<()>>,
370 ) -> Result<AgentToolResult, ToolError> {
371 if let Some(cb) = progress_cb {
372 cb(format!("Executing: {}", command));
373 }
374
375 let timeout = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
376 let start = Instant::now();
377
378 let work_dir = match cwd {
380 Some(dir) if !dir.is_empty() => {
381 let validated = validate_cwd(dir, Some(root_dir))?;
382 Some(validated.to_string_lossy().to_string())
383 }
384 _ => Some(root_dir.to_string_lossy().to_string()),
385 };
386
387 let mut cmd = Self::build_shell_command(command, &work_dir, env);
389
390 let mut child = cmd
392 .spawn()
393 .map_err(|e| format!("Failed to spawn command: {}", e))?;
394
395 let mut stdout_pipe = child
397 .stdout
398 .take()
399 .ok_or_else(|| "Failed to capture stdout".to_string())?;
400 let mut stderr_pipe = child
401 .stderr
402 .take()
403 .ok_or_else(|| "Failed to capture stderr".to_string())?;
404
405 let stdout_handle = tokio::spawn(async move {
407 let mut buf = Vec::new();
408 let _ = stdout_pipe.read_to_end(&mut buf).await;
409 buf
410 });
411 let stderr_handle = tokio::spawn(async move {
412 let mut buf = Vec::new();
413 let _ = stderr_pipe.read_to_end(&mut buf).await;
414 buf
415 });
416
417 let result = Self::wait_with_timeout_and_signal(&mut child, timeout, &mut signal).await;
419
420 let elapsed = start.elapsed();
421
422 let stdout_bytes = stdout_handle.await.unwrap_or_default();
424 let stderr_bytes = stderr_handle.await.unwrap_or_default();
425
426 let stdout_str = String::from_utf8_lossy(&stdout_bytes).to_string();
427 let stderr_str = String::from_utf8_lossy(&stderr_bytes).to_string();
428
429 if let Some(cb) = progress_cb {
430 cb(format!(
431 "Process completed in {}",
432 Self::format_duration(elapsed)
433 ));
434 }
435
436 match result {
437 Ok(status) => {
438 let exit_code = status.code();
439 if let Some(code) = exit_code {
440 if let Some(cb) = progress_cb {
441 cb(format!("Process exited with code {}", code));
442 }
443 }
444 let combined = if stderr_str.is_empty() {
445 stdout_str.clone()
446 } else if stdout_str.is_empty() {
447 stderr_str.clone()
448 } else {
449 format!("{}\n{}", stdout_str, stderr_str)
450 };
451
452 let security_warning = is_dangerous_command(command);
453
454 let truncation = truncate::truncate_head(
455 if combined.is_empty() {
456 "(no output)"
457 } else {
458 &combined
459 },
460 &TruncationOptions::default(),
461 );
462
463 let mut output = Self::build_output(&truncation, elapsed, exit_code);
464
465 if let Some(ref warning) = security_warning {
466 output.push_str(&format!("\n{}", warning));
467 }
468
469 if status.success() {
470 Ok(AgentToolResult::success(output))
471 } else {
472 Ok(AgentToolResult::error(output))
473 }
474 }
475 Err(e) => {
476 let output = Self::format_error_output(&stdout_str, &stderr_str, &e, elapsed);
477 Ok(AgentToolResult::error(output))
478 }
479 }
480 }
481}
482
483impl Default for BashTool {
484 fn default() -> Self {
485 Self::new()
486 }
487}
488
489#[async_trait]
490impl AgentTool for BashTool {
491 fn name(&self) -> &str {
492 "bash"
493 }
494
495 fn label(&self) -> &str {
496 "Bash"
497 }
498
499 fn essential(&self) -> bool {
500 true
501 }
502 fn description(&self) -> &str {
503 "Execute a bash command in a shell. Returns stdout and stderr. \
504 Output is truncated to 2000 lines or 50KB (whichever is hit first). \
505 Set timeout to limit execution time."
506 }
507
508 fn parameters_schema(&self) -> Value {
509 json!({
510 "type": "object",
511 "properties": {
512 "command": {
513 "type": "string",
514 "description": "The bash command to execute"
515 },
516 "timeout": {
517 "type": "integer",
518 "description": "Timeout in seconds (default: 120)",
519 "default": 120
520 },
521 "cwd": {
522 "type": "string",
523 "description": "Working directory for the command (optional)"
524 },
525 "env": {
526 "type": "object",
527 "description": "Environment variables as key-value pairs (optional)",
528 "additionalProperties": {
529 "type": "string"
530 }
531 }
532 },
533 "required": ["command"]
534 })
535 }
536
537 async fn execute(
538 &self,
539 _tool_call_id: &str,
540 params: Value,
541 signal: Option<oneshot::Receiver<()>>,
542 ctx: &ToolContext,
543 ) -> Result<AgentToolResult, ToolError> {
544 let command = params
545 .get("command")
546 .and_then(|v: &Value| v.as_str())
547 .ok_or_else(|| "Missing required parameter: command".to_string())?;
548
549 let cwd = params.get("cwd").and_then(|v: &Value| v.as_str());
550 let timeout = params.get("timeout").and_then(|v: &Value| v.as_u64());
551 let env = params.get("env").and_then(|v: &Value| v.as_object());
552
553 let progress_cb = self
554 .progress_callback
555 .lock()
556 .expect("progress callback lock poisoned")
557 .clone();
558
559 let root = self.root_dir.as_deref().unwrap_or(ctx.root());
561
562 Self::run_command(root, command, cwd, env, timeout, &progress_cb, signal).await
563 }
564
565 fn on_progress(&self, callback: ProgressCallback) {
566 let cb = self.progress_callback.clone();
567 let mut guard = cb.lock().expect("progress callback lock poisoned");
568 *guard = Some(callback);
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575
576 fn make_params(command: &str) -> Value {
577 json!({ "command": command })
578 }
579
580 fn make_params_with_timeout(command: &str, timeout: u64) -> Value {
581 json!({ "command": command, "timeout": timeout })
582 }
583
584 fn make_params_with_cwd(command: &str, cwd: &str) -> Value {
585 json!({ "command": command, "cwd": cwd })
586 }
587
588 fn make_params_with_env(command: &str, env: serde_json::Value) -> Value {
589 json!({ "command": command, "env": env })
590 }
591
592 #[tokio::test]
593 async fn test_simple_command() {
594 let tool = BashTool::new();
595 let result = tool
596 .execute(
597 "test-1",
598 make_params("echo hello"),
599 None,
600 &ToolContext::default(),
601 )
602 .await
603 .unwrap();
604 assert!(result.success);
605 assert!(result.output.contains("hello"));
606 }
607
608 #[tokio::test]
609 async fn test_command_with_args() {
610 let tool = BashTool::new();
611 let result = tool
612 .execute(
613 "test-2",
614 make_params("echo hello world"),
615 None,
616 &ToolContext::default(),
617 )
618 .await
619 .unwrap();
620 assert!(result.success);
621 assert!(result.output.contains("hello world"));
622 }
623
624 #[tokio::test]
625 async fn test_failed_command() {
626 let tool = BashTool::new();
627 let result = tool
628 .execute(
629 "test-3",
630 make_params("exit 1"),
631 None,
632 &ToolContext::default(),
633 )
634 .await
635 .unwrap();
636 assert!(!result.success);
637 assert!(result.output.contains("exited with code 1"));
638 }
639
640 #[tokio::test]
641 async fn test_missing_command_param() {
642 let tool = BashTool::new();
643 let result = tool
644 .execute("test-4", json!({}), None, &ToolContext::default())
645 .await;
646 assert!(result.is_err());
647 assert!(result
648 .unwrap_err()
649 .contains("Missing required parameter: command"));
650 }
651
652 #[tokio::test]
653 async fn test_no_output() {
654 let tool = BashTool::new();
655 let result = tool
656 .execute("test-5", make_params("true"), None, &ToolContext::default())
657 .await
658 .unwrap();
659 assert!(result.success);
660 assert!(result.output.contains("(no output)"));
661 }
662
663 #[tokio::test]
664 async fn test_stderr_capture() {
665 let tool = BashTool::new();
666 let result = tool
667 .execute(
668 "test-6",
669 make_params("echo error_msg >&2"),
670 None,
671 &ToolContext::default(),
672 )
673 .await
674 .unwrap();
675 assert!(result.success);
676 assert!(result.output.contains("error_msg"));
677 }
678
679 #[tokio::test]
680 async fn test_timeout_kills_process() {
681 let tool = BashTool::new();
682 let result = tool
683 .execute(
684 "test-7",
685 make_params_with_timeout("sleep 300", 1),
686 None,
687 &ToolContext::default(),
688 )
689 .await
690 .unwrap();
691 assert!(!result.success);
692 assert!(result.output.contains("timed out"));
693 }
694
695 #[tokio::test]
696 async fn test_timeout_default() {
697 let tool = BashTool::new();
699 let schema = tool.parameters_schema();
700 assert_eq!(schema["properties"]["timeout"]["default"], 120);
701 }
702
703 #[tokio::test]
704 async fn test_working_directory() {
705 let tool = BashTool::with_cwd(PathBuf::from("/tmp"));
706 let result = tool
707 .execute(
708 "test-8",
709 make_params_with_cwd("pwd", "/tmp"),
710 None,
711 &ToolContext::default(),
712 )
713 .await
714 .unwrap();
715 assert!(result.success);
716 assert!(result.output.contains("/tmp") || result.output.contains("/private/tmp"));
717 }
718
719 #[tokio::test]
720 async fn test_working_directory_nonexistent() {
721 let tool = BashTool::new();
722 let result = tool
723 .execute(
724 "test-9",
725 make_params_with_cwd("echo hi", "/nonexistent/dir/xyz"),
726 None,
727 &ToolContext::default(),
728 )
729 .await;
730 assert!(result.is_err());
731 assert!(result.unwrap_err().contains("does not exist"));
732 }
733
734 #[tokio::test]
735 async fn test_working_directory_traversal() {
736 let tool = BashTool::new();
737 let result = tool
738 .execute(
739 "test-10",
740 make_params_with_cwd("echo hi", "/tmp/../etc"),
741 None,
742 &ToolContext::default(),
743 )
744 .await;
745 assert!(result.is_err());
746 assert!(result.unwrap_err().contains("Path traversal"));
747 }
748
749 #[tokio::test]
750 async fn test_env_variables() {
751 let tool = BashTool::new();
752 let result = tool
753 .execute(
754 "test-11",
755 make_params_with_env(
756 "echo $OXI_TEST_VAR",
757 json!({ "OXI_TEST_VAR": "hello_from_env" }),
758 ),
759 None,
760 &ToolContext::default(),
761 )
762 .await
763 .unwrap();
764 assert!(result.success);
765 assert!(result.output.contains("hello_from_env"));
766 }
767
768 #[tokio::test]
769 async fn test_env_variables_multiple() {
770 let tool = BashTool::new();
771 let result = tool
772 .execute(
773 "test-12",
774 make_params_with_env(
775 "echo $OXI_A $OXI_B",
776 json!({ "OXI_A": "first", "OXI_B": "second" }),
777 ),
778 None,
779 &ToolContext::default(),
780 )
781 .await
782 .unwrap();
783 assert!(result.success);
784 assert!(result.output.contains("first second"));
785 }
786
787 #[tokio::test]
788 async fn test_duration_timing() {
789 let tool = BashTool::new();
790 let result = tool
791 .execute(
792 "test-13",
793 make_params("sleep 0.1 && echo done"),
794 None,
795 &ToolContext::default(),
796 )
797 .await
798 .unwrap();
799 assert!(result.success);
800 assert!(result.output.contains("Took "));
801 assert!(result.output.contains("s")); }
803
804 #[tokio::test]
805 async fn test_combined_stdout_stderr() {
806 let tool = BashTool::new();
807 let result = tool
808 .execute(
809 "test",
810 make_params("echo stdout_msg; echo stderr_msg >&2"),
811 None,
812 &ToolContext::default(),
813 )
814 .await
815 .unwrap();
816 assert!(result.success);
817 assert!(result.output.contains("stdout_msg"));
818 assert!(result.output.contains("stderr_msg"));
819 }
820
821 #[tokio::test]
822 async fn test_output_truncation() {
823 let tool = BashTool::new();
824 let result = tool
826 .execute(
827 "test-15",
828 make_params("seq 1 3000"),
829 None,
830 &ToolContext::default(),
831 )
832 .await
833 .unwrap();
834 assert!(result.success);
835 assert!(result.output.contains("truncated") || result.output.contains("Truncated"));
836 }
837
838 #[tokio::test]
839 async fn test_signal_aborts_process() {
840 let tool = BashTool::new();
841 let (tx, rx) = oneshot::channel();
842
843 tokio::spawn(async move {
845 tokio::time::sleep(Duration::from_millis(100)).await;
846 let _ = tx.send(());
847 });
848
849 let result = tool
850 .execute(
851 "test-16",
852 make_params("sleep 300"),
853 Some(rx),
854 &ToolContext::default(),
855 )
856 .await
857 .unwrap();
858 assert!(!result.success);
859 assert!(result.output.contains("aborted"));
860 }
861
862 #[tokio::test]
863 async fn test_parameters_schema() {
864 let tool = BashTool::new();
865 let schema = tool.parameters_schema();
866
867 let required = schema["required"].as_array().unwrap();
869 assert!(required.iter().any(|r| r.as_str() == Some("command")));
870
871 let props = schema["properties"].as_object().unwrap();
873 assert!(props.contains_key("command"));
874 assert!(props.contains_key("timeout"));
875 assert!(props.contains_key("cwd"));
876 assert!(props.contains_key("env"));
877
878 assert_eq!(props["command"]["type"], "string");
880 assert_eq!(props["timeout"]["type"], "integer");
881 assert_eq!(props["cwd"]["type"], "string");
882 assert_eq!(props["env"]["type"], "object");
883 }
884
885 #[tokio::test]
886 async fn test_multiline_output() {
887 let tool = BashTool::new();
888 let result = tool
889 .execute(
890 "test",
891 make_params("echo line1 && echo line2 && echo line3"),
892 None,
893 &ToolContext::default(),
894 )
895 .await
896 .unwrap();
897 assert!(result.success);
898 assert!(result.output.contains("line1"));
899 assert!(result.output.contains("line2"));
900 assert!(result.output.contains("line3"));
901 }
902
903 #[tokio::test]
904 async fn test_format_duration() {
905 assert_eq!(
906 BashTool::format_duration(Duration::from_millis(500)),
907 "0.5s"
908 );
909 assert_eq!(BashTool::format_duration(Duration::from_secs(1)), "1.0s");
910 assert_eq!(
911 BashTool::format_duration(Duration::from_secs(65)),
912 "1m 5.0s"
913 );
914 assert_eq!(
915 BashTool::format_duration(Duration::from_secs(120)),
916 "2m 0.0s"
917 );
918 }
919}