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)]
168#[cfg(not(target_os = "windows"))]
169mod tests {
170    use super::*;
171    use tempfile::TempDir;
172
173    #[tokio::test]
174    async fn runs_echo_in_workspace() {
175        let tmp = TempDir::new().unwrap();
176        let out = RunShell::new(tmp.path())
177            .execute(json!({"command": "echo hello && pwd"}))
178            .await
179            .unwrap();
180        assert!(out.contains("exit: 0"));
181        assert!(out.contains("hello"));
182    }
183
184    #[tokio::test]
185    async fn captures_nonzero_status() {
186        let tmp = TempDir::new().unwrap();
187        let out = RunShell::new(tmp.path())
188            .execute(json!({"command": "exit 7"}))
189            .await
190            .unwrap();
191        assert!(out.contains("exit: 7"));
192    }
193
194    #[tokio::test]
195    async fn enforces_timeout() {
196        let tmp = TempDir::new().unwrap();
197        let tool = RunShell::new(tmp.path()).with_timeout(Duration::from_millis(150));
198        let err = tool
199            .execute(json!({"command": "sleep 5"}))
200            .await
201            .unwrap_err();
202        assert!(matches!(err, Error::Tool { .. }));
203    }
204
205    #[tokio::test]
206    async fn runs_in_subdir_when_cwd_given() {
207        let tmp = TempDir::new().unwrap();
208        // Create a subdirectory with a marker file
209        let sub = tmp.path().join("sub");
210        std::fs::create_dir(&sub).unwrap();
211        std::fs::write(sub.join("marker.txt"), "content").unwrap();
212
213        let out = RunShell::new(tmp.path())
214            .execute(json!({"command": "ls", "cwd": "sub"}))
215            .await
216            .unwrap();
217
218        assert!(out.contains("exit: 0"));
219        assert!(out.contains("marker.txt"));
220    }
221
222    #[tokio::test]
223    async fn rejects_cwd_outside_workspace() {
224        let tmp = TempDir::new().unwrap();
225        let err = RunShell::new(tmp.path())
226            .execute(json!({"command": "echo hello", "cwd": "../escape"}))
227            .await
228            .unwrap_err();
229
230        assert!(matches!(err, Error::BadToolArgs { ref name, .. } if name == "run_shell"));
231        let err_msg = format!("{err}");
232        assert!(err_msg.contains("cwd"));
233    }
234
235    #[tokio::test]
236    async fn accepts_dot_cwd_as_root() {
237        let tmp = TempDir::new().unwrap();
238        let out = RunShell::new(tmp.path())
239            .execute(json!({"command": "pwd", "cwd": "."}))
240            .await
241            .unwrap();
242
243        assert!(out.contains("exit: 0"));
244        // pwd should output something non-empty
245        assert!(out.contains("--- stdout ---"));
246    }
247
248    #[tokio::test]
249    async fn existing_no_cwd_call_still_works() {
250        let tmp = TempDir::new().unwrap();
251        let out = RunShell::new(tmp.path())
252            .execute(json!({"command": "echo hello"}))
253            .await
254            .unwrap();
255
256        assert!(out.contains("exit: 0"));
257        assert!(out.contains("hello"));
258    }
259
260    // Tests for env-vars passthrough (goal-27)
261    #[tokio::test]
262    async fn env_overrides_and_errors() {
263        let tmp = TempDir::new().unwrap();
264        let tool = RunShell::new(tmp.path());
265
266        // Test A: env var is set and visible in the command
267        let out = tool
268            .execute(json!({"command": "echo $RECURSIVE_TEST_VAR", "env": {"RECURSIVE_TEST_VAR": "hello"}}))
269            .await
270            .unwrap();
271        assert!(out.contains("exit: 0"));
272        assert!(out.contains("hello"));
273
274        // Test B: non-string env value returns BadToolArgs
275        let err = tool
276            .execute(json!({"command": "echo x", "env": {"MY_KEY": 42}}))
277            .await
278            .unwrap_err();
279        assert!(matches!(err, Error::BadToolArgs { .. }));
280        let err_msg = format!("{err}");
281        assert!(
282            err_msg.contains("MY_KEY"),
283            "error should mention the offending key: {err_msg}"
284        );
285
286        // Test C (regression): omitting env works exactly as before
287        let out = tool
288            .execute(json!({"command": "echo hello"}))
289            .await
290            .unwrap();
291        assert!(out.contains("exit: 0"));
292        assert!(out.contains("hello"));
293    }
294}