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::{
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::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::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_infrastructure::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_infrastructure::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_infrastructure::paths::path_to_display_string(&resolved),
182                error
183            ))
184        })
185    }
186
187    async fn prepare_environment() -> PreparedCommandEnvironment {
188        let overrides = bamboo_infrastructure::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_infrastructure::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        })
326    }
327}
328
329impl Default for BashTool {
330    fn default() -> Self {
331        Self::new()
332    }
333}
334
335#[async_trait]
336impl Tool for BashTool {
337    fn name(&self) -> &str {
338        "Bash"
339    }
340
341    fn description(&self) -> &str {
342        "Execute shell commands with streaming output (supports background mode). Default timeout is 120000ms (max 600000ms); captured stdout/stderr are each capped at 512KB."
343    }
344
345    fn parameters_schema(&self) -> serde_json::Value {
346        json!({
347            "type": "object",
348            "properties": {
349                "command": {
350                    "type": "string",
351                    "description": "The command to execute"
352                },
353                "timeout": {
354                    "type": "number",
355                    "description": "Optional timeout in milliseconds (default 120000, max 600000)"
356                },
357                "description": {
358                    "type": "string",
359                    "description": "Optional short context label for the command"
360                },
361                "run_in_background": {
362                    "type": "boolean",
363                    "description": "Set to true to run this command in the background"
364                },
365                "workdir": {
366                    "type": "string",
367                    "description": "Optional working directory. Relative paths are resolved from the session workspace."
368                }
369            },
370            "required": ["command"],
371            "additionalProperties": false
372        })
373    }
374
375    async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
376        self.execute_with_context(args, ToolExecutionContext::none("Bash"))
377            .await
378    }
379
380    async fn execute_with_context(
381        &self,
382        args: serde_json::Value,
383        ctx: ToolExecutionContext<'_>,
384    ) -> Result<ToolResult, ToolError> {
385        let parsed: BashArgs = serde_json::from_value(args)
386            .map_err(|e| ToolError::InvalidArguments(format!("Invalid Bash args: {}", e)))?;
387
388        let command = parsed.command.trim();
389        if command.is_empty() {
390            return Err(ToolError::InvalidArguments(
391                "'command' cannot be empty".to_string(),
392            ));
393        }
394
395        let _ = parsed.description;
396        let timeout_ms = Self::effective_timeout_ms(parsed.timeout);
397        let session_workspace = workspace_state::workspace_or_process_cwd(ctx.session_id);
398        let cwd = Self::resolve_cwd(&session_workspace, parsed.workdir.as_deref())?;
399        if parsed.run_in_background.unwrap_or(false) {
400            let shell = bash_runtime::spawn_background(command, Some(&cwd))
401                .await
402                .map_err(ToolError::Execution)?;
403
404            if let Some(requested_timeout) = parsed.timeout {
405                let kill_after_ms = Self::effective_timeout_ms(Some(requested_timeout));
406                let shell_clone = shell.clone();
407                tokio::spawn(async move {
408                    tokio::time::sleep(Duration::from_millis(kill_after_ms)).await;
409                    if shell_clone.status() == "running" {
410                        let _ = shell_clone.kill().await;
411                    }
412                });
413            }
414
415            return Ok(ToolResult {
416                success: true,
417                result: json!({
418                    "bash_id": shell.id,
419                    "command": shell.command,
420                    "status": "running",
421                    "cwd": bamboo_infrastructure::paths::path_to_display_string(&cwd),
422                    "environment": Self::environment_json(&shell.environment, false),
423                })
424                .to_string(),
425                display_preference: Some("Collapsible".to_string()),
426            });
427        }
428
429        self.run_foreground(command, timeout_ms, &cwd, ctx).await
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use bamboo_agent_core::AgentEvent;
437    use bamboo_infrastructure::{
438        clear_command_environment_cache_for_tests, prime_command_environment_cache_for_tests,
439        CommandEnvironmentDiagnostics, CommandEnvironmentSource, PythonDiscoveryDiagnostics,
440    };
441    use serde_json::Value;
442    use std::collections::HashMap;
443    use tokio::sync::mpsc;
444    use tokio::time::{sleep, Duration, Instant};
445
446    #[cfg(target_os = "windows")]
447    fn mixed_output_command() -> &'static str {
448        "echo out && echo err 1>&2"
449    }
450
451    #[cfg(not(target_os = "windows"))]
452    fn mixed_output_command() -> &'static str {
453        "printf 'out\\n'; printf 'err\\n' 1>&2"
454    }
455
456    #[cfg(target_os = "windows")]
457    fn invalid_utf8_stderr_command() -> String {
458        let shell = bamboo_infrastructure::preferred_bash_shell();
459        if shell.arg == "-lc" {
460            "printf '\\377\\n' 1>&2".to_string()
461        } else {
462            "powershell -NoProfile -Command \"$bytes = [byte[]](0xFF,0x0A); [Console]::OpenStandardError().Write($bytes,0,$bytes.Length)\"".to_string()
463        }
464    }
465
466    #[cfg(not(target_os = "windows"))]
467    fn invalid_utf8_stderr_command() -> String {
468        "printf '\\377\\n' 1>&2".to_string()
469    }
470
471    fn test_environment_diagnostics() -> CommandEnvironmentDiagnostics {
472        CommandEnvironmentDiagnostics {
473            source: CommandEnvironmentSource::InheritedProcess,
474            import_shell: None,
475            import_error: Some("test-import-disabled".to_string()),
476            path: Some("/usr/bin:/bin".to_string()),
477            path_entries: Some(2),
478            python: PythonDiscoveryDiagnostics {
479                configured: Some("python3".to_string()),
480                resolved: Some("/usr/bin/python3".to_string()),
481                invocation: Some("/usr/bin/python3".to_string()),
482                source: Some("path".to_string()),
483                tried: vec!["python3".to_string(), "python".to_string()],
484                tried_preview: vec!["python3".to_string(), "python".to_string()],
485                tried_total: 2,
486                tried_truncated: false,
487                hint: None,
488            },
489        }
490    }
491
492    fn prime_test_command_environment() {
493        clear_command_environment_cache_for_tests();
494        prime_command_environment_cache_for_tests(
495            HashMap::from([("PATH".to_string(), "/usr/bin:/bin".to_string())]),
496            test_environment_diagnostics(),
497        );
498    }
499
500    #[tokio::test]
501    async fn bash_foreground_returns_stdout_stderr_and_streams_tokens() {
502        prime_test_command_environment();
503        let tool = BashTool::new();
504        let (tx, mut rx) = mpsc::channel(32);
505
506        let result = tool
507            .execute_with_context(
508                json!({
509                    "command": mixed_output_command()
510                }),
511                ToolExecutionContext {
512                    session_id: Some("session_1"),
513                    tool_call_id: "call_1",
514                    event_tx: Some(&tx),
515                    available_tool_schemas: None,
516                },
517            )
518            .await
519            .unwrap();
520
521        assert!(result.success);
522
523        let payload: Value = serde_json::from_str(&result.result).unwrap();
524        assert_eq!(payload["timed_out"], false);
525        assert_eq!(payload["exit_code"], 0);
526        assert!(payload["stdout"]
527            .as_str()
528            .unwrap_or_default()
529            .contains("out"));
530        assert!(payload["stderr"]
531            .as_str()
532            .unwrap_or_default()
533            .contains("err"));
534        assert_eq!(payload["environment"]["source"], "process_env");
535        assert_eq!(
536            payload["environment"]["import_error"],
537            "test-import-disabled"
538        );
539        assert_eq!(
540            payload["environment"]["python"]["resolved"],
541            "/usr/bin/python3"
542        );
543        assert_eq!(
544            payload["environment"]["python"]["invocation"],
545            "/usr/bin/python3"
546        );
547        assert_eq!(payload["environment"]["python"]["source"], "path");
548        assert_eq!(
549            payload["environment"]["python"]["tried_preview"][0],
550            "python3"
551        );
552        assert_eq!(payload["environment"]["python"]["tried_total"], 1);
553        assert!(payload["environment"]["python"].get("tried").is_none());
554
555        let mut streamed = Vec::new();
556        while let Ok(event) = rx.try_recv() {
557            if let AgentEvent::ToolToken { content, .. } = event {
558                streamed.push(content);
559            }
560        }
561
562        assert!(streamed.iter().any(|line| line.contains("out")));
563        assert!(streamed.iter().any(|line| line.contains("err")));
564    }
565
566    #[tokio::test]
567    async fn bash_foreground_tolerates_invalid_utf8_stderr() {
568        prime_test_command_environment();
569        let tool = BashTool::new();
570        let result = tool
571            .execute(json!({
572                "command": invalid_utf8_stderr_command()
573            }))
574            .await;
575
576        assert!(result.is_ok(), "invalid UTF-8 stderr should not fail");
577        let payload: Value = serde_json::from_str(&result.unwrap().result).unwrap();
578        let stderr = payload["stderr"].as_str().unwrap_or_default();
579        assert!(!stderr.is_empty());
580    }
581
582    #[cfg(not(target_os = "windows"))]
583    #[tokio::test]
584    async fn bash_foreground_failure_includes_full_python_tried_list() {
585        prime_test_command_environment();
586        let tool = BashTool::new();
587        let result = tool
588            .execute(json!({
589                "command": "false"
590            }))
591            .await
592            .unwrap();
593
594        assert!(!result.success);
595        let payload: Value = serde_json::from_str(&result.result).unwrap();
596        assert_eq!(payload["exit_code"], 1);
597        assert_eq!(payload["environment"]["python"]["tried_total"], 1);
598        assert_eq!(payload["environment"]["python"]["tried"][0], "python3");
599    }
600
601    #[cfg(not(target_os = "windows"))]
602    #[tokio::test]
603    async fn bash_foreground_sets_stdout_truncated_when_output_exceeds_cap() {
604        prime_test_command_environment();
605        let tool = BashTool::new();
606        let result = tool
607            .execute(json!({
608                "command": "i=0; while [ $i -lt 70000 ]; do printf 'aaaaaaaaaa'; i=$((i+1)); done; printf '\\n'"
609            }))
610            .await
611            .unwrap();
612
613        let payload: Value = serde_json::from_str(&result.result).unwrap();
614        assert_eq!(payload["timed_out"], false);
615        assert_eq!(payload["stdout_truncated"], true);
616    }
617
618    #[cfg(not(target_os = "windows"))]
619    #[tokio::test]
620    async fn bash_background_honors_explicit_timeout() {
621        prime_test_command_environment();
622        let tool = BashTool::new();
623        let result = tool
624            .execute(json!({
625                "command": "sleep 2",
626                "run_in_background": true,
627                "timeout": 50
628            }))
629            .await
630            .unwrap();
631        let payload: Value = serde_json::from_str(&result.result).unwrap();
632        assert_eq!(payload["environment"]["source"], "process_env");
633        assert_eq!(
634            payload["environment"]["python"]["resolved"],
635            "/usr/bin/python3"
636        );
637        assert_eq!(
638            payload["environment"]["python"]["invocation"],
639            "/usr/bin/python3"
640        );
641        assert_eq!(payload["environment"]["python"]["tried_total"], 1);
642        assert!(payload["environment"]["python"].get("tried").is_none());
643        let shell_id = payload["bash_id"].as_str().unwrap().to_string();
644
645        let started = Instant::now();
646        loop {
647            let shell = super::bash_runtime::get_shell(&shell_id).unwrap();
648            if shell.status() == "completed" {
649                break;
650            }
651            if started.elapsed() > Duration::from_secs(2) {
652                panic!("background shell did not stop after timeout");
653            }
654            sleep(Duration::from_millis(25)).await;
655        }
656    }
657
658    #[tokio::test]
659    async fn bash_resolves_relative_workdir_from_session_workspace() {
660        prime_test_command_environment();
661        let tool = BashTool::new();
662        let dir = tempfile::tempdir().unwrap();
663        let base = dir.path().join("base");
664        let nested = base.join("nested");
665        tokio::fs::create_dir_all(&nested).await.unwrap();
666
667        let session_id = format!("session_{}", uuid::Uuid::new_v4());
668        super::workspace_state::set_workspace(&session_id, base.canonicalize().unwrap());
669
670        let result = tool
671            .execute_with_context(
672                json!({
673                    "command": "pwd",
674                    "workdir": "nested"
675                }),
676                ToolExecutionContext {
677                    session_id: Some(&session_id),
678                    tool_call_id: "call_1",
679                    event_tx: None,
680                    available_tool_schemas: None,
681                },
682            )
683            .await
684            .unwrap();
685
686        let payload: Value = serde_json::from_str(&result.result).unwrap();
687        let expected =
688            bamboo_infrastructure::paths::path_to_display_string(&nested.canonicalize().unwrap());
689        assert_eq!(payload["cwd"].as_str().unwrap_or_default(), expected);
690    }
691
692    #[tokio::test]
693    async fn bash_rejects_workdir_that_is_not_directory() {
694        prime_test_command_environment();
695        let tool = BashTool::new();
696        let file = tempfile::NamedTempFile::new().unwrap();
697
698        let result = tool
699            .execute(json!({
700                "command": "echo hello",
701                "workdir": file.path()
702            }))
703            .await;
704
705        assert!(
706            matches!(result, Err(ToolError::InvalidArguments(msg)) if msg.contains("directory"))
707        );
708    }
709}