1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolError, ToolExecutionContext, ToolResult};
3use bamboo_infrastructure::process::{
4 build_command_environment, decode_process_line_lossy, hide_window_for_tokio_command,
5 preferred_bash_shell, render_command_line, trace_windows_command,
6 windows_command_trace_enabled, PreparedCommandEnvironment,
7};
8use serde::Deserialize;
9use serde_json::{json, Map, Value};
10use std::path::{Path, PathBuf};
11use std::process::Stdio;
12use tokio::io::{AsyncBufReadExt, BufReader};
13use tokio::process::Command;
14use tokio::time::{Duration, Instant};
15
16use super::{bash_runtime, workspace_state};
17
18const DEFAULT_TIMEOUT_MS: u64 = 120_000;
19const MAX_TIMEOUT_MS: u64 = 600_000;
20const MAX_CAPTURE_BYTES: usize = 512 * 1024;
21
22#[derive(Debug, Deserialize)]
23struct BashArgs {
24 command: String,
25 #[serde(default)]
26 timeout: Option<u64>,
27 #[serde(default)]
28 description: Option<String>,
29 #[serde(default)]
30 run_in_background: Option<bool>,
31 #[serde(default)]
32 workdir: Option<String>,
33}
34
35pub struct BashTool;
36
37impl BashTool {
38 pub fn new() -> Self {
39 Self
40 }
41
42 fn effective_timeout_ms(requested: Option<u64>) -> u64 {
43 let value = requested.unwrap_or(DEFAULT_TIMEOUT_MS);
44 value.clamp(1, MAX_TIMEOUT_MS)
45 }
46
47 fn append_capped(buffer: &mut String, line: &str, truncated: &mut bool) {
48 if *truncated {
49 return;
50 }
51 let needed = line.len() + 1;
52 if buffer.len() + needed <= MAX_CAPTURE_BYTES {
53 buffer.push_str(line);
54 buffer.push('\n');
55 return;
56 }
57
58 let remaining = MAX_CAPTURE_BYTES.saturating_sub(buffer.len());
59 if remaining > 0 {
60 let take = remaining.saturating_sub(1);
61 if take > 0 {
62 let mut end = take.min(line.len());
63 while end > 0 && !line.is_char_boundary(end) {
64 end -= 1;
65 }
66 buffer.push_str(&line[..end]);
67 }
68 if buffer.len() < MAX_CAPTURE_BYTES {
69 buffer.push('\n');
70 }
71 }
72 *truncated = true;
73 }
74
75 fn python_diagnostics_json(
76 diagnostics: &bamboo_infrastructure::process::PythonDiscoveryDiagnostics,
77 include_full_tried: bool,
78 ) -> Value {
79 let mut python = Map::new();
80 if let Some(configured) = diagnostics.configured.as_ref() {
81 python.insert("configured".to_string(), json!(configured));
82 }
83 if let Some(resolved) = diagnostics.resolved.as_ref() {
84 python.insert("resolved".to_string(), json!(resolved));
85 }
86 if let Some(invocation) = diagnostics.invocation.as_ref() {
87 python.insert("invocation".to_string(), json!(invocation));
88 }
89 if let Some(source) = diagnostics.source.as_ref() {
90 python.insert("source".to_string(), json!(source));
91 }
92 if !diagnostics.tried_preview.is_empty() {
93 python.insert(
94 "tried_preview".to_string(),
95 json!(diagnostics.tried_preview),
96 );
97 }
98 if diagnostics.tried_total > 0 {
99 python.insert("tried_total".to_string(), json!(diagnostics.tried_total));
100 python.insert(
101 "tried_truncated".to_string(),
102 json!(diagnostics.tried_truncated),
103 );
104 }
105 if let Some(hint) = diagnostics.hint.as_ref() {
106 python.insert("hint".to_string(), json!(hint));
107 }
108 if include_full_tried && !diagnostics.tried.is_empty() {
109 python.insert("tried".to_string(), json!(diagnostics.tried));
110 }
111 Value::Object(python)
112 }
113
114 fn environment_json(
115 diagnostics: &bamboo_infrastructure::process::CommandEnvironmentDiagnostics,
116 include_full_python_tried: bool,
117 ) -> Value {
118 let mut environment = Map::new();
119 environment.insert("source".to_string(), json!(diagnostics.source.as_str()));
120 if let Some(import_shell) = diagnostics.import_shell.as_ref() {
121 environment.insert("import_shell".to_string(), json!(import_shell));
122 }
123 if let Some(import_error) = diagnostics.import_error.as_ref() {
124 environment.insert("import_error".to_string(), json!(import_error));
125 }
126 if let Some(path) = diagnostics.path.as_ref() {
127 environment.insert("path".to_string(), json!(path));
128 }
129 if let Some(path_entries) = diagnostics.path_entries {
130 environment.insert("path_entries".to_string(), json!(path_entries));
131 }
132
133 let python = Self::python_diagnostics_json(&diagnostics.python, include_full_python_tried);
134 if python
135 .as_object()
136 .map(|map| !map.is_empty())
137 .unwrap_or(false)
138 {
139 environment.insert("python".to_string(), python);
140 }
141
142 Value::Object(environment)
143 }
144
145 fn resolve_cwd(session_workspace: &Path, workdir: Option<&str>) -> Result<PathBuf, ToolError> {
146 let resolved = match workdir {
147 Some(raw) => {
148 let trimmed = raw.trim();
149 if trimmed.is_empty() {
150 return Err(ToolError::InvalidArguments(
151 "'workdir' cannot be empty".to_string(),
152 ));
153 }
154 let requested = Path::new(trimmed);
155 if requested.is_absolute() {
156 requested.to_path_buf()
157 } else {
158 session_workspace.join(requested)
159 }
160 }
161 None => session_workspace.to_path_buf(),
162 };
163
164 let metadata = std::fs::metadata(&resolved).map_err(|error| {
165 ToolError::InvalidArguments(format!(
166 "Invalid workdir '{}': {}",
167 bamboo_config::paths::path_to_display_string(&resolved),
168 error
169 ))
170 })?;
171 if !metadata.is_dir() {
172 return Err(ToolError::InvalidArguments(format!(
173 "workdir must be a directory: {}",
174 bamboo_config::paths::path_to_display_string(&resolved)
175 )));
176 }
177
178 resolved.canonicalize().map_err(|error| {
179 ToolError::Execution(format!(
180 "Failed to canonicalize workdir '{}': {}",
181 bamboo_config::paths::path_to_display_string(&resolved),
182 error
183 ))
184 })
185 }
186
187 async fn prepare_environment() -> PreparedCommandEnvironment {
188 let overrides = bamboo_llm::Config::current_env_vars();
189 build_command_environment(&overrides).await
190 }
191
192 async fn run_foreground(
193 &self,
194 command: &str,
195 timeout_ms: u64,
196 cwd: &Path,
197 ctx: ToolExecutionContext<'_>,
198 ) -> Result<ToolResult, ToolError> {
199 let shell = preferred_bash_shell();
200 trace_windows_command(
201 "agent.bash.foreground",
202 &shell.program,
203 [shell.arg, command],
204 );
205 if windows_command_trace_enabled() {
206 let rendered = render_command_line(&shell.program, [shell.arg, command]);
207 ctx.emit_tool_token(format!("[windows-cmd-trace] {rendered}\n"))
208 .await;
209 }
210
211 let prepared_env = Self::prepare_environment().await;
212
213 let mut cmd = Command::new(&shell.program);
214 hide_window_for_tokio_command(&mut cmd);
215 cmd.current_dir(cwd);
216 prepared_env.apply_to_tokio_command(&mut cmd);
217 cmd.arg(shell.arg)
218 .arg(command)
219 .stdin(Stdio::null())
220 .stdout(Stdio::piped())
221 .stderr(Stdio::piped())
222 .kill_on_drop(true);
223
224 let mut child = cmd
225 .spawn()
226 .map_err(|e| ToolError::Execution(format!("Failed to execute command: {}", e)))?;
227
228 let stdout = child
229 .stdout
230 .take()
231 .ok_or_else(|| ToolError::Execution("Failed to capture stdout".to_string()))?;
232 let stderr = child
233 .stderr
234 .take()
235 .ok_or_else(|| ToolError::Execution("Failed to capture stderr".to_string()))?;
236
237 let mut stdout_reader = BufReader::new(stdout);
238 let mut stderr_reader = BufReader::new(stderr);
239 let mut stdout_line_bytes = Vec::new();
240 let mut stderr_line_bytes = Vec::new();
241
242 let mut stdout_buf = String::new();
243 let mut stderr_buf = String::new();
244 let mut stdout_truncated = false;
245 let mut stderr_truncated = false;
246 let mut stdout_done = false;
247 let mut stderr_done = false;
248 let deadline = Instant::now() + Duration::from_millis(timeout_ms);
249 let mut timed_out = false;
250
251 while !(stdout_done && stderr_done) {
252 if Instant::now() >= deadline {
253 timed_out = true;
254 break;
255 }
256
257 let remaining = deadline.saturating_duration_since(Instant::now());
258 tokio::select! {
259 line = stdout_reader.read_until(b'\n', &mut stdout_line_bytes), if !stdout_done => {
260 match line {
261 Ok(0) => stdout_done = true,
262 Ok(_) => {
263 let line = decode_process_line_lossy(&mut stdout_line_bytes);
264 Self::append_capped(&mut stdout_buf, &line, &mut stdout_truncated);
265 ctx.emit_tool_token(format!("{}\n", line)).await;
266 }
267 Err(e) => {
268 return Err(ToolError::Execution(format!("Failed reading stdout: {}", e)));
269 }
270 }
271 }
272 line = stderr_reader.read_until(b'\n', &mut stderr_line_bytes), if !stderr_done => {
273 match line {
274 Ok(0) => stderr_done = true,
275 Ok(_) => {
276 let line = decode_process_line_lossy(&mut stderr_line_bytes);
277 Self::append_capped(&mut stderr_buf, &line, &mut stderr_truncated);
278 ctx.emit_tool_token(format!("{}\n", line)).await;
279 }
280 Err(e) => {
281 return Err(ToolError::Execution(format!("Failed reading stderr: {}", e)));
282 }
283 }
284 }
285 _ = tokio::time::sleep(remaining) => {
286 timed_out = true;
287 break;
288 }
289 }
290 }
291
292 let status = if timed_out {
293 let _ = child.kill().await;
294 None
295 } else {
296 Some(
297 child
298 .wait()
299 .await
300 .map_err(|e| ToolError::Execution(format!("Failed waiting command: {}", e)))?,
301 )
302 };
303
304 let exit_code = status.and_then(|s| s.code());
305 let success = !timed_out && exit_code.unwrap_or(-1) == 0;
306 let cwd_display = bamboo_config::paths::path_to_display_string(cwd);
307
308 let environment = Self::environment_json(&prepared_env.diagnostics, !success);
309
310 Ok(ToolResult {
311 success,
312 result: json!({
313 "command": command,
314 "cwd": cwd_display,
315 "stdout": stdout_buf,
316 "stderr": stderr_buf,
317 "exit_code": exit_code,
318 "timed_out": timed_out,
319 "stdout_truncated": stdout_truncated,
320 "stderr_truncated": stderr_truncated,
321 "environment": environment,
322 })
323 .to_string(),
324 display_preference: Some("Collapsible".to_string()),
325 images: Vec::new(),
326 })
327 }
328}
329
330impl Default for BashTool {
331 fn default() -> Self {
332 Self::new()
333 }
334}
335
336#[async_trait]
337impl Tool for BashTool {
338 fn name(&self) -> &str {
339 "Bash"
340 }
341
342 fn description(&self) -> &str {
343 "Execute shell commands with streaming output (supports background mode). Default timeout is 120000ms (max 600000ms); captured stdout/stderr are each capped at 512KB."
344 }
345
346 fn parameters_schema(&self) -> serde_json::Value {
347 json!({
348 "type": "object",
349 "properties": {
350 "command": {
351 "type": "string",
352 "description": "The command to execute"
353 },
354 "timeout": {
355 "type": "number",
356 "description": "Optional timeout in milliseconds (default 120000, max 600000)"
357 },
358 "description": {
359 "type": "string",
360 "description": "Optional short context label for the command"
361 },
362 "run_in_background": {
363 "type": "boolean",
364 "description": "Set to true to run this command in the background"
365 },
366 "workdir": {
367 "type": "string",
368 "description": "Optional working directory. Relative paths are resolved from the session workspace."
369 }
370 },
371 "required": ["command"],
372 "additionalProperties": false
373 })
374 }
375
376 async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
377 self.execute_with_context(args, ToolExecutionContext::none("Bash"))
378 .await
379 }
380
381 async fn execute_with_context(
382 &self,
383 args: serde_json::Value,
384 ctx: ToolExecutionContext<'_>,
385 ) -> Result<ToolResult, ToolError> {
386 let parsed: BashArgs = serde_json::from_value(args)
387 .map_err(|e| ToolError::InvalidArguments(format!("Invalid Bash args: {}", e)))?;
388
389 let command = parsed.command.trim();
390 if command.is_empty() {
391 return Err(ToolError::InvalidArguments(
392 "'command' cannot be empty".to_string(),
393 ));
394 }
395
396 let _ = parsed.description;
397 let timeout_ms = Self::effective_timeout_ms(parsed.timeout);
398 let session_workspace = workspace_state::workspace_or_process_cwd(ctx.session_id);
399 let cwd = Self::resolve_cwd(&session_workspace, parsed.workdir.as_deref())?;
400 if parsed.run_in_background.unwrap_or(false) {
401 let shell = bash_runtime::spawn_background(command, Some(&cwd))
402 .await
403 .map_err(ToolError::Execution)?;
404
405 if let Some(requested_timeout) = parsed.timeout {
406 let kill_after_ms = Self::effective_timeout_ms(Some(requested_timeout));
407 let shell_clone = shell.clone();
408 tokio::spawn(async move {
409 tokio::time::sleep(Duration::from_millis(kill_after_ms)).await;
410 if shell_clone.status() == "running" {
411 let _ = shell_clone.kill().await;
412 }
413 });
414 }
415
416 return Ok(ToolResult {
417 success: true,
418 result: json!({
419 "bash_id": shell.id,
420 "command": shell.command,
421 "status": "running",
422 "cwd": bamboo_config::paths::path_to_display_string(&cwd),
423 "environment": Self::environment_json(&shell.environment, false),
424 })
425 .to_string(),
426 display_preference: Some("Collapsible".to_string()),
427 images: Vec::new(),
428 });
429 }
430
431 self.run_foreground(command, timeout_ms, &cwd, ctx).await
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 use bamboo_agent_core::AgentEvent;
439 use bamboo_infrastructure::process::{
440 clear_command_environment_cache_for_tests, prime_command_environment_cache_for_tests,
441 CommandEnvironmentDiagnostics, CommandEnvironmentSource, PythonDiscoveryDiagnostics,
442 };
443 use serde_json::Value;
444 use std::collections::HashMap;
445 use tokio::sync::mpsc;
446 use tokio::time::{sleep, Duration, Instant};
447
448 #[cfg(target_os = "windows")]
449 fn mixed_output_command() -> &'static str {
450 "echo out && echo err 1>&2"
451 }
452
453 #[cfg(not(target_os = "windows"))]
454 fn mixed_output_command() -> &'static str {
455 "printf 'out\\n'; printf 'err\\n' 1>&2"
456 }
457
458 #[cfg(target_os = "windows")]
459 fn invalid_utf8_stderr_command() -> String {
460 let shell = bamboo_infrastructure::process::preferred_bash_shell();
461 if shell.arg == "-lc" {
462 "printf '\\377\\n' 1>&2".to_string()
463 } else {
464 "powershell -NoProfile -Command \"$bytes = [byte[]](0xFF,0x0A); [Console]::OpenStandardError().Write($bytes,0,$bytes.Length)\"".to_string()
465 }
466 }
467
468 #[cfg(not(target_os = "windows"))]
469 fn invalid_utf8_stderr_command() -> String {
470 "printf '\\377\\n' 1>&2".to_string()
471 }
472
473 fn test_environment_diagnostics() -> CommandEnvironmentDiagnostics {
474 CommandEnvironmentDiagnostics {
475 source: CommandEnvironmentSource::InheritedProcess,
476 import_shell: None,
477 import_error: Some("test-import-disabled".to_string()),
478 path: Some("/usr/bin:/bin".to_string()),
479 path_entries: Some(2),
480 python: PythonDiscoveryDiagnostics {
481 configured: Some("python3".to_string()),
482 resolved: Some("/usr/bin/python3".to_string()),
483 invocation: Some("/usr/bin/python3".to_string()),
484 source: Some("path".to_string()),
485 tried: vec!["python3".to_string(), "python".to_string()],
486 tried_preview: vec!["python3".to_string(), "python".to_string()],
487 tried_total: 2,
488 tried_truncated: false,
489 hint: None,
490 },
491 }
492 }
493
494 fn prime_test_command_environment() {
495 clear_command_environment_cache_for_tests();
496 prime_command_environment_cache_for_tests(
497 HashMap::from([("PATH".to_string(), "/usr/bin:/bin".to_string())]),
498 test_environment_diagnostics(),
499 );
500 }
501
502 #[tokio::test]
503 async fn bash_foreground_returns_stdout_stderr_and_streams_tokens() {
504 prime_test_command_environment();
505 let tool = BashTool::new();
506 let (tx, mut rx) = mpsc::channel(32);
507
508 let result = tool
509 .execute_with_context(
510 json!({
511 "command": mixed_output_command()
512 }),
513 ToolExecutionContext {
514 session_id: Some("session_1"),
515 tool_call_id: "call_1",
516 event_tx: Some(&tx),
517 available_tool_schemas: None,
518 },
519 )
520 .await
521 .unwrap();
522
523 assert!(result.success);
524
525 let payload: Value = serde_json::from_str(&result.result).unwrap();
526 assert_eq!(payload["timed_out"], false);
527 assert_eq!(payload["exit_code"], 0);
528 assert!(payload["stdout"]
529 .as_str()
530 .unwrap_or_default()
531 .contains("out"));
532 assert!(payload["stderr"]
533 .as_str()
534 .unwrap_or_default()
535 .contains("err"));
536 assert_eq!(payload["environment"]["source"], "process_env");
537 assert_eq!(
538 payload["environment"]["import_error"],
539 "test-import-disabled"
540 );
541 assert_eq!(
542 payload["environment"]["python"]["resolved"],
543 "/usr/bin/python3"
544 );
545 assert_eq!(
546 payload["environment"]["python"]["invocation"],
547 "/usr/bin/python3"
548 );
549 assert_eq!(payload["environment"]["python"]["source"], "path");
550 assert_eq!(
551 payload["environment"]["python"]["tried_preview"][0],
552 "python3"
553 );
554 assert_eq!(payload["environment"]["python"]["tried_total"], 1);
555 assert!(payload["environment"]["python"].get("tried").is_none());
556
557 let mut streamed = Vec::new();
558 while let Ok(event) = rx.try_recv() {
559 if let AgentEvent::ToolToken { content, .. } = event {
560 streamed.push(content);
561 }
562 }
563
564 assert!(streamed.iter().any(|line| line.contains("out")));
565 assert!(streamed.iter().any(|line| line.contains("err")));
566 }
567
568 #[tokio::test]
569 async fn bash_foreground_tolerates_invalid_utf8_stderr() {
570 prime_test_command_environment();
571 let tool = BashTool::new();
572 let result = tool
573 .execute(json!({
574 "command": invalid_utf8_stderr_command()
575 }))
576 .await;
577
578 assert!(result.is_ok(), "invalid UTF-8 stderr should not fail");
579 let payload: Value = serde_json::from_str(&result.unwrap().result).unwrap();
580 let stderr = payload["stderr"].as_str().unwrap_or_default();
581 assert!(!stderr.is_empty());
582 }
583
584 #[cfg(not(target_os = "windows"))]
585 #[tokio::test]
586 async fn bash_foreground_failure_includes_full_python_tried_list() {
587 prime_test_command_environment();
588 let tool = BashTool::new();
589 let result = tool
590 .execute(json!({
591 "command": "false"
592 }))
593 .await
594 .unwrap();
595
596 assert!(!result.success);
597 let payload: Value = serde_json::from_str(&result.result).unwrap();
598 assert_eq!(payload["exit_code"], 1);
599 assert_eq!(payload["environment"]["python"]["tried_total"], 1);
600 assert_eq!(payload["environment"]["python"]["tried"][0], "python3");
601 }
602
603 #[cfg(not(target_os = "windows"))]
604 #[tokio::test]
605 async fn bash_foreground_sets_stdout_truncated_when_output_exceeds_cap() {
606 prime_test_command_environment();
607 let tool = BashTool::new();
608 let result = tool
609 .execute(json!({
610 "command": "i=0; while [ $i -lt 70000 ]; do printf 'aaaaaaaaaa'; i=$((i+1)); done; printf '\\n'"
611 }))
612 .await
613 .unwrap();
614
615 let payload: Value = serde_json::from_str(&result.result).unwrap();
616 assert_eq!(payload["timed_out"], false);
617 assert_eq!(payload["stdout_truncated"], true);
618 }
619
620 #[cfg(not(target_os = "windows"))]
621 #[tokio::test]
622 async fn bash_background_honors_explicit_timeout() {
623 prime_test_command_environment();
624 let tool = BashTool::new();
625 let result = tool
626 .execute(json!({
627 "command": "sleep 2",
628 "run_in_background": true,
629 "timeout": 50
630 }))
631 .await
632 .unwrap();
633 let payload: Value = serde_json::from_str(&result.result).unwrap();
634 assert_eq!(payload["environment"]["source"], "process_env");
635 assert_eq!(
636 payload["environment"]["python"]["resolved"],
637 "/usr/bin/python3"
638 );
639 assert_eq!(
640 payload["environment"]["python"]["invocation"],
641 "/usr/bin/python3"
642 );
643 assert_eq!(payload["environment"]["python"]["tried_total"], 1);
644 assert!(payload["environment"]["python"].get("tried").is_none());
645 let shell_id = payload["bash_id"].as_str().unwrap().to_string();
646
647 let started = Instant::now();
648 loop {
649 let shell = super::bash_runtime::get_shell(&shell_id).unwrap();
650 if shell.status() == "completed" {
651 break;
652 }
653 if started.elapsed() > Duration::from_secs(2) {
654 panic!("background shell did not stop after timeout");
655 }
656 sleep(Duration::from_millis(25)).await;
657 }
658 }
659
660 #[tokio::test]
661 async fn bash_resolves_relative_workdir_from_session_workspace() {
662 prime_test_command_environment();
663 let tool = BashTool::new();
664 let dir = tempfile::tempdir().unwrap();
665 let base = dir.path().join("base");
666 let nested = base.join("nested");
667 tokio::fs::create_dir_all(&nested).await.unwrap();
668
669 let session_id = format!("session_{}", uuid::Uuid::new_v4());
670 super::workspace_state::set_workspace(&session_id, base.canonicalize().unwrap());
671
672 let result = tool
673 .execute_with_context(
674 json!({
675 "command": "pwd",
676 "workdir": "nested"
677 }),
678 ToolExecutionContext {
679 session_id: Some(&session_id),
680 tool_call_id: "call_1",
681 event_tx: None,
682 available_tool_schemas: None,
683 },
684 )
685 .await
686 .unwrap();
687
688 let payload: Value = serde_json::from_str(&result.result).unwrap();
689 let expected =
690 bamboo_config::paths::path_to_display_string(&nested.canonicalize().unwrap());
691 assert_eq!(payload["cwd"].as_str().unwrap_or_default(), expected);
692 }
693
694 #[tokio::test]
695 async fn bash_rejects_workdir_that_is_not_directory() {
696 prime_test_command_environment();
697 let tool = BashTool::new();
698 let file = tempfile::NamedTempFile::new().unwrap();
699
700 let result = tool
701 .execute(json!({
702 "command": "echo hello",
703 "workdir": file.path()
704 }))
705 .await;
706
707 assert!(
708 matches!(result, Err(ToolError::InvalidArguments(msg)) if msg.contains("directory"))
709 );
710 }
711}