Skip to main content

adk_devtools/tools/
bash.rs

1//! `bash` — run a shell command inside the workspace.
2//!
3//! Executes host-local (`sh -c`) with the working directory pinned to the
4//! workspace root and a timeout. Streams stdout/stderr incrementally via
5//! [`ToolContext::emit_progress`] for UI implementations that display live
6//! terminal output.
7//!
8//! **Not** strongly isolated; production deployments should run behind a
9//! containerized `CodeExecutor` (see the coding-agent design, §9).
10//! Mutating use requires [`Workspace::bash_allowed`].
11
12use std::process::Stdio;
13use std::sync::Arc;
14use std::time::Duration;
15
16use adk_core::{Result, Tool, ToolContext};
17use async_trait::async_trait;
18use serde_json::{Value, json};
19use tokio::io::{AsyncRead, AsyncReadExt};
20
21use crate::error::DevToolError;
22use crate::tools::read::require_str;
23use crate::workspace::Workspace;
24
25/// Runs a shell command in the workspace root with a timeout.
26///
27/// Streams stdout and stderr line-by-line via [`ToolContext::emit_progress`]
28/// so UI layers can display live terminal output. The final result contains
29/// stdout and stderr, each capped at [`Workspace::max_output`] bytes; output
30/// past the cap is read and discarded, so memory stays bounded.
31///
32/// Cancelling the call (dropping its future) kills the command and every
33/// process it started, as a timeout does.
34pub struct BashTool {
35    workspace: Workspace,
36}
37
38impl BashTool {
39    /// Create a `bash` tool bound to `workspace`.
40    pub fn new(workspace: Workspace) -> Self {
41        Self { workspace }
42    }
43}
44
45#[async_trait]
46impl Tool for BashTool {
47    fn name(&self) -> &str {
48        "bash"
49    }
50
51    fn description(&self) -> &str {
52        "Run a shell command in the workspace root and return stdout, stderr, and the \
53         exit code. Streams output incrementally for live UI display. Has a timeout."
54    }
55
56    fn parameters_schema(&self) -> Option<Value> {
57        Some(json!({
58            "type": "object",
59            "properties": {
60                "command": { "type": "string", "description": "The shell command to run." },
61                "timeout_secs": { "type": "integer", "description": "Optional timeout in seconds (default: workspace setting)." }
62            },
63            "required": ["command"]
64        }))
65    }
66
67    async fn execute(&self, ctx: Arc<dyn ToolContext>, args: Value) -> Result<Value> {
68        if !self.workspace.bash_allowed() {
69            return Err(DevToolError::BashDisabled.into());
70        }
71        let command = require_str(&args, "command")?;
72        let timeout = args
73            .get("timeout_secs")
74            .and_then(Value::as_u64)
75            .map(Duration::from_secs)
76            .unwrap_or_else(|| self.workspace.bash_timeout_value());
77
78        let mut cmd = tokio::process::Command::new("sh");
79        cmd.arg("-c")
80            .arg(&command)
81            .current_dir(self.workspace.root())
82            .stdout(Stdio::piped())
83            .stderr(Stdio::piped())
84            .kill_on_drop(true);
85
86        // The parent environment of an agent process routinely holds provider API keys,
87        // and `env` would print them. Pass through only what tools need, unless the
88        // workspace opts into inheriting.
89        if !self.workspace.inherits_env() {
90            cmd.env_clear();
91            for (key, value) in self.workspace.bash_env() {
92                cmd.env(key, value);
93            }
94        }
95
96        // Run in its own process group so a timeout or a cancelled call can terminate
97        // descendants. Killing only the direct child left `sh`'s children running.
98        #[cfg(unix)]
99        cmd.process_group(0);
100
101        let mut child = cmd.spawn().map_err(DevToolError::from)?;
102        let mut group = ProcessGroupGuard { pid: child.id() };
103
104        let stdout_pipe = child.stdout.take();
105        let stderr_pipe = child.stderr.take();
106        let cap = self.workspace.max_output();
107
108        let result = tokio::time::timeout(timeout, async {
109            let (stdout, stderr) = tokio::join!(
110                capture(stdout_pipe, cap, ctx.as_ref(), "stdout"),
111                capture(stderr_pipe, cap, ctx.as_ref(), "stderr"),
112            );
113            let status = child.wait().await?;
114            // The child is reaped, so its pid may be reused and must not be signalled.
115            group.disarm();
116            Ok::<_, std::io::Error>((status, stdout, stderr))
117        })
118        .await;
119
120        match result {
121            Ok(Ok((status, (stdout, out_exceeded), (stderr, err_exceeded)))) => {
122                let (stdout, out_trunc) = bound_output(&stdout, cap, out_exceeded);
123                let (stderr, err_trunc) = bound_output(&stderr, cap, err_exceeded);
124                Ok(json!({
125                    "command": command,
126                    "exit_code": status.code(),
127                    "stdout": stdout,
128                    "stderr": stderr,
129                    "truncated": out_trunc || err_trunc,
130                }))
131            }
132            Ok(Err(e)) => Err(DevToolError::from(e).into()),
133            Err(_) => {
134                group.kill();
135                // Also signal the child directly, which is all that is available off Unix.
136                let _ = child.start_kill();
137                ctx.emit_progress("stderr", &format!("\n[timeout after {}s]\n", timeout.as_secs()))
138                    .await;
139                Err(DevToolError::Timeout(timeout).into())
140            }
141        }
142    }
143}
144
145/// Kills the command's whole process group, including when dropped.
146///
147/// The child leads its own process group, so signalling the negated pid reaches
148/// grandchildren too. `kill_on_drop` reaches only the direct child, which left
149/// descendants — a spawned server, a background build — running after a timeout or a
150/// cancelled call. The guard is disarmed once the child is reaped, because its pid may
151/// then be reused.
152struct ProcessGroupGuard {
153    pid: Option<u32>,
154}
155
156impl ProcessGroupGuard {
157    fn kill(&self) {
158        #[cfg(unix)]
159        if let Some(pid) = self.pid {
160            // SAFETY: `killpg` takes a process-group id and a signal, and cannot violate
161            // memory safety. A failure means the group already exited.
162            unsafe {
163                libc::killpg(pid as libc::pid_t, libc::SIGKILL);
164            }
165        }
166        #[cfg(not(unix))]
167        let _ = self.pid;
168    }
169
170    fn disarm(&mut self) {
171        self.pid = None;
172    }
173}
174
175impl Drop for ProcessGroupGuard {
176    fn drop(&mut self) {
177        self.kill();
178    }
179}
180
181/// Reads one output stream to EOF, keeping at most `cap` bytes.
182///
183/// Returns the kept bytes and whether the stream exceeded the cap. Output past the
184/// cap is read and discarded rather than left in the pipe, because a writer blocked
185/// on a full pipe would hang until the timeout. Complete lines are forwarded as
186/// progress up to the cap.
187async fn capture(
188    pipe: Option<impl AsyncRead + Unpin>,
189    cap: usize,
190    ctx: &dyn ToolContext,
191    stream: &str,
192) -> (Vec<u8>, bool) {
193    let mut kept = Vec::new();
194    let mut exceeded = false;
195    let Some(mut pipe) = pipe else {
196        return (kept, exceeded);
197    };
198    let mut chunk = [0u8; 8192];
199    // Bytes of `kept` already forwarded as progress.
200    let mut emitted = 0;
201    loop {
202        let read = match pipe.read(&mut chunk).await {
203            Ok(0) | Err(_) => break,
204            Ok(read) => read,
205        };
206        let room = cap - kept.len();
207        if read > room && !exceeded {
208            exceeded = true;
209            tracing::debug!(
210                output.stream = stream,
211                output.cap = cap,
212                "discarding output past the cap"
213            );
214        }
215        kept.extend_from_slice(&chunk[..read.min(room)]);
216        if let Some(newline) = kept[emitted..].iter().rposition(|&byte| byte == b'\n') {
217            let end = emitted + newline + 1;
218            ctx.emit_progress(stream, &String::from_utf8_lossy(&kept[emitted..end])).await;
219            emitted = end;
220        }
221    }
222    if emitted < kept.len() {
223        ctx.emit_progress(stream, &String::from_utf8_lossy(&kept[emitted..])).await;
224    }
225    (kept, exceeded)
226}
227
228/// Decodes captured output and bounds it to `cap` bytes on a character boundary.
229///
230/// The byte cap can split a multi-byte character, and lossy decoding can lengthen
231/// the text, so the cut is taken on the nearest boundary at or below `cap`.
232fn bound_output(bytes: &[u8], cap: usize, exceeded: bool) -> (String, bool) {
233    let mut text = String::from_utf8_lossy(bytes).into_owned();
234    let cut = text.len() > cap;
235    if cut {
236        text.truncate(text.floor_char_boundary(cap));
237    }
238    let truncated = exceeded || cut;
239    if truncated {
240        text.push_str("\n…[truncated]");
241    }
242    (text, truncated)
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn a_cap_inside_a_multi_byte_character_does_not_panic() {
251        // `€` is three bytes, so a cap of 4 falls inside the second one.
252        let (text, truncated) = bound_output("€€€".as_bytes(), 4, false);
253        assert_eq!((text.as_str(), truncated), ("€\n…[truncated]", true));
254    }
255
256    #[test]
257    fn a_character_split_by_the_capture_cap_is_dropped() {
258        // The capture kept `a` and the first byte of `é` before the cap was hit.
259        let (text, truncated) = bound_output(&"aé".as_bytes()[..2], 2, true);
260        assert_eq!((text.as_str(), truncated), ("a\n…[truncated]", true));
261    }
262
263    #[test]
264    fn output_within_the_cap_is_unchanged() {
265        let (text, truncated) = bound_output("héllo\n".as_bytes(), 1024, false);
266        assert_eq!((text.as_str(), truncated), ("héllo\n", false));
267    }
268}