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::{AsyncBufReadExt, BufReader};
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 still
29/// contains the complete stdout/stderr for the model to consume.
30pub struct BashTool {
31    workspace: Workspace,
32}
33
34impl BashTool {
35    /// Create a `bash` tool bound to `workspace`.
36    pub fn new(workspace: Workspace) -> Self {
37        Self { workspace }
38    }
39}
40
41#[async_trait]
42impl Tool for BashTool {
43    fn name(&self) -> &str {
44        "bash"
45    }
46
47    fn description(&self) -> &str {
48        "Run a shell command in the workspace root and return stdout, stderr, and the \
49         exit code. Streams output incrementally for live UI display. Has a timeout."
50    }
51
52    fn parameters_schema(&self) -> Option<Value> {
53        Some(json!({
54            "type": "object",
55            "properties": {
56                "command": { "type": "string", "description": "The shell command to run." },
57                "timeout_secs": { "type": "integer", "description": "Optional timeout in seconds (default: workspace setting)." }
58            },
59            "required": ["command"]
60        }))
61    }
62
63    async fn execute(&self, ctx: Arc<dyn ToolContext>, args: Value) -> Result<Value> {
64        if !self.workspace.bash_allowed() {
65            return Err(DevToolError::BashDisabled.into());
66        }
67        let command = require_str(&args, "command")?;
68        let timeout = args
69            .get("timeout_secs")
70            .and_then(Value::as_u64)
71            .map(Duration::from_secs)
72            .unwrap_or_else(|| self.workspace.bash_timeout_value());
73
74        let mut cmd = tokio::process::Command::new("sh");
75        cmd.arg("-c")
76            .arg(&command)
77            .current_dir(self.workspace.root())
78            .stdout(Stdio::piped())
79            .stderr(Stdio::piped());
80
81        // The parent environment of an agent process routinely holds provider API keys,
82        // and `env` would print them. Pass through only what tools need, unless the
83        // workspace opts into inheriting.
84        if !self.workspace.inherits_env() {
85            cmd.env_clear();
86            for (key, value) in self.workspace.bash_env() {
87                cmd.env(key, value);
88            }
89        }
90
91        // Run in its own process group so a timeout can terminate descendants. Killing
92        // only the direct child left `sh`'s children running after the tool returned.
93        #[cfg(unix)]
94        cmd.process_group(0);
95
96        let mut child = cmd.spawn().map_err(DevToolError::from)?;
97        let child_pid = child.id();
98
99        let stdout_pipe = child.stdout.take();
100        let stderr_pipe = child.stderr.take();
101        let cap = self.workspace.max_output();
102
103        // Stream stdout and stderr concurrently, emitting lines as progress events
104        let ctx_out = ctx.clone();
105        let stdout_task = tokio::spawn(async move {
106            let mut output = String::new();
107            if let Some(pipe) = stdout_pipe {
108                let mut reader = BufReader::new(pipe).lines();
109                while let Ok(Some(line)) = reader.next_line().await {
110                    ctx_out.emit_progress("stdout", &format!("{line}\n")).await;
111                    output.push_str(&line);
112                    output.push('\n');
113                }
114            }
115            output
116        });
117
118        let ctx_err = ctx.clone();
119        let stderr_task = tokio::spawn(async move {
120            let mut output = String::new();
121            if let Some(pipe) = stderr_pipe {
122                let mut reader = BufReader::new(pipe).lines();
123                while let Ok(Some(line)) = reader.next_line().await {
124                    ctx_err.emit_progress("stderr", &format!("{line}\n")).await;
125                    output.push_str(&line);
126                    output.push('\n');
127                }
128            }
129            output
130        });
131
132        // Wait for completion with timeout
133        let result = tokio::time::timeout(timeout, async {
134            let status = child.wait().await?;
135            let stdout = stdout_task.await.unwrap_or_default();
136            let stderr = stderr_task.await.unwrap_or_default();
137            Ok::<_, std::io::Error>((status, stdout, stderr))
138        })
139        .await;
140
141        match result {
142            Ok(Ok((status, stdout, stderr))) => {
143                let (stdout, out_trunc) = truncate(stdout, cap);
144                let (stderr, err_trunc) = truncate(stderr, cap);
145                Ok(json!({
146                    "command": command,
147                    "exit_code": status.code(),
148                    "stdout": stdout,
149                    "stderr": stderr,
150                    "truncated": out_trunc || err_trunc,
151                }))
152            }
153            Ok(Err(e)) => Err(DevToolError::from(e).into()),
154            Err(_) => {
155                terminate_process_group(&mut child, child_pid);
156                ctx.emit_progress("stderr", &format!("\n[timeout after {}s]\n", timeout.as_secs()))
157                    .await;
158                Err(DevToolError::Timeout(timeout).into())
159            }
160        }
161    }
162}
163
164/// Terminate a timed-out command and everything it started.
165///
166/// The child leads its own process group, so signalling the negated pid reaches
167/// grandchildren too. Killing only the direct child left descendants — a spawned server,
168/// a background build — running after the tool returned.
169fn terminate_process_group(child: &mut tokio::process::Child, pid: Option<u32>) {
170    #[cfg(unix)]
171    if let Some(pid) = pid {
172        // SAFETY: `killpg` takes a process-group id and a signal, and cannot violate
173        // memory safety. A failure means the group already exited.
174        unsafe {
175            libc::killpg(pid as libc::pid_t, libc::SIGKILL);
176        }
177    }
178    #[cfg(not(unix))]
179    let _ = pid;
180
181    // Also signal the child directly, which is all that is available off Unix.
182    let _ = child.start_kill();
183}
184
185fn truncate(mut s: String, cap: usize) -> (String, bool) {
186    if s.len() <= cap {
187        return (s, false);
188    }
189    s.truncate(cap);
190    s.push_str("\n…[truncated]");
191    (s, true)
192}