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/// Resolve the bash executable for the bash tool.
185///
186/// Unix: `bash` from PATH, as always. Windows: bash is usually NOT on PATH —
187/// prefer an explicit PATH hit, then Git Bash's known install locations, then
188/// WSL's `bash.exe` (runs inside the default distro) as a last resort.
189pub(crate) fn bash_program() -> std::ffi::OsString {
190    #[cfg(unix)]
191    {
192        "bash".into()
193    }
194    #[cfg(windows)]
195    {
196        let on_path = std::env::var_os("PATH")
197            .map(|p| std::env::split_paths(&p).any(|d| d.join("bash.exe").is_file()))
198            .unwrap_or(false);
199        if on_path {
200            return "bash".into();
201        }
202        for var in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
203            if let Some(pf) = std::env::var_os(var) {
204                let candidate = std::path::Path::new(&pf).join(r"Git\bin\bash.exe");
205                if candidate.is_file() {
206                    return candidate.into_os_string();
207                }
208            }
209        }
210        let wsl = std::path::Path::new(r"C:\Windows\System32\bash.exe");
211        if wsl.is_file() {
212            return wsl.as_os_str().to_os_string();
213        }
214        // Nothing found — let spawn fail with a readable NotFound error.
215        "bash".into()
216    }
217}
218
219/// The command, behind a `sudo` shell function. The child has no terminal
220/// (setsid), so a bare sudo could not prompt anyway. With the askpass
221/// broker (Linux, see `sudo_askpass`) sudo asks the user through the
222/// verified helper; without it (other platforms, embedders, setup failure)
223/// sudo must not ask at all: `-n` fails with sudo's own "a password is
224/// required", and NOPASSWD rules work either way. The password never
225/// travels through the command's stdin, which is empty.
226pub(crate) fn bash_script_with_secure_sudo(command: &str, askpass: bool) -> String {
227    #[cfg(unix)]
228    let call = if askpass {
229        r#"command sudo -A "$@""#
230    } else {
231        r#"command sudo -n "$@""#
232    };
233    #[cfg(not(unix))]
234    let call = {
235        let _ = askpass;
236        r#"command sudo -S -p '[sudo] password required: ' "$@""#
237    };
238    format!(
239        r#"sudo() {{
240    {call}
241}}
242{command}"#
243    )
244}
245
246/// Resolve the PowerShell executable. Prefers pwsh (PowerShell 7+, the
247/// cross-platform build with sane defaults) and falls back to the
248/// Windows-bundled powershell.exe (5.1).
249#[cfg(windows)]
250pub(crate) fn powershell_program() -> std::ffi::OsString {
251    let on_path = |exe: &str| {
252        std::env::var_os("PATH")
253            .map(|p| std::env::split_paths(&p).any(|d| d.join(exe).is_file()))
254            .unwrap_or(false)
255    };
256    if on_path("pwsh.exe") {
257        return "pwsh".into();
258    }
259    for var in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
260        if let Some(pf) = std::env::var_os(var) {
261            let candidate = std::path::Path::new(&pf).join(r"PowerShell\7\pwsh.exe");
262            if candidate.is_file() {
263                return candidate.into_os_string();
264            }
265        }
266    }
267    // powershell.exe is always present on Windows (System32).
268    "powershell".into()
269}
270
271/// Unix stub — the powershell tool is only registered on Windows, so this is
272/// never called there; it exists so cross-compilation `cargo check` works.
273#[cfg(not(windows))]
274pub(crate) fn powershell_program() -> std::ffi::OsString {
275    "pwsh".into()
276}
277
278#[async_trait::async_trait]
279impl Tool for BashTool {
280    fn origin(&self) -> crate::tools::ToolOrigin {
281        crate::tools::ToolOrigin::Builtin
282    }
283
284    /// Explicitly NonIdempotent (Task 24): arbitrary shell side effects —
285    /// serialized execution, no concurrency key.
286    fn effect(&self) -> crate::tools::catalog::ToolEffect {
287        crate::tools::catalog::ToolEffect::NonIdempotent
288    }
289
290    fn name(&self) -> &str {
291        "bash"
292    }
293
294    fn description(&self) -> &str {
295        "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."
296    }
297
298    fn parameters(&self) -> Value {
299        json!({
300            "type": "object",
301            "properties": {
302                "command": {
303                    "type": "string",
304                    "description": "The bash command to execute"
305                },
306                "timeout": {
307                    "type": "integer",
308                    "description": "Timeout in seconds (default: 30). Use a larger value for long-running commands."
309                }
310            },
311            "required": ["command"]
312        })
313    }
314
315    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
316        let command = params["command"]
317            .as_str()
318            .ok_or_else(|| RuntimeError::Tool("Missing command parameter".to_string()))?;
319
320        run_bash(command, params["timeout"].as_u64(), ctx, prepare_askpass()).await
321    }
322}
323
324/// The production askpass broker, if this platform and binary support it.
325fn prepare_askpass() -> Option<Askpass> {
326    #[cfg(target_os = "linux")]
327    {
328        use super::sudo_askpass::broker::{AskpassConfig, PreparedBroker};
329        let config = AskpassConfig::production()?;
330        match PreparedBroker::prepare(&config) {
331            Ok(broker) => Some(broker),
332            Err(e) => {
333                tracing::warn!("sudo askpass unavailable for this command: {e}");
334                None
335            }
336        }
337    }
338    #[cfg(not(target_os = "linux"))]
339    {
340        None
341    }
342}
343
344pub(crate) async fn run_bash(
345    command: &str,
346    timeout: Option<u64>,
347    ctx: ToolContext,
348    askpass: Option<Askpass>,
349) -> Result<String> {
350    let script = bash_script_with_secure_sudo(command, askpass.is_some());
351    run_shell_command(&script, ShellSpec::Bash, timeout, ctx, askpass).await
352}
353
354/// Kills the command's whole process group unless the command finished on
355/// its own. `child.kill()` stops only the shell: everything the command
356/// started (a test runner's workers, `cmd &` jobs) used to keep running, and
357/// keep using memory, after a timeout or cancel (S348). The command leads its
358/// own group: `setsid` in `pre_exec`, and `systemd-run --scope` execs in
359/// place, so the pid is the group id. Dropped before the `Child`, so the
360/// leader is not reaped by Child drop before the kill. Explicit kill paths
361/// signal/disarm first; normal wait disarms synchronously on completion.
362struct KillGroupUnlessFinished {
363    pgid: Option<u32>,
364}
365
366impl KillGroupUnlessFinished {
367    fn new(pid: Option<u32>) -> Self {
368        Self { pgid: pid }
369    }
370
371    /// The command exited by itself: nothing to kill.
372    fn finished(&mut self) {
373        self.pgid = None;
374    }
375
376    /// Signal and disarm BEFORE any explicit kill/wait can reap the leader.
377    fn kill_now(&mut self) {
378        #[cfg(unix)]
379        if let Some(pgid) = self.pgid.take() {
380            // SAFETY: killpg(2) on our own child's process group.
381            unsafe {
382                libc::killpg(pgid as libc::pid_t, libc::SIGKILL);
383            }
384        }
385        #[cfg(not(unix))]
386        {
387            self.pgid = None;
388        }
389    }
390}
391
392impl Drop for KillGroupUnlessFinished {
393    fn drop(&mut self) {
394        self.kill_now();
395    }
396}
397
398/// Which shell backend the shared executor drives.
399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400pub(crate) enum ShellSpec {
401    Bash,
402    PowerShell,
403}
404
405impl ShellSpec {
406    /// Names the command's systemd scope (`systemctl --user list-units --type=scope`).
407    fn scope_description(self) -> &'static str {
408        match self {
409            ShellSpec::Bash => "synaps tool: bash",
410            ShellSpec::PowerShell => "synaps tool: powershell",
411        }
412    }
413}
414
415/// Shared execution core for bash + powershell: empty stdin, piped
416/// stdout/stderr, chunked streaming with byte-conservation accounting, the
417/// sudo askpass broker (bash on Linux), truncation, timeout. The only
418/// per-shell differences are which program is spawned and which args it
419/// takes. Nothing the command prints can raise a password prompt: only a
420/// verified sudo can, through `askpass`.
421pub(crate) async fn run_shell_command(
422    script: &str,
423    spec: ShellSpec,
424    requested_timeout: Option<u64>,
425    ctx: ToolContext,
426    askpass: Option<Askpass>,
427) -> Result<String> {
428    {
429        let requested_timeout = requested_timeout.unwrap_or(ctx.limits.bash_timeout);
430        // H5: enforce bash_max_timeout cap — prevent DoS via prompt injection
431        // requesting unbounded timeouts (e.g. timeout:2592000 + infinite loop).
432        let timeout_secs = if ctx.limits.bash_max_timeout > 0 {
433            requested_timeout.min(ctx.limits.bash_max_timeout)
434        } else {
435            requested_timeout
436        };
437        let max_output = ctx.limits.max_tool_buffer;
438
439        let (program, args): (std::ffi::OsString, Vec<std::ffi::OsString>) = match spec {
440            ShellSpec::Bash => (bash_program(), vec!["-c".into(), script.into()]),
441            ShellSpec::PowerShell => (
442                powershell_program(),
443                vec!["-NoProfile".into(), "-Command".into(), script.into()],
444            ),
445        };
446        let effective_env = ctx.capabilities.env.as_ref()
447            .map(|env| env.iter().map(|(k, v)| (k.into(), v.into())).collect())
448            .unwrap_or_else(crate::process_scope::inherited_env);
449        let probe_env = effective_env.clone();
450        let probe_cwd = ctx.capabilities.cwd.clone();
451        // This worker can only launch a bounded throwaway probe. Abandoning
452        // this future can never launch the real command from the worker.
453        if ctx.capabilities.launch_cancel.as_ref().is_some_and(|c| c.is_cancelled()) {
454            return Err(RuntimeError::Tool("Command canceled before launch".into()));
455        }
456        let probe_task = tokio::task::spawn_blocking(move || {
457            crate::process_scope::launcher_for_effective_env(
458                &probe_env, probe_cwd.as_deref(), crate::process_scope::ScopeKind::Tool)
459        });
460        let launcher = if let Some(cancel) = &ctx.capabilities.launch_cancel {
461            tokio::select! {
462                biased;
463                _ = cancel.cancelled() => return Err(RuntimeError::Tool("Command canceled before launch".into())),
464                result = probe_task => result,
465            }
466        } else {
467            probe_task.await
468        }.map_err(|e| RuntimeError::Tool(e.to_string()))?;
469        let (program, args) = match launcher {
470            Some(launcher) => launcher.wrap(spec.scope_description(),
471                crate::process_scope::resolve_program(program, &effective_env,
472                    ctx.capabilities.cwd.as_deref()).map_err(|e| RuntimeError::Tool(e.to_string()))?, args),
473            None => (program, args),
474        };
475        let mut cmd = tokio::process::Command::new(program);
476        if let Some(cwd) = ctx.capabilities.cwd.as_deref() {
477            cmd.current_dir(cwd);
478        }
479        cmd.env_clear().envs(effective_env);
480        #[cfg(target_os = "linux")]
481        if let Some(broker) = &askpass {
482            cmd.env("SUDO_ASKPASS", broker.helper_path());
483        }
484        cmd.args(&args)
485            .stdin(std::process::Stdio::null())
486            .stdout(std::process::Stdio::piped())
487            .stderr(std::process::Stdio::piped())
488            .kill_on_drop(true);
489
490        // Start the child in a new session (setsid) so it has no controlling
491        // terminal. Programs that open /dev/tty directly (SSH fingerprint
492        // prompts, gpg pinentry, git credential helpers, pagers) will get
493        // ENXIO and fail with a readable error on stderr instead of hanging
494        // invisibly until timeout. Sudo asks through its askpass helper
495        // instead (see bash_script_with_secure_sudo()).
496        #[cfg(unix)]
497        unsafe {
498            cmd.pre_exec(|| {
499                libc::setsid();
500                Ok(())
501            });
502        }
503
504        if ctx.capabilities.launch_cancel.as_ref().is_some_and(|c| c.is_cancelled()) {
505            return Err(RuntimeError::Tool("Command canceled before launch".into()));
506        }
507        let mut child = cmd.spawn().map_err(|e| RuntimeError::Tool(e.to_string()))?;
508        // Armed until the command finishes on its own: a timeout, a cancel,
509        // the output cap or an abandoned password prompt kills the whole
510        // process group, not just the shell.
511        let mut group = KillGroupUnlessFinished::new(child.id());
512
513        let stdout = child
514            .stdout
515            .take()
516            .ok_or_else(|| RuntimeError::Tool("Failed to capture stdout".to_string()))?;
517        let stderr = child
518            .stderr
519            .take()
520            .ok_or_else(|| RuntimeError::Tool("Failed to capture stderr".to_string()))?;
521        // Serve sudo's password requests for as long as the command runs.
522        let redactions = Redactions::default();
523        let password_cancel = crate::CancellationToken::new();
524        #[cfg(target_os = "linux")]
525        let _askpass = askpass.and_then(|broker| {
526            broker
527                .start(
528                    child.id(),
529                    ctx.capabilities.secret_prompt.clone(),
530                    redactions.clone(),
531                    password_cancel.clone(),
532                )
533                .map_err(|e| tracing::warn!("sudo askpass unavailable for this command: {e}"))
534                .ok()
535        });
536        #[cfg(not(target_os = "linux"))]
537        let _ = askpass;
538
539        let (tx_inter, mut rx_inter) =
540            tokio::sync::mpsc::channel::<(bool, HandoffChunk)>(BASH_INTERMEDIARY_CHANNEL_CAPACITY);
541
542        tokio::spawn(forward_output(stdout, false, tx_inter.clone()));
543        tokio::spawn(forward_output(stderr, true, tx_inter.clone()));
544
545        drop(tx_inter);
546
547        let result = tokio::time::timeout(tokio::time::Duration::from_secs(timeout_secs), async {
548            let mut full_output = String::new();
549            let mut truncated = false;
550            let mut streamed_bytes = 0usize;
551
552            loop {
553                let item = tokio::select! {
554                    biased;
555                    // The user cancelled sudo's password prompt: stop the
556                    // command rather than let it run on without it.
557                    _ = password_cancel.cancelled() => {
558                        group.kill_now();
559                        let _ = child.kill().await;
560                        return Err(RuntimeError::Tool(
561                            "Command canceled while waiting for password".to_string(),
562                        ));
563                    }
564                    item = rx_inter.recv() => item,
565                };
566                let Some((_is_stderr, chunk)) = item else {
567                    break;
568                };
569                let mut msg = chunk.consume();
570                for secret in redactions.lock().unwrap_or_else(|p| p.into_inner()).iter() {
571                    if !secret.is_empty() {
572                        msg = msg.replace(secret.as_str(), "[redacted]");
573                    }
574                }
575
576                if truncated {
577                    continue;
578                }
579
580                let added_all = append_bounded(&mut full_output, &msg, max_output);
581                if let Some(ref txd) = ctx.channels.tx_delta {
582                    if streamed_bytes < MAX_STREAMED_DELTA_BYTES {
583                        let remaining = MAX_STREAMED_DELTA_BYTES - streamed_bytes;
584                        let delta = if msg.len() <= remaining {
585                            msg.clone()
586                        } else {
587                            crate::truncate_str(&msg, remaining).to_string()
588                        };
589                        streamed_bytes += delta.len();
590                        if !delta.is_empty() {
591                            txd.send(delta);
592                        }
593                    }
594                }
595
596                if !added_all {
597                    full_output.push_str(&format!("\n\n[output truncated at {}]", max_output));
598                    if let Some(ref txd) = ctx.channels.tx_delta {
599                        txd.send(format!("\n\n[output truncated at {}]", max_output));
600                    }
601                    truncated = true;
602                    group.kill_now();
603                    let _ = child.kill().await;
604                }
605            }
606            let status = child.wait().await;
607            // wait errors may mean the leader was already reaped externally;
608            // never signal a potentially recycled process-group ID afterwards.
609            group.finished();
610            let status = status.map_err(|e| RuntimeError::Tool(e.to_string()))?;
611            // Finished on its own: leave anything it deliberately started in
612            // the background (`server &`) running, as before.
613            // Drop (and so zeroize) the passwords now the command is done.
614            redactions.lock().unwrap_or_else(|p| p.into_inner()).clear();
615            Ok::<_, RuntimeError>((status, full_output, truncated))
616        })
617        .await;
618
619        match result {
620            Ok(Ok((status, output, was_truncated))) => {
621                if status.success() || was_truncated {
622                    Ok(output)
623                } else {
624                    let mut notice = String::new();
625                    // T5: when a command fails and references a stripped secret,
626                    // append a notice (once per name per session).
627                    if !ctx.capabilities.env_stripped.is_empty() {
628                        let mut warned = ctx.capabilities.env_warned.lock().unwrap_or_else(|p| p.into_inner());
629                        for name in &ctx.capabilities.env_stripped {
630                            if warned.contains(name) {
631                                continue;
632                            }
633                            // Word-boundary match: covers $NAME, ${NAME},
634                            // ${NAME:?}, ${NAME:-x}, $env:NAME and NAME=…
635                            if references_env_name(script, name) {
636                                notice.push_str(&format!(
637                                    "\nnote: ${name} was stripped from the session env as a secret; \
638                                     reattach from a shell that has it \
639                                     (synaps --system … from that shell) \
640                                     — the daemon never receives credentials."
641                                ));
642                                warned.insert(name.clone());
643                            }
644                        }
645                    }
646                    Err(RuntimeError::Tool(format!(
647                        "Command failed (exit {}):\n{}{notice}",
648                        status.code().unwrap_or(-1),
649                        output
650                    )))
651                }
652            }
653            Ok(Err(e)) => Err(RuntimeError::Tool(format!(
654                "Failed to execute command: {}",
655                e
656            ))),
657            Err(_) => Err(RuntimeError::Tool(format!(
658                "Command timed out after {}s",
659                timeout_secs
660            ))),
661        }
662    }
663}
664
665/// Does `script` mention `name` as a whole identifier (not as a substring of
666/// a longer one)? Used by the T5 stripped-secret notice; a false positive
667/// costs one advisory line on an already-failing command, a false negative
668/// hides the reason the command failed — so err towards matching.
669fn references_env_name(script: &str, name: &str) -> bool {
670    let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
671    let mut from = 0;
672    while let Some(pos) = script[from..].find(name) {
673        let start = from + pos;
674        let end = start + name.len();
675        let before_ok = !script[..start].chars().next_back().is_some_and(is_ident);
676        let after_ok = !script[end..].chars().next().is_some_and(is_ident);
677        if before_ok && after_ok {
678            return true;
679        }
680        from = end;
681    }
682    false
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688
689    #[test]
690    fn explicit_child_kills_signal_and_disarm_group_before_reaping() {
691        let source = include_str!("bash.rs");
692        let executor = source.split("pub(crate) async fn run_shell_command").nth(1).unwrap()
693            .split("#[cfg(test)]").next().unwrap();
694        let kills: Vec<_> = executor.match_indices("let _ = child.kill().await;").collect();
695        assert_eq!(kills.len(), 2, "audit every new explicit kill path");
696        for (offset, _) in kills {
697            assert!(executor[..offset].trim_end().ends_with("group.kill_now();"));
698        }
699        assert!(executor.contains("let status = child.wait().await;"));
700        let wait = executor.split("let status = child.wait().await;").nth(1).unwrap();
701        assert!(wait.find("group.finished();").unwrap() < wait.find("status.map_err").unwrap());
702        assert!(executor.find("let mut child = cmd.spawn()").unwrap()
703            < executor.find("let mut group = KillGroupUnlessFinished::new").unwrap());
704    }
705
706    #[tokio::test]
707    async fn canceled_launch_never_runs_payload() {
708        let dir = tempfile::tempdir().unwrap();
709        let marker = dir.path().join("must-not-exist");
710        let token = crate::CancellationToken::new();
711        token.cancel();
712        let mut ctx = create_tool_context();
713        ctx.capabilities.launch_cancel = Some(token);
714        let error = BashTool.execute(json!({"command": format!("touch '{}'", marker.display())}), ctx)
715            .await.unwrap_err().to_string();
716        assert!(error.contains("canceled before launch"), "{error}");
717        assert!(!marker.exists());
718    }
719
720    #[cfg(target_os = "linux")]
721    fn own_cgroup() -> String {
722        std::fs::read_to_string("/proc/self/cgroup")
723            .unwrap_or_default()
724            .trim()
725            .to_string()
726    }
727
728    /// The command runs in its own systemd scope when the machine supports
729    /// it, so an out-of-memory kill aimed at it cannot take the daemon's
730    /// cgroup down with it (S348). Without a reachable user manager (CI
731    /// containers), the command must still run, in this process's cgroup.
732    #[cfg(target_os = "linux")]
733    #[tokio::test]
734    async fn bash_command_gets_its_own_scope_when_available() {
735        if std::env::var("SYNAPS_TEST_REAL_SCOPES").as_deref() != Ok("1") { return; }
736        let out = BashTool
737            .execute(
738                json!({ "command": "cat /proc/self/cgroup; echo pid=$$", "timeout": 30 }),
739                create_tool_context(),
740            )
741            .await
742            .unwrap();
743        let child_cgroup = out.lines().next().unwrap_or_default().trim().to_string();
744        assert!(child_cgroup.starts_with("0::/"), "cgroup v2 line: {out}");
745        if crate::process_scope::launcher_for_env(None).is_some() {
746            assert_ne!(
747                child_cgroup,
748                own_cgroup(),
749                "the command must leave our cgroup"
750            );
751            assert!(child_cgroup.ends_with(".scope"), "{child_cgroup}");
752            assert!(child_cgroup.contains("/app.slice/"), "{child_cgroup}");
753        } else {
754            assert_eq!(child_cgroup, own_cgroup(), "unwrapped fallback");
755        }
756    }
757
758    /// A bash command's scope goes in `tool_scope_slice`, never in the
759    /// daemon's slice; the probe carries the same `--slice`.
760    #[cfg(target_os = "linux")]
761    #[tokio::test]
762    #[serial_test::serial(synaps_base_dir)]
763    async fn a_bash_command_is_scoped_in_the_tool_slice() {
764        use crate::process_scope::test_support::{fake_env, fake_systemd_run, recorded};
765        let base = crate::test_env::BaseDirGuard::new();
766        let _scopes = crate::test_env::EnvVarGuard::remove("SYNAPS_PROCESS_SCOPES");
767        std::fs::write(
768            base.path().join("config"),
769            "tool_scope_slice = t_tools.slice\ndaemon_scope_slice = d_daemon.slice\n",
770        )
771        .unwrap();
772        let dir = tempfile::tempdir().unwrap();
773        let fake = fake_systemd_run(dir.path());
774        let mut env = fake_env(&fake);
775        env.push(("PATH".to_string(), std::env::var("PATH").unwrap_or_default()));
776        let mut ctx = create_tool_context();
777        ctx.capabilities.env = Some(env);
778        let out = BashTool
779            .execute(json!({ "command": "echo scoped-run", "timeout": 30 }), ctx)
780            .await
781            .unwrap();
782        assert!(out.contains("scoped-run"), "{out}");
783        let launch = recorded(dir.path(), "launch.args");
784        let slices: Vec<&String> = launch.iter().filter(|a| a.starts_with("--slice")).collect();
785        assert_eq!(slices, ["--slice=t_tools.slice"], "{launch:?}");
786        assert!(recorded(dir.path(), "probe.args").contains(&"--slice=t_tools.slice".to_string()));
787    }
788
789    /// A session environment rebuilt without the user-bus variables cannot
790    /// reach systemd: the command starts unwrapped instead of failing.
791    #[cfg(target_os = "linux")]
792    #[tokio::test]
793    async fn bash_command_with_a_rebuilt_env_lacking_the_user_bus_runs_unwrapped() {
794        let mut ctx = create_tool_context();
795        ctx.capabilities.env = Some(vec![(
796            "PATH".to_string(),
797            std::env::var("PATH").unwrap_or_default(),
798        )]);
799        let out = BashTool
800            .execute(
801                json!({ "command": "cat /proc/self/cgroup", "timeout": 30 }),
802                ctx,
803            )
804            .await
805            .unwrap();
806        assert_eq!(out.lines().next().unwrap_or_default().trim(), own_cgroup());
807    }
808
809    #[cfg(target_os = "linux")]
810    fn processes_named(name: &str) -> Vec<String> {
811        let out = std::process::Command::new("pgrep")
812            .args(["-f", name])
813            .output()
814            .unwrap();
815        String::from_utf8_lossy(&out.stdout)
816            .split_whitespace()
817            .map(str::to_string)
818            .collect()
819    }
820
821    /// A timed-out command takes everything it started with it, not just the
822    /// shell (S348: a timed-out test run would leave all its workers running).
823    /// Each child is renamed to the marker (`exec -a`), so a survivor is seen.
824    #[cfg(target_os = "linux")]
825    #[tokio::test]
826    async fn bash_timeout_kills_the_whole_process_group() {
827        let marker = format!("synaps-timeout-child-{}", std::process::id());
828        let out = BashTool
829            .execute(
830                json!({
831                    "command": format!("(exec -a {marker} sleep 300) & (exec -a {marker} sleep 300); wait"),
832                    "timeout": 1
833                }),
834                create_tool_context(),
835            )
836            .await;
837        let text = match out {
838            Ok(s) => s,
839            Err(e) => e.to_string(),
840        };
841        assert!(text.contains("timed out"), "{text}");
842        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
843        while !processes_named(&marker).is_empty() && std::time::Instant::now() < deadline {
844            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
845        }
846        let left = processes_named(&marker);
847        if !left.is_empty() {
848            let _ = std::process::Command::new("pkill")
849                .args(["-f", &marker])
850                .status();
851        }
852        assert!(left.is_empty(), "children survived the timeout: {left:?}");
853    }
854
855    /// A command that exits by itself keeps what it deliberately started in
856    /// the background, as before: only abnormal endings kill the group.
857    #[cfg(target_os = "linux")]
858    #[tokio::test]
859    async fn bash_normal_exit_leaves_a_background_job_running() {
860        let marker = format!("synaps-background-job-{}", std::process::id());
861        let out = BashTool
862            .execute(
863                json!({
864                    "command": format!("(exec -a {marker} sleep 30) >/dev/null 2>&1 & echo started"),
865                    "timeout": 10
866                }),
867                create_tool_context(),
868            )
869            .await
870            .unwrap();
871        assert!(out.contains("started"), "{out}");
872        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
873        let alive = processes_named(&marker);
874        let _ = std::process::Command::new("pkill")
875            .args(["-f", &marker])
876            .status();
877        assert!(
878            !alive.is_empty(),
879            "a deliberate background job must survive a normal exit"
880        );
881    }
882
883    #[tokio::test]
884    async fn bash_intermediary_handoff_conserves_bytes_under_large_output() {
885        let before = bash_intermediary_snapshot();
886        let tool = BashTool;
887        let mut ctx = create_tool_context();
888        ctx.limits.max_tool_buffer = 64 * 1024;
889        let result = tool
890            .execute(
891                json!({
892                    "command": "python3 -c \"import sys; sys.stdout.write('x' * 1048576)\"",
893                    "timeout": 30
894                }),
895                ctx,
896            )
897            .await
898            .unwrap();
899        assert!(result.contains("output truncated"));
900
901        // The counters are process-global and sibling tests run bash
902        // concurrently, so a before/after delta is skewed by whatever a
903        // sibling had mid-channel when `before` was read, and that skew
904        // never rebalances. Assert the invariant that does hold: once
905        // in-flight relays drain, every byte ever produced in this process
906        // was consumed or dropped. Siblings only delay this; a leaked byte
907        // keeps it false forever.
908        let after = wait_handoff_settled().await;
909        assert_eq!(after.retained_bytes, 0);
910        assert!(after.accepted_bytes >= after.consumed_bytes);
911        assert!(after.produced_bytes - before.produced_bytes >= 64 * 1024);
912    }
913
914    #[test]
915    fn sanitizes_terminal_control_sequences_and_nuls() {
916        let cleaned = sanitize_output("ok\x1b[2J\x00done");
917        assert_eq!(cleaned, "okdone");
918    }
919
920    use super::super::test_helpers::create_tool_context;
921    use crate::tools::Tool;
922    use serde_json::json;
923
924    #[test]
925    fn test_bash_tool_schema() {
926        let tool = BashTool;
927        assert_eq!(tool.name(), "bash");
928        assert!(!tool.description().is_empty());
929
930        let params = tool.parameters();
931        assert_eq!(params["type"], "object");
932        assert!(params["properties"].is_object());
933        assert!(params["required"].is_array());
934    }
935
936    #[tokio::test]
937    async fn test_bash_tool_execution() {
938        let tool = BashTool;
939
940        // Test simple echo command
941        let ctx = create_tool_context();
942        let params = json!({
943            "command": "echo hello"
944        });
945
946        let result = tool.execute(params, ctx).await.unwrap();
947        assert!(result.contains("hello"));
948
949        // Test timeout parameter with quick command
950        let ctx = create_tool_context();
951        let params = json!({
952            "command": "sleep 1",
953            "timeout": 2
954        });
955
956        let result = tool.execute(params, ctx).await;
957        // Should succeed (1 second sleep with 2 second timeout)
958        assert!(result.is_ok());
959
960        // Test timeout with longer command
961        let ctx = create_tool_context();
962        let params = json!({
963            "command": "sleep 3",
964            "timeout": 1
965        });
966
967        let result = tool.execute(params, ctx).await;
968        // Should timeout
969        assert!(result.is_err());
970        assert!(result.unwrap_err().to_string().contains("timed out"));
971    }
972
973    #[tokio::test]
974    async fn test_bash_tool_requested_timeout_is_clamped_by_max_timeout() {
975        let tool = BashTool;
976        let mut ctx = create_tool_context();
977        ctx.limits.bash_max_timeout = 1;
978
979        let params = json!({
980            "command": "sleep 5; echo done",
981            "timeout": 10
982        });
983
984        let result = tool.execute(params, ctx).await;
985        assert!(
986            result.unwrap_err().to_string().contains("timed out"),
987            "requested timeout MUST be clamped by bash_max_timeout"
988        );
989    }
990
991    fn prompt_channel() -> (
992        crate::tools::SecretPromptHandle,
993        tokio::sync::mpsc::UnboundedReceiver<crate::tools::SecretPromptRequest>,
994    ) {
995        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
996        (crate::tools::SecretPromptHandle::new(tx), rx)
997    }
998
999    /// Output that merely looks like a password prompt. The first line is
1000    /// what a diagnostic printing the tool's own command lines showed on
1001    /// the day this misfired: the old wrapper, prompt string included.
1002    const PROMPT_LOOKALIKES: &str = r#"printf '%s\n' "command sudo -S -p '[sudo] password required: ' \"\$@\"" >&2
1003printf '[sudo] password for x: ' >&2
1004printf 'Password: ' >&2
1005if read -r got; then echo "stdin gave: $got"; else echo stdin-eof; fi"#;
1006
1007    /// Regression: text on stderr can no longer raise a password prompt,
1008    /// and nothing is ever written to the command's stdin (it is empty).
1009    #[tokio::test]
1010    async fn sudo_prompt_lookalike_output_raises_no_prompt_and_feeds_no_stdin() {
1011        let (handle, mut prompts) = prompt_channel();
1012        let mut ctx = create_tool_context();
1013        ctx.capabilities.secret_prompt = Some(handle);
1014        let started = std::time::Instant::now();
1015        let out = BashTool
1016            .execute(json!({"command": PROMPT_LOOKALIKES, "timeout": 30}), ctx)
1017            .await
1018            .unwrap();
1019        assert!(out.contains("stdin-eof"), "{out}");
1020        assert!(out.contains("[sudo] password for x: "), "it is only output: {out}");
1021        assert!(out.contains("password required: "), "{out}");
1022        assert!(prompts.try_recv().is_err(), "no prompt was raised");
1023        assert!(started.elapsed() < std::time::Duration::from_secs(10));
1024    }
1025
1026    #[cfg(unix)]
1027    #[test]
1028    fn the_sudo_wrapper_asks_through_askpass_or_not_at_all() {
1029        let script = super::bash_script_with_secure_sudo("sudo id", true);
1030        assert_eq!(script, "sudo() {\n    command sudo -A \"$@\"\n}\nsudo id");
1031        let script = super::bash_script_with_secure_sudo("sudo id", false);
1032        assert_eq!(script, "sudo() {\n    command sudo -n \"$@\"\n}\nsudo id");
1033        for askpass in [true, false] {
1034            let script = super::bash_script_with_secure_sudo("x", askpass);
1035            assert!(!script.contains(" -S") && !script.contains("password"), "{script}");
1036        }
1037    }
1038
1039    /// Without the broker (this test binary does not dispatch the helper),
1040    /// sudo is told never to ask: it fails with its own error, no prompt.
1041    #[cfg(target_os = "linux")]
1042    #[tokio::test]
1043    async fn without_askpass_sudo_never_asks() {
1044        let fake = tempfile::tempdir().unwrap();
1045        crate::tools::sudo_askpass::testing::write_fixtures(fake.path()).unwrap();
1046        let (handle, mut prompts) = prompt_channel();
1047        let mut ctx = create_tool_context();
1048        ctx.capabilities.secret_prompt = Some(handle);
1049        let command = format!(
1050            "export PATH=\"{}:$PATH\"; sudo echo ran; echo after:$?",
1051            fake.path().display()
1052        );
1053        let out = BashTool
1054            .execute(json!({"command": command, "timeout": 30}), ctx)
1055            .await
1056            .unwrap();
1057        assert!(out.contains("sudo: a password is required"), "{out}");
1058        assert!(out.contains("after:1") && !out.contains("ran\n"), "{out}");
1059        assert!(prompts.try_recv().is_err());
1060    }
1061
1062    #[tokio::test]
1063    async fn test_bash_control_char_output_is_sanitized_and_bounded_in_deltas() {
1064        let tool = BashTool;
1065        let channel = crate::tools::output::delta_channel();
1066        let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
1067        let mut ctx = create_tool_context();
1068        ctx.channels.tx_delta = Some(delta_tx);
1069        ctx.limits.max_tool_buffer = 256;
1070
1071        let params = json!({
1072            "command": "python3 -c \"import sys; sys.stdout.buffer.write(b'clean\\x1b[2J\\x00' + b'A' * 2000); sys.stdout.flush()\"",
1073            "timeout": 30
1074        });
1075
1076        let result = tool.execute(params, ctx).await.unwrap();
1077        let mut streamed = String::new();
1078        while let Some(delta) = delta_rx.try_drain() {
1079            streamed.push_str(&delta);
1080        }
1081
1082        assert!(result.contains("[output truncated at 256]"));
1083        assert!(!result.contains('\u{1b}'));
1084        assert!(!result.contains('\0'));
1085        assert!(!streamed.contains('\u{1b}'));
1086        assert!(!streamed.contains('\0'));
1087        assert!(
1088            streamed.len() <= 2048,
1089            "streamed deltas must be bounded, got {} bytes",
1090            streamed.len()
1091        );
1092    }
1093
1094    #[tokio::test]
1095    async fn test_bash_binary_output_is_sanitized() {
1096        let tool = BashTool;
1097        let ctx = create_tool_context();
1098        let params = json!({
1099            "command": "python3 -c \"import sys; sys.stdout.buffer.write(bytes(range(32)) + b'visible')\"",
1100            "timeout": 30
1101        });
1102
1103        let result = tool.execute(params, ctx).await.unwrap();
1104
1105        assert!(result.contains("visible"));
1106        assert!(!result.contains('\0'));
1107        assert!(!result.contains('\u{1b}'));
1108    }
1109
1110    #[tokio::test]
1111    async fn test_bash_tool_timeout() {
1112        let tool = BashTool;
1113        let ctx = create_tool_context();
1114
1115        let params = json!({
1116            "command": "sleep 10",
1117            "timeout": 1
1118        });
1119
1120        let result = tool.execute(params, ctx).await;
1121
1122        // Should timeout and return error
1123        assert!(result.is_err());
1124        let error = result.unwrap_err().to_string();
1125        assert!(error.contains("timed out"));
1126    }
1127
1128    #[tokio::test]
1129    async fn test_bash_tool_failure() {
1130        let tool = BashTool;
1131        let ctx = create_tool_context();
1132
1133        let params = json!({
1134            "command": "exit 1"
1135        });
1136
1137        let result = tool.execute(params, ctx).await;
1138
1139        // Should fail and return error
1140        assert!(result.is_err());
1141        let error = result.unwrap_err().to_string();
1142        assert!(error.contains("failed") || error.contains("exit"));
1143    }
1144
1145    #[cfg(unix)]
1146    #[tokio::test]
1147    async fn bash_honours_capability_cwd_when_set() {
1148        let tmp = tempfile::TempDir::new().unwrap();
1149        let expected = tmp.path().canonicalize().unwrap();
1150        let mut ctx = create_tool_context();
1151        ctx.capabilities.cwd = Some(tmp.path().to_path_buf());
1152        let out = BashTool
1153            .execute(json!({"command": "pwd -P"}), ctx)
1154            .await
1155            .unwrap();
1156        assert_eq!(out.trim(), expected.to_string_lossy());
1157    }
1158
1159    #[cfg(unix)]
1160    #[tokio::test]
1161    async fn bash_inherits_process_cwd_when_capability_cwd_is_none() {
1162        let expected = std::env::current_dir().unwrap().canonicalize().unwrap();
1163        let ctx = create_tool_context();
1164        assert!(ctx.capabilities.cwd.is_none());
1165        let out = BashTool
1166            .execute(json!({"command": "pwd -P"}), ctx)
1167            .await
1168            .unwrap();
1169        assert_eq!(out.trim(), expected.to_string_lossy());
1170    }
1171
1172    #[test]
1173    fn references_env_name_is_word_bounded() {
1174        assert!(references_env_name("echo ${GH_TOKEN:?}", "GH_TOKEN"));
1175        assert!(references_env_name("echo $GH_TOKEN", "GH_TOKEN"));
1176        assert!(references_env_name("GH_TOKEN=x gh auth", "GH_TOKEN"));
1177        assert!(references_env_name("$env:GH_TOKEN", "GH_TOKEN"));
1178        assert!(!references_env_name("echo $GH_TOKEN_FILE", "GH_TOKEN"));
1179        assert!(!references_env_name("echo $MY_GH_TOKEN", "GH_TOKEN"));
1180        assert!(!references_env_name("gh auth status", "GH_TOKEN"));
1181    }
1182
1183    /// T5 notice: non-zero exit + script references stripped var → notice.
1184    #[cfg(unix)]
1185    #[tokio::test]
1186    async fn bash_notice_on_stripped_secret_reference() {
1187        let warned = std::sync::Arc::new(std::sync::Mutex::new(
1188            std::collections::HashSet::<String>::new(),
1189        ));
1190        // --- case 1: non-zero exit + reference → notice
1191        let mut ctx = create_tool_context();
1192        ctx.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1193        ctx.capabilities.env_warned = warned.clone();
1194        let err = BashTool
1195            .execute(json!({"command": "echo ${GH_TOKEN:?} 2>&1; exit 1"}), ctx)
1196            .await
1197            .unwrap_err();
1198        let msg = format!("{err}");
1199        assert!(
1200            msg.contains("note: $GH_TOKEN was stripped"),
1201            "notice must name the var: {msg}"
1202        );
1203
1204        // --- case 2: zero exit → no notice
1205        let mut ctx2 = create_tool_context();
1206        ctx2.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1207        ctx2.capabilities.env_warned = warned.clone();
1208        let out = BashTool
1209            .execute(json!({"command": "echo $GH_TOKEN; exit 0"}), ctx2)
1210            .await
1211            .unwrap();
1212        assert!(
1213            !out.contains("note:"),
1214            "zero exit must not produce a notice: {out}"
1215        );
1216
1217        // --- case 3: second failure → no duplicate (same Arc)
1218        let mut ctx3 = create_tool_context();
1219        ctx3.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1220        ctx3.capabilities.env_warned = warned.clone();
1221        let err2 = BashTool
1222            .execute(json!({"command": "echo ${GH_TOKEN}; exit 1"}), ctx3)
1223            .await
1224            .unwrap_err();
1225        let msg2 = format!("{err2}");
1226        assert!(
1227            !msg2.contains("note: $GH_TOKEN was stripped"),
1228            "dedup must suppress the second notice: {msg2}"
1229        );
1230    }
1231}
1232
1233/// Issue #286: a multi-byte character split across two reads must come back
1234/// whole, not as U+FFFD.
1235#[cfg(test)]
1236mod utf8_chunk_tests {
1237    use super::*;
1238    use crate::tools::Tool;
1239    use serde_json::json;
1240    use std::collections::VecDeque;
1241    use std::pin::Pin;
1242    use std::task::{Context, Poll};
1243
1244    /// Yields exactly the given chunks, one per `read`, then EOF.
1245    struct ChunkedReader(VecDeque<Vec<u8>>);
1246
1247    impl tokio::io::AsyncRead for ChunkedReader {
1248        fn poll_read(
1249            mut self: Pin<&mut Self>,
1250            _cx: &mut Context<'_>,
1251            buf: &mut tokio::io::ReadBuf<'_>,
1252        ) -> Poll<std::io::Result<()>> {
1253            if let Some(chunk) = self.0.pop_front() {
1254                assert!(chunk.len() <= buf.remaining(), "test chunk larger than read buffer");
1255                buf.put_slice(&chunk);
1256            }
1257            Poll::Ready(Ok(()))
1258        }
1259    }
1260
1261    async fn forward_chunks(chunks: Vec<Vec<u8>>, is_stderr: bool) -> String {
1262        let (tx, mut rx) = tokio::sync::mpsc::channel(1024);
1263        forward_output(ChunkedReader(chunks.into()), is_stderr, tx).await;
1264        let mut out = String::new();
1265        while let Some((stderr, chunk)) = rx.recv().await {
1266            let msg = chunk.consume();
1267            assert_eq!(stderr, is_stderr);
1268            out.push_str(&msg);
1269        }
1270        out
1271    }
1272
1273    #[tokio::test]
1274    async fn characters_split_at_every_offset_survive_both_streams() {
1275        for ch in ['é', '▀', '😀'] {
1276            let text = format!("a{ch}b{ch}{ch}c");
1277            let bytes = text.as_bytes();
1278            for split in 1..bytes.len() {
1279                for is_stderr in [false, true] {
1280                    let out = forward_chunks(
1281                        vec![bytes[..split].to_vec(), bytes[split..].to_vec()],
1282                        is_stderr,
1283                    )
1284                    .await;
1285                    assert!(!out.contains('\u{FFFD}'), "{ch:?} split at {split}: {out:?}");
1286                    assert_eq!(out.as_bytes(), bytes, "{ch:?} split at {split}");
1287                }
1288            }
1289        }
1290    }
1291
1292    #[tokio::test]
1293    async fn one_byte_reads_reassemble_every_character() {
1294        let text = "é▀😀 mixed ascii ✓ 日本語";
1295        let chunks = text.bytes().map(|b| vec![b]).collect();
1296        assert_eq!(forward_chunks(chunks, false).await, text);
1297    }
1298
1299    #[tokio::test]
1300    async fn genuinely_invalid_bytes_still_become_replacement_characters() {
1301        assert_eq!(forward_chunks(vec![b"ok\xffok".to_vec()], false).await, "ok\u{FFFD}ok");
1302        // A lead byte followed by a non-continuation byte in the next read.
1303        assert_eq!(
1304            forward_chunks(vec![b"a\xe2".to_vec(), b"Xb".to_vec()], false).await,
1305            "a\u{FFFD}Xb"
1306        );
1307        // A stray continuation byte at the start of a read.
1308        assert_eq!(
1309            forward_chunks(vec![b"a".to_vec(), b"\x80b".to_vec()], false).await,
1310            "a\u{FFFD}b"
1311        );
1312    }
1313
1314    #[tokio::test]
1315    async fn incomplete_sequence_at_eof_is_flushed_not_dropped() {
1316        assert_eq!(forward_chunks(vec![b"ab\xe2\x96".to_vec()], false).await, "ab\u{FFFD}");
1317        assert_eq!(
1318            forward_chunks(vec![b"ab".to_vec(), b"\xf0\x9f".to_vec()], true).await,
1319            "ab\u{FFFD}"
1320        );
1321    }
1322
1323    /// A consumer that gives up (timeout, cancelled password prompt) drops
1324    /// the receiver with chunks still queued. Those bytes must be counted as
1325    /// dropped, or the process-wide accounting never settles again.
1326    #[tokio::test]
1327    async fn chunks_still_queued_when_the_receiver_goes_away_count_as_dropped() {
1328        let (tx, rx) = tokio::sync::mpsc::channel(16);
1329        let chunks: Vec<Vec<u8>> = (0..8).map(|_| b"queued".to_vec()).collect();
1330        forward_output(ChunkedReader(chunks.into()), false, tx).await;
1331        let before = bash_intermediary_snapshot();
1332        drop(rx);
1333        let after = wait_handoff_settled().await;
1334        assert!(after.dropped_bytes - before.dropped_bytes >= 8 * 6);
1335    }
1336
1337    /// A password prompt has no trailing newline; it must be forwarded as soon
1338    /// as it is read, not held back waiting for more output or EOF.
1339    #[tokio::test]
1340    async fn prompt_without_newline_is_forwarded_before_eof() {
1341        let (mut writer, reader) = tokio::io::duplex(4096);
1342        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1343        let task = tokio::spawn(forward_output(reader, true, tx));
1344        use tokio::io::AsyncWriteExt;
1345        writer.write_all("[sudo] password for x: ".as_bytes()).await.unwrap();
1346        let (is_stderr, chunk) =
1347            tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
1348                .await
1349                .expect("prompt must arrive without EOF")
1350                .unwrap();
1351        let msg = chunk.consume();
1352        assert!(is_stderr);
1353        assert_eq!(msg, "[sudo] password for x: ");
1354        drop(writer);
1355        task.await.unwrap();
1356    }
1357
1358    /// Through the real tool: more than 1 KiB of 3-byte characters on both
1359    /// streams. 1024 is not a multiple of 3, so the reads split characters.
1360    #[tokio::test]
1361    async fn bash_tool_output_over_1kib_keeps_multibyte_characters_whole() {
1362        let channel = crate::tools::output::delta_channel();
1363        let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
1364        let mut ctx = super::super::test_helpers::create_tool_context();
1365        ctx.channels.tx_delta = Some(delta_tx);
1366        let result = BashTool
1367            .execute(json!({"command": "printf '▀%.0s' {1..2000}", "timeout": 30}), ctx)
1368            .await
1369            .unwrap();
1370        let mut streamed = String::new();
1371        while let Some(delta) = delta_rx.try_drain() {
1372            streamed.push_str(&delta);
1373        }
1374        let expected = "▀".repeat(2000);
1375        assert!(!result.contains('\u{FFFD}'), "result has U+FFFD");
1376        assert_eq!(result, expected);
1377        assert!(!streamed.contains('\u{FFFD}'), "deltas have U+FFFD");
1378        assert!(expected.starts_with(&streamed) && !streamed.is_empty());
1379
1380        let ctx = super::super::test_helpers::create_tool_context();
1381        let err = BashTool
1382            .execute(
1383                json!({"command": "python3 -c \"import sys; sys.stderr.write(('▀'*119+'\\n')*45)\"; exit 3", "timeout": 30}),
1384                ctx,
1385            )
1386            .await
1387            .unwrap_err()
1388            .to_string();
1389        assert!(!err.contains('\u{FFFD}'), "stderr has U+FFFD: {err}");
1390        assert!(err.contains(&("▀".repeat(119) + "\n").repeat(45)), "{err}");
1391    }
1392}