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 #[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 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 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 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 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 #[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 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 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 #[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 #[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 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 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 #[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 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 #[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 #[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 #[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 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 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 #[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 #[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 assert!(
439 result.result.contains("\"stdin_closed\":true"),
440 "result should report stdin closed: {}",
441 result.result
442 );
443
444 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 #[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 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 #[test]
497 fn default_append_newline_is_true() {
498 assert!(default_append_newline());
499 }
500}