coro_core/tools/utils/
run.rs

1//! Tool execution utilities
2
3use 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/// Command execution options
11#[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/// Command execution result
35#[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
45/// Execute a command with comprehensive options
46pub 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        // Parse command and arguments
55        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    // Set working directory
68    if let Some(working_dir) = &options.working_directory {
69        cmd.current_dir(working_dir);
70    }
71
72    // Set environment variables
73    for (key, value) in &options.environment {
74        cmd.env(key, value);
75    }
76
77    // Configure stdio
78    cmd.stdin(Stdio::piped()).stdout(Stdio::piped());
79
80    if options.capture_stderr {
81        cmd.stderr(Stdio::piped());
82    } else {
83        cmd.stderr(Stdio::inherit());
84    }
85
86    let mut child = cmd.spawn()?;
87
88    // Execute with timeout
89    let timeout_duration = Duration::from_secs(options.timeout_seconds.unwrap_or(120));
90    let result = timeout(timeout_duration, async {
91        execute_child(&mut child, options.capture_stderr).await
92    })
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            // Kill the process if it's still running
115            let _ = child.kill().await;
116
117            Ok(CommandResult {
118                exit_code: -1,
119                stdout: String::new(),
120                stderr: format!(
121                    "Command timed out after {} seconds",
122                    timeout_duration.as_secs()
123                ),
124                duration_ms: duration.as_millis() as u64,
125                timed_out: true,
126                truncated: false,
127            })
128        }
129    }
130}
131
132/// Execute child process and capture output
133async fn execute_child(child: &mut Child, capture_stderr: bool) -> Result<(i32, String, String)> {
134    let stdout = child.stdout.take().ok_or("Failed to capture stdout")?;
135    let stderr = if capture_stderr {
136        child.stderr.take()
137    } else {
138        None
139    };
140
141    let mut stdout_reader = BufReader::new(stdout);
142    let mut stdout_lines = Vec::new();
143    let mut stderr_lines = Vec::new();
144
145    // Read stdout
146    let stdout_task = async {
147        let mut line = String::new();
148        while stdout_reader.read_line(&mut line).await? > 0 {
149            stdout_lines.push(line.clone());
150            line.clear();
151        }
152        Ok::<(), std::io::Error>(())
153    };
154
155    // Read stderr if capturing
156    let stderr_task = async {
157        if let Some(stderr) = stderr {
158            let mut stderr_reader = BufReader::new(stderr);
159            let mut line = String::new();
160            while stderr_reader.read_line(&mut line).await? > 0 {
161                stderr_lines.push(line.clone());
162                line.clear();
163            }
164        }
165        Ok::<(), std::io::Error>(())
166    };
167
168    // Wait for both tasks to complete
169    let (stdout_result, stderr_result) = tokio::join!(stdout_task, stderr_task);
170    stdout_result?;
171    stderr_result?;
172
173    // Wait for process to exit
174    let status = child.wait().await?;
175    let exit_code = status.code().unwrap_or(-1);
176
177    let stdout_output = stdout_lines.join("");
178    let stderr_output = stderr_lines.join("");
179
180    Ok((exit_code, stdout_output, stderr_output))
181}
182
183/// Truncate output if it exceeds the limit
184fn truncate_output(output: &str, limit: usize) -> (bool, String) {
185    if output.len() <= limit {
186        (false, output.to_string())
187    } else {
188        let truncated = format!(
189            "{}\n\n<output truncated after {} characters>\n\
190             <NOTE>To see the full output, increase the truncate_after limit or \
191             redirect output to a file.</NOTE>",
192            &output[..limit],
193            limit
194        );
195        (true, truncated)
196    }
197}
198
199/// Stream command output in real-time
200pub async fn stream_command(
201    command: &str,
202    options: CommandOptions,
203    mut output_handler: impl FnMut(&str) -> Result<()>,
204) -> Result<CommandResult> {
205    let start_time = Instant::now();
206
207    let mut cmd = if let Some(shell) = &options.shell {
208        let mut cmd = Command::new(shell);
209        cmd.arg("-c").arg(command);
210        cmd
211    } else {
212        let parts: Vec<&str> = command.split_whitespace().collect();
213        if parts.is_empty() {
214            return Err("Empty command".into());
215        }
216
217        let mut cmd = Command::new(parts[0]);
218        if parts.len() > 1 {
219            cmd.args(&parts[1..]);
220        }
221        cmd
222    };
223
224    if let Some(working_dir) = &options.working_directory {
225        cmd.current_dir(working_dir);
226    }
227
228    for (key, value) in &options.environment {
229        cmd.env(key, value);
230    }
231
232    cmd.stdin(Stdio::piped())
233        .stdout(Stdio::piped())
234        .stderr(Stdio::piped());
235
236    let mut child = cmd.spawn()?;
237
238    let stdout = child.stdout.take().ok_or("Failed to capture stdout")?;
239    let stderr = child.stderr.take().ok_or("Failed to capture stderr")?;
240
241    let mut stdout_reader = BufReader::new(stdout);
242    let mut stderr_reader = BufReader::new(stderr);
243
244    let mut all_output = String::new();
245    let mut exit_code = 0;
246    let mut timed_out = false;
247
248    let timeout_duration = Duration::from_secs(options.timeout_seconds.unwrap_or(120));
249    let result = timeout(timeout_duration, async {
250        let mut stdout_line = String::new();
251        let mut stderr_line = String::new();
252
253        loop {
254            tokio::select! {
255                result = stdout_reader.read_line(&mut stdout_line) => {
256                    match result {
257                        Ok(0) => break, // EOF
258                        Ok(_) => {
259                            output_handler(&stdout_line)?;
260                            all_output.push_str(&stdout_line);
261                            stdout_line.clear();
262                        }
263                        Err(e) => return Err(e.into()),
264                    }
265                }
266                result = stderr_reader.read_line(&mut stderr_line) => {
267                    match result {
268                        Ok(0) => {}, // EOF on stderr
269                        Ok(_) => {
270                            output_handler(&stderr_line)?;
271                            all_output.push_str(&stderr_line);
272                            stderr_line.clear();
273                        }
274                        Err(e) => return Err(e.into()),
275                    }
276                }
277                status = child.wait() => {
278                    exit_code = status?.code().unwrap_or(-1);
279                    break;
280                }
281            }
282        }
283
284        Ok::<(), crate::error::Error>(())
285    })
286    .await;
287
288    let duration = start_time.elapsed();
289
290    match result {
291        Ok(Ok(())) => {}
292        Ok(Err(e)) => return Err(e),
293        Err(_) => {
294            let _ = child.kill().await;
295            timed_out = true;
296            exit_code = -1;
297        }
298    }
299
300    let truncate_limit = options.truncate_after.unwrap_or(16000);
301    let (truncated, final_output) = truncate_output(&all_output, truncate_limit);
302
303    Ok(CommandResult {
304        exit_code,
305        stdout: final_output,
306        stderr: String::new(), // Combined with stdout in streaming mode
307        duration_ms: duration.as_millis() as u64,
308        timed_out,
309        truncated,
310    })
311}
312
313/// Validate command safety (basic checks)
314pub fn validate_command_safety(command: &str) -> Result<()> {
315    let dangerous_patterns = [
316        "rm -rf /",
317        ":(){ :|:& };:", // Fork bomb
318        "dd if=/dev/zero",
319        "mkfs.",
320        "format ",
321        "> /dev/",
322        "chmod 777 /",
323        "chown root /",
324    ];
325
326    let command_lower = command.to_lowercase();
327    for pattern in &dangerous_patterns {
328        if command_lower.contains(pattern) {
329            return Err(format!("Potentially dangerous command detected: {}", pattern).into());
330        }
331    }
332
333    Ok(())
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[tokio::test]
341    async fn test_simple_command() {
342        let options = CommandOptions::default();
343        let result = execute_command("echo 'Hello, World!'", options)
344            .await
345            .unwrap();
346
347        assert_eq!(result.exit_code, 0);
348        assert!(result.stdout.contains("Hello, World!"));
349        assert!(!result.timed_out);
350    }
351
352    #[tokio::test]
353    async fn test_command_timeout() {
354        let options = CommandOptions {
355            timeout_seconds: Some(1),
356            ..Default::default()
357        };
358
359        let result = execute_command("sleep 5", options).await.unwrap();
360
361        assert!(result.timed_out);
362        assert_eq!(result.exit_code, -1);
363    }
364
365    #[test]
366    fn test_output_truncation() {
367        let long_output = "a".repeat(20000);
368        let (truncated, output) = truncate_output(&long_output, 1000);
369
370        assert!(truncated);
371        assert!(output.len() > 1000); // Includes truncation message
372        assert!(output.contains("output truncated"));
373    }
374
375    #[test]
376    fn test_command_safety_validation() {
377        assert!(validate_command_safety("echo hello").is_ok());
378        assert!(validate_command_safety("rm -rf /").is_err());
379        assert!(validate_command_safety("dd if=/dev/zero of=/dev/sda").is_err());
380    }
381}