adk_devtools/tools/
bash.rs1use 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
25pub struct BashTool {
35 workspace: Workspace,
36}
37
38impl BashTool {
39 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 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 #[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 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 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
145struct ProcessGroupGuard {
153 pid: Option<u32>,
154}
155
156impl ProcessGroupGuard {
157 fn kill(&self) {
158 #[cfg(unix)]
159 if let Some(pid) = self.pid {
160 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
181async 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 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
228fn 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 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 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}