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::acp::ToolKind;
17use crate::error::{Error, Result};
18use crate::llm::ToolSpec;
19
20/// Hard ceiling for the LLM-supplied `max_output_bytes` arg. Generous
21/// enough for a full `cargo build` diagnostic dump, small enough that a
22/// runaway command can't exhaust the agent's memory.
23const MAX_OUTPUT_BYTES_HARD_CAP: usize = 2 * 1024 * 1024;
24
25#[derive(Debug, Clone)]
26pub struct RunShell {
27    pub root: PathBuf,
28    pub timeout: Duration,
29    pub max_output_bytes: usize,
30}
31
32impl RunShell {
33    pub fn new(root: impl Into<PathBuf>) -> Self {
34        Self {
35            root: root.into(),
36            timeout: Duration::from_secs(300),
37            max_output_bytes: 128 * 1024,
38        }
39    }
40
41    pub fn with_timeout(mut self, t: Duration) -> Self {
42        self.timeout = t;
43        self
44    }
45
46    /// Override the default per-stream output cap. Useful when the host
47    /// wants every `Bash` call to retain more (or less) output than the
48    /// 128 KiB default. The LLM can also request a larger cap per-call
49    /// via the `max_output_bytes` arg; this setter only changes the
50    /// baseline the per-call arg is clamped against.
51    pub fn with_max_output_bytes(mut self, n: usize) -> Self {
52        self.max_output_bytes = n.min(MAX_OUTPUT_BYTES_HARD_CAP);
53        self
54    }
55}
56
57#[async_trait]
58impl Tool for RunShell {
59    fn spec(&self) -> ToolSpec {
60        ToolSpec {
61            name: "Bash".into(),
62            description:
63                "Run a shell command (sh -c) from the workspace root, or from an optional subdirectory inside it via `cwd`."
64                    .into(),
65            parameters: json!({
66                "type": "object",
67                "properties": {
68                    "command": {
69                        "type": "string",
70                        "description": "Command line to execute via sh -c"
71                    },
72                    "cwd": {
73                        "type": "string",
74                        "description": "Optional subdirectory (relative to workspace root) to run the command in. Must stay inside the workspace."
75                    },
76                    "env": {
77                        "type": "object",
78                        "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.",
79                        "additionalProperties": {
80                            "type": "string"
81                        }
82                    },
83                    "max_output_bytes": {
84                        "type": "integer",
85                        "minimum": 0,
86                        "description": "Per-stream (stdout / stderr) byte cap. Defaults to the tool's configured limit (128 KiB). Pass a larger value (≤ 2 MiB) when you need the full output of a verbose command — e.g. `cargo build` error logs — and the default would truncate the relevant lines. The cap is applied per-stream; both stdout and stderr are bounded independently."
87                    }
88                },
89                "required": ["command"]
90            }),
91        }
92    }
93
94    fn kind(&self) -> ToolKind {
95        ToolKind::Execute
96    }
97
98    async fn execute(&self, args: Value) -> Result<String> {
99        let command = args["command"].as_str().ok_or_else(|| Error::BadToolArgs {
100            name: "Bash".into(),
101            message: "missing `command`".into(),
102        })?;
103
104        // Determine the working directory: resolve optional cwd or use root.
105        let cwd = if let Some(rel) = args.get("cwd").and_then(|v| v.as_str()) {
106            resolve_within(&self.root, rel).map_err(|e| Error::BadToolArgs {
107                name: "Bash".into(),
108                message: format!("cwd: {e}"),
109            })?
110        } else {
111            self.root.clone()
112        };
113
114        let mut cmd = Command::new("/bin/sh");
115        cmd.arg("-c").arg(command);
116        cmd.current_dir(&cwd);
117        cmd.stdout(Stdio::piped());
118        cmd.stderr(Stdio::piped());
119        // Defence in depth against orphan processes on timeout: Tokio's
120        // `Child` defaults to `kill_on_drop = false`, so a bare `return Err`
121        // in the timeout branch would leave the shell and any of its
122        // descendants running. The timeout branch also calls `start_kill`
123        // explicitly so the intent is visible at the call site, but
124        // `kill_on_drop(true)` covers the case where a future refactor
125        // adds another early return (panic, `?` propagation, etc.).
126        cmd.kill_on_drop(true);
127
128        // Apply optional env overrides
129        if let Some(env_map) = args.get("env").and_then(|v| v.as_object()) {
130            for (key, val) in env_map {
131                let val_str = val.as_str().ok_or_else(|| Error::BadToolArgs {
132                    name: "Bash".to_string(),
133                    message: format!("env value for `{key}` must be a string, got {:?}", val),
134                })?;
135                cmd.env(key, val_str);
136            }
137        }
138
139        let mut child = cmd.spawn().map_err(|e| Error::Tool {
140            name: "Bash".into(),
141            call_id: None,
142            message: format!("spawn failed: {e}"),
143        })?;
144
145        let mut stdout = child.stdout.take().ok_or_else(|| Error::Tool {
146            name: "Bash".into(),
147            call_id: None,
148            message: "stdout was not piped".into(),
149        })?;
150        let mut stderr = child.stderr.take().ok_or_else(|| Error::Tool {
151            name: "Bash".into(),
152            call_id: None,
153            message: "stderr was not piped".into(),
154        })?;
155
156        // LLM may ask for a larger per-stream cap when it knows it needs
157        // the full output (e.g. a long `cargo build` diagnostic). Clamp
158        // to the hard cap so a malformed request can't exhaust memory;
159        // values below the configured default are honoured too, since a
160        // smaller cap is always safe.
161        let max = match args.get("max_output_bytes").and_then(|v| v.as_u64()) {
162            Some(n) => (n as usize).min(MAX_OUTPUT_BYTES_HARD_CAP),
163            None => self.max_output_bytes,
164        };
165        let stdout_task = tokio::spawn(async move { read_capped(&mut stdout, max).await });
166        let stderr_task = tokio::spawn(async move { read_capped(&mut stderr, max).await });
167
168        let wait = child.wait();
169        let status = match tokio::time::timeout(self.timeout, wait).await {
170            Ok(s) => s.map_err(|e| Error::Tool {
171                name: "Bash".into(),
172                call_id: None,
173                message: format!("wait failed: {e}"),
174            })?,
175            Err(_) => {
176                // Best-effort SIGKILL of the timed-out process group.
177                // `kill_on_drop(true)` set at spawn is the safety net,
178                // but explicit kill here ensures the OS reaps the child
179                // promptly rather than waiting for the Drop to fire when
180                // the error path returns. `start_kill` is non-blocking
181                // and tolerant of the child having already exited.
182                let _ = child.start_kill();
183                let _ = child.wait().await;
184                // Drain the reader tasks before returning. They block on
185                // the stdout/stderr pipes until the kill closes them;
186                // awaiting them here prevents their JoinHandles from
187                // detaching with output still buffered. Order matters:
188                // kill (and reap) BEFORE awaiting the readers, or the
189                // pipes never close and the drain deadlocks.
190                let _ = stdout_task.await;
191                let _ = stderr_task.await;
192                return Err(Error::Tool {
193                    name: "Bash".into(),
194                    call_id: None,
195                    message: format!("command timed out after {:?}", self.timeout),
196                });
197            }
198        };
199
200        let out = stdout_task.await.unwrap_or_default();
201        let err = stderr_task.await.unwrap_or_default();
202        let code = status
203            .code()
204            .map(|c| c.to_string())
205            .unwrap_or_else(|| "signal".into());
206
207        Ok(format!(
208            "exit: {code}\n--- stdout ---\n{out}\n--- stderr ---\n{err}"
209        ))
210    }
211}
212
213async fn read_capped<R: AsyncReadExt + Unpin>(reader: &mut R, max: usize) -> String {
214    let mut buf = Vec::with_capacity(8 * 1024);
215    let mut tmp = [0u8; 8 * 1024];
216    loop {
217        match reader.read(&mut tmp).await {
218            Ok(0) => break,
219            Ok(n) => {
220                if buf.len() + n > max {
221                    let take = max.saturating_sub(buf.len());
222                    buf.extend_from_slice(&tmp[..take]);
223                    buf.extend_from_slice(b"\n... [output truncated]");
224                    let _ = tokio::io::copy(reader, &mut tokio::io::sink()).await;
225                    break;
226                }
227                buf.extend_from_slice(&tmp[..n]);
228            }
229            Err(_) => break,
230        }
231    }
232    String::from_utf8_lossy(&buf).into_owned()
233}
234
235#[cfg(test)]
236#[cfg(not(target_os = "windows"))]
237mod tests {
238    use super::*;
239    use tempfile::TempDir;
240
241    #[tokio::test]
242    async fn runs_echo_in_workspace() {
243        let tmp = TempDir::new().unwrap();
244        let out = RunShell::new(tmp.path())
245            .execute(json!({"command": "echo hello && pwd"}))
246            .await
247            .unwrap();
248        assert!(out.contains("exit: 0"));
249        assert!(out.contains("hello"));
250    }
251
252    #[tokio::test]
253    async fn captures_nonzero_status() {
254        let tmp = TempDir::new().unwrap();
255        let out = RunShell::new(tmp.path())
256            .execute(json!({"command": "exit 7"}))
257            .await
258            .unwrap();
259        assert!(out.contains("exit: 7"));
260    }
261
262    #[tokio::test]
263    async fn enforces_timeout() {
264        let tmp = TempDir::new().unwrap();
265        let tool = RunShell::new(tmp.path()).with_timeout(Duration::from_millis(150));
266        let err = tool
267            .execute(json!({"command": "sleep 5"}))
268            .await
269            .unwrap_err();
270        assert!(matches!(err, Error::Tool { .. }));
271    }
272
273    // The default 128 KiB cap truncates `cargo build`-sized diagnostics
274    // before the relevant error lines. The LLM can opt out per-call by
275    // passing `max_output_bytes`. We verify that:
276    //   1. The arg actually raises the cap (200 KiB output is preserved).
277    //   2. The hard cap (2 MiB) is enforced — a 16 MiB request still
278    //      truncates well below that, so a malformed request can't OOM
279    //      the agent.
280    #[tokio::test]
281    async fn per_call_max_output_bytes_can_raise_within_hard_cap() {
282        let tmp = TempDir::new().unwrap();
283        let tool = RunShell::new(tmp.path());
284        // 200 KiB of `x` lines; default cap would truncate, requested
285        // cap (200 * 1024) keeps it whole.
286        let wanted_bytes = 200 * 1024;
287        let out = tool
288            .execute(json!({
289                "command": format!("yes x | head -c {}", wanted_bytes),
290                "max_output_bytes": wanted_bytes,
291            }))
292            .await
293            .unwrap();
294        assert!(
295            !out.contains("[output truncated]"),
296            "output was truncated despite the per-call cap: {out}"
297        );
298    }
299
300    #[tokio::test]
301    async fn per_call_max_output_bytes_clamped_to_hard_cap() {
302        let tmp = TempDir::new().unwrap();
303        let tool = RunShell::new(tmp.path());
304        // 4 MiB request: clamped to 2 MiB hard cap. 3 MiB of output
305        // therefore truncates.
306        let out = tool
307            .execute(json!({
308                "command": "yes x | head -c 3145728",
309                "max_output_bytes": 4_194_304u32,
310            }))
311            .await
312            .unwrap();
313        assert!(
314            out.contains("[output truncated]"),
315            "hard cap was not enforced: {out}"
316        );
317    }
318
319    // Regression: before P0-A, the timeout branch returned Err without
320    // killing the spawned child. `kill_on_drop(true)` and an explicit
321    // `start_kill` together guarantee the child is reaped. We verify by
322    // having the child `exec sleep` (so the shell PID *becomes* the
323    // sleep PID — killing the child kills the actual sleeper), writing
324    // that PID to a marker file, then `kill -0`-polling after the
325    // timeout. Pre-fix this test hangs for the full 30s sleep; post-fix
326    // the PID is gone within a couple of seconds.
327    #[tokio::test]
328    async fn timeout_kills_child_process() {
329        let tmp = TempDir::new().unwrap();
330        let marker = tmp.path().join("child.pid");
331        let marker_str = marker.to_string_lossy().into_owned();
332        // `exec` replaces the sh process with sleep, so the PID we
333        // capture is the PID `start_kill` targets.
334        let command = format!("echo $$ > {marker_str} && exec sleep 30");
335        let tool = RunShell::new(tmp.path()).with_timeout(Duration::from_millis(150));
336        let err = tool
337            .execute(json!({ "command": command }))
338            .await
339            .unwrap_err();
340        assert!(matches!(err, Error::Tool { .. }));
341
342        let pid_str = std::fs::read_to_string(&marker)
343            .expect("child should have written its PID before exec");
344        let pid: i32 = pid_str
345            .trim()
346            .parse()
347            .expect("PID file should contain a number");
348
349        // Poll `kill -0` for up to 5 seconds; the child must be gone.
350        // We shell out instead of binding libc to honour AGENTS.md's
351        // "no new deps without justification" rule.
352        let mut dead = false;
353        for _ in 0..50 {
354            let probe = std::process::Command::new("kill")
355                .arg("-0")
356                .arg(pid.to_string())
357                .output()
358                .expect("kill -0 probe should spawn");
359            if !probe.status.success() {
360                dead = true;
361                break;
362            }
363            tokio::time::sleep(Duration::from_millis(100)).await;
364        }
365        assert!(
366            dead,
367            "timed-out child PID {pid} still alive after 5s — orphan"
368        );
369    }
370
371    // The timeout arm must kill the child and then AWAIT both reader
372    // tasks before returning, so their JoinHandles don't detach with
373    // buffered output. The observable contract: `execute` returns `Err`
374    // promptly — it must NOT hang until the child's sleep would have
375    // finished. `exec` makes the shell PID *become* the sleeper, so the
376    // kill closes the pipes and the drain completes immediately (a plain
377    // `sleep 30` would leave an orphaned descendant holding the pipes
378    // and the drain would block for the full sleep — a known limitation
379    // of best-effort cleanup on this path). If the drain were ordered
380    // before the kill (deadlock) or the kill were dropped (readers never
381    // see EOF), this test would hang for the full 30s.
382    #[tokio::test]
383    async fn shell_timeout_drains_reader_tasks() {
384        let tmp = TempDir::new().unwrap();
385        let tool = RunShell::new(tmp.path()).with_timeout(Duration::from_millis(200));
386        let result = tokio::time::timeout(
387            Duration::from_secs(5),
388            tool.execute(json!({"command": "exec sleep 30"})),
389        )
390        .await;
391        let err = match result {
392            Ok(r) => r.expect_err("expected the timed-out command to fail"),
393            Err(_) => panic!("execute did not return within 5s — reader tasks not drained"),
394        };
395        match err {
396            Error::Tool { message, .. } => assert!(
397                message.contains("timed out"),
398                "expected a timeout error, got: {message}"
399            ),
400            other => panic!("expected Tool timeout error, got: {other}"),
401        }
402    }
403
404    #[tokio::test]
405    async fn runs_in_subdir_when_cwd_given() {
406        let tmp = TempDir::new().unwrap();
407        // Create a subdirectory with a marker file
408        let sub = tmp.path().join("sub");
409        std::fs::create_dir(&sub).unwrap();
410        std::fs::write(sub.join("marker.txt"), "content").unwrap();
411
412        let out = RunShell::new(tmp.path())
413            .execute(json!({"command": "ls", "cwd": "sub"}))
414            .await
415            .unwrap();
416
417        assert!(out.contains("exit: 0"));
418        assert!(out.contains("marker.txt"));
419    }
420
421    #[tokio::test]
422    async fn rejects_cwd_outside_workspace() {
423        let tmp = TempDir::new().unwrap();
424        let err = RunShell::new(tmp.path())
425            .execute(json!({"command": "echo hello", "cwd": "../escape"}))
426            .await
427            .unwrap_err();
428
429        assert!(matches!(err, Error::BadToolArgs { ref name, .. } if name == "Bash"));
430        let err_msg = format!("{err}");
431        assert!(err_msg.contains("cwd"));
432    }
433
434    #[tokio::test]
435    async fn accepts_dot_cwd_as_root() {
436        let tmp = TempDir::new().unwrap();
437        let out = RunShell::new(tmp.path())
438            .execute(json!({"command": "pwd", "cwd": "."}))
439            .await
440            .unwrap();
441
442        assert!(out.contains("exit: 0"));
443        // pwd should output something non-empty
444        assert!(out.contains("--- stdout ---"));
445    }
446
447    #[tokio::test]
448    async fn existing_no_cwd_call_still_works() {
449        let tmp = TempDir::new().unwrap();
450        let out = RunShell::new(tmp.path())
451            .execute(json!({"command": "echo hello"}))
452            .await
453            .unwrap();
454
455        assert!(out.contains("exit: 0"));
456        assert!(out.contains("hello"));
457    }
458
459    // ── Builder method tests ──────────────────────────────────────────────────
460
461    #[test]
462    fn with_timeout_stores_value() {
463        // kills `replace RunShell::with_timeout -> RunShell with Default::default()`
464        let tmp = TempDir::new().unwrap();
465        let d = Duration::from_millis(777);
466        let tool = RunShell::new(tmp.path()).with_timeout(d);
467        assert_eq!(tool.timeout, d, "with_timeout must persist the duration");
468    }
469
470    #[test]
471    fn with_max_output_bytes_clamps_to_hard_cap() {
472        // kills `n.min(MAX_OUTPUT_BYTES_HARD_CAP)` → `n` mutant
473        let tmp = TempDir::new().unwrap();
474        let tool = RunShell::new(tmp.path()).with_max_output_bytes(MAX_OUTPUT_BYTES_HARD_CAP * 4);
475        assert_eq!(
476            tool.max_output_bytes, MAX_OUTPUT_BYTES_HARD_CAP,
477            "with_max_output_bytes must clamp at the hard cap"
478        );
479    }
480
481    #[test]
482    fn with_max_output_bytes_within_cap_is_preserved() {
483        // complementary: values within the hard cap are stored as-is
484        let tmp = TempDir::new().unwrap();
485        let tool = RunShell::new(tmp.path()).with_max_output_bytes(64 * 1024);
486        assert_eq!(
487            tool.max_output_bytes,
488            64 * 1024,
489            "with_max_output_bytes must store values within the hard cap unchanged"
490        );
491    }
492
493    // Tests for env-vars passthrough (goal-27)
494    #[tokio::test]
495    async fn env_overrides_and_errors() {
496        let tmp = TempDir::new().unwrap();
497        let tool = RunShell::new(tmp.path());
498
499        // Test A: env var is set and visible in the command
500        let out = tool
501            .execute(json!({"command": "echo $RECURSIVE_TEST_VAR", "env": {"RECURSIVE_TEST_VAR": "hello"}}))
502            .await
503            .unwrap();
504        assert!(out.contains("exit: 0"));
505        assert!(out.contains("hello"));
506
507        // Test B: non-string env value returns BadToolArgs
508        let err = tool
509            .execute(json!({"command": "echo x", "env": {"MY_KEY": 42}}))
510            .await
511            .unwrap_err();
512        assert!(matches!(err, Error::BadToolArgs { .. }));
513        let err_msg = format!("{err}");
514        assert!(
515            err_msg.contains("MY_KEY"),
516            "error should mention the offending key: {err_msg}"
517        );
518
519        // Test C (regression): omitting env works exactly as before
520        let out = tool
521            .execute(json!({"command": "echo hello"}))
522            .await
523            .unwrap();
524        assert!(out.contains("exit: 0"));
525        assert!(out.contains("hello"));
526    }
527}