Skip to main content

hematite/tools/
shell.rs

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; // 64 KB cap (higher for professional mode)
7
8/// Execute a shell command and return its stdout/stderr combined as a String.
9///
10/// Unified Shell Adapter:
11/// - Windows: Tries `pwsh` first, then `powershell.exe`, then `cmd /C`.
12/// - Unix: Tries `sh -c`.
13pub 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    // Expand @path/to/file into the absolute workspace path before execution.
21    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
118/// Build the platform-appropriate shell invocation.
119async 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}