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