pub mod bsh;
#[cfg(test)]
pub mod test;
pub mod types;
use std::pin::Pin;
use tokio_stream::Stream;
pub use types::BashRunInput;
use crate::ToolDescription;
use crate::bash::bsh::BashError;
use crate::bash::bsh::SpawnOutput;
#[must_use]
pub fn strip_ansi(bytes: &[u8]) -> String {
let clean = strip_ansi_escapes::strip(bytes);
let text = String::from_utf8_lossy(&clean);
text.replace("\r\n", "\n").replace('\r', "")
}
pub const DEFAULT_TIMEOUT_MS: u64 = 600_000;
pub struct Bash {
timeout: Option<u64>,
env: Option<Vec<(String, String)>>,
pty: bool,
cwd: String,
pub description_run: ToolDescription,
}
impl Default for Bash {
fn default() -> Self {
Self::new()
}
}
impl Bash {
#[must_use]
pub fn new() -> Self {
Self {
timeout: Some(DEFAULT_TIMEOUT_MS),
env: None,
pty: false,
cwd: String::new(),
description_run: Self::build_description_run(DEFAULT_TIMEOUT_MS),
}
}
fn build_description_run(configured_ms: u64) -> ToolDescription {
let description = format!(
"Execute a bash command and return its output. \
Outputs above the token budget are head/tail-truncated: the \
middle is saved to a log file (path given in the truncation \
notice) that you can read back in parts with fs_read \
(offset/limit) or find_grep. \
Environment variables and PTY mode are wrapper configuration, \
not call arguments. \
The configured default timeout is {configured_ms} \
milliseconds ({} minutes); to extend it for a single call, pass \
the optional `timeout_ms` argument with an integer strictly \
greater than {configured_ms}. Values equal to or below \
the default are rejected — the argument can only raise the \
timeout, never lower it.",
configured_ms / 60_000
);
serde_json::json!({
"name": "bash_run",
"description": description,
"inputSchema": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": concat!(
"The bash command to execute. Must not be an absolute path ",
"and must not match dangerous security patterns."
)
},
"timeout_ms": {
"type": "integer",
"description": concat!(
"Optional. Extend the execution timeout for this call only, ",
"in milliseconds. Must be an integer strictly greater than the ",
"configured default (see the tool description); values equal to ",
"or below the default are rejected. Does not change the default."
)
}
},
"required": ["command"]
}
})
}
#[must_use]
pub fn timeout(mut self, ms: u64) -> Self {
self.timeout = Some(ms);
self.description_run = Self::build_description_run(ms);
self
}
#[must_use]
pub fn env(mut self, env: Option<Vec<(String, String)>>) -> Self {
self.env = env;
self
}
#[must_use]
pub const fn pty(mut self, v: bool) -> Self {
self.pty = v;
self
}
#[must_use]
pub fn cwd(mut self, path: impl Into<String>) -> Self {
self.cwd = path.into();
self
}
pub fn run<'a>(
&'a self,
command: &'a str,
) -> Result<Pin<Box<dyn Stream<Item = SpawnOutput> + Send + 'a>>, BashError> {
self.run_with_timeout(command, None)
}
pub fn run_with_timeout<'a>(
&'a self,
command: &'a str,
timeout_ms: Option<u64>,
) -> Result<Pin<Box<dyn Stream<Item = SpawnOutput> + Send + 'a>>, BashError> {
let configured = self.timeout.unwrap_or(0);
if let Some(ms) = timeout_ms {
if ms <= configured {
return Err(BashError {
text_err: Some(format!(
"timeout_ms must be strictly greater than the configured timeout \
({configured} ms); the per-call argument can only raise the \
timeout, never lower it"
)),
exec_err: None,
});
}
return bsh::run(Some(ms), &self.env, self.pty, command, &self.cwd);
}
bsh::run(self.timeout, &self.env, self.pty, command, &self.cwd)
}
}
#[cfg(test)]
mod timeout_default_tests {
use super::Bash;
use super::DEFAULT_TIMEOUT_MS;
#[test]
fn new_uses_default_timeout() {
let bash = Bash::new();
assert_eq!(bash.timeout, Some(DEFAULT_TIMEOUT_MS));
assert_eq!(DEFAULT_TIMEOUT_MS, 600_000);
}
#[test]
fn description_interpolates_default_timeout() {
let desc = Bash::new().description_run;
let text = desc["description"].as_str().unwrap();
assert!(
text.contains("default timeout is 600000 milliseconds (10 minutes)"),
"description must state the default timeout, got: {text}"
);
assert!(
text.contains("strictly greater than 600000"),
"description must state the raise-only rule, got: {text}"
);
let schema = &desc["inputSchema"];
assert_eq!(
schema["properties"]["timeout_ms"]["type"].as_str(),
Some("integer"),
"inputSchema must expose the optional timeout_ms argument"
);
assert_eq!(
schema["required"].as_array().unwrap(),
&["command".to_string()],
"timeout_ms must stay optional"
);
}
#[test]
fn builder_timeout_regenerates_description() {
let bash = Bash::new().timeout(1_800_000);
let text = bash.description_run["description"].as_str().unwrap();
assert!(
text.contains("default timeout is 1800000 milliseconds (30 minutes)"),
"description must track the instance's configured timeout, got: {text}"
);
assert!(
!text.contains("600000"),
"stale default must not remain after the override, got: {text}"
);
}
#[test]
fn run_with_timeout_rejects_lower_or_equal() {
let bash = Bash::new();
for ms in [0, DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS - 1] {
let err = match bash.run_with_timeout("echo hi", Some(ms)) {
Err(err) => err,
Ok(_) => panic!("must reject timeout_ms = {ms} <= configured"),
};
assert!(
err.text_err
.as_deref()
.unwrap_or_default()
.contains("strictly greater"),
"expected raise-only error, got: {:?}",
err.text_err
);
}
assert!(
bash.run_with_timeout("echo hi", Some(DEFAULT_TIMEOUT_MS + 1))
.is_ok(),
"timeout_ms above the default must be accepted"
);
assert!(bash.run_with_timeout("echo hi", None).is_ok());
}
}
#[cfg(test)]
mod strip_ansi_tests {
use super::strip_ansi;
#[test]
fn strips_escapes_and_normalizes_crlf() {
assert_eq!(strip_ansi(b"\x1b[32mfoo\x1b[0m\r\nbar\r\n"), "foo\nbar\n");
}
#[test]
fn drops_lone_carriage_returns() {
assert_eq!(strip_ansi(b"10%\r50%\r100%\n"), "10%50%100%\n");
}
#[test]
fn keeps_non_ascii_text() {
assert_eq!(strip_ansi("café ☕\r\n".as_bytes()), "café ☕\n");
}
}