1use super::{strip_ansi, Tool, ToolContext};
2use crate::{Result, RuntimeError};
3use serde_json::{json, Value};
4use zeroize::Zeroize;
5
6const BASH_INTERMEDIARY_CHANNEL_CAPACITY: usize = 64;
7static BASH_INTERMEDIARY_PRODUCED_BYTES: std::sync::atomic::AtomicU64 =
8 std::sync::atomic::AtomicU64::new(0);
9static BASH_INTERMEDIARY_ACCEPTED_BYTES: std::sync::atomic::AtomicU64 =
10 std::sync::atomic::AtomicU64::new(0);
11static BASH_INTERMEDIARY_CONSUMED_BYTES: std::sync::atomic::AtomicU64 =
12 std::sync::atomic::AtomicU64::new(0);
13static BASH_INTERMEDIARY_DROPPED_BYTES: std::sync::atomic::AtomicU64 =
14 std::sync::atomic::AtomicU64::new(0);
15
16#[derive(Debug, Clone, Copy)]
17pub struct BashIntermediarySnapshot {
18 pub produced_bytes: u64,
19 pub accepted_bytes: u64,
20 pub consumed_bytes: u64,
21 pub dropped_bytes: u64,
22 pub retained_bytes: u64,
23}
24
25pub fn bash_intermediary_snapshot() -> BashIntermediarySnapshot {
26 use std::sync::atomic::Ordering;
27 let produced = BASH_INTERMEDIARY_PRODUCED_BYTES.load(Ordering::Relaxed);
28 let accepted = BASH_INTERMEDIARY_ACCEPTED_BYTES.load(Ordering::Relaxed);
29 let consumed = BASH_INTERMEDIARY_CONSUMED_BYTES.load(Ordering::Relaxed);
30 let dropped = BASH_INTERMEDIARY_DROPPED_BYTES.load(Ordering::Relaxed);
31 BashIntermediarySnapshot {
32 produced_bytes: produced,
33 accepted_bytes: accepted,
34 consumed_bytes: consumed,
35 dropped_bytes: dropped,
36 retained_bytes: produced.saturating_sub(consumed).saturating_sub(dropped),
37 }
38}
39
40pub struct BashTool;
41
42const READ_CHUNK_SIZE: usize = 1024;
43const MAX_STREAMED_DELTA_BYTES: usize = 16 * 1024;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46enum PromptKind {
47 Sudo,
48 Password,
49}
50
51fn sanitize_output(input: &str) -> String {
52 let stripped = strip_ansi(input);
53 stripped
54 .chars()
55 .filter(|ch| {
56 *ch == '\n' || *ch == '\r' || *ch == '\t' || (!ch.is_control() && *ch != '\u{7f}')
57 })
58 .collect()
59}
60
61async fn forward_output<R>(
67 mut reader: R,
68 is_stderr: bool,
69 tx: tokio::sync::mpsc::Sender<(bool, String)>,
70) where
71 R: tokio::io::AsyncRead + Unpin,
72{
73 use std::sync::atomic::Ordering;
74 use tokio::io::AsyncReadExt;
75 let mut buf = vec![0u8; READ_CHUNK_SIZE];
76 let mut decoder = super::utf8_stream::Utf8StreamDecoder::new();
77 loop {
78 let (text, eof) = match reader.read(&mut buf).await {
79 Ok(0) | Err(_) => (decoder.finish(), true),
80 Ok(n) => (decoder.decode(&buf[..n]), false),
81 };
82 let msg = sanitize_output(&text);
83 if !msg.is_empty() {
84 BASH_INTERMEDIARY_PRODUCED_BYTES.fetch_add(msg.len() as u64, Ordering::Relaxed);
85 let len = msg.len();
86 if tx.send((is_stderr, msg)).await.is_ok() {
87 BASH_INTERMEDIARY_ACCEPTED_BYTES.fetch_add(len as u64, Ordering::Relaxed);
88 } else {
89 BASH_INTERMEDIARY_DROPPED_BYTES.fetch_add(len as u64, Ordering::Relaxed);
90 break;
91 }
92 }
93 if eof {
94 break;
95 }
96 }
97}
98
99fn detect_password_prompt(text: &str) -> Option<PromptKind> {
100 let lower = text.to_ascii_lowercase();
101 let has_password = lower.contains("password");
102 if !has_password {
103 return None;
104 }
105 if lower.contains("[sudo]") || lower.contains("sudo") {
106 Some(PromptKind::Sudo)
107 } else if lower.trim_end().ends_with(':') || lower.contains("password:") {
108 Some(PromptKind::Password)
109 } else {
110 None
111 }
112}
113
114fn append_bounded(output: &mut String, text: &str, max_output: usize) -> bool {
115 if output.len() >= max_output {
116 return false;
117 }
118 let remaining = max_output - output.len();
119 if text.len() <= remaining {
120 output.push_str(text);
121 true
122 } else {
123 let mut end = remaining;
124 while end > 0 && !text.is_char_boundary(end) {
125 end -= 1;
126 }
127 output.push_str(&text[..end]);
128 false
129 }
130}
131
132pub(crate) fn bash_program() -> std::ffi::OsString {
138 #[cfg(unix)]
139 {
140 "bash".into()
141 }
142 #[cfg(windows)]
143 {
144 let on_path = std::env::var_os("PATH")
145 .map(|p| std::env::split_paths(&p).any(|d| d.join("bash.exe").is_file()))
146 .unwrap_or(false);
147 if on_path {
148 return "bash".into();
149 }
150 for var in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
151 if let Some(pf) = std::env::var_os(var) {
152 let candidate = std::path::Path::new(&pf).join(r"Git\bin\bash.exe");
153 if candidate.is_file() {
154 return candidate.into_os_string();
155 }
156 }
157 }
158 let wsl = std::path::Path::new(r"C:\Windows\System32\bash.exe");
159 if wsl.is_file() {
160 return wsl.as_os_str().to_os_string();
161 }
162 "bash".into()
164 }
165}
166
167pub(crate) fn bash_script_with_secure_sudo(command: &str) -> String {
168 format!(
174 r#"sudo() {{
175 command sudo -S -p '[sudo] password required: ' "$@"
176}}
177{command}"#
178 )
179}
180
181#[cfg(windows)]
185pub(crate) fn powershell_program() -> std::ffi::OsString {
186 let on_path = |exe: &str| {
187 std::env::var_os("PATH")
188 .map(|p| std::env::split_paths(&p).any(|d| d.join(exe).is_file()))
189 .unwrap_or(false)
190 };
191 if on_path("pwsh.exe") {
192 return "pwsh".into();
193 }
194 for var in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
195 if let Some(pf) = std::env::var_os(var) {
196 let candidate = std::path::Path::new(&pf).join(r"PowerShell\7\pwsh.exe");
197 if candidate.is_file() {
198 return candidate.into_os_string();
199 }
200 }
201 }
202 "powershell".into()
204}
205
206#[cfg(not(windows))]
209pub(crate) fn powershell_program() -> std::ffi::OsString {
210 "pwsh".into()
211}
212
213#[async_trait::async_trait]
214impl Tool for BashTool {
215 fn origin(&self) -> crate::tools::ToolOrigin {
216 crate::tools::ToolOrigin::Builtin
217 }
218
219 fn effect(&self) -> crate::tools::catalog::ToolEffect {
222 crate::tools::catalog::ToolEffect::NonIdempotent
223 }
224
225 fn name(&self) -> &str {
226 "bash"
227 }
228
229 fn description(&self) -> &str {
230 "Execute a bash command and return its output. Use for running programs, installing packages, git operations, and any shell commands. Commands time out after 30 seconds by default; pass a larger timeout when needed. If sudo asks for a password, the user is prompted securely in the TUI and the password is never shown to the model."
231 }
232
233 fn parameters(&self) -> Value {
234 json!({
235 "type": "object",
236 "properties": {
237 "command": {
238 "type": "string",
239 "description": "The bash command to execute"
240 },
241 "timeout": {
242 "type": "integer",
243 "description": "Timeout in seconds (default: 30). Use a larger value for long-running commands."
244 }
245 },
246 "required": ["command"]
247 })
248 }
249
250 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
251 let command = params["command"]
252 .as_str()
253 .ok_or_else(|| RuntimeError::Tool("Missing command parameter".to_string()))?;
254
255 let script = bash_script_with_secure_sudo(command);
256 run_shell_command(&script, ShellSpec::Bash, params["timeout"].as_u64(), ctx).await
257 }
258}
259
260struct KillGroupUnlessFinished {
269 pgid: Option<u32>,
270}
271
272impl KillGroupUnlessFinished {
273 fn new(pid: Option<u32>) -> Self {
274 Self { pgid: pid }
275 }
276
277 fn finished(&mut self) {
279 self.pgid = None;
280 }
281
282 fn kill_now(&mut self) {
284 #[cfg(unix)]
285 if let Some(pgid) = self.pgid.take() {
286 unsafe {
288 libc::killpg(pgid as libc::pid_t, libc::SIGKILL);
289 }
290 }
291 #[cfg(not(unix))]
292 {
293 self.pgid = None;
294 }
295 }
296}
297
298impl Drop for KillGroupUnlessFinished {
299 fn drop(&mut self) {
300 self.kill_now();
301 }
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub(crate) enum ShellSpec {
307 Bash,
308 PowerShell,
309}
310
311impl ShellSpec {
312 fn scope_description(self) -> &'static str {
314 match self {
315 ShellSpec::Bash => "synaps tool: bash",
316 ShellSpec::PowerShell => "synaps tool: powershell",
317 }
318 }
319}
320
321pub(crate) async fn run_shell_command(
326 script: &str,
327 spec: ShellSpec,
328 requested_timeout: Option<u64>,
329 ctx: ToolContext,
330) -> Result<String> {
331 {
332 let requested_timeout = requested_timeout.unwrap_or(ctx.limits.bash_timeout);
333 let timeout_secs = if ctx.limits.bash_max_timeout > 0 {
336 requested_timeout.min(ctx.limits.bash_max_timeout)
337 } else {
338 requested_timeout
339 };
340 let max_output = ctx.limits.max_tool_buffer;
341
342 let (program, args): (std::ffi::OsString, Vec<std::ffi::OsString>) = match spec {
343 ShellSpec::Bash => (bash_program(), vec!["-c".into(), script.into()]),
344 ShellSpec::PowerShell => (
345 powershell_program(),
346 vec!["-NoProfile".into(), "-Command".into(), script.into()],
347 ),
348 };
349 let effective_env = ctx.capabilities.env.as_ref()
350 .map(|env| env.iter().map(|(k, v)| (k.into(), v.into())).collect())
351 .unwrap_or_else(crate::process_scope::inherited_env);
352 let probe_env = effective_env.clone();
353 let probe_cwd = ctx.capabilities.cwd.clone();
354 if ctx.capabilities.launch_cancel.as_ref().is_some_and(|c| c.is_cancelled()) {
357 return Err(RuntimeError::Tool("Command canceled before launch".into()));
358 }
359 let probe_task = tokio::task::spawn_blocking(move || {
360 crate::process_scope::launcher_for_effective_env(
361 &probe_env, probe_cwd.as_deref())
362 });
363 let launcher = if let Some(cancel) = &ctx.capabilities.launch_cancel {
364 tokio::select! {
365 biased;
366 _ = cancel.cancelled() => return Err(RuntimeError::Tool("Command canceled before launch".into())),
367 result = probe_task => result,
368 }
369 } else {
370 probe_task.await
371 }.map_err(|e| RuntimeError::Tool(e.to_string()))?;
372 let (program, args) = match launcher {
373 Some(launcher) => launcher.wrap(spec.scope_description(),
374 crate::process_scope::resolve_program(program, &effective_env,
375 ctx.capabilities.cwd.as_deref()).map_err(|e| RuntimeError::Tool(e.to_string()))?, args),
376 None => (program, args),
377 };
378 let mut cmd = tokio::process::Command::new(program);
379 if let Some(cwd) = ctx.capabilities.cwd.as_deref() {
380 cmd.current_dir(cwd);
381 }
382 cmd.env_clear().envs(effective_env);
383 cmd.args(&args)
384 .stdin(std::process::Stdio::piped())
385 .stdout(std::process::Stdio::piped())
386 .stderr(std::process::Stdio::piped())
387 .kill_on_drop(true);
388
389 #[cfg(unix)]
396 unsafe {
397 cmd.pre_exec(|| {
398 libc::setsid();
399 Ok(())
400 });
401 }
402
403 if ctx.capabilities.launch_cancel.as_ref().is_some_and(|c| c.is_cancelled()) {
404 return Err(RuntimeError::Tool("Command canceled before launch".into()));
405 }
406 let mut child = cmd.spawn().map_err(|e| RuntimeError::Tool(e.to_string()))?;
407 let mut group = KillGroupUnlessFinished::new(child.id());
411
412 let stdout = child
413 .stdout
414 .take()
415 .ok_or_else(|| RuntimeError::Tool("Failed to capture stdout".to_string()))?;
416 let stderr = child
417 .stderr
418 .take()
419 .ok_or_else(|| RuntimeError::Tool("Failed to capture stderr".to_string()))?;
420 let stdin = child
421 .stdin
422 .take()
423 .ok_or_else(|| RuntimeError::Tool("Failed to capture stdin".to_string()))?;
424
425 let (tx_inter, mut rx_inter) =
426 tokio::sync::mpsc::channel::<(bool, String)>(BASH_INTERMEDIARY_CHANNEL_CAPACITY);
427
428 tokio::spawn(forward_output(stdout, false, tx_inter.clone()));
429 tokio::spawn(forward_output(stderr, true, tx_inter.clone()));
430
431 drop(tx_inter);
432
433 let result = tokio::time::timeout(tokio::time::Duration::from_secs(timeout_secs), async {
434 use tokio::io::AsyncWriteExt;
435
436 let mut stdin = stdin;
437 let mut full_output = String::new();
438 let mut stderr_tail = String::new();
439 let mut truncated = false;
440 let mut streamed_bytes = 0usize;
441 let mut redactions: Vec<String> = Vec::new();
442
443 while let Some((is_stderr, mut msg)) = rx_inter.recv().await {
444 BASH_INTERMEDIARY_CONSUMED_BYTES
445 .fetch_add(msg.len() as u64, std::sync::atomic::Ordering::Relaxed);
446 if is_stderr {
447 stderr_tail.push_str(&msg);
448 if stderr_tail.len() > 512 {
449 let keep_from = stderr_tail.len() - 512;
450 if let Some((idx, _)) =
451 stderr_tail.char_indices().find(|(i, _)| *i >= keep_from)
452 {
453 stderr_tail.drain(..idx);
454 }
455 }
456 if let Some(kind) = detect_password_prompt(&stderr_tail) {
457 let prompt_text = stderr_tail.trim().to_string();
458 let secret = match &ctx.capabilities.secret_prompt {
459 Some(prompt) => {
460 prompt
461 .prompt(
462 match kind {
463 PromptKind::Sudo => {
464 "sudo password required".to_string()
465 }
466 PromptKind::Password => "password required".to_string(),
467 },
468 prompt_text.clone(),
469 )
470 .await
471 }
472 None => None,
473 };
474 match secret {
475 Some(mut value) => {
476 let secret_value = value.clone();
477 if !secret_value.is_empty() {
478 redactions.push(secret_value);
479 }
480 value.push('\n');
481 let write_result = stdin.write_all(value.as_bytes()).await;
482 let flush_result = stdin.flush().await;
483 value.zeroize();
485 write_result.map_err(|e| RuntimeError::Tool(e.to_string()))?;
486 flush_result.map_err(|e| RuntimeError::Tool(e.to_string()))?;
487 }
488 None => {
489 group.kill_now();
490 let _ = child.kill().await;
491 return Err(RuntimeError::Tool(
492 "Command canceled while waiting for password".to_string(),
493 ));
494 }
495 }
496 let prompt_len = prompt_text.len();
497 if prompt_len <= msg.len() {
498 let keep_len = msg.len() - prompt_len;
499 msg.truncate(keep_len);
500 } else {
501 msg.clear();
502 }
503 stderr_tail.clear();
504 }
505 }
506
507 for secret in &redactions {
508 if !secret.is_empty() {
509 msg = msg.replace(secret, "[redacted]");
510 }
511 }
512
513 if truncated {
514 continue;
515 }
516
517 let added_all = append_bounded(&mut full_output, &msg, max_output);
518 if let Some(ref txd) = ctx.channels.tx_delta {
519 if streamed_bytes < MAX_STREAMED_DELTA_BYTES {
520 let remaining = MAX_STREAMED_DELTA_BYTES - streamed_bytes;
521 let delta = if msg.len() <= remaining {
522 msg.clone()
523 } else {
524 crate::truncate_str(&msg, remaining).to_string()
525 };
526 streamed_bytes += delta.len();
527 if !delta.is_empty() {
528 txd.send(delta);
529 }
530 }
531 }
532
533 if !added_all {
534 full_output.push_str(&format!("\n\n[output truncated at {}]", max_output));
535 if let Some(ref txd) = ctx.channels.tx_delta {
536 txd.send(format!("\n\n[output truncated at {}]", max_output));
537 }
538 truncated = true;
539 group.kill_now();
540 let _ = child.kill().await;
541 }
542 }
543 let status = child.wait().await;
544 group.finished();
547 let status = status.map_err(|e| RuntimeError::Tool(e.to_string()))?;
548 for secret in &mut redactions {
552 secret.zeroize();
553 }
554 Ok::<_, RuntimeError>((status, full_output, truncated))
555 })
556 .await;
557
558 match result {
559 Ok(Ok((status, output, was_truncated))) => {
560 if status.success() || was_truncated {
561 Ok(output)
562 } else {
563 let mut notice = String::new();
564 if !ctx.capabilities.env_stripped.is_empty() {
567 let mut warned = ctx.capabilities.env_warned.lock().unwrap_or_else(|p| p.into_inner());
568 for name in &ctx.capabilities.env_stripped {
569 if warned.contains(name) {
570 continue;
571 }
572 if references_env_name(script, name) {
575 notice.push_str(&format!(
576 "\nnote: ${name} was stripped from the session env as a secret; \
577 reattach from a shell that has it \
578 (synaps --system … from that shell) \
579 — the daemon never receives credentials."
580 ));
581 warned.insert(name.clone());
582 }
583 }
584 }
585 Err(RuntimeError::Tool(format!(
586 "Command failed (exit {}):\n{}{notice}",
587 status.code().unwrap_or(-1),
588 output
589 )))
590 }
591 }
592 Ok(Err(e)) => Err(RuntimeError::Tool(format!(
593 "Failed to execute command: {}",
594 e
595 ))),
596 Err(_) => Err(RuntimeError::Tool(format!(
597 "Command timed out after {}s",
598 timeout_secs
599 ))),
600 }
601 }
602}
603
604fn references_env_name(script: &str, name: &str) -> bool {
609 let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
610 let mut from = 0;
611 while let Some(pos) = script[from..].find(name) {
612 let start = from + pos;
613 let end = start + name.len();
614 let before_ok = !script[..start].chars().next_back().is_some_and(is_ident);
615 let after_ok = !script[end..].chars().next().is_some_and(is_ident);
616 if before_ok && after_ok {
617 return true;
618 }
619 from = end;
620 }
621 false
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627
628 #[test]
629 fn explicit_child_kills_signal_and_disarm_group_before_reaping() {
630 let source = include_str!("bash.rs");
631 let executor = source.split("pub(crate) async fn run_shell_command").nth(1).unwrap()
632 .split("#[cfg(test)]").next().unwrap();
633 let kills: Vec<_> = executor.match_indices("let _ = child.kill().await;").collect();
634 assert_eq!(kills.len(), 2, "audit every new explicit kill path");
635 for (offset, _) in kills {
636 assert!(executor[..offset].trim_end().ends_with("group.kill_now();"));
637 }
638 assert!(executor.contains("let status = child.wait().await;"));
639 let wait = executor.split("let status = child.wait().await;").nth(1).unwrap();
640 assert!(wait.find("group.finished();").unwrap() < wait.find("status.map_err").unwrap());
641 assert!(executor.find("let mut child = cmd.spawn()").unwrap()
642 < executor.find("let mut group = KillGroupUnlessFinished::new").unwrap());
643 }
644
645 #[tokio::test]
646 async fn canceled_launch_never_runs_payload() {
647 let dir = tempfile::tempdir().unwrap();
648 let marker = dir.path().join("must-not-exist");
649 let token = crate::CancellationToken::new();
650 token.cancel();
651 let mut ctx = create_tool_context();
652 ctx.capabilities.launch_cancel = Some(token);
653 let error = BashTool.execute(json!({"command": format!("touch '{}'", marker.display())}), ctx)
654 .await.unwrap_err().to_string();
655 assert!(error.contains("canceled before launch"), "{error}");
656 assert!(!marker.exists());
657 }
658
659 fn own_cgroup() -> String {
660 std::fs::read_to_string("/proc/self/cgroup")
661 .unwrap_or_default()
662 .trim()
663 .to_string()
664 }
665
666 #[cfg(target_os = "linux")]
671 #[tokio::test]
672 async fn bash_command_gets_its_own_scope_when_available() {
673 if std::env::var("SYNAPS_TEST_REAL_SCOPES").as_deref() != Ok("1") { return; }
674 let out = BashTool
675 .execute(
676 json!({ "command": "cat /proc/self/cgroup; echo pid=$$", "timeout": 30 }),
677 create_tool_context(),
678 )
679 .await
680 .unwrap();
681 let child_cgroup = out.lines().next().unwrap_or_default().trim().to_string();
682 assert!(child_cgroup.starts_with("0::/"), "cgroup v2 line: {out}");
683 if crate::process_scope::launcher_for_env(None).is_some() {
684 assert_ne!(
685 child_cgroup,
686 own_cgroup(),
687 "the command must leave our cgroup"
688 );
689 assert!(child_cgroup.ends_with(".scope"), "{child_cgroup}");
690 assert!(child_cgroup.contains("/app.slice/"), "{child_cgroup}");
691 } else {
692 assert_eq!(child_cgroup, own_cgroup(), "unwrapped fallback");
693 }
694 }
695
696 #[cfg(target_os = "linux")]
699 #[tokio::test]
700 async fn bash_command_with_a_rebuilt_env_lacking_the_user_bus_runs_unwrapped() {
701 let mut ctx = create_tool_context();
702 ctx.capabilities.env = Some(vec![(
703 "PATH".to_string(),
704 std::env::var("PATH").unwrap_or_default(),
705 )]);
706 let out = BashTool
707 .execute(
708 json!({ "command": "cat /proc/self/cgroup", "timeout": 30 }),
709 ctx,
710 )
711 .await
712 .unwrap();
713 assert_eq!(out.lines().next().unwrap_or_default().trim(), own_cgroup());
714 }
715
716 fn processes_named(name: &str) -> Vec<String> {
717 let out = std::process::Command::new("pgrep")
718 .args(["-f", name])
719 .output()
720 .unwrap();
721 String::from_utf8_lossy(&out.stdout)
722 .split_whitespace()
723 .map(str::to_string)
724 .collect()
725 }
726
727 #[cfg(target_os = "linux")]
731 #[tokio::test]
732 async fn bash_timeout_kills_the_whole_process_group() {
733 let marker = format!("synaps-timeout-child-{}", std::process::id());
734 let out = BashTool
735 .execute(
736 json!({
737 "command": format!("(exec -a {marker} sleep 300) & (exec -a {marker} sleep 300); wait"),
738 "timeout": 1
739 }),
740 create_tool_context(),
741 )
742 .await;
743 let text = match out {
744 Ok(s) => s,
745 Err(e) => e.to_string(),
746 };
747 assert!(text.contains("timed out"), "{text}");
748 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
749 while !processes_named(&marker).is_empty() && std::time::Instant::now() < deadline {
750 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
751 }
752 let left = processes_named(&marker);
753 if !left.is_empty() {
754 let _ = std::process::Command::new("pkill")
755 .args(["-f", &marker])
756 .status();
757 }
758 assert!(left.is_empty(), "children survived the timeout: {left:?}");
759 }
760
761 #[cfg(target_os = "linux")]
764 #[tokio::test]
765 async fn bash_normal_exit_leaves_a_background_job_running() {
766 let marker = format!("synaps-background-job-{}", std::process::id());
767 let out = BashTool
768 .execute(
769 json!({
770 "command": format!("(exec -a {marker} sleep 30) >/dev/null 2>&1 & echo started"),
771 "timeout": 10
772 }),
773 create_tool_context(),
774 )
775 .await
776 .unwrap();
777 assert!(out.contains("started"), "{out}");
778 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
779 let alive = processes_named(&marker);
780 let _ = std::process::Command::new("pkill")
781 .args(["-f", &marker])
782 .status();
783 assert!(
784 !alive.is_empty(),
785 "a deliberate background job must survive a normal exit"
786 );
787 }
788
789 #[tokio::test]
790 async fn bash_intermediary_handoff_conserves_bytes_under_large_output() {
791 let before = bash_intermediary_snapshot();
792 let tool = BashTool;
793 let mut ctx = create_tool_context();
794 ctx.limits.max_tool_buffer = 64 * 1024;
795 let result = tool
796 .execute(
797 json!({
798 "command": "python3 -c \"import sys; sys.stdout.write('x' * 1048576)\"",
799 "timeout": 30
800 }),
801 ctx,
802 )
803 .await
804 .unwrap();
805 assert!(result.contains("output truncated"));
806
807 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
817 let (mut produced, mut consumed, mut accepted, mut dropped);
818 loop {
819 let after = bash_intermediary_snapshot();
820 produced = after.produced_bytes - before.produced_bytes;
821 consumed = after.consumed_bytes - before.consumed_bytes;
822 accepted = after.accepted_bytes - before.accepted_bytes;
823 dropped = after.dropped_bytes - before.dropped_bytes;
824 let balanced =
825 produced == consumed + dropped && after.retained_bytes == before.retained_bytes;
826 if balanced || std::time::Instant::now() >= deadline {
827 assert_eq!(
828 produced,
829 consumed + dropped,
830 "handoff bytes must be conserved at quiescence"
831 );
832 assert_eq!(after.retained_bytes, before.retained_bytes);
833 break;
834 }
835 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
836 }
837 assert!(accepted >= consumed);
838 assert!(produced >= 64 * 1024);
839 }
840
841 #[test]
842 fn detects_sudo_password_prompt_without_newline() {
843 assert_eq!(
844 detect_password_prompt("[sudo] password for me: "),
845 Some(PromptKind::Sudo)
846 );
847 }
848
849 #[test]
850 fn sanitizes_terminal_control_sequences_and_nuls() {
851 let cleaned = sanitize_output("ok\x1b[2J\x00done");
852 assert_eq!(cleaned, "okdone");
853 }
854
855 use super::super::test_helpers::create_tool_context;
856 use crate::tools::Tool;
857 use serde_json::json;
858
859 #[test]
860 fn test_bash_tool_schema() {
861 let tool = BashTool;
862 assert_eq!(tool.name(), "bash");
863 assert!(!tool.description().is_empty());
864
865 let params = tool.parameters();
866 assert_eq!(params["type"], "object");
867 assert!(params["properties"].is_object());
868 assert!(params["required"].is_array());
869 }
870
871 #[tokio::test]
872 async fn test_bash_tool_execution() {
873 let tool = BashTool;
874
875 let ctx = create_tool_context();
877 let params = json!({
878 "command": "echo hello"
879 });
880
881 let result = tool.execute(params, ctx).await.unwrap();
882 assert!(result.contains("hello"));
883
884 let ctx = create_tool_context();
886 let params = json!({
887 "command": "sleep 1",
888 "timeout": 2
889 });
890
891 let result = tool.execute(params, ctx).await;
892 assert!(result.is_ok());
894
895 let ctx = create_tool_context();
897 let params = json!({
898 "command": "sleep 3",
899 "timeout": 1
900 });
901
902 let result = tool.execute(params, ctx).await;
903 assert!(result.is_err());
905 assert!(result.unwrap_err().to_string().contains("timed out"));
906 }
907
908 #[tokio::test]
909 async fn test_bash_tool_requested_timeout_is_clamped_by_max_timeout() {
910 let tool = BashTool;
911 let mut ctx = create_tool_context();
912 ctx.limits.bash_max_timeout = 1;
913
914 let params = json!({
915 "command": "sleep 5; echo done",
916 "timeout": 10
917 });
918
919 let result = tool.execute(params, ctx).await;
920 assert!(
921 result.unwrap_err().to_string().contains("timed out"),
922 "requested timeout MUST be clamped by bash_max_timeout"
923 );
924 }
925
926 #[tokio::test]
927 async fn test_bash_fake_sudo_prompt_uses_secret_prompt_and_redacts_password() {
928 let tool = BashTool;
929 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
930 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
931 let channel = crate::tools::output::delta_channel();
932 let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
933
934 let responder = tokio::spawn(async move {
935 let req = prompt_rx
936 .recv()
937 .await
938 .expect("bash should request a secret prompt");
939 assert!(
940 req.prompt.to_ascii_lowercase().contains("password"),
941 "prompt was {:?}",
942 req.prompt
943 );
944 req.response_tx.send(Some("swordfish".to_string())).unwrap();
945 });
946
947 let mut ctx = create_tool_context();
948 ctx.capabilities.secret_prompt = Some(prompt_handle);
949 ctx.channels.tx_delta = Some(delta_tx);
950 let params = json!({
951 "command": "printf '[sudo] password for testuser: ' >&2; read -r pw; if [ \"$pw\" = swordfish ]; then echo AUTH_OK; else echo AUTH_FAIL; fi",
952 "timeout": 30
953 });
954
955 let result = tool.execute(params, ctx).await.unwrap();
956 responder.await.unwrap();
957 let mut streamed = String::new();
958 while let Some(delta) = delta_rx.try_drain() {
959 streamed.push_str(&delta);
960 }
961
962 assert!(result.contains("AUTH_OK"));
963 assert!(!result.contains("swordfish"));
964 assert!(!result.contains("[sudo] password"));
965 assert!(!streamed.contains("[sudo] password"));
966 }
967
968 #[test]
969 fn test_bash_wraps_sudo_to_force_stdin_prompt() {
970 let script = super::bash_script_with_secure_sudo("sudo id");
971
972 assert!(script.contains("sudo()"));
973 assert!(script.contains("command sudo -S -p '[sudo] password required: '"));
974 assert!(script.ends_with("sudo id"));
975 }
976
977 #[cfg(unix)]
983 fn write_fake_sudo(dir: &std::path::Path) {
984 use std::os::unix::fs::PermissionsExt;
985 let sudo_path = dir.join("sudo");
986 std::fs::write(
987 &sudo_path,
988 r#"#!/bin/sh
989prompt=""
990while [ $# -gt 0 ]; do
991 case "$1" in
992 -S) shift ;;
993 -p) prompt="$2"; shift 2 ;;
994 -k) exit 0 ;;
995 *) break ;;
996 esac
997done
998printf '%s' "$prompt" >&2
999read -r _pw
1000exit 1
1001"#,
1002 )
1003 .expect("write fake sudo");
1004 let mut perms = std::fs::metadata(&sudo_path)
1005 .expect("stat fake sudo")
1006 .permissions();
1007 perms.set_mode(0o755);
1008 std::fs::set_permissions(&sudo_path, perms).expect("chmod fake sudo");
1009 }
1010
1011 #[cfg(unix)]
1024 #[tokio::test]
1025 async fn test_bash_sudo_function_prompt_is_intercepted_before_streaming() {
1026 let fake_bin = tempfile::tempdir().expect("tempdir");
1027 write_fake_sudo(fake_bin.path());
1028 let tool = BashTool;
1029 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
1030 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
1031 let channel = crate::tools::output::delta_channel();
1032 let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
1033
1034 let responder = tokio::spawn(async move {
1035 let req = prompt_rx
1036 .recv()
1037 .await
1038 .expect("bash should request a secret prompt");
1039 assert!(
1040 req.prompt.contains("[sudo] password required"),
1041 "prompt was {:?}",
1042 req.prompt
1043 );
1044 req.response_tx
1045 .send(Some("wrong-password-for-test".to_string()))
1046 .unwrap();
1047 });
1048
1049 let mut ctx = create_tool_context();
1050 ctx.capabilities.secret_prompt = Some(prompt_handle);
1051 ctx.channels.tx_delta = Some(delta_tx);
1052 let params = json!({
1053 "command": format!(
1054 "export PATH=\"{}:$PATH\"; sudo -k; sudo -v",
1055 fake_bin.path().display()
1056 ),
1057 "timeout": 30
1058 });
1059
1060 let _ = tool.execute(params, ctx).await;
1061 responder.await.unwrap();
1062 let mut streamed = String::new();
1063 while let Some(delta) = delta_rx.try_drain() {
1064 streamed.push_str(&delta);
1065 }
1066
1067 assert!(
1068 !streamed.contains("[sudo] password required"),
1069 "sudo password prompt leaked into deltas: {streamed:?}"
1070 );
1071 }
1072
1073 #[tokio::test]
1074 async fn test_bash_control_char_output_is_sanitized_and_bounded_in_deltas() {
1075 let tool = BashTool;
1076 let channel = crate::tools::output::delta_channel();
1077 let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
1078 let mut ctx = create_tool_context();
1079 ctx.channels.tx_delta = Some(delta_tx);
1080 ctx.limits.max_tool_buffer = 256;
1081
1082 let params = json!({
1083 "command": "python3 -c \"import sys; sys.stdout.buffer.write(b'clean\\x1b[2J\\x00' + b'A' * 2000); sys.stdout.flush()\"",
1084 "timeout": 30
1085 });
1086
1087 let result = tool.execute(params, ctx).await.unwrap();
1088 let mut streamed = String::new();
1089 while let Some(delta) = delta_rx.try_drain() {
1090 streamed.push_str(&delta);
1091 }
1092
1093 assert!(result.contains("[output truncated at 256]"));
1094 assert!(!result.contains('\u{1b}'));
1095 assert!(!result.contains('\0'));
1096 assert!(!streamed.contains('\u{1b}'));
1097 assert!(!streamed.contains('\0'));
1098 assert!(
1099 streamed.len() <= 2048,
1100 "streamed deltas must be bounded, got {} bytes",
1101 streamed.len()
1102 );
1103 }
1104
1105 #[tokio::test]
1106 async fn test_bash_echoed_secret_is_redacted_from_output() {
1107 let tool = BashTool;
1108 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
1109 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
1110
1111 let responder = tokio::spawn(async move {
1112 let req = prompt_rx
1113 .recv()
1114 .await
1115 .expect("bash should request a secret prompt");
1116 req.response_tx.send(Some("swordfish".to_string())).unwrap();
1117 });
1118
1119 let mut ctx = create_tool_context();
1120 ctx.capabilities.secret_prompt = Some(prompt_handle);
1121 let params = json!({
1122 "command": "printf 'Password: ' >&2; read -r pw; echo seen:$pw",
1123 "timeout": 30
1124 });
1125
1126 let result = tool.execute(params, ctx).await.unwrap();
1127 responder.await.unwrap();
1128
1129 assert!(result.contains("seen:[redacted]"));
1130 assert!(!result.contains("swordfish"));
1131 }
1132
1133 #[tokio::test]
1134 async fn test_bash_sequential_password_prompts_are_each_handled() {
1135 let tool = BashTool;
1136 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
1137 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
1138
1139 let responder = tokio::spawn(async move {
1140 for value in ["first", "second"] {
1141 let req = prompt_rx
1142 .recv()
1143 .await
1144 .expect("bash should request each secret prompt");
1145 assert!(req.prompt.to_ascii_lowercase().contains("password"));
1146 req.response_tx.send(Some(value.to_string())).unwrap();
1147 }
1148 });
1149
1150 let mut ctx = create_tool_context();
1151 ctx.capabilities.secret_prompt = Some(prompt_handle);
1152 let params = json!({
1153 "command": "printf 'Password: ' >&2; read -r one; printf 'Password: ' >&2; read -r two; echo done:$one:$two",
1154 "timeout": 30
1155 });
1156
1157 let result = tool.execute(params, ctx).await.unwrap();
1158 responder.await.unwrap();
1159
1160 assert!(result.contains("done:[redacted]:[redacted]"));
1161 assert!(!result.contains("first"));
1162 assert!(!result.contains("second"));
1163 }
1164
1165 #[tokio::test]
1166 async fn test_bash_password_prompt_cancel_kills_command_without_leaking_partial_secret() {
1167 let tool = BashTool;
1168 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
1169 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
1170
1171 let responder = tokio::spawn(async move {
1172 let req = prompt_rx
1173 .recv()
1174 .await
1175 .expect("bash should request a secret prompt");
1176 req.response_tx.send(None).unwrap();
1177 });
1178
1179 let mut ctx = create_tool_context();
1180 ctx.capabilities.secret_prompt = Some(prompt_handle);
1181 let params = json!({
1182 "command": "printf 'Password: ' >&2; read -r pw; echo should-not-run:$pw",
1183 "timeout": 30
1184 });
1185
1186 let err = tool.execute(params, ctx).await.unwrap_err().to_string();
1187 responder.await.unwrap();
1188
1189 assert!(err.contains("waiting for password"));
1190 assert!(!err.contains("should-not-run"));
1191 }
1192
1193 #[tokio::test]
1194 async fn test_bash_binary_output_is_sanitized() {
1195 let tool = BashTool;
1196 let ctx = create_tool_context();
1197 let params = json!({
1198 "command": "python3 -c \"import sys; sys.stdout.buffer.write(bytes(range(32)) + b'visible')\"",
1199 "timeout": 30
1200 });
1201
1202 let result = tool.execute(params, ctx).await.unwrap();
1203
1204 assert!(result.contains("visible"));
1205 assert!(!result.contains('\0'));
1206 assert!(!result.contains('\u{1b}'));
1207 }
1208
1209 #[tokio::test]
1210 async fn test_bash_tool_timeout() {
1211 let tool = BashTool;
1212 let ctx = create_tool_context();
1213
1214 let params = json!({
1215 "command": "sleep 10",
1216 "timeout": 1
1217 });
1218
1219 let result = tool.execute(params, ctx).await;
1220
1221 assert!(result.is_err());
1223 let error = result.unwrap_err().to_string();
1224 assert!(error.contains("timed out"));
1225 }
1226
1227 #[tokio::test]
1228 async fn test_bash_tool_failure() {
1229 let tool = BashTool;
1230 let ctx = create_tool_context();
1231
1232 let params = json!({
1233 "command": "exit 1"
1234 });
1235
1236 let result = tool.execute(params, ctx).await;
1237
1238 assert!(result.is_err());
1240 let error = result.unwrap_err().to_string();
1241 assert!(error.contains("failed") || error.contains("exit"));
1242 }
1243
1244 #[cfg(unix)]
1245 #[tokio::test]
1246 async fn bash_honours_capability_cwd_when_set() {
1247 let tmp = tempfile::TempDir::new().unwrap();
1248 let expected = tmp.path().canonicalize().unwrap();
1249 let mut ctx = create_tool_context();
1250 ctx.capabilities.cwd = Some(tmp.path().to_path_buf());
1251 let out = BashTool
1252 .execute(json!({"command": "pwd -P"}), ctx)
1253 .await
1254 .unwrap();
1255 assert_eq!(out.trim(), expected.to_string_lossy());
1256 }
1257
1258 #[cfg(unix)]
1259 #[tokio::test]
1260 async fn bash_inherits_process_cwd_when_capability_cwd_is_none() {
1261 let expected = std::env::current_dir().unwrap().canonicalize().unwrap();
1262 let ctx = create_tool_context();
1263 assert!(ctx.capabilities.cwd.is_none());
1264 let out = BashTool
1265 .execute(json!({"command": "pwd -P"}), ctx)
1266 .await
1267 .unwrap();
1268 assert_eq!(out.trim(), expected.to_string_lossy());
1269 }
1270
1271 #[test]
1272 fn references_env_name_is_word_bounded() {
1273 assert!(references_env_name("echo ${GH_TOKEN:?}", "GH_TOKEN"));
1274 assert!(references_env_name("echo $GH_TOKEN", "GH_TOKEN"));
1275 assert!(references_env_name("GH_TOKEN=x gh auth", "GH_TOKEN"));
1276 assert!(references_env_name("$env:GH_TOKEN", "GH_TOKEN"));
1277 assert!(!references_env_name("echo $GH_TOKEN_FILE", "GH_TOKEN"));
1278 assert!(!references_env_name("echo $MY_GH_TOKEN", "GH_TOKEN"));
1279 assert!(!references_env_name("gh auth status", "GH_TOKEN"));
1280 }
1281
1282 #[cfg(unix)]
1284 #[tokio::test]
1285 async fn bash_notice_on_stripped_secret_reference() {
1286 let warned = std::sync::Arc::new(std::sync::Mutex::new(
1287 std::collections::HashSet::<String>::new(),
1288 ));
1289 let mut ctx = create_tool_context();
1291 ctx.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1292 ctx.capabilities.env_warned = warned.clone();
1293 let err = BashTool
1294 .execute(json!({"command": "echo ${GH_TOKEN:?} 2>&1; exit 1"}), ctx)
1295 .await
1296 .unwrap_err();
1297 let msg = format!("{err}");
1298 assert!(
1299 msg.contains("note: $GH_TOKEN was stripped"),
1300 "notice must name the var: {msg}"
1301 );
1302
1303 let mut ctx2 = create_tool_context();
1305 ctx2.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1306 ctx2.capabilities.env_warned = warned.clone();
1307 let out = BashTool
1308 .execute(json!({"command": "echo $GH_TOKEN; exit 0"}), ctx2)
1309 .await
1310 .unwrap();
1311 assert!(
1312 !out.contains("note:"),
1313 "zero exit must not produce a notice: {out}"
1314 );
1315
1316 let mut ctx3 = create_tool_context();
1318 ctx3.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1319 ctx3.capabilities.env_warned = warned.clone();
1320 let err2 = BashTool
1321 .execute(json!({"command": "echo ${GH_TOKEN}; exit 1"}), ctx3)
1322 .await
1323 .unwrap_err();
1324 let msg2 = format!("{err2}");
1325 assert!(
1326 !msg2.contains("note: $GH_TOKEN was stripped"),
1327 "dedup must suppress the second notice: {msg2}"
1328 );
1329 }
1330}
1331
1332#[cfg(test)]
1335mod utf8_chunk_tests {
1336 use super::*;
1337 use crate::tools::Tool;
1338 use serde_json::json;
1339 use std::collections::VecDeque;
1340 use std::pin::Pin;
1341 use std::task::{Context, Poll};
1342
1343 struct ChunkedReader(VecDeque<Vec<u8>>);
1345
1346 impl tokio::io::AsyncRead for ChunkedReader {
1347 fn poll_read(
1348 mut self: Pin<&mut Self>,
1349 _cx: &mut Context<'_>,
1350 buf: &mut tokio::io::ReadBuf<'_>,
1351 ) -> Poll<std::io::Result<()>> {
1352 if let Some(chunk) = self.0.pop_front() {
1353 assert!(chunk.len() <= buf.remaining(), "test chunk larger than read buffer");
1354 buf.put_slice(&chunk);
1355 }
1356 Poll::Ready(Ok(()))
1357 }
1358 }
1359
1360 fn consumed(msg: &str) {
1364 BASH_INTERMEDIARY_CONSUMED_BYTES
1365 .fetch_add(msg.len() as u64, std::sync::atomic::Ordering::Relaxed);
1366 }
1367
1368 async fn forward_chunks(chunks: Vec<Vec<u8>>, is_stderr: bool) -> String {
1369 let (tx, mut rx) = tokio::sync::mpsc::channel(1024);
1370 forward_output(ChunkedReader(chunks.into()), is_stderr, tx).await;
1371 let mut out = String::new();
1372 while let Some((stderr, msg)) = rx.recv().await {
1373 consumed(&msg);
1374 assert_eq!(stderr, is_stderr);
1375 out.push_str(&msg);
1376 }
1377 out
1378 }
1379
1380 #[tokio::test]
1381 async fn characters_split_at_every_offset_survive_both_streams() {
1382 for ch in ['é', '▀', '😀'] {
1383 let text = format!("a{ch}b{ch}{ch}c");
1384 let bytes = text.as_bytes();
1385 for split in 1..bytes.len() {
1386 for is_stderr in [false, true] {
1387 let out = forward_chunks(
1388 vec![bytes[..split].to_vec(), bytes[split..].to_vec()],
1389 is_stderr,
1390 )
1391 .await;
1392 assert!(!out.contains('\u{FFFD}'), "{ch:?} split at {split}: {out:?}");
1393 assert_eq!(out.as_bytes(), bytes, "{ch:?} split at {split}");
1394 }
1395 }
1396 }
1397 }
1398
1399 #[tokio::test]
1400 async fn one_byte_reads_reassemble_every_character() {
1401 let text = "é▀😀 mixed ascii ✓ 日本語";
1402 let chunks = text.bytes().map(|b| vec![b]).collect();
1403 assert_eq!(forward_chunks(chunks, false).await, text);
1404 }
1405
1406 #[tokio::test]
1407 async fn genuinely_invalid_bytes_still_become_replacement_characters() {
1408 assert_eq!(forward_chunks(vec![b"ok\xffok".to_vec()], false).await, "ok\u{FFFD}ok");
1409 assert_eq!(
1411 forward_chunks(vec![b"a\xe2".to_vec(), b"Xb".to_vec()], false).await,
1412 "a\u{FFFD}Xb"
1413 );
1414 assert_eq!(
1416 forward_chunks(vec![b"a".to_vec(), b"\x80b".to_vec()], false).await,
1417 "a\u{FFFD}b"
1418 );
1419 }
1420
1421 #[tokio::test]
1422 async fn incomplete_sequence_at_eof_is_flushed_not_dropped() {
1423 assert_eq!(forward_chunks(vec![b"ab\xe2\x96".to_vec()], false).await, "ab\u{FFFD}");
1424 assert_eq!(
1425 forward_chunks(vec![b"ab".to_vec(), b"\xf0\x9f".to_vec()], true).await,
1426 "ab\u{FFFD}"
1427 );
1428 }
1429
1430 #[tokio::test]
1433 async fn prompt_without_newline_is_forwarded_before_eof() {
1434 let (mut writer, reader) = tokio::io::duplex(4096);
1435 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1436 let task = tokio::spawn(forward_output(reader, true, tx));
1437 use tokio::io::AsyncWriteExt;
1438 writer.write_all("[sudo] password for x: ".as_bytes()).await.unwrap();
1439 let (is_stderr, msg) =
1440 tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
1441 .await
1442 .expect("prompt must arrive without EOF")
1443 .unwrap();
1444 consumed(&msg);
1445 assert!(is_stderr);
1446 assert_eq!(detect_password_prompt(&msg), Some(PromptKind::Sudo), "{msg:?}");
1447 drop(writer);
1448 task.await.unwrap();
1449 }
1450
1451 #[tokio::test]
1454 async fn bash_tool_output_over_1kib_keeps_multibyte_characters_whole() {
1455 let channel = crate::tools::output::delta_channel();
1456 let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
1457 let mut ctx = super::super::test_helpers::create_tool_context();
1458 ctx.channels.tx_delta = Some(delta_tx);
1459 let result = BashTool
1460 .execute(json!({"command": "printf '▀%.0s' {1..2000}", "timeout": 30}), ctx)
1461 .await
1462 .unwrap();
1463 let mut streamed = String::new();
1464 while let Some(delta) = delta_rx.try_drain() {
1465 streamed.push_str(&delta);
1466 }
1467 let expected = "▀".repeat(2000);
1468 assert!(!result.contains('\u{FFFD}'), "result has U+FFFD");
1469 assert_eq!(result, expected);
1470 assert!(!streamed.contains('\u{FFFD}'), "deltas have U+FFFD");
1471 assert!(expected.starts_with(&streamed) && !streamed.is_empty());
1472
1473 let ctx = super::super::test_helpers::create_tool_context();
1474 let err = BashTool
1475 .execute(
1476 json!({"command": "python3 -c \"import sys; sys.stderr.write(('▀'*119+'\\n')*45)\"; exit 3", "timeout": 30}),
1477 ctx,
1478 )
1479 .await
1480 .unwrap_err()
1481 .to_string();
1482 assert!(!err.contains('\u{FFFD}'), "stderr has U+FFFD: {err}");
1483 assert!(err.contains(&("▀".repeat(119) + "\n").repeat(45)), "{err}");
1484 }
1485}