Skip to main content

bamboo_tools/tools/
bash_input.rs

1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolCtx, ToolError, ToolOutcome, ToolResult};
3use serde::Deserialize;
4use serde_json::json;
5
6use super::bash_runtime;
7
8#[derive(Debug, Deserialize)]
9struct BashInputArgs {
10    bash_id: String,
11    input: String,
12    #[serde(default = "default_append_newline")]
13    append_newline: bool,
14    /// When true, send EOF (close stdin) after writing `input`. Lets a
15    /// consumer that reads stdin until EOF (`cat`, `sort`, a REPL) finish
16    /// instead of running until killed.
17    #[serde(default)]
18    eof: bool,
19}
20
21fn default_append_newline() -> bool {
22    true
23}
24
25pub struct BashInputTool;
26
27impl BashInputTool {
28    pub fn new() -> Self {
29        Self
30    }
31}
32
33impl Default for BashInputTool {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39#[async_trait]
40impl Tool for BashInputTool {
41    fn name(&self) -> &str {
42        "BashInput"
43    }
44
45    fn description(&self) -> &str {
46        "Send input to the stdin of an interactive background Bash shell. \
47         The shell must have been spawned with Bash(interactive=true), which \
48         gives it a piped stdin; non-interactive shells have no stdin pipe and \
49         this tool returns an error. By default a trailing newline is appended \
50         so the input is delivered as a complete line. Set eof to true to send \
51         end-of-input (close stdin) after writing; a consumer that reads stdin \
52         until EOF (e.g. cat, sort, a REPL) can then terminate normally. The \
53         input is written as its UTF-8 bytes."
54    }
55
56    fn parameters_schema(&self) -> serde_json::Value {
57        json!({
58            "type": "object",
59            "properties": {
60                "bash_id": {
61                    "type": "string",
62                    "description": "The ID of the interactive background shell to send input to"
63                },
64                "input": {
65                    "type": "string",
66                    "description": "The text to write to the shell's stdin"
67                },
68                "append_newline": {
69                    "type": "boolean",
70                    "description": "Append a trailing newline to the input (default true). Set to false to send the input as UTF-8 bytes without a line terminator."
71                },
72                "eof": {
73                    "type": "boolean",
74                    "description": "After writing `input`, close the shell's stdin (send EOF) so a consumer that reads until end-of-file (e.g. cat, sort, a REPL) can finish. Default false. When eof is true, an empty `input` is allowed (sends EOF only)."
75                }
76            },
77            "required": ["bash_id", "input"],
78            "additionalProperties": false
79        })
80    }
81
82    async fn invoke(
83        &self,
84        args: serde_json::Value,
85        _ctx: ToolCtx,
86    ) -> Result<ToolOutcome, ToolError> {
87        let parsed: BashInputArgs = serde_json::from_value(args)
88            .map_err(|e| ToolError::InvalidArguments(format!("Invalid BashInput args: {}", e)))?;
89
90        // Empty input is only meaningful when sending EOF (close_stdin) —
91        // otherwise a write of zero bytes with no newline is a no-op and almost
92        // certainly a caller mistake.
93        if parsed.input.is_empty() && !parsed.append_newline && !parsed.eof {
94            return Err(ToolError::InvalidArguments(
95                "'input' must not be empty unless eof is true (or append_newline is true)"
96                    .to_string(),
97            ));
98        }
99
100        let shell = bash_runtime::get_shell(parsed.bash_id.trim()).ok_or_else(|| {
101            ToolError::Execution(format!("Background shell '{}' not found", parsed.bash_id))
102        })?;
103
104        // Write any provided input first. When `input` is empty and no newline
105        // is requested there is nothing to write — skip straight to the optional
106        // EOF so an "eof only" call is a clean close rather than a zero-byte
107        // write.
108        let mut bytes_written = 0usize;
109        if !parsed.input.is_empty() || parsed.append_newline {
110            shell
111                .write_stdin(&parsed.input, parsed.append_newline)
112                .await
113                .map_err(ToolError::Execution)?;
114            bytes_written = if parsed.append_newline {
115                parsed.input.len() + 1
116            } else {
117                parsed.input.len()
118            };
119        }
120
121        // Optionally close stdin (send EOF) so a consumer that reads until
122        // end-of-file can terminate normally. Done after the write so the bytes
123        // are flushed before the pipe is closed.
124        let stdin_closed = if parsed.eof {
125            shell.close_stdin().await;
126            true
127        } else {
128            false
129        };
130
131        Ok(ToolOutcome::Completed(ToolResult {
132            success: true,
133            result: json!({
134                "bash_id": shell.id,
135                "status": shell.status(),
136                "bytes_written": bytes_written,
137                "stdin_closed": stdin_closed,
138            })
139            .to_string(),
140            display_preference: Some("Collapsible".to_string()),
141            images: Vec::new(),
142        }))
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use bamboo_infrastructure::process::{
150        clear_command_environment_cache_for_tests, prime_command_environment_cache_for_tests,
151        CommandEnvironmentDiagnostics, CommandEnvironmentSource, PythonDiscoveryDiagnostics,
152    };
153    use std::collections::HashMap;
154    use tokio::time::{sleep, Duration, Instant};
155
156    fn test_environment_diagnostics() -> CommandEnvironmentDiagnostics {
157        CommandEnvironmentDiagnostics {
158            source: CommandEnvironmentSource::InheritedProcess,
159            import_shell: None,
160            import_error: Some("test-import-disabled".to_string()),
161            path: Some("/usr/bin:/bin".to_string()),
162            path_entries: Some(2),
163            python: PythonDiscoveryDiagnostics {
164                configured: Some("python3".to_string()),
165                resolved: Some("/usr/bin/python3".to_string()),
166                invocation: Some("/usr/bin/python3".to_string()),
167                source: Some("path".to_string()),
168                tried: vec!["python3".to_string(), "python".to_string()],
169                tried_preview: vec!["python3".to_string(), "python".to_string()],
170                tried_total: 2,
171                tried_truncated: false,
172                hint: None,
173            },
174        }
175    }
176
177    fn prime_test_command_environment() {
178        clear_command_environment_cache_for_tests();
179        prime_command_environment_cache_for_tests(
180            HashMap::from([("PATH".to_string(), "/usr/bin:/bin".to_string())]),
181            test_environment_diagnostics(),
182        );
183    }
184
185    /// Helper: poll `shell`'s output until a line containing `needle` appears,
186    /// or time out after `secs` seconds.
187    async fn wait_for_output_contains(shell: &bash_runtime::ShellSession, needle: &str, secs: u64) {
188        let deadline = Instant::now() + Duration::from_secs(secs);
189        loop {
190            let (lines, _, _) = shell.read_output_since(0, None).await;
191            if lines.iter().any(|l| l.contains(needle)) {
192                return;
193            }
194            if Instant::now() >= deadline {
195                panic!("timed out waiting for '{needle}' in output; got: {lines:?}");
196            }
197            sleep(Duration::from_millis(50)).await;
198        }
199    }
200
201    // (a) Interactive shell: BashInput feeds stdin, output appears via read_output_since.
202    #[cfg(not(target_os = "windows"))]
203    #[tokio::test]
204    async fn bash_input_feeds_interactive_shell_and_output_appears() {
205        prime_test_command_environment();
206        // `cat` echoes its stdin to stdout — perfect for round-trip verification.
207        let shell = bash_runtime::spawn_background("cat", None, None, None, true, None)
208            .await
209            .expect("spawn interactive shell");
210        assert_eq!(shell.status(), "running");
211
212        let tool = BashInputTool::new();
213        let out = tool
214            .invoke(
215                json!({
216                    "bash_id": shell.id,
217                    "input": "hello-from-bashinput"
218                }),
219                ToolCtx::none("t"),
220            )
221            .await
222            .expect("BashInput should succeed on interactive shell");
223        let ToolOutcome::Completed(result) = out else {
224            panic!("expected Completed")
225        };
226        assert!(result.success);
227
228        // The echoed input must appear in the shell's captured output.
229        wait_for_output_contains(&shell, "hello-from-bashinput", 5).await;
230
231        let _ = shell.kill().await;
232        let _ = bash_runtime::remove_shell(&shell.id);
233    }
234
235    // (b) write_stdin on a NON-interactive shell returns a clear error — never panics.
236    #[cfg(not(target_os = "windows"))]
237    #[tokio::test]
238    async fn write_stdin_errors_on_non_interactive_shell() {
239        prime_test_command_environment();
240        let shell = bash_runtime::spawn_background("sleep 5", None, None, None, false, None)
241            .await
242            .expect("spawn non-interactive shell");
243
244        let err = shell
245            .write_stdin("hello", true)
246            .await
247            .expect_err("write_stdin must error on non-interactive shell");
248        assert!(
249            err.contains("interactive"),
250            "error should explain the shell is not interactive: {err}"
251        );
252
253        let _ = shell.kill().await;
254        let _ = bash_runtime::remove_shell(&shell.id);
255    }
256
257    // (c) write_stdin on an EXITED interactive shell returns a clear error — never panics.
258    #[cfg(not(target_os = "windows"))]
259    #[tokio::test]
260    async fn write_stdin_errors_on_exited_interactive_shell() {
261        prime_test_command_environment();
262        let shell = bash_runtime::spawn_background("true", None, None, None, true, None)
263            .await
264            .expect("spawn interactive shell");
265
266        // Wait for the shell to exit.
267        let deadline = Instant::now() + Duration::from_secs(3);
268        loop {
269            if shell.status() == "completed" {
270                break;
271            }
272            if Instant::now() >= deadline {
273                panic!("shell did not exit in time");
274            }
275            sleep(Duration::from_millis(25)).await;
276        }
277        // Give the OS a moment to close the pipe after process exit.
278        sleep(Duration::from_millis(50)).await;
279
280        let err = shell
281            .write_stdin("hello", true)
282            .await
283            .expect_err("write_stdin must error on exited shell");
284        assert!(
285            !err.contains("interactive"),
286            "error should be a pipe/write failure, not a missing-handle error: {err}"
287        );
288
289        let _ = bash_runtime::remove_shell(&shell.id);
290    }
291
292    // (d) A non-interactive command that reads stdin gets immediate EOF (Stdio::null)
293    // and terminates — it must NOT hang waiting for input. This is the preserved
294    // default behavior: Stdio::null() on every non-interactive path.
295    #[cfg(not(target_os = "windows"))]
296    #[tokio::test]
297    async fn non_interactive_stdin_reader_gets_eof_and_terminates() {
298        prime_test_command_environment();
299        // `cat` reads stdin; with Stdio::null() it receives immediate EOF and exits 0.
300        let shell = bash_runtime::spawn_background("cat", None, None, None, false, None)
301            .await
302            .expect("spawn non-interactive shell");
303
304        let deadline = Instant::now() + Duration::from_secs(3);
305        loop {
306            if shell.status() == "completed" {
307                break;
308            }
309            if Instant::now() >= deadline {
310                panic!("non-interactive `cat` must terminate on EOF, not hang");
311            }
312            sleep(Duration::from_millis(25)).await;
313        }
314
315        let code = shell.exit_code().await;
316        assert_eq!(code, Some(0), "cat should exit cleanly on immediate EOF");
317
318        let _ = bash_runtime::remove_shell(&shell.id);
319    }
320
321    // BashInput on an unknown shell id returns a not-found error.
322    #[tokio::test]
323    async fn bash_input_errors_on_unknown_shell() {
324        let tool = BashInputTool::new();
325        let result = tool
326            .invoke(
327                json!({
328                    "bash_id": "nonexistent-shell-id",
329                    "input": "hello"
330                }),
331                ToolCtx::none("t"),
332            )
333            .await;
334        assert!(result.is_err(), "BashInput must error on unknown shell id");
335        match result {
336            Err(ToolError::Execution(msg)) => {
337                assert!(msg.contains("not found"), "unexpected error: {msg}");
338            }
339            Err(other) => panic!("expected Execution error, got {other:?}"),
340            Ok(_) => panic!("expected Execution error, got Ok"),
341        }
342    }
343
344    // BashInput on a non-interactive shell returns an error via the tool path.
345    #[cfg(not(target_os = "windows"))]
346    #[tokio::test]
347    async fn bash_input_errors_on_non_interactive_shell_via_tool() {
348        prime_test_command_environment();
349        let shell = bash_runtime::spawn_background("sleep 5", None, None, None, false, None)
350            .await
351            .expect("spawn non-interactive shell");
352
353        let tool = BashInputTool::new();
354        let result = tool
355            .invoke(
356                json!({
357                    "bash_id": shell.id,
358                    "input": "hello"
359                }),
360                ToolCtx::none("t"),
361            )
362            .await;
363        assert!(
364            result.is_err(),
365            "BashInput must error on non-interactive shell"
366        );
367        match result {
368            Err(ToolError::Execution(msg)) => {
369                assert!(
370                    msg.contains("interactive"),
371                    "error should mention interactive: {msg}"
372                );
373            }
374            Err(other) => panic!("expected Execution error, got {other:?}"),
375            Ok(_) => panic!("expected Execution error, got Ok"),
376        }
377
378        let _ = shell.kill().await;
379        let _ = bash_runtime::remove_shell(&shell.id);
380    }
381
382    // append_newline=false sends the input as UTF-8 bytes without a trailing
383    // newline. (It is NOT arbitrary binary — `input` is a JSON String, so it is
384    // validated UTF-8.)
385    #[cfg(not(target_os = "windows"))]
386    #[tokio::test]
387    async fn bash_input_append_newline_false_sends_utf8_bytes() {
388        prime_test_command_environment();
389        let shell = bash_runtime::spawn_background("cat", None, None, None, true, None)
390            .await
391            .expect("spawn interactive shell");
392
393        let tool = BashInputTool::new();
394        // Send "utf8-payload" with no newline; cat won't produce a line until it
395        // gets one, so send a second write WITH newline to flush it.
396        let out = tool
397            .invoke(
398                json!({
399                    "bash_id": shell.id,
400                    "input": "utf8-payload",
401                    "append_newline": false
402                }),
403                ToolCtx::none("t"),
404            )
405            .await
406            .expect("utf-8 write should succeed");
407        let ToolOutcome::Completed(result) = out else {
408            panic!("expected Completed")
409        };
410        assert!(result.success);
411
412        // Now send a newline so cat emits the buffered line.
413        tool.invoke(
414            json!({
415                "bash_id": shell.id,
416                "input": "",
417            }),
418            ToolCtx::none("t"),
419        )
420        .await
421        .expect("newline write should succeed");
422
423        wait_for_output_contains(&shell, "utf8-payload", 5).await;
424
425        let _ = shell.kill().await;
426        let _ = bash_runtime::remove_shell(&shell.id);
427    }
428
429    // BashInput rejects empty input with append_newline=false.
430    #[tokio::test]
431    async fn bash_input_rejects_empty_raw_input() {
432        let tool = BashInputTool::new();
433        let result = tool
434            .invoke(
435                json!({
436                    "bash_id": "fake",
437                    "input": "",
438                    "append_newline": false
439                }),
440                ToolCtx::none("t"),
441            )
442            .await;
443        assert!(matches!(result, Err(ToolError::InvalidArguments(_))));
444    }
445
446    // eof:true writes any input then closes stdin, so an interactive `cat`
447    // (which echoes stdin until EOF) reaches end-of-file and terminates — its
448    // status flips to "completed" and the echoed input is captured.
449    #[cfg(not(target_os = "windows"))]
450    #[tokio::test]
451    async fn bash_input_eof_closes_stdin_and_lets_consumer_terminate() {
452        prime_test_command_environment();
453        let shell = bash_runtime::spawn_background("cat", None, None, None, true, None)
454            .await
455            .expect("spawn interactive shell");
456
457        let tool = BashInputTool::new();
458        let out = tool
459            .invoke(
460                json!({
461                    "bash_id": shell.id,
462                    "input": "line-one",
463                    "eof": true,
464                }),
465                ToolCtx::none("t"),
466            )
467            .await
468            .expect("eof write should succeed");
469        let ToolOutcome::Completed(result) = out else {
470            panic!("expected Completed")
471        };
472        assert!(result.success);
473        // We reported the line write and the close.
474        assert!(
475            result.result.contains("\"stdin_closed\":true"),
476            "result should report stdin closed: {}",
477            result.result
478        );
479
480        // `cat` reads until EOF; closing stdin lets it finish instead of hanging.
481        wait_for_output_contains(&shell, "line-one", 5).await;
482        let deadline = Instant::now() + Duration::from_secs(5);
483        loop {
484            if shell.status() == "completed" {
485                break;
486            }
487            if Instant::now() >= deadline {
488                panic!("interactive cat must terminate on EOF, not hang");
489            }
490            sleep(Duration::from_millis(25)).await;
491        }
492
493        let _ = bash_runtime::remove_shell(&shell.id);
494    }
495
496    // eof:true with empty input is accepted (sends EOF only, no payload).
497    #[cfg(not(target_os = "windows"))]
498    #[tokio::test]
499    async fn bash_input_eof_allows_empty_input() {
500        prime_test_command_environment();
501        let shell = bash_runtime::spawn_background("cat", None, None, None, true, None)
502            .await
503            .expect("spawn interactive shell");
504
505        let tool = BashInputTool::new();
506        let out = tool
507            .invoke(
508                json!({
509                    "bash_id": shell.id,
510                    "input": "",
511                    "eof": true,
512                }),
513                ToolCtx::none("t"),
514            )
515            .await
516            .expect("eof-only write should succeed");
517        let ToolOutcome::Completed(result) = out else {
518            panic!("expected Completed")
519        };
520        assert!(result.success);
521
522        // With stdin closed, `cat` reaches EOF and exits — it must not hang.
523        let deadline = Instant::now() + Duration::from_secs(5);
524        loop {
525            if shell.status() == "completed" {
526                break;
527            }
528            if Instant::now() >= deadline {
529                panic!("interactive cat must terminate on EOF, not hang");
530            }
531            sleep(Duration::from_millis(25)).await;
532        }
533
534        let _ = bash_runtime::remove_shell(&shell.id);
535    }
536
537    // The default append_newline is true (serde default function).
538    #[test]
539    fn default_append_newline_is_true() {
540        assert!(default_append_newline());
541    }
542}