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