1use std::path::{Path, PathBuf};
24use std::process::Stdio;
25use std::time::{Duration, Instant};
26
27use async_trait::async_trait;
28use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
29use tokio::process::Command;
30
31use crate::constants::{COMMAND_MAX_TIMEOUT_SECS, COMMAND_TIMEOUT_SECS};
32use crate::domain::{
33 ManagedProcess, ManagedProcessStatus, ToolDefinition, ToolMetadata, ToolOutcome,
34 ToolRunMetadata,
35};
36
37use super::super::ctx::{ExecContext, ProgressEvent};
38use super::ToolExecutor;
39
40pub struct ExecuteCommandTool;
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52enum CommandMode {
53 Wait,
54 Background,
55}
56
57impl CommandMode {
58 fn parse(args: &serde_json::Value) -> Result<Self, String> {
59 match args.get("mode").and_then(|v| v.as_str()).unwrap_or("wait") {
60 "wait" | "foreground" => Ok(Self::Wait),
61 "background" => Ok(Self::Background),
62 other => Err(format!(
63 "execute_command: mode must be 'wait' or 'background', got '{}'",
64 other
65 )),
66 }
67 }
68}
69
70#[async_trait]
71impl ToolExecutor for ExecuteCommandTool {
72 fn name(&self) -> &'static str {
73 "execute_command"
74 }
75
76 fn schema(&self) -> ToolDefinition {
77 ToolDefinition {
78 name: "execute_command".to_string(),
79 description:
80 "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."
81 .to_string(),
82 input_schema: serde_json::json!({
83 "type": "object",
84 "properties": {
85 "command": { "type": "string", "description": "Shell command to run." },
86 "working_dir": { "type": "string", "description": "Override working directory (absolute)." },
87 "mode": {
88 "type": "string",
89 "enum": ["wait", "background"],
90 "default": "wait",
91 "description": "Use 'background' for long-running servers, daemons, and GUI launchers."
92 },
93 "timeout": {
94 "type": "integer",
95 "description": "Per-call foreground timeout in seconds. Default 30, max 300. Foreground timeout kills the child."
96 },
97 "startup_timeout_secs": {
98 "type": "integer",
99 "description": "Background mode: seconds to watch startup logs for readiness. Default 5, max 30."
100 },
101 "ready_pattern": {
102 "type": "string",
103 "description": "Background mode: text that marks the server/app ready when it appears in the startup log."
104 },
105 "open_url": {
106 "type": "string",
107 "description": "Background mode: URL to open with the default browser after startup."
108 }
109 },
110 "required": ["command"]
111 }),
112 }
113 }
114
115 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
116 let Some(command) = args.get("command").and_then(|v| v.as_str()) else {
117 return ToolOutcome::error("execute_command requires 'command' (string)", 0.0);
118 };
119
120 if contains_dangerous_command(command) {
121 return ToolOutcome::error(format!("Dangerous command blocked: {}", command), 0.0);
122 }
123
124 let (effective_workdir, within_project) = match args
129 .get("working_dir")
130 .and_then(|v| v.as_str())
131 {
132 Some(raw) => match super::path_safety::resolve_path_within(&ctx.workdir, raw) {
133 Ok(resolved) => resolved,
134 Err(e) => {
135 return ToolOutcome::error(format!("execute_command working_dir: {e}"), 0.0);
136 },
137 },
138 None => (ctx.workdir.clone(), true),
139 };
140
141 let category = if within_project {
142 crate::runtime::ToolCategory::Shell
143 } else {
144 crate::runtime::ToolCategory::ExternalDirectory
145 };
146 let mut policy_request =
147 crate::runtime::ActionRequest::new("execute_command", category, command.to_string());
148 policy_request.command = Some(command.to_string());
149 if !within_project {
150 policy_request.path = Some(effective_workdir.display().to_string());
151 }
152 let pending_action = serde_json::json!({
153 "tool": "execute_command",
154 "args": args.clone(),
155 "workdir": effective_workdir.display().to_string(),
156 "turn_id": ctx.turn.0,
157 "call_id": ctx.call_id.0,
158 "task_id": ctx.task_id.clone(),
159 });
160 match super::policy_gate::gate(&ctx, policy_request, &[], pending_action.clone(), true)
165 .await
166 {
167 super::policy_gate::Gate::Block(outcome) => return outcome,
168 super::policy_gate::Gate::Proceed { risk } => {
169 if ctx.config.safety.checkpoint_on_mutation
170 && risk != crate::runtime::RiskClass::ReadOnly
171 {
172 let _ = crate::runtime::create_checkpoint_for_task(
173 &ctx.workdir,
174 &[],
175 Some(pending_action.clone()),
176 ctx.task_id.clone(),
177 );
178 }
179 },
180 }
181
182 let mode = match CommandMode::parse(&args) {
183 Ok(mode) => mode,
184 Err(error) => return ToolOutcome::error(error, 0.0),
185 };
186 let shell_payload = serde_json::json!({
187 "task_id": ctx.task_id.clone(),
188 "turn_id": ctx.turn.0,
189 "call_id": ctx.call_id.0,
190 "command": command,
191 "working_dir": effective_workdir.display().to_string(),
192 });
193 let _ = crate::runtime::run_plugin_hooks("before_shell", &shell_payload);
194 if mode == CommandMode::Background {
195 let startup_timeout_secs = args
196 .get("startup_timeout_secs")
197 .or_else(|| args.get("startup_timeout"))
198 .and_then(|v| v.as_u64())
199 .unwrap_or(5)
200 .clamp(1, 30);
201 let ready_pattern = args
202 .get("ready_pattern")
203 .and_then(|v| v.as_str())
204 .map(str::to_string);
205 let open_url = args
206 .get("open_url")
207 .and_then(|v| v.as_str())
208 .filter(|v| !v.trim().is_empty())
209 .map(str::to_string);
210 let outcome = run_background_command(
211 command,
212 &effective_workdir,
213 startup_timeout_secs,
214 ready_pattern.as_deref(),
215 open_url.as_deref(),
216 ctx,
217 )
218 .await;
219 let _ = crate::runtime::run_plugin_hooks(
220 "after_shell",
221 &serde_json::json!({
222 "command": command,
223 "status": format!("{:?}", outcome.status),
224 "summary": &outcome.summary,
225 }),
226 );
227 return outcome;
228 }
229
230 let timeout_secs = args
231 .get("timeout")
232 .and_then(|v| v.as_u64())
233 .unwrap_or(COMMAND_TIMEOUT_SECS)
234 .min(COMMAND_MAX_TIMEOUT_SECS);
235
236 let command = command.to_string();
237 let start = Instant::now();
238 let progress = ctx.progress.clone();
239
240 let mut cmd = Command::new(if cfg!(target_os = "windows") {
244 "cmd"
245 } else {
246 "sh"
247 });
248 cmd.arg(if cfg!(target_os = "windows") { "/C" } else { "-c" })
249 .arg(&command)
250 .stdin(Stdio::null())
251 .stdout(Stdio::piped())
252 .stderr(Stdio::piped())
253 .kill_on_drop(false);
263
264 #[cfg(unix)]
267 cmd.process_group(0);
268
269 cmd.current_dir(&effective_workdir);
270 scrub_secret_env(&mut cmd);
271
272 let outcome = match run_command(
277 cmd,
278 progress,
279 ctx.token.clone(),
280 ctx.background.clone(),
281 Duration::from_secs(timeout_secs),
282 )
283 .await
284 {
285 Ok(CommandRunResult::Completed(run)) => {
286 let duration_secs = start.elapsed().as_secs_f64();
287 let output_len = run.output.len();
288 ToolOutcome::success(run.output.clone(), "command completed", duration_secs)
289 .with_metadata(command_metadata(CommandMetadataInput {
290 command: command.clone(),
291 working_dir: Some(effective_workdir.display().to_string()),
292 exit_code: run.exit_code,
293 timed_out: false,
294 background: false,
295 stdout_lines: run.stdout_lines,
296 stderr_lines: run.stderr_lines,
297 detected_urls: all_urls(&run.output),
298 pid: None,
299 log_path: None,
300 byte_count: Some(output_len),
301 }))
302 },
303 Ok(CommandRunResult::Detached { pid, log_path }) => {
304 let duration_secs = start.elapsed().as_secs_f64();
306 let log_path_str = log_path.display().to_string();
307 let output = format!(
308 "Moved to background.\nPID: {pid}\nLog: {log_path_str}\nManage it with /processes, /logs {pid}, /stop {pid}."
309 );
310 let process = ManagedProcess {
311 id: format!("bg-{pid}"),
312 pid,
313 command: command.to_string(),
314 cwd: Some(effective_workdir.display().to_string()),
315 log_path: log_path_str.clone(),
316 detected_url: None,
317 status: ManagedProcessStatus::Running,
318 };
319 let mut metadata = command_metadata(CommandMetadataInput {
320 command: command.to_string(),
321 working_dir: Some(effective_workdir.display().to_string()),
322 exit_code: None,
323 timed_out: false,
324 background: true,
325 stdout_lines: 0,
326 stderr_lines: 0,
327 detected_urls: Vec::new(),
328 pid: Some(pid),
329 log_path: Some(log_path_str),
330 byte_count: Some(output.len()),
331 });
332 metadata.process = Some(process);
333 ToolOutcome::success(output, "moved to background", duration_secs)
334 .with_metadata(metadata)
335 },
336 Ok(CommandRunResult::Cancelled) => ToolOutcome::cancelled(),
337 Ok(CommandRunResult::TimedOut) => {
338 let message = format!(
339 "Command timed out after {} seconds and was killed. \
340 For dev servers, GUI apps, or other long-running commands, call execute_command with mode=\"background\".",
341 timeout_secs
342 );
343 let duration_secs = start.elapsed().as_secs_f64();
344 ToolOutcome::error(message, duration_secs).with_metadata(command_metadata(
345 CommandMetadataInput {
346 command: command.clone(),
347 working_dir: Some(effective_workdir.display().to_string()),
348 exit_code: None,
349 timed_out: true,
350 background: false,
351 stdout_lines: 0,
352 stderr_lines: 0,
353 detected_urls: Vec::new(),
354 pid: None,
355 log_path: None,
356 byte_count: None,
357 },
358 ))
359 },
360 Err(e) => {
361 let duration_secs = start.elapsed().as_secs_f64();
362 ToolOutcome::error(format!("Command failed: {}", e), duration_secs).with_metadata(
363 command_metadata(CommandMetadataInput {
364 command: command.clone(),
365 working_dir: Some(effective_workdir.display().to_string()),
366 exit_code: None,
367 timed_out: false,
368 background: false,
369 stdout_lines: 0,
370 stderr_lines: 0,
371 detected_urls: Vec::new(),
372 pid: None,
373 log_path: None,
374 byte_count: None,
375 }),
376 )
377 },
378 };
379 let _ = crate::runtime::run_plugin_hooks(
380 "after_shell",
381 &serde_json::json!({
382 "command": command,
383 "status": format!("{:?}", outcome.status),
384 "summary": &outcome.summary,
385 }),
386 );
387 outcome
388 }
389}
390
391#[derive(Debug)]
392struct BackgroundStartup {
393 ready_message: String,
394 log_excerpt: String,
395 detected_url: Option<String>,
396}
397
398async fn run_background_command(
399 command: &str,
400 workdir: &Path,
401 startup_timeout_secs: u64,
402 ready_pattern: Option<&str>,
403 open_url: Option<&str>,
404 ctx: ExecContext,
405) -> ToolOutcome {
406 let start = Instant::now();
407
408 {
409 let log_path = background_log_path();
410 let pid = match launch_background_process(command, workdir, &log_path).await {
411 Ok(pid) => pid,
412 Err(error) => {
413 return ToolOutcome::error(error, start.elapsed().as_secs_f64());
414 },
415 };
416
417 let startup = match wait_for_background_startup(
418 pid,
419 &log_path,
420 startup_timeout_secs,
421 ready_pattern,
422 &ctx,
423 )
424 .await
425 {
426 Ok(startup) => startup,
427 Err(BackgroundWaitError::Cancelled) => {
428 crate::utils::terminate_tree(pid, crate::utils::Grace::Graceful).await;
429 return ToolOutcome::cancelled();
430 },
431 Err(BackgroundWaitError::ExitedEarly(log_excerpt)) => {
432 return ToolOutcome::error(
433 format!(
434 "Background command exited during startup. Log: {}\n\n{}",
435 log_path.display(),
436 log_excerpt
437 ),
438 start.elapsed().as_secs_f64(),
439 );
440 },
441 };
442
443 let opened = if let Some(url) = open_url {
444 Some((url.to_string(), open_browser_url(url).await))
445 } else {
446 None
447 };
448
449 let mut output = format!(
450 "Background command started.\nPID: {}\nLog: {}\n{}\n",
451 pid,
452 log_path.display(),
453 startup.ready_message
454 );
455 if let Some(url) = startup.detected_url.as_ref() {
456 output.push_str(&format!("Detected URL: {}\n", url));
457 }
458 if let Some((url, result)) = opened {
459 match result {
460 Ok(()) => output.push_str(&format!("Opened URL: {}\n", url)),
461 Err(error) => output.push_str(&format!("Open URL failed: {} ({})\n", url, error)),
462 }
463 }
464 if !startup.log_excerpt.trim().is_empty() {
465 output.push_str("\n--- startup output ---\n");
466 output.push_str(&startup.log_excerpt);
467 }
468
469 let duration_secs = start.elapsed().as_secs_f64();
470 let log_path_str = log_path.display().to_string();
471 let detected_urls = startup.detected_url.iter().cloned().collect::<Vec<_>>();
472 let process = ManagedProcess {
473 id: format!("bg-{}", pid),
474 pid,
475 command: command.to_string(),
476 cwd: Some(workdir.display().to_string()),
477 log_path: log_path_str.clone(),
478 detected_url: startup.detected_url.clone(),
479 status: ManagedProcessStatus::Running,
480 };
481 let byte_count = output.len();
482 let mut metadata = command_metadata(CommandMetadataInput {
483 command: command.to_string(),
484 working_dir: Some(workdir.display().to_string()),
485 exit_code: None,
486 timed_out: false,
487 background: true,
488 stdout_lines: startup.log_excerpt.lines().count(),
489 stderr_lines: 0,
490 detected_urls,
491 pid: Some(pid),
492 log_path: Some(log_path_str),
493 byte_count: Some(byte_count),
494 });
495 metadata.process = Some(process);
496 ToolOutcome::success(output, "background process started", duration_secs)
497 .with_metadata(metadata)
498 }
499}
500
501#[cfg(not(target_os = "windows"))]
502async fn launch_background_process(
503 command: &str,
504 workdir: &Path,
505 log_path: &Path,
506) -> Result<u32, String> {
507 create_log_file_blocking(log_path).map_err(|e| {
513 format!(
514 "failed to create background log {}: {e}",
515 log_path.display()
516 )
517 })?;
518 let mut launcher = Command::new("sh");
519 launcher
520 .arg("-c")
521 .arg(
522 r#"log=$MERMAID_BG_LOG
528cmd=$MERMAID_BG_COMMAND
529: > "$log" || exit 125
530if command -v setsid >/dev/null 2>&1; then
531 setsid sh -c "$cmd" > "$log" 2>&1 < /dev/null &
532else
533 nohup sh -c "$cmd" > "$log" 2>&1 < /dev/null &
534fi
535printf '%s\n' "$!""#,
536 )
537 .env("MERMAID_BG_LOG", log_path)
538 .env("MERMAID_BG_COMMAND", command)
539 .current_dir(workdir)
540 .stdin(Stdio::null())
541 .stdout(Stdio::piped())
542 .stderr(Stdio::piped());
543 scrub_secret_env(&mut launcher);
544
545 let output = launcher
546 .output()
547 .await
548 .map_err(|e| format!("failed to launch background command: {}", e))?;
549 if !output.status.success() {
550 return Err(format!(
551 "background launcher failed: {}",
552 String::from_utf8_lossy(&output.stderr)
553 ));
554 }
555 let stdout = String::from_utf8_lossy(&output.stdout);
556 stdout.trim().parse::<u32>().map_err(|e| {
557 format!(
558 "background launcher did not return a pid: {} ({})",
559 stdout, e
560 )
561 })
562}
563
564#[cfg(target_os = "windows")]
569async fn launch_background_process(
570 command: &str,
571 workdir: &Path,
572 log_path: &Path,
573) -> Result<u32, String> {
574 const DETACHED_PROCESS: u32 = 0x0000_0008;
578 const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
579 let log = std::fs::File::create(log_path).map_err(|e| {
580 format!(
581 "failed to create background log {}: {e}",
582 log_path.display()
583 )
584 })?;
585 let log_err = log
586 .try_clone()
587 .map_err(|e| format!("failed to clone background log handle: {e}"))?;
588 let mut launcher = Command::new("cmd");
589 launcher
590 .arg("/C")
591 .arg(command)
592 .current_dir(workdir)
593 .stdin(Stdio::null())
594 .stdout(Stdio::from(log))
595 .stderr(Stdio::from(log_err))
596 .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
597 scrub_secret_env(&mut launcher);
598 let child = launcher
599 .spawn()
600 .map_err(|e| format!("failed to launch background command: {e}"))?;
601 child
602 .id()
603 .ok_or_else(|| "background command produced no pid".to_string())
604}
605
606#[derive(Debug)]
607enum BackgroundWaitError {
608 Cancelled,
609 ExitedEarly(String),
610}
611
612async fn wait_for_background_startup(
613 pid: u32,
614 log_path: &Path,
615 startup_timeout_secs: u64,
616 ready_pattern: Option<&str>,
617 ctx: &ExecContext,
618) -> Result<BackgroundStartup, BackgroundWaitError> {
619 let start = Instant::now();
620 let startup_timeout = Duration::from_secs(startup_timeout_secs);
621
622 loop {
623 if ctx.token.is_cancelled() {
624 return Err(BackgroundWaitError::Cancelled);
625 }
626
627 let last_log = read_log_lossy(log_path).await;
628 let detected_url = first_url(&last_log);
629
630 if !process_running(pid).await {
631 return Err(BackgroundWaitError::ExitedEarly(tail_lines(&last_log, 40)));
632 }
633
634 if let Some(pattern) = ready_pattern {
635 if last_log.contains(pattern) {
636 return Ok(BackgroundStartup {
637 ready_message: format!("Ready: matched pattern {:?}", pattern),
638 log_excerpt: tail_lines(&last_log, 40),
639 detected_url,
640 });
641 }
642 } else if start.elapsed() >= Duration::from_secs(1) || !last_log.is_empty() {
643 return Ok(BackgroundStartup {
644 ready_message:
645 "Ready: no ready_pattern provided; process is running after startup check"
646 .to_string(),
647 log_excerpt: tail_lines(&last_log, 40),
648 detected_url,
649 });
650 }
651
652 if start.elapsed() >= startup_timeout {
653 let ready_message = if let Some(pattern) = ready_pattern {
654 format!(
655 "Ready: pattern {:?} was not seen within {}s; process is still running",
656 pattern, startup_timeout_secs
657 )
658 } else {
659 format!(
660 "Ready: startup check reached {}s; process is still running",
661 startup_timeout_secs
662 )
663 };
664 return Ok(BackgroundStartup {
665 ready_message,
666 log_excerpt: tail_lines(&last_log, 40),
667 detected_url,
668 });
669 }
670
671 tokio::select! {
672 _ = ctx.token.cancelled() => return Err(BackgroundWaitError::Cancelled),
673 _ = tokio::time::sleep(Duration::from_millis(200)) => {},
674 }
675 }
676}
677
678async fn read_log_lossy(path: &Path) -> String {
679 tokio::fs::read_to_string(path).await.unwrap_or_default()
680}
681
682#[cfg(not(target_os = "windows"))]
683async fn process_running(pid: u32) -> bool {
684 Command::new("kill")
685 .arg("-0")
686 .arg(pid.to_string())
687 .stdin(Stdio::null())
688 .stdout(Stdio::null())
689 .stderr(Stdio::null())
690 .status()
691 .await
692 .map(|status| status.success())
693 .unwrap_or(false)
694}
695
696#[cfg(target_os = "windows")]
699async fn process_running(pid: u32) -> bool {
700 Command::new("tasklist")
701 .args(["/FI", &format!("PID eq {pid}"), "/NH"])
702 .stdin(Stdio::null())
703 .stdout(Stdio::piped())
704 .stderr(Stdio::null())
705 .output()
706 .await
707 .map(|out| String::from_utf8_lossy(&out.stdout).contains(&pid.to_string()))
708 .unwrap_or(false)
709}
710
711fn background_log_path() -> PathBuf {
723 let nanos = std::time::SystemTime::now()
724 .duration_since(std::time::UNIX_EPOCH)
725 .map(|d| d.as_nanos())
726 .unwrap_or_default();
727 let name = format!("mermaid-bg-{}-{}.log", std::process::id(), nanos);
728 match crate::utils::private_temp_dir() {
729 Ok(dir) => dir.join(name),
730 Err(_) => std::env::temp_dir().join(name),
731 }
732}
733
734#[cfg(unix)]
741fn create_log_file_blocking(path: &Path) -> std::io::Result<std::fs::File> {
742 use std::os::unix::fs::OpenOptionsExt;
743 std::fs::OpenOptions::new()
744 .write(true)
745 .create_new(true)
746 .mode(0o600)
747 .open(path)
748}
749
750fn create_tee_log_blocking(path: &Path) -> Option<tokio::fs::File> {
755 #[cfg(unix)]
756 let std_file = create_log_file_blocking(path).ok();
757 #[cfg(not(unix))]
758 let std_file = std::fs::File::create(path).ok();
759 std_file.map(tokio::fs::File::from_std)
760}
761
762struct CommandMetadataInput {
763 command: String,
764 working_dir: Option<String>,
765 exit_code: Option<i32>,
766 timed_out: bool,
767 background: bool,
768 stdout_lines: usize,
769 stderr_lines: usize,
770 detected_urls: Vec<String>,
771 pid: Option<u32>,
772 log_path: Option<String>,
773 byte_count: Option<usize>,
774}
775
776fn command_metadata(input: CommandMetadataInput) -> ToolRunMetadata {
777 ToolRunMetadata {
778 detail: ToolMetadata::ExecuteCommand {
779 command: input.command,
780 working_dir: input.working_dir,
781 exit_code: input.exit_code,
782 timed_out: input.timed_out,
783 background: input.background,
784 stdout_lines: input.stdout_lines,
785 stderr_lines: input.stderr_lines,
786 detected_urls: input.detected_urls,
787 pid: input.pid,
788 log_path: input.log_path,
789 },
790 line_count: Some(input.stdout_lines + input.stderr_lines),
791 byte_count: input.byte_count,
792 ..ToolRunMetadata::default()
793 }
794}
795
796fn tail_lines(text: &str, max_lines: usize) -> String {
797 let lines: Vec<&str> = text.lines().collect();
798 let start = lines.len().saturating_sub(max_lines);
799 lines[start..].join("\n")
800}
801
802fn first_url(text: &str) -> Option<String> {
803 text.split_whitespace()
804 .find(|part| part.starts_with("http://") || part.starts_with("https://"))
805 .map(|url| {
806 url.trim_matches(|c: char| matches!(c, ')' | ']' | '}' | ',' | ';' | '"' | '\''))
807 .to_string()
808 })
809}
810
811fn all_urls(text: &str) -> Vec<String> {
812 text.split_whitespace()
813 .filter(|part| part.starts_with("http://") || part.starts_with("https://"))
814 .map(|url| {
815 url.trim_matches(|c: char| matches!(c, ')' | ']' | '}' | ',' | ';' | '"' | '\''))
816 .to_string()
817 })
818 .collect()
819}
820
821async fn open_browser_url(url: &str) -> Result<(), String> {
822 #[cfg(target_os = "macos")]
823 let mut command = {
824 let mut cmd = Command::new("open");
825 cmd.arg(url);
826 cmd
827 };
828
829 #[cfg(target_os = "linux")]
830 let mut command = {
831 let mut cmd = Command::new("xdg-open");
832 cmd.arg(url);
833 cmd
834 };
835
836 #[cfg(target_os = "windows")]
837 let mut command = {
838 let mut cmd = Command::new("cmd");
839 cmd.args(["/C", "start", "", url]);
840 cmd
841 };
842
843 command
844 .stdin(Stdio::null())
845 .stdout(Stdio::null())
846 .stderr(Stdio::null())
847 .kill_on_drop(false)
848 .spawn()
849 .map(|_| ())
850 .map_err(|e| e.to_string())
851}
852
853#[derive(Debug, Clone)]
858struct CommandRunOutput {
859 output: String,
860 exit_code: Option<i32>,
861 stdout_lines: usize,
862 stderr_lines: usize,
863}
864
865enum CommandRunResult {
870 Completed(CommandRunOutput),
871 Detached { pid: u32, log_path: PathBuf },
872 Cancelled,
873 TimedOut,
874}
875
876const SECRET_ENV_VARS: &[&str] = &[
882 "ANTHROPIC_API_KEY",
883 "OPENAI_API_KEY",
884 "GEMINI_API_KEY",
885 "GOOGLE_API_KEY",
886 "OLLAMA_API_KEY",
887 "GROQ_API_KEY",
888 "MISTRAL_API_KEY",
889 "DEEPSEEK_API_KEY",
890 "OPENROUTER_API_KEY",
891 "XAI_API_KEY",
892 "TOGETHER_API_KEY",
893 "MERMAID_DAEMON_TOKEN",
894];
895
896fn scrub_secret_env(cmd: &mut Command) {
901 for (name, _) in std::env::vars() {
902 if is_secret_env_name(&name) {
903 cmd.env_remove(&name);
904 }
905 }
906}
907
908fn is_secret_env_name(name: &str) -> bool {
912 let upper = name.to_ascii_uppercase();
913 SECRET_ENV_VARS.contains(&upper.as_str())
914 || upper.contains("API_KEY")
915 || upper.contains("APIKEY")
916 || upper.contains("ACCESS_KEY")
917 || upper.contains("PRIVATE_KEY")
918 || upper.contains("SECRET")
919 || upper.contains("PASSWORD")
920 || upper.contains("PASSWD")
921 || upper.contains("CREDENTIAL")
922 || upper.contains("TOKEN")
923 || upper.contains("WEBHOOK")
924 || upper.contains("DATABASE_URL")
925 || upper.ends_with("_DSN")
926 || upper.contains("CONNECTION_STRING")
927 || upper == "KUBECONFIG"
928 || upper == "SSH_AUTH_SOCK"
929}
930
931const TEE_LOG_CAP_BYTES: usize = 64 * 1024 * 1024;
940
941async fn read_capped<R: AsyncRead + Unpin>(
942 mut reader: R,
943 cap: usize,
944 log_cap: usize,
945 progress: Option<tokio::sync::mpsc::Sender<ProgressEvent>>,
946 log: Option<std::sync::Arc<tokio::sync::Mutex<tokio::fs::File>>>,
947) -> (String, bool) {
948 let mut buf = [0u8; 8192];
949 let mut bytes: Vec<u8> = Vec::new();
950 let mut truncated = false;
951 let mut logged: usize = 0;
952 let mut log_capped = false;
953 loop {
954 match reader.read(&mut buf).await {
955 Ok(0) => break,
956 Ok(n) => {
957 if let Some(file) = &log
962 && !log_capped
963 {
964 let mut f = file.lock().await;
965 if logged + n <= log_cap {
966 let _ = f.write_all(&buf[..n]).await;
967 logged += n;
968 } else {
969 let remaining = log_cap - logged;
970 let _ = f.write_all(&buf[..remaining]).await;
971 let _ = f.write_all(b"\n...[log truncated]...\n").await;
972 log_capped = true;
973 }
974 let _ = f.flush().await;
975 }
976 if let Some(tx) = &progress {
977 let chunk = String::from_utf8_lossy(&buf[..n]);
978 for line in chunk.split('\n') {
979 if !line.is_empty() {
980 let _ = tx.send(ProgressEvent::Output(line.to_string())).await;
981 }
982 }
983 }
984 if bytes.len() < cap {
985 let take = (cap - bytes.len()).min(n);
986 bytes.extend_from_slice(&buf[..take]);
987 if take < n {
988 truncated = true;
989 }
990 } else {
991 truncated = true;
992 }
993 },
994 Err(_) => break,
995 }
996 }
997 let mut out = String::from_utf8_lossy(&bytes).into_owned();
998 if truncated {
999 out.push_str(&format!("\n…[output truncated at {} bytes]…", cap));
1000 }
1001 (out, truncated)
1002}
1003
1004async fn run_command(
1005 mut cmd: Command,
1006 progress: tokio::sync::mpsc::Sender<ProgressEvent>,
1007 token: tokio_util::sync::CancellationToken,
1008 background: tokio_util::sync::CancellationToken,
1009 timeout: Duration,
1010) -> std::io::Result<CommandRunResult> {
1011 let mut child = cmd.spawn()?;
1012 let pid = child.id();
1013
1014 let stdout = child
1015 .stdout
1016 .take()
1017 .ok_or_else(|| std::io::Error::other("child stdout unavailable"))?;
1018 let stderr = child
1019 .stderr
1020 .take()
1021 .ok_or_else(|| std::io::Error::other("child stderr unavailable"))?;
1022
1023 let log_path = background_log_path();
1027 let log =
1028 create_tee_log_blocking(&log_path).map(|f| std::sync::Arc::new(tokio::sync::Mutex::new(f)));
1029
1030 let cap = crate::constants::MAX_TOOL_OUTPUT_BYTES;
1031 let stdout_task = tokio::spawn(read_capped(
1032 stdout,
1033 cap,
1034 TEE_LOG_CAP_BYTES,
1035 Some(progress.clone()),
1036 log.clone(),
1037 ));
1038 let stderr_task = tokio::spawn(read_capped(
1039 stderr,
1040 cap,
1041 TEE_LOG_CAP_BYTES,
1042 None,
1043 log.clone(),
1044 ));
1045
1046 let (done_tx, done_rx) = tokio::sync::oneshot::channel();
1051 let driver = tokio::spawn(async move {
1052 let (output, _) = stdout_task.await.unwrap_or_default();
1053 let (errors, _) = stderr_task.await.unwrap_or_default();
1054 let status = child.wait().await;
1055 let _ = done_tx.send((output, errors, status));
1056 });
1057
1058 let timeout_fut = tokio::time::sleep(timeout);
1059
1060 tokio::select! {
1061 biased;
1062 _ = background.cancelled() => {
1063 match pid {
1064 Some(pid) => {
1068 drop(driver);
1069 Ok(CommandRunResult::Detached { pid, log_path })
1070 }
1071 None => {
1076 driver.abort();
1077 let _ = tokio::fs::remove_file(&log_path).await;
1078 Ok(CommandRunResult::Cancelled)
1079 }
1080 }
1081 }
1082 _ = token.cancelled() => {
1083 if let Some(p) = pid {
1087 crate::utils::terminate_tree(p, crate::utils::Grace::Immediate).await;
1088 }
1089 driver.abort();
1097 let _ = tokio::fs::remove_file(&log_path).await;
1098 Ok(CommandRunResult::Cancelled)
1099 }
1100 res = done_rx => {
1101 drop(log);
1103 let _ = tokio::fs::remove_file(&log_path).await;
1104 let (output, errors, status) = res
1105 .map_err(|_| std::io::Error::other("command driver dropped before completing"))?;
1106 let status = status?;
1107 let stdout_lines = output.lines().count();
1108 let stderr_lines = errors.lines().count();
1109 let mut full_output = output;
1110 if !errors.is_empty() {
1111 full_output.push_str("\n--- stderr ---\n");
1112 full_output.push_str(&errors);
1113 }
1114 if !status.success() {
1115 full_output.push_str(&format!(
1116 "\n--- Command exited with status: {} ---",
1117 status.code().unwrap_or(-1)
1118 ));
1119 }
1120 Ok(CommandRunResult::Completed(CommandRunOutput {
1121 output: full_output,
1122 exit_code: status.code(),
1123 stdout_lines,
1124 stderr_lines,
1125 }))
1126 }
1127 _ = timeout_fut => {
1128 if let Some(p) = pid {
1134 crate::utils::terminate_tree(p, crate::utils::Grace::Immediate).await;
1135 }
1136 driver.abort();
1137 let _ = tokio::fs::remove_file(&log_path).await;
1138 Ok(CommandRunResult::TimedOut)
1139 }
1140 }
1141}
1142
1143fn contains_dangerous_command(command: &str) -> bool {
1152 crate::runtime::is_destructive_command(command)
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157 use super::*;
1158 use crate::domain::{ToolCallId, TurnId};
1159 use crate::providers::ctx::test_exec_context;
1160 use std::path::PathBuf;
1161
1162 #[tokio::test]
1163 async fn tee_log_is_capped() {
1164 let dir = std::env::temp_dir().join(format!("mermaid_teelog_{}", std::process::id()));
1168 let _ = std::fs::create_dir_all(&dir);
1169 let path = dir.join("log.txt");
1170 let file = tokio::fs::File::create(&path).await.unwrap();
1171 let log = std::sync::Arc::new(tokio::sync::Mutex::new(file));
1172 let data = vec![b'x'; 4000];
1174 let _ = read_capped(&data[..], 1_000_000, 16, None, Some(log)).await;
1175 let written = std::fs::read(&path).unwrap();
1176 assert!(
1177 written.len() < 200,
1178 "log must be capped near 16 bytes + marker, got {}",
1179 written.len()
1180 );
1181 assert!(String::from_utf8_lossy(&written).contains("log truncated"));
1182 let _ = std::fs::remove_dir_all(&dir);
1183 }
1184
1185 #[cfg(unix)]
1186 #[test]
1187 fn tee_log_created_owner_only_and_refuses_existing() {
1188 use std::os::unix::fs::PermissionsExt;
1193 let dir = std::env::temp_dir().join(format!("mermaid_loghard_{}", std::process::id()));
1194 let _ = std::fs::create_dir_all(&dir);
1195 let path = dir.join("bg.log");
1196 let _ = std::fs::remove_file(&path);
1197
1198 let file = create_log_file_blocking(&path).expect("first create succeeds");
1199 drop(file);
1200 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1201 assert_eq!(mode, 0o600, "tee log must be owner-only, got {mode:o}");
1202
1203 assert!(
1206 create_log_file_blocking(&path).is_err(),
1207 "O_EXCL must refuse an existing path"
1208 );
1209 let _ = std::fs::remove_dir_all(&dir);
1210 }
1211
1212 #[test]
1213 fn secret_env_name_denylist_covers_common_carriers() {
1214 for name in [
1216 "ANTHROPIC_API_KEY",
1217 "AWS_SECRET_ACCESS_KEY",
1218 "GITHUB_TOKEN",
1219 "MY_SERVICE_PRIVATE_KEY",
1220 "DATABASE_URL",
1221 "SENTRY_DSN",
1222 "SLACK_WEBHOOK_URL",
1223 "KUBECONFIG",
1224 "SSH_AUTH_SOCK",
1225 "DB_PASSWORD",
1226 "PG_CONNECTION_STRING",
1227 ] {
1228 assert!(is_secret_env_name(name), "{name} should be scrubbed");
1229 }
1230 for name in [
1232 "PATH",
1233 "HOME",
1234 "CARGO_HOME",
1235 "LANG",
1236 "XAUTHORITY",
1237 "RUSTUP_HOME",
1238 ] {
1239 assert!(!is_secret_env_name(name), "{name} should NOT be scrubbed");
1240 }
1241 }
1242
1243 #[tokio::test]
1244 async fn out_of_project_working_dir_is_escalated_and_blocked() {
1245 let project = std::env::temp_dir().join(format!("mermaid_wd_{}", std::process::id()));
1250 let _ = std::fs::remove_dir_all(&project);
1251 std::fs::create_dir_all(&project).unwrap();
1252 let outside = project.parent().unwrap().to_path_buf();
1253
1254 let mk_ctx = || {
1255 let (tx, rx) = tokio::sync::mpsc::channel(64);
1256 let mut config = crate::app::Config::default();
1257 config.safety.mode = crate::runtime::SafetyMode::ReadOnly;
1258 let ctx = crate::providers::ctx::ExecContext::new(
1259 tokio_util::sync::CancellationToken::new(),
1260 tx,
1261 ToolCallId(1),
1262 TurnId(1),
1263 project.clone(),
1264 std::sync::Arc::new(config),
1265 String::new(),
1266 None,
1267 crate::runtime::SafetyMode::ReadOnly,
1268 None,
1269 None,
1270 None,
1271 );
1272 (ctx, rx)
1273 };
1274
1275 let (ctx, _rx) = mk_ctx();
1276 let outcome = ExecuteCommandTool
1277 .execute(serde_json::json!({"command": "echo hi"}), ctx)
1278 .await;
1279 assert!(
1280 outcome.is_success(),
1281 "in-project read-only echo should run: {outcome:?}",
1282 );
1283
1284 let (ctx, _rx) = mk_ctx();
1285 let outcome = ExecuteCommandTool
1286 .execute(
1287 serde_json::json!({
1288 "command": "echo hi",
1289 "working_dir": outside.display().to_string(),
1290 }),
1291 ctx,
1292 )
1293 .await;
1294 assert_eq!(
1295 outcome.status,
1296 crate::domain::ToolStatus::Error,
1297 "out-of-project working_dir must be escalated + blocked: {outcome:?}",
1298 );
1299
1300 let _ = std::fs::remove_dir_all(&project);
1301 }
1302
1303 #[tokio::test]
1304 async fn safe_command_runs_and_captures_output() {
1305 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1306 let outcome = ExecuteCommandTool
1307 .execute(serde_json::json!({"command": "echo hello world"}), ctx)
1308 .await;
1309 assert!(outcome.is_success(), "expected success: {:?}", outcome);
1310 assert!(outcome.output().contains("hello world"));
1311 }
1312
1313 #[tokio::test]
1314 async fn dangerous_command_blocked() {
1315 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1316 let outcome = ExecuteCommandTool
1317 .execute(serde_json::json!({"command": "rm -rf /"}), ctx)
1318 .await;
1319 let error = outcome.error_message().expect("expected error");
1320 assert!(error.contains("Dangerous"));
1321 }
1322
1323 #[tokio::test]
1324 async fn cancellation_aborts_long_running_command() {
1325 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1326 let token = ctx.token.clone();
1327 let handle = tokio::spawn(async move {
1328 ExecuteCommandTool
1329 .execute(serde_json::json!({"command": "sleep 10"}), ctx)
1330 .await
1331 });
1332 tokio::time::sleep(Duration::from_millis(30)).await;
1334 token.cancel();
1335 let start = Instant::now();
1336 let outcome = tokio::time::timeout(Duration::from_secs(5), handle)
1339 .await
1340 .expect("didn't hang")
1341 .expect("join");
1342 let elapsed = start.elapsed();
1343 assert!(outcome.was_cancelled());
1344 assert!(
1348 elapsed < Duration::from_secs(2),
1349 "cancellation took {:?} — far slower than expected (regression?)",
1350 elapsed
1351 );
1352 }
1353
1354 #[tokio::test]
1355 async fn timeout_honored() {
1356 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1357 let outcome = ExecuteCommandTool
1358 .execute(serde_json::json!({"command": "sleep 5", "timeout": 1}), ctx)
1359 .await;
1360 assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
1361 let output = outcome.as_tool_message_content();
1362 assert!(output.contains("timed out"));
1363 assert!(output.contains("was killed"));
1364 assert!(output.contains("mode=\"background\""));
1365 }
1366
1367 #[cfg(not(target_os = "windows"))]
1372 #[tokio::test]
1373 async fn timeout_kills_process_tree() {
1374 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1375 let marker =
1377 std::env::temp_dir().join(format!("mermaid_timeout_pgid_{}.pid", std::process::id()));
1378 let _ = std::fs::remove_file(&marker);
1379 let command = format!(
1380 "sh -c 'echo $$ > {}; sleep 30' & sleep 30",
1381 marker.display()
1382 );
1383 let outcome = ExecuteCommandTool
1384 .execute(serde_json::json!({ "command": command, "timeout": 1 }), ctx)
1385 .await;
1386 assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
1387
1388 let mut pid = None;
1391 for _ in 0..30 {
1392 if let Ok(s) = std::fs::read_to_string(&marker)
1393 && let Ok(p) = s.trim().parse::<u32>()
1394 {
1395 pid = Some(p);
1396 break;
1397 }
1398 tokio::time::sleep(Duration::from_millis(50)).await;
1399 }
1400 let pid = pid.expect("grandchild never recorded its pid");
1401
1402 let mut alive = true;
1404 for _ in 0..40 {
1405 if !process_running(pid).await {
1406 alive = false;
1407 break;
1408 }
1409 tokio::time::sleep(Duration::from_millis(50)).await;
1410 }
1411 let _ = std::fs::remove_file(&marker);
1412 assert!(!alive, "grandchild pid {pid} leaked past the timeout");
1413 }
1414
1415 #[cfg(not(target_os = "windows"))]
1416 #[tokio::test]
1417 async fn background_mode_returns_pid_log_and_detected_url() {
1418 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1419 let outcome = ExecuteCommandTool
1420 .execute(
1421 serde_json::json!({
1422 "command": "printf 'ready http://127.0.0.1:54321\\n'; exec sleep 30",
1423 "mode": "background",
1424 "startup_timeout_secs": 2,
1425 "ready_pattern": "ready"
1426 }),
1427 ctx,
1428 )
1429 .await;
1430
1431 assert!(
1432 outcome.is_success(),
1433 "expected background success: {:?}",
1434 outcome
1435 );
1436 let output = outcome.output().to_string();
1437 assert!(output.contains("Background command started"));
1438 assert!(output.contains("PID:"));
1439 assert!(output.contains("Log:"));
1440 assert!(output.contains("Ready: matched pattern"));
1441 assert!(output.contains("Detected URL: http://127.0.0.1:54321"));
1442
1443 if let Some(pid) = parse_pid(&output) {
1444 let _ = Command::new("kill").arg(pid.to_string()).status().await;
1445 }
1446 }
1447
1448 #[cfg(target_os = "windows")]
1449 #[tokio::test]
1450 async fn background_mode_returns_pid_and_log_on_windows() {
1451 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1452 let outcome = ExecuteCommandTool
1453 .execute(
1454 serde_json::json!({
1455 "command": "echo ready & ping -n 30 127.0.0.1",
1456 "mode": "background",
1457 "startup_timeout_secs": 3,
1458 "ready_pattern": "ready"
1459 }),
1460 ctx,
1461 )
1462 .await;
1463
1464 assert!(
1465 outcome.is_success(),
1466 "expected background success on Windows: {:?}",
1467 outcome
1468 );
1469 let output = outcome.output().to_string();
1470 assert!(output.contains("Background command started"));
1471 assert!(output.contains("PID:"));
1472 assert!(output.contains("Ready: matched pattern"));
1473 assert!(
1475 outcome.metadata.process.is_some(),
1476 "background outcome must carry a ManagedProcess"
1477 );
1478
1479 if let Some(pid) = parse_pid(&output) {
1481 crate::utils::terminate_tree(pid, crate::utils::Grace::Graceful).await;
1482 }
1483 }
1484
1485 #[tokio::test]
1486 async fn ctrl_b_backgrounds_a_running_foreground_command() {
1487 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), std::env::temp_dir());
1488 let background = ctx.background.clone();
1489 let command = if cfg!(target_os = "windows") {
1491 "ping -n 30 127.0.0.1"
1492 } else {
1493 "sleep 30"
1494 };
1495
1496 let canceller = tokio::spawn(async move {
1498 tokio::time::sleep(Duration::from_millis(300)).await;
1499 background.cancel();
1500 });
1501 let outcome = ExecuteCommandTool
1502 .execute(
1503 serde_json::json!({ "command": command, "timeout": 60 }),
1504 ctx,
1505 )
1506 .await;
1507 let _ = canceller.await;
1508
1509 assert!(
1510 outcome.is_success(),
1511 "backgrounding should yield success: {:?}",
1512 outcome
1513 );
1514 let output = outcome.output().to_string();
1515 assert!(output.contains("Moved to background"), "got: {output}");
1516 let process = outcome.metadata.process.clone();
1518 assert!(
1519 process.is_some(),
1520 "background outcome must carry a ManagedProcess"
1521 );
1522
1523 if let Some(p) = process {
1525 crate::utils::terminate_tree(p.pid, crate::utils::Grace::Graceful).await;
1526 }
1527 }
1528
1529 fn parse_pid(output: &str) -> Option<u32> {
1530 output
1531 .lines()
1532 .find_map(|line| line.strip_prefix("PID: "))
1533 .and_then(|pid| pid.trim().parse().ok())
1534 }
1535
1536 #[test]
1537 fn dangerous_detection_covers_known_shapes() {
1538 assert!(contains_dangerous_command("rm -rf /"));
1539 assert!(contains_dangerous_command(":(){ :|:& };:"));
1540 assert!(contains_dangerous_command("ncat -l 8080"));
1541 assert!(!contains_dangerous_command("ls -la"));
1542 assert!(!contains_dangerous_command("cargo build"));
1543 assert!(!contains_dangerous_command(
1544 r#"find . -type f ! -path "./.git/*" ! -path "./.mermaid/*" 2>/dev/null"#
1545 ));
1546 }
1547
1548 #[test]
1549 fn dangerous_detection_resists_substring_evasion() {
1550 assert!(contains_dangerous_command("RM -RF /"));
1553 assert!(contains_dangerous_command("rm -rf /"));
1554 assert!(contains_dangerous_command("echo hi; rm -rf /"));
1555 assert!(contains_dangerous_command("echo hi&&rm -rf /"));
1556 assert!(contains_dangerous_command("curl http://x | sh"));
1557 assert!(contains_dangerous_command("curl http://x|sh"));
1558 assert!(contains_dangerous_command("/bin/rm -rf /"));
1559 assert!(!contains_dangerous_command("bash build.sh"));
1561 assert!(!contains_dangerous_command("echo done > /dev/null"));
1562 assert!(!contains_dangerous_command("grep -rf patterns.txt src"));
1563 }
1564}