1use serde_json::Value;
2use std::path::Path;
3use std::time::Duration;
4
5const DEFAULT_TIMEOUT_SECS: u64 = 60;
6const MAX_OUTPUT_BYTES: usize = 65_536; pub async fn execute(args: &Value) -> Result<String, String> {
14 let mut command = args
15 .get("command")
16 .and_then(|v| v.as_str())
17 .ok_or_else(|| "Missing required argument: 'command'".to_string())?
18 .to_string();
19
20 if command.contains('@') {
22 let root = crate::tools::file_ops::workspace_root();
23 let root_str = root.to_string_lossy().to_string().replace("\\", "/");
24 command = command.replace('@', &format!("{}/", root_str.trim_end_matches('/')));
25 }
26
27 let timeout_ms = args
28 .get("timeout_ms")
29 .and_then(|v| v.as_u64())
30 .or_else(|| {
31 args.get("timeout_secs")
32 .and_then(|v| v.as_u64())
33 .map(|s| s * 1000)
34 })
35 .unwrap_or(DEFAULT_TIMEOUT_SECS * 1000);
36
37 let run_in_background = args
38 .get("run_in_background")
39 .and_then(|v| v.as_bool())
40 .unwrap_or(false);
41
42 let cwd =
43 std::env::current_dir().map_err(|e| format!("Failed to get working directory: {e}"))?;
44
45 execute_command_in_dir(&command, &cwd, timeout_ms, run_in_background).await
46}
47
48pub async fn execute_command_in_dir(
49 command: &str,
50 cwd: &Path,
51 timeout_ms: u64,
52 run_in_background: bool,
53) -> Result<String, String> {
54 crate::tools::guard::bash_is_safe(command)?;
55
56 let mut tokio_cmd = build_command(command).await;
57 tokio_cmd
58 .current_dir(cwd)
59 .stdout(std::process::Stdio::piped())
60 .stderr(std::process::Stdio::piped());
61
62 let sandbox_root = cwd.join(".hematite").join("sandbox");
63 let _ = std::fs::create_dir_all(&sandbox_root);
64 tokio_cmd.env("HOME", &sandbox_root);
65 tokio_cmd.env("TMPDIR", &sandbox_root);
66
67 if run_in_background {
68 let _child = tokio_cmd
69 .spawn()
70 .map_err(|e| format!("Failed to spawn background process: {e}"))?;
71 return Ok(
72 "[background_task_id: spawned]\nCommand started in background. Use `ps` or `jobs` to monitor if available."
73 .into(),
74 );
75 }
76
77 let child_future = tokio_cmd.output();
78
79 let output = match tokio::time::timeout(Duration::from_millis(timeout_ms), child_future).await {
80 Ok(Ok(output)) => output,
81 Ok(Err(e)) => return Err(format!("Failed to execution process: {e}")),
82 Err(_) => {
83 return Err(format!(
84 "Command timed out after {} ms: {}",
85 timeout_ms, command
86 ))
87 }
88 };
89
90 let stdout = cap_bytes(&output.stdout, MAX_OUTPUT_BYTES / 2);
91 let stderr = cap_bytes(&output.stderr, MAX_OUTPUT_BYTES / 2);
92
93 let exit_info = match output.status.code() {
94 Some(0) => String::new(),
95 Some(code) => format!("\n[exit code: {code}]"),
96 None => "\n[process terminated by signal]".to_string(),
97 };
98
99 let mut result = String::new();
100 if !stdout.is_empty() {
101 result.push_str(&stdout);
102 }
103 if !stderr.is_empty() {
104 if !result.is_empty() {
105 result.push('\n');
106 }
107 result.push_str("[stderr]\n");
108 result.push_str(&stderr);
109 }
110 if result.is_empty() {
111 result.push_str("(no output)");
112 }
113 result.push_str(&exit_info);
114
115 Ok(crate::agent::utils::strip_ansi(&result))
116}
117
118async fn build_command(command: &str) -> tokio::process::Command {
120 #[cfg(target_os = "windows")]
121 {
122 let normalized = command
123 .replace("/dev/null", "$null")
124 .replace("1>/dev/null", "2>$null")
125 .replace("2>/dev/null", "2>$null");
126
127 if which("pwsh").await {
128 let mut cmd = tokio::process::Command::new("pwsh");
129 cmd.args(["-NoProfile", "-NonInteractive", "-Command", &normalized]);
130 cmd
131 } else {
132 let mut cmd = tokio::process::Command::new("powershell");
133 cmd.args(["-NoProfile", "-NonInteractive", "-Command", &normalized]);
134 cmd
135 }
136 }
137 #[cfg(not(target_os = "windows"))]
138 {
139 let mut cmd = tokio::process::Command::new("sh");
140 cmd.args(["-c", command]);
141 cmd
142 }
143}
144
145#[allow(dead_code)]
146async fn which(name: &str) -> bool {
147 #[cfg(target_os = "windows")]
148 let check = format!("{}.exe", name);
149 #[cfg(not(target_os = "windows"))]
150 let check = name;
151
152 tokio::process::Command::new("where")
153 .arg(check)
154 .stdout(std::process::Stdio::null())
155 .stderr(std::process::Stdio::null())
156 .status()
157 .await
158 .map(|s| s.success())
159 .unwrap_or(false)
160}
161
162fn cap_bytes(bytes: &[u8], max: usize) -> String {
163 if bytes.len() <= max {
164 String::from_utf8_lossy(bytes).into_owned()
165 } else {
166 let mut s = String::from_utf8_lossy(&bytes[..max]).into_owned();
167 s.push_str(&format!("\n... [truncated - {} bytes total]", bytes.len()));
168 s
169 }
170}