1use super::{strip_ansi, Tool, ToolContext};
2use crate::{Result, RuntimeError};
3use serde_json::{json, Value};
4use zeroize::Zeroizing;
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
25impl BashIntermediarySnapshot {
26 pub fn is_settled(&self) -> bool {
29 self.produced_bytes == self.consumed_bytes + self.dropped_bytes
30 }
31}
32
33pub fn bash_intermediary_snapshot() -> BashIntermediarySnapshot {
38 use std::sync::atomic::Ordering::SeqCst;
39 let consumed = BASH_INTERMEDIARY_CONSUMED_BYTES.load(SeqCst);
40 let dropped = BASH_INTERMEDIARY_DROPPED_BYTES.load(SeqCst);
41 let accepted = BASH_INTERMEDIARY_ACCEPTED_BYTES.load(SeqCst);
42 let produced = BASH_INTERMEDIARY_PRODUCED_BYTES.load(SeqCst);
43 BashIntermediarySnapshot {
44 produced_bytes: produced,
45 accepted_bytes: accepted,
46 consumed_bytes: consumed,
47 dropped_bytes: dropped,
48 retained_bytes: produced.saturating_sub(consumed).saturating_sub(dropped),
49 }
50}
51
52#[cfg(test)]
55async fn wait_handoff_settled() -> BashIntermediarySnapshot {
56 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
57 loop {
58 let now = bash_intermediary_snapshot();
59 if now.is_settled() {
60 return now;
61 }
62 assert!(std::time::Instant::now() < deadline, "handoff bytes never settled: {now:?}");
63 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
64 }
65}
66
67pub struct BashTool;
68
69struct HandoffChunk(Option<String>);
75
76impl HandoffChunk {
77 fn new(text: String) -> Self {
78 BASH_INTERMEDIARY_PRODUCED_BYTES
79 .fetch_add(text.len() as u64, std::sync::atomic::Ordering::SeqCst);
80 Self(Some(text))
81 }
82
83 fn len(&self) -> usize {
84 self.0.as_ref().map_or(0, String::len)
85 }
86
87 fn consume(mut self) -> String {
88 let text = self.0.take().unwrap_or_default();
89 BASH_INTERMEDIARY_CONSUMED_BYTES
90 .fetch_add(text.len() as u64, std::sync::atomic::Ordering::SeqCst);
91 text
92 }
93}
94
95impl Drop for HandoffChunk {
96 fn drop(&mut self) {
97 if let Some(text) = self.0.take() {
98 BASH_INTERMEDIARY_DROPPED_BYTES
99 .fetch_add(text.len() as u64, std::sync::atomic::Ordering::SeqCst);
100 }
101 }
102}
103
104const READ_CHUNK_SIZE: usize = 1024;
105const MAX_STREAMED_DELTA_BYTES: usize = 16 * 1024;
106
107pub(crate) type Redactions = std::sync::Arc<std::sync::Mutex<Vec<Zeroizing<String>>>>;
110
111#[cfg(target_os = "linux")]
113pub(crate) type Askpass = super::sudo_askpass::broker::PreparedBroker;
114#[cfg(not(target_os = "linux"))]
115pub(crate) type Askpass = std::convert::Infallible;
116
117fn sanitize_output(input: &str) -> String {
118 let stripped = strip_ansi(input);
119 stripped
120 .chars()
121 .filter(|ch| {
122 *ch == '\n' || *ch == '\r' || *ch == '\t' || (!ch.is_control() && *ch != '\u{7f}')
123 })
124 .collect()
125}
126
127async fn forward_output<R>(
133 mut reader: R,
134 is_stderr: bool,
135 tx: tokio::sync::mpsc::Sender<(bool, HandoffChunk)>,
136) where
137 R: tokio::io::AsyncRead + Unpin,
138{
139 use tokio::io::AsyncReadExt;
140 let mut buf = vec![0u8; READ_CHUNK_SIZE];
141 let mut decoder = super::utf8_stream::Utf8StreamDecoder::new();
142 loop {
143 let (text, eof) = match reader.read(&mut buf).await {
144 Ok(0) | Err(_) => (decoder.finish(), true),
145 Ok(n) => (decoder.decode(&buf[..n]), false),
146 };
147 let msg = sanitize_output(&text);
148 if !msg.is_empty() {
149 let chunk = HandoffChunk::new(msg);
150 let Ok(permit) = tx.reserve().await else {
154 break;
155 };
156 BASH_INTERMEDIARY_ACCEPTED_BYTES
157 .fetch_add(chunk.len() as u64, std::sync::atomic::Ordering::SeqCst);
158 permit.send((is_stderr, chunk));
159 }
160 if eof {
161 break;
162 }
163 }
164}
165
166fn append_bounded(output: &mut String, text: &str, max_output: usize) -> bool {
167 if output.len() >= max_output {
168 return false;
169 }
170 let remaining = max_output - output.len();
171 if text.len() <= remaining {
172 output.push_str(text);
173 true
174 } else {
175 let mut end = remaining;
176 while end > 0 && !text.is_char_boundary(end) {
177 end -= 1;
178 }
179 output.push_str(&text[..end]);
180 false
181 }
182}
183
184fn output_limit_notice(max_output: usize) -> String {
189 format!(
190 "\n\n[output truncated at {max_output} bytes: the command was stopped at this limit \
191 before it finished, so its exit status is unknown. Redirect large output to a file \
192 (e.g. cmd > out.log 2>&1) and read only the parts you need.]"
193 )
194}
195
196fn timed_out_message(
203 timeout_secs: u64,
204 requested_secs: Option<u64>,
205 output: &str,
206 max_output: usize,
207) -> String {
208 let mut message = format!("Command timed out after {timeout_secs}s and was stopped");
209 if let Some(requested_secs) = requested_secs.filter(|&secs| secs > timeout_secs) {
210 message.push_str(&format!(
211 " ({requested_secs}s was requested; the maximum is {timeout_secs}s)"
212 ));
213 }
214 message.push('.');
215 if output.is_empty() {
216 message.push_str(
217 " No output was captured before the timeout; \
218 output the program had buffered but not flushed is lost.",
219 );
220 return message;
221 }
222 let tail = tail_on_char_boundary(output, max_output);
223 if tail.len() == output.len() {
224 message.push_str(" Output before the timeout:\n");
225 } else {
226 message.push_str(&format!(
227 " Output before the timeout (last {} of {} bytes):\n",
228 tail.len(),
229 output.len()
230 ));
231 }
232 message.push_str(tail);
233 message
234}
235
236fn tail_on_char_boundary(text: &str, max_bytes: usize) -> &str {
239 if text.len() <= max_bytes {
240 return text;
241 }
242 let mut start = text.len() - max_bytes;
243 while !text.is_char_boundary(start) {
244 start += 1;
245 }
246 &text[start..]
247}
248
249pub(crate) fn bash_program() -> std::ffi::OsString {
255 #[cfg(unix)]
256 {
257 "bash".into()
258 }
259 #[cfg(windows)]
260 {
261 let on_path = std::env::var_os("PATH")
262 .map(|p| std::env::split_paths(&p).any(|d| d.join("bash.exe").is_file()))
263 .unwrap_or(false);
264 if on_path {
265 return "bash".into();
266 }
267 for var in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
268 if let Some(pf) = std::env::var_os(var) {
269 let candidate = std::path::Path::new(&pf).join(r"Git\bin\bash.exe");
270 if candidate.is_file() {
271 return candidate.into_os_string();
272 }
273 }
274 }
275 let wsl = std::path::Path::new(r"C:\Windows\System32\bash.exe");
276 if wsl.is_file() {
277 return wsl.as_os_str().to_os_string();
278 }
279 "bash".into()
281 }
282}
283
284pub(crate) fn bash_script_with_secure_sudo(command: &str, askpass: bool) -> String {
292 #[cfg(unix)]
293 let call = if askpass {
294 r#"command sudo -A "$@""#
295 } else {
296 r#"command sudo -n "$@""#
297 };
298 #[cfg(not(unix))]
299 let call = {
300 let _ = askpass;
301 r#"command sudo -S -p '[sudo] password required: ' "$@""#
302 };
303 format!(
304 r#"sudo() {{
305 {call}
306}}
307{command}"#
308 )
309}
310
311#[cfg(windows)]
315pub(crate) fn powershell_program() -> std::ffi::OsString {
316 let on_path = |exe: &str| {
317 std::env::var_os("PATH")
318 .map(|p| std::env::split_paths(&p).any(|d| d.join(exe).is_file()))
319 .unwrap_or(false)
320 };
321 if on_path("pwsh.exe") {
322 return "pwsh".into();
323 }
324 for var in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
325 if let Some(pf) = std::env::var_os(var) {
326 let candidate = std::path::Path::new(&pf).join(r"PowerShell\7\pwsh.exe");
327 if candidate.is_file() {
328 return candidate.into_os_string();
329 }
330 }
331 }
332 "powershell".into()
334}
335
336#[cfg(not(windows))]
339pub(crate) fn powershell_program() -> std::ffi::OsString {
340 "pwsh".into()
341}
342
343#[async_trait::async_trait]
344impl Tool for BashTool {
345 fn origin(&self) -> crate::tools::ToolOrigin {
346 crate::tools::ToolOrigin::Builtin
347 }
348
349 fn effect(&self) -> crate::tools::catalog::ToolEffect {
352 crate::tools::catalog::ToolEffect::NonIdempotent
353 }
354
355 fn name(&self) -> &str {
356 "bash"
357 }
358
359 fn description(&self) -> &str {
360 "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. Stdin is empty. On Linux, if sudo needs a password, the user is asked in the TUI, shown the command sudo will run, and the password goes only to sudo, never to the model. Other password prompts are not answered."
361 }
362
363 fn parameters(&self) -> Value {
364 json!({
365 "type": "object",
366 "properties": {
367 "command": {
368 "type": "string",
369 "description": "The bash command to execute"
370 },
371 "timeout": {
372 "type": "integer",
373 "description": "Timeout in seconds (default: 30). Use a larger value for long-running commands."
374 }
375 },
376 "required": ["command"]
377 })
378 }
379
380 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
381 let command = params["command"]
382 .as_str()
383 .ok_or_else(|| RuntimeError::Tool("Missing command parameter".to_string()))?;
384
385 run_bash(command, params["timeout"].as_u64(), ctx, prepare_askpass()).await
386 }
387}
388
389fn prepare_askpass() -> Option<Askpass> {
391 #[cfg(target_os = "linux")]
392 {
393 use super::sudo_askpass::broker::{AskpassConfig, PreparedBroker};
394 let config = AskpassConfig::production()?;
395 match PreparedBroker::prepare(&config) {
396 Ok(broker) => Some(broker),
397 Err(e) => {
398 tracing::warn!("sudo askpass unavailable for this command: {e}");
399 None
400 }
401 }
402 }
403 #[cfg(not(target_os = "linux"))]
404 {
405 None
406 }
407}
408
409pub(crate) async fn run_bash(
410 command: &str,
411 timeout: Option<u64>,
412 ctx: ToolContext,
413 askpass: Option<Askpass>,
414) -> Result<String> {
415 let script = bash_script_with_secure_sudo(command, askpass.is_some());
416 run_shell_command(&script, ShellSpec::Bash, timeout, ctx, askpass).await
417}
418
419struct KillGroupUnlessFinished {
428 pgid: Option<u32>,
429}
430
431impl KillGroupUnlessFinished {
432 fn new(pid: Option<u32>) -> Self {
433 Self { pgid: pid }
434 }
435
436 fn finished(&mut self) {
438 self.pgid = None;
439 }
440
441 fn kill_now(&mut self) {
443 #[cfg(unix)]
444 if let Some(pgid) = self.pgid.take() {
445 unsafe {
447 libc::killpg(pgid as libc::pid_t, libc::SIGKILL);
448 }
449 }
450 #[cfg(not(unix))]
451 {
452 self.pgid = None;
453 }
454 }
455}
456
457impl Drop for KillGroupUnlessFinished {
458 fn drop(&mut self) {
459 self.kill_now();
460 }
461}
462
463#[derive(Debug, Clone, Copy, PartialEq, Eq)]
465pub(crate) enum ShellSpec {
466 Bash,
467 PowerShell,
468}
469
470impl ShellSpec {
471 fn invocation(self, script: &str) -> (std::ffi::OsString, Vec<std::ffi::OsString>) {
481 match self {
482 ShellSpec::Bash => (bash_program(), vec!["-c".into(), script.into()]),
483 ShellSpec::PowerShell => (
484 powershell_program(),
485 ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script]
486 .into_iter()
487 .map(Into::into)
488 .collect(),
489 ),
490 }
491 }
492
493 fn scope_description(self) -> &'static str {
495 match self {
496 ShellSpec::Bash => "synaps tool: bash",
497 ShellSpec::PowerShell => "synaps tool: powershell",
498 }
499 }
500}
501
502pub(crate) async fn run_shell_command(
509 script: &str,
510 spec: ShellSpec,
511 requested_timeout: Option<u64>,
512 ctx: ToolContext,
513 askpass: Option<Askpass>,
514) -> Result<String> {
515 {
516 let timeout_secs = requested_timeout.unwrap_or(ctx.limits.bash_timeout);
517 let timeout_secs = if ctx.limits.bash_max_timeout > 0 {
520 timeout_secs.min(ctx.limits.bash_max_timeout)
521 } else {
522 timeout_secs
523 };
524 let max_output = ctx.limits.max_tool_buffer;
525
526 let (program, args) = spec.invocation(script);
527 let effective_env = ctx.capabilities.env.as_ref()
528 .map(|env| env.spawn_vars().map(|(k, v)| (k.into(), v.into())).collect())
529 .unwrap_or_else(crate::process_scope::inherited_env);
530 let probe_env = effective_env.clone();
531 let probe_cwd = ctx.capabilities.cwd.clone();
532 if ctx.capabilities.launch_cancel.as_ref().is_some_and(|c| c.is_cancelled()) {
535 return Err(RuntimeError::Tool("Command canceled before launch".into()));
536 }
537 let probe_task = tokio::task::spawn_blocking(move || {
538 crate::process_scope::launcher_for_effective_env(
539 &probe_env, probe_cwd.as_deref(), crate::process_scope::ScopeKind::Tool)
540 });
541 let launcher = if let Some(cancel) = &ctx.capabilities.launch_cancel {
542 tokio::select! {
543 biased;
544 _ = cancel.cancelled() => return Err(RuntimeError::Tool("Command canceled before launch".into())),
545 result = probe_task => result,
546 }
547 } else {
548 probe_task.await
549 }.map_err(|e| RuntimeError::Tool(e.to_string()))?;
550 let (program, args) = match launcher {
551 Some(launcher) => launcher.wrap(spec.scope_description(),
552 crate::process_scope::resolve_program(program, &effective_env,
553 ctx.capabilities.cwd.as_deref()).map_err(|e| RuntimeError::Tool(e.to_string()))?, args),
554 None => (program, args),
555 };
556 let mut cmd = tokio::process::Command::new(program);
557 if let Some(cwd) = ctx.capabilities.cwd.as_deref() {
558 cmd.current_dir(cwd);
559 }
560 cmd.env_clear().envs(effective_env);
561 #[cfg(target_os = "linux")]
562 if let Some(broker) = &askpass {
563 cmd.env("SUDO_ASKPASS", broker.helper_path());
564 }
565 cmd.args(&args)
566 .stdin(std::process::Stdio::null())
567 .stdout(std::process::Stdio::piped())
568 .stderr(std::process::Stdio::piped())
569 .kill_on_drop(true);
570
571 #[cfg(unix)]
578 unsafe {
579 cmd.pre_exec(|| {
580 libc::setsid();
581 Ok(())
582 });
583 }
584
585 if ctx.capabilities.launch_cancel.as_ref().is_some_and(|c| c.is_cancelled()) {
586 return Err(RuntimeError::Tool("Command canceled before launch".into()));
587 }
588 let mut child = cmd.spawn().map_err(|e| RuntimeError::Tool(e.to_string()))?;
589 let mut group = KillGroupUnlessFinished::new(child.id());
593
594 let stdout = child
595 .stdout
596 .take()
597 .ok_or_else(|| RuntimeError::Tool("Failed to capture stdout".to_string()))?;
598 let stderr = child
599 .stderr
600 .take()
601 .ok_or_else(|| RuntimeError::Tool("Failed to capture stderr".to_string()))?;
602 let redactions = Redactions::default();
604 let password_cancel = crate::CancellationToken::new();
605 #[cfg(target_os = "linux")]
606 let _askpass = askpass.and_then(|broker| {
607 broker
608 .start(
609 child.id(),
610 ctx.capabilities.secret_prompt.clone(),
611 redactions.clone(),
612 password_cancel.clone(),
613 )
614 .map_err(|e| tracing::warn!("sudo askpass unavailable for this command: {e}"))
615 .ok()
616 });
617 #[cfg(not(target_os = "linux"))]
618 let _ = askpass;
619
620 let (tx_inter, mut rx_inter) =
621 tokio::sync::mpsc::channel::<(bool, HandoffChunk)>(BASH_INTERMEDIARY_CHANNEL_CAPACITY);
622
623 tokio::spawn(forward_output(stdout, false, tx_inter.clone()));
624 tokio::spawn(forward_output(stderr, true, tx_inter.clone()));
625
626 drop(tx_inter);
627
628 let mut full_output = String::new();
631 let result = tokio::time::timeout(tokio::time::Duration::from_secs(timeout_secs), async {
632 let mut truncated = false;
633 let mut streamed_bytes = 0usize;
634
635 loop {
636 let item = tokio::select! {
637 biased;
638 _ = password_cancel.cancelled() => {
641 group.kill_now();
642 let _ = child.kill().await;
643 return Err(RuntimeError::Tool(
644 "Command canceled while waiting for password".to_string(),
645 ));
646 }
647 item = rx_inter.recv() => item,
648 };
649 let Some((_is_stderr, chunk)) = item else {
650 break;
651 };
652 let mut msg = chunk.consume();
653 for secret in redactions.lock().unwrap_or_else(|p| p.into_inner()).iter() {
654 if !secret.is_empty() {
655 msg = msg.replace(secret.as_str(), "[redacted]");
656 }
657 }
658
659 if truncated {
660 continue;
661 }
662
663 let kept_from = full_output.len();
664 let added_all = append_bounded(&mut full_output, &msg, max_output);
665 if let Some(ref txd) = ctx.channels.tx_delta {
666 if streamed_bytes < MAX_STREAMED_DELTA_BYTES {
667 let kept = &full_output[kept_from..];
670 let remaining = MAX_STREAMED_DELTA_BYTES - streamed_bytes;
671 let delta = crate::truncate_str(kept, remaining).to_string();
672 streamed_bytes += delta.len();
673 if !delta.is_empty() {
674 txd.send(delta);
675 }
676 }
677 }
678
679 if !added_all {
680 let notice = output_limit_notice(max_output);
683 full_output.push_str(¬ice);
684 if let Some(ref txd) = ctx.channels.tx_delta {
685 txd.send(notice);
686 }
687 truncated = true;
688 group.kill_now();
689 let _ = child.kill().await;
690 }
691 }
692 let status = child.wait().await;
693 group.finished();
696 let status = status.map_err(|e| RuntimeError::Tool(e.to_string()))?;
697 redactions.lock().unwrap_or_else(|p| p.into_inner()).clear();
701 Ok::<_, RuntimeError>((status, std::mem::take(&mut full_output), truncated))
702 })
703 .await;
704
705 match result {
706 Ok(Ok((status, output, was_truncated))) => {
707 if status.success() || was_truncated {
708 Ok(output)
709 } else {
710 let notice = ctx.capabilities.stripped_env_notices(|name| references_env_name(script, name));
715 Err(RuntimeError::Tool(format!(
716 "Command failed (exit {}):\n{}{notice}",
717 status.code().unwrap_or(-1),
718 output
719 )))
720 }
721 }
722 Ok(Err(e)) => Err(RuntimeError::Tool(format!(
723 "Failed to execute command: {}",
724 e
725 ))),
726 Err(_) => Err(RuntimeError::Tool(timed_out_message(
730 timeout_secs,
731 requested_timeout,
732 &full_output,
733 max_output,
734 ))),
735 }
736 }
737}
738
739fn references_env_name(script: &str, name: &str) -> bool {
744 let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
745 let mut from = 0;
746 while let Some(pos) = script[from..].find(name) {
747 let start = from + pos;
748 let end = start + name.len();
749 let before_ok = !script[..start].chars().next_back().is_some_and(is_ident);
750 let after_ok = !script[end..].chars().next().is_some_and(is_ident);
751 if before_ok && after_ok {
752 return true;
753 }
754 from = end;
755 }
756 false
757}
758
759#[cfg(test)]
760mod tests {
761 use super::*;
762
763 #[test]
767 fn powershell_runs_with_execution_policy_bypass() {
768 let (_, args) = ShellSpec::PowerShell.invocation(r".\test.ps1");
769 let args: Vec<_> = args.iter().map(|a| a.to_string_lossy().into_owned()).collect();
770 assert_eq!(args, ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", r".\test.ps1"]);
771 let (_, args) = ShellSpec::Bash.invocation("echo hi");
772 assert_eq!(args, [std::ffi::OsString::from("-c"), "echo hi".into()]);
773 }
774
775 #[test]
776 fn explicit_child_kills_signal_and_disarm_group_before_reaping() {
777 let source = include_str!("bash.rs");
778 let executor = source.split("pub(crate) async fn run_shell_command").nth(1).unwrap()
779 .split("#[cfg(test)]").next().unwrap();
780 let kills: Vec<_> = executor.match_indices("let _ = child.kill().await;").collect();
781 assert_eq!(kills.len(), 2, "audit every new explicit kill path");
782 for (offset, _) in kills {
783 assert!(executor[..offset].trim_end().ends_with("group.kill_now();"));
784 }
785 assert!(executor.contains("let status = child.wait().await;"));
786 let wait = executor.split("let status = child.wait().await;").nth(1).unwrap();
787 assert!(wait.find("group.finished();").unwrap() < wait.find("status.map_err").unwrap());
788 assert!(executor.find("let mut child = cmd.spawn()").unwrap()
789 < executor.find("let mut group = KillGroupUnlessFinished::new").unwrap());
790 }
791
792 #[tokio::test]
793 async fn canceled_launch_never_runs_payload() {
794 let dir = tempfile::tempdir().unwrap();
795 let marker = dir.path().join("must-not-exist");
796 let token = crate::CancellationToken::new();
797 token.cancel();
798 let mut ctx = create_tool_context();
799 ctx.capabilities.launch_cancel = Some(token);
800 let error = BashTool.execute(json!({"command": format!("touch '{}'", marker.display())}), ctx)
801 .await.unwrap_err().to_string();
802 assert!(error.contains("canceled before launch"), "{error}");
803 assert!(!marker.exists());
804 }
805
806 #[cfg(target_os = "linux")]
807 fn own_cgroup() -> String {
808 std::fs::read_to_string("/proc/self/cgroup")
809 .unwrap_or_default()
810 .trim()
811 .to_string()
812 }
813
814 #[cfg(target_os = "linux")]
819 #[tokio::test]
820 async fn bash_command_gets_its_own_scope_when_available() {
821 if std::env::var("SYNAPS_TEST_REAL_SCOPES").as_deref() != Ok("1") { return; }
822 let out = BashTool
823 .execute(
824 json!({ "command": "cat /proc/self/cgroup; echo pid=$$", "timeout": 30 }),
825 create_tool_context(),
826 )
827 .await
828 .unwrap();
829 let child_cgroup = out.lines().next().unwrap_or_default().trim().to_string();
830 assert!(child_cgroup.starts_with("0::/"), "cgroup v2 line: {out}");
831 if crate::process_scope::launcher_for_env(None).is_some() {
832 assert_ne!(
833 child_cgroup,
834 own_cgroup(),
835 "the command must leave our cgroup"
836 );
837 assert!(child_cgroup.ends_with(".scope"), "{child_cgroup}");
838 assert!(child_cgroup.contains("/app.slice/"), "{child_cgroup}");
839 } else {
840 assert_eq!(child_cgroup, own_cgroup(), "unwrapped fallback");
841 }
842 }
843
844 #[cfg(target_os = "linux")]
847 #[tokio::test]
848 #[serial_test::serial(synaps_base_dir)]
849 async fn a_bash_command_is_scoped_in_the_tool_slice() {
850 use crate::process_scope::test_support::{fake_env, fake_systemd_run, recorded};
851 let base = crate::test_env::BaseDirGuard::new();
852 let _scopes = crate::test_env::EnvVarGuard::remove("SYNAPS_PROCESS_SCOPES");
853 std::fs::write(
854 base.path().join("config"),
855 "tool_scope_slice = t_tools.slice\ndaemon_scope_slice = d_daemon.slice\n",
856 )
857 .unwrap();
858 let dir = tempfile::tempdir().unwrap();
859 let fake = fake_systemd_run(dir.path());
860 let mut env = fake_env(&fake);
861 env.push(("PATH".to_string(), std::env::var("PATH").unwrap_or_default()));
862 let mut ctx = create_tool_context();
863 ctx.capabilities.env = Some(env.into());
864 let out = BashTool
865 .execute(json!({ "command": "echo scoped-run", "timeout": 30 }), ctx)
866 .await
867 .unwrap();
868 assert!(out.contains("scoped-run"), "{out}");
869 let launch = recorded(dir.path(), "launch.args");
870 let slices: Vec<&String> = launch.iter().filter(|a| a.starts_with("--slice")).collect();
871 assert_eq!(slices, ["--slice=t_tools.slice"], "{launch:?}");
872 assert!(recorded(dir.path(), "probe.args").contains(&"--slice=t_tools.slice".to_string()));
873 }
874
875 #[cfg(target_os = "linux")]
878 #[tokio::test]
879 async fn bash_command_with_a_rebuilt_env_lacking_the_user_bus_runs_unwrapped() {
880 let mut ctx = create_tool_context();
881 ctx.capabilities.env = Some(
882 vec![("PATH".to_string(), std::env::var("PATH").unwrap_or_default())].into(),
883 );
884 let out = BashTool
885 .execute(
886 json!({ "command": "cat /proc/self/cgroup", "timeout": 30 }),
887 ctx,
888 )
889 .await
890 .unwrap();
891 assert_eq!(out.lines().next().unwrap_or_default().trim(), own_cgroup());
892 }
893
894 #[cfg(target_os = "linux")]
895 fn processes_named(name: &str) -> Vec<String> {
896 let out = std::process::Command::new("pgrep")
897 .args(["-f", name])
898 .output()
899 .unwrap();
900 String::from_utf8_lossy(&out.stdout)
901 .split_whitespace()
902 .map(str::to_string)
903 .collect()
904 }
905
906 #[cfg(target_os = "linux")]
910 #[tokio::test]
911 async fn bash_timeout_kills_the_whole_process_group() {
912 let marker = format!("synaps-timeout-child-{}", std::process::id());
913 let out = BashTool
914 .execute(
915 json!({
916 "command": format!("(exec -a {marker} sleep 300) & (exec -a {marker} sleep 300); wait"),
917 "timeout": 1
918 }),
919 create_tool_context(),
920 )
921 .await;
922 let text = match out {
923 Ok(s) => s,
924 Err(e) => e.to_string(),
925 };
926 assert!(text.contains("timed out"), "{text}");
927 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
928 while !processes_named(&marker).is_empty() && std::time::Instant::now() < deadline {
929 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
930 }
931 let left = processes_named(&marker);
932 if !left.is_empty() {
933 let _ = std::process::Command::new("pkill")
934 .args(["-f", &marker])
935 .status();
936 }
937 assert!(left.is_empty(), "children survived the timeout: {left:?}");
938 }
939
940 #[cfg(target_os = "linux")]
943 #[tokio::test]
944 async fn bash_normal_exit_leaves_a_background_job_running() {
945 let marker = format!("synaps-background-job-{}", std::process::id());
946 let out = BashTool
947 .execute(
948 json!({
949 "command": format!("(exec -a {marker} sleep 30) >/dev/null 2>&1 & echo started"),
950 "timeout": 10
951 }),
952 create_tool_context(),
953 )
954 .await
955 .unwrap();
956 assert!(out.contains("started"), "{out}");
957 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
958 let alive = processes_named(&marker);
959 let _ = std::process::Command::new("pkill")
960 .args(["-f", &marker])
961 .status();
962 assert!(
963 !alive.is_empty(),
964 "a deliberate background job must survive a normal exit"
965 );
966 }
967
968 #[tokio::test]
969 async fn bash_intermediary_handoff_conserves_bytes_under_large_output() {
970 let before = bash_intermediary_snapshot();
971 let tool = BashTool;
972 let mut ctx = create_tool_context();
973 ctx.limits.max_tool_buffer = 64 * 1024;
974 let result = tool
975 .execute(
976 json!({
977 "command": "python3 -c \"import sys; sys.stdout.write('x' * 1048576)\"",
978 "timeout": 30
979 }),
980 ctx,
981 )
982 .await
983 .unwrap();
984 let notice = output_limit_notice(64 * 1024);
987 assert!(result.ends_with(¬ice));
988 assert_eq!(result.len(), 64 * 1024 + notice.len());
989
990 let after = wait_handoff_settled().await;
998 assert_eq!(after.retained_bytes, 0);
999 assert!(after.accepted_bytes >= after.consumed_bytes);
1000 assert!(after.produced_bytes - before.produced_bytes >= 64 * 1024);
1001 }
1002
1003 #[test]
1004 fn sanitizes_terminal_control_sequences_and_nuls() {
1005 let cleaned = sanitize_output("ok\x1b[2J\x00done");
1006 assert_eq!(cleaned, "okdone");
1007 }
1008
1009 use super::super::test_helpers::create_tool_context;
1010 use crate::tools::Tool;
1011 use serde_json::json;
1012
1013 #[test]
1014 fn test_bash_tool_schema() {
1015 let tool = BashTool;
1016 assert_eq!(tool.name(), "bash");
1017 assert!(!tool.description().is_empty());
1018
1019 let params = tool.parameters();
1020 assert_eq!(params["type"], "object");
1021 assert!(params["properties"].is_object());
1022 assert!(params["required"].is_array());
1023 }
1024
1025 #[tokio::test]
1026 async fn test_bash_tool_execution() {
1027 let tool = BashTool;
1028
1029 let ctx = create_tool_context();
1031 let params = json!({
1032 "command": "echo hello"
1033 });
1034
1035 let result = tool.execute(params, ctx).await.unwrap();
1036 assert!(result.contains("hello"));
1037
1038 let ctx = create_tool_context();
1040 let params = json!({
1041 "command": "sleep 1",
1042 "timeout": 2
1043 });
1044
1045 let result = tool.execute(params, ctx).await;
1046 assert!(result.is_ok());
1048
1049 let ctx = create_tool_context();
1051 let params = json!({
1052 "command": "sleep 3",
1053 "timeout": 1
1054 });
1055
1056 let result = tool.execute(params, ctx).await;
1057 assert!(result.is_err());
1059 assert!(result.unwrap_err().to_string().contains("timed out"));
1060 }
1061
1062 #[tokio::test]
1063 async fn test_bash_tool_requested_timeout_is_clamped_by_max_timeout() {
1064 let tool = BashTool;
1065 let mut ctx = create_tool_context();
1066 ctx.limits.bash_max_timeout = 1;
1067
1068 let params = json!({
1069 "command": "sleep 5; echo done",
1070 "timeout": 10
1071 });
1072
1073 let error = tool_error(tool.execute(params, ctx).await);
1074 assert!(
1075 error.contains("timed out"),
1076 "requested timeout MUST be clamped by bash_max_timeout"
1077 );
1078 assert!(
1080 error.starts_with(
1081 "Tool execution failed: Command timed out after 1s and was stopped \
1082 (10s was requested; the maximum is 1s)."
1083 ),
1084 "{error}"
1085 );
1086 }
1087
1088 #[tokio::test]
1091 async fn a_default_timeout_cut_by_the_maximum_is_not_called_requested() {
1092 let mut ctx = create_tool_context();
1093 ctx.limits.bash_timeout = 10;
1094 ctx.limits.bash_max_timeout = 1;
1095
1096 let error = tool_error(BashTool.execute(json!({"command": "sleep 5"}), ctx).await);
1097 let head = "Tool execution failed: Command timed out after 1s and was stopped. ";
1098 assert!(error.starts_with(head), "{error}");
1099 assert!(!error.contains("requested"), "{error}");
1100 }
1101
1102 fn prompt_channel() -> (
1103 crate::tools::SecretPromptHandle,
1104 tokio::sync::mpsc::UnboundedReceiver<crate::tools::SecretPromptRequest>,
1105 ) {
1106 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
1107 (crate::tools::SecretPromptHandle::new(tx), rx)
1108 }
1109
1110 const PROMPT_LOOKALIKES: &str = r#"printf '%s\n' "command sudo -S -p '[sudo] password required: ' \"\$@\"" >&2
1114printf '[sudo] password for x: ' >&2
1115printf 'Password: ' >&2
1116if read -r got; then echo "stdin gave: $got"; else echo stdin-eof; fi"#;
1117
1118 #[tokio::test]
1121 async fn sudo_prompt_lookalike_output_raises_no_prompt_and_feeds_no_stdin() {
1122 let (handle, mut prompts) = prompt_channel();
1123 let mut ctx = create_tool_context();
1124 ctx.capabilities.secret_prompt = Some(handle);
1125 let started = std::time::Instant::now();
1126 let out = BashTool
1127 .execute(json!({"command": PROMPT_LOOKALIKES, "timeout": 30}), ctx)
1128 .await
1129 .unwrap();
1130 assert!(out.contains("stdin-eof"), "{out}");
1131 assert!(out.contains("[sudo] password for x: "), "it is only output: {out}");
1132 assert!(out.contains("password required: "), "{out}");
1133 assert!(prompts.try_recv().is_err(), "no prompt was raised");
1134 assert!(started.elapsed() < std::time::Duration::from_secs(10));
1135 }
1136
1137 #[cfg(unix)]
1138 #[test]
1139 fn the_sudo_wrapper_asks_through_askpass_or_not_at_all() {
1140 let script = super::bash_script_with_secure_sudo("sudo id", true);
1141 assert_eq!(script, "sudo() {\n command sudo -A \"$@\"\n}\nsudo id");
1142 let script = super::bash_script_with_secure_sudo("sudo id", false);
1143 assert_eq!(script, "sudo() {\n command sudo -n \"$@\"\n}\nsudo id");
1144 for askpass in [true, false] {
1145 let script = super::bash_script_with_secure_sudo("x", askpass);
1146 assert!(!script.contains(" -S") && !script.contains("password"), "{script}");
1147 }
1148 }
1149
1150 #[cfg(target_os = "linux")]
1153 #[tokio::test]
1154 async fn without_askpass_sudo_never_asks() {
1155 let fake = tempfile::tempdir().unwrap();
1156 crate::tools::sudo_askpass::testing::write_fixtures(fake.path()).unwrap();
1157 let (handle, mut prompts) = prompt_channel();
1158 let mut ctx = create_tool_context();
1159 ctx.capabilities.secret_prompt = Some(handle);
1160 let command = format!(
1161 "export PATH=\"{}:$PATH\"; sudo echo ran; echo after:$?",
1162 fake.path().display()
1163 );
1164 let out = BashTool
1165 .execute(json!({"command": command, "timeout": 30}), ctx)
1166 .await
1167 .unwrap();
1168 assert!(out.contains("sudo: a password is required"), "{out}");
1169 assert!(out.contains("after:1") && !out.contains("ran\n"), "{out}");
1170 assert!(prompts.try_recv().is_err());
1171 }
1172
1173 #[tokio::test]
1174 async fn test_bash_control_char_output_is_sanitized_and_bounded_in_deltas() {
1175 let tool = BashTool;
1176 let channel = crate::tools::output::delta_channel();
1177 let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
1178 let mut ctx = create_tool_context();
1179 ctx.channels.tx_delta = Some(delta_tx);
1180 ctx.limits.max_tool_buffer = 256;
1181
1182 let params = json!({
1183 "command": "python3 -c \"import sys; sys.stdout.buffer.write(b'clean\\x1b[2J\\x00' + b'A' * 2000); sys.stdout.flush()\"",
1184 "timeout": 30
1185 });
1186
1187 let result = tool.execute(params, ctx).await.unwrap();
1188 let mut streamed = String::new();
1189 while let Some(delta) = delta_rx.try_drain() {
1190 streamed.push_str(&delta);
1191 }
1192
1193 assert!(result.ends_with(&output_limit_notice(256)), "{result}");
1194 assert_eq!(streamed, result);
1196 assert!(!result.contains('\u{1b}'));
1197 assert!(!result.contains('\0'));
1198 assert!(!streamed.contains('\u{1b}'));
1199 assert!(!streamed.contains('\0'));
1200 assert!(
1201 streamed.len() <= 2048,
1202 "streamed deltas must be bounded, got {} bytes",
1203 streamed.len()
1204 );
1205 }
1206
1207 #[tokio::test]
1208 async fn test_bash_binary_output_is_sanitized() {
1209 let tool = BashTool;
1210 let ctx = create_tool_context();
1211 let params = json!({
1212 "command": "python3 -c \"import sys; sys.stdout.buffer.write(bytes(range(32)) + b'visible')\"",
1213 "timeout": 30
1214 });
1215
1216 let result = tool.execute(params, ctx).await.unwrap();
1217
1218 assert!(result.contains("visible"));
1219 assert!(!result.contains('\0'));
1220 assert!(!result.contains('\u{1b}'));
1221 }
1222
1223 #[tokio::test]
1224 async fn test_bash_tool_timeout() {
1225 let tool = BashTool;
1226 let ctx = create_tool_context();
1227
1228 let params = json!({
1229 "command": "sleep 10",
1230 "timeout": 1
1231 });
1232
1233 let error = tool_error(tool.execute(params, ctx).await);
1234 assert_eq!(
1237 error,
1238 "Tool execution failed: Command timed out after 1s and was stopped. \
1239 No output was captured before the timeout; \
1240 output the program had buffered but not flushed is lost."
1241 );
1242 }
1243
1244 #[tokio::test]
1245 async fn test_bash_tool_failure() {
1246 let tool = BashTool;
1247 let ctx = create_tool_context();
1248
1249 let params = json!({
1250 "command": "exit 1"
1251 });
1252
1253 let result = tool.execute(params, ctx).await;
1254
1255 assert!(result.is_err());
1257 let error = result.unwrap_err().to_string();
1258 assert!(error.contains("failed") || error.contains("exit"));
1259 }
1260
1261 fn tool_error(result: crate::Result<String>) -> String {
1263 match result {
1264 Ok(out) => panic!("expected an error, got: {out}"),
1265 Err(e) => e.to_string(),
1266 }
1267 }
1268
1269 #[test]
1271 fn the_output_limit_notice_says_the_command_was_stopped() {
1272 assert_eq!(
1273 output_limit_notice(256),
1274 "\n\n[output truncated at 256 bytes: the command was stopped at this limit \
1275 before it finished, so its exit status is unknown. Redirect large output \
1276 to a file (e.g. cmd > out.log 2>&1) and read only the parts you need.]"
1277 );
1278 }
1279
1280 #[cfg(unix)]
1285 #[tokio::test]
1286 async fn the_output_cap_stops_the_command_and_says_its_status_is_unknown() {
1287 let channel = crate::tools::output::delta_channel();
1288 let history = channel.output_handle();
1289 let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
1290 let mut ctx = create_tool_context();
1291 ctx.channels.tx_delta = Some(delta_tx);
1292 ctx.limits.max_tool_buffer = 256;
1293 let started = std::time::Instant::now();
1294 let result = BashTool
1295 .execute(
1296 json!({"command": "printf 'x%.0s' {1..2000}; sleep 30; echo finished", "timeout": 30}),
1297 ctx,
1298 )
1299 .await
1300 .unwrap();
1301 assert!(
1302 started.elapsed() < std::time::Duration::from_secs(20),
1303 "stopped at the cap"
1304 );
1305 assert_eq!(result, "x".repeat(256) + &output_limit_notice(256));
1306 assert!(result.contains("the command was stopped"), "{result}");
1307 assert!(result.contains("its exit status is unknown"), "{result}");
1308 let mut streamed = String::new();
1309 while let Some(delta) = delta_rx.try_drain() {
1310 streamed.push_str(&delta);
1311 }
1312 assert_eq!(streamed, result);
1313 assert_eq!(history.model_history().text, result);
1314 }
1315
1316 #[cfg(unix)]
1319 #[tokio::test]
1320 async fn output_up_to_the_cap_is_unchanged_and_one_byte_more_is_stopped() {
1321 for (bytes, expected) in [
1322 (255, "y".repeat(255)),
1323 (256, "y".repeat(256)),
1324 (257, "y".repeat(256) + &output_limit_notice(256)),
1325 ] {
1326 let mut ctx = create_tool_context();
1327 ctx.limits.max_tool_buffer = 256;
1328 let command = format!("printf 'y%.0s' {{1..{bytes}}}");
1329 let result = BashTool
1330 .execute(json!({"command": command, "timeout": 30}), ctx)
1331 .await
1332 .unwrap();
1333 assert_eq!(result, expected, "{bytes} bytes");
1334 }
1335 }
1336
1337 #[cfg(unix)]
1340 #[tokio::test]
1341 async fn a_nonzero_exit_under_the_cap_is_still_a_failure() {
1342 let mut ctx = create_tool_context();
1343 ctx.limits.max_tool_buffer = 256;
1344 let error = tool_error(
1345 BashTool
1346 .execute(
1347 json!({"command": "printf partial; exit 3", "timeout": 30}),
1348 ctx,
1349 )
1350 .await,
1351 );
1352 assert_eq!(
1353 error,
1354 "Tool execution failed: Command failed (exit 3):\npartial"
1355 );
1356 }
1357
1358 #[test]
1359 fn the_timeout_message_names_the_stop_and_keeps_the_output() {
1360 assert_eq!(
1361 timed_out_message(30, None, "", 256),
1362 "Command timed out after 30s and was stopped. No output was captured before \
1363 the timeout; output the program had buffered but not flushed is lost."
1364 );
1365 assert_eq!(
1366 timed_out_message(30, Some(30), "step 1\nstep 2\n", 256),
1367 "Command timed out after 30s and was stopped. Output before the timeout:\n\
1368 step 1\nstep 2\n"
1369 );
1370 assert_eq!(
1371 timed_out_message(300, Some(600), "x", 256),
1372 "Command timed out after 300s and was stopped (600s was requested; \
1373 the maximum is 300s). Output before the timeout:\nx"
1374 );
1375 assert_eq!(
1378 timed_out_message(300, None, "x", 256),
1379 "Command timed out after 300s and was stopped. Output before the timeout:\nx"
1380 );
1381 let long = format!("{}END", "a".repeat(1000));
1382 assert_eq!(
1383 timed_out_message(1, Some(1), &long, 10),
1384 "Command timed out after 1s and was stopped. \
1385 Output before the timeout (last 10 of 1003 bytes):\naaaaaaaEND"
1386 );
1387 }
1388
1389 #[test]
1392 fn the_timeout_tail_is_cut_on_a_character_boundary() {
1393 let text = "ab▀é😀c▀";
1394 for max in 0..=text.len() + 1 {
1395 let tail = tail_on_char_boundary(text, max);
1396 assert!(
1397 text.ends_with(tail) && tail.len() <= max,
1398 "max {max}: {tail:?}"
1399 );
1400 let before = &text[..text.len() - tail.len()];
1401 let next = before.chars().next_back().map_or(0, char::len_utf8);
1402 assert!(
1403 before.is_empty() || tail.len() + next > max,
1404 "max {max}: {tail:?}"
1405 );
1406 }
1407 assert_eq!(
1408 timed_out_message(1, None, "▀▀▀", 4),
1409 "Command timed out after 1s and was stopped. \
1410 Output before the timeout (last 3 of 9 bytes):\n▀"
1411 );
1412 }
1413
1414 #[cfg(unix)]
1417 #[tokio::test]
1418 async fn a_timeout_keeps_the_output_printed_before_it() {
1419 let channel = crate::tools::output::delta_channel();
1420 let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
1421 let mut ctx = create_tool_context();
1422 ctx.channels.tx_delta = Some(delta_tx);
1423 let error = tool_error(
1424 BashTool
1425 .execute(
1426 json!({"command": "echo out-before; echo err-before >&2; sleep 30", "timeout": 1}),
1427 ctx,
1428 )
1429 .await,
1430 );
1431 let head = "Tool execution failed: Command timed out after 1s and was stopped. \
1432 Output before the timeout:\n";
1433 assert!(error.starts_with(head), "{error}");
1434 let output = &error[head.len()..];
1435 assert!(
1436 output.contains("out-before\n") && output.contains("err-before\n"),
1437 "{error}"
1438 );
1439 assert_eq!(output.len(), "out-before\nerr-before\n".len(), "{error}");
1440 let mut streamed = String::new();
1441 while let Some(delta) = delta_rx.try_drain() {
1442 streamed.push_str(&delta);
1443 }
1444 assert_eq!(output, streamed);
1445 }
1446
1447 #[cfg(unix)]
1453 #[tokio::test]
1454 async fn a_timeout_after_a_flood_keeps_a_bounded_tail() {
1455 let dir = tempfile::tempdir().unwrap();
1456 let pid_file = dir.path().join("holder.pid");
1457 let command = format!(
1458 r#"python3 -c "import os, sys, time; os.setsid(); open(sys.argv[1], 'w').write(str(os.getpid())); time.sleep(60)" '{pid}' &
1459while [ ! -s '{pid}' ]; do sleep 0.02; done
1460python3 -c "import sys; sys.stdout.buffer.write('▀'.encode() * 100000)"
1461wait"#,
1462 pid = pid_file.display()
1463 );
1464 let mut ctx = create_tool_context();
1465 ctx.limits.max_tool_buffer = 256;
1466 let error = tool_error(
1467 BashTool
1468 .execute(json!({"command": command, "timeout": 5}), ctx)
1469 .await,
1470 );
1471 if let Some(pid) = std::fs::read_to_string(&pid_file)
1472 .ok()
1473 .and_then(|p| p.trim().parse::<libc::pid_t>().ok())
1474 {
1475 unsafe {
1477 libc::kill(pid, libc::SIGKILL);
1478 }
1479 }
1480 let notice = output_limit_notice(256);
1481 let (head, tail) = error.split_once('\n').unwrap_or_else(|| panic!("{error}"));
1482 let captured = 255 + notice.len();
1484 assert_eq!(
1485 head,
1486 format!(
1487 "Tool execution failed: Command timed out after 5s and was stopped. \
1488 Output before the timeout (last {} of {captured} bytes):",
1489 tail.len()
1490 ),
1491 );
1492 assert!(
1493 tail.len() <= 256 && tail.len() > 256 - '▀'.len_utf8(),
1494 "{}",
1495 tail.len()
1496 );
1497 assert!(tail.ends_with(¬ice), "{error}");
1498 let chars = &tail[..tail.len() - notice.len()];
1499 assert!(
1500 !chars.is_empty() && chars.chars().all(|c| c == '▀'),
1501 "{chars:?}"
1502 );
1503 }
1504
1505 #[cfg(target_os = "linux")]
1508 #[tokio::test]
1509 async fn a_timeout_redacts_a_password_in_the_output_before_it() {
1510 let bin = tempfile::tempdir().unwrap();
1511 let helper = crate::tools::sudo_askpass::testing::write_fixtures(bin.path()).unwrap();
1512 let (handle, mut prompts) = prompt_channel();
1513 let user = tokio::spawn(async move {
1514 let request = prompts.recv().await.expect("sudo asks for the password");
1515 let _ = request.response_tx.send(Some("swordfish".to_string()));
1516 });
1517 let mut ctx = create_tool_context();
1518 ctx.capabilities.secret_prompt = Some(handle);
1519 let command = format!(
1520 "export PATH=\"{}:$PATH\"\nFAKE_SUDO_ECHO=1 sudo true; echo done; sleep 30",
1521 bin.path().display()
1522 );
1523 let error = tool_error(
1524 crate::tools::sudo_askpass::testing::run_bash_with_test_askpass(
1525 &command,
1526 Some(5),
1527 ctx,
1528 &helper,
1529 )
1530 .await,
1531 );
1532 user.await.unwrap();
1533 assert!(
1534 error.starts_with(
1535 "Tool execution failed: Command timed out after 5s and was stopped. \
1536 Output before the timeout:\n"
1537 ),
1538 "{error}"
1539 );
1540 assert!(
1541 error.contains("sudo got: [redacted]\n") && error.contains("done\n"),
1542 "{error}"
1543 );
1544 assert!(!error.contains("swordfish"), "{error}");
1545 }
1546
1547 #[cfg(unix)]
1550 fn process_running(pid: libc::pid_t) -> bool {
1551 if unsafe { libc::kill(pid, 0) } != 0 {
1553 return false;
1554 }
1555 #[cfg(target_os = "linux")]
1556 let state = std::fs::read_to_string(format!("/proc/{pid}/stat"))
1557 .ok()
1558 .and_then(|stat| {
1559 stat.rsplit_once(')')
1560 .map(|(_, rest)| rest.trim_start().to_string())
1561 });
1562 #[cfg(not(target_os = "linux"))]
1563 let state = std::process::Command::new("ps")
1564 .args(["-o", "stat=", "-p", &pid.to_string()])
1565 .output()
1566 .ok()
1567 .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string());
1568 state.is_some_and(|s| !s.is_empty() && !s.starts_with('Z'))
1569 }
1570
1571 #[cfg(unix)]
1574 #[tokio::test]
1575 async fn a_timeout_leaves_no_process_of_the_command_running() {
1576 let dir = tempfile::tempdir().unwrap();
1577 let pid_file = dir.path().join("child.pid");
1578 let command = format!(
1579 "sleep 300 & echo $! > '{}'; echo started; wait",
1580 pid_file.display()
1581 );
1582 let error = tool_error(
1583 BashTool
1584 .execute(
1585 json!({"command": command, "timeout": 1}),
1586 create_tool_context(),
1587 )
1588 .await,
1589 );
1590 assert_eq!(
1591 error,
1592 "Tool execution failed: Command timed out after 1s and was stopped. \
1593 Output before the timeout:\nstarted\n"
1594 );
1595 let pid: libc::pid_t = std::fs::read_to_string(&pid_file)
1596 .unwrap()
1597 .trim()
1598 .parse()
1599 .unwrap();
1600 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1601 while process_running(pid) && std::time::Instant::now() < deadline {
1602 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1603 }
1604 let survived = process_running(pid);
1605 if survived {
1606 unsafe {
1608 libc::kill(pid, libc::SIGKILL);
1609 }
1610 }
1611 assert!(!survived, "the command's child {pid} outlived the timeout");
1612 }
1613
1614 #[cfg(unix)]
1615 #[tokio::test]
1616 async fn bash_honours_capability_cwd_when_set() {
1617 let tmp = tempfile::TempDir::new().unwrap();
1618 let expected = tmp.path().canonicalize().unwrap();
1619 let mut ctx = create_tool_context();
1620 ctx.capabilities.cwd = Some(tmp.path().to_path_buf());
1621 let out = BashTool
1622 .execute(json!({"command": "pwd -P"}), ctx)
1623 .await
1624 .unwrap();
1625 assert_eq!(out.trim(), expected.to_string_lossy());
1626 }
1627
1628 #[cfg(unix)]
1629 #[tokio::test]
1630 async fn bash_inherits_process_cwd_when_capability_cwd_is_none() {
1631 let expected = std::env::current_dir().unwrap().canonicalize().unwrap();
1632 let ctx = create_tool_context();
1633 assert!(ctx.capabilities.cwd.is_none());
1634 let out = BashTool
1635 .execute(json!({"command": "pwd -P"}), ctx)
1636 .await
1637 .unwrap();
1638 assert_eq!(out.trim(), expected.to_string_lossy());
1639 }
1640
1641 #[test]
1642 fn references_env_name_is_word_bounded() {
1643 assert!(references_env_name("echo ${GH_TOKEN:?}", "GH_TOKEN"));
1644 assert!(references_env_name("echo $GH_TOKEN", "GH_TOKEN"));
1645 assert!(references_env_name("GH_TOKEN=x gh auth", "GH_TOKEN"));
1646 assert!(references_env_name("$env:GH_TOKEN", "GH_TOKEN"));
1647 assert!(!references_env_name("echo $GH_TOKEN_FILE", "GH_TOKEN"));
1648 assert!(!references_env_name("echo $MY_GH_TOKEN", "GH_TOKEN"));
1649 assert!(!references_env_name("gh auth status", "GH_TOKEN"));
1650 }
1651
1652 #[cfg(unix)]
1654 #[tokio::test]
1655 async fn bash_notice_on_stripped_secret_reference() {
1656 let warned = std::sync::Arc::new(std::sync::Mutex::new(
1657 std::collections::HashSet::<String>::new(),
1658 ));
1659 let mut ctx = create_tool_context();
1661 ctx.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1662 ctx.capabilities.env_warned = warned.clone();
1663 let err = BashTool
1664 .execute(json!({"command": "echo ${GH_TOKEN:?} 2>&1; exit 1"}), ctx)
1665 .await
1666 .unwrap_err();
1667 let msg = format!("{err}");
1668 assert!(
1669 msg.contains("note: $GH_TOKEN was stripped"),
1670 "notice must name the var: {msg}"
1671 );
1672
1673 let mut ctx2 = create_tool_context();
1675 ctx2.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1676 ctx2.capabilities.env_warned = warned.clone();
1677 let out = BashTool
1678 .execute(json!({"command": "echo $GH_TOKEN; exit 0"}), ctx2)
1679 .await
1680 .unwrap();
1681 assert!(
1682 !out.contains("note:"),
1683 "zero exit must not produce a notice: {out}"
1684 );
1685
1686 let mut ctx3 = create_tool_context();
1688 ctx3.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1689 ctx3.capabilities.env_warned = warned.clone();
1690 let err2 = BashTool
1691 .execute(json!({"command": "echo ${GH_TOKEN}; exit 1"}), ctx3)
1692 .await
1693 .unwrap_err();
1694 let msg2 = format!("{err2}");
1695 assert!(
1696 !msg2.contains("note: $GH_TOKEN was stripped"),
1697 "dedup must suppress the second notice: {msg2}"
1698 );
1699 }
1700}
1701
1702#[cfg(test)]
1705mod utf8_chunk_tests {
1706 use super::*;
1707 use crate::tools::Tool;
1708 use serde_json::json;
1709 use std::collections::VecDeque;
1710 use std::pin::Pin;
1711 use std::task::{Context, Poll};
1712
1713 struct ChunkedReader(VecDeque<Vec<u8>>);
1715
1716 impl tokio::io::AsyncRead for ChunkedReader {
1717 fn poll_read(
1718 mut self: Pin<&mut Self>,
1719 _cx: &mut Context<'_>,
1720 buf: &mut tokio::io::ReadBuf<'_>,
1721 ) -> Poll<std::io::Result<()>> {
1722 if let Some(chunk) = self.0.pop_front() {
1723 assert!(chunk.len() <= buf.remaining(), "test chunk larger than read buffer");
1724 buf.put_slice(&chunk);
1725 }
1726 Poll::Ready(Ok(()))
1727 }
1728 }
1729
1730 async fn forward_chunks(chunks: Vec<Vec<u8>>, is_stderr: bool) -> String {
1731 let (tx, mut rx) = tokio::sync::mpsc::channel(1024);
1732 forward_output(ChunkedReader(chunks.into()), is_stderr, tx).await;
1733 let mut out = String::new();
1734 while let Some((stderr, chunk)) = rx.recv().await {
1735 let msg = chunk.consume();
1736 assert_eq!(stderr, is_stderr);
1737 out.push_str(&msg);
1738 }
1739 out
1740 }
1741
1742 #[tokio::test]
1743 async fn characters_split_at_every_offset_survive_both_streams() {
1744 for ch in ['é', '▀', '😀'] {
1745 let text = format!("a{ch}b{ch}{ch}c");
1746 let bytes = text.as_bytes();
1747 for split in 1..bytes.len() {
1748 for is_stderr in [false, true] {
1749 let out = forward_chunks(
1750 vec![bytes[..split].to_vec(), bytes[split..].to_vec()],
1751 is_stderr,
1752 )
1753 .await;
1754 assert!(!out.contains('\u{FFFD}'), "{ch:?} split at {split}: {out:?}");
1755 assert_eq!(out.as_bytes(), bytes, "{ch:?} split at {split}");
1756 }
1757 }
1758 }
1759 }
1760
1761 #[tokio::test]
1762 async fn one_byte_reads_reassemble_every_character() {
1763 let text = "é▀😀 mixed ascii ✓ 日本語";
1764 let chunks = text.bytes().map(|b| vec![b]).collect();
1765 assert_eq!(forward_chunks(chunks, false).await, text);
1766 }
1767
1768 #[tokio::test]
1769 async fn genuinely_invalid_bytes_still_become_replacement_characters() {
1770 assert_eq!(forward_chunks(vec![b"ok\xffok".to_vec()], false).await, "ok\u{FFFD}ok");
1771 assert_eq!(
1773 forward_chunks(vec![b"a\xe2".to_vec(), b"Xb".to_vec()], false).await,
1774 "a\u{FFFD}Xb"
1775 );
1776 assert_eq!(
1778 forward_chunks(vec![b"a".to_vec(), b"\x80b".to_vec()], false).await,
1779 "a\u{FFFD}b"
1780 );
1781 }
1782
1783 #[tokio::test]
1784 async fn incomplete_sequence_at_eof_is_flushed_not_dropped() {
1785 assert_eq!(forward_chunks(vec![b"ab\xe2\x96".to_vec()], false).await, "ab\u{FFFD}");
1786 assert_eq!(
1787 forward_chunks(vec![b"ab".to_vec(), b"\xf0\x9f".to_vec()], true).await,
1788 "ab\u{FFFD}"
1789 );
1790 }
1791
1792 #[tokio::test]
1796 async fn chunks_still_queued_when_the_receiver_goes_away_count_as_dropped() {
1797 let (tx, rx) = tokio::sync::mpsc::channel(16);
1798 let chunks: Vec<Vec<u8>> = (0..8).map(|_| b"queued".to_vec()).collect();
1799 forward_output(ChunkedReader(chunks.into()), false, tx).await;
1800 let before = bash_intermediary_snapshot();
1801 drop(rx);
1802 let after = wait_handoff_settled().await;
1803 assert!(after.dropped_bytes - before.dropped_bytes >= 8 * 6);
1804 }
1805
1806 #[tokio::test]
1809 async fn prompt_without_newline_is_forwarded_before_eof() {
1810 let (mut writer, reader) = tokio::io::duplex(4096);
1811 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1812 let task = tokio::spawn(forward_output(reader, true, tx));
1813 use tokio::io::AsyncWriteExt;
1814 writer.write_all("[sudo] password for x: ".as_bytes()).await.unwrap();
1815 let (is_stderr, chunk) =
1816 tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
1817 .await
1818 .expect("prompt must arrive without EOF")
1819 .unwrap();
1820 let msg = chunk.consume();
1821 assert!(is_stderr);
1822 assert_eq!(msg, "[sudo] password for x: ");
1823 drop(writer);
1824 task.await.unwrap();
1825 }
1826
1827 #[tokio::test]
1830 async fn bash_tool_output_over_1kib_keeps_multibyte_characters_whole() {
1831 let channel = crate::tools::output::delta_channel();
1832 let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
1833 let mut ctx = super::super::test_helpers::create_tool_context();
1834 ctx.channels.tx_delta = Some(delta_tx);
1835 let result = BashTool
1836 .execute(json!({"command": "printf '▀%.0s' {1..2000}", "timeout": 30}), ctx)
1837 .await
1838 .unwrap();
1839 let mut streamed = String::new();
1840 while let Some(delta) = delta_rx.try_drain() {
1841 streamed.push_str(&delta);
1842 }
1843 let expected = "▀".repeat(2000);
1844 assert!(!result.contains('\u{FFFD}'), "result has U+FFFD");
1845 assert_eq!(result, expected);
1846 assert!(!streamed.contains('\u{FFFD}'), "deltas have U+FFFD");
1847 assert!(expected.starts_with(&streamed) && !streamed.is_empty());
1848
1849 let ctx = super::super::test_helpers::create_tool_context();
1850 let err = BashTool
1851 .execute(
1852 json!({"command": "python3 -c \"import sys; sys.stderr.write(('▀'*119+'\\n')*45)\"; exit 3", "timeout": 30}),
1853 ctx,
1854 )
1855 .await
1856 .unwrap_err()
1857 .to_string();
1858 assert!(!err.contains('\u{FFFD}'), "stderr has U+FFFD: {err}");
1859 assert!(err.contains(&("▀".repeat(119) + "\n").repeat(45)), "{err}");
1860 }
1861}