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}