Skip to main content

hematite/tools/
shell.rs

1use serde_json::Value;
2use std::time::Duration;
3
4const DEFAULT_TIMEOUT_SECS: u64 = 60;
5const MAX_OUTPUT_BYTES: usize = 65_536; // 64 KB cap (higher for professional mode)
6
7/// Execute a shell command and return its stdout/stderr combined as a String.
8///
9/// Unified Shell Adapter:
10/// - Windows: Tries `pwsh` first, then `powershell.exe`, then `cmd /C`.
11/// - Unix: Tries `sh -c`.
12pub async fn execute(args: &Value) -> Result<String, String> {
13    let mut command = args
14        .get("command")
15        .and_then(|v| v.as_str())
16        .ok_or_else(|| "Missing required argument: 'command'".to_string())?
17        .to_string();
18
19    // ── [Intelli-Hematite] Smart @ Resolution ───────────────────────────────
20    // Expands @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    // Security gate — absolute blacklist regardless of YOLO state.
43    crate::tools::guard::bash_is_safe(&command)?;
44
45    let cwd =
46        std::env::current_dir().map_err(|e| format!("Failed to get working directory: {e}"))?;
47
48    let mut tokio_cmd = build_command(&command).await;
49    tokio_cmd
50        .current_dir(&cwd)
51        .stdout(std::process::Stdio::piped())
52        .stderr(std::process::Stdio::piped());
53
54    // ── Environment Isolation ───────────────────────────────────────────────
55    let sandbox_root = cwd.join(".hematite").join("sandbox");
56    let _ = std::fs::create_dir_all(&sandbox_root);
57    tokio_cmd.env("HOME", &sandbox_root);
58    tokio_cmd.env("TMPDIR", &sandbox_root);
59    // ────────────────────────────────────────────────────────────────────────
60
61    if run_in_background {
62        let _child = tokio_cmd
63            .spawn()
64            .map_err(|e| format!("Failed to spawn background process: {e}"))?;
65        return Ok("[background_task_id: spawned]\nCommand started in background. Use `ps` or `jobs` to monitor if available.".into());
66    }
67
68    let child_future = tokio_cmd.output();
69
70    let output = match tokio::time::timeout(Duration::from_millis(timeout_ms), child_future).await {
71        Ok(Ok(output)) => output,
72        Ok(Err(e)) => return Err(format!("Failed to execution process: {e}")),
73        Err(_) => {
74            return Err(format!(
75                "Command timed out after {} ms: {}",
76                timeout_ms, &command
77            ))
78        }
79    };
80
81    let stdout = cap_bytes(&output.stdout, MAX_OUTPUT_BYTES / 2);
82    let stderr = cap_bytes(&output.stderr, MAX_OUTPUT_BYTES / 2);
83
84    let exit_info = match output.status.code() {
85        Some(0) => String::new(),
86        Some(code) => format!("\n[exit code: {code}]"),
87        None => "\n[process terminated by signal]".to_string(),
88    };
89
90    let mut result = String::new();
91    if !stdout.is_empty() {
92        result.push_str(&stdout);
93    }
94    if !stderr.is_empty() {
95        if !result.is_empty() {
96            result.push('\n');
97        }
98        result.push_str("[stderr]\n");
99        result.push_str(&stderr);
100    }
101    if result.is_empty() {
102        result.push_str("(no output)");
103    }
104    result.push_str(&exit_info);
105
106    // Filter out ANSI escape codes and terminal noise before returning.
107    Ok(crate::agent::utils::strip_ansi(&result))
108}
109
110/// Build the platform-appropriate shell invocation.
111async fn build_command(command: &str) -> tokio::process::Command {
112    #[cfg(target_os = "windows")]
113    {
114        // On Windows, the best shell is PowerShell (pwsh or powershell.exe).
115        // It handles Unix-style paths better and is the modern standard.
116        // We normalize simple redirections.
117        let normalized = command
118            .replace("/dev/null", "$null")
119            .replace("1>/dev/null", "2>$null")
120            .replace("2>/dev/null", "2>$null");
121
122        // Try pwsh (Core) then PowerShell Desktop.
123        if which("pwsh").await {
124            let mut cmd = tokio::process::Command::new("pwsh");
125            cmd.args(["-NoProfile", "-NonInteractive", "-Command", &normalized]);
126            cmd
127        } else {
128            let mut cmd = tokio::process::Command::new("powershell");
129            cmd.args(["-NoProfile", "-NonInteractive", "-Command", &normalized]);
130            cmd
131        }
132    }
133    #[cfg(not(target_os = "windows"))]
134    {
135        let mut cmd = tokio::process::Command::new("sh");
136        cmd.args(["-c", command]);
137        cmd
138    }
139}
140
141#[allow(dead_code)]
142async fn which(name: &str) -> bool {
143    #[cfg(target_os = "windows")]
144    let check = format!("{}.exe", name);
145    #[cfg(not(target_os = "windows"))]
146    let check = name;
147
148    tokio::process::Command::new("where")
149        .arg(check)
150        .stdout(std::process::Stdio::null())
151        .stderr(std::process::Stdio::null())
152        .status()
153        .await
154        .map(|s| s.success())
155        .unwrap_or(false)
156}
157
158fn cap_bytes(bytes: &[u8], max: usize) -> String {
159    if bytes.len() <= max {
160        String::from_utf8_lossy(bytes).into_owned()
161    } else {
162        let mut s = String::from_utf8_lossy(&bytes[..max]).into_owned();
163        s.push_str(&format!("\n… [truncated — {} bytes total]", bytes.len()));
164        s
165    }
166}