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