Skip to main content

bamboo_tools/tools/
bash.rs

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}