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 #[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 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 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 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 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 #[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 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 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 #[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 #[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 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 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 #[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 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 #[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 #[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 #[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 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 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 #[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 #[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 assert!(
475 result.result.contains("\"stdin_closed\":true"),
476 "result should report stdin closed: {}",
477 result.result
478 );
479
480 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 #[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 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 #[test]
539 fn default_append_newline_is_true() {
540 assert!(default_append_newline());
541 }
542}