1use std::path::{Path, PathBuf};
22use std::process::Stdio;
23use std::time::{Duration, Instant};
24
25use async_trait::async_trait;
26use tokio::io::{AsyncBufReadExt, BufReader};
27use tokio::process::Command;
28
29use crate::constants::{COMMAND_MAX_TIMEOUT_SECS, COMMAND_TIMEOUT_SECS};
30use crate::domain::{
31 ManagedProcess, ManagedProcessStatus, ToolDefinition, ToolMetadata, ToolOutcome,
32 ToolRunMetadata,
33};
34
35use super::super::ctx::{ExecContext, ProgressEvent};
36use super::ToolExecutor;
37
38pub struct ExecuteCommandTool;
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50enum CommandMode {
51 Wait,
52 Background,
53}
54
55impl CommandMode {
56 fn parse(args: &serde_json::Value) -> Result<Self, String> {
57 match args.get("mode").and_then(|v| v.as_str()).unwrap_or("wait") {
58 "wait" | "foreground" => Ok(Self::Wait),
59 "background" => Ok(Self::Background),
60 other => Err(format!(
61 "execute_command: mode must be 'wait' or 'background', got '{}'",
62 other
63 )),
64 }
65 }
66}
67
68#[async_trait]
69impl ToolExecutor for ExecuteCommandTool {
70 fn name(&self) -> &'static str {
71 "execute_command"
72 }
73
74 fn schema(&self) -> ToolDefinition {
75 ToolDefinition {
76 name: "execute_command".to_string(),
77 description:
78 "Run a shell command. Use mode='wait' for finite commands, or mode='background' for dev servers and GUI/daemon-style commands that should keep running after the tool returns. Ctrl+C during foreground execution aborts the child immediately."
79 .to_string(),
80 input_schema: serde_json::json!({
81 "type": "object",
82 "properties": {
83 "command": { "type": "string", "description": "Shell command to run." },
84 "working_dir": { "type": "string", "description": "Override working directory (absolute)." },
85 "mode": {
86 "type": "string",
87 "enum": ["wait", "background"],
88 "default": "wait",
89 "description": "Use 'background' for long-running servers, daemons, and GUI launchers."
90 },
91 "timeout": {
92 "type": "integer",
93 "description": "Per-call foreground timeout in seconds. Default 30, max 300. Foreground timeout kills the child."
94 },
95 "startup_timeout_secs": {
96 "type": "integer",
97 "description": "Background mode: seconds to watch startup logs for readiness. Default 5, max 30."
98 },
99 "ready_pattern": {
100 "type": "string",
101 "description": "Background mode: text that marks the server/app ready when it appears in the startup log."
102 },
103 "open_url": {
104 "type": "string",
105 "description": "Background mode: URL to open with the desktop browser after startup."
106 }
107 },
108 "required": ["command"]
109 }),
110 }
111 }
112
113 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
114 let Some(command) = args.get("command").and_then(|v| v.as_str()) else {
115 return ToolOutcome::error("execute_command requires 'command' (string)", 0.0);
116 };
117
118 if contains_dangerous_command(command) {
119 return ToolOutcome::error(format!("Dangerous command blocked: {}", command), 0.0);
120 }
121
122 let mode = match CommandMode::parse(&args) {
123 Ok(mode) => mode,
124 Err(error) => return ToolOutcome::error(error, 0.0),
125 };
126 let working_dir = args
127 .get("working_dir")
128 .and_then(|v| v.as_str())
129 .map(|s| s.to_string());
130 if mode == CommandMode::Background {
131 let startup_timeout_secs = args
132 .get("startup_timeout_secs")
133 .or_else(|| args.get("startup_timeout"))
134 .and_then(|v| v.as_u64())
135 .unwrap_or(5)
136 .clamp(1, 30);
137 let ready_pattern = args
138 .get("ready_pattern")
139 .and_then(|v| v.as_str())
140 .map(str::to_string);
141 let open_url = args
142 .get("open_url")
143 .and_then(|v| v.as_str())
144 .filter(|v| !v.trim().is_empty())
145 .map(str::to_string);
146 return run_background_command(
147 command,
148 working_dir.as_deref(),
149 startup_timeout_secs,
150 ready_pattern.as_deref(),
151 open_url.as_deref(),
152 ctx,
153 )
154 .await;
155 }
156
157 let timeout_secs = args
158 .get("timeout")
159 .and_then(|v| v.as_u64())
160 .unwrap_or(COMMAND_TIMEOUT_SECS)
161 .min(COMMAND_MAX_TIMEOUT_SECS);
162
163 let command = command.to_string();
164 let start = Instant::now();
165 let progress = ctx.progress.clone();
166
167 let mut cmd = Command::new(if cfg!(target_os = "windows") {
170 "cmd"
171 } else {
172 "sh"
173 });
174 cmd.arg(if cfg!(target_os = "windows") { "/C" } else { "-c" })
175 .arg(&command)
176 .stdin(Stdio::null())
177 .stdout(Stdio::piped())
178 .stderr(Stdio::piped())
179 .kill_on_drop(true);
183
184 if let Some(dir) = working_dir.as_ref() {
185 cmd.current_dir(dir);
186 } else {
187 cmd.current_dir(&ctx.workdir);
188 }
189
190 let run_fut = run_command(cmd, progress);
191 let timeout_fut = tokio::time::sleep(Duration::from_secs(timeout_secs));
192
193 tokio::select! {
194 biased;
195 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
196 _ = timeout_fut => {
197 let message = format!(
198 "Command timed out after {} seconds and was killed. \
199 For dev servers, GUI apps, or other long-running commands, call execute_command with mode=\"background\".",
200 timeout_secs
201 );
202 let duration_secs = start.elapsed().as_secs_f64();
203 ToolOutcome::error(message, duration_secs).with_metadata(command_metadata(
204 CommandMetadataInput {
205 command: command.clone(),
206 working_dir: working_dir.clone(),
207 exit_code: None,
208 timed_out: true,
209 background: false,
210 stdout_lines: 0,
211 stderr_lines: 0,
212 detected_urls: Vec::new(),
213 pid: None,
214 log_path: None,
215 byte_count: None,
216 },
217 ))
218 },
219 result = run_fut => match result {
220 Ok(run) => {
221 let duration_secs = start.elapsed().as_secs_f64();
222 let output_len = run.output.len();
223 ToolOutcome::success(run.output.clone(), "command completed", duration_secs)
224 .with_metadata(command_metadata(
225 CommandMetadataInput {
226 command: command.clone(),
227 working_dir: working_dir.clone(),
228 exit_code: run.exit_code,
229 timed_out: false,
230 background: false,
231 stdout_lines: run.stdout_lines,
232 stderr_lines: run.stderr_lines,
233 detected_urls: all_urls(&run.output),
234 pid: None,
235 log_path: None,
236 byte_count: Some(output_len),
237 },
238 ))
239 },
240 Err(e) => {
241 let duration_secs = start.elapsed().as_secs_f64();
242 ToolOutcome::error(format!("Command failed: {}", e), duration_secs)
243 .with_metadata(command_metadata(
244 CommandMetadataInput {
245 command: command.clone(),
246 working_dir: working_dir.clone(),
247 exit_code: None,
248 timed_out: false,
249 background: false,
250 stdout_lines: 0,
251 stderr_lines: 0,
252 detected_urls: Vec::new(),
253 pid: None,
254 log_path: None,
255 byte_count: None,
256 },
257 ))
258 },
259 },
260 }
261 }
262}
263
264#[derive(Debug)]
265struct BackgroundStartup {
266 ready_message: String,
267 log_excerpt: String,
268 detected_url: Option<String>,
269}
270
271async fn run_background_command(
272 command: &str,
273 working_dir: Option<&str>,
274 startup_timeout_secs: u64,
275 ready_pattern: Option<&str>,
276 open_url: Option<&str>,
277 ctx: ExecContext,
278) -> ToolOutcome {
279 let start = Instant::now();
280
281 #[cfg(target_os = "windows")]
282 {
283 let _ = (
284 command,
285 working_dir,
286 startup_timeout_secs,
287 ready_pattern,
288 open_url,
289 ctx,
290 );
291 return ToolOutcome::error(
292 "execute_command background mode is not supported on Windows yet",
293 start.elapsed().as_secs_f64(),
294 );
295 }
296
297 #[cfg(not(target_os = "windows"))]
298 {
299 let workdir = working_dir
300 .map(PathBuf::from)
301 .unwrap_or_else(|| ctx.workdir.clone());
302 let log_path = background_log_path();
303 let pid = match launch_background_process(command, &workdir, &log_path).await {
304 Ok(pid) => pid,
305 Err(error) => {
306 return ToolOutcome::error(error, start.elapsed().as_secs_f64());
307 },
308 };
309
310 let startup = match wait_for_background_startup(
311 pid,
312 &log_path,
313 startup_timeout_secs,
314 ready_pattern,
315 &ctx,
316 )
317 .await
318 {
319 Ok(startup) => startup,
320 Err(BackgroundWaitError::Cancelled) => {
321 let _ = kill_background_process(pid).await;
322 return ToolOutcome::cancelled();
323 },
324 Err(BackgroundWaitError::ExitedEarly(log_excerpt)) => {
325 return ToolOutcome::error(
326 format!(
327 "Background command exited during startup. Log: {}\n\n{}",
328 log_path.display(),
329 log_excerpt
330 ),
331 start.elapsed().as_secs_f64(),
332 );
333 },
334 };
335
336 let opened = if let Some(url) = open_url {
337 Some((url.to_string(), open_browser_url(url).await))
338 } else {
339 None
340 };
341
342 let mut output = format!(
343 "Background command started.\nPID: {}\nLog: {}\n{}\n",
344 pid,
345 log_path.display(),
346 startup.ready_message
347 );
348 if let Some(url) = startup.detected_url.as_ref() {
349 output.push_str(&format!("Detected URL: {}\n", url));
350 }
351 if let Some((url, result)) = opened {
352 match result {
353 Ok(()) => output.push_str(&format!("Opened URL: {}\n", url)),
354 Err(error) => output.push_str(&format!("Open URL failed: {} ({})\n", url, error)),
355 }
356 }
357 if !startup.log_excerpt.trim().is_empty() {
358 output.push_str("\n--- startup output ---\n");
359 output.push_str(&startup.log_excerpt);
360 }
361
362 let duration_secs = start.elapsed().as_secs_f64();
363 let log_path_str = log_path.display().to_string();
364 let detected_urls = startup.detected_url.iter().cloned().collect::<Vec<_>>();
365 let process = ManagedProcess {
366 id: format!("bg-{}", pid),
367 pid,
368 command: command.to_string(),
369 cwd: Some(workdir.display().to_string()),
370 log_path: log_path_str.clone(),
371 detected_url: startup.detected_url.clone(),
372 status: ManagedProcessStatus::Running,
373 };
374 let byte_count = output.len();
375 let mut metadata = command_metadata(CommandMetadataInput {
376 command: command.to_string(),
377 working_dir: working_dir.map(str::to_string),
378 exit_code: None,
379 timed_out: false,
380 background: true,
381 stdout_lines: startup.log_excerpt.lines().count(),
382 stderr_lines: 0,
383 detected_urls,
384 pid: Some(pid),
385 log_path: Some(log_path_str),
386 byte_count: Some(byte_count),
387 });
388 metadata.process = Some(process);
389 ToolOutcome::success(output, "background process started", duration_secs)
390 .with_metadata(metadata)
391 }
392}
393
394#[cfg(not(target_os = "windows"))]
395async fn launch_background_process(
396 command: &str,
397 workdir: &Path,
398 log_path: &Path,
399) -> Result<u32, String> {
400 let mut launcher = Command::new("sh");
401 launcher
402 .arg("-c")
403 .arg(
404 r#"log=$MERMAID_BG_LOG
405cmd=$MERMAID_BG_COMMAND
406: > "$log" || exit 125
407nohup sh -c "$cmd" > "$log" 2>&1 < /dev/null &
408printf '%s\n' "$!""#,
409 )
410 .env("MERMAID_BG_LOG", log_path)
411 .env("MERMAID_BG_COMMAND", command)
412 .current_dir(workdir)
413 .stdin(Stdio::null())
414 .stdout(Stdio::piped())
415 .stderr(Stdio::piped());
416
417 let output = launcher
418 .output()
419 .await
420 .map_err(|e| format!("failed to launch background command: {}", e))?;
421 if !output.status.success() {
422 return Err(format!(
423 "background launcher failed: {}",
424 String::from_utf8_lossy(&output.stderr)
425 ));
426 }
427 let stdout = String::from_utf8_lossy(&output.stdout);
428 stdout.trim().parse::<u32>().map_err(|e| {
429 format!(
430 "background launcher did not return a pid: {} ({})",
431 stdout, e
432 )
433 })
434}
435
436#[cfg(not(target_os = "windows"))]
437#[derive(Debug)]
438enum BackgroundWaitError {
439 Cancelled,
440 ExitedEarly(String),
441}
442
443#[cfg(not(target_os = "windows"))]
444async fn wait_for_background_startup(
445 pid: u32,
446 log_path: &Path,
447 startup_timeout_secs: u64,
448 ready_pattern: Option<&str>,
449 ctx: &ExecContext,
450) -> Result<BackgroundStartup, BackgroundWaitError> {
451 let start = Instant::now();
452 let startup_timeout = Duration::from_secs(startup_timeout_secs);
453
454 loop {
455 if ctx.token.is_cancelled() {
456 return Err(BackgroundWaitError::Cancelled);
457 }
458
459 let last_log = read_log_lossy(log_path).await;
460 let detected_url = first_url(&last_log);
461
462 if !process_running(pid).await {
463 return Err(BackgroundWaitError::ExitedEarly(tail_lines(&last_log, 40)));
464 }
465
466 if let Some(pattern) = ready_pattern {
467 if last_log.contains(pattern) {
468 return Ok(BackgroundStartup {
469 ready_message: format!("Ready: matched pattern {:?}", pattern),
470 log_excerpt: tail_lines(&last_log, 40),
471 detected_url,
472 });
473 }
474 } else if start.elapsed() >= Duration::from_secs(1) || !last_log.is_empty() {
475 return Ok(BackgroundStartup {
476 ready_message:
477 "Ready: no ready_pattern provided; process is running after startup check"
478 .to_string(),
479 log_excerpt: tail_lines(&last_log, 40),
480 detected_url,
481 });
482 }
483
484 if start.elapsed() >= startup_timeout {
485 let ready_message = if let Some(pattern) = ready_pattern {
486 format!(
487 "Ready: pattern {:?} was not seen within {}s; process is still running",
488 pattern, startup_timeout_secs
489 )
490 } else {
491 format!(
492 "Ready: startup check reached {}s; process is still running",
493 startup_timeout_secs
494 )
495 };
496 return Ok(BackgroundStartup {
497 ready_message,
498 log_excerpt: tail_lines(&last_log, 40),
499 detected_url,
500 });
501 }
502
503 tokio::select! {
504 _ = ctx.token.cancelled() => return Err(BackgroundWaitError::Cancelled),
505 _ = tokio::time::sleep(Duration::from_millis(200)) => {},
506 }
507 }
508}
509
510#[cfg(not(target_os = "windows"))]
511async fn read_log_lossy(path: &Path) -> String {
512 tokio::fs::read_to_string(path).await.unwrap_or_default()
513}
514
515#[cfg(not(target_os = "windows"))]
516async fn process_running(pid: u32) -> bool {
517 Command::new("kill")
518 .arg("-0")
519 .arg(pid.to_string())
520 .stdin(Stdio::null())
521 .stdout(Stdio::null())
522 .stderr(Stdio::null())
523 .status()
524 .await
525 .map(|status| status.success())
526 .unwrap_or(false)
527}
528
529#[cfg(not(target_os = "windows"))]
530async fn kill_background_process(pid: u32) -> std::io::Result<()> {
531 let _ = Command::new("kill")
532 .arg(pid.to_string())
533 .stdin(Stdio::null())
534 .stdout(Stdio::null())
535 .stderr(Stdio::null())
536 .status()
537 .await?;
538 Ok(())
539}
540
541fn background_log_path() -> PathBuf {
542 let nanos = std::time::SystemTime::now()
543 .duration_since(std::time::UNIX_EPOCH)
544 .map(|d| d.as_nanos())
545 .unwrap_or_default();
546 std::env::temp_dir().join(format!("mermaid-bg-{}-{}.log", std::process::id(), nanos))
547}
548
549struct CommandMetadataInput {
550 command: String,
551 working_dir: Option<String>,
552 exit_code: Option<i32>,
553 timed_out: bool,
554 background: bool,
555 stdout_lines: usize,
556 stderr_lines: usize,
557 detected_urls: Vec<String>,
558 pid: Option<u32>,
559 log_path: Option<String>,
560 byte_count: Option<usize>,
561}
562
563fn command_metadata(input: CommandMetadataInput) -> ToolRunMetadata {
564 ToolRunMetadata {
565 detail: ToolMetadata::ExecuteCommand {
566 command: input.command,
567 working_dir: input.working_dir,
568 exit_code: input.exit_code,
569 timed_out: input.timed_out,
570 background: input.background,
571 stdout_lines: input.stdout_lines,
572 stderr_lines: input.stderr_lines,
573 detected_urls: input.detected_urls,
574 pid: input.pid,
575 log_path: input.log_path,
576 },
577 line_count: Some(input.stdout_lines + input.stderr_lines),
578 byte_count: input.byte_count,
579 ..ToolRunMetadata::default()
580 }
581}
582
583fn tail_lines(text: &str, max_lines: usize) -> String {
584 let lines: Vec<&str> = text.lines().collect();
585 let start = lines.len().saturating_sub(max_lines);
586 lines[start..].join("\n")
587}
588
589fn first_url(text: &str) -> Option<String> {
590 text.split_whitespace()
591 .find(|part| part.starts_with("http://") || part.starts_with("https://"))
592 .map(|url| {
593 url.trim_matches(|c: char| matches!(c, ')' | ']' | '}' | ',' | ';' | '"' | '\''))
594 .to_string()
595 })
596}
597
598fn all_urls(text: &str) -> Vec<String> {
599 text.split_whitespace()
600 .filter(|part| part.starts_with("http://") || part.starts_with("https://"))
601 .map(|url| {
602 url.trim_matches(|c: char| matches!(c, ')' | ']' | '}' | ',' | ';' | '"' | '\''))
603 .to_string()
604 })
605 .collect()
606}
607
608async fn open_browser_url(url: &str) -> Result<(), String> {
609 #[cfg(target_os = "macos")]
610 let mut command = {
611 let mut cmd = Command::new("open");
612 cmd.arg(url);
613 cmd
614 };
615
616 #[cfg(target_os = "linux")]
617 let mut command = {
618 let mut cmd = Command::new("xdg-open");
619 cmd.arg(url);
620 cmd
621 };
622
623 #[cfg(target_os = "windows")]
624 let mut command = {
625 let mut cmd = Command::new("cmd");
626 cmd.args(["/C", "start", "", url]);
627 cmd
628 };
629
630 command
631 .stdin(Stdio::null())
632 .stdout(Stdio::null())
633 .stderr(Stdio::null())
634 .kill_on_drop(false)
635 .spawn()
636 .map(|_| ())
637 .map_err(|e| e.to_string())
638}
639
640#[derive(Debug, Clone)]
645struct CommandRunOutput {
646 output: String,
647 exit_code: Option<i32>,
648 stdout_lines: usize,
649 stderr_lines: usize,
650}
651
652async fn run_command(
653 mut cmd: Command,
654 progress: tokio::sync::mpsc::Sender<ProgressEvent>,
655) -> std::io::Result<CommandRunOutput> {
656 let mut child = cmd.spawn()?;
657
658 let stdout = child
659 .stdout
660 .take()
661 .ok_or_else(|| std::io::Error::other("child stdout unavailable"))?;
662 let stderr = child
663 .stderr
664 .take()
665 .ok_or_else(|| std::io::Error::other("child stderr unavailable"))?;
666
667 let progress_clone = progress.clone();
668 let stdout_task = tokio::spawn(async move {
669 let mut reader = BufReader::new(stdout).lines();
670 let mut output = String::new();
671 while let Ok(Some(line)) = reader.next_line().await {
672 let _ = progress_clone
673 .send(ProgressEvent::Output(line.clone()))
674 .await;
675 output.push_str(&line);
676 output.push('\n');
677 }
678 output
679 });
680
681 let stderr_task = tokio::spawn(async move {
682 let mut reader = BufReader::new(stderr).lines();
683 let mut errors = String::new();
684 while let Ok(Some(line)) = reader.next_line().await {
685 errors.push_str(&line);
686 errors.push('\n');
687 }
688 errors
689 });
690
691 let output = stdout_task.await.unwrap_or_default();
692 let errors = stderr_task.await.unwrap_or_default();
693 let status = child.wait().await?;
694 let stdout_lines = output.lines().count();
695 let stderr_lines = errors.lines().count();
696
697 let mut full_output = output;
698 if !errors.is_empty() {
699 full_output.push_str("\n--- stderr ---\n");
700 full_output.push_str(&errors);
701 }
702 if !status.success() {
703 full_output.push_str(&format!(
704 "\n--- Command exited with status: {} ---",
705 status.code().unwrap_or(-1)
706 ));
707 }
708 Ok(CommandRunOutput {
709 output: full_output,
710 exit_code: status.code(),
711 stdout_lines,
712 stderr_lines,
713 })
714}
715
716fn contains_dangerous_command(command: &str) -> bool {
728 let dangerous_patterns = [
729 "rm -rf /",
730 "rm -rf /*",
731 "dd if=/dev/zero of=/",
732 "dd if=/dev/random of=/",
733 "dd if=/dev/urandom of=/",
734 "mkfs.",
735 "format c:",
736 "> /dev/sda",
737 "chmod -R 777 /",
738 "chmod -R 000 /",
739 ":(){ :|:& };:",
740 ":(){ :|:&};:",
741 "curl | bash",
742 "curl | sh",
743 "wget | bash",
744 "wget | sh",
745 "nc -l",
746 "ncat -l",
747 "socat tcp-listen:",
748 ];
749
750 let lower = command.to_lowercase();
751 for pattern in &dangerous_patterns {
752 if lower.contains(pattern) {
753 return true;
754 }
755 }
756
757 let system_dir_patterns: [(&str, bool); 10] = [
758 ("/etc", false),
759 ("/usr", false),
760 ("/boot", false),
761 ("/proc", false),
762 ("/sys", false),
763 ("/dev/", true),
764 ("/home", false),
765 ("C:\\Windows", false),
766 ("C:\\Program Files", false),
767 ("C:\\Users", false),
768 ];
769
770 let has_rm = lower.starts_with("rm ")
771 || lower.contains(" rm ")
772 || lower.contains(";rm ")
773 || lower.contains("&rm ")
774 || lower.contains("|rm ")
775 || lower.contains("$(rm ")
776 || lower.contains("`rm ");
777 let has_del = lower.starts_with("del ")
778 || lower.contains(" del ")
779 || lower.contains(";del ")
780 || lower.contains("&del ")
781 || lower.contains("$(del ")
782 || lower.contains("`del ");
783
784 if has_rm || has_del {
785 for (dir, require_trailing) in &system_dir_patterns {
786 if *require_trailing {
787 if command.contains(dir)
788 && !command.contains(&format!("{}null", dir))
789 && !command.contains(&format!("{}zero", dir))
790 {
791 return true;
792 }
793 } else if command.contains(dir) {
794 return true;
795 }
796 }
797 if command.contains(" ~/")
798 || command.ends_with(" ~")
799 || command.contains(" ~ ")
800 || command.contains("$HOME")
801 {
802 return true;
803 }
804 }
805
806 false
807}
808
809#[cfg(test)]
810mod tests {
811 use super::*;
812 use crate::domain::{ToolCallId, TurnId};
813 use crate::providers::ctx::test_exec_context;
814 use std::path::PathBuf;
815
816 #[tokio::test]
817 async fn safe_command_runs_and_captures_output() {
818 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
819 let outcome = ExecuteCommandTool
820 .execute(serde_json::json!({"command": "echo hello world"}), ctx)
821 .await;
822 assert!(outcome.is_success(), "expected success: {:?}", outcome);
823 assert!(outcome.output().contains("hello world"));
824 }
825
826 #[tokio::test]
827 async fn dangerous_command_blocked() {
828 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
829 let outcome = ExecuteCommandTool
830 .execute(serde_json::json!({"command": "rm -rf /"}), ctx)
831 .await;
832 let error = outcome.error_message().expect("expected error");
833 assert!(error.contains("Dangerous"));
834 }
835
836 #[tokio::test]
837 async fn cancellation_aborts_long_running_command() {
838 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
839 let token = ctx.token.clone();
840 let handle = tokio::spawn(async move {
841 ExecuteCommandTool
842 .execute(serde_json::json!({"command": "sleep 10"}), ctx)
843 .await
844 });
845 tokio::time::sleep(Duration::from_millis(30)).await;
847 token.cancel();
848 let start = Instant::now();
849 let outcome = tokio::time::timeout(Duration::from_millis(500), handle)
850 .await
851 .expect("didn't hang")
852 .expect("join");
853 let elapsed = start.elapsed();
854 assert!(outcome.was_cancelled());
855 assert!(
856 elapsed < Duration::from_millis(200),
857 "cancellation took {:?}",
858 elapsed
859 );
860 }
861
862 #[tokio::test]
863 async fn timeout_honored() {
864 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
865 let outcome = ExecuteCommandTool
866 .execute(serde_json::json!({"command": "sleep 5", "timeout": 1}), ctx)
867 .await;
868 assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
869 let output = outcome.as_tool_message_content();
870 assert!(output.contains("timed out"));
871 assert!(output.contains("was killed"));
872 assert!(output.contains("mode=\"background\""));
873 }
874
875 #[cfg(not(target_os = "windows"))]
876 #[tokio::test]
877 async fn background_mode_returns_pid_log_and_detected_url() {
878 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
879 let outcome = ExecuteCommandTool
880 .execute(
881 serde_json::json!({
882 "command": "printf 'ready http://127.0.0.1:54321\\n'; exec sleep 30",
883 "mode": "background",
884 "startup_timeout_secs": 2,
885 "ready_pattern": "ready"
886 }),
887 ctx,
888 )
889 .await;
890
891 assert!(
892 outcome.is_success(),
893 "expected background success: {:?}",
894 outcome
895 );
896 let output = outcome.output().to_string();
897 assert!(output.contains("Background command started"));
898 assert!(output.contains("PID:"));
899 assert!(output.contains("Log:"));
900 assert!(output.contains("Ready: matched pattern"));
901 assert!(output.contains("Detected URL: http://127.0.0.1:54321"));
902
903 if let Some(pid) = parse_pid(&output) {
904 let _ = Command::new("kill").arg(pid.to_string()).status().await;
905 }
906 }
907
908 fn parse_pid(output: &str) -> Option<u32> {
909 output
910 .lines()
911 .find_map(|line| line.strip_prefix("PID: "))
912 .and_then(|pid| pid.trim().parse().ok())
913 }
914
915 #[test]
916 fn dangerous_detection_covers_known_shapes() {
917 assert!(contains_dangerous_command("rm -rf /"));
918 assert!(contains_dangerous_command(":(){ :|:& };:"));
919 assert!(contains_dangerous_command("ncat -l 8080"));
920 assert!(!contains_dangerous_command("ls -la"));
921 assert!(!contains_dangerous_command("cargo build"));
922 assert!(!contains_dangerous_command(
923 r#"find . -type f ! -path "./.git/*" ! -path "./.mermaid/*" 2>/dev/null"#
924 ));
925 }
926}