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 },
740 )
741 .await
742 .unwrap();
743
744 assert!(result.success);
745
746 let payload: Value = serde_json::from_str(&result.result).unwrap();
747 assert_eq!(payload["timed_out"], false);
748 assert_eq!(payload["exit_code"], 0);
749 assert!(payload["stdout"]
750 .as_str()
751 .unwrap_or_default()
752 .contains("out"));
753 assert!(payload["stderr"]
754 .as_str()
755 .unwrap_or_default()
756 .contains("err"));
757 assert_eq!(payload["environment"]["source"], "process_env");
758 assert_eq!(
759 payload["environment"]["import_error"],
760 "test-import-disabled"
761 );
762 assert_eq!(
763 payload["environment"]["python"]["resolved"],
764 "/usr/bin/python3"
765 );
766 assert_eq!(
767 payload["environment"]["python"]["invocation"],
768 "/usr/bin/python3"
769 );
770 assert_eq!(payload["environment"]["python"]["source"], "path");
771 assert_eq!(
772 payload["environment"]["python"]["tried_preview"][0],
773 "python3"
774 );
775 assert_eq!(payload["environment"]["python"]["tried_total"], 1);
776 assert!(payload["environment"]["python"].get("tried").is_none());
777
778 let mut streamed = Vec::new();
779 while let Ok(event) = rx.try_recv() {
780 if let AgentEvent::ToolToken { content, .. } = event {
781 streamed.push(content);
782 }
783 }
784
785 assert!(streamed.iter().any(|line| line.contains("out")));
786 assert!(streamed.iter().any(|line| line.contains("err")));
787 }
788
789 #[tokio::test]
790 async fn bash_foreground_tolerates_invalid_utf8_stderr() {
791 prime_test_command_environment();
792 let tool = BashTool::new();
793 let result = tool
794 .execute(json!({
795 "command": invalid_utf8_stderr_command()
796 }))
797 .await;
798
799 assert!(result.is_ok(), "invalid UTF-8 stderr should not fail");
800 let payload: Value = serde_json::from_str(&result.unwrap().result).unwrap();
801 let stderr = payload["stderr"].as_str().unwrap_or_default();
802 assert!(!stderr.is_empty());
803 }
804
805 #[cfg(not(target_os = "windows"))]
806 #[tokio::test]
807 async fn bash_foreground_failure_includes_full_python_tried_list() {
808 prime_test_command_environment();
809 let tool = BashTool::new();
810 let result = tool
811 .execute(json!({
812 "command": "false"
813 }))
814 .await
815 .unwrap();
816
817 assert!(!result.success);
818 let payload: Value = serde_json::from_str(&result.result).unwrap();
819 assert_eq!(payload["exit_code"], 1);
820 assert_eq!(payload["environment"]["python"]["tried_total"], 1);
821 assert_eq!(payload["environment"]["python"]["tried"][0], "python3");
822 }
823
824 #[cfg(not(target_os = "windows"))]
825 #[tokio::test]
826 async fn bash_foreground_sets_stdout_truncated_when_output_exceeds_cap() {
827 prime_test_command_environment();
828 let tool = BashTool::new();
829 let result = tool
830 .execute(json!({
831 "command": "i=0; while [ $i -lt 70000 ]; do printf 'aaaaaaaaaa'; i=$((i+1)); done; printf '\\n'"
832 }))
833 .await
834 .unwrap();
835
836 let payload: Value = serde_json::from_str(&result.result).unwrap();
837 assert_eq!(payload["timed_out"], false);
838 assert_eq!(payload["stdout_truncated"], true);
839 }
840
841 #[cfg(not(target_os = "windows"))]
842 #[tokio::test]
843 async fn bash_background_honors_explicit_timeout() {
844 prime_test_command_environment();
845 let tool = BashTool::new();
846 let result = tool
847 .execute(json!({
848 "command": "sleep 2",
849 "run_in_background": true,
850 "timeout": 50
851 }))
852 .await
853 .unwrap();
854 let payload: Value = serde_json::from_str(&result.result).unwrap();
855 assert_eq!(payload["environment"]["source"], "process_env");
856 assert_eq!(
857 payload["environment"]["python"]["resolved"],
858 "/usr/bin/python3"
859 );
860 assert_eq!(
861 payload["environment"]["python"]["invocation"],
862 "/usr/bin/python3"
863 );
864 assert_eq!(payload["environment"]["python"]["tried_total"], 1);
865 assert!(payload["environment"]["python"].get("tried").is_none());
866 let shell_id = payload["bash_id"].as_str().unwrap().to_string();
867
868 let started = Instant::now();
869 loop {
870 let shell = super::bash_runtime::get_shell(&shell_id).unwrap();
871 if shell.status() == "completed" {
872 break;
873 }
874 if started.elapsed() > Duration::from_secs(2) {
875 panic!("background shell did not stop after timeout");
876 }
877 sleep(Duration::from_millis(25)).await;
878 }
879 }
880
881 #[cfg(not(target_os = "windows"))]
884 #[tokio::test]
885 async fn bash_background_emits_completion_event_with_exit_code() {
886 prime_test_command_environment();
887 let (tx, mut rx) = mpsc::channel(8);
888 let shell = super::bash_runtime::spawn_background("true", None, Some(tx), None, false)
889 .await
890 .expect("background shell should spawn");
891 let expected_id = shell.id.clone();
892
893 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
894 .await
895 .expect("timed out waiting for BashCompleted event")
896 .expect("event channel closed before BashCompleted");
897
898 match event {
899 AgentEvent::BashCompleted {
900 bash_id,
901 command,
902 exit_code,
903 status,
904 } => {
905 assert_eq!(bash_id, expected_id);
906 assert_eq!(command, "true");
907 assert_eq!(exit_code, Some(0));
908 assert_eq!(status, "completed");
909 }
910 other => panic!("expected BashCompleted, got {other:?}"),
911 }
912 }
913
914 #[cfg(not(target_os = "windows"))]
917 #[tokio::test]
918 async fn bash_background_emits_completion_event_for_failing_command() {
919 prime_test_command_environment();
920 let (tx, mut rx) = mpsc::channel(8);
921 let shell = super::bash_runtime::spawn_background("false", None, Some(tx), None, false)
922 .await
923 .expect("background shell should spawn");
924 let expected_id = shell.id.clone();
925
926 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
927 .await
928 .expect("timed out waiting for BashCompleted event")
929 .expect("event channel closed before BashCompleted");
930
931 match event {
932 AgentEvent::BashCompleted {
933 bash_id,
934 exit_code,
935 status,
936 ..
937 } => {
938 assert_eq!(bash_id, expected_id);
939 assert_eq!(exit_code, Some(1));
940 assert_eq!(status, "completed");
941 }
942 other => panic!("expected BashCompleted, got {other:?}"),
943 }
944 }
945
946 #[cfg(not(target_os = "windows"))]
949 #[tokio::test]
950 async fn bash_background_emits_killed_when_shell_is_killed() {
951 prime_test_command_environment();
952 let (tx, mut rx) = mpsc::channel(8);
953 let shell = super::bash_runtime::spawn_background("sleep 30", None, Some(tx), None, false)
954 .await
955 .expect("background shell should spawn");
956 let expected_id = shell.id.clone();
957
958 shell.kill().await.expect("shell should be killable");
959
960 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
961 .await
962 .expect("timed out waiting for BashCompleted event")
963 .expect("event channel closed before BashCompleted");
964
965 match event {
966 AgentEvent::BashCompleted {
967 bash_id,
968 exit_code,
969 status,
970 ..
971 } => {
972 assert_eq!(bash_id, expected_id);
973 assert_eq!(exit_code, None);
974 assert_eq!(status, "killed");
975 }
976 other => panic!("expected BashCompleted, got {other:?}"),
977 }
978 }
979
980 #[cfg(not(target_os = "windows"))]
983 #[tokio::test]
984 async fn bash_background_without_sender_still_completes() {
985 prime_test_command_environment();
986 let shell = super::bash_runtime::spawn_background("true", None, None, None, false)
987 .await
988 .expect("background shell should spawn");
989
990 let started = Instant::now();
991 loop {
992 if shell.status() == "completed" {
993 break;
994 }
995 if started.elapsed() > Duration::from_secs(3) {
996 panic!("shell never reached completed without a sender");
997 }
998 sleep(Duration::from_millis(25)).await;
999 }
1000 }
1001
1002 #[cfg(not(target_os = "windows"))]
1008 #[tokio::test]
1009 async fn bash_background_drops_completion_when_channel_saturated() {
1010 prime_test_command_environment();
1011 let (tx, mut rx) = mpsc::channel::<AgentEvent>(1);
1013 tx.try_send(AgentEvent::Token {
1014 content: "occupy".into(),
1015 })
1016 .expect("prefill channel slot");
1017
1018 let shell = super::bash_runtime::spawn_background("true", None, Some(tx), None, false)
1019 .await
1020 .expect("background shell should spawn");
1021
1022 sleep(Duration::from_millis(650)).await;
1025
1026 let only = rx
1029 .recv()
1030 .await
1031 .expect("prefilled token should still be present");
1032 assert!(
1033 matches!(only, AgentEvent::Token { .. }),
1034 "expected only the pre-filled token, got {only:?}"
1035 );
1036 assert!(
1037 tokio::time::timeout(Duration::from_millis(50), rx.recv())
1038 .await
1039 .is_err(),
1040 "no BashCompleted should be delivered after a saturation drop"
1041 );
1042 assert_eq!(
1043 shell.status(),
1044 "completed",
1045 "shell must still reach completed after a dropped signal"
1046 );
1047 }
1048
1049 #[cfg(not(target_os = "windows"))]
1054 #[tokio::test]
1055 async fn bash_tool_background_dispatch_emits_completion_event() {
1056 prime_test_command_environment();
1057 let tool = BashTool::new();
1058 let (tx, mut rx) = mpsc::channel(8);
1059 let ctx = ToolExecutionContext::for_dispatch(
1060 "session_84",
1061 "call_84",
1062 &tx,
1063 &[],
1064 ToolExecutionSessionFlags::default(),
1065 true,
1066 );
1067
1068 let result = tool
1069 .execute_with_context(json!({ "command": "true", "run_in_background": true }), ctx)
1070 .await
1071 .expect("background dispatch should succeed");
1072 assert!(result.success);
1073
1074 let payload: Value = serde_json::from_str(&result.result).unwrap();
1075 let bash_id = payload["bash_id"].as_str().unwrap().to_string();
1076 assert_eq!(payload["status"], "running");
1077
1078 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
1079 .await
1080 .expect("timed out waiting for BashCompleted event")
1081 .expect("event channel closed before BashCompleted");
1082
1083 match event {
1084 AgentEvent::BashCompleted {
1085 bash_id: id,
1086 exit_code,
1087 status,
1088 ..
1089 } => {
1090 assert_eq!(id, bash_id);
1091 assert_eq!(exit_code, Some(0));
1092 assert_eq!(status, "completed");
1093 }
1094 other => panic!("expected BashCompleted, got {other:?}"),
1095 }
1096 }
1097
1098 #[tokio::test]
1099 async fn bash_resolves_relative_workdir_from_session_workspace() {
1100 prime_test_command_environment();
1101 let tool = BashTool::new();
1102 let dir = tempfile::tempdir().unwrap();
1103 let base = dir.path().join("base");
1104 let nested = base.join("nested");
1105 tokio::fs::create_dir_all(&nested).await.unwrap();
1106
1107 let session_id = format!("session_{}", uuid::Uuid::new_v4());
1108 super::workspace_state::set_workspace(&session_id, base.canonicalize().unwrap());
1109
1110 let result = tool
1111 .execute_with_context(
1112 json!({
1113 "command": "pwd",
1114 "workdir": "nested"
1115 }),
1116 ToolExecutionContext {
1117 session_id: Some(&session_id),
1118 tool_call_id: "call_1",
1119 event_tx: None,
1120 available_tool_schemas: None,
1121 bypass_permissions: false,
1122 can_async_resume: false,
1123 },
1124 )
1125 .await
1126 .unwrap();
1127
1128 let payload: Value = serde_json::from_str(&result.result).unwrap();
1129 let expected =
1130 bamboo_config::paths::path_to_display_string(&nested.canonicalize().unwrap());
1131 assert_eq!(payload["cwd"].as_str().unwrap_or_default(), expected);
1132 }
1133
1134 #[tokio::test]
1135 async fn bash_rejects_workdir_that_is_not_directory() {
1136 prime_test_command_environment();
1137 let tool = BashTool::new();
1138 let file = tempfile::NamedTempFile::new().unwrap();
1139
1140 let result = tool
1141 .execute(json!({
1142 "command": "echo hello",
1143 "workdir": file.path()
1144 }))
1145 .await;
1146
1147 assert!(
1148 matches!(result, Err(ToolError::InvalidArguments(msg)) if msg.contains("directory"))
1149 );
1150 }
1151
1152 #[cfg(not(target_os = "windows"))]
1156 #[tokio::test]
1157 async fn running_shells_for_session_filters_by_session_and_status() {
1158 prime_test_command_environment();
1159
1160 let a1 = super::bash_runtime::spawn_background(
1162 "sleep 30",
1163 None,
1164 None,
1165 Some("sess-A".to_string()),
1166 false,
1167 )
1168 .await
1169 .expect("spawn a1");
1170 let a2 = super::bash_runtime::spawn_background(
1171 "sleep 30",
1172 None,
1173 None,
1174 Some("sess-A".to_string()),
1175 false,
1176 )
1177 .await
1178 .expect("spawn a2");
1179 let b = super::bash_runtime::spawn_background(
1181 "sleep 30",
1182 None,
1183 None,
1184 Some("sess-B".to_string()),
1185 false,
1186 )
1187 .await
1188 .expect("spawn b");
1189 let untagged = super::bash_runtime::spawn_background("sleep 30", None, None, None, false)
1191 .await
1192 .expect("spawn untagged");
1193 let done = super::bash_runtime::spawn_background(
1195 "true",
1196 None,
1197 None,
1198 Some("sess-A".to_string()),
1199 false,
1200 )
1201 .await
1202 .expect("spawn done");
1203
1204 let started = Instant::now();
1206 loop {
1207 if done.status() == "completed" {
1208 break;
1209 }
1210 if started.elapsed() > Duration::from_secs(3) {
1211 panic!("sess-A fast shell never completed");
1212 }
1213 sleep(Duration::from_millis(25)).await;
1214 }
1215
1216 let mut running = super::bash_runtime::running_shells_for_session("sess-A");
1218 running.sort();
1219 let mut expected = vec![a1.id.clone(), a2.id.clone()];
1220 expected.sort();
1221 assert_eq!(running, expected);
1222
1223 assert_eq!(
1225 super::bash_runtime::running_shells_for_session("sess-B"),
1226 vec![b.id.clone()]
1227 );
1228
1229 for shell in [a1, a2, b, untagged] {
1232 let _ = shell.kill().await;
1233 }
1234 let _ = super::bash_runtime::remove_shell(&done.id);
1235 }
1236
1237 #[cfg(not(target_os = "windows"))]
1242 #[tokio::test]
1243 async fn auto_path_fast_command_returns_synchronous_result() {
1244 prime_test_command_environment();
1245 let tool = BashTool::new();
1246 let (tx, _rx) = mpsc::channel(32);
1247 let ctx = ToolExecutionContext {
1248 session_id: Some("session_auto_fast"),
1249 tool_call_id: "call_auto_fast",
1250 event_tx: Some(&tx),
1251 available_tool_schemas: None,
1252 bypass_permissions: false,
1253 can_async_resume: false,
1254 };
1255
1256 let result = tool
1257 .execute_with_context(json!({ "command": "echo auto-fast-output" }), ctx)
1258 .await
1259 .expect("auto fast command should succeed");
1260
1261 let payload: Value = serde_json::from_str(&result.result).unwrap();
1262 assert!(
1263 payload.get("bash_id").is_none(),
1264 "fast auto command must not return a bash_id"
1265 );
1266 assert_eq!(payload["exit_code"], 0);
1267 assert_eq!(payload["timed_out"], false);
1268 assert!(payload["stdout"]
1269 .as_str()
1270 .unwrap_or_default()
1271 .contains("auto-fast-output"));
1272 }
1273
1274 #[cfg(not(target_os = "windows"))]
1279 #[tokio::test]
1280 async fn auto_path_promotes_long_command_to_background() {
1281 prime_test_command_environment();
1282 let tool = BashTool::new();
1283 let session_id = "session_auto_promote";
1284 let (tx, mut rx) = mpsc::channel(8);
1285 let ctx = ToolExecutionContext::for_dispatch(
1286 session_id,
1287 "call_auto_promote",
1288 &tx,
1289 &[],
1290 ToolExecutionSessionFlags::default(),
1291 true,
1294 );
1295 let cwd = super::workspace_state::workspace_or_process_cwd(Some(session_id));
1296
1297 let result = tool
1302 .run_streaming_command("sleep 10", 60000, Some(200), &cwd, ctx)
1303 .await
1304 .expect("auto promote should succeed");
1305
1306 assert!(result.success);
1307 let payload: Value = serde_json::from_str(&result.result).unwrap();
1308 let bash_id = payload["bash_id"]
1309 .as_str()
1310 .expect("promoted result must have bash_id")
1311 .to_string();
1312 assert_eq!(payload["status"], "running");
1313
1314 let running = super::bash_runtime::running_shells_for_session(session_id);
1315 assert!(
1316 running.contains(&bash_id),
1317 "adopted shell {bash_id} must appear in running_shells, got {running:?}"
1318 );
1319
1320 let event = tokio::time::timeout(Duration::from_secs(15), rx.recv())
1321 .await
1322 .expect("timed out waiting for BashCompleted")
1323 .expect("event channel closed before BashCompleted");
1324 match event {
1325 AgentEvent::BashCompleted {
1326 bash_id: id,
1327 exit_code,
1328 status,
1329 ..
1330 } => {
1331 assert_eq!(id, bash_id);
1332 assert_eq!(exit_code, Some(0));
1333 assert_eq!(status, "completed");
1334 }
1335 other => panic!("expected BashCompleted, got {other:?}"),
1336 }
1337
1338 if let Some(shell) = super::bash_runtime::get_shell(&bash_id) {
1339 let _ = shell.kill().await;
1340 }
1341 }
1342
1343 #[cfg(not(target_os = "windows"))]
1346 #[tokio::test]
1347 async fn force_sync_does_not_promote_and_times_out() {
1348 prime_test_command_environment();
1349 let tool = BashTool::new();
1350
1351 let result = tool
1352 .execute(json!({
1353 "command": "sleep 10",
1354 "run_in_background": false,
1355 "timeout": 50
1356 }))
1357 .await
1358 .expect("force-sync should produce a timed-out result");
1359
1360 let payload: Value = serde_json::from_str(&result.result).unwrap();
1361 assert!(
1362 payload.get("bash_id").is_none(),
1363 "force-sync must never promote"
1364 );
1365 assert_eq!(payload["timed_out"], true);
1366 assert!(!result.success, "timed-out result must not be successful");
1367 }
1368
1369 #[cfg(not(target_os = "windows"))]
1377 #[tokio::test]
1378 async fn auto_path_does_not_promote_when_not_resume_capable() {
1379 prime_test_command_environment();
1380 let tool = BashTool::new();
1381
1382 let result = tool
1383 .execute(json!({
1384 "command": "sleep 10",
1385 "timeout": 50
1386 }))
1387 .await
1388 .expect("non-resume-capable auto path should produce a result");
1389
1390 let payload: Value = serde_json::from_str(&result.result).unwrap();
1391 assert!(
1392 payload.get("bash_id").is_none(),
1393 "auto path must not promote when can_async_resume is false"
1394 );
1395 assert_eq!(payload["timed_out"], true);
1396 assert!(
1397 !result.success,
1398 "a timed-out command must not report success"
1399 );
1400 }
1401
1402 #[cfg(not(target_os = "windows"))]
1405 #[tokio::test]
1406 async fn adopt_running_child_preserves_seeded_output() {
1407 prime_test_command_environment();
1408 let shell = bamboo_infrastructure::process::preferred_bash_shell();
1409 let mut cmd = tokio::process::Command::new(&shell.program);
1410 bamboo_infrastructure::process::hide_window_for_tokio_command(&mut cmd);
1411 cmd.arg(shell.arg)
1412 .arg("echo seeded-line-1; echo seeded-line-2; sleep 5")
1413 .stdin(std::process::Stdio::null())
1414 .stdout(std::process::Stdio::piped())
1415 .stderr(std::process::Stdio::piped())
1416 .kill_on_drop(true);
1417 let mut child = cmd.spawn().expect("spawn child");
1418 let stdout_reader = tokio::io::BufReader::new(child.stdout.take().unwrap());
1419 let stderr_reader = tokio::io::BufReader::new(child.stderr.take().unwrap());
1420
1421 sleep(Duration::from_millis(200)).await;
1423
1424 let session = super::bash_runtime::adopt_running_child(
1425 child,
1426 stdout_reader,
1427 stderr_reader,
1428 vec!["seeded-line-1".to_string(), "seeded-line-2".to_string()],
1429 vec![],
1430 "echo seeded-line-1; echo seeded-line-2; sleep 5",
1431 Some("session_seed_test".to_string()),
1432 test_environment_diagnostics(),
1433 None,
1434 )
1435 .await
1436 .expect("adopt should succeed");
1437
1438 let (lines, _cursor, _dropped) = session.read_output_since(0, None).await;
1439 assert!(
1440 lines.iter().any(|l| l.contains("seeded-line-1")),
1441 "seeded line 1 must be present, got {lines:?}"
1442 );
1443 assert!(
1444 lines.iter().any(|l| l.contains("seeded-line-2")),
1445 "seeded line 2 must be present, got {lines:?}"
1446 );
1447
1448 let _ = session.kill().await;
1449 let _ = super::bash_runtime::remove_shell(&session.id);
1450 }
1451}