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 {
322 libc::kill(pgid, libc::SIGKILL);
323 }
324 }
325 }
326 let _ = child.kill().await;
327 let _ = child.wait().await;
328 }
329
330 fn format_error_output(
332 stdout_str: &str,
333 stderr_str: &str,
334 error_msg: &str,
335 elapsed: Duration,
336 ) -> String {
337 let mut output = String::new();
338 if !stdout_str.is_empty() {
339 output.push_str(stdout_str);
340 }
341 if !stderr_str.is_empty() {
342 if !output.is_empty() {
343 output.push('\n');
344 }
345 output.push_str(stderr_str);
346 }
347
348 if !output.is_empty() {
349 let truncation = truncate::truncate_head(&output, &TruncationOptions::default());
350 output = truncation.content;
351 }
352
353 output.push_str(&format!("\n\n{}", error_msg));
354 output.push_str(&format!("\nTook {}", Self::format_duration(elapsed)));
355 output
356 }
357
358 async fn run_command(
360 root_dir: &Path,
361 command: &str,
362 cwd: Option<&str>,
363 env: Option<&serde_json::Map<String, Value>>,
364 timeout_secs: Option<u64>,
365 progress_cb: &Option<ProgressCallback>,
366 mut signal: Option<oneshot::Receiver<()>>,
367 ) -> Result<AgentToolResult, ToolError> {
368 if let Some(cb) = progress_cb {
369 cb(format!("Executing: {}", command));
370 }
371
372 let timeout = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
373 let start = Instant::now();
374
375 let work_dir = match cwd {
377 Some(dir) if !dir.is_empty() => {
378 let validated = validate_cwd(dir, Some(root_dir))?;
379 Some(validated.to_string_lossy().to_string())
380 }
381 _ => Some(root_dir.to_string_lossy().to_string()),
382 };
383
384 let mut cmd = Self::build_shell_command(command, &work_dir, env);
386
387 let mut child = cmd
389 .spawn()
390 .map_err(|e| format!("Failed to spawn command: {}", e))?;
391
392 let mut stdout_pipe = child
394 .stdout
395 .take()
396 .ok_or_else(|| "Failed to capture stdout".to_string())?;
397 let mut stderr_pipe = child
398 .stderr
399 .take()
400 .ok_or_else(|| "Failed to capture stderr".to_string())?;
401
402 let stdout_handle = tokio::spawn(async move {
404 let mut buf = Vec::new();
405 let _ = stdout_pipe.read_to_end(&mut buf).await;
406 buf
407 });
408 let stderr_handle = tokio::spawn(async move {
409 let mut buf = Vec::new();
410 let _ = stderr_pipe.read_to_end(&mut buf).await;
411 buf
412 });
413
414 let result = Self::wait_with_timeout_and_signal(&mut child, timeout, &mut signal).await;
416
417 let elapsed = start.elapsed();
418
419 let stdout_bytes = stdout_handle.await.unwrap_or_default();
421 let stderr_bytes = stderr_handle.await.unwrap_or_default();
422
423 let stdout_str = String::from_utf8_lossy(&stdout_bytes).to_string();
424 let stderr_str = String::from_utf8_lossy(&stderr_bytes).to_string();
425
426 if let Some(cb) = progress_cb {
427 cb(format!(
428 "Process completed in {}",
429 Self::format_duration(elapsed)
430 ));
431 }
432
433 match result {
434 Ok(status) => {
435 let exit_code = status.code();
436 if let Some(code) = exit_code {
437 if let Some(cb) = progress_cb {
438 cb(format!("Process exited with code {}", code));
439 }
440 }
441 let combined = if stderr_str.is_empty() {
442 stdout_str.clone()
443 } else if stdout_str.is_empty() {
444 stderr_str.clone()
445 } else {
446 format!("{}\n{}", stdout_str, stderr_str)
447 };
448
449 let security_warning = is_dangerous_command(command);
450
451 let truncation = truncate::truncate_head(
452 if combined.is_empty() {
453 "(no output)"
454 } else {
455 &combined
456 },
457 &TruncationOptions::default(),
458 );
459
460 let mut output = Self::build_output(&truncation, elapsed, exit_code);
461
462 if let Some(ref warning) = security_warning {
463 output.push_str(&format!("\n{}", warning));
464 }
465
466 if status.success() {
467 Ok(AgentToolResult::success(output))
468 } else {
469 Ok(AgentToolResult::error(output))
470 }
471 }
472 Err(e) => {
473 let output = Self::format_error_output(&stdout_str, &stderr_str, &e, elapsed);
474 Ok(AgentToolResult::error(output))
475 }
476 }
477 }
478}
479
480impl Default for BashTool {
481 fn default() -> Self {
482 Self::new()
483 }
484}
485
486#[async_trait]
487impl AgentTool for BashTool {
488 fn name(&self) -> &str {
489 "bash"
490 }
491
492 fn label(&self) -> &str {
493 "Bash"
494 }
495
496 fn essential(&self) -> bool {
497 true
498 }
499 fn description(&self) -> &str {
500 "Execute a bash command in a shell. Returns stdout and stderr. \
501 Output is truncated to 2000 lines or 50KB (whichever is hit first). \
502 Set timeout to limit execution time."
503 }
504
505 fn parameters_schema(&self) -> Value {
506 json!({
507 "type": "object",
508 "properties": {
509 "command": {
510 "type": "string",
511 "description": "The bash command to execute"
512 },
513 "timeout": {
514 "type": "integer",
515 "description": "Timeout in seconds (default: 120)",
516 "default": 120
517 },
518 "cwd": {
519 "type": "string",
520 "description": "Working directory for the command (optional)"
521 },
522 "env": {
523 "type": "object",
524 "description": "Environment variables as key-value pairs (optional)",
525 "additionalProperties": {
526 "type": "string"
527 }
528 }
529 },
530 "required": ["command"]
531 })
532 }
533
534 async fn execute(
535 &self,
536 _tool_call_id: &str,
537 params: Value,
538 signal: Option<oneshot::Receiver<()>>,
539 ctx: &ToolContext,
540 ) -> Result<AgentToolResult, ToolError> {
541 let command = params
542 .get("command")
543 .and_then(|v: &Value| v.as_str())
544 .ok_or_else(|| "Missing required parameter: command".to_string())?;
545
546 let cwd = params.get("cwd").and_then(|v: &Value| v.as_str());
547 let timeout = params.get("timeout").and_then(|v: &Value| v.as_u64());
548 let env = params.get("env").and_then(|v: &Value| v.as_object());
549
550 let progress_cb = self
551 .progress_callback
552 .lock()
553 .expect("progress callback lock poisoned")
554 .clone();
555
556 let root = self.root_dir.as_deref().unwrap_or(ctx.root());
558
559 Self::run_command(root, command, cwd, env, timeout, &progress_cb, signal).await
560 }
561
562 fn on_progress(&self, callback: ProgressCallback) {
563 let cb = self.progress_callback.clone();
564 let mut guard = cb.lock().expect("progress callback lock poisoned");
565 *guard = Some(callback);
566 }
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572
573 fn make_params(command: &str) -> Value {
574 json!({ "command": command })
575 }
576
577 fn make_params_with_timeout(command: &str, timeout: u64) -> Value {
578 json!({ "command": command, "timeout": timeout })
579 }
580
581 fn make_params_with_cwd(command: &str, cwd: &str) -> Value {
582 json!({ "command": command, "cwd": cwd })
583 }
584
585 fn make_params_with_env(command: &str, env: serde_json::Value) -> Value {
586 json!({ "command": command, "env": env })
587 }
588
589 #[tokio::test]
590 async fn test_simple_command() {
591 let tool = BashTool::new();
592 let result = tool
593 .execute(
594 "test-1",
595 make_params("echo hello"),
596 None,
597 &ToolContext::default(),
598 )
599 .await
600 .unwrap();
601 assert!(result.success);
602 assert!(result.output.contains("hello"));
603 }
604
605 #[tokio::test]
606 async fn test_command_with_args() {
607 let tool = BashTool::new();
608 let result = tool
609 .execute(
610 "test-2",
611 make_params("echo hello world"),
612 None,
613 &ToolContext::default(),
614 )
615 .await
616 .unwrap();
617 assert!(result.success);
618 assert!(result.output.contains("hello world"));
619 }
620
621 #[tokio::test]
622 async fn test_failed_command() {
623 let tool = BashTool::new();
624 let result = tool
625 .execute(
626 "test-3",
627 make_params("exit 1"),
628 None,
629 &ToolContext::default(),
630 )
631 .await
632 .unwrap();
633 assert!(!result.success);
634 assert!(result.output.contains("exited with code 1"));
635 }
636
637 #[tokio::test]
638 async fn test_missing_command_param() {
639 let tool = BashTool::new();
640 let result = tool
641 .execute("test-4", json!({}), None, &ToolContext::default())
642 .await;
643 assert!(result.is_err());
644 assert!(result
645 .unwrap_err()
646 .contains("Missing required parameter: command"));
647 }
648
649 #[tokio::test]
650 async fn test_no_output() {
651 let tool = BashTool::new();
652 let result = tool
653 .execute("test-5", make_params("true"), None, &ToolContext::default())
654 .await
655 .unwrap();
656 assert!(result.success);
657 assert!(result.output.contains("(no output)"));
658 }
659
660 #[tokio::test]
661 async fn test_stderr_capture() {
662 let tool = BashTool::new();
663 let result = tool
664 .execute(
665 "test-6",
666 make_params("echo error_msg >&2"),
667 None,
668 &ToolContext::default(),
669 )
670 .await
671 .unwrap();
672 assert!(result.success);
673 assert!(result.output.contains("error_msg"));
674 }
675
676 #[tokio::test]
677 async fn test_timeout_kills_process() {
678 let tool = BashTool::new();
679 let result = tool
680 .execute(
681 "test-7",
682 make_params_with_timeout("sleep 300", 1),
683 None,
684 &ToolContext::default(),
685 )
686 .await
687 .unwrap();
688 assert!(!result.success);
689 assert!(result.output.contains("timed out"));
690 }
691
692 #[tokio::test]
693 async fn test_timeout_default() {
694 let tool = BashTool::new();
696 let schema = tool.parameters_schema();
697 assert_eq!(schema["properties"]["timeout"]["default"], 120);
698 }
699
700 #[tokio::test]
701 async fn test_working_directory() {
702 let tool = BashTool::with_cwd(PathBuf::from("/tmp"));
703 let result = tool
704 .execute(
705 "test-8",
706 make_params_with_cwd("pwd", "/tmp"),
707 None,
708 &ToolContext::default(),
709 )
710 .await
711 .unwrap();
712 assert!(result.success);
713 assert!(result.output.contains("/tmp") || result.output.contains("/private/tmp"));
714 }
715
716 #[tokio::test]
717 async fn test_working_directory_nonexistent() {
718 let tool = BashTool::new();
719 let result = tool
720 .execute(
721 "test-9",
722 make_params_with_cwd("echo hi", "/nonexistent/dir/xyz"),
723 None,
724 &ToolContext::default(),
725 )
726 .await;
727 assert!(result.is_err());
728 assert!(result.unwrap_err().contains("does not exist"));
729 }
730
731 #[tokio::test]
732 async fn test_working_directory_traversal() {
733 let tool = BashTool::new();
734 let result = tool
735 .execute(
736 "test-10",
737 make_params_with_cwd("echo hi", "/tmp/../etc"),
738 None,
739 &ToolContext::default(),
740 )
741 .await;
742 assert!(result.is_err());
743 assert!(result.unwrap_err().contains("Path traversal"));
744 }
745
746 #[tokio::test]
747 async fn test_env_variables() {
748 let tool = BashTool::new();
749 let result = tool
750 .execute(
751 "test-11",
752 make_params_with_env(
753 "echo $OXI_TEST_VAR",
754 json!({ "OXI_TEST_VAR": "hello_from_env" }),
755 ),
756 None,
757 &ToolContext::default(),
758 )
759 .await
760 .unwrap();
761 assert!(result.success);
762 assert!(result.output.contains("hello_from_env"));
763 }
764
765 #[tokio::test]
766 async fn test_env_variables_multiple() {
767 let tool = BashTool::new();
768 let result = tool
769 .execute(
770 "test-12",
771 make_params_with_env(
772 "echo $OXI_A $OXI_B",
773 json!({ "OXI_A": "first", "OXI_B": "second" }),
774 ),
775 None,
776 &ToolContext::default(),
777 )
778 .await
779 .unwrap();
780 assert!(result.success);
781 assert!(result.output.contains("first second"));
782 }
783
784 #[tokio::test]
785 async fn test_duration_timing() {
786 let tool = BashTool::new();
787 let result = tool
788 .execute(
789 "test-13",
790 make_params("sleep 0.1 && echo done"),
791 None,
792 &ToolContext::default(),
793 )
794 .await
795 .unwrap();
796 assert!(result.success);
797 assert!(result.output.contains("Took "));
798 assert!(result.output.contains("s")); }
800
801 #[tokio::test]
802 async fn test_combined_stdout_stderr() {
803 let tool = BashTool::new();
804 let result = tool
805 .execute(
806 "test",
807 make_params("echo stdout_msg; echo stderr_msg >&2"),
808 None,
809 &ToolContext::default(),
810 )
811 .await
812 .unwrap();
813 assert!(result.success);
814 assert!(result.output.contains("stdout_msg"));
815 assert!(result.output.contains("stderr_msg"));
816 }
817
818 #[tokio::test]
819 async fn test_output_truncation() {
820 let tool = BashTool::new();
821 let result = tool
823 .execute(
824 "test-15",
825 make_params("seq 1 3000"),
826 None,
827 &ToolContext::default(),
828 )
829 .await
830 .unwrap();
831 assert!(result.success);
832 assert!(result.output.contains("truncated") || result.output.contains("Truncated"));
833 }
834
835 #[tokio::test]
836 async fn test_signal_aborts_process() {
837 let tool = BashTool::new();
838 let (tx, rx) = oneshot::channel();
839
840 tokio::spawn(async move {
842 tokio::time::sleep(Duration::from_millis(100)).await;
843 let _ = tx.send(());
844 });
845
846 let result = tool
847 .execute(
848 "test-16",
849 make_params("sleep 300"),
850 Some(rx),
851 &ToolContext::default(),
852 )
853 .await
854 .unwrap();
855 assert!(!result.success);
856 assert!(result.output.contains("aborted"));
857 }
858
859 #[tokio::test]
860 async fn test_parameters_schema() {
861 let tool = BashTool::new();
862 let schema = tool.parameters_schema();
863
864 let required = schema["required"].as_array().unwrap();
866 assert!(required.iter().any(|r| r.as_str() == Some("command")));
867
868 let props = schema["properties"].as_object().unwrap();
870 assert!(props.contains_key("command"));
871 assert!(props.contains_key("timeout"));
872 assert!(props.contains_key("cwd"));
873 assert!(props.contains_key("env"));
874
875 assert_eq!(props["command"]["type"], "string");
877 assert_eq!(props["timeout"]["type"], "integer");
878 assert_eq!(props["cwd"]["type"], "string");
879 assert_eq!(props["env"]["type"], "object");
880 }
881
882 #[tokio::test]
883 async fn test_multiline_output() {
884 let tool = BashTool::new();
885 let result = tool
886 .execute(
887 "test",
888 make_params("echo line1 && echo line2 && echo line3"),
889 None,
890 &ToolContext::default(),
891 )
892 .await
893 .unwrap();
894 assert!(result.success);
895 assert!(result.output.contains("line1"));
896 assert!(result.output.contains("line2"));
897 assert!(result.output.contains("line3"));
898 }
899
900 #[tokio::test]
901 async fn test_format_duration() {
902 assert_eq!(
903 BashTool::format_duration(Duration::from_millis(500)),
904 "0.5s"
905 );
906 assert_eq!(BashTool::format_duration(Duration::from_secs(1)), "1.0s");
907 assert_eq!(
908 BashTool::format_duration(Duration::from_secs(65)),
909 "1m 5.0s"
910 );
911 assert_eq!(
912 BashTool::format_duration(Duration::from_secs(120)),
913 "2m 0.0s"
914 );
915 }
916}