Skip to main content

bamboo_tools/tools/
bash_input.rs

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