Skip to main content

cosh_tools/bash/
mod.rs

1//! Shared-state wrapper for bash execution.
2//!
3//! [`Bash`] holds execution configuration — environment variables,
4//! PTY mode, and working directory — so callers don't have to
5//! reconstruct options on every invocation.
6//!
7//! # Example
8//!
9//! ```ignore
10//! use cosh_tools::bash::Bash;
11//!
12//! let bash = Bash::new().cwd("/project");
13//! let mut stream = bash.run("ls -la")?;
14//! ```
15
16pub mod bsh;
17#[cfg(test)]
18pub mod test;
19pub mod types;
20
21use std::pin::Pin;
22use tokio_stream::Stream;
23
24pub use types::BashRunInput;
25
26use crate::ToolDescription;
27use crate::bash::bsh::BashError;
28use crate::bash::bsh::SpawnOutput;
29
30/// Clean PTY output for display and model consumption.
31///
32/// PTY chunks carry ANSI escape sequences (colors, cursor moves) and CRLF
33/// line endings (the PTY line discipline turns `\n` into `\r\n`). Strip the
34/// escapes and normalize the line endings so the text is plain, newline-
35/// separated output.
36///
37/// Only feed this function data ending on a **complete line** (see the
38/// carry-buffer loop in the harness dispatch): a byte stream can split an
39/// escape sequence, a multi-byte UTF-8 char, or a CRLF pair across read
40/// boundaries, and a fresh parser per call would corrupt them.
41#[must_use]
42pub fn strip_ansi(bytes: &[u8]) -> String {
43    let clean = strip_ansi_escapes::strip(bytes);
44    let text = String::from_utf8_lossy(&clean);
45    // Normalize CRLF, then drop lone `\r` (progress-bar redraw frames:
46    // `cargo`, `wget`, …) so redraws merge into one line instead of
47    // accumulating invisible carrier returns in the output.
48    text.replace("\r\n", "\n").replace('\r', "")
49}
50
51/// Default execution timeout in milliseconds: 10 minutes (600_000 ms).
52///
53/// This is the timeout a `Bash` built with [`Bash::new`] runs with — the
54/// hang guard described in [`Bash::timeout`]. The value is interpolated into
55/// the `bash_run` tool description so the model knows the baseline it can
56/// extend per call via the optional `timeout_ms` argument.
57pub const DEFAULT_TIMEOUT_MS: u64 = 600_000;
58
59/// Shared-state wrapper for bash execution.
60///
61/// Use the builder methods after [`new`](Self::new) to configure the
62/// environment, then call [`run`](Self::run) to execute a command.
63pub struct Bash {
64    timeout: Option<u64>,
65    env: Option<Vec<(String, String)>>,
66    pty: bool,
67    cwd: String,
68
69    /// MCP Tool description for `run`.
70    pub description_run: ToolDescription,
71}
72
73impl Default for Bash {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl Bash {
80    /// Create a new `Bash` with default settings.
81    ///
82    /// - `timeout`: [`DEFAULT_TIMEOUT_MS`] (10 minutes)
83    /// - `env`: `None` (inherit parent process)
84    /// - `pty`: `false` (piped stdout/stderr, not pseudo-terminal)
85    /// - `cwd`: `""` (inherited from the parent process)
86    #[must_use]
87    pub fn new() -> Self {
88        Self {
89            timeout: Some(DEFAULT_TIMEOUT_MS),
90            env: None,
91            pty: false,
92            cwd: String::new(),
93            description_run: Self::build_description_run(DEFAULT_TIMEOUT_MS),
94        }
95    }
96
97    /// Build the model-facing `bash_run` tool description for a configured
98    /// timeout.
99    ///
100    /// The timeout value is interpolated here, from the **instance field** —
101    /// not from [`DEFAULT_TIMEOUT_MS`] directly — so the text always states
102    /// the threshold that `run_with_timeout` actually enforces, even when a
103    /// caller overrides the default via [`Bash::timeout`]. This struct owns
104    /// the property: the harness never restates the value.
105    fn build_description_run(configured_ms: u64) -> ToolDescription {
106        let description = format!(
107            "Execute a bash command and return its output. \
108             Outputs above the token budget are head/tail-truncated: the \
109             middle is saved to a log file (path given in the truncation \
110             notice) that you can read back in parts with fs_read \
111             (offset/limit) or find_grep. \
112             Environment variables and PTY mode are wrapper configuration, \
113             not call arguments. \
114             The configured default timeout is {configured_ms} \
115             milliseconds ({} minutes); to extend it for a single call, pass \
116             the optional `timeout_ms` argument with an integer strictly \
117             greater than {configured_ms}. Values equal to or below \
118             the default are rejected — the argument can only raise the \
119             timeout, never lower it.",
120            configured_ms / 60_000
121        );
122        serde_json::json!({
123            "name": "bash_run",
124            "description": description,
125            "inputSchema": {
126                "type": "object",
127                "properties": {
128                    "command": {
129                        "type": "string",
130                        "description": concat!(
131                            "The bash command to execute. Must not be an absolute path ",
132                            "and must not match dangerous security patterns."
133                        )
134                    },
135                    "timeout_ms": {
136                        "type": "integer",
137                        "description": concat!(
138                            "Optional. Extend the execution timeout for this call only, ",
139                            "in milliseconds. Must be an integer strictly greater than the ",
140                            "configured default (see the tool description); values equal to ",
141                            "or below the default are rejected. Does not change the default."
142                        )
143                    }
144                },
145                "required": ["command"]
146            }
147        })
148    }
149
150    /// Set the execution timeout in milliseconds.
151    ///
152    /// When the timeout elapses, the child process is killed and the stream
153    /// yields a final item with `signal: Some(-1)` and `exit_code: None`.
154    /// The model-facing tool description is regenerated with the new value,
155    /// so it always states the threshold `run_with_timeout` enforces.
156    #[must_use]
157    pub fn timeout(mut self, ms: u64) -> Self {
158        self.timeout = Some(ms);
159        self.description_run = Self::build_description_run(ms);
160        self
161    }
162
163    /// Set environment variables for the child process.
164    ///
165    /// Pass `None` to inherit the parent's environment.
166    #[must_use]
167    pub fn env(mut self, env: Option<Vec<(String, String)>>) -> Self {
168        self.env = env;
169        self
170    }
171
172    /// Use a pseudo-terminal for the child process.
173    ///
174    /// When `true`, stdout and stderr are multiplexed into the PTY
175    /// (colored output, prompts, etc.). When `false` (default), they
176    /// are captured as separate piped streams.
177    #[must_use]
178    pub const fn pty(mut self, v: bool) -> Self {
179        self.pty = v;
180        self
181    }
182
183    /// Set the working directory for the command.
184    #[must_use]
185    pub fn cwd(mut self, path: impl Into<String>) -> Self {
186        self.cwd = path.into();
187        self
188    }
189
190    /// Execute a bash command and return its output as an async stream.
191    ///
192    /// The returned stream borrows from `self` — it must not outlive the
193    /// [`Bash`] instance.
194    ///
195    /// # Errors
196    ///
197    /// Returns [`BashError`] if the command is an absolute path or matches
198    /// a dangerous security pattern.
199    pub fn run<'a>(
200        &'a self,
201        command: &'a str,
202    ) -> Result<Pin<Box<dyn Stream<Item = SpawnOutput> + Send + 'a>>, BashError> {
203        self.run_with_timeout(command, None)
204    }
205
206    /// Execute a bash command with an optional per-call timeout override.
207    ///
208    /// `timeout_ms` extends the configured timeout for **this call only** —
209    /// it never changes the default held by `self`. It may only *raise* the
210    /// timeout: a value that is not strictly greater than the instance's
211    /// configured timeout is rejected with a [`BashError`] and nothing is
212    /// executed. `None` runs with the configured timeout.
213    ///
214    /// # Errors
215    ///
216    /// Returns [`BashError`] if the command is an absolute path, matches a
217    /// dangerous security pattern, or `timeout_ms` fails to raise the
218    /// configured timeout.
219    pub fn run_with_timeout<'a>(
220        &'a self,
221        command: &'a str,
222        timeout_ms: Option<u64>,
223    ) -> Result<Pin<Box<dyn Stream<Item = SpawnOutput> + Send + 'a>>, BashError> {
224        let configured = self.timeout.unwrap_or(0);
225        if let Some(ms) = timeout_ms {
226            if ms <= configured {
227                return Err(BashError {
228                    text_err: Some(format!(
229                        "timeout_ms must be strictly greater than the configured timeout \
230                         ({configured} ms); the per-call argument can only raise the \
231                         timeout, never lower it"
232                    )),
233                    exec_err: None,
234                });
235            }
236            return bsh::run(Some(ms), &self.env, self.pty, command, &self.cwd);
237        }
238        bsh::run(self.timeout, &self.env, self.pty, command, &self.cwd)
239    }
240}
241
242#[cfg(test)]
243mod timeout_default_tests {
244    use super::Bash;
245    use super::DEFAULT_TIMEOUT_MS;
246
247    /// `Bash::new()` ships the 10-minute default — the harness relies on it
248    /// (it no longer calls `.timeout(...)` itself).
249    #[test]
250    fn new_uses_default_timeout() {
251        let bash = Bash::new();
252        assert_eq!(bash.timeout, Some(DEFAULT_TIMEOUT_MS));
253        assert_eq!(DEFAULT_TIMEOUT_MS, 600_000);
254    }
255
256    /// The default is interpolated into the model-facing description so the
257    /// model knows the baseline the `timeout_ms` argument must exceed.
258    #[test]
259    fn description_interpolates_default_timeout() {
260        let desc = Bash::new().description_run;
261        let text = desc["description"].as_str().unwrap();
262        assert!(
263            text.contains("default timeout is 600000 milliseconds (10 minutes)"),
264            "description must state the default timeout, got: {text}"
265        );
266        assert!(
267            text.contains("strictly greater than 600000"),
268            "description must state the raise-only rule, got: {text}"
269        );
270        let schema = &desc["inputSchema"];
271        assert_eq!(
272            schema["properties"]["timeout_ms"]["type"].as_str(),
273            Some("integer"),
274            "inputSchema must expose the optional timeout_ms argument"
275        );
276        // `command` remains the only required argument.
277        assert_eq!(
278            schema["required"].as_array().unwrap(),
279            &["command".to_string()],
280            "timeout_ms must stay optional"
281        );
282    }
283
284    /// A dev-level builder override regenerates the description with the new
285    /// configured value: the text always states the threshold
286    /// `run_with_timeout` enforces — never just the constant's default.
287    #[test]
288    fn builder_timeout_regenerates_description() {
289        let bash = Bash::new().timeout(1_800_000);
290        let text = bash.description_run["description"].as_str().unwrap();
291        assert!(
292            text.contains("default timeout is 1800000 milliseconds (30 minutes)"),
293            "description must track the instance's configured timeout, got: {text}"
294        );
295        assert!(
296            !text.contains("600000"),
297            "stale default must not remain after the override, got: {text}"
298        );
299    }
300
301    /// A `timeout_ms` that fails to raise the configured timeout is rejected
302    /// before anything is spawned.
303    #[test]
304    fn run_with_timeout_rejects_lower_or_equal() {
305        let bash = Bash::new();
306        for ms in [0, DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS - 1] {
307            let err = match bash.run_with_timeout("echo hi", Some(ms)) {
308                Err(err) => err,
309                Ok(_) => panic!("must reject timeout_ms = {ms} <= configured"),
310            };
311            assert!(
312                err.text_err
313                    .as_deref()
314                    .unwrap_or_default()
315                    .contains("strictly greater"),
316                "expected raise-only error, got: {:?}",
317                err.text_err
318            );
319        }
320        // Valid overrides (strictly greater) pass validation and spawn.
321        assert!(
322            bash.run_with_timeout("echo hi", Some(DEFAULT_TIMEOUT_MS + 1))
323                .is_ok(),
324            "timeout_ms above the default must be accepted"
325        );
326        assert!(bash.run_with_timeout("echo hi", None).is_ok());
327    }
328}
329
330#[cfg(test)]
331mod strip_ansi_tests {
332    use super::strip_ansi;
333
334    #[test]
335    fn strips_escapes_and_normalizes_crlf() {
336        assert_eq!(strip_ansi(b"\x1b[32mfoo\x1b[0m\r\nbar\r\n"), "foo\nbar\n");
337    }
338
339    #[test]
340    fn drops_lone_carriage_returns() {
341        // Progress-bar redraw frames merge into one line.
342        assert_eq!(strip_ansi(b"10%\r50%\r100%\n"), "10%50%100%\n");
343    }
344
345    #[test]
346    fn keeps_non_ascii_text() {
347        assert_eq!(strip_ansi("café ☕\r\n".as_bytes()), "café ☕\n");
348    }
349}