1use std::path::{Path, PathBuf};
22use std::process::Stdio;
23use std::time::{Duration, Instant};
24
25use async_trait::async_trait;
26use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
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 default 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 mut policy_request = crate::runtime::ActionRequest::new(
123 "execute_command",
124 crate::runtime::ToolCategory::Shell,
125 command.to_string(),
126 );
127 policy_request.command = Some(command.to_string());
128 let pending_action = serde_json::json!({
129 "tool": "execute_command",
130 "args": args.clone(),
131 "workdir": ctx.workdir.display().to_string(),
132 "turn_id": ctx.turn.0,
133 "call_id": ctx.call_id.0,
134 "task_id": ctx.task_id.clone(),
135 });
136 match super::policy_gate::gate(&ctx, policy_request, &[], pending_action.clone(), true)
141 .await
142 {
143 super::policy_gate::Gate::Block(outcome) => return outcome,
144 super::policy_gate::Gate::Proceed { risk } => {
145 if ctx.config.safety.checkpoint_on_mutation
146 && risk != crate::runtime::RiskClass::ReadOnly
147 {
148 let _ = crate::runtime::create_checkpoint_for_task(
149 &ctx.workdir,
150 &[],
151 Some(pending_action.clone()),
152 ctx.task_id.clone(),
153 );
154 }
155 },
156 }
157
158 let mode = match CommandMode::parse(&args) {
159 Ok(mode) => mode,
160 Err(error) => return ToolOutcome::error(error, 0.0),
161 };
162 let working_dir = args
163 .get("working_dir")
164 .and_then(|v| v.as_str())
165 .map(|s| s.to_string());
166 let shell_payload = serde_json::json!({
167 "task_id": ctx.task_id.clone(),
168 "turn_id": ctx.turn.0,
169 "call_id": ctx.call_id.0,
170 "command": command,
171 "working_dir": working_dir.clone().unwrap_or_else(|| ctx.workdir.display().to_string()),
172 });
173 let _ = crate::runtime::run_plugin_hooks("before_shell", &shell_payload);
174 if mode == CommandMode::Background {
175 let startup_timeout_secs = args
176 .get("startup_timeout_secs")
177 .or_else(|| args.get("startup_timeout"))
178 .and_then(|v| v.as_u64())
179 .unwrap_or(5)
180 .clamp(1, 30);
181 let ready_pattern = args
182 .get("ready_pattern")
183 .and_then(|v| v.as_str())
184 .map(str::to_string);
185 let open_url = args
186 .get("open_url")
187 .and_then(|v| v.as_str())
188 .filter(|v| !v.trim().is_empty())
189 .map(str::to_string);
190 let outcome = run_background_command(
191 command,
192 working_dir.as_deref(),
193 startup_timeout_secs,
194 ready_pattern.as_deref(),
195 open_url.as_deref(),
196 ctx,
197 )
198 .await;
199 let _ = crate::runtime::run_plugin_hooks(
200 "after_shell",
201 &serde_json::json!({
202 "command": command,
203 "status": format!("{:?}", outcome.status),
204 "summary": &outcome.summary,
205 }),
206 );
207 return outcome;
208 }
209
210 let timeout_secs = args
211 .get("timeout")
212 .and_then(|v| v.as_u64())
213 .unwrap_or(COMMAND_TIMEOUT_SECS)
214 .min(COMMAND_MAX_TIMEOUT_SECS);
215
216 let command = command.to_string();
217 let start = Instant::now();
218 let progress = ctx.progress.clone();
219
220 let mut cmd = Command::new(if cfg!(target_os = "windows") {
223 "cmd"
224 } else {
225 "sh"
226 });
227 cmd.arg(if cfg!(target_os = "windows") { "/C" } else { "-c" })
228 .arg(&command)
229 .stdin(Stdio::null())
230 .stdout(Stdio::piped())
231 .stderr(Stdio::piped())
232 .kill_on_drop(true);
236
237 if let Some(dir) = working_dir.as_ref() {
238 cmd.current_dir(dir);
239 } else {
240 cmd.current_dir(&ctx.workdir);
241 }
242 scrub_secret_env(&mut cmd);
243
244 let run_fut = run_command(cmd, progress, ctx.background.clone());
245 let timeout_fut = tokio::time::sleep(Duration::from_secs(timeout_secs));
246
247 let outcome = tokio::select! {
248 biased;
249 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
250 _ = timeout_fut => {
251 let message = format!(
252 "Command timed out after {} seconds and was killed. \
253 For dev servers, GUI apps, or other long-running commands, call execute_command with mode=\"background\".",
254 timeout_secs
255 );
256 let duration_secs = start.elapsed().as_secs_f64();
257 ToolOutcome::error(message, duration_secs).with_metadata(command_metadata(
258 CommandMetadataInput {
259 command: command.clone(),
260 working_dir: working_dir.clone(),
261 exit_code: None,
262 timed_out: true,
263 background: false,
264 stdout_lines: 0,
265 stderr_lines: 0,
266 detected_urls: Vec::new(),
267 pid: None,
268 log_path: None,
269 byte_count: None,
270 },
271 ))
272 },
273 result = run_fut => match result {
274 Ok(CommandRunResult::Completed(run)) => {
275 let duration_secs = start.elapsed().as_secs_f64();
276 let output_len = run.output.len();
277 ToolOutcome::success(run.output.clone(), "command completed", duration_secs)
278 .with_metadata(command_metadata(
279 CommandMetadataInput {
280 command: command.clone(),
281 working_dir: working_dir.clone(),
282 exit_code: run.exit_code,
283 timed_out: false,
284 background: false,
285 stdout_lines: run.stdout_lines,
286 stderr_lines: run.stderr_lines,
287 detected_urls: all_urls(&run.output),
288 pid: None,
289 log_path: None,
290 byte_count: Some(output_len),
291 },
292 ))
293 },
294 Ok(CommandRunResult::Detached { pid, log_path }) => {
295 let duration_secs = start.elapsed().as_secs_f64();
297 let log_path_str = log_path.display().to_string();
298 let output = format!(
299 "Moved to background.\nPID: {pid}\nLog: {log_path_str}\nManage it with /processes, /logs {pid}, /stop {pid}."
300 );
301 let process = ManagedProcess {
302 id: format!("bg-{pid}"),
303 pid,
304 command: command.to_string(),
305 cwd: Some(
306 working_dir
307 .clone()
308 .unwrap_or_else(|| ctx.workdir.display().to_string()),
309 ),
310 log_path: log_path_str.clone(),
311 detected_url: None,
312 status: ManagedProcessStatus::Running,
313 };
314 let mut metadata = command_metadata(CommandMetadataInput {
315 command: command.to_string(),
316 working_dir: working_dir.clone(),
317 exit_code: None,
318 timed_out: false,
319 background: true,
320 stdout_lines: 0,
321 stderr_lines: 0,
322 detected_urls: Vec::new(),
323 pid: Some(pid),
324 log_path: Some(log_path_str),
325 byte_count: Some(output.len()),
326 });
327 metadata.process = Some(process);
328 ToolOutcome::success(output, "moved to background", duration_secs)
329 .with_metadata(metadata)
330 },
331 Err(e) => {
332 let duration_secs = start.elapsed().as_secs_f64();
333 ToolOutcome::error(format!("Command failed: {}", e), duration_secs)
334 .with_metadata(command_metadata(
335 CommandMetadataInput {
336 command: command.clone(),
337 working_dir: working_dir.clone(),
338 exit_code: None,
339 timed_out: false,
340 background: false,
341 stdout_lines: 0,
342 stderr_lines: 0,
343 detected_urls: Vec::new(),
344 pid: None,
345 log_path: None,
346 byte_count: None,
347 },
348 ))
349 },
350 },
351 };
352 let _ = crate::runtime::run_plugin_hooks(
353 "after_shell",
354 &serde_json::json!({
355 "command": command,
356 "status": format!("{:?}", outcome.status),
357 "summary": &outcome.summary,
358 }),
359 );
360 outcome
361 }
362}
363
364#[derive(Debug)]
365struct BackgroundStartup {
366 ready_message: String,
367 log_excerpt: String,
368 detected_url: Option<String>,
369}
370
371async fn run_background_command(
372 command: &str,
373 working_dir: Option<&str>,
374 startup_timeout_secs: u64,
375 ready_pattern: Option<&str>,
376 open_url: Option<&str>,
377 ctx: ExecContext,
378) -> ToolOutcome {
379 let start = Instant::now();
380
381 {
382 let workdir = working_dir
383 .map(PathBuf::from)
384 .unwrap_or_else(|| ctx.workdir.clone());
385 let log_path = background_log_path();
386 let pid = match launch_background_process(command, &workdir, &log_path).await {
387 Ok(pid) => pid,
388 Err(error) => {
389 return ToolOutcome::error(error, start.elapsed().as_secs_f64());
390 },
391 };
392
393 let startup = match wait_for_background_startup(
394 pid,
395 &log_path,
396 startup_timeout_secs,
397 ready_pattern,
398 &ctx,
399 )
400 .await
401 {
402 Ok(startup) => startup,
403 Err(BackgroundWaitError::Cancelled) => {
404 let _ = kill_background_process(pid).await;
405 return ToolOutcome::cancelled();
406 },
407 Err(BackgroundWaitError::ExitedEarly(log_excerpt)) => {
408 return ToolOutcome::error(
409 format!(
410 "Background command exited during startup. Log: {}\n\n{}",
411 log_path.display(),
412 log_excerpt
413 ),
414 start.elapsed().as_secs_f64(),
415 );
416 },
417 };
418
419 let opened = if let Some(url) = open_url {
420 Some((url.to_string(), open_browser_url(url).await))
421 } else {
422 None
423 };
424
425 let mut output = format!(
426 "Background command started.\nPID: {}\nLog: {}\n{}\n",
427 pid,
428 log_path.display(),
429 startup.ready_message
430 );
431 if let Some(url) = startup.detected_url.as_ref() {
432 output.push_str(&format!("Detected URL: {}\n", url));
433 }
434 if let Some((url, result)) = opened {
435 match result {
436 Ok(()) => output.push_str(&format!("Opened URL: {}\n", url)),
437 Err(error) => output.push_str(&format!("Open URL failed: {} ({})\n", url, error)),
438 }
439 }
440 if !startup.log_excerpt.trim().is_empty() {
441 output.push_str("\n--- startup output ---\n");
442 output.push_str(&startup.log_excerpt);
443 }
444
445 let duration_secs = start.elapsed().as_secs_f64();
446 let log_path_str = log_path.display().to_string();
447 let detected_urls = startup.detected_url.iter().cloned().collect::<Vec<_>>();
448 let process = ManagedProcess {
449 id: format!("bg-{}", pid),
450 pid,
451 command: command.to_string(),
452 cwd: Some(workdir.display().to_string()),
453 log_path: log_path_str.clone(),
454 detected_url: startup.detected_url.clone(),
455 status: ManagedProcessStatus::Running,
456 };
457 let byte_count = output.len();
458 let mut metadata = command_metadata(CommandMetadataInput {
459 command: command.to_string(),
460 working_dir: working_dir.map(str::to_string),
461 exit_code: None,
462 timed_out: false,
463 background: true,
464 stdout_lines: startup.log_excerpt.lines().count(),
465 stderr_lines: 0,
466 detected_urls,
467 pid: Some(pid),
468 log_path: Some(log_path_str),
469 byte_count: Some(byte_count),
470 });
471 metadata.process = Some(process);
472 ToolOutcome::success(output, "background process started", duration_secs)
473 .with_metadata(metadata)
474 }
475}
476
477#[cfg(not(target_os = "windows"))]
478async fn launch_background_process(
479 command: &str,
480 workdir: &Path,
481 log_path: &Path,
482) -> Result<u32, String> {
483 let mut launcher = Command::new("sh");
484 launcher
485 .arg("-c")
486 .arg(
487 r#"log=$MERMAID_BG_LOG
488cmd=$MERMAID_BG_COMMAND
489: > "$log" || exit 125
490nohup sh -c "$cmd" > "$log" 2>&1 < /dev/null &
491printf '%s\n' "$!""#,
492 )
493 .env("MERMAID_BG_LOG", log_path)
494 .env("MERMAID_BG_COMMAND", command)
495 .current_dir(workdir)
496 .stdin(Stdio::null())
497 .stdout(Stdio::piped())
498 .stderr(Stdio::piped());
499 scrub_secret_env(&mut launcher);
500
501 let output = launcher
502 .output()
503 .await
504 .map_err(|e| format!("failed to launch background command: {}", e))?;
505 if !output.status.success() {
506 return Err(format!(
507 "background launcher failed: {}",
508 String::from_utf8_lossy(&output.stderr)
509 ));
510 }
511 let stdout = String::from_utf8_lossy(&output.stdout);
512 stdout.trim().parse::<u32>().map_err(|e| {
513 format!(
514 "background launcher did not return a pid: {} ({})",
515 stdout, e
516 )
517 })
518}
519
520#[cfg(target_os = "windows")]
525async fn launch_background_process(
526 command: &str,
527 workdir: &Path,
528 log_path: &Path,
529) -> Result<u32, String> {
530 const DETACHED_PROCESS: u32 = 0x0000_0008;
534 const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
535 let log = std::fs::File::create(log_path).map_err(|e| {
536 format!(
537 "failed to create background log {}: {e}",
538 log_path.display()
539 )
540 })?;
541 let log_err = log
542 .try_clone()
543 .map_err(|e| format!("failed to clone background log handle: {e}"))?;
544 let mut launcher = Command::new("cmd");
545 launcher
546 .arg("/C")
547 .arg(command)
548 .current_dir(workdir)
549 .stdin(Stdio::null())
550 .stdout(Stdio::from(log))
551 .stderr(Stdio::from(log_err))
552 .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
553 scrub_secret_env(&mut launcher);
554 let child = launcher
555 .spawn()
556 .map_err(|e| format!("failed to launch background command: {e}"))?;
557 child
558 .id()
559 .ok_or_else(|| "background command produced no pid".to_string())
560}
561
562#[derive(Debug)]
563enum BackgroundWaitError {
564 Cancelled,
565 ExitedEarly(String),
566}
567
568async fn wait_for_background_startup(
569 pid: u32,
570 log_path: &Path,
571 startup_timeout_secs: u64,
572 ready_pattern: Option<&str>,
573 ctx: &ExecContext,
574) -> Result<BackgroundStartup, BackgroundWaitError> {
575 let start = Instant::now();
576 let startup_timeout = Duration::from_secs(startup_timeout_secs);
577
578 loop {
579 if ctx.token.is_cancelled() {
580 return Err(BackgroundWaitError::Cancelled);
581 }
582
583 let last_log = read_log_lossy(log_path).await;
584 let detected_url = first_url(&last_log);
585
586 if !process_running(pid).await {
587 return Err(BackgroundWaitError::ExitedEarly(tail_lines(&last_log, 40)));
588 }
589
590 if let Some(pattern) = ready_pattern {
591 if last_log.contains(pattern) {
592 return Ok(BackgroundStartup {
593 ready_message: format!("Ready: matched pattern {:?}", pattern),
594 log_excerpt: tail_lines(&last_log, 40),
595 detected_url,
596 });
597 }
598 } else if start.elapsed() >= Duration::from_secs(1) || !last_log.is_empty() {
599 return Ok(BackgroundStartup {
600 ready_message:
601 "Ready: no ready_pattern provided; process is running after startup check"
602 .to_string(),
603 log_excerpt: tail_lines(&last_log, 40),
604 detected_url,
605 });
606 }
607
608 if start.elapsed() >= startup_timeout {
609 let ready_message = if let Some(pattern) = ready_pattern {
610 format!(
611 "Ready: pattern {:?} was not seen within {}s; process is still running",
612 pattern, startup_timeout_secs
613 )
614 } else {
615 format!(
616 "Ready: startup check reached {}s; process is still running",
617 startup_timeout_secs
618 )
619 };
620 return Ok(BackgroundStartup {
621 ready_message,
622 log_excerpt: tail_lines(&last_log, 40),
623 detected_url,
624 });
625 }
626
627 tokio::select! {
628 _ = ctx.token.cancelled() => return Err(BackgroundWaitError::Cancelled),
629 _ = tokio::time::sleep(Duration::from_millis(200)) => {},
630 }
631 }
632}
633
634async fn read_log_lossy(path: &Path) -> String {
635 tokio::fs::read_to_string(path).await.unwrap_or_default()
636}
637
638#[cfg(not(target_os = "windows"))]
639async fn process_running(pid: u32) -> bool {
640 Command::new("kill")
641 .arg("-0")
642 .arg(pid.to_string())
643 .stdin(Stdio::null())
644 .stdout(Stdio::null())
645 .stderr(Stdio::null())
646 .status()
647 .await
648 .map(|status| status.success())
649 .unwrap_or(false)
650}
651
652#[cfg(target_os = "windows")]
655async fn process_running(pid: u32) -> bool {
656 Command::new("tasklist")
657 .args(["/FI", &format!("PID eq {pid}"), "/NH"])
658 .stdin(Stdio::null())
659 .stdout(Stdio::piped())
660 .stderr(Stdio::null())
661 .output()
662 .await
663 .map(|out| String::from_utf8_lossy(&out.stdout).contains(&pid.to_string()))
664 .unwrap_or(false)
665}
666
667#[cfg(not(target_os = "windows"))]
668async fn kill_background_process(pid: u32) -> std::io::Result<()> {
669 let _ = Command::new("kill")
670 .arg(pid.to_string())
671 .stdin(Stdio::null())
672 .stdout(Stdio::null())
673 .stderr(Stdio::null())
674 .status()
675 .await?;
676 Ok(())
677}
678
679#[cfg(target_os = "windows")]
682async fn kill_background_process(pid: u32) -> std::io::Result<()> {
683 let _ = Command::new("taskkill")
684 .args(["/PID", &pid.to_string(), "/T", "/F"])
685 .stdin(Stdio::null())
686 .stdout(Stdio::null())
687 .stderr(Stdio::null())
688 .status()
689 .await?;
690 Ok(())
691}
692
693fn background_log_path() -> PathBuf {
694 let nanos = std::time::SystemTime::now()
695 .duration_since(std::time::UNIX_EPOCH)
696 .map(|d| d.as_nanos())
697 .unwrap_or_default();
698 std::env::temp_dir().join(format!("mermaid-bg-{}-{}.log", std::process::id(), nanos))
699}
700
701struct CommandMetadataInput {
702 command: String,
703 working_dir: Option<String>,
704 exit_code: Option<i32>,
705 timed_out: bool,
706 background: bool,
707 stdout_lines: usize,
708 stderr_lines: usize,
709 detected_urls: Vec<String>,
710 pid: Option<u32>,
711 log_path: Option<String>,
712 byte_count: Option<usize>,
713}
714
715fn command_metadata(input: CommandMetadataInput) -> ToolRunMetadata {
716 ToolRunMetadata {
717 detail: ToolMetadata::ExecuteCommand {
718 command: input.command,
719 working_dir: input.working_dir,
720 exit_code: input.exit_code,
721 timed_out: input.timed_out,
722 background: input.background,
723 stdout_lines: input.stdout_lines,
724 stderr_lines: input.stderr_lines,
725 detected_urls: input.detected_urls,
726 pid: input.pid,
727 log_path: input.log_path,
728 },
729 line_count: Some(input.stdout_lines + input.stderr_lines),
730 byte_count: input.byte_count,
731 ..ToolRunMetadata::default()
732 }
733}
734
735fn tail_lines(text: &str, max_lines: usize) -> String {
736 let lines: Vec<&str> = text.lines().collect();
737 let start = lines.len().saturating_sub(max_lines);
738 lines[start..].join("\n")
739}
740
741fn first_url(text: &str) -> Option<String> {
742 text.split_whitespace()
743 .find(|part| part.starts_with("http://") || part.starts_with("https://"))
744 .map(|url| {
745 url.trim_matches(|c: char| matches!(c, ')' | ']' | '}' | ',' | ';' | '"' | '\''))
746 .to_string()
747 })
748}
749
750fn all_urls(text: &str) -> Vec<String> {
751 text.split_whitespace()
752 .filter(|part| part.starts_with("http://") || part.starts_with("https://"))
753 .map(|url| {
754 url.trim_matches(|c: char| matches!(c, ')' | ']' | '}' | ',' | ';' | '"' | '\''))
755 .to_string()
756 })
757 .collect()
758}
759
760async fn open_browser_url(url: &str) -> Result<(), String> {
761 #[cfg(target_os = "macos")]
762 let mut command = {
763 let mut cmd = Command::new("open");
764 cmd.arg(url);
765 cmd
766 };
767
768 #[cfg(target_os = "linux")]
769 let mut command = {
770 let mut cmd = Command::new("xdg-open");
771 cmd.arg(url);
772 cmd
773 };
774
775 #[cfg(target_os = "windows")]
776 let mut command = {
777 let mut cmd = Command::new("cmd");
778 cmd.args(["/C", "start", "", url]);
779 cmd
780 };
781
782 command
783 .stdin(Stdio::null())
784 .stdout(Stdio::null())
785 .stderr(Stdio::null())
786 .kill_on_drop(false)
787 .spawn()
788 .map(|_| ())
789 .map_err(|e| e.to_string())
790}
791
792#[derive(Debug, Clone)]
797struct CommandRunOutput {
798 output: String,
799 exit_code: Option<i32>,
800 stdout_lines: usize,
801 stderr_lines: usize,
802}
803
804enum CommandRunResult {
807 Completed(CommandRunOutput),
808 Detached { pid: u32, log_path: PathBuf },
809}
810
811const SECRET_ENV_VARS: &[&str] = &[
817 "ANTHROPIC_API_KEY",
818 "OPENAI_API_KEY",
819 "GEMINI_API_KEY",
820 "GOOGLE_API_KEY",
821 "OLLAMA_API_KEY",
822 "GROQ_API_KEY",
823 "MISTRAL_API_KEY",
824 "DEEPSEEK_API_KEY",
825 "OPENROUTER_API_KEY",
826 "XAI_API_KEY",
827 "TOGETHER_API_KEY",
828 "MERMAID_DAEMON_TOKEN",
829];
830
831fn scrub_secret_env(cmd: &mut Command) {
836 for (name, _) in std::env::vars() {
837 let upper = name.to_ascii_uppercase();
838 let looks_secret = SECRET_ENV_VARS.contains(&upper.as_str())
839 || upper.contains("API_KEY")
840 || upper.contains("APIKEY")
841 || upper.contains("ACCESS_KEY")
842 || upper.contains("SECRET")
843 || upper.contains("PASSWORD")
844 || upper.contains("PASSWD")
845 || upper.contains("CREDENTIAL")
846 || upper.contains("TOKEN");
847 if looks_secret {
848 cmd.env_remove(&name);
849 }
850 }
851}
852
853async fn read_capped<R: AsyncRead + Unpin>(
858 mut reader: R,
859 cap: usize,
860 progress: Option<tokio::sync::mpsc::Sender<ProgressEvent>>,
861 log: Option<std::sync::Arc<tokio::sync::Mutex<tokio::fs::File>>>,
862) -> (String, bool) {
863 let mut buf = [0u8; 8192];
864 let mut bytes: Vec<u8> = Vec::new();
865 let mut truncated = false;
866 loop {
867 match reader.read(&mut buf).await {
868 Ok(0) => break,
869 Ok(n) => {
870 if let Some(file) = &log {
873 let mut f = file.lock().await;
874 let _ = f.write_all(&buf[..n]).await;
875 let _ = f.flush().await;
876 }
877 if let Some(tx) = &progress {
878 let chunk = String::from_utf8_lossy(&buf[..n]);
879 for line in chunk.split('\n') {
880 if !line.is_empty() {
881 let _ = tx.send(ProgressEvent::Output(line.to_string())).await;
882 }
883 }
884 }
885 if bytes.len() < cap {
886 let take = (cap - bytes.len()).min(n);
887 bytes.extend_from_slice(&buf[..take]);
888 if take < n {
889 truncated = true;
890 }
891 } else {
892 truncated = true;
893 }
894 },
895 Err(_) => break,
896 }
897 }
898 let mut out = String::from_utf8_lossy(&bytes).into_owned();
899 if truncated {
900 out.push_str(&format!("\n…[output truncated at {} bytes]…", cap));
901 }
902 (out, truncated)
903}
904
905async fn run_command(
906 mut cmd: Command,
907 progress: tokio::sync::mpsc::Sender<ProgressEvent>,
908 background: tokio_util::sync::CancellationToken,
909) -> std::io::Result<CommandRunResult> {
910 let mut child = cmd.spawn()?;
911 let pid = child.id();
912
913 let stdout = child
914 .stdout
915 .take()
916 .ok_or_else(|| std::io::Error::other("child stdout unavailable"))?;
917 let stderr = child
918 .stderr
919 .take()
920 .ok_or_else(|| std::io::Error::other("child stderr unavailable"))?;
921
922 let log_path = background_log_path();
925 let log = tokio::fs::File::create(&log_path)
926 .await
927 .ok()
928 .map(|f| std::sync::Arc::new(tokio::sync::Mutex::new(f)));
929
930 let cap = crate::constants::MAX_TOOL_OUTPUT_BYTES;
931 let stdout_task = tokio::spawn(read_capped(
932 stdout,
933 cap,
934 Some(progress.clone()),
935 log.clone(),
936 ));
937 let stderr_task = tokio::spawn(read_capped(stderr, cap, None, log.clone()));
938
939 let (done_tx, done_rx) = tokio::sync::oneshot::channel();
944 let driver = tokio::spawn(async move {
945 let (output, _) = stdout_task.await.unwrap_or_default();
946 let (errors, _) = stderr_task.await.unwrap_or_default();
947 let status = child.wait().await;
948 let _ = done_tx.send((output, errors, status));
949 });
950
951 tokio::select! {
952 biased;
953 _ = background.cancelled() => {
954 drop(driver);
957 Ok(CommandRunResult::Detached { pid: pid.unwrap_or(0), log_path })
958 }
959 res = done_rx => {
960 drop(log);
962 let _ = tokio::fs::remove_file(&log_path).await;
963 let (output, errors, status) = res
964 .map_err(|_| std::io::Error::other("command driver dropped before completing"))?;
965 let status = status?;
966 let stdout_lines = output.lines().count();
967 let stderr_lines = errors.lines().count();
968 let mut full_output = output;
969 if !errors.is_empty() {
970 full_output.push_str("\n--- stderr ---\n");
971 full_output.push_str(&errors);
972 }
973 if !status.success() {
974 full_output.push_str(&format!(
975 "\n--- Command exited with status: {} ---",
976 status.code().unwrap_or(-1)
977 ));
978 }
979 Ok(CommandRunResult::Completed(CommandRunOutput {
980 output: full_output,
981 exit_code: status.code(),
982 stdout_lines,
983 stderr_lines,
984 }))
985 }
986 }
987}
988
989fn contains_dangerous_command(command: &str) -> bool {
1001 let dangerous_patterns = [
1002 "rm -rf /",
1003 "rm -rf /*",
1004 "dd if=/dev/zero of=/",
1005 "dd if=/dev/random of=/",
1006 "dd if=/dev/urandom of=/",
1007 "mkfs.",
1008 "format c:",
1009 "> /dev/sda",
1010 "chmod -R 777 /",
1011 "chmod -R 000 /",
1012 ":(){ :|:& };:",
1013 ":(){ :|:&};:",
1014 "curl | bash",
1015 "curl | sh",
1016 "wget | bash",
1017 "wget | sh",
1018 "nc -l",
1019 "ncat -l",
1020 "socat tcp-listen:",
1021 ];
1022
1023 let lower = command.to_lowercase();
1024 for pattern in &dangerous_patterns {
1025 if lower.contains(pattern) {
1026 return true;
1027 }
1028 }
1029
1030 let system_dir_patterns: [(&str, bool); 10] = [
1031 ("/etc", false),
1032 ("/usr", false),
1033 ("/boot", false),
1034 ("/proc", false),
1035 ("/sys", false),
1036 ("/dev/", true),
1037 ("/home", false),
1038 ("C:\\Windows", false),
1039 ("C:\\Program Files", false),
1040 ("C:\\Users", false),
1041 ];
1042
1043 let has_rm = lower.starts_with("rm ")
1044 || lower.contains(" rm ")
1045 || lower.contains(";rm ")
1046 || lower.contains("&rm ")
1047 || lower.contains("|rm ")
1048 || lower.contains("$(rm ")
1049 || lower.contains("`rm ");
1050 let has_del = lower.starts_with("del ")
1051 || lower.contains(" del ")
1052 || lower.contains(";del ")
1053 || lower.contains("&del ")
1054 || lower.contains("$(del ")
1055 || lower.contains("`del ");
1056
1057 if has_rm || has_del {
1058 for (dir, require_trailing) in &system_dir_patterns {
1059 if *require_trailing {
1060 if command.contains(dir)
1061 && !command.contains(&format!("{}null", dir))
1062 && !command.contains(&format!("{}zero", dir))
1063 {
1064 return true;
1065 }
1066 } else if command.contains(dir) {
1067 return true;
1068 }
1069 }
1070 if command.contains(" ~/")
1071 || command.ends_with(" ~")
1072 || command.contains(" ~ ")
1073 || command.contains("$HOME")
1074 {
1075 return true;
1076 }
1077 }
1078
1079 false
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084 use super::*;
1085 use crate::domain::{ToolCallId, TurnId};
1086 use crate::providers::ctx::test_exec_context;
1087 use std::path::PathBuf;
1088
1089 #[tokio::test]
1090 async fn safe_command_runs_and_captures_output() {
1091 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1092 let outcome = ExecuteCommandTool
1093 .execute(serde_json::json!({"command": "echo hello world"}), ctx)
1094 .await;
1095 assert!(outcome.is_success(), "expected success: {:?}", outcome);
1096 assert!(outcome.output().contains("hello world"));
1097 }
1098
1099 #[tokio::test]
1100 async fn dangerous_command_blocked() {
1101 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1102 let outcome = ExecuteCommandTool
1103 .execute(serde_json::json!({"command": "rm -rf /"}), ctx)
1104 .await;
1105 let error = outcome.error_message().expect("expected error");
1106 assert!(error.contains("Dangerous"));
1107 }
1108
1109 #[tokio::test]
1110 async fn cancellation_aborts_long_running_command() {
1111 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1112 let token = ctx.token.clone();
1113 let handle = tokio::spawn(async move {
1114 ExecuteCommandTool
1115 .execute(serde_json::json!({"command": "sleep 10"}), ctx)
1116 .await
1117 });
1118 tokio::time::sleep(Duration::from_millis(30)).await;
1120 token.cancel();
1121 let start = Instant::now();
1122 let outcome = tokio::time::timeout(Duration::from_secs(5), handle)
1125 .await
1126 .expect("didn't hang")
1127 .expect("join");
1128 let elapsed = start.elapsed();
1129 assert!(outcome.was_cancelled());
1130 assert!(
1134 elapsed < Duration::from_secs(2),
1135 "cancellation took {:?} — far slower than expected (regression?)",
1136 elapsed
1137 );
1138 }
1139
1140 #[tokio::test]
1141 async fn timeout_honored() {
1142 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1143 let outcome = ExecuteCommandTool
1144 .execute(serde_json::json!({"command": "sleep 5", "timeout": 1}), ctx)
1145 .await;
1146 assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
1147 let output = outcome.as_tool_message_content();
1148 assert!(output.contains("timed out"));
1149 assert!(output.contains("was killed"));
1150 assert!(output.contains("mode=\"background\""));
1151 }
1152
1153 #[cfg(not(target_os = "windows"))]
1154 #[tokio::test]
1155 async fn background_mode_returns_pid_log_and_detected_url() {
1156 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1157 let outcome = ExecuteCommandTool
1158 .execute(
1159 serde_json::json!({
1160 "command": "printf 'ready http://127.0.0.1:54321\\n'; exec sleep 30",
1161 "mode": "background",
1162 "startup_timeout_secs": 2,
1163 "ready_pattern": "ready"
1164 }),
1165 ctx,
1166 )
1167 .await;
1168
1169 assert!(
1170 outcome.is_success(),
1171 "expected background success: {:?}",
1172 outcome
1173 );
1174 let output = outcome.output().to_string();
1175 assert!(output.contains("Background command started"));
1176 assert!(output.contains("PID:"));
1177 assert!(output.contains("Log:"));
1178 assert!(output.contains("Ready: matched pattern"));
1179 assert!(output.contains("Detected URL: http://127.0.0.1:54321"));
1180
1181 if let Some(pid) = parse_pid(&output) {
1182 let _ = Command::new("kill").arg(pid.to_string()).status().await;
1183 }
1184 }
1185
1186 #[cfg(target_os = "windows")]
1187 #[tokio::test]
1188 async fn background_mode_returns_pid_and_log_on_windows() {
1189 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1190 let outcome = ExecuteCommandTool
1191 .execute(
1192 serde_json::json!({
1193 "command": "echo ready & ping -n 30 127.0.0.1",
1194 "mode": "background",
1195 "startup_timeout_secs": 3,
1196 "ready_pattern": "ready"
1197 }),
1198 ctx,
1199 )
1200 .await;
1201
1202 assert!(
1203 outcome.is_success(),
1204 "expected background success on Windows: {:?}",
1205 outcome
1206 );
1207 let output = outcome.output().to_string();
1208 assert!(output.contains("Background command started"));
1209 assert!(output.contains("PID:"));
1210 assert!(output.contains("Ready: matched pattern"));
1211 assert!(
1213 outcome.metadata.process.is_some(),
1214 "background outcome must carry a ManagedProcess"
1215 );
1216
1217 if let Some(pid) = parse_pid(&output) {
1219 let _ = kill_background_process(pid).await;
1220 }
1221 }
1222
1223 #[tokio::test]
1224 async fn ctrl_b_backgrounds_a_running_foreground_command() {
1225 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1226 let background = ctx.background.clone();
1227 let command = if cfg!(target_os = "windows") {
1229 "ping -n 30 127.0.0.1"
1230 } else {
1231 "sleep 30"
1232 };
1233
1234 let canceller = tokio::spawn(async move {
1236 tokio::time::sleep(Duration::from_millis(300)).await;
1237 background.cancel();
1238 });
1239 let outcome = ExecuteCommandTool
1240 .execute(
1241 serde_json::json!({ "command": command, "timeout": 60 }),
1242 ctx,
1243 )
1244 .await;
1245 let _ = canceller.await;
1246
1247 assert!(
1248 outcome.is_success(),
1249 "backgrounding should yield success: {:?}",
1250 outcome
1251 );
1252 let output = outcome.output().to_string();
1253 assert!(output.contains("Moved to background"), "got: {output}");
1254 let process = outcome.metadata.process.clone();
1256 assert!(
1257 process.is_some(),
1258 "background outcome must carry a ManagedProcess"
1259 );
1260
1261 if let Some(p) = process {
1263 let _ = kill_background_process(p.pid).await;
1264 }
1265 }
1266
1267 fn parse_pid(output: &str) -> Option<u32> {
1268 output
1269 .lines()
1270 .find_map(|line| line.strip_prefix("PID: "))
1271 .and_then(|pid| pid.trim().parse().ok())
1272 }
1273
1274 #[test]
1275 fn dangerous_detection_covers_known_shapes() {
1276 assert!(contains_dangerous_command("rm -rf /"));
1277 assert!(contains_dangerous_command(":(){ :|:& };:"));
1278 assert!(contains_dangerous_command("ncat -l 8080"));
1279 assert!(!contains_dangerous_command("ls -la"));
1280 assert!(!contains_dangerous_command("cargo build"));
1281 assert!(!contains_dangerous_command(
1282 r#"find . -type f ! -path "./.git/*" ! -path "./.mermaid/*" 2>/dev/null"#
1283 ));
1284 }
1285}