trae_agent_rs_core/tools/utils/
run.rs1use crate::error::Result;
4use std::collections::HashMap;
5use std::process::Stdio;
6use tokio::io::{AsyncBufReadExt, BufReader};
7use tokio::process::{Child, Command};
8use tokio::time::{timeout, Duration, Instant};
9
10#[derive(Debug, Clone)]
12pub struct CommandOptions {
13 pub timeout_seconds: Option<u64>,
14 pub truncate_after: Option<usize>,
15 pub working_directory: Option<String>,
16 pub environment: HashMap<String, String>,
17 pub capture_stderr: bool,
18 pub shell: Option<String>,
19}
20
21impl Default for CommandOptions {
22 fn default() -> Self {
23 Self {
24 timeout_seconds: Some(120),
25 truncate_after: Some(16000),
26 working_directory: None,
27 environment: HashMap::new(),
28 capture_stderr: true,
29 shell: Some("/bin/bash".to_string()),
30 }
31 }
32}
33
34#[derive(Debug, Clone)]
36pub struct CommandResult {
37 pub exit_code: i32,
38 pub stdout: String,
39 pub stderr: String,
40 pub duration_ms: u64,
41 pub timed_out: bool,
42 pub truncated: bool,
43}
44
45pub async fn execute_command(command: &str, options: CommandOptions) -> Result<CommandResult> {
47 let start_time = Instant::now();
48
49 let mut cmd = if let Some(shell) = &options.shell {
50 let mut cmd = Command::new(shell);
51 cmd.arg("-c").arg(command);
52 cmd
53 } else {
54 let parts: Vec<&str> = command.split_whitespace().collect();
56 if parts.is_empty() {
57 return Err("Empty command".into());
58 }
59
60 let mut cmd = Command::new(parts[0]);
61 if parts.len() > 1 {
62 cmd.args(&parts[1..]);
63 }
64 cmd
65 };
66
67 if let Some(working_dir) = &options.working_directory {
69 cmd.current_dir(working_dir);
70 }
71
72 for (key, value) in &options.environment {
74 cmd.env(key, value);
75 }
76
77 cmd.stdin(Stdio::piped())
79 .stdout(Stdio::piped());
80
81 if options.capture_stderr {
82 cmd.stderr(Stdio::piped());
83 } else {
84 cmd.stderr(Stdio::inherit());
85 }
86
87 let mut child = cmd.spawn()?;
88
89 let timeout_duration = Duration::from_secs(options.timeout_seconds.unwrap_or(120));
91 let result = timeout(timeout_duration, async {
92 execute_child(&mut child, options.capture_stderr).await
93 }).await;
94
95 let duration = start_time.elapsed();
96
97 match result {
98 Ok(Ok((exit_code, stdout, stderr))) => {
99 let truncate_limit = options.truncate_after.unwrap_or(16000);
100 let (stdout_truncated, stdout_final) = truncate_output(&stdout, truncate_limit);
101 let (stderr_truncated, stderr_final) = truncate_output(&stderr, truncate_limit);
102
103 Ok(CommandResult {
104 exit_code,
105 stdout: stdout_final,
106 stderr: stderr_final,
107 duration_ms: duration.as_millis() as u64,
108 timed_out: false,
109 truncated: stdout_truncated || stderr_truncated,
110 })
111 }
112 Ok(Err(e)) => Err(e),
113 Err(_) => {
114 let _ = child.kill().await;
116
117 Ok(CommandResult {
118 exit_code: -1,
119 stdout: String::new(),
120 stderr: format!("Command timed out after {} seconds", timeout_duration.as_secs()),
121 duration_ms: duration.as_millis() as u64,
122 timed_out: true,
123 truncated: false,
124 })
125 }
126 }
127}
128
129async fn execute_child(child: &mut Child, capture_stderr: bool) -> Result<(i32, String, String)> {
131 let stdout = child.stdout.take().ok_or("Failed to capture stdout")?;
132 let stderr = if capture_stderr {
133 child.stderr.take()
134 } else {
135 None
136 };
137
138 let mut stdout_reader = BufReader::new(stdout);
139 let mut stdout_lines = Vec::new();
140 let mut stderr_lines = Vec::new();
141
142 let stdout_task = async {
144 let mut line = String::new();
145 while stdout_reader.read_line(&mut line).await? > 0 {
146 stdout_lines.push(line.clone());
147 line.clear();
148 }
149 Ok::<(), std::io::Error>(())
150 };
151
152 let stderr_task = async {
154 if let Some(stderr) = stderr {
155 let mut stderr_reader = BufReader::new(stderr);
156 let mut line = String::new();
157 while stderr_reader.read_line(&mut line).await? > 0 {
158 stderr_lines.push(line.clone());
159 line.clear();
160 }
161 }
162 Ok::<(), std::io::Error>(())
163 };
164
165 let (stdout_result, stderr_result) = tokio::join!(stdout_task, stderr_task);
167 stdout_result?;
168 stderr_result?;
169
170 let status = child.wait().await?;
172 let exit_code = status.code().unwrap_or(-1);
173
174 let stdout_output = stdout_lines.join("");
175 let stderr_output = stderr_lines.join("");
176
177 Ok((exit_code, stdout_output, stderr_output))
178}
179
180fn truncate_output(output: &str, limit: usize) -> (bool, String) {
182 if output.len() <= limit {
183 (false, output.to_string())
184 } else {
185 let truncated = format!(
186 "{}\n\n<output truncated after {} characters>\n\
187 <NOTE>To see the full output, increase the truncate_after limit or \
188 redirect output to a file.</NOTE>",
189 &output[..limit],
190 limit
191 );
192 (true, truncated)
193 }
194}
195
196pub async fn stream_command(
198 command: &str,
199 options: CommandOptions,
200 mut output_handler: impl FnMut(&str) -> Result<()>,
201) -> Result<CommandResult> {
202 let start_time = Instant::now();
203
204 let mut cmd = if let Some(shell) = &options.shell {
205 let mut cmd = Command::new(shell);
206 cmd.arg("-c").arg(command);
207 cmd
208 } else {
209 let parts: Vec<&str> = command.split_whitespace().collect();
210 if parts.is_empty() {
211 return Err("Empty command".into());
212 }
213
214 let mut cmd = Command::new(parts[0]);
215 if parts.len() > 1 {
216 cmd.args(&parts[1..]);
217 }
218 cmd
219 };
220
221 if let Some(working_dir) = &options.working_directory {
222 cmd.current_dir(working_dir);
223 }
224
225 for (key, value) in &options.environment {
226 cmd.env(key, value);
227 }
228
229 cmd.stdin(Stdio::piped())
230 .stdout(Stdio::piped())
231 .stderr(Stdio::piped());
232
233 let mut child = cmd.spawn()?;
234
235 let stdout = child.stdout.take().ok_or("Failed to capture stdout")?;
236 let stderr = child.stderr.take().ok_or("Failed to capture stderr")?;
237
238 let mut stdout_reader = BufReader::new(stdout);
239 let mut stderr_reader = BufReader::new(stderr);
240
241 let mut all_output = String::new();
242 let mut exit_code = 0;
243 let mut timed_out = false;
244
245 let timeout_duration = Duration::from_secs(options.timeout_seconds.unwrap_or(120));
246 let result = timeout(timeout_duration, async {
247 let mut stdout_line = String::new();
248 let mut stderr_line = String::new();
249
250 loop {
251 tokio::select! {
252 result = stdout_reader.read_line(&mut stdout_line) => {
253 match result {
254 Ok(0) => break, Ok(_) => {
256 output_handler(&stdout_line)?;
257 all_output.push_str(&stdout_line);
258 stdout_line.clear();
259 }
260 Err(e) => return Err(e.into()),
261 }
262 }
263 result = stderr_reader.read_line(&mut stderr_line) => {
264 match result {
265 Ok(0) => {}, Ok(_) => {
267 output_handler(&stderr_line)?;
268 all_output.push_str(&stderr_line);
269 stderr_line.clear();
270 }
271 Err(e) => return Err(e.into()),
272 }
273 }
274 status = child.wait() => {
275 exit_code = status?.code().unwrap_or(-1);
276 break;
277 }
278 }
279 }
280
281 Ok::<(), crate::error::Error>(())
282 }).await;
283
284 let duration = start_time.elapsed();
285
286 match result {
287 Ok(Ok(())) => {},
288 Ok(Err(e)) => return Err(e),
289 Err(_) => {
290 let _ = child.kill().await;
291 timed_out = true;
292 exit_code = -1;
293 }
294 }
295
296 let truncate_limit = options.truncate_after.unwrap_or(16000);
297 let (truncated, final_output) = truncate_output(&all_output, truncate_limit);
298
299 Ok(CommandResult {
300 exit_code,
301 stdout: final_output,
302 stderr: String::new(), duration_ms: duration.as_millis() as u64,
304 timed_out,
305 truncated,
306 })
307}
308
309pub fn validate_command_safety(command: &str) -> Result<()> {
311 let dangerous_patterns = [
312 "rm -rf /",
313 ":(){ :|:& };:", "dd if=/dev/zero",
315 "mkfs.",
316 "format ",
317 "> /dev/",
318 "chmod 777 /",
319 "chown root /",
320 ];
321
322 let command_lower = command.to_lowercase();
323 for pattern in &dangerous_patterns {
324 if command_lower.contains(pattern) {
325 return Err(format!("Potentially dangerous command detected: {}", pattern).into());
326 }
327 }
328
329 Ok(())
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[tokio::test]
337 async fn test_simple_command() {
338 let options = CommandOptions::default();
339 let result = execute_command("echo 'Hello, World!'", options).await.unwrap();
340
341 assert_eq!(result.exit_code, 0);
342 assert!(result.stdout.contains("Hello, World!"));
343 assert!(!result.timed_out);
344 }
345
346 #[tokio::test]
347 async fn test_command_timeout() {
348 let mut options = CommandOptions::default();
349 options.timeout_seconds = Some(1);
350
351 let result = execute_command("sleep 5", options).await.unwrap();
352
353 assert!(result.timed_out);
354 assert_eq!(result.exit_code, -1);
355 }
356
357 #[test]
358 fn test_output_truncation() {
359 let long_output = "a".repeat(20000);
360 let (truncated, output) = truncate_output(&long_output, 1000);
361
362 assert!(truncated);
363 assert!(output.len() > 1000); assert!(output.contains("output truncated"));
365 }
366
367 #[test]
368 fn test_command_safety_validation() {
369 assert!(validate_command_safety("echo hello").is_ok());
370 assert!(validate_command_safety("rm -rf /").is_err());
371 assert!(validate_command_safety("dd if=/dev/zero of=/dev/sda").is_err());
372 }
373}