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
22const PROMOTE_TO_BACKGROUND_AFTER_MS: u64 = 10_000;
27
28#[derive(Debug, Deserialize)]
29struct BashArgs {
30 command: String,
31 #[serde(default)]
32 timeout: Option<u64>,
33 #[serde(default)]
34 description: Option<String>,
35 #[serde(default)]
36 run_in_background: Option<bool>,
37 #[serde(default)]
38 interactive: Option<bool>,
39 #[serde(default)]
40 workdir: Option<String>,
41}
42
43pub struct BashTool;
44
45impl BashTool {
46 pub fn new() -> Self {
47 Self
48 }
49
50 fn effective_timeout_ms(requested: Option<u64>) -> u64 {
51 let value = requested.unwrap_or(DEFAULT_TIMEOUT_MS);
52 value.clamp(1, MAX_TIMEOUT_MS)
53 }
54
55 fn append_capped(buffer: &mut String, line: &str, truncated: &mut bool) {
56 if *truncated {
57 return;
58 }
59 let needed = line.len() + 1;
60 if buffer.len() + needed <= MAX_CAPTURE_BYTES {
61 buffer.push_str(line);
62 buffer.push('\n');
63 return;
64 }
65
66 let remaining = MAX_CAPTURE_BYTES.saturating_sub(buffer.len());
67 if remaining > 0 {
68 let take = remaining.saturating_sub(1);
69 if take > 0 {
70 let mut end = take.min(line.len());
71 while end > 0 && !line.is_char_boundary(end) {
72 end -= 1;
73 }
74 buffer.push_str(&line[..end]);
75 }
76 if buffer.len() < MAX_CAPTURE_BYTES {
77 buffer.push('\n');
78 }
79 }
80 *truncated = true;
81 }
82
83 fn push_capped_seed_line(buf: &mut Vec<String>, line: String) {
91 buf.push(line);
92 let cap = bash_runtime::MAX_OUTPUT_LINES;
93 if buf.len() > cap {
94 let overflow = buf.len() - cap;
95 buf.drain(0..overflow);
96 }
97 }
98
99 fn python_diagnostics_json(
100 diagnostics: &bamboo_infrastructure::process::PythonDiscoveryDiagnostics,
101 include_full_tried: bool,
102 ) -> Value {
103 let mut python = Map::new();
104 if let Some(configured) = diagnostics.configured.as_ref() {
105 python.insert("configured".to_string(), json!(configured));
106 }
107 if let Some(resolved) = diagnostics.resolved.as_ref() {
108 python.insert("resolved".to_string(), json!(resolved));
109 }
110 if let Some(invocation) = diagnostics.invocation.as_ref() {
111 python.insert("invocation".to_string(), json!(invocation));
112 }
113 if let Some(source) = diagnostics.source.as_ref() {
114 python.insert("source".to_string(), json!(source));
115 }
116 if !diagnostics.tried_preview.is_empty() {
117 python.insert(
118 "tried_preview".to_string(),
119 json!(diagnostics.tried_preview),
120 );
121 }
122 if diagnostics.tried_total > 0 {
123 python.insert("tried_total".to_string(), json!(diagnostics.tried_total));
124 python.insert(
125 "tried_truncated".to_string(),
126 json!(diagnostics.tried_truncated),
127 );
128 }
129 if let Some(hint) = diagnostics.hint.as_ref() {
130 python.insert("hint".to_string(), json!(hint));
131 }
132 if include_full_tried && !diagnostics.tried.is_empty() {
133 python.insert("tried".to_string(), json!(diagnostics.tried));
134 }
135 Value::Object(python)
136 }
137
138 fn environment_json(
139 diagnostics: &bamboo_infrastructure::process::CommandEnvironmentDiagnostics,
140 include_full_python_tried: bool,
141 ) -> Value {
142 let mut environment = Map::new();
143 environment.insert("source".to_string(), json!(diagnostics.source.as_str()));
144 if let Some(import_shell) = diagnostics.import_shell.as_ref() {
145 environment.insert("import_shell".to_string(), json!(import_shell));
146 }
147 if let Some(import_error) = diagnostics.import_error.as_ref() {
148 environment.insert("import_error".to_string(), json!(import_error));
149 }
150 if let Some(path) = diagnostics.path.as_ref() {
151 environment.insert("path".to_string(), json!(path));
152 }
153 if let Some(path_entries) = diagnostics.path_entries {
154 environment.insert("path_entries".to_string(), json!(path_entries));
155 }
156
157 let python = Self::python_diagnostics_json(&diagnostics.python, include_full_python_tried);
158 if python
159 .as_object()
160 .map(|map| !map.is_empty())
161 .unwrap_or(false)
162 {
163 environment.insert("python".to_string(), python);
164 }
165
166 Value::Object(environment)
167 }
168
169 fn resolve_cwd(session_workspace: &Path, workdir: Option<&str>) -> Result<PathBuf, ToolError> {
170 let resolved = match workdir {
171 Some(raw) => {
172 let trimmed = raw.trim();
173 if trimmed.is_empty() {
174 return Err(ToolError::InvalidArguments(
175 "'workdir' cannot be empty".to_string(),
176 ));
177 }
178 let requested = Path::new(trimmed);
179 if requested.is_absolute() {
180 requested.to_path_buf()
181 } else {
182 session_workspace.join(requested)
183 }
184 }
185 None => session_workspace.to_path_buf(),
186 };
187
188 let metadata = std::fs::metadata(&resolved).map_err(|error| {
189 ToolError::InvalidArguments(format!(
190 "Invalid workdir '{}': {}",
191 bamboo_config::paths::path_to_display_string(&resolved),
192 error
193 ))
194 })?;
195 if !metadata.is_dir() {
196 return Err(ToolError::InvalidArguments(format!(
197 "workdir must be a directory: {}",
198 bamboo_config::paths::path_to_display_string(&resolved)
199 )));
200 }
201
202 resolved.canonicalize().map_err(|error| {
203 ToolError::Execution(format!(
204 "Failed to canonicalize workdir '{}': {}",
205 bamboo_config::paths::path_to_display_string(&resolved),
206 error
207 ))
208 })
209 }
210
211 async fn prepare_environment() -> PreparedCommandEnvironment {
212 let overrides = bamboo_llm::Config::current_env_vars();
213 build_command_environment(&overrides).await
214 }
215
216 async fn run_streaming_command(
229 &self,
230 command: &str,
231 timeout_ms: u64,
232 promote_after_ms: Option<u64>,
233 cwd: &Path,
234 ctx: ToolExecutionContext<'_>,
235 ) -> Result<ToolResult, ToolError> {
236 let shell = preferred_bash_shell();
237 trace_windows_command(
238 "agent.bash.foreground",
239 &shell.program,
240 [shell.arg, command],
241 );
242 if windows_command_trace_enabled() {
243 let rendered = render_command_line(&shell.program, [shell.arg, command]);
244 ctx.emit_tool_token(format!("[windows-cmd-trace] {rendered}\n"))
245 .await;
246 }
247
248 let prepared_env = Self::prepare_environment().await;
249
250 let mut cmd = Command::new(&shell.program);
251 hide_window_for_tokio_command(&mut cmd);
252 cmd.current_dir(cwd);
253 prepared_env.apply_to_tokio_command(&mut cmd);
254 cmd.arg(shell.arg)
255 .arg(command)
256 .stdin(Stdio::null())
257 .stdout(Stdio::piped())
258 .stderr(Stdio::piped())
259 .kill_on_drop(true);
260
261 let mut child = cmd
262 .spawn()
263 .map_err(|e| ToolError::Execution(format!("Failed to execute command: {}", e)))?;
264
265 let stdout = child
266 .stdout
267 .take()
268 .ok_or_else(|| ToolError::Execution("Failed to capture stdout".to_string()))?;
269 let stderr = child
270 .stderr
271 .take()
272 .ok_or_else(|| ToolError::Execution("Failed to capture stderr".to_string()))?;
273
274 let mut stdout_reader = BufReader::new(stdout);
275 let mut stderr_reader = BufReader::new(stderr);
276 let mut stdout_line_bytes = Vec::new();
277 let mut stderr_line_bytes = Vec::new();
278
279 let mut stdout_buf = String::new();
280 let mut stderr_buf = String::new();
281 let mut stdout_lines: Vec<String> = Vec::new();
285 let mut stderr_lines: Vec<String> = Vec::new();
286 let mut stdout_truncated = false;
287 let mut stderr_truncated = false;
288 let mut stdout_done = false;
289 let mut stderr_done = false;
290
291 let timeout_deadline = Instant::now() + Duration::from_millis(timeout_ms);
294 let effective_deadline = match promote_after_ms {
295 Some(promote_ms) => {
296 (Instant::now() + Duration::from_millis(promote_ms)).min(timeout_deadline)
297 }
298 None => timeout_deadline,
299 };
300
301 while !(stdout_done && stderr_done) {
302 if Instant::now() >= effective_deadline {
303 break;
304 }
305
306 let remaining = effective_deadline.saturating_duration_since(Instant::now());
307 tokio::select! {
308 line = stdout_reader.read_until(b'\n', &mut stdout_line_bytes), if !stdout_done => {
309 match line {
310 Ok(0) => stdout_done = true,
311 Ok(_) => {
312 let line = decode_process_line_lossy(&mut stdout_line_bytes);
313 Self::append_capped(&mut stdout_buf, &line, &mut stdout_truncated);
314 if promote_after_ms.is_some() {
315 Self::push_capped_seed_line(&mut stdout_lines, line.clone());
316 }
317 ctx.emit_tool_token(format!("{}\n", line)).await;
318 }
319 Err(e) => {
320 return Err(ToolError::Execution(format!("Failed reading stdout: {}", e)));
321 }
322 }
323 }
324 line = stderr_reader.read_until(b'\n', &mut stderr_line_bytes), if !stderr_done => {
325 match line {
326 Ok(0) => stderr_done = true,
327 Ok(_) => {
328 let line = decode_process_line_lossy(&mut stderr_line_bytes);
329 Self::append_capped(&mut stderr_buf, &line, &mut stderr_truncated);
330 if promote_after_ms.is_some() {
331 Self::push_capped_seed_line(&mut stderr_lines, line.clone());
332 }
333 ctx.emit_tool_token(format!("{}\n", line)).await;
334 }
335 Err(e) => {
336 return Err(ToolError::Execution(format!("Failed reading stderr: {}", e)));
337 }
338 }
339 }
340 _ = tokio::time::sleep(remaining) => {
341 break;
342 }
343 }
344 }
345
346 let streams_closed = stdout_done && stderr_done;
347
348 let promotion_fired =
352 !streams_closed && promote_after_ms.is_some() && promote_after_ms.unwrap() < timeout_ms;
353
354 if streams_closed {
355 let status = child
357 .wait()
358 .await
359 .map_err(|e| ToolError::Execution(format!("Failed waiting command: {}", e)))?;
360 let exit_code = status.code();
361 let success = exit_code.unwrap_or(-1) == 0;
362 let cwd_display = bamboo_config::paths::path_to_display_string(cwd);
363 let environment = Self::environment_json(&prepared_env.diagnostics, !success);
364
365 return Ok(ToolResult {
366 success,
367 result: json!({
368 "command": command,
369 "cwd": cwd_display,
370 "stdout": stdout_buf,
371 "stderr": stderr_buf,
372 "exit_code": exit_code,
373 "timed_out": false,
374 "stdout_truncated": stdout_truncated,
375 "stderr_truncated": stderr_truncated,
376 "environment": environment,
377 })
378 .to_string(),
379 display_preference: Some("Collapsible".to_string()),
380 images: Vec::new(),
381 });
382 }
383
384 if promotion_fired {
385 if !stdout_line_bytes.is_empty() {
394 let partial = decode_process_line_lossy(&mut stdout_line_bytes);
395 if !partial.is_empty() {
396 Self::push_capped_seed_line(&mut stdout_lines, partial);
397 }
398 }
399 if !stderr_line_bytes.is_empty() {
400 let partial = decode_process_line_lossy(&mut stderr_line_bytes);
401 if !partial.is_empty() {
402 Self::push_capped_seed_line(&mut stderr_lines, partial);
403 }
404 }
405
406 let session = bash_runtime::adopt_running_child(
407 child,
408 stdout_reader,
409 stderr_reader,
410 stdout_lines,
411 stderr_lines,
412 command,
413 ctx.session_id.map(str::to_string),
414 prepared_env.diagnostics.clone(),
415 ctx.cloned_sender(),
416 )
417 .await
418 .map_err(ToolError::Execution)?;
419
420 return Ok(ToolResult {
421 success: true,
422 result: json!({
423 "bash_id": session.id,
424 "command": session.command,
425 "status": "running",
426 "cwd": bamboo_config::paths::path_to_display_string(cwd),
427 "environment": Self::environment_json(&session.environment, false),
428 })
429 .to_string(),
430 display_preference: Some("Collapsible".to_string()),
431 images: Vec::new(),
432 });
433 }
434
435 let _ = child.kill().await;
437 let cwd_display = bamboo_config::paths::path_to_display_string(cwd);
438 let environment = Self::environment_json(&prepared_env.diagnostics, true);
439
440 Ok(ToolResult {
441 success: false,
442 result: json!({
443 "command": command,
444 "cwd": cwd_display,
445 "stdout": stdout_buf,
446 "stderr": stderr_buf,
447 "exit_code": serde_json::Value::Null,
448 "timed_out": true,
449 "stdout_truncated": stdout_truncated,
450 "stderr_truncated": stderr_truncated,
451 "environment": environment,
452 })
453 .to_string(),
454 display_preference: Some("Collapsible".to_string()),
455 images: Vec::new(),
456 })
457 }
458}
459
460impl Default for BashTool {
461 fn default() -> Self {
462 Self::new()
463 }
464}
465
466#[async_trait]
467impl Tool for BashTool {
468 fn name(&self) -> &str {
469 "Bash"
470 }
471
472 fn description(&self) -> &str {
473 "Execute shell commands with streaming output (supports background mode). \
474 By default (run_in_background omitted), commands run synchronously but are \
475 auto-promoted to background if they run longer than ~10s — fast commands \
476 behave exactly as foreground. Set run_in_background to false to force \
477 synchronous (block until timeout), or true to force immediate background. \
478 Set interactive to true to spawn in the background with a piped stdin so \
479 input can be fed over time via BashInput (interactive implies background; \
480 use it only to answer an interactive prompt). Backgrounded commands are \
481 observed via BashOutput and the loop waits for them at turn end. Default \
482 timeout is 120000ms (max 600000ms); captured stdout/stderr are each \
483 capped at 512KB."
484 }
485
486 fn parameters_schema(&self) -> serde_json::Value {
487 json!({
488 "type": "object",
489 "properties": {
490 "command": {
491 "type": "string",
492 "description": "The command to execute"
493 },
494 "timeout": {
495 "type": "number",
496 "description": "Optional timeout in milliseconds (default 120000, max 600000)"
497 },
498 "description": {
499 "type": "string",
500 "description": "Optional short context label for the command"
501 },
502 "run_in_background": {
503 "type": "boolean",
504 "description": "Controls execution mode. Omit (default) for auto: runs synchronously but auto-backgrounds if the command runs longer than ~10s. Set to false to force synchronous (block until timeout). Set to true to force immediate background (observe via BashOutput; the loop waits at turn end)."
505 },
506 "interactive": {
507 "type": "boolean",
508 "description": "Opt-in: spawn the command in the BACKGROUND with a piped stdin so input can be fed over time via BashInput (interactive:true implies run_in_background — returns a bash_id immediately). When omitted/false the command's stdin is closed (Stdio::null), so a command that reads stdin gets immediate EOF — the default behavior is unchanged. Use this only to answer an interactive prompt in a long-running background shell."
509 },
510 "workdir": {
511 "type": "string",
512 "description": "Optional working directory. Relative paths are resolved from the session workspace."
513 }
514 },
515 "required": ["command"],
516 "additionalProperties": false
517 })
518 }
519
520 async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
521 self.execute_with_context(args, ToolExecutionContext::none("Bash"))
522 .await
523 }
524
525 async fn execute_with_context(
526 &self,
527 args: serde_json::Value,
528 ctx: ToolExecutionContext<'_>,
529 ) -> Result<ToolResult, ToolError> {
530 let parsed: BashArgs = serde_json::from_value(args)
531 .map_err(|e| ToolError::InvalidArguments(format!("Invalid Bash args: {}", e)))?;
532
533 let command = parsed.command.trim();
534 if command.is_empty() {
535 return Err(ToolError::InvalidArguments(
536 "'command' cannot be empty".to_string(),
537 ));
538 }
539
540 let _ = parsed.description;
541 let timeout_ms = Self::effective_timeout_ms(parsed.timeout);
542 let session_workspace = workspace_state::workspace_or_process_cwd(ctx.session_id);
543 let cwd = Self::resolve_cwd(&session_workspace, parsed.workdir.as_deref())?;
544
545 if parsed.interactive == Some(true) {
546 let shell = bash_runtime::spawn_background(
552 command,
553 Some(&cwd),
554 ctx.cloned_sender(),
555 ctx.session_id.map(str::to_string),
556 true,
557 )
558 .await
559 .map_err(ToolError::Execution)?;
560
561 if let Some(requested_timeout) = parsed.timeout {
562 let kill_after_ms = Self::effective_timeout_ms(Some(requested_timeout));
563 let shell_clone = shell.clone();
564 tokio::spawn(async move {
565 tokio::time::sleep(Duration::from_millis(kill_after_ms)).await;
566 if shell_clone.status() == "running" {
567 let _ = shell_clone.kill().await;
568 }
569 });
570 }
571
572 return Ok(ToolResult {
573 success: true,
574 result: json!({
575 "bash_id": shell.id,
576 "command": shell.command,
577 "status": "running",
578 "interactive": true,
579 "cwd": bamboo_config::paths::path_to_display_string(&cwd),
580 "environment": Self::environment_json(&shell.environment, false),
581 })
582 .to_string(),
583 display_preference: Some("Collapsible".to_string()),
584 images: Vec::new(),
585 });
586 }
587
588 match parsed.run_in_background {
589 Some(true) => {
590 let shell = bash_runtime::spawn_background(
592 command,
593 Some(&cwd),
594 ctx.cloned_sender(),
595 ctx.session_id.map(str::to_string),
596 false,
597 )
598 .await
599 .map_err(ToolError::Execution)?;
600
601 if let Some(requested_timeout) = parsed.timeout {
602 let kill_after_ms = Self::effective_timeout_ms(Some(requested_timeout));
603 let shell_clone = shell.clone();
604 tokio::spawn(async move {
605 tokio::time::sleep(Duration::from_millis(kill_after_ms)).await;
606 if shell_clone.status() == "running" {
607 let _ = shell_clone.kill().await;
608 }
609 });
610 }
611
612 Ok(ToolResult {
613 success: true,
614 result: json!({
615 "bash_id": shell.id,
616 "command": shell.command,
617 "status": "running",
618 "cwd": bamboo_config::paths::path_to_display_string(&cwd),
619 "environment": Self::environment_json(&shell.environment, false),
620 })
621 .to_string(),
622 display_preference: Some("Collapsible".to_string()),
623 images: Vec::new(),
624 })
625 }
626 Some(false) => {
627 self.run_streaming_command(command, timeout_ms, None, &cwd, ctx)
631 .await
632 }
633 None => {
634 let promote_after_ms = if ctx.can_async_resume {
642 Some(PROMOTE_TO_BACKGROUND_AFTER_MS)
643 } else {
644 None
645 };
646 self.run_streaming_command(command, timeout_ms, promote_after_ms, &cwd, ctx)
647 .await
648 }
649 }
650 }
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656 use bamboo_agent_core::tools::ToolExecutionSessionFlags;
657 use bamboo_agent_core::AgentEvent;
658 use bamboo_infrastructure::process::{
659 clear_command_environment_cache_for_tests, prime_command_environment_cache_for_tests,
660 CommandEnvironmentDiagnostics, CommandEnvironmentSource, PythonDiscoveryDiagnostics,
661 };
662 use serde_json::Value;
663 use std::collections::HashMap;
664 use tokio::sync::mpsc;
665 use tokio::time::{sleep, Duration, Instant};
666
667 #[cfg(target_os = "windows")]
668 fn mixed_output_command() -> &'static str {
669 "echo out && echo err 1>&2"
670 }
671
672 #[cfg(not(target_os = "windows"))]
673 fn mixed_output_command() -> &'static str {
674 "printf 'out\\n'; printf 'err\\n' 1>&2"
675 }
676
677 #[cfg(target_os = "windows")]
678 fn invalid_utf8_stderr_command() -> String {
679 let shell = bamboo_infrastructure::process::preferred_bash_shell();
680 if shell.arg == "-lc" {
681 "printf '\\377\\n' 1>&2".to_string()
682 } else {
683 "powershell -NoProfile -Command \"$bytes = [byte[]](0xFF,0x0A); [Console]::OpenStandardError().Write($bytes,0,$bytes.Length)\"".to_string()
684 }
685 }
686
687 #[cfg(not(target_os = "windows"))]
688 fn invalid_utf8_stderr_command() -> String {
689 "printf '\\377\\n' 1>&2".to_string()
690 }
691
692 fn test_environment_diagnostics() -> CommandEnvironmentDiagnostics {
693 CommandEnvironmentDiagnostics {
694 source: CommandEnvironmentSource::InheritedProcess,
695 import_shell: None,
696 import_error: Some("test-import-disabled".to_string()),
697 path: Some("/usr/bin:/bin".to_string()),
698 path_entries: Some(2),
699 python: PythonDiscoveryDiagnostics {
700 configured: Some("python3".to_string()),
701 resolved: Some("/usr/bin/python3".to_string()),
702 invocation: Some("/usr/bin/python3".to_string()),
703 source: Some("path".to_string()),
704 tried: vec!["python3".to_string(), "python".to_string()],
705 tried_preview: vec!["python3".to_string(), "python".to_string()],
706 tried_total: 2,
707 tried_truncated: false,
708 hint: None,
709 },
710 }
711 }
712
713 fn prime_test_command_environment() {
714 clear_command_environment_cache_for_tests();
715 prime_command_environment_cache_for_tests(
716 HashMap::from([("PATH".to_string(), "/usr/bin:/bin".to_string())]),
717 test_environment_diagnostics(),
718 );
719 }
720
721 #[tokio::test]
722 async fn bash_foreground_returns_stdout_stderr_and_streams_tokens() {
723 prime_test_command_environment();
724 let tool = BashTool::new();
725 let (tx, mut rx) = mpsc::channel(32);
726
727 let result = tool
728 .execute_with_context(
729 json!({
730 "command": mixed_output_command()
731 }),
732 ToolExecutionContext {
733 session_id: Some("session_1"),
734 tool_call_id: "call_1",
735 event_tx: Some(&tx),
736 available_tool_schemas: None,
737 bypass_permissions: false,
738 can_async_resume: false,
739 pre_parsed_args: None,
740 },
741 )
742 .await
743 .unwrap();
744
745 assert!(result.success);
746
747 let payload: Value = serde_json::from_str(&result.result).unwrap();
748 assert_eq!(payload["timed_out"], false);
749 assert_eq!(payload["exit_code"], 0);
750 assert!(payload["stdout"]
751 .as_str()
752 .unwrap_or_default()
753 .contains("out"));
754 assert!(payload["stderr"]
755 .as_str()
756 .unwrap_or_default()
757 .contains("err"));
758 assert_eq!(payload["environment"]["source"], "process_env");
759 assert_eq!(
760 payload["environment"]["import_error"],
761 "test-import-disabled"
762 );
763 assert_eq!(
764 payload["environment"]["python"]["resolved"],
765 "/usr/bin/python3"
766 );
767 assert_eq!(
768 payload["environment"]["python"]["invocation"],
769 "/usr/bin/python3"
770 );
771 assert_eq!(payload["environment"]["python"]["source"], "path");
772 assert_eq!(
773 payload["environment"]["python"]["tried_preview"][0],
774 "python3"
775 );
776 assert_eq!(payload["environment"]["python"]["tried_total"], 1);
777 assert!(payload["environment"]["python"].get("tried").is_none());
778
779 let mut streamed = Vec::new();
780 while let Ok(event) = rx.try_recv() {
781 if let AgentEvent::ToolToken { content, .. } = event {
782 streamed.push(content);
783 }
784 }
785
786 assert!(streamed.iter().any(|line| line.contains("out")));
787 assert!(streamed.iter().any(|line| line.contains("err")));
788 }
789
790 #[tokio::test]
791 async fn bash_foreground_tolerates_invalid_utf8_stderr() {
792 prime_test_command_environment();
793 let tool = BashTool::new();
794 let result = tool
795 .execute(json!({
796 "command": invalid_utf8_stderr_command()
797 }))
798 .await;
799
800 assert!(result.is_ok(), "invalid UTF-8 stderr should not fail");
801 let payload: Value = serde_json::from_str(&result.unwrap().result).unwrap();
802 let stderr = payload["stderr"].as_str().unwrap_or_default();
803 assert!(!stderr.is_empty());
804 }
805
806 #[cfg(not(target_os = "windows"))]
807 #[tokio::test]
808 async fn bash_foreground_failure_includes_full_python_tried_list() {
809 prime_test_command_environment();
810 let tool = BashTool::new();
811 let result = tool
812 .execute(json!({
813 "command": "false"
814 }))
815 .await
816 .unwrap();
817
818 assert!(!result.success);
819 let payload: Value = serde_json::from_str(&result.result).unwrap();
820 assert_eq!(payload["exit_code"], 1);
821 assert_eq!(payload["environment"]["python"]["tried_total"], 1);
822 assert_eq!(payload["environment"]["python"]["tried"][0], "python3");
823 }
824
825 #[cfg(not(target_os = "windows"))]
826 #[tokio::test]
827 async fn bash_foreground_sets_stdout_truncated_when_output_exceeds_cap() {
828 prime_test_command_environment();
829 let tool = BashTool::new();
830 let result = tool
831 .execute(json!({
832 "command": "i=0; while [ $i -lt 70000 ]; do printf 'aaaaaaaaaa'; i=$((i+1)); done; printf '\\n'"
833 }))
834 .await
835 .unwrap();
836
837 let payload: Value = serde_json::from_str(&result.result).unwrap();
838 assert_eq!(payload["timed_out"], false);
839 assert_eq!(payload["stdout_truncated"], true);
840 }
841
842 #[cfg(not(target_os = "windows"))]
843 #[tokio::test]
844 async fn bash_background_honors_explicit_timeout() {
845 prime_test_command_environment();
846 let tool = BashTool::new();
847 let result = tool
848 .execute(json!({
849 "command": "sleep 2",
850 "run_in_background": true,
851 "timeout": 50
852 }))
853 .await
854 .unwrap();
855 let payload: Value = serde_json::from_str(&result.result).unwrap();
856 assert_eq!(payload["environment"]["source"], "process_env");
857 assert_eq!(
858 payload["environment"]["python"]["resolved"],
859 "/usr/bin/python3"
860 );
861 assert_eq!(
862 payload["environment"]["python"]["invocation"],
863 "/usr/bin/python3"
864 );
865 assert_eq!(payload["environment"]["python"]["tried_total"], 1);
866 assert!(payload["environment"]["python"].get("tried").is_none());
867 let shell_id = payload["bash_id"].as_str().unwrap().to_string();
868
869 let started = Instant::now();
870 loop {
871 let shell = super::bash_runtime::get_shell(&shell_id).unwrap();
872 if shell.status() == "completed" {
873 break;
874 }
875 if started.elapsed() > Duration::from_secs(2) {
876 panic!("background shell did not stop after timeout");
877 }
878 sleep(Duration::from_millis(25)).await;
879 }
880 }
881
882 #[cfg(not(target_os = "windows"))]
885 #[tokio::test]
886 async fn bash_background_emits_completion_event_with_exit_code() {
887 prime_test_command_environment();
888 let (tx, mut rx) = mpsc::channel(8);
889 let shell = super::bash_runtime::spawn_background("true", None, Some(tx), None, false)
890 .await
891 .expect("background shell should spawn");
892 let expected_id = shell.id.clone();
893
894 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
895 .await
896 .expect("timed out waiting for BashCompleted event")
897 .expect("event channel closed before BashCompleted");
898
899 match event {
900 AgentEvent::BashCompleted {
901 bash_id,
902 command,
903 exit_code,
904 status,
905 } => {
906 assert_eq!(bash_id, expected_id);
907 assert_eq!(command, "true");
908 assert_eq!(exit_code, Some(0));
909 assert_eq!(status, "completed");
910 }
911 other => panic!("expected BashCompleted, got {other:?}"),
912 }
913 }
914
915 #[cfg(not(target_os = "windows"))]
918 #[tokio::test]
919 async fn bash_background_emits_completion_event_for_failing_command() {
920 prime_test_command_environment();
921 let (tx, mut rx) = mpsc::channel(8);
922 let shell = super::bash_runtime::spawn_background("false", None, Some(tx), None, false)
923 .await
924 .expect("background shell should spawn");
925 let expected_id = shell.id.clone();
926
927 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
928 .await
929 .expect("timed out waiting for BashCompleted event")
930 .expect("event channel closed before BashCompleted");
931
932 match event {
933 AgentEvent::BashCompleted {
934 bash_id,
935 exit_code,
936 status,
937 ..
938 } => {
939 assert_eq!(bash_id, expected_id);
940 assert_eq!(exit_code, Some(1));
941 assert_eq!(status, "completed");
942 }
943 other => panic!("expected BashCompleted, got {other:?}"),
944 }
945 }
946
947 #[cfg(not(target_os = "windows"))]
950 #[tokio::test]
951 async fn bash_background_emits_killed_when_shell_is_killed() {
952 prime_test_command_environment();
953 let (tx, mut rx) = mpsc::channel(8);
954 let shell = super::bash_runtime::spawn_background("sleep 30", None, Some(tx), None, false)
955 .await
956 .expect("background shell should spawn");
957 let expected_id = shell.id.clone();
958
959 shell.kill().await.expect("shell should be killable");
960
961 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
962 .await
963 .expect("timed out waiting for BashCompleted event")
964 .expect("event channel closed before BashCompleted");
965
966 match event {
967 AgentEvent::BashCompleted {
968 bash_id,
969 exit_code,
970 status,
971 ..
972 } => {
973 assert_eq!(bash_id, expected_id);
974 assert_eq!(exit_code, None);
975 assert_eq!(status, "killed");
976 }
977 other => panic!("expected BashCompleted, got {other:?}"),
978 }
979 }
980
981 #[cfg(not(target_os = "windows"))]
984 #[tokio::test]
985 async fn bash_background_without_sender_still_completes() {
986 prime_test_command_environment();
987 let shell = super::bash_runtime::spawn_background("true", None, None, None, false)
988 .await
989 .expect("background shell should spawn");
990
991 let started = Instant::now();
992 loop {
993 if shell.status() == "completed" {
994 break;
995 }
996 if started.elapsed() > Duration::from_secs(3) {
997 panic!("shell never reached completed without a sender");
998 }
999 sleep(Duration::from_millis(25)).await;
1000 }
1001 }
1002
1003 #[cfg(not(target_os = "windows"))]
1009 #[tokio::test]
1010 async fn bash_background_drops_completion_when_channel_saturated() {
1011 prime_test_command_environment();
1012 let (tx, mut rx) = mpsc::channel::<AgentEvent>(1);
1014 tx.try_send(AgentEvent::Token {
1015 content: "occupy".into(),
1016 })
1017 .expect("prefill channel slot");
1018
1019 let shell = super::bash_runtime::spawn_background("true", None, Some(tx), None, false)
1020 .await
1021 .expect("background shell should spawn");
1022
1023 sleep(Duration::from_millis(650)).await;
1026
1027 let only = rx
1030 .recv()
1031 .await
1032 .expect("prefilled token should still be present");
1033 assert!(
1034 matches!(only, AgentEvent::Token { .. }),
1035 "expected only the pre-filled token, got {only:?}"
1036 );
1037 assert!(
1038 tokio::time::timeout(Duration::from_millis(50), rx.recv())
1039 .await
1040 .is_err(),
1041 "no BashCompleted should be delivered after a saturation drop"
1042 );
1043 assert_eq!(
1044 shell.status(),
1045 "completed",
1046 "shell must still reach completed after a dropped signal"
1047 );
1048 }
1049
1050 #[cfg(not(target_os = "windows"))]
1055 #[tokio::test]
1056 async fn bash_tool_background_dispatch_emits_completion_event() {
1057 prime_test_command_environment();
1058 let tool = BashTool::new();
1059 let (tx, mut rx) = mpsc::channel(8);
1060 let ctx = ToolExecutionContext::for_dispatch(
1061 "session_84",
1062 "call_84",
1063 &tx,
1064 &[],
1065 ToolExecutionSessionFlags::default(),
1066 true,
1067 None,
1068 );
1069
1070 let result = tool
1071 .execute_with_context(json!({ "command": "true", "run_in_background": true }), ctx)
1072 .await
1073 .expect("background dispatch should succeed");
1074 assert!(result.success);
1075
1076 let payload: Value = serde_json::from_str(&result.result).unwrap();
1077 let bash_id = payload["bash_id"].as_str().unwrap().to_string();
1078 assert_eq!(payload["status"], "running");
1079
1080 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
1081 .await
1082 .expect("timed out waiting for BashCompleted event")
1083 .expect("event channel closed before BashCompleted");
1084
1085 match event {
1086 AgentEvent::BashCompleted {
1087 bash_id: id,
1088 exit_code,
1089 status,
1090 ..
1091 } => {
1092 assert_eq!(id, bash_id);
1093 assert_eq!(exit_code, Some(0));
1094 assert_eq!(status, "completed");
1095 }
1096 other => panic!("expected BashCompleted, got {other:?}"),
1097 }
1098 }
1099
1100 #[tokio::test]
1101 async fn bash_resolves_relative_workdir_from_session_workspace() {
1102 prime_test_command_environment();
1103 let tool = BashTool::new();
1104 let dir = tempfile::tempdir().unwrap();
1105 let base = dir.path().join("base");
1106 let nested = base.join("nested");
1107 tokio::fs::create_dir_all(&nested).await.unwrap();
1108
1109 let session_id = format!("session_{}", uuid::Uuid::new_v4());
1110 super::workspace_state::set_workspace(&session_id, base.canonicalize().unwrap());
1111
1112 let result = tool
1113 .execute_with_context(
1114 json!({
1115 "command": "pwd",
1116 "workdir": "nested"
1117 }),
1118 ToolExecutionContext {
1119 session_id: Some(&session_id),
1120 tool_call_id: "call_1",
1121 event_tx: None,
1122 available_tool_schemas: None,
1123 bypass_permissions: false,
1124 can_async_resume: false,
1125 pre_parsed_args: None,
1126 },
1127 )
1128 .await
1129 .unwrap();
1130
1131 let payload: Value = serde_json::from_str(&result.result).unwrap();
1132 let expected =
1133 bamboo_config::paths::path_to_display_string(&nested.canonicalize().unwrap());
1134 assert_eq!(payload["cwd"].as_str().unwrap_or_default(), expected);
1135 }
1136
1137 #[tokio::test]
1138 async fn bash_rejects_workdir_that_is_not_directory() {
1139 prime_test_command_environment();
1140 let tool = BashTool::new();
1141 let file = tempfile::NamedTempFile::new().unwrap();
1142
1143 let result = tool
1144 .execute(json!({
1145 "command": "echo hello",
1146 "workdir": file.path()
1147 }))
1148 .await;
1149
1150 assert!(
1151 matches!(result, Err(ToolError::InvalidArguments(msg)) if msg.contains("directory"))
1152 );
1153 }
1154
1155 #[cfg(not(target_os = "windows"))]
1159 #[tokio::test]
1160 async fn running_shells_for_session_filters_by_session_and_status() {
1161 prime_test_command_environment();
1162
1163 let a1 = super::bash_runtime::spawn_background(
1165 "sleep 30",
1166 None,
1167 None,
1168 Some("sess-A".to_string()),
1169 false,
1170 )
1171 .await
1172 .expect("spawn a1");
1173 let a2 = super::bash_runtime::spawn_background(
1174 "sleep 30",
1175 None,
1176 None,
1177 Some("sess-A".to_string()),
1178 false,
1179 )
1180 .await
1181 .expect("spawn a2");
1182 let b = super::bash_runtime::spawn_background(
1184 "sleep 30",
1185 None,
1186 None,
1187 Some("sess-B".to_string()),
1188 false,
1189 )
1190 .await
1191 .expect("spawn b");
1192 let untagged = super::bash_runtime::spawn_background("sleep 30", None, None, None, false)
1194 .await
1195 .expect("spawn untagged");
1196 let done = super::bash_runtime::spawn_background(
1198 "true",
1199 None,
1200 None,
1201 Some("sess-A".to_string()),
1202 false,
1203 )
1204 .await
1205 .expect("spawn done");
1206
1207 let started = Instant::now();
1209 loop {
1210 if done.status() == "completed" {
1211 break;
1212 }
1213 if started.elapsed() > Duration::from_secs(3) {
1214 panic!("sess-A fast shell never completed");
1215 }
1216 sleep(Duration::from_millis(25)).await;
1217 }
1218
1219 let mut running = super::bash_runtime::running_shells_for_session("sess-A");
1221 running.sort();
1222 let mut expected = vec![a1.id.clone(), a2.id.clone()];
1223 expected.sort();
1224 assert_eq!(running, expected);
1225
1226 assert_eq!(
1228 super::bash_runtime::running_shells_for_session("sess-B"),
1229 vec![b.id.clone()]
1230 );
1231
1232 for shell in [a1, a2, b, untagged] {
1235 let _ = shell.kill().await;
1236 }
1237 let _ = super::bash_runtime::remove_shell(&done.id);
1238 }
1239
1240 #[cfg(not(target_os = "windows"))]
1245 #[tokio::test]
1246 async fn auto_path_fast_command_returns_synchronous_result() {
1247 prime_test_command_environment();
1248 let tool = BashTool::new();
1249 let (tx, _rx) = mpsc::channel(32);
1250 let ctx = ToolExecutionContext {
1251 session_id: Some("session_auto_fast"),
1252 tool_call_id: "call_auto_fast",
1253 event_tx: Some(&tx),
1254 available_tool_schemas: None,
1255 bypass_permissions: false,
1256 can_async_resume: false,
1257 pre_parsed_args: None,
1258 };
1259
1260 let result = tool
1261 .execute_with_context(json!({ "command": "echo auto-fast-output" }), ctx)
1262 .await
1263 .expect("auto fast command should succeed");
1264
1265 let payload: Value = serde_json::from_str(&result.result).unwrap();
1266 assert!(
1267 payload.get("bash_id").is_none(),
1268 "fast auto command must not return a bash_id"
1269 );
1270 assert_eq!(payload["exit_code"], 0);
1271 assert_eq!(payload["timed_out"], false);
1272 assert!(payload["stdout"]
1273 .as_str()
1274 .unwrap_or_default()
1275 .contains("auto-fast-output"));
1276 }
1277
1278 #[cfg(not(target_os = "windows"))]
1283 #[tokio::test]
1284 async fn auto_path_promotes_long_command_to_background() {
1285 prime_test_command_environment();
1286 let tool = BashTool::new();
1287 let session_id = "session_auto_promote";
1288 let (tx, mut rx) = mpsc::channel(8);
1289 let ctx = ToolExecutionContext::for_dispatch(
1290 session_id,
1291 "call_auto_promote",
1292 &tx,
1293 &[],
1294 ToolExecutionSessionFlags::default(),
1295 true,
1298 None,
1299 );
1300 let cwd = super::workspace_state::workspace_or_process_cwd(Some(session_id));
1301
1302 let result = tool
1307 .run_streaming_command("sleep 10", 60000, Some(200), &cwd, ctx)
1308 .await
1309 .expect("auto promote should succeed");
1310
1311 assert!(result.success);
1312 let payload: Value = serde_json::from_str(&result.result).unwrap();
1313 let bash_id = payload["bash_id"]
1314 .as_str()
1315 .expect("promoted result must have bash_id")
1316 .to_string();
1317 assert_eq!(payload["status"], "running");
1318
1319 let running = super::bash_runtime::running_shells_for_session(session_id);
1320 assert!(
1321 running.contains(&bash_id),
1322 "adopted shell {bash_id} must appear in running_shells, got {running:?}"
1323 );
1324
1325 let event = tokio::time::timeout(Duration::from_secs(15), rx.recv())
1326 .await
1327 .expect("timed out waiting for BashCompleted")
1328 .expect("event channel closed before BashCompleted");
1329 match event {
1330 AgentEvent::BashCompleted {
1331 bash_id: id,
1332 exit_code,
1333 status,
1334 ..
1335 } => {
1336 assert_eq!(id, bash_id);
1337 assert_eq!(exit_code, Some(0));
1338 assert_eq!(status, "completed");
1339 }
1340 other => panic!("expected BashCompleted, got {other:?}"),
1341 }
1342
1343 if let Some(shell) = super::bash_runtime::get_shell(&bash_id) {
1344 let _ = shell.kill().await;
1345 }
1346 }
1347
1348 #[cfg(not(target_os = "windows"))]
1351 #[tokio::test]
1352 async fn force_sync_does_not_promote_and_times_out() {
1353 prime_test_command_environment();
1354 let tool = BashTool::new();
1355
1356 let result = tool
1357 .execute(json!({
1358 "command": "sleep 10",
1359 "run_in_background": false,
1360 "timeout": 50
1361 }))
1362 .await
1363 .expect("force-sync should produce a timed-out result");
1364
1365 let payload: Value = serde_json::from_str(&result.result).unwrap();
1366 assert!(
1367 payload.get("bash_id").is_none(),
1368 "force-sync must never promote"
1369 );
1370 assert_eq!(payload["timed_out"], true);
1371 assert!(!result.success, "timed-out result must not be successful");
1372 }
1373
1374 #[cfg(not(target_os = "windows"))]
1382 #[tokio::test]
1383 async fn auto_path_does_not_promote_when_not_resume_capable() {
1384 prime_test_command_environment();
1385 let tool = BashTool::new();
1386
1387 let result = tool
1388 .execute(json!({
1389 "command": "sleep 10",
1390 "timeout": 50
1391 }))
1392 .await
1393 .expect("non-resume-capable auto path should produce a result");
1394
1395 let payload: Value = serde_json::from_str(&result.result).unwrap();
1396 assert!(
1397 payload.get("bash_id").is_none(),
1398 "auto path must not promote when can_async_resume is false"
1399 );
1400 assert_eq!(payload["timed_out"], true);
1401 assert!(
1402 !result.success,
1403 "a timed-out command must not report success"
1404 );
1405 }
1406
1407 #[cfg(not(target_os = "windows"))]
1410 #[tokio::test]
1411 async fn adopt_running_child_preserves_seeded_output() {
1412 prime_test_command_environment();
1413 let shell = bamboo_infrastructure::process::preferred_bash_shell();
1414 let mut cmd = tokio::process::Command::new(&shell.program);
1415 bamboo_infrastructure::process::hide_window_for_tokio_command(&mut cmd);
1416 cmd.arg(shell.arg)
1417 .arg("echo seeded-line-1; echo seeded-line-2; sleep 5")
1418 .stdin(std::process::Stdio::null())
1419 .stdout(std::process::Stdio::piped())
1420 .stderr(std::process::Stdio::piped())
1421 .kill_on_drop(true);
1422 let mut child = cmd.spawn().expect("spawn child");
1423 let stdout_reader = tokio::io::BufReader::new(child.stdout.take().unwrap());
1424 let stderr_reader = tokio::io::BufReader::new(child.stderr.take().unwrap());
1425
1426 sleep(Duration::from_millis(200)).await;
1428
1429 let session = super::bash_runtime::adopt_running_child(
1430 child,
1431 stdout_reader,
1432 stderr_reader,
1433 vec!["seeded-line-1".to_string(), "seeded-line-2".to_string()],
1434 vec![],
1435 "echo seeded-line-1; echo seeded-line-2; sleep 5",
1436 Some("session_seed_test".to_string()),
1437 test_environment_diagnostics(),
1438 None,
1439 )
1440 .await
1441 .expect("adopt should succeed");
1442
1443 let (lines, _cursor, _dropped) = session.read_output_since(0, None).await;
1444 assert!(
1445 lines.iter().any(|l| l.contains("seeded-line-1")),
1446 "seeded line 1 must be present, got {lines:?}"
1447 );
1448 assert!(
1449 lines.iter().any(|l| l.contains("seeded-line-2")),
1450 "seeded line 2 must be present, got {lines:?}"
1451 );
1452
1453 let _ = session.kill().await;
1454 let _ = super::bash_runtime::remove_shell(&session.id);
1455 }
1456}