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())
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 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 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 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 #[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 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 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 #[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 #[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 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 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 #[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 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 #[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 #[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 #[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 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 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 #[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 #[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 assert!(
478 result.result.contains("\"stdin_closed\":true"),
479 "result should report stdin closed: {}",
480 result.result
481 );
482
483 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 #[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 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 #[test]
542 fn default_append_newline_is_true() {
543 assert!(default_append_newline());
544 }
545}