Skip to main content

agent_engine/tools/
bash.rs

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    /// Every byte ever produced has been consumed or dropped: nothing is in
27    /// flight anywhere in the process. A leaked byte keeps this false forever.
28    pub fn is_settled(&self) -> bool {
29        self.produced_bytes == self.consumed_bytes + self.dropped_bytes
30    }
31}
32
33/// Counters are read downstream-first. Each one only grows, and upstream
34/// counts are bumped before the bytes move on, so a snapshot torn by a
35/// concurrent relay can overstate in-flight bytes but never understate them:
36/// `is_settled` is never true unless the process really was settled.
37pub 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/// Test oracle for the handoff counters: wait until the whole process is
53/// settled. Concurrent relays only delay it; a leaked byte times it out.
54#[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
69/// One sanitized read in flight between a stream reader and the executor.
70/// Its bytes are counted out exactly once: `consume` when the executor takes
71/// it, or `Drop` when it never arrives (the channel closed before the send,
72/// or it was still queued when the receiver went away on timeout or cancel).
73/// Tokio drops queued values with the channel, so no path can leak a count.
74struct 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
107/// sudo passwords handed out during one command, redacted from its output
108/// and zeroized when dropped.
109pub(crate) type Redactions = std::sync::Arc<std::sync::Mutex<Vec<Zeroizing<String>>>>;
110
111/// The askpass broker one bash call uses (Linux), prepared before spawn.
112#[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
127/// Read one child stream in `READ_CHUNK_SIZE` chunks and forward the
128/// sanitized text to the bash executor, tagged stdout/stderr. A character
129/// split between two reads is held back and decoded whole (#286); only an
130/// incomplete sequence (at most 3 bytes) waits, so prompts without a
131/// trailing newline are still forwarded as soon as they are read.
132async 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            // Count acceptance before the send makes the chunk visible, so
151            // accepted >= consumed holds at every instant. A closed channel
152            // drops the chunk here, and its Drop counts it.
153            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
184/// Ends the result of a command whose output passed `max_output`. The
185/// command is killed there, so this is not a display cut: it never finished
186/// and its exit status is unknown. Keeps the old `[output truncated at N`
187/// prefix, so whatever counts these stops still matches.
188fn 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
196/// The result of a command the timeout stopped. The first line still starts
197/// `Command timed out after`; what the command printed before the timeout
198/// follows, already sanitized and redacted like any result, and cut to its
199/// last `max_output` bytes if it must be: a hang shows at the end.
200/// `requested_secs` is the timeout the model passed, if it passed one: a
201/// configured default above `bash_max_timeout` was never asked for.
202fn 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
236/// The longest suffix of `text` that fits in `max_bytes` and starts on a
237/// character boundary.
238fn 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
249/// Resolve the bash executable for the bash tool.
250///
251/// Unix: `bash` from PATH, as always. Windows: bash is usually NOT on PATH —
252/// prefer an explicit PATH hit, then Git Bash's known install locations, then
253/// WSL's `bash.exe` (runs inside the default distro) as a last resort.
254pub(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        // Nothing found — let spawn fail with a readable NotFound error.
280        "bash".into()
281    }
282}
283
284/// The command, behind a `sudo` shell function. The child has no terminal
285/// (setsid), so a bare sudo could not prompt anyway. With the askpass
286/// broker (Linux, see `sudo_askpass`) sudo asks the user through the
287/// verified helper; without it (other platforms, embedders, setup failure)
288/// sudo must not ask at all: `-n` fails with sudo's own "a password is
289/// required", and NOPASSWD rules work either way. The password never
290/// travels through the command's stdin, which is empty.
291pub(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/// Resolve the PowerShell executable. Prefers pwsh (PowerShell 7+, the
312/// cross-platform build with sane defaults) and falls back to the
313/// Windows-bundled powershell.exe (5.1).
314#[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.exe is always present on Windows (System32).
333    "powershell".into()
334}
335
336/// Unix stub — the powershell tool is only registered on Windows, so this is
337/// never called there; it exists so cross-compilation `cargo check` works.
338#[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    /// Explicitly NonIdempotent (Task 24): arbitrary shell side effects —
350    /// serialized execution, no concurrency key.
351    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
389/// The production askpass broker, if this platform and binary support it.
390fn 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
419/// Kills the command's whole process group unless the command finished on
420/// its own. `child.kill()` stops only the shell: everything the command
421/// started (a test runner's workers, `cmd &` jobs) used to keep running, and
422/// keep using memory, after a timeout or cancel (S348). The command leads its
423/// own group: `setsid` in `pre_exec`, and `systemd-run --scope` execs in
424/// place, so the pid is the group id. Dropped before the `Child`, so the
425/// leader is not reaped by Child drop before the kill. Explicit kill paths
426/// signal/disarm first; normal wait disarms synchronously on completion.
427struct KillGroupUnlessFinished {
428    pgid: Option<u32>,
429}
430
431impl KillGroupUnlessFinished {
432    fn new(pid: Option<u32>) -> Self {
433        Self { pgid: pid }
434    }
435
436    /// The command exited by itself: nothing to kill.
437    fn finished(&mut self) {
438        self.pgid = None;
439    }
440
441    /// Signal and disarm BEFORE any explicit kill/wait can reap the leader.
442    fn kill_now(&mut self) {
443        #[cfg(unix)]
444        if let Some(pgid) = self.pgid.take() {
445            // SAFETY: killpg(2) on our own child's process group.
446            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/// Which shell backend the shared executor drives.
464#[derive(Debug, Clone, Copy, PartialEq, Eq)]
465pub(crate) enum ShellSpec {
466    Bash,
467    PowerShell,
468}
469
470impl ShellSpec {
471    /// The program and argv that run `script`.
472    ///
473    /// PowerShell gets `-ExecutionPolicy Bypass`: a stock Windows client has
474    /// the `Restricted` policy, so `.\script.ps1` failed with "running
475    /// scripts is disabled on this system". The flag is process-scoped, and
476    /// PowerShells the command starts inherit it (through
477    /// `PSExecutionPolicyPreference`). A policy set by Group Policy still
478    /// wins. The policy is not a security boundary: the tool already runs any
479    /// command as the user.
480    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    /// Names the command's systemd scope (`systemctl --user list-units --type=scope`).
494    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
502/// Shared execution core for bash + powershell: empty stdin, piped
503/// stdout/stderr, chunked streaming with byte-conservation accounting, the
504/// sudo askpass broker (bash on Linux), truncation, timeout. The only
505/// per-shell differences are which program is spawned and which args it
506/// takes. Nothing the command prints can raise a password prompt: only a
507/// verified sudo can, through `askpass`.
508pub(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        // H5: enforce bash_max_timeout cap — prevent DoS via prompt injection
518        // requesting unbounded timeouts (e.g. timeout:2592000 + infinite loop).
519        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        // This worker can only launch a bounded throwaway probe. Abandoning
533        // this future can never launch the real command from the worker.
534        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        // Start the child in a new session (setsid) so it has no controlling
572        // terminal. Programs that open /dev/tty directly (SSH fingerprint
573        // prompts, gpg pinentry, git credential helpers, pagers) will get
574        // ENXIO and fail with a readable error on stderr instead of hanging
575        // invisibly until timeout. Sudo asks through its askpass helper
576        // instead (see bash_script_with_secure_sudo()).
577        #[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        // Armed until the command finishes on its own: a timeout, a cancel,
590        // the output cap or an abandoned password prompt kills the whole
591        // process group, not just the shell.
592        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        // Serve sudo's password requests for as long as the command runs.
603        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        // Outside the timed future: a timeout drops that future, and what
629        // the command printed before it must outlive it.
630        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                    // The user cancelled sudo's password prompt: stop the
639                    // command rather than let it run on without it.
640                    _ = 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                        // Stream only what the result keeps, never the
668                        // bytes past the cap.
669                        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                    // The command is stopped, not its output shortened for
681                    // display: say so, in the result and the stream alike.
682                    let notice = output_limit_notice(max_output);
683                    full_output.push_str(&notice);
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            // wait errors may mean the leader was already reaped externally;
694            // never signal a potentially recycled process-group ID afterwards.
695            group.finished();
696            let status = status.map_err(|e| RuntimeError::Tool(e.to_string()))?;
697            // Finished on its own: leave anything it deliberately started in
698            // the background (`server &`) running, as before.
699            // Drop (and so zeroize) the passwords now the command is done.
700            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                    // T5: when a command fails and references a stripped secret,
711                    // append a notice (once per name per session). Word-boundary
712                    // match: covers $NAME, ${NAME}, ${NAME:?}, ${NAME:-x},
713                    // $env:NAME and NAME=…
714                    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            // The group is still armed: `group` drops on this return and
727            // kills it (Unix), exactly as before. Elsewhere only the shell
728            // is killed, by `kill_on_drop`.
729            Err(_) => Err(RuntimeError::Tool(timed_out_message(
730                timeout_secs,
731                requested_timeout,
732                &full_output,
733                max_output,
734            ))),
735        }
736    }
737}
738
739/// Does `script` mention `name` as a whole identifier (not as a substring of
740/// a longer one)? Used by the T5 stripped-secret notice; a false positive
741/// costs one advisory line on an already-failing command, a false negative
742/// hides the reason the command failed — so err towards matching.
743fn 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    /// A stock Windows client has the `Restricted` execution policy: without
764    /// Bypass the powershell tool could not run `.\script.ps1`. The script is
765    /// the last argument, after `-Command`, exactly as given.
766    #[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    /// The command runs in its own systemd scope when the machine supports
815    /// it, so an out-of-memory kill aimed at it cannot take the daemon's
816    /// cgroup down with it (S348). Without a reachable user manager (CI
817    /// containers), the command must still run, in this process's cgroup.
818    #[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    /// A bash command's scope goes in `tool_scope_slice`, never in the
845    /// daemon's slice; the probe carries the same `--slice`.
846    #[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    /// A session environment rebuilt without the user-bus variables cannot
876    /// reach systemd: the command starts unwrapped instead of failing.
877    #[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    /// A timed-out command takes everything it started with it, not just the
907    /// shell (S348: a timed-out test run would leave all its workers running).
908    /// Each child is renamed to the marker (`exec -a`), so a survivor is seen.
909    #[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    /// A command that exits by itself keeps what it deliberately started in
941    /// the background, as before: only abnormal endings kill the group.
942    #[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        // Stopped at the cap: the result is the cap's worth of output and
985        // the notice saying so, nothing more (the memory bound holds).
986        let notice = output_limit_notice(64 * 1024);
987        assert!(result.ends_with(&notice));
988        assert_eq!(result.len(), 64 * 1024 + notice.len());
989
990        // The counters are process-global and sibling tests run bash
991        // concurrently, so a before/after delta is skewed by whatever a
992        // sibling had mid-channel when `before` was read, and that skew
993        // never rebalances. Assert the invariant that does hold: once
994        // in-flight relays drain, every byte ever produced in this process
995        // was consumed or dropped. Siblings only delay this; a leaked byte
996        // keeps it false forever.
997        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        // Test simple echo command
1030        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        // Test timeout parameter with quick command
1039        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        // Should succeed (1 second sleep with 2 second timeout)
1047        assert!(result.is_ok());
1048
1049        // Test timeout with longer command
1050        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        // Should timeout
1058        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        // The model is told it got less time than it asked for.
1079        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    /// The configured default above `bash_max_timeout` is cut too, but the
1089    /// model asked for nothing, so the error does not say it did.
1090    #[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    /// Output that merely looks like a password prompt. The first line is
1111    /// what a diagnostic printing the tool's own command lines showed on
1112    /// the day this misfired: the old wrapper, prompt string included.
1113    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    /// Regression: text on stderr can no longer raise a password prompt,
1119    /// and nothing is ever written to the command's stdin (it is empty).
1120    #[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    /// Without the broker (this test binary does not dispatch the helper),
1151    /// sudo is told never to ask: it fails with its own error, no prompt.
1152    #[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        // The stream carries exactly what the result keeps, notice included.
1195        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        // Still an error starting `Command timed out after`; a command that
1235        // printed nothing says so instead of showing an empty section.
1236        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        // Should fail and return error
1256        assert!(result.is_err());
1257        let error = result.unwrap_err().to_string();
1258        assert!(error.contains("failed") || error.contains("exit"));
1259    }
1260
1261    /// The text the model reads for a failed call.
1262    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    /// The exact words the model reads when the output cap stops a command.
1270    #[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    /// Past the cap the command is killed and the result says so: the cap's
1281    /// worth of output, then the notice, in the result, the stream and the
1282    /// history the stream feeds alike. Still a success result (unchanged),
1283    /// and back at once, not at the end of the command or the timeout.
1284    #[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    /// Output up to the cap, the cap exactly included, comes back whole and
1317    /// unmarked; one byte more stops the command.
1318    #[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    /// A command that fails after a little output is still a failure with
1338    /// its exit status: the cap only speaks when it stopped something.
1339    #[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        // No timeout from the model: a default the maximum cut was never
1376        // requested, so the message does not say it was.
1377        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    /// The kept tail starts on a character boundary for every budget, and is
1390    /// the longest one that fits.
1391    #[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    /// A timed-out command's error carries what it printed before the
1415    /// timeout, stdout and stderr, exactly as it was streamed.
1416    #[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    /// The cap kills the command's group, but a process that left the group
1448    /// (setsid) still holds the pipes, so the call ends at the timeout. The
1449    /// error stays within `max_output`: the tail of what was captured, the
1450    /// cap's notice included, cut inside a 3-byte character and moved to
1451    /// the next whole one.
1452    #[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            // SAFETY: kills the holder this test started.
1476            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        // 85 whole characters (255 bytes) fit under the cap, then the notice.
1483        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(&notice), "{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    /// What a timed-out command printed goes through the same redaction as
1506    /// any result: a sudo password it echoed is never shown.
1507    #[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    /// Is `pid` still running? One that was killed but not yet reaped by its
1548    /// new parent (a zombie) is not.
1549    #[cfg(unix)]
1550    fn process_running(pid: libc::pid_t) -> bool {
1551        // SAFETY: signal 0 only checks that the process exists.
1552        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    /// After a timeout nothing the command started is left running: a child
1572    /// that records its pid and sleeps is gone, not just the shell.
1573    #[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            // SAFETY: kills the child this test's command started.
1607            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    /// T5 notice: non-zero exit + script references stripped var → notice.
1653    #[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        // --- case 1: non-zero exit + reference → notice
1660        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        // --- case 2: zero exit → no notice
1674        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        // --- case 3: second failure → no duplicate (same Arc)
1687        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/// Issue #286: a multi-byte character split across two reads must come back
1703/// whole, not as U+FFFD.
1704#[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    /// Yields exactly the given chunks, one per `read`, then EOF.
1714    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        // A lead byte followed by a non-continuation byte in the next read.
1772        assert_eq!(
1773            forward_chunks(vec![b"a\xe2".to_vec(), b"Xb".to_vec()], false).await,
1774            "a\u{FFFD}Xb"
1775        );
1776        // A stray continuation byte at the start of a read.
1777        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    /// A consumer that gives up (timeout, cancelled password prompt) drops
1793    /// the receiver with chunks still queued. Those bytes must be counted as
1794    /// dropped, or the process-wide accounting never settles again.
1795    #[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    /// A password prompt has no trailing newline; it must be forwarded as soon
1807    /// as it is read, not held back waiting for more output or EOF.
1808    #[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    /// Through the real tool: more than 1 KiB of 3-byte characters on both
1828    /// streams. 1024 is not a multiple of 3, so the reads split characters.
1829    #[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}