Skip to main content

recursive/tools/
shell.rs

1//! `run_shell`: execute a command in the workspace.
2//!
3//! Uses `/bin/sh -c` so the model can write idiomatic one-liners (pipes,
4//! redirects, etc.). Stdout and stderr are captured and returned together.
5
6use async_trait::async_trait;
7use serde_json::{json, Value};
8use std::path::PathBuf;
9use std::process::Stdio;
10use std::time::Duration;
11use tokio::io::AsyncReadExt;
12use tokio::process::Command;
13
14use super::resolve_within;
15use super::Tool;
16use crate::error::{Error, Result};
17use crate::llm::ToolSpec;
18
19#[derive(Debug, Clone)]
20pub struct RunShell {
21    pub root: PathBuf,
22    pub timeout: Duration,
23    pub max_output_bytes: usize,
24}
25
26impl RunShell {
27    pub fn new(root: impl Into<PathBuf>) -> Self {
28        Self {
29            root: root.into(),
30            timeout: Duration::from_secs(300),
31            max_output_bytes: 128 * 1024,
32        }
33    }
34
35    pub fn with_timeout(mut self, t: Duration) -> Self {
36        self.timeout = t;
37        self
38    }
39}
40
41#[async_trait]
42impl Tool for RunShell {
43    fn spec(&self) -> ToolSpec {
44        ToolSpec {
45            name: "run_shell".into(),
46            description:
47                "Run a shell command (sh -c) from the workspace root, or from an optional subdirectory inside it via `cwd`."
48                    .into(),
49            parameters: json!({
50                "type": "object",
51                "properties": {
52                    "command": {
53                        "type": "string",
54                        "description": "Command line to execute via sh -c"
55                    },
56                    "cwd": {
57                        "type": "string",
58                        "description": "Optional subdirectory (relative to workspace root) to run the command in. Must stay inside the workspace."
59                    },
60                    "env": {
61                        "type": "object",
62                        "description": "Optional extra env vars set for this command only. Values must be strings; non-string values are rejected. These add to (or override) the inherited env.",
63                        "additionalProperties": {
64                            "type": "string"
65                        }
66                    }
67                },
68                "required": ["command"]
69            }),
70        }
71    }
72
73    async fn execute(&self, args: Value) -> Result<String> {
74        let command = args["command"].as_str().ok_or_else(|| Error::BadToolArgs {
75            name: "run_shell".into(),
76            message: "missing `command`".into(),
77        })?;
78
79        // Determine the working directory: resolve optional cwd or use root.
80        let cwd = if let Some(rel) = args.get("cwd").and_then(|v| v.as_str()) {
81            resolve_within(&self.root, rel).map_err(|e| Error::BadToolArgs {
82                name: "run_shell".into(),
83                message: format!("cwd: {e}"),
84            })?
85        } else {
86            self.root.clone()
87        };
88
89        let mut cmd = Command::new("/bin/sh");
90        cmd.arg("-c").arg(command);
91        cmd.current_dir(&cwd);
92        cmd.stdout(Stdio::piped());
93        cmd.stderr(Stdio::piped());
94
95        // Apply optional env overrides
96        if let Some(env_map) = args.get("env").and_then(|v| v.as_object()) {
97            for (key, val) in env_map {
98                let val_str = val.as_str().ok_or_else(|| Error::BadToolArgs {
99                    name: "run_shell".to_string(),
100                    message: format!("env value for `{key}` must be a string, got {:?}", val),
101                })?;
102                cmd.env(key, val_str);
103            }
104        }
105
106        let mut child = cmd.spawn().map_err(|e| Error::Tool {
107            name: "run_shell".into(),
108            message: format!("spawn failed: {e}"),
109        })?;
110
111        let mut stdout = child.stdout.take().expect("stdout piped");
112        let mut stderr = child.stderr.take().expect("stderr piped");
113
114        let max = self.max_output_bytes;
115        let stdout_task = tokio::spawn(async move { read_capped(&mut stdout, max).await });
116        let stderr_task = tokio::spawn(async move { read_capped(&mut stderr, max).await });
117
118        let wait = child.wait();
119        let status = match tokio::time::timeout(self.timeout, wait).await {
120            Ok(s) => s.map_err(|e| Error::Tool {
121                name: "run_shell".into(),
122                message: format!("wait failed: {e}"),
123            })?,
124            Err(_) => {
125                return Err(Error::Tool {
126                    name: "run_shell".into(),
127                    message: format!("command timed out after {:?}", self.timeout),
128                });
129            }
130        };
131
132        let out = stdout_task.await.unwrap_or_default();
133        let err = stderr_task.await.unwrap_or_default();
134        let code = status
135            .code()
136            .map(|c| c.to_string())
137            .unwrap_or_else(|| "signal".into());
138
139        Ok(format!(
140            "exit: {code}\n--- stdout ---\n{out}\n--- stderr ---\n{err}"
141        ))
142    }
143}
144
145async fn read_capped<R: AsyncReadExt + Unpin>(reader: &mut R, max: usize) -> String {
146    let mut buf = Vec::with_capacity(8 * 1024);
147    let mut tmp = [0u8; 8 * 1024];
148    loop {
149        match reader.read(&mut tmp).await {
150            Ok(0) => break,
151            Ok(n) => {
152                if buf.len() + n > max {
153                    let take = max.saturating_sub(buf.len());
154                    buf.extend_from_slice(&tmp[..take]);
155                    buf.extend_from_slice(b"\n... [output truncated]");
156                    let _ = tokio::io::copy(reader, &mut tokio::io::sink()).await;
157                    break;
158                }
159                buf.extend_from_slice(&tmp[..n]);
160            }
161            Err(_) => break,
162        }
163    }
164    String::from_utf8_lossy(&buf).into_owned()
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use tempfile::TempDir;
171
172    #[tokio::test]
173    async fn runs_echo_in_workspace() {
174        let tmp = TempDir::new().unwrap();
175        let out = RunShell::new(tmp.path())
176            .execute(json!({"command": "echo hello && pwd"}))
177            .await
178            .unwrap();
179        assert!(out.contains("exit: 0"));
180        assert!(out.contains("hello"));
181    }
182
183    #[tokio::test]
184    async fn captures_nonzero_status() {
185        let tmp = TempDir::new().unwrap();
186        let out = RunShell::new(tmp.path())
187            .execute(json!({"command": "exit 7"}))
188            .await
189            .unwrap();
190        assert!(out.contains("exit: 7"));
191    }
192
193    #[tokio::test]
194    async fn enforces_timeout() {
195        let tmp = TempDir::new().unwrap();
196        let tool = RunShell::new(tmp.path()).with_timeout(Duration::from_millis(150));
197        let err = tool
198            .execute(json!({"command": "sleep 5"}))
199            .await
200            .unwrap_err();
201        assert!(matches!(err, Error::Tool { .. }));
202    }
203
204    #[tokio::test]
205    async fn runs_in_subdir_when_cwd_given() {
206        let tmp = TempDir::new().unwrap();
207        // Create a subdirectory with a marker file
208        let sub = tmp.path().join("sub");
209        std::fs::create_dir(&sub).unwrap();
210        std::fs::write(sub.join("marker.txt"), "content").unwrap();
211
212        let out = RunShell::new(tmp.path())
213            .execute(json!({"command": "ls", "cwd": "sub"}))
214            .await
215            .unwrap();
216
217        assert!(out.contains("exit: 0"));
218        assert!(out.contains("marker.txt"));
219    }
220
221    #[tokio::test]
222    async fn rejects_cwd_outside_workspace() {
223        let tmp = TempDir::new().unwrap();
224        let err = RunShell::new(tmp.path())
225            .execute(json!({"command": "echo hello", "cwd": "../escape"}))
226            .await
227            .unwrap_err();
228
229        assert!(matches!(err, Error::BadToolArgs { ref name, .. } if name == "run_shell"));
230        let err_msg = format!("{err}");
231        assert!(err_msg.contains("cwd"));
232    }
233
234    #[tokio::test]
235    async fn accepts_dot_cwd_as_root() {
236        let tmp = TempDir::new().unwrap();
237        let out = RunShell::new(tmp.path())
238            .execute(json!({"command": "pwd", "cwd": "."}))
239            .await
240            .unwrap();
241
242        assert!(out.contains("exit: 0"));
243        // pwd should output something non-empty
244        assert!(out.contains("--- stdout ---"));
245    }
246
247    #[tokio::test]
248    async fn existing_no_cwd_call_still_works() {
249        let tmp = TempDir::new().unwrap();
250        let out = RunShell::new(tmp.path())
251            .execute(json!({"command": "echo hello"}))
252            .await
253            .unwrap();
254
255        assert!(out.contains("exit: 0"));
256        assert!(out.contains("hello"));
257    }
258
259    // Tests for env-vars passthrough (goal-27)
260    #[tokio::test]
261    async fn env_overrides_and_errors() {
262        let tmp = TempDir::new().unwrap();
263        let tool = RunShell::new(tmp.path());
264
265        // Test A: env var is set and visible in the command
266        let out = tool
267            .execute(json!({"command": "echo $RECURSIVE_TEST_VAR", "env": {"RECURSIVE_TEST_VAR": "hello"}}))
268            .await
269            .unwrap();
270        assert!(out.contains("exit: 0"));
271        assert!(out.contains("hello"));
272
273        // Test B: non-string env value returns BadToolArgs
274        let err = tool
275            .execute(json!({"command": "echo x", "env": {"MY_KEY": 42}}))
276            .await
277            .unwrap_err();
278        assert!(matches!(err, Error::BadToolArgs { .. }));
279        let err_msg = format!("{err}");
280        assert!(
281            err_msg.contains("MY_KEY"),
282            "error should mention the offending key: {err_msg}"
283        );
284
285        // Test C (regression): omitting env works exactly as before
286        let out = tool
287            .execute(json!({"command": "echo hello"}))
288            .await
289            .unwrap();
290        assert!(out.contains("exit: 0"));
291        assert!(out.contains("hello"));
292    }
293}