use crate::{Tool, Workspace};
use async_trait::async_trait;
use directories::UserDirs;
use regex::RegexSet;
use serde_json::json;
use std::collections::HashSet;
use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use crate::util::TOOL_OUTPUT_BUDGET_BYTES;
use crate::util::UnwrapPoison;
use crate::util::scrub_credentials;
use crate::util::strip_ansi_escapes;
mod bg;
#[cfg(unix)]
pub(crate) mod grep_engine;
mod profiles;
mod readonly;
mod scan;
pub(crate) use self::bg::BackgroundSessions;
use self::profiles::{CARGO_COMPILE_PREFIXES, GEN_FALLBACK, PROFILES, Profile};
pub use self::readonly::ShellMode;
use self::readonly::check_command;
pub(super) const SHELL_PREFIXES: &[&str] = &[
"cd",
"pushd",
"popd",
"export",
"source",
".",
"sudo",
"time",
"!",
"command",
"builtin",
"env",
"nohup",
"exec",
"nice",
"noglob",
"nocorrect",
"eval",
"npx",
];
#[cfg(test)]
pub(super) const NON_DELEGATING_PREFIXES: &[&str] =
&["cd", "pushd", "popd", "export", "source", "."];
const GIT_GLOBAL_FLAGS: &[&str] = &["-C", "--git-dir", "--work-tree", "-c"];
const DEFAULT_SHELL_TIMEOUT_SECS: u64 = 600;
const MAX_SHELL_TIMEOUT_SECS: u64 = 3600;
const DEFAULT_OUTPUT_DRAIN_TIMEOUT_SECS: u64 = 10;
const DRAIN_CANCEL_GRACE: Duration = Duration::from_secs(2);
const SHELL_PIPE_READ_CAP: usize = 256 * 1024;
const TIMEOUT_OUTPUT_TAIL_CHARS: usize = 2_000;
#[cfg(not(target_os = "windows"))]
const SAFE_ENV_VARS: &[&str] = &[
"PATH", "HOME", "TERM", "LANG", "LC_ALL", "LC_CTYPE", "USER", "SHELL", "TMPDIR",
];
#[cfg(target_os = "windows")]
const SAFE_ENV_VARS: &[&str] = &[
"PATH",
"PATHEXT",
"HOME",
"USERPROFILE",
"HOMEDRIVE",
"HOMEPATH",
"SYSTEMROOT",
"SYSTEMDRIVE",
"WINDIR",
"COMSPEC",
"TEMP",
"TMP",
"TERM",
"LANG",
"USERNAME",
];
pub(crate) fn apply_safe_env(cmd: &mut tokio::process::Command) {
cmd.env_clear();
for &name in SAFE_ENV_VARS {
if let Some(value) = baseline_env_value(name) {
cmd.env(name, value);
}
}
}
fn build_shell_command(command: &str, workspace_root: &Path) -> tokio::process::Command {
#[cfg(not(target_os = "windows"))]
let mut process = {
let mut p = tokio::process::Command::new("sh");
p.arg("-c").arg(command);
#[cfg(unix)]
{
p.process_group(0);
}
p
};
#[cfg(target_os = "windows")]
let mut process = {
const CREATE_NO_WINDOW: u32 = 0x08000000;
let mut p = tokio::process::Command::new("cmd.exe");
p.arg("/C").arg(command).creation_flags(CREATE_NO_WINDOW);
p
};
process.current_dir(workspace_root);
apply_safe_env(&mut process);
process
}
#[derive(Debug)]
enum ShellRunResult {
Completed {
stdout: Vec<u8>,
stderr: Vec<u8>,
status: std::process::ExitStatus,
elapsed: Duration,
},
TimedOut {
stdout: Vec<u8>,
stderr: Vec<u8>,
pid: Option<u32>,
elapsed: Duration,
},
DrainTimedOut {
stdout: Vec<u8>,
stderr: Vec<u8>,
pid: Option<u32>,
elapsed: Duration,
},
SpawnFailed(std::io::Error),
}
async fn read_stream_limited(
reader: &mut (impl tokio::io::AsyncRead + Unpin),
cap: usize,
cancel: tokio_util::sync::CancellationToken,
) -> Vec<u8> {
use tokio::io::AsyncReadExt;
let mut buf = Vec::new();
let mut chunk = [0u8; 8192];
loop {
let to_read = if buf.len() >= cap {
chunk.len() } else {
(cap - buf.len()).min(chunk.len())
};
tokio::select! {
biased;
result = reader.read(&mut chunk[..to_read]) => {
match result {
Ok(0) | Err(_) => break,
Ok(n) => {
if buf.len() < cap {
let take = n.min(cap - buf.len());
buf.extend_from_slice(&chunk[..take]);
}
}
}
}
() = cancel.cancelled() => break,
}
}
buf
}
fn spawn_pipe_reader(
pipe: Option<impl tokio::io::AsyncRead + Unpin + Send + 'static>,
cancel: tokio_util::sync::CancellationToken,
) -> tokio::task::JoinHandle<Vec<u8>> {
tokio::spawn(async move {
if let Some(mut reader) = pipe {
read_stream_limited(&mut reader, SHELL_PIPE_READ_CAP, cancel).await
} else {
Vec::new()
}
})
}
async fn await_pipe_reader_with_cancellation_timeout(
handle: tokio::task::JoinHandle<Vec<u8>>,
label: &str,
cancellation_timeout: Duration,
) -> Vec<u8> {
tokio::time::timeout(cancellation_timeout, handle)
.await
.ok()
.and_then(std::result::Result::ok)
.unwrap_or_else(|| {
tracing::warn!(
"{label} reader did not respond to cancellation within {cancellation_timeout:?}"
);
Vec::new()
})
}
async fn finish_partial_reader(
partial: Option<Vec<u8>>,
handle: tokio::task::JoinHandle<Vec<u8>>,
label: &str,
) -> Vec<u8> {
match partial {
Some(data) => data,
None => {
await_pipe_reader_with_cancellation_timeout(handle, label, DRAIN_CANCEL_GRACE).await
}
}
}
fn output_drain_timeout() -> Duration {
crate::util::env_duration_secs(
"MAHBOT_SHELL_DRAIN_TIMEOUT_SECS",
DEFAULT_OUTPUT_DRAIN_TIMEOUT_SECS,
)
}
#[cfg(unix)]
fn kill_process_group(pid: u32, signal: libc::c_int) {
let pid_signed: libc::pid_t = pid.try_into().expect("PID fits in pid_t");
let ret = unsafe { libc::kill(-pid_signed, signal) };
if ret != 0 {
let err = std::io::Error::last_os_error();
tracing::warn!(
pid = pid,
signal,
err = %err,
"kill(-pgid) failed — leftover processes may survive"
);
}
}
#[cfg_attr(not(unix), allow(dead_code))]
struct KillOnDrop {
pid: u32,
armed: bool,
}
impl KillOnDrop {
fn new(pid: u32) -> Self {
Self { pid, armed: true }
}
fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for KillOnDrop {
fn drop(&mut self) {
if self.armed {
#[cfg(unix)]
kill_process_group(self.pid, libc::SIGKILL);
}
}
}
enum DrainOutcome {
Both(Vec<u8>, Vec<u8>),
Partial {
stdout: Option<Vec<u8>>,
stderr: Option<Vec<u8>>,
},
}
async fn drain_pipe_readers(
mut stdout_handle: &mut tokio::task::JoinHandle<Vec<u8>>,
mut stderr_handle: &mut tokio::task::JoinHandle<Vec<u8>>,
drain_limit: Duration,
) -> DrainOutcome {
let drain = tokio::time::sleep(drain_limit);
tokio::pin!(drain);
let mut stdout_done = None;
let mut stderr_done = None;
loop {
tokio::select! {
biased;
r = &mut stdout_handle, if stdout_done.is_none() => {
stdout_done = Some(r.unwrap_or_else(|e| {
tracing::warn!(%e, "stdout reader task panicked");
Vec::new()
}));
}
r = &mut stderr_handle, if stderr_done.is_none() => {
stderr_done = Some(r.unwrap_or_else(|e| {
tracing::warn!(%e, "stderr reader task panicked");
Vec::new()
}));
}
() = &mut drain => break,
}
if stdout_done.is_some() && stderr_done.is_some() {
break;
}
}
match (stdout_done, stderr_done) {
(Some(stdout), Some(stderr)) => DrainOutcome::Both(stdout, stderr),
(stdout, stderr) => DrainOutcome::Partial { stdout, stderr },
}
}
async fn run_command_with_timeout(
cmd: &mut tokio::process::Command,
timeout: Duration,
drain_limit: Duration,
) -> ShellRunResult {
let start = std::time::Instant::now();
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => return ShellRunResult::SpawnFailed(e),
};
let pid = child.id();
let mut kill_guard = pid.map(KillOnDrop::new);
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let cancel = tokio_util::sync::CancellationToken::new();
let mut stdout_handle = spawn_pipe_reader(stdout_pipe, cancel.clone());
let mut stderr_handle = spawn_pipe_reader(stderr_pipe, cancel.clone());
match tokio::time::timeout(timeout, child.wait()).await {
Ok(Ok(status)) => {
if let Some(guard) = &mut kill_guard {
guard.disarm();
}
match drain_pipe_readers(&mut stdout_handle, &mut stderr_handle, drain_limit).await {
DrainOutcome::Both(stdout, stderr) => ShellRunResult::Completed {
stdout,
stderr,
status,
elapsed: start.elapsed(),
},
DrainOutcome::Partial { stdout, stderr } => {
#[cfg(unix)]
if let Some(pid) = pid {
kill_process_group(pid, libc::SIGKILL);
}
cancel.cancel();
let (stdout, stderr) = tokio::join!(
finish_partial_reader(stdout, stdout_handle, "stdout"),
finish_partial_reader(stderr, stderr_handle, "stderr"),
);
ShellRunResult::DrainTimedOut {
stdout,
stderr,
pid,
elapsed: start.elapsed(),
}
}
}
}
Ok(Err(e)) => ShellRunResult::SpawnFailed(e),
Err(_) => {
#[cfg(unix)]
{
let pid = pid.expect("PID is available after successful spawn");
let _ = child.start_kill();
kill_process_group(pid, libc::SIGKILL);
let _ = child.wait().await;
if let Some(guard) = &mut kill_guard {
guard.disarm();
}
}
#[cfg(not(unix))]
{
let _ = child.kill().await;
}
cancel.cancel();
let stdout = await_pipe_reader_with_cancellation_timeout(
stdout_handle,
"stdout",
DRAIN_CANCEL_GRACE,
)
.await;
let stderr = await_pipe_reader_with_cancellation_timeout(
stderr_handle,
"stderr",
DRAIN_CANCEL_GRACE,
)
.await;
ShellRunResult::TimedOut {
stdout,
stderr,
pid,
elapsed: start.elapsed(),
}
}
}
}
fn tail_chars(s: &str, max_chars: usize) -> String {
let char_count = s.chars().count();
if char_count <= max_chars {
return s.to_string();
}
s.chars().skip(char_count - max_chars).collect()
}
fn append_output_tail(msg: &mut String, label: &str, data: &[u8]) {
if !data.is_empty() {
let scrubbed = strip_and_scrub(data);
let tail = tail_chars(&scrubbed, TIMEOUT_OUTPUT_TAIL_CHARS);
let _ = write!(
msg,
"\n{label} (last {} chars): {tail}",
tail.chars().count()
);
}
}
fn format_timeout_error(
command: &str,
elapsed: Duration,
timeout_limit: Duration,
pid: Option<u32>,
stdout: &[u8],
stderr: &[u8],
) -> String {
let mut msg = format!(
"Shell command timed out.\n\
command: {command}\n\
elapsed: {:.1}s\n\
timeout_limit: {:.0}s",
elapsed.as_secs_f64(),
timeout_limit.as_secs_f64(),
);
if let Some(p) = pid {
let _ = write!(msg, "\npid: {p}");
}
msg.push_str("\nreason: command was killed after exceeding the timeout");
msg.push_str(
"\nhint: for known-long commands, pass a larger per-call timeout via the `timeout_secs` tool argument (max 3600s).",
);
append_output_tail(&mut msg, "stdout", stdout);
append_output_tail(&mut msg, "stderr", stderr);
msg
}
fn format_drain_timeout_error(
command: &str,
elapsed: Duration,
drain_limit: Duration,
pid: Option<u32>,
stdout: &[u8],
stderr: &[u8],
) -> String {
let mut msg = format!(
"Shell command output drain timed out.\n\
command: {command}\n\
elapsed: {:.1}s\n\
drain_limit: {:.0}s\n\
reason: the command exited but a leftover process kept the output \
pipes open past the drain limit, so EOF never arrived",
elapsed.as_secs_f64(),
drain_limit.as_secs_f64(),
);
if let Some(p) = pid {
let _ = write!(msg, "\nkilled process group: {p}");
}
msg.push_str(
"\nhint: the tool does not support processes that outlive the command; \
keep launched processes inside the command's lifetime. \
If background execution is genuinely required, state that in your final response.",
);
append_output_tail(&mut msg, "stdout", stdout);
append_output_tail(&mut msg, "stderr", stderr);
msg
}
pub struct ShellTool {
pub mode: ShellMode,
}
impl ShellTool {
#[must_use]
pub const fn new(mode: ShellMode) -> Self {
Self { mode }
}
async fn launch_background(
&self,
ws: &Workspace,
command: &str,
) -> anyhow::Result<(String, Option<i32>)> {
let sessions = Self::background_sessions_handle()?;
let path = sessions
.launch(command, ws.as_path())
.await
.map_err(anyhow::Error::msg)?;
Ok((
format!(
"Background session started.\n\
output file: {}\n\
command: {command}\n\
The command is running detached from this tool call — its raw output \
is written to the output file. Read the file with the read tool to follow \
progress. When the command exits, the line `[exit status: N]` is appended \
to the end of the file (including for exit 0) — its presence means the \
command finished. Stop the session with the shell tool's `stop` argument \
set to this output-file path.",
path.display()
),
Some(0),
))
}
async fn stop_background(&self, stop_path: &str) -> anyhow::Result<(String, Option<i32>)> {
let sessions = Self::background_sessions_handle()?;
let path = PathBuf::from(stop_path);
match sessions.stop(&path).await {
Ok(self::bg::StopResult::Stopped) => Ok((
format!(
"Background session stopped.\noutput file: {}",
path.display()
),
Some(0),
)),
Ok(self::bg::StopResult::AlreadyFinished) => Ok((
format!(
"Background session already finished — no action taken.\noutput file: {}",
path.display()
),
Some(0),
)),
Err(e) => anyhow::bail!("{e}"),
}
}
fn background_sessions_handle()
-> anyhow::Result<std::sync::Arc<crate::tools::shell::BackgroundSessions>> {
crate::agent::CURRENT_TOOL_BACKGROUND_SESSIONS
.try_with(std::clone::Clone::clone)
.unwrap_or(None)
.ok_or_else(|| {
anyhow::anyhow!(
"Background shell mode is not available in this context \
(no agent session registry)."
)
})
}
#[expect(clippy::too_many_lines)] pub(crate) async fn execute_with_status(
&self,
ws: &Workspace,
args: serde_json::Value,
) -> anyhow::Result<(String, Option<i32>)> {
if self.mode == ShellMode::Full {
let stop_path = super::get_opt_str(&args, "stop").filter(|s| !s.is_empty());
let background = super::get_opt_bool(&args, "background").unwrap_or(false);
if let Some(stop_path) = stop_path {
if background {
anyhow::bail!(
"The `stop` and `background` arguments cannot be combined — \
pass only `stop` with the output-file path of a background session."
);
}
return self.stop_background(stop_path).await;
}
if background {
let command_str = super::get_str(&args, "command")?;
return self.launch_background(ws, command_str).await;
}
}
let command_str = super::get_str(&args, "command")?;
let mut exec_str = command_str.to_string();
if self.mode == ShellMode::ReadOnly {
let ctx = self::readonly::CheckContext::for_workspace(ws.as_path());
if let Err(rejection) = check_command(command_str, &ctx) {
anyhow::bail!("{rejection}");
}
#[cfg(unix)]
if let Some(rewritten) = grep_engine::try_serve_command(command_str, ws.as_path()) {
if check_command(&rewritten, &ctx).is_ok() {
exec_str = rewritten;
}
}
}
let mut cmd = build_shell_command(&exec_str, ws.as_path());
let timeout_secs = super::get_opt_u64(&args, "timeout_secs")
.map_or(DEFAULT_SHELL_TIMEOUT_SECS, |s| {
s.min(MAX_SHELL_TIMEOUT_SECS)
});
let timeout = Duration::from_secs(timeout_secs);
let drain_limit = output_drain_timeout();
#[cfg_attr(not(unix), allow(unused_mut))] let mut result = run_command_with_timeout(&mut cmd, timeout, drain_limit).await;
#[cfg(unix)]
match &mut result {
ShellRunResult::Completed { stderr, .. }
| ShellRunResult::TimedOut { stderr, .. }
| ShellRunResult::DrainTimedOut { stderr, .. } => {
if exec_str != command_str {
grep_engine::strip_stream_size_marker(stderr);
}
}
ShellRunResult::SpawnFailed(_) => {}
}
#[cfg(unix)]
let result = if exec_str != command_str
&& matches!(
&result,
ShellRunResult::Completed { status, stderr, .. }
if status.code() == Some(grep_engine::ENGINE_FAILED_EXIT)
|| stderr
.windows(grep_engine::STALE_BINARY_LOCK_MSG.len())
.any(|w| w == grep_engine::STALE_BINARY_LOCK_MSG.as_bytes())
) {
let mut original = build_shell_command(command_str, ws.as_path());
run_command_with_timeout(&mut original, timeout, drain_limit).await
} else {
result
};
if exec_str != command_str {
let exit_code = match &result {
ShellRunResult::Completed { status, .. } => status.code(),
_ => None,
};
tracing::debug!(
command = command_str,
?exit_code,
"grep engine: served exit"
);
}
match result {
ShellRunResult::Completed {
stdout,
stderr,
status,
elapsed,
} => {
let stdout = decode_and_strip_ansi(&stdout);
let stderr = decode_and_strip_ansi(&stderr);
let exit_code = status.code(); let exit_note = match exit_code {
Some(c) => format!("[exit status: {c}]"),
None => "[exit status: terminated by signal]".to_string(),
};
let processed = process_shell_output(
command_str,
&stdout,
&stderr,
exit_code.unwrap_or(-1),
elapsed,
);
let mut combined = processed;
if exit_code != Some(0) {
combined.push_str("\n\n");
combined.push_str(&exit_note);
}
Ok((combined, exit_code))
}
ShellRunResult::TimedOut {
stdout,
stderr,
pid,
elapsed,
} => {
tracing::info!(
command = command_str,
elapsed_secs = elapsed.as_secs_f64(),
?pid,
stdout_bytes = stdout.len(),
stderr_bytes = stderr.len(),
"Shell command timed out"
);
let msg =
format_timeout_error(command_str, elapsed, timeout, pid, &stdout, &stderr);
anyhow::bail!("{msg}");
}
ShellRunResult::DrainTimedOut {
stdout,
stderr,
pid,
elapsed,
} => {
tracing::info!(
command = command_str,
elapsed_secs = elapsed.as_secs_f64(),
drain_limit_secs = drain_limit.as_secs_f64(),
?pid,
stdout_bytes = stdout.len(),
stderr_bytes = stderr.len(),
"Shell command output drain timed out — leftover process held the pipes"
);
let msg = format_drain_timeout_error(
command_str,
elapsed,
drain_limit,
pid,
&stdout,
&stderr,
);
anyhow::bail!("{msg}");
}
ShellRunResult::SpawnFailed(e) => anyhow::bail!(
"Failed to start shell command.\n\
command: {command_str}\n\
reason: {e}"
),
}
}
}
fn extra_shell_path_prefixes() -> Vec<PathBuf> {
let mut v = Vec::new();
if let Some(dir) = crate::util::cargo_bin_dir() {
v.push(dir);
}
if let Ok(cargo_home) = std::env::var("CARGO_HOME")
&& !cargo_home.is_empty()
&& let Some(dirs) = UserDirs::new()
{
v.push(dirs.home_dir().join(".cargo").join("bin"));
}
#[cfg(unix)]
if let Some(dirs) = UserDirs::new() {
v.push(dirs.home_dir().join(".npm-global").join("bin"));
}
#[cfg(target_os = "macos")]
{
v.push(PathBuf::from("/opt/homebrew/bin"));
v.push(PathBuf::from("/usr/local/bin"));
}
v
}
#[cfg(unix)]
const fn default_search_path_without_parent_env() -> &'static str {
"/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
}
#[cfg(windows)]
fn windows_system_root() -> String {
r"C:\Windows".to_string()
}
#[cfg(windows)]
fn default_search_path_without_parent_env() -> String {
let root = windows_system_root();
format!(r"{root}\System32;{root};{root}\System32\Wbem;{root}\System32\WindowsPowerShell\v1.0")
}
fn prepend_path_entries(base: &str, extras: &[PathBuf]) -> String {
let sep = if cfg!(windows) { ";" } else { ":" };
let mut seen = HashSet::<String>::new();
let mut parts = Vec::new();
let normalize = |s: &str| -> String {
if cfg!(windows) {
s.to_lowercase()
} else {
s.to_string()
}
};
for p in extras {
let s = p.to_string_lossy().to_string();
if s.is_empty() {
continue;
}
if seen.insert(normalize(&s)) {
parts.push(s);
}
}
for part in base.split(sep) {
if part.is_empty() {
continue;
}
if seen.insert(normalize(part)) {
parts.push(part.to_string());
}
}
parts.join(sep)
}
fn resolved_shell_path() -> String {
let base = default_search_path_without_parent_env();
prepend_path_entries(base, &extra_shell_path_prefixes())
}
pub(crate) fn shell_tmpdir() -> String {
crate::temp_root::shell_tmpdir()
}
fn baseline_env_value(name: &str) -> Option<String> {
match name {
"PATH" => Some(resolved_shell_path()),
"HOME" | "USERPROFILE" => {
UserDirs::new().map(|d| d.home_dir().to_string_lossy().into_owned())
}
"USER" | "USERNAME" => std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.ok()
.or_else(|| Some("user".into())),
"TERM" => Some("dumb".into()),
"LANG" | "LC_ALL" | "LC_CTYPE" => Some("C.UTF-8".into()),
"SHELL" => Some("/bin/sh".into()),
"TMPDIR" => Some(shell_tmpdir()),
_ => {
#[cfg(windows)]
if let Some(val) = windows_baseline_env_value(name) {
return Some(val);
}
None
}
}
}
#[cfg(windows)]
fn windows_baseline_env_value(name: &str) -> Option<String> {
match name {
"PATHEXT" => Some(".COM;.EXE;.BAT;.CMD;.VBS;.JS".into()),
"HOMEDRIVE" | "HOMEPATH" => UserDirs::new().and_then(|d| {
let s = d.home_dir().to_string_lossy().into_owned();
if s.len() >= 2 && s.as_bytes().get(1) == Some(&b':') {
match name {
"HOMEDRIVE" => Some(s[..2].to_string()),
_ => Some(s[2..].to_string()),
}
} else {
None
}
}),
"SYSTEMROOT" | "WINDIR" => Some(windows_system_root()),
"SYSTEMDRIVE" => Some("C:".into()),
"COMSPEC" => {
let root = windows_system_root();
Some(format!(r"{root}\System32\cmd.exe"))
}
"TEMP" | "TMP" => {
let root = windows_system_root();
Some(format!(r"{root}\Temp"))
}
_ => None,
}
}
#[async_trait]
impl Tool for ShellTool {
fn name(&self) -> &'static str {
"shell"
}
fn description(&self) -> String {
match self.mode {
ShellMode::ReadOnly => {
let banner = crate::prompt::load_prompt("tool/shell_readonly_banner.md");
let base = crate::prompt::load_prompt(&format!("tool/{}.md", self.name()));
format!("{banner}\n\n{base}")
}
ShellMode::Full => crate::prompt::load_prompt("tool/shell_full.md"),
}
}
fn parameters_schema(&self) -> serde_json::Value {
match self.mode {
ShellMode::ReadOnly => super::tool_params_schema(
&json!({
"command": {
"type": "string",
"description": "The shell command to execute"
},
"timeout_secs": {
"type": "integer",
"description": "Optional custom timeout in seconds (default: 600, max: 3600). Use this for long-running commands that need more than the default 10-minute timeout.",
"minimum": 1,
"maximum": 3600
},
}),
&["command"],
),
ShellMode::Full => super::tool_params_schema(
&json!({
"command": {
"type": "string",
"description": "The shell command to execute. Required for normal and background runs; not needed (and ignored) when `stop` is set."
},
"timeout_secs": {
"type": "integer",
"description": "Optional custom timeout in seconds (default: 600, max: 3600). Use this for long-running commands that need more than the default 10-minute timeout.",
"minimum": 1,
"maximum": 3600
},
"background": {
"type": "boolean",
"description": "When true, run the command in the background: it keeps running after this tool call returns and its raw output is written to a file in the temp area whose path is returned. Read that file with the read tool; when the command exits, the line `[exit status: N]` is appended to its end (including exit 0). `timeout_secs` is ignored in background mode. Default: false.",
"default": false
},
"stop": {
"type": "string",
"description": "Output-file path of a background session (as returned by a background launch) to stop. The process group is stopped two-stage (SIGTERM, ~5s grace, SIGKILL). Pass only `stop` with the exact path — a `command` is not needed and is ignored if present, and `background` must NOT be combined with `stop` (the tool rejects the combination). Stopping an already-finished session is a no-op."
},
}),
&[],
),
}
}
fn side_effects(&self) -> bool {
self.mode != ShellMode::ReadOnly
}
fn should_scrub_output(&self, _args: &serde_json::Value) -> bool {
false }
async fn execute(&self, ws: &Workspace, args: serde_json::Value) -> anyhow::Result<String> {
self.execute_with_status(ws, args)
.await
.map(|(output, _)| output)
}
}
fn agent_temp_dir() -> Option<std::path::PathBuf> {
let dir = std::env::temp_dir().join(".agent");
std::fs::create_dir_all(&dir).ok()?;
Some(dir)
}
pub(crate) const NON_AGENT_SPILL_OWNER: &str = "";
static SPILL_OWNERS: std::sync::LazyLock<
std::sync::Mutex<std::collections::HashMap<String, Vec<std::path::PathBuf>>>,
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
fn record_spill_owner(path: std::path::PathBuf) {
let agent = crate::agent::CURRENT_TOOL_AGENT_ID
.try_with(Clone::clone)
.unwrap_or(None);
let key = agent.unwrap_or_else(|| NON_AGENT_SPILL_OWNER.to_string());
let mut map = SPILL_OWNERS.lock().unwrap_poison();
map.entry(key).or_default().push(path);
}
pub(crate) fn cleanup_agent_spills(agent_id: &str) {
let mut map = SPILL_OWNERS.lock().unwrap_poison();
let Some(paths) = map.remove(agent_id) else {
return;
};
for p in paths {
let _ = std::fs::remove_file(&p);
}
}
const fn check_outside_quotes(c: char, in_single: &mut bool, in_double: &mut bool) -> bool {
match c {
'\'' if !*in_double => {
*in_single = !*in_single;
false
}
'"' if !*in_single => {
*in_double = !*in_double;
false
}
_ => !*in_single && !*in_double,
}
}
const fn track_char_context(
c: char,
in_single: &mut bool,
in_double: &mut bool,
escaped: &mut bool,
) -> bool {
if *escaped {
*escaped = false;
return false;
}
if c == '\\' && !*in_single {
*escaped = true;
return false;
}
check_outside_quotes(c, in_single, in_double)
}
fn consume_substitution(
c: char,
chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
current: &mut String,
) -> bool {
if c == '$' && chars.peek() == Some(&'(') {
current.push(c);
current.push(chars.next().expect("peeked '('"));
let mut depth = 1usize;
let mut sub_single = false;
let mut sub_double = false;
let mut sub_escaped = false;
for c2 in chars.by_ref() {
current.push(c2);
if !track_char_context(c2, &mut sub_single, &mut sub_double, &mut sub_escaped) {
continue;
}
if c2 == '(' {
depth += 1;
} else if c2 == ')' {
depth -= 1;
if depth == 0 {
break;
}
}
}
return true;
}
if c == '`' {
current.push(c);
let mut sub_escaped = false;
for c2 in chars.by_ref() {
current.push(c2);
if sub_escaped {
sub_escaped = false;
} else if c2 == '\\' {
sub_escaped = true;
} else if c2 == '`' {
break;
}
}
return true;
}
false
}
fn extract_command_segments(command: &str) -> Vec<String> {
let scan = scan::strip_heredoc_bodies(command);
segment_command(&scan, SegmentMode::Profile)
.expect("profile segmentation never errors")
.into_iter()
.map(|(seg, _)| seg)
.collect()
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum SegmentMode {
Profile,
Grep,
}
#[derive(Clone, Copy)]
pub(super) enum EmptySegPolicy {
Skip,
Error,
}
const fn is_escape_sensitive(c: char) -> bool {
matches!(
c,
'\\' | '\'' | '"' | '>' | '<' | '&' | '|' | ';' | '$' | '`'
)
}
fn segment_opens_case(segment: &str) -> bool {
let mut words = segment.split_whitespace();
match words.next() {
Some("case") => true,
Some("do" | "then" | "else" | "elif" | "if") => words.next() == Some("case"),
_ => false,
}
}
fn segment_ends_case(segment: &str) -> bool {
let mut words = segment.split_whitespace();
match words.next() {
Some("esac") => true,
Some("do" | "then" | "else" | "elif" | "if") => words.next() == Some("esac"),
_ => false,
}
}
#[expect(clippy::too_many_lines)] pub(super) fn segment_command(command: &str, mode: SegmentMode) -> Option<Vec<(String, String)>> {
let mut out: Vec<(String, String)> = Vec::new();
let mut current = String::new();
let mut in_single = false;
let mut in_double = false;
let mut in_case = false;
let mut chars = command.chars().peekable();
let flush = |current: &mut String,
out: &mut Vec<(String, String)>,
conn: &str,
policy: EmptySegPolicy,
in_case: &mut bool|
-> bool {
let t = current.trim();
let pushed = !t.is_empty();
if pushed {
out.push((t.to_string(), conn.to_string()));
*in_case = segment_opens_case(t) || (*in_case && !segment_ends_case(t));
}
current.clear();
pushed || matches!(policy, EmptySegPolicy::Skip)
};
let base = match mode {
SegmentMode::Profile => EmptySegPolicy::Skip,
SegmentMode::Grep => EmptySegPolicy::Error,
};
while let Some(c) = chars.next() {
if c == '\\' && !in_single {
match chars.next() {
Some('\n') => continue,
Some(next) if mode == SegmentMode::Profile && !is_escape_sensitive(next) => {
current.push(next);
}
Some(next) => {
current.push('\\');
current.push(next);
}
None => current.push('\\'),
}
continue;
}
if check_outside_quotes(c, &mut in_single, &mut in_double) {
if consume_substitution(c, &mut chars, &mut current) {
continue;
}
match c {
'&' if chars.peek() == Some(&'&') => {
chars.next();
if !flush(&mut current, &mut out, "&&", base, &mut in_case) {
return None;
}
continue;
}
'|' if current.trim_end().ends_with('>') => {
current.push(c);
continue;
}
'|' => {
if mode == SegmentMode::Grep && chars.peek() == Some(&'&') {
chars.next();
if !flush(&mut current, &mut out, "|&", base, &mut in_case) {
return None;
}
} else if chars.peek() == Some(&'|') {
chars.next();
if !flush(&mut current, &mut out, "||", base, &mut in_case) {
return None;
}
} else if !flush(&mut current, &mut out, "|", base, &mut in_case) {
return None;
}
continue;
}
'\n' => {
flush(
&mut current,
&mut out,
"\n",
EmptySegPolicy::Skip,
&mut in_case,
);
continue;
}
';' => {
if !flush(&mut current, &mut out, ";", base, &mut in_case) {
return None;
}
if mode == SegmentMode::Grep && in_case && chars.peek() == Some(&';') {
chars.next();
}
continue;
}
_ => {}
}
}
current.push(c);
}
flush(
&mut current,
&mut out,
"",
EmptySegPolicy::Skip,
&mut in_case,
);
if mode == SegmentMode::Grep
&& matches!(
out.last().map(|(_, c)| c.as_str()),
Some("|" | "|&" | "||" | "&&")
)
{
return None;
}
Some(out)
}
pub(super) fn find_first_non_flag_index(words: &[&str], is_git: bool) -> Option<usize> {
let mut i = 0;
while i < words.len() {
let w = words[i];
if is_git && GIT_GLOBAL_FLAGS.contains(&w) {
i += 2; continue;
}
if !is_git && w.starts_with('+') {
i += 1;
continue;
}
if w == "2>&1" || w == "1>&2" {
i += 1;
continue;
}
if w.starts_with('-') {
i += 1;
continue;
}
return Some(i);
}
None
}
pub(super) fn find_first_command_word_index(words: &[&str]) -> Option<usize> {
words.iter().position(|w| {
let u = scan::strip_quoted_word(w);
!SHELL_PREFIXES.contains(&u) && !w.starts_with('-') && !is_env_assignment(u)
})
}
fn command_word_from_segment(segment: &str) -> Option<(usize, &str, Vec<&str>)> {
let trimmed = segment.trim();
let words = scan::split_words_keeping_substitutions(trimmed);
let idx = find_first_command_word_index(&words)?;
let cmd = words[idx]
.rsplit('/')
.next()
.expect("rsplit always yields at least one element");
Some((idx, cmd, words))
}
pub(super) fn first_command_word(segment: &str) -> &str {
let Some((_, cmd, _)) = command_word_from_segment(segment) else {
return "";
};
cmd
}
pub(super) fn canonical_command(segment: &str) -> String {
let Some((cmd_idx, cmd, words)) = command_word_from_segment(segment) else {
return String::new();
};
let remaining = &words[cmd_idx + 1..];
if remaining.is_empty() {
return cmd.to_string();
}
let is_git = cmd == "git";
if let Some(sub_idx) = find_first_non_flag_index(remaining, is_git) {
format!("{} {}", cmd, remaining[sub_idx])
} else {
cmd.to_string()
}
}
fn is_env_assignment(word: &str) -> bool {
if let Some(eq_pos) = word.find('=')
&& eq_pos > 0
{
let prefix = &word[..eq_pos];
return prefix
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& prefix
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_');
}
false
}
fn select_profile(segments: &[String], is_chained: bool) -> &'static Profile {
for segment in segments {
let canonical = canonical_command(segment);
if canonical.is_empty() {
continue;
}
for p in PROFILES.iter() {
if is_chained && p.standalone_only {
continue;
}
if p.match_command.is_match(&canonical) {
return p;
}
}
}
&GEN_FALLBACK
}
fn combine_output(
stdout: &str,
stderr: &str,
exit_code: i32,
keep_stderr: Option<&RegexSet>,
) -> String {
let stderr_trimmed = stderr.trim();
if stderr_trimmed.is_empty() {
return stdout.to_string();
}
let filtered = keep_stderr.and_then(|patterns| filter_keep_stderr(stderr, patterns));
match (exit_code == 0, filtered) {
(_, Some(relevant)) => {
if stdout.is_empty() {
format!("stderr:\n{relevant}")
} else {
format!("{stdout}\nstderr:\n{relevant}")
}
}
(false, None) if keep_stderr.is_none() => {
if stdout.is_empty() {
format!("stderr:\n{stderr_trimmed}")
} else {
format!("{stdout}\nstderr:\n{stderr_trimmed}")
}
}
_ => stdout.to_string(),
}
}
fn filter_keep_stderr<'a>(stderr: &'a str, patterns: &RegexSet) -> Option<String> {
let relevant: Vec<&'a str> = stderr.lines().filter(|l| patterns.is_match(l)).collect();
if relevant.is_empty() {
return None;
}
Some(relevant.join("\n"))
}
fn finish_shell_output(
mut combined: String,
elapsed: Duration,
full_output_for_spill: Option<&str>,
) -> String {
if elapsed.as_secs_f64() >= 1.0 {
let _ = write!(combined, "\n[took {:.1}s]", elapsed.as_secs_f64());
}
if let Some(pre) = full_output_for_spill {
debug_assert!(
pre.len() > TOOL_OUTPUT_BUDGET_BYTES,
"invariant: pre-truncation output ({}) must exceed threshold ({})",
pre.len(),
TOOL_OUTPUT_BUDGET_BYTES,
);
let byte_count = pre.len();
let line_count = pre.lines().count();
if let Some(path) = spill_output(pre) {
let hint = format_spill_header(&path, byte_count, line_count);
combined.push('\n');
combined.push_str(&hint);
}
return combined;
}
try_spill_to_file(combined, TOOL_OUTPUT_BUDGET_BYTES)
}
fn push_line(buf: &mut String, line: &str) {
if !buf.is_empty() {
buf.push('\n');
}
buf.push_str(line);
}
fn collapse_blank_lines(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut blank_run = 0usize;
for line in input.lines() {
if line.trim().is_empty() {
blank_run += 1;
if blank_run > 2 {
continue; }
} else {
blank_run = 0;
}
push_line(&mut result, line);
}
result
}
pub(super) fn filter_cargo_test_output(output: &str, exit_code: i32) -> String {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Section {
Normal,
InFailures,
}
struct CargoTestFilter {
section: Section,
has_failures: bool,
has_compile_errors: bool,
summary_lines: Vec<String>,
output_lines: Vec<String>,
}
let exit_ok = exit_code == 0;
let mut f = CargoTestFilter {
section: Section::Normal,
has_failures: false,
has_compile_errors: false,
summary_lines: Vec::new(),
output_lines: Vec::new(),
};
for line in output.lines() {
let trimmed = line.trim_start();
if CARGO_COMPILE_PREFIXES
.iter()
.any(|p| *p != "Running" && trimmed.starts_with(p))
{
continue;
}
if trimmed.starts_with("test ") && trimmed.contains("... ok") {
continue;
}
if trimmed.starts_with("running ") {
continue;
}
if trimmed.starts_with("error[") || trimmed.starts_with("error:") {
f.has_compile_errors = true;
}
if trimmed == "failures:" {
f.section = Section::InFailures;
continue;
}
if trimmed.starts_with("test result:") {
f.summary_lines.push(line.to_string());
f.section = Section::Normal;
continue;
}
if f.section != Section::Normal {
f.has_failures = true;
f.output_lines.push(line.to_string());
continue;
}
f.output_lines.push(line.to_string());
}
if f.has_failures {
let mut result = f.output_lines.join("\n");
if !f.summary_lines.is_empty() {
push_line(&mut result, &f.summary_lines.join("\n"));
}
return result;
}
if f.has_compile_errors && !exit_ok {
let lines: Vec<&str> = f
.output_lines
.iter()
.map(String::as_str)
.filter(|l| !l.trim().is_empty())
.collect();
let last = lines
.iter()
.rev()
.take(15)
.rev()
.copied()
.collect::<Vec<_>>();
return last.join("\n");
}
if !f.summary_lines.is_empty() {
return f.summary_lines.join("\n");
}
let result = output.to_string();
if exit_ok && result.trim().is_empty() {
"[cargo test: ok]".to_string()
} else {
result
}
}
fn parse_ls_line(line: &str) -> Option<(char, String, String)> {
if line.starts_with("total ") || line.trim().is_empty() {
return None;
}
let mut parts = line.split_whitespace();
let permissions = parts.next()?;
if permissions.len() < 10
|| !(permissions.starts_with('-')
|| permissions.starts_with('d')
|| permissions.starts_with('l'))
{
return None;
}
let file_type = permissions.chars().next()?;
parts.next(); parts.next(); parts.next(); let size = parts.next()?.to_string();
parts.next(); parts.next(); parts.next(); let name = parts.collect::<Vec<_>>().join(" ").trim().to_string();
if name.is_empty() || name == "." || name == ".." {
return None;
}
let name = name
.split(" -> ")
.next()
.expect("split always yields at least one element")
.to_string();
if name.is_empty() {
return None;
}
Some((file_type, size, name))
}
#[expect(clippy::cast_precision_loss)]
fn human_readable_size(size: &str) -> String {
if let Ok(bytes) = size.parse::<u64>() {
if bytes >= 1_000_000_000 {
format!("{:.1}G", bytes as f64 / 1_000_000_000.0)
} else if bytes >= 1_000_000 {
format!("{:.1}M", bytes as f64 / 1_000_000.0)
} else if bytes >= 1_000 {
format!("{:.1}K", bytes as f64 / 1_000.0)
} else {
format!("{bytes}B")
}
} else {
size.to_string() }
}
pub(super) fn compact_ls(output: &str, _exit_code: i32) -> String {
if !output.lines().any(|line| line.starts_with("total ")) {
return output.to_string();
}
let mut dirs: Vec<String> = Vec::new();
let mut files: Vec<(String, String)> = Vec::new();
let mut ext_counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut lines_seen = 0usize;
for line in output.lines() {
if line.starts_with("total ") || line.trim().is_empty() {
continue;
}
lines_seen += 1;
let Some((file_type, size, name)) = parse_ls_line(line) else {
continue;
};
if file_type == 'd' {
dirs.push(name);
} else {
let ext = if let Some((_, e)) = name.rsplit_once('.') {
format!(".{e}")
} else {
"no ext".to_string()
};
*ext_counts.entry(ext).or_insert(0) += 1;
let human = human_readable_size(&size);
files.push((name, human));
}
}
if dirs.is_empty() && files.is_empty() {
if lines_seen > 0 {
return "(empty)\n".to_string();
}
return output.to_string();
}
let mut entries = String::new();
for d in &dirs {
let _ = writeln!(entries, "{d}/");
}
for (name, size) in &files {
let _ = writeln!(entries, "{name} {size}");
}
let _ = write!(
entries,
"Summary: {} files, {} dirs",
files.len(),
dirs.len()
);
if !ext_counts.is_empty() {
let mut sorted: Vec<_> = ext_counts.iter().collect();
sorted.sort_by(|a, b| b.1.cmp(a.1));
let parts: Vec<String> = sorted
.iter()
.take(5)
.map(|(ext, count)| format!("{count} {ext}"))
.collect();
let _ = write!(entries, " ({})", parts.join(", "));
if sorted.len() > 5 {
let _ = write!(entries, ", +{} more", sorted.len() - 5);
}
}
entries.push('\n');
entries
}
fn process_shell_output(
command: &str,
stdout: &str,
stderr: &str,
exit_code: i32,
elapsed: Duration,
) -> String {
let segments = extract_command_segments(command);
let is_chained = segments.len() > 1;
let profile = select_profile(&segments, is_chained);
apply_profile_pipeline(profile, stdout, stderr, exit_code, elapsed)
}
fn apply_strip_lines(output: &str, profile: &Profile) -> String {
output
.lines()
.filter(|l| {
if let Some(ref set) = profile.strip_lines
&& set.is_match(l)
{
return false;
}
true
})
.collect::<Vec<_>>()
.join("\n")
}
fn format_sandwich(output: &str, head: usize, tail: usize, marker_verb: &str) -> String {
let lines: Vec<&str> = output.lines().collect();
let total = lines.len();
if total <= head + tail {
return output.to_string();
}
let omitted = total - head - tail;
let mut result = lines[..head].join("\n");
if result.is_empty() {
let _ = write!(result, "... ({omitted} lines {marker_verb})");
} else {
let _ = write!(result, "\n... ({omitted} lines {marker_verb})");
}
if tail > 0 {
let _ = write!(result, "\n{}", lines[total - tail..].join("\n"));
}
result
}
fn apply_line_truncation(output: &str, profile: &Profile) -> (String, Option<String>) {
let head = profile.head_lines.unwrap_or(0);
let tail = profile.tail_lines.unwrap_or(0);
let max = profile.max_lines;
if head == 0 && tail == 0 && max.is_none() {
return (output.to_string(), None);
}
let line_count = output.lines().count();
let should_sandwich = (head > 0 || tail > 0)
&& line_count > head + tail
&& output.len() > TOOL_OUTPUT_BUDGET_BYTES;
let pre_truncation = if should_sandwich {
Some(output.to_string())
} else {
None
};
let result = if should_sandwich {
format_sandwich(output, head, tail, "omitted")
} else if let Some(max) = max {
format_sandwich(output, max, 0, "truncated")
} else {
output.to_string()
};
debug_assert!(
!should_sandwich || max.is_none_or(|m| result.lines().count() <= m),
"sandwich result ({}) exceeds max_lines ({:?}) — profile invariant violated",
result.lines().count(),
max,
);
(result, pre_truncation)
}
fn apply_profile_pipeline(
profile: &Profile,
output: &str,
stderr: &str,
exit_code: i32,
elapsed: Duration,
) -> String {
let stderr = scrub_credentials(stderr);
let output = scrub_credentials(output);
let combine =
|output: &str| combine_output(output, &stderr, exit_code, profile.keep_stderr.as_ref());
let mut processed = apply_strip_lines(&output, profile);
processed = collapse_blank_lines(&processed);
if let Some(max) = profile.max_line_len {
processed = truncate_line_width(&processed, max);
}
let (truncated, pre_head_tail) = apply_line_truncation(&processed, profile);
processed = truncated;
if processed.trim().is_empty()
&& let Some(msg) = profile.on_empty
{
if exit_code != 0
&& let Some(fail_msg) = profile.on_fail_msg
{
let secs = elapsed.as_secs_f64();
return combine(&format!("{fail_msg} ({secs:.1}s)"));
}
let exit_note = if exit_code == 0 { "" } else { " (failed)" };
let secs = elapsed.as_secs_f64();
return combine(&format!("{msg}{exit_note} ({secs:.1}s)"));
}
if let Some(transform) = profile.output_transform {
processed = transform(&processed, exit_code);
}
let combined = combine(&processed);
finish_shell_output(combined, elapsed, pre_head_tail.as_deref())
}
fn decode_and_strip_ansi(data: &[u8]) -> String {
let decoded = String::from_utf8_lossy(data);
strip_ansi_escapes(&decoded)
}
fn strip_and_scrub(data: &[u8]) -> String {
scrub_credentials(&decode_and_strip_ansi(data))
}
fn truncate_line_width(input: &str, max_line_len: usize) -> String {
let mut result = String::with_capacity(input.len());
for line in input.lines() {
if line.len() > max_line_len {
let cut = line.floor_char_boundary(max_line_len);
push_line(&mut result, &line[..cut]);
let _ = write!(
result,
"\n... ({} more chars on this line)",
line[cut..].chars().count()
);
} else {
push_line(&mut result, line);
}
}
result
}
fn format_spill_header(path: &Path, byte_count: usize, line_count: usize) -> String {
format!(
"[Output saved to {} ({} bytes, {} lines)]\n\
[view with: read {}]\n",
path.display(),
byte_count,
line_count,
path.display(),
)
}
fn format_spill_preview(output: &str, path: &Path) -> String {
let line_count = output.lines().count();
let byte_count = output.len();
let header = format_spill_header(path, byte_count, line_count);
format!("{header}{}", format_sandwich(output, 5, 5, "omitted"))
}
fn write_to_spill(content: &str, filename: &str) -> Option<std::path::PathBuf> {
let dir = agent_temp_dir()?;
let path = dir.join(filename);
std::fs::write(&path, content).ok()?;
record_spill_owner(path.clone());
Some(path)
}
fn spill_output(output: &str) -> Option<std::path::PathBuf> {
let filename = crate::tools::path::format_spill_filename();
write_to_spill(output, &filename)
}
fn try_spill_to_file(output: String, threshold_bytes: usize) -> String {
if output.len() <= threshold_bytes {
return output;
}
match spill_output(&output) {
Some(path) => format_spill_preview(&output, &path),
None => crate::util::truncate_tool_output(&output),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::workspace::test_ws;
use tempfile::TempDir;
use crate::util::test::{env_lock, set_env_var};
fn assert_contains_not_contains(
name: &str,
result: &str,
contains: &[&str],
not_contains: &[&str],
) {
for &s in contains {
assert!(
result.contains(s),
"[{name}] expected contains {s:?}\n got: {result:?}",
);
}
for &s in not_contains {
assert!(
!result.contains(s),
"[{name}] expected NOT contains {s:?}\n got: {result:?}",
);
}
}
#[derive(Default)]
struct ShellOutputCase {
name: &'static str,
command: &'static str,
stdout: &'static str,
stderr: &'static str,
exit_code: i32,
elapsed_secs: f64,
contains: &'static [&'static str],
not_contains: &'static [&'static str],
eq: Option<&'static str>,
}
#[derive(Default)]
struct CargoTestFilterCase {
name: &'static str,
output: &'static str,
exit_code: i32,
contains: &'static [&'static str],
not_contains: &'static [&'static str],
}
fn check_shell_output(cases: &[ShellOutputCase]) {
for case in cases {
let result = process_shell_output(
case.command,
case.stdout,
case.stderr,
case.exit_code,
Duration::from_secs_f64(case.elapsed_secs),
);
assert_contains_not_contains(case.name, &result, case.contains, case.not_contains);
if let Some(expected) = case.eq {
assert_eq!(
result.trim(),
expected,
"[{}] expected eq {expected:?}",
case.name,
);
}
}
}
fn check_cargo_test_filter(cases: &[CargoTestFilterCase]) {
for case in cases {
let result = filter_cargo_test_output(case.output, case.exit_code);
assert_contains_not_contains(case.name, &result, case.contains, case.not_contains);
}
}
#[test]
fn cargo_test_filter_cases() {
let cases: &[CargoTestFilterCase] = &[
CargoTestFilterCase {
name: "failure block captures failures and panic message",
output: "\n\
Compiling foo v1.0.0\n\
test test1 ... ok\n\
test test2 ... FAILED\n\
\n\
failures:\n\
\n\
---- test2 stdout ----\n\
thread 'test2' panicked at src/lib.rs:42:\n\
assertion failed\n\
\n\
\n\
failures:\n\
test2\n\
\n\
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out\n\
",
exit_code: 1,
contains: &["test2 ... FAILED", "assertion failed", "test result:"],
not_contains: &["Compiling", "test1 ... ok"],
},
CargoTestFilterCase {
name: "all pass returns summary",
output: "\
Compiling foo v1.0.0\n\
Checking bar v2.0.0\n\
test test1 ... ok\n\
test test2 ... ok\n\
\n\
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n\
",
exit_code: 0,
contains: &["test result:"],
not_contains: &["Compiling", "Checking", "test1 ... ok", "test2 ... ok"],
},
CargoTestFilterCase {
name: "compile error fallback preserves errors",
output: "\
Compiling foo v1.0.0\n\
error[E0425]: cannot find value `bar` in this scope\n\
--> src/lib.rs:1:5\n\
\n\
error: could not compile `foo` due to 1 previous error\n\
",
exit_code: 1,
contains: &["error[E0425]", "could not compile"],
not_contains: &["Compiling"],
},
CargoTestFilterCase {
name: "Running preserved in test output",
output: "\
Compiling foo v1.0.0\n\
Running unittests src/lib.rs\n\
test test1 ... ok\n\
test test2 ... FAILED\n\
\n\
failures:\n\
\n\
---- test2 stdout ----\n\
assertion failed\n\
\n\
failures:\n\
test2\n\
\n\
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out\n\
",
exit_code: 1,
contains: &["Running unittests", "test2 ... FAILED", "test result:"],
not_contains: &["Compiling"],
},
];
check_cargo_test_filter(cases);
}
#[expect(clippy::too_many_lines)]
#[test]
fn profile_selection_cases() {
let cases: &[ShellOutputCase] = &[
ShellOutputCase {
name: "cargo --release test triggers state machine",
command: "cargo --release test",
eq: Some("[cargo test: ok]"),
..Default::default()
},
ShellOutputCase {
name: "git -C /repo diff triggers git diff on_empty",
command: "git -C /repo diff",
contains: &["no changes"],
..Default::default()
},
ShellOutputCase {
name: "unknown tool falls through to generic",
command: "some_obscure_tool --flag",
stdout: "some\nrandom\noutput\n",
contains: &["some", "output"],
..Default::default()
},
ShellOutputCase {
name: "empty command uses fallback",
command: "",
stdout: "hello world",
contains: &["hello"],
..Default::default()
},
ShellOutputCase {
name: "builtins-only falls through to generic",
command: "cd .. && cd /tmp",
stdout: "some output",
contains: &["some output"],
..Default::default()
},
ShellOutputCase {
name: "chained command selects first matching profile (pnpm install)",
command: "cd frontend && pnpm install && pnpm build",
stdout: "Already up to date\nsome output\n",
not_contains: &["Already up to date"],
..Default::default()
},
ShellOutputCase {
name: "chained cargo test falls through to GEN_FALLBACK",
command: "cd project && cargo test",
stdout: "Compiling foo v1.0.0\ntest test1 ... ok\ntest test2 ... FAILED\n\nfailures:\n\n---- test2 stdout ----\npanic!\n\nfailures:\n test2\n\ntest result: FAILED. 1 passed; 1 failed\n",
exit_code: 1,
contains: &["test2 ... FAILED", "Compiling", "test1 ... ok"],
..Default::default()
},
ShellOutputCase {
name: "chained cargo test compile error regression",
command: "cargo test --lib || true",
stdout: "",
stderr: "error[E0425]: cannot find value `x` in this scope\n --> src/lib.rs:2:21\n |\n2 | let y = x + 1;\n | ^ not found in this scope\n",
exit_code: 0,
not_contains: &["[cargo test: ok]"],
..Default::default()
},
ShellOutputCase {
name: "chained git log preserves content",
command: "cd repo && git log --oneline",
stdout: "commit abc123\nAuthor: test\nDate: Mon Jan 1\n\n initial commit\n",
contains: &["commit", "Author"],
..Default::default()
},
ShellOutputCase {
name: "multi-line ls skips compact_ls (newline = chained)",
command: "ls -la\ncat README.md",
stdout: "total 8\n-rw-r--r-- 1 user group 2048 May 21 10:00 file.txt\n",
contains: &["total 8", "file.txt"],
not_contains: &["Summary:"],
..Default::default()
},
ShellOutputCase {
name: "multi-line cargo test skips state machine",
command: "cargo test --lib\ncat notes.txt",
stdout: "Compiling foo v1.0.0\ntest test1 ... ok\ntest result: ok. 1 passed; 1 failed\n",
contains: &["Compiling foo", "test1 ... ok", "test result:"],
not_contains: &["[cargo test: ok]"],
..Default::default()
},
ShellOutputCase {
name: "heredoc command stays single segment for profile selection",
command: "cargo test --lib <<EOF\nbody\nEOF",
stdout: "test test1 ... ok\ntest result: ok. 1 passed; 0 failed\n",
eq: Some("test result: ok. 1 passed; 0 failed"),
..Default::default()
},
ShellOutputCase {
name: "heredoc with substitution body becomes chained",
command: "cargo test --lib <<EOF\n$(echo hi)\nEOF",
stdout: "Compiling foo v1.0.0\ntest test1 ... ok\ntest result: ok. 1 passed; 0 failed\n",
contains: &["Compiling foo", "test1 ... ok", "test result:"],
not_contains: &["[cargo test: ok]"],
..Default::default()
},
ShellOutputCase {
name: "npx eslint selects eslint profile",
command: "npx eslint .",
contains: &["[eslint: ok]"],
..Default::default()
},
ShellOutputCase {
name: "npx prettier selects prettier profile",
command: "npx prettier --check file.js",
stdout: "unchanged",
contains: &["unchanged"],
..Default::default()
},
ShellOutputCase {
name: "npx tsc selects tsc profile",
command: "npx tsc --noEmit",
contains: &["[tsc: ok]"],
..Default::default()
},
ShellOutputCase {
name: "npx vitest selects vitest profile",
command: "npx vitest --run",
stdout: "stdout: Tests passed\nPASS src/test.ts\n",
not_contains: &["PASS"],
..Default::default()
},
ShellOutputCase {
name: "npx with flags before subcommand selects eslint profile",
command: "npx --yes eslint .",
contains: &["[eslint: ok]"],
..Default::default()
},
ShellOutputCase {
name: "unknown npx tool falls through to generic",
command: "npx some_obscure_tool --flag",
stdout: "some\nrandom\noutput\n",
contains: &["some", "output"],
..Default::default()
},
];
check_shell_output(cases);
}
#[expect(clippy::too_many_lines)]
#[test]
fn tool_profile_cases() {
let cases: &[ShellOutputCase] = &[
ShellOutputCase {
name: "git diff no changes via on_empty",
command: "git diff",
contains: &["no changes"],
..Default::default()
},
ShellOutputCase {
name: "docker build success via on_empty",
command: "docker build -t myimage .",
stdout: "Step 1/3 : FROM alpine\n ---> abc123\nStep 2/3 : RUN echo hi\n ---> Using cache\nStep 3/3 : CMD [\"sh\"]\n ---> def456\nSuccessfully built abc123\nSuccessfully tagged myimage:latest\n",
contains: &["[docker"],
..Default::default()
},
ShellOutputCase {
name: "git log preserves content",
command: "git log --oneline",
stdout: "commit abc123\nAuthor: test\nDate: Mon Jan 1\n\n initial commit\n\ncommit def456\nAuthor: test\nDate: Tue Jan 2\n\n second commit\n\n",
contains: &["commit", "Author"],
..Default::default()
},
ShellOutputCase {
name: "generic pipeline: strips ANSI, preserves content",
command: "unknown",
stdout: "Compiling foo v1.0.0 (/tmp)\nCompiling bar v2.0.0 (/tmp)\nresult: ok\nline1\nline2\nline3\nline3\nline3\nline3\nline3\nline3\nline3\n",
contains: &["Compiling", "result: ok"],
not_contains: &["\x1B["],
..Default::default()
},
ShellOutputCase {
name: "du strips blank lines",
command: "du -sh",
stdout: "1.0K\t./file1\n\n2.0K\t./file2\n\n\n3.0K\t./file3",
not_contains: &["\n\n"],
..Default::default()
},
ShellOutputCase {
name: "make strips directory noise",
command: "make",
stdout: "make[1]: Entering directory `/tmp'\nmake[1]: Leaving directory `/tmp'\ncc -c file.c\nNothing to be done",
not_contains: &["Entering directory", "Nothing to be done"],
..Default::default()
},
ShellOutputCase {
name: "rsync success shows transfer summary",
command: "rsync -avz source/ dest/",
stdout: "building file list ... done\nsent 100 bytes received 50 bytes\n\ntotal size is 98765 speedup is 658.43\n",
contains: &["building file list", "total size is", "98765"],
..Default::default()
},
ShellOutputCase {
name: "tsc on empty returns ok",
command: "tsc --noEmit",
eq: Some("[tsc: ok] (0.0s)"),
..Default::default()
},
ShellOutputCase {
name: "tsc on empty shows timing",
command: "tsc --noEmit",
elapsed_secs: 3.2,
contains: &["(3.2s)"],
..Default::default()
},
ShellOutputCase {
name: "docker strips build steps and shows on_empty",
command: "docker build -t myapp .",
stdout: "Step 1/10 : FROM node:18\nStep 2/10 : WORKDIR /app\n ---> Using cache\nSuccessfully built abc123\nSuccessfully tagged myapp:latest\n",
contains: &["[docker: ok]"],
not_contains: &["Step "],
..Default::default()
},
ShellOutputCase {
name: "gh strips warning noise, preserves output",
command: "gh pr create --fill",
stdout: " \n - some detail\nwarning: consider updating gh\n✓ Created pull request\n",
contains: &["Created pull request"],
not_contains: &["warning:"],
..Default::default()
},
ShellOutputCase {
name: "terraform shows no changes message",
command: "terraform plan",
stdout: "data.aws_region.current: Refreshing state...\nNo changes. Your infrastructure matches the configuration.\n",
contains: &["No changes", "infrastructure matches"],
..Default::default()
},
ShellOutputCase {
name: "pytest strips collected count",
command: "pytest",
stdout: "============================= test session starts ==============================\ncollected 5 items\n\n.test..\n\n============================== 5 passed ==============================\n",
not_contains: &["collected"],
..Default::default()
},
ShellOutputCase {
name: "python -m pytest falls through to generic (collected preserved)",
command: "python -m pytest tests/",
stdout: "============================= test session starts ==============================\ncollected 5 items\n\n.test..\n\n============================== 5 passed ==============================\n",
contains: &["collected"],
..Default::default()
},
ShellOutputCase {
name: "poetry run pytest falls through to generic (collected preserved)",
command: "poetry run pytest tests/",
stdout: "============================= test session starts ==============================\ncollected 5 items\n\n.test..\n\n============================== 5 passed ==============================\n",
contains: &["collected"],
..Default::default()
},
];
check_shell_output(cases);
}
#[test]
fn compact_ls_cases() {
let cases: &[ShellOutputCase] = &[
ShellOutputCase {
name: "empty directory shows (empty)",
command: "ls -la",
stdout: "total 0\ndrwxr-xr-x 2 user group 64 May 21 10:00 .\ndrwxr-xr-x 3 user group 96 May 21 10:00 ..\n",
eq: Some("(empty)"),
..Default::default()
},
ShellOutputCase {
name: "mixed files and dirs shows summary",
command: "ls -la",
stdout: "total 32\ndrwxr-xr-x 5 user group 160 May 21 10:00 .\ndrwxr-xr-x 3 user group 96 May 21 10:00 ..\n-rw-r--r-- 1 user group 2048 May 21 10:00 main.rs\n-rw-r--r-- 1 user group 4096 May 21 10:00 lib.rs\ndrwxr-xr-x 2 user group 64 May 21 10:00 src\nlrwxr-xr-x 1 user group 5 May 21 10:00 link -> target\n",
contains: &["src/", "main.rs", "lib.rs", "Summary:"],
not_contains: &["link -> target"],
..Default::default()
},
ShellOutputCase {
name: "dotless files classified as no ext",
command: "ls -la",
stdout: "total 16\n-rw-r--r-- 1 user group 1024 May 21 10:00 Makefile\n-rw-r--r-- 1 user group 2048 May 21 10:00 README\n-rw-r--r-- 1 user group 512 May 21 10:00 .gitignore\n-rw-r--r-- 1 user group 1024 May 21 10:00 main.rs\n",
contains: &["Makefile", "README", "no ext", ".rs"],
not_contains: &[".Makefile", ".README"],
..Default::default()
},
ShellOutputCase {
name: "plain ls passes through without compaction",
command: "ls",
stdout: "Cargo.toml\nCargo.lock\nsrc\ntarget\nREADME.md\n",
contains: &["Cargo.toml", "src"],
not_contains: &["(empty)", "Summary:"],
..Default::default()
},
ShellOutputCase {
name: "chained ls skips compact_ls",
command: "ls -l && echo done",
stdout: "total 8\n-rw-r--r-- 1 user group 1024 May 21 10:00 foo\n-rw-r--r-- 1 user group 2048 May 21 10:00 bar\ndone\n",
contains: &["done"],
not_contains: &["Summary:"],
..Default::default()
},
ShellOutputCase {
name: "piped ls skips compact_ls",
command: "ls -l | head -5",
stdout: "total 8\n-rw-r--r-- 1 user group 1024 May 21 10:00 foo\n-rw-r--r-- 1 user group 2048 May 21 10:00 bar\n",
contains: &["total 8"],
not_contains: &["Summary:"],
..Default::default()
},
];
check_shell_output(cases);
}
#[test]
fn cargo_build_cases() {
let cases: &[ShellOutputCase] = &[
ShellOutputCase {
name: "cargo build strips Compiling, preserves errors",
command: "cargo build",
stdout: "Compiling foo v1.0.0 (/tmp)\nCompiling bar v2.0.0 (/tmp)\n Compiling baz v3.0.0 (/tmp)\nerror[E0425]: cannot find value\n\nFor more information about this error, try `rustc --explain E0425`.\nerror: could not compile `foo` due to 1 previous error",
exit_code: 1,
contains: &["error[E0425]", "could not compile"],
not_contains: &["Compiling foo"],
..Default::default()
},
ShellOutputCase {
name: "cargo check strips Checking lines",
command: "cargo check",
stdout: " Checking foo v1.0.0\n Checking bar v2.0.0\n warning: unused import\n\nwarning: 1 warning emitted\n\n Finished `dev` profile [unoptimized] target\n",
not_contains: &["Checking"],
..Default::default()
},
ShellOutputCase {
name: "cargo build strips Compiling and Finished on success",
command: "cargo build",
stdout: " Compiling foo v1.0.0\n Compiling bar v2.0.0\n Finished dev [unoptimized]\n",
not_contains: &["Compiling", "Finished"],
..Default::default()
},
ShellOutputCase {
name: "absolute cargo check strips Compiling",
command: "/usr/local/bin/cargo check",
stdout: " Compiling foo v1.0.0\nwarning: unused import\n",
not_contains: &["Compiling"],
..Default::default()
},
ShellOutputCase {
name: "chained cargo build strips Compiling, preserves errors",
command: "cd project && cargo build",
stdout: " Compiling foo v1.0.0\n Compiling bar v2.0.0\nerror[E0425]: cannot find value\n",
exit_code: 1,
contains: &["error[E0425]"],
not_contains: &["Compiling"],
..Default::default()
},
ShellOutputCase {
name: "cargo build keeps stderr warnings on success",
command: "cargo build",
stdout: " Compiling foo v1.0.0\n Finished\n",
stderr: "warning: unused import: `std::fs`\n --> src/main.rs:1:5\n",
contains: &["warning:"],
..Default::default()
},
ShellOutputCase {
name: "cargo clippy failure shows on_fail_msg (no (failed) suffix)",
command: "cargo clippy",
exit_code: 1,
eq: Some("[cargo clippy: failed] (0.0s)"),
..Default::default()
},
ShellOutputCase {
name: "cargo clippy failure filters progress lines from stderr",
command: "cargo clippy",
stderr: " Checking mahbot v0.1.0 (/Users/user/mahbot)\nwarning: unused import: `std::fs`\n --> src/main.rs:1:5\n",
exit_code: 1,
contains: &["warning:", "[cargo clippy: failed]"],
not_contains: &["Checking mahbot"],
..Default::default()
},
ShellOutputCase {
name: "cargo clippy failure omits stderr when no keep_stderr match",
command: "cargo clippy",
stderr: " Checking mahbot v0.1.0\n Finished dev [unoptimized]\n",
exit_code: 1,
eq: Some("[cargo clippy: failed] (0.0s)"),
not_contains: &["Checking", "stderr:"],
..Default::default()
},
ShellOutputCase {
name: "cargo build failure shows on_fail_msg (no (failed) suffix)",
command: "cargo build",
exit_code: 1,
eq: Some("[cargo: failed] (0.0s)"),
..Default::default()
},
ShellOutputCase {
name: "cargo build success still shows ok (backward compat)",
command: "cargo build",
eq: Some("[cargo: ok] (0.0s)"),
..Default::default()
},
];
check_shell_output(cases);
}
#[test]
fn shell_safe_env_vars() {
for var in SAFE_ENV_VARS {
let lower = var.to_lowercase();
assert!(
!lower.contains("key") && !lower.contains("secret") && !lower.contains("token")
);
}
assert!(SAFE_ENV_VARS.contains(&"PATH"));
assert!(SAFE_ENV_VARS.contains(&"HOME") || SAFE_ENV_VARS.contains(&"USERPROFILE"));
assert!(SAFE_ENV_VARS.contains(&"TERM"));
}
#[cfg(unix)]
#[tokio::test]
async fn build_shell_command_isolates_environment() {
let tmp = TempDir::new().expect("tempdir");
let mut cmd = {
let _guard = env_lock().lock().unwrap_poison();
build_shell_command("env", tmp.path())
};
let output = cmd.output().await.expect("env should run");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("HOME="), "HOME must be in safe env");
assert!(stdout.contains("PATH="), "PATH must be in safe env");
assert!(
!stdout.contains("CARGO_HOME="),
"CARGO_HOME must not leak into subprocess env"
);
}
#[tokio::test]
async fn shell_executes_allowed_command() {
let tmp = TempDir::new().expect("tempdir");
let result = ShellTool::new(ShellMode::Full)
.execute(&test_ws(tmp.path()), json!({"command": "echo hello"}))
.await;
assert!(
result.is_ok(),
"echo command execution should succeed: {result:?}"
);
let result = result.unwrap();
assert!(result.trim().contains("hello"));
}
#[cfg(unix)]
#[tokio::test]
async fn shell_nonzero_exit_with_stdout_counts_as_success() {
let tmp = TempDir::new().expect("tempdir");
let result = ShellTool::new(ShellMode::Full)
.execute(
&test_ws(tmp.path()),
json!({"command": "echo partial; test -f nonexistent_file_xyz"}),
)
.await;
assert!(
result.is_ok(),
"shell should return Ok(String) when stdout present: {result:?}"
);
let result = result.unwrap();
assert!(result.contains("partial"));
assert!(
result.contains("[exit status: 1]"),
"model should still see real exit status, got {result:?}",
);
}
#[tokio::test]
async fn shell_captures_exit_code() {
let tmp = TempDir::new().expect("tempdir");
let result = ShellTool::new(ShellMode::Full)
.execute(
&test_ws(tmp.path()),
json!({"command": "ls nonexistent_dir_xyz"}),
)
.await;
assert!(
result.is_ok(),
"command with nonexistent path should return ok: {result:?}"
);
let output = result.unwrap();
assert!(
output.contains("[exit status: 1]"),
"output should contain exit status: {output:?}",
);
assert!(
output.contains("nonexistent_dir_xyz"),
"output should contain the error: {output:?}"
);
}
async fn execute_with_bg_registry(
shell_tool: &ShellTool,
ws: &crate::Workspace,
args: serde_json::Value,
sessions: &std::sync::Arc<crate::tools::shell::BackgroundSessions>,
) -> anyhow::Result<String> {
crate::agent::CURRENT_TOOL_BACKGROUND_SESSIONS
.scope(Some(sessions.clone()), async {
shell_tool.execute(ws, args).await
})
.await
}
#[tokio::test]
async fn background_launch_via_tool_registers_session_and_read_tool_reads_output() {
let tmp = TempDir::new().expect("tempdir");
let ws = test_ws(tmp.path());
let sessions = std::sync::Arc::new(BackgroundSessions::default());
let output = execute_with_bg_registry(
&ShellTool::new(ShellMode::Full),
&ws,
json!({"command": "echo bg-via-tool", "background": true}),
&sessions,
)
.await
.expect("background launch succeeds");
let path_line = output
.lines()
.find(|l| l.starts_with("output file:"))
.expect("launch message must name the output file");
let path = PathBuf::from(path_line.trim_start_matches("output file:").trim());
assert!(
path.file_name()
.is_some_and(|n| n.to_string_lossy().starts_with("bg_")),
"bg_* name shape: {path:?}"
);
assert!(sessions.contains(&path), "session must be registered");
let content = crate::tools::ReadTool
.execute(&ws, json!({"path": path.to_string_lossy().to_string()}))
.await
.expect("read tool must read the bg output file");
assert!(
content.contains("bg-via-tool"),
"raw output must be in the file: {content}"
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while !sessions.is_finished(&path) && std::time::Instant::now() < deadline {
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
assert!(sessions.is_finished(&path), "session should finish");
let content = std::fs::read_to_string(&path).expect("bg output file readable");
assert!(
content.contains("[exit status: 0]"),
"annotation must be appended: {content}"
);
}
#[tokio::test]
async fn background_stop_via_tool() {
let _env = set_env_var("MAHBOT_BG_STOP_GRACE_SECS", Some("0"));
let tmp = TempDir::new().expect("tempdir");
let ws = test_ws(tmp.path());
let sessions = std::sync::Arc::new(BackgroundSessions::default());
let tool = ShellTool::new(ShellMode::Full);
let launch_out = execute_with_bg_registry(
&tool,
&ws,
json!({"command": "sleep 30", "background": true}),
&sessions,
)
.await
.expect("launch");
let path = launch_out
.lines()
.find(|l| l.starts_with("output file:"))
.expect("output file line")
.trim_start_matches("output file:")
.trim();
let stop_out = execute_with_bg_registry(&tool, &ws, json!({"stop": path}), &sessions)
.await
.expect("stop succeeds");
assert!(
stop_out.contains("Background session stopped"),
"stop message: {stop_out}"
);
assert!(
sessions.is_finished(Path::new(path)),
"stopped session must be finished"
);
}
#[tokio::test]
async fn background_stop_and_background_conflict_errors() {
let tmp = TempDir::new().expect("tempdir");
let ws = test_ws(tmp.path());
let sessions = std::sync::Arc::new(BackgroundSessions::default());
let err = execute_with_bg_registry(
&ShellTool::new(ShellMode::Full),
&ws,
json!({"command": "echo hi", "background": true, "stop": "/tmp/.agent/bg_0000.out"}),
&sessions,
)
.await
.expect_err("stop + background must be rejected");
assert!(
err.to_string().contains("cannot be combined"),
"error message: {err}"
);
}
#[tokio::test]
async fn background_unavailable_without_agent_context() {
let tmp = TempDir::new().expect("tempdir");
let err = ShellTool::new(ShellMode::Full)
.execute(
&test_ws(tmp.path()),
json!({"command": "sleep 30", "background": true}),
)
.await
.expect_err("background without an agent registry must error");
assert!(
err.to_string().contains("not available in this context"),
"error message: {err}"
);
}
#[test]
fn full_description_and_schema_cover_background_capability() {
let full = ShellTool::new(ShellMode::Full);
let description = full.description();
assert!(
description.contains("Background mode"),
"Full description must describe background mode"
);
assert!(
description.contains("[exit status: N]"),
"Full description must document the completion annotation"
);
let schema = full.parameters_schema();
let props = schema["properties"].as_object().expect("schema properties");
assert!(
props.contains_key("background"),
"Full schema must advertise the background argument"
);
assert!(
props.contains_key("stop"),
"Full schema must advertise the stop argument"
);
assert_eq!(
props["background"]["type"], "boolean",
"background must be a boolean"
);
assert_eq!(
props["background"]["default"], false,
"background must default to false"
);
}
#[ignore = "waits out real command timeouts against live processes (hardcoded waits); runs only when explicitly invoked"]
#[cfg(unix)]
#[tokio::test]
async fn run_command_with_timeout_kills_long_sleep() {
let mut cmd = tokio::process::Command::new("sh");
cmd.arg("-c").arg("sleep 10");
let result =
run_command_with_timeout(&mut cmd, Duration::from_secs(1), Duration::from_secs(10))
.await;
match result {
ShellRunResult::TimedOut { elapsed, .. } => {
assert!(
elapsed < Duration::from_secs(3),
"expected ~1s timeout, got {elapsed:?}"
);
}
other => panic!("expected TimedOut, got {other:?}"),
}
}
#[ignore = "waits out real command timeouts against live processes (hardcoded waits); runs only when explicitly invoked"]
#[cfg(unix)]
#[tokio::test]
async fn run_command_with_timeout_captures_partial_stdout() {
let mut cmd = tokio::process::Command::new("sh");
cmd.arg("-c").arg("echo started; sleep 60");
let result =
run_command_with_timeout(&mut cmd, Duration::from_secs(2), Duration::from_secs(10))
.await;
match result {
ShellRunResult::TimedOut { stdout, .. } => {
let s = String::from_utf8_lossy(&stdout);
assert!(
s.contains("started"),
"stdout should contain partial output: {s}"
);
}
other => panic!("expected TimedOut, got {other:?}"),
}
}
#[ignore = "waits out real command timeouts against live processes (hardcoded waits); runs only when explicitly invoked"]
#[cfg(unix)]
#[tokio::test]
async fn shell_timeout_error_includes_diagnostics() {
let tmp = TempDir::new().expect("tempdir");
let mut cmd = build_shell_command("echo before-timeout; sleep 30", tmp.path());
let result =
run_command_with_timeout(&mut cmd, Duration::from_secs(1), Duration::from_secs(10))
.await;
let ShellRunResult::TimedOut {
stdout,
stderr,
pid,
elapsed,
} = result
else {
panic!("expected timeout");
};
let msg = format_timeout_error(
"echo test",
elapsed,
Duration::from_secs(1),
pid,
&stdout,
&stderr,
);
assert!(msg.contains("elapsed:"), "msg: {msg}");
assert!(msg.contains("timeout_limit:"), "msg: {msg}");
assert!(msg.contains("timeout_secs"), "msg: {msg}");
assert!(msg.contains("before-timeout"), "msg: {msg}");
let ansi_stdout = b"\x1B[31mred error\x1B[0m";
let ansi_stderr = b"\x1B[1mBOLD STUFF\x1B[22m";
let ansi_msg = format_timeout_error(
"test",
elapsed,
Duration::from_mins(5),
Some(42),
ansi_stdout,
ansi_stderr,
);
assert!(
ansi_msg.contains("red error"),
"ANSI text content should survive stripping: {ansi_msg}"
);
assert!(
!ansi_msg.contains("\x1B["),
"ANSI escape codes should be stripped from timeout error: {ansi_msg}"
);
assert!(
ansi_msg.contains("BOLD STUFF"),
"ANSI text content should survive stripping: {ansi_msg}"
);
}
#[ignore = "waits out real command timeouts against live processes (hardcoded waits); runs only when explicitly invoked"]
#[cfg(unix)]
#[tokio::test]
async fn process_group_kills_grandchildren_on_timeout() {
let dir = TempDir::new().expect("tempdir");
let pid_path = dir.path().join("grandchild.pid");
let pid_path_str = pid_path.to_str().expect("valid utf-8 path");
let cmd_str = format!("sleep 999 & echo $! > {pid_path_str}; wait");
let mut cmd = build_shell_command(&cmd_str, dir.path());
let result =
run_command_with_timeout(&mut cmd, Duration::from_secs(2), Duration::from_secs(10))
.await;
assert!(
matches!(result, ShellRunResult::TimedOut { .. }),
"expected TimedOut, got {result:?}"
);
tokio::time::sleep(Duration::from_millis(500)).await;
let pid_content = std::fs::read_to_string(&pid_path)
.expect("grandchild PID file must exist — grandchild was launched");
let pid: i32 = pid_content.trim().parse().expect("valid PID from file");
let ret = unsafe { libc::kill(pid, 0) };
let err = std::io::Error::last_os_error();
assert_eq!(
ret, -1,
"grandchild (pid={pid}) should be dead after PGID kill, err: {err:?}"
);
assert_eq!(
err.raw_os_error(),
Some(libc::ESRCH),
"expected ESRCH (no such process) for grandchild pid={pid}, got: {err:?}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn output_drain_times_out_when_background_process_holds_pipes() {
let dir = TempDir::new().expect("tempdir");
let pid_path = dir.path().join("bg.pid");
let pid_path_str = pid_path.to_str().expect("valid utf-8 path");
let cmd_str = format!("echo before-drain; sleep 999 & echo $! > {pid_path_str}");
let mut cmd = build_shell_command(&cmd_str, dir.path());
let result = run_command_with_timeout(
&mut cmd,
Duration::from_secs(30),
Duration::from_millis(600),
)
.await;
let ShellRunResult::DrainTimedOut {
stdout,
stderr,
elapsed,
..
} = result
else {
panic!("expected DrainTimedOut, got {result:?}");
};
let out = String::from_utf8_lossy(&stdout);
assert!(out.contains("before-drain"), "partial stdout: {out}");
assert!(
elapsed < Duration::from_secs(5),
"drain should error within the bound: {elapsed:?}"
);
let msg = format_drain_timeout_error(
"test",
elapsed,
Duration::from_millis(600),
None,
&stdout,
&stderr,
);
assert!(msg.contains("drain"), "msg: {msg}");
assert!(msg.contains("before-drain"), "msg: {msg}");
let pid_content = std::fs::read_to_string(&pid_path)
.expect("grandchild PID file must exist — grandchild was launched");
let pid: i32 = pid_content.trim().parse().expect("valid PID from file");
let deadline = std::time::Instant::now() + Duration::from_secs(2);
let mut alive = true;
while std::time::Instant::now() < deadline {
if unsafe { libc::kill(pid, 0) } != 0 {
alive = false;
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(
!alive,
"leftover (pid={pid}) should be dead after PGID kill"
);
}
#[cfg(unix)]
#[tokio::test]
async fn output_drain_completes_for_short_lived_background_job() {
let dir = TempDir::new().expect("tempdir");
let mut cmd = build_shell_command("echo done; sleep 1 &", dir.path());
let result =
run_command_with_timeout(&mut cmd, Duration::from_secs(30), Duration::from_secs(5))
.await;
let ShellRunResult::Completed { stdout, .. } = result else {
panic!("expected Completed, got {result:?}");
};
assert!(String::from_utf8_lossy(&stdout).contains("done"));
}
#[cfg(unix)]
#[tokio::test]
async fn output_drain_timeout_keeps_completed_side() {
let dir = TempDir::new().expect("tempdir");
let mut cmd = build_shell_command("echo out; sleep 999 >/dev/null &", dir.path());
let result = run_command_with_timeout(
&mut cmd,
Duration::from_secs(30),
Duration::from_millis(600),
)
.await;
let ShellRunResult::DrainTimedOut { stdout, .. } = result else {
panic!("expected DrainTimedOut, got {result:?}");
};
assert!(
String::from_utf8_lossy(&stdout).contains("out"),
"completed stdout side must be preserved: {stdout:?}"
);
}
#[test]
fn pipeline_credential_scrubbing_cases() {
let cases: &[ShellOutputCase] = &[
ShellOutputCase {
name: "git diff on_empty scrubs credentials in stderr",
command: "git diff",
stderr: "api_key=abcdefghijklmnop12345678",
exit_code: 1,
not_contains: &["api_key=abcdefghijklmnop12345678"],
contains: &["api_key=abcd*[REDACTED]", "no changes"],
..Default::default()
},
ShellOutputCase {
name: "on-empty scrubs credentials in stderr",
command: "tsc --noEmit",
stderr: "warning: api_key=abcdefghijklmnop12345678",
exit_code: 1,
not_contains: &["api_key=abcdefghijklmnop12345678"],
contains: &["api_key=abcd*[REDACTED]", "[tsc: ok]"],
..Default::default()
},
ShellOutputCase {
name: "main pipeline scrubs credentials in stdout",
command: "echo test",
stdout: "API_KEY=abcdefghijklmnop12345678",
not_contains: &["abcdefghijklmnop12345678"],
contains: &["API_KEY=abcd*[REDACTED]"],
..Default::default()
},
];
check_shell_output(cases);
}
#[test]
fn truncate_line_width_short_and_long() {
let long = "a".repeat(500);
let result = truncate_line_width(&long, 100);
assert!(result.len() < long.len() + 100, "should truncate");
assert!(
result.contains("more chars on this line"),
"should show continuation marker"
);
let lines: Vec<&str> = result.lines().collect();
assert_eq!(lines.len(), 2, "truncated line + continuation marker");
assert_eq!(
lines[0].len(),
100,
"first line should be exactly max_chars"
);
assert!(
!lines[0].contains("..."),
"first line should not contain truncation marker"
);
let input = "hello\nworld";
let result = truncate_line_width(input, 500);
assert_eq!(result, input, "short lines should pass through");
}
#[test]
fn try_spill_to_file_behavior() {
let short = "hello".to_string();
let result = try_spill_to_file(short, TOOL_OUTPUT_BUDGET_BYTES);
assert_eq!(result, "hello", "short output should pass through");
let large = "x".repeat(TOOL_OUTPUT_BUDGET_BYTES * 2);
let result = try_spill_to_file(large, TOOL_OUTPUT_BUDGET_BYTES);
assert!(
result.contains("[Output saved to"),
"should contain spill path"
);
assert!(
result.contains("10000 bytes"),
"should mention byte count: {result}"
);
let lines: Vec<String> = (0..800).map(|i| format!("line_{i:04}")).collect();
let multi = lines.join("\n");
let multi_len = multi.len();
assert!(
multi_len > TOOL_OUTPUT_BUDGET_BYTES,
"test data {multi_len} must exceed spill threshold"
);
let result = try_spill_to_file(multi, TOOL_OUTPUT_BUDGET_BYTES);
assert!(
result.contains("[Output saved to"),
"should contain spill path"
);
assert!(
result.contains("[view with: read "),
"should contain actionable read hint"
);
assert!(result.contains("line_0000"), "should show first line");
assert!(result.contains("line_0799"), "should show last line");
assert!(
result.len() < multi_len,
"inline preview should be truncated"
);
assert!(
std::fs::read_dir(std::env::temp_dir().join(".agent")).is_ok(),
"spill dir should exist"
);
}
#[cfg(unix)]
#[test]
fn resolved_shell_path_includes_npm_global_bin() {
let path = resolved_shell_path();
assert!(
path.contains(".npm-global/bin"),
"PATH should include ~/.npm-global/bin for globally installed npm tools: {path}"
);
}
#[cfg(unix)]
#[test]
fn resolved_shell_path_includes_cargo_bin() {
let path = resolved_shell_path();
assert!(
path.contains(".cargo/bin"),
"PATH should include ~/.cargo/bin: {path}"
);
}
#[cfg(unix)]
#[test]
fn resolved_shell_path_includes_cargo_home_when_set() {
let _guard = set_env_var("CARGO_HOME", Some("/custom/cargo"));
let path = resolved_shell_path();
assert!(
path.contains("/custom/cargo/bin"),
"PATH should include $CARGO_HOME/bin when CARGO_HOME is set: {path}"
);
assert!(
path.contains(".cargo/bin"),
"PATH should still include ~/.cargo/bin when CARGO_HOME is set (belt-and-suspenders): {path}"
);
}
#[cfg(unix)]
#[test]
fn resolved_shell_path_dedup_cargo_home_and_default() {
let Some(dirs) = UserDirs::new() else {
return;
};
let default_cargo_home = dirs.home_dir().join(".cargo").to_string_lossy().to_string();
let _guard = set_env_var("CARGO_HOME", Some(&default_cargo_home));
let path = resolved_shell_path();
let sep = ":";
let count = path
.split(sep)
.filter(|part| *part == format!("{default_cargo_home}/bin"))
.count();
assert_eq!(
count, 1,
"$CARGO_HOME/bin and ~/.cargo/bin should deduplicate when they point to the same directory"
);
}
#[cfg(target_os = "macos")]
#[test]
fn resolved_shell_path_includes_homebrew() {
let path = resolved_shell_path();
assert!(
path.contains("/opt/homebrew/bin"),
"PATH should include Homebrew bin on macOS: {path}"
);
}
#[test]
fn collapse_blank_lines_cases() {
let cases: &[(&str, &str)] = &[
("a\n\n\n\nb\n\n\nc", "a\n\n\nb\n\n\nc"),
("a\n\nb\n\n\nc\n\n\n\nd", "a\n\nb\n\n\nc\n\n\nd"),
("a\nb\nc", "a\nb\nc"),
("\n\n\n\n\n", ""),
];
for (input, expected) in cases {
let result = collapse_blank_lines(input);
assert_eq!(result, *expected, "input: {input:?}");
}
}
#[test]
fn extract_segments_cases() {
let cases: &[(&str, &[&str])] = &[
("cargo build", &["cargo build"]),
("cd project && cargo build", &["cd project", "cargo build"]),
(
"npm run build 2>&1 | tee build.log",
&["npm run build 2>&1", "tee build.log"],
),
("echo 'foo && bar' | cat", &["echo 'foo && bar'", "cat"]),
("cargo build ; cargo test", &["cargo build", "cargo test"]),
("echo 'foo && bar'", &["echo 'foo && bar'"]),
("echo \"pipe | test\"", &["echo \"pipe | test\""]),
(
"touch /tmp/a\necho hi > /tmp/b",
&["touch /tmp/a", "echo hi > /tmp/b"],
),
("echo hello \\\nworld", &["echo hello world"]),
("cat <<EOF\nbody\nEOF", &["cat"]),
(
"cat <<EOF > /tmp/out\nbody\nEOF",
&["cat > /tmp/out"], ),
("cat <<< hi > /tmp/out", &["cat <<< hi > /tmp/out"]),
("echo hi >| /tmp/force", &["echo hi >| /tmp/force"]),
("echo $(echo hi; touch x)", &["echo $(echo hi; touch x)"]),
("echo $(echo hi) ; touch x", &["echo $(echo hi)", "touch x"]),
("echo `echo hi; touch x`", &["echo `echo hi; touch x`"]),
(
"cd /tmp && echo $(echo a && echo b)",
&["cd /tmp", "echo $(echo a && echo b)"],
),
("echo $(echo $(ls; pwd))", &["echo $(echo $(ls; pwd))"]),
(
"echo $(echo $(echo hi)) ; touch x",
&["echo $(echo $(echo hi))", "touch x"],
),
(
"echo $( (echo hi) ; echo more) tail",
&["echo $( (echo hi) ; echo more) tail"],
),
(
"echo $(( (a) && (b) )) tail",
&["echo $(( (a) && (b) )) tail"],
),
(
"echo $(echo $((a+1)); echo x)",
&["echo $(echo $((a+1)); echo x)"],
),
("echo $(echo hi\necho bye)", &["echo $(echo hi\necho bye)"]),
("cat <<EOF\n$(touch ws)\nEOF", &["cat", "$(touch ws)"]),
(
"echo \"$(echo hi; touch x)\"",
&["echo \"$(echo hi; touch x)\""],
),
(
"echo \"$(echo hi)\" ; touch x",
&["echo \"$(echo hi)\"", "touch x"],
),
(
"echo \"`echo hi; touch x`\"",
&["echo \"`echo hi; touch x`\""],
),
(
"echo $(echo \\)) ; touch x",
&["echo $(echo \\))", "touch x"],
),
(
"echo `echo \\`hi\\`` ; touch x",
&["echo `echo \\`hi\\``", "touch x"],
),
(
"echo $(echo 'a\\') ; touch x",
&["echo $(echo 'a\\')", "touch x"],
),
];
for (input, expected) in cases {
let result = extract_command_segments(input);
assert_eq!(
result.iter().map(String::as_str).collect::<Vec<_>>(),
*expected,
"input: {input:?}"
);
}
}
#[test]
fn canonical_command_cases() {
let cases: &[(&str, &str)] = &[
("/usr/local/bin/cargo build", "cargo build"),
("git -C /repo diff", "git diff"),
("git -c user.name=me log", "git log"),
("git -- diff", "git diff"), ("sudo cargo build", "cargo build"),
("sudo -E cargo build", "cargo build"), ("sudo --preserve-env cargo build", "cargo build"), ("sudo -E git -C /repo diff", "git diff"), ("time -v cargo test", "cargo test"), ("cd", ""), ("cd ..", ".."), ("pnpm install", "pnpm install"),
("yarn add foo", "yarn add"),
("cargo test --lib", "cargo test"),
("cargo --release build", "cargo build"),
("cargo --release --verbose build", "cargo build"),
("cargo +nightly build", "cargo build"),
("cargo +stable check", "cargo check"),
("cargo build 2>&1", "cargo build"),
("cargo --version 2>&1", "cargo"),
("git --version 2>&1", "git"),
("git status 2>&1", "git status"),
("CC=gcc make", "make"), ("VAR=val cargo check", "cargo check"),
("CC=gcc CXX=g++ make -j4", "make"), ("CC=gcc", ""), ("sudo CC=gcc make", "make"), ("python -m pytest tests/", "python pytest"),
("poetry run pytest tests/", "poetry run"),
("npx eslint", "eslint"),
("npx eslint .", "eslint ."),
("npx eslint --fix .", "eslint ."),
("npx --yes eslint .", "eslint ."),
("npx prettier --check file.js", "prettier file.js"),
("npx vitest --run", "vitest"),
("npx tsc --noEmit", "tsc"),
(
"npx --yes create-react-app my-app",
"create-react-app my-app",
),
];
for &(input, expected) in cases {
assert_eq!(
canonical_command(input),
expected,
"canonical_command({input:?})",
);
}
}
#[test]
fn first_command_word_consistent_with_canonical() {
let inputs: &[&str] = &[
"/usr/local/bin/cargo build",
"git -C /repo diff",
"git -c user.name=me log",
"git -- diff",
"sudo cargo build",
"sudo -E cargo build",
"sudo --preserve-env cargo build",
"sudo -E git -C /repo diff",
"time -v cargo test",
"cd",
"cd ..",
"pnpm install",
"yarn add foo",
"cargo test --lib",
"cargo --release build",
"cargo --release --verbose build",
"CC=gcc make",
"VAR=val cargo check",
"CC=gcc CXX=g++ make -j4",
"CC=gcc",
"sudo CC=gcc make",
"",
" ",
"ls",
"cat file.txt",
"/bin/echo hello",
];
for &input in inputs {
let canonical = canonical_command(input);
let first = first_command_word(input);
if canonical.is_empty() {
assert!(
first.is_empty(),
"first_command_word({input:?}) should be empty when canonical_command is empty",
);
} else {
let expected_first = canonical.split_whitespace().next().unwrap_or("");
assert_eq!(
first, expected_first,
"first_command_word({input:?}) should match first word of canonical_command({input:?}) = {canonical:?}",
);
}
}
}
#[test]
fn test_all_profiles_have_valid_configs() {
let profiles = PROFILES.iter().collect::<Vec<_>>();
assert!(
!profiles.is_empty(),
"should have at least the generic fallback"
);
for p in &profiles {
assert!(
!p.match_command.as_str().is_empty(),
"match_command should not be empty"
);
if let (Some(head), Some(tail), Some(max)) = (p.head_lines, p.tail_lines, p.max_lines) {
assert!(
head + tail < max,
"head+tail ({head}+{tail}) should be strictly less than max_lines ({max}) — omission marker would overflow"
);
}
}
}
#[test]
fn profile_df_caps_at_20_lines() {
let input = (0..50)
.map(|i| format!("filesystem{i} used avail capacity mounted_on"))
.collect::<Vec<_>>()
.join("\n");
let result = process_shell_output("df -h", &input, "", 0, Duration::ZERO);
let lines = result.lines().count();
assert!(lines <= 21, "df should cap at ~21 lines, got {lines}");
assert!(lines >= 19, "df should have around 20 lines, got {lines}");
}
type QuoteStep = (char, bool, bool, bool);
#[test]
fn check_outside_quotes_cases() {
let cases: &[(&str, &[QuoteStep])] = &[
("normal char outside", &[('a', true, false, false)]),
(
"single quote blocks",
&[
('\'', false, true, false),
('>', false, true, false),
('\'', false, false, false),
('>', true, false, false),
],
),
(
"double quote blocks",
&[
('"', false, false, true),
('>', false, false, true),
('"', false, false, false),
('>', true, false, false),
],
),
(
"single inside double",
&[('"', false, false, true), ('\'', false, false, true)],
),
(
"double inside single",
&[('\'', false, true, false), ('"', false, true, false)],
),
];
for (name, steps) in cases {
let (mut s, mut d) = (false, false);
for (i, &(ch, exp_out, exp_s, exp_d)) in steps.iter().enumerate() {
let result = check_outside_quotes(ch, &mut s, &mut d);
assert_eq!(
result, exp_out,
"{name} step {i}: check_outside_quotes({ch:?}) returned {result}, expected {exp_out}",
);
assert_eq!(
s, exp_s,
"{name} step {i}: after {ch:?}, in_single={s}, expected {exp_s}",
);
assert_eq!(
d, exp_d,
"{name} step {i}: after {ch:?}, in_double={d}, expected {exp_d}",
);
}
}
}
type ContextStep = (char, bool, bool, bool, bool);
#[test]
fn track_char_context_cases() {
let cases: &[(&str, &[ContextStep])] = &[
(
"backslash escapes outside quotes",
&[
('\\', false, false, false, true), ('a', false, false, false, false), ('a', true, false, false, false), ],
),
(
"escaped backslash",
&[
('\\', false, false, false, true), ('\\', false, false, false, false), ('a', true, false, false, false), ],
),
(
"escaped quote inside double does not toggle",
&[
('"', false, false, true, false), ('\\', false, false, true, true), ('"', false, false, true, false), ('"', false, false, false, false), ],
),
(
"backslash inside single is literal",
&[
('\'', false, true, false, false), ('\\', false, true, false, false), ('a', false, true, false, false), ('\'', false, false, false, false), ('>', true, false, false, false), ],
),
];
for (name, steps) in cases {
let (mut s, mut d, mut e) = (false, false, false);
for (i, &(ch, exp_out, exp_s, exp_d, exp_e)) in steps.iter().enumerate() {
let result = track_char_context(ch, &mut s, &mut d, &mut e);
assert_eq!(
result, exp_out,
"{name} step {i}: track_char_context({ch:?}) returned {result}, expected {exp_out}",
);
assert_eq!(
s, exp_s,
"{name} step {i}: after {ch:?}, in_single={s}, expected {exp_s}",
);
assert_eq!(
d, exp_d,
"{name} step {i}: after {ch:?}, in_double={d}, expected {exp_d}",
);
assert_eq!(
e, exp_e,
"{name} step {i}: after {ch:?}, escaped={e}, expected {exp_e}",
);
}
}
}
struct TruncateCase {
name: &'static str,
head: usize,
tail: usize,
max: Option<usize>,
output: &'static str,
pre_is_some: bool,
check_contains: &'static [&'static str],
}
fn check_truncate(cases: &[TruncateCase]) {
for case in cases {
let mut p = Profile::new("test");
if case.head > 0 || case.tail > 0 {
p = p.head(case.head).tail(case.tail);
}
if let Some(m) = case.max {
p = p.max(m);
}
let (result, pre) = apply_line_truncation(case.output, &p);
assert_eq!(
pre.is_some(),
case.pre_is_some,
"[{}] pre.is_some mismatch. pre: {pre:?}",
case.name
);
assert_contains_not_contains(case.name, &result, case.check_contains, &[]);
}
}
#[test]
fn truncate_simple_cases() {
check_truncate(&[
TruncateCase {
name: "no config passthrough",
head: 0,
tail: 0,
max: None,
output: "line1\nline2\nline3",
pre_is_some: false,
check_contains: &["line1\nline2\nline3"],
},
TruncateCase {
name: "head+tail small output no sandwich",
head: 2,
tail: 2,
max: None,
output: "line1\nline2\nline3\nline4\nline5",
pre_is_some: false,
check_contains: &["line1\nline2\nline3\nline4\nline5"],
},
TruncateCase {
name: "max only caps at limit",
head: 0,
tail: 0,
max: Some(3),
output: "a\nb\nc\nd\ne",
pre_is_some: false,
check_contains: &["... (2 lines truncated)"],
},
TruncateCase {
name: "max only fits no truncation",
head: 0,
tail: 0,
max: Some(10),
output: "a\nb\nc",
pre_is_some: false,
check_contains: &["a\nb\nc"],
},
TruncateCase {
name: "head+tail fits when under limit",
head: 5,
tail: 3,
max: None,
output: "a\nb\nc\nd",
pre_is_some: false,
check_contains: &["a\nb\nc\nd"],
},
TruncateCase {
name: "head+tail+max small output no sandwich",
head: 2,
tail: 2,
max: Some(3),
output: "a\nb\nc\nd\ne",
pre_is_some: false,
check_contains: &["... (2 lines truncated)"],
},
]);
}
#[test]
fn truncate_head_tail_triggers_sandwich_large_output() {
let p = Profile::new("test").head(2).tail(2);
let lines: Vec<String> = (0..100)
.map(|i| {
format!(
"line {i} aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
)
})
.collect();
let output = lines.join("\n");
assert!(
output.len() > TOOL_OUTPUT_BUDGET_BYTES,
"test output must exceed threshold (got {} bytes)",
output.len()
);
let (result, pre) = apply_line_truncation(&output, &p);
assert!(pre.is_some(), "should capture pre-truncation output");
assert!(
result.contains("... (96 lines omitted)"),
"should have omission marker"
);
assert!(
result.starts_with("line 0 aaaaaaaa"),
"should start with head"
);
assert!(
result.ends_with("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
"should end with tail"
);
let p2 = Profile::new("test").head(2).tail(2).max(100);
let (result2, pre2) = apply_line_truncation(&output, &p2);
assert!(pre2.is_some(), "should capture pre-truncation output");
assert!(
result2.contains("... (96 lines omitted)"),
"should have sandwich omission marker"
);
assert!(
!result2.contains("lines truncated"),
"sandwich should not be additionally truncated when head+tail+1 <= max"
);
}
#[test]
fn format_sandwich_cases() {
let cases: &[(&str, usize, usize, &str)] = &[
("a\nb\nc", 2, 2, "a\nb\nc"),
(
"a\nb\nc\nd\ne\nf\ng",
2,
2,
"a\nb\n... (3 lines omitted)\nf\ng",
),
("a\nb\nc\nd\ne\nf\ng", 7, 0, "a\nb\nc\nd\ne\nf\ng"),
("a\nb\nc\nd\ne\nf\ng", 0, 7, "a\nb\nc\nd\ne\nf\ng"),
(
"a\nb\nc\nd\ne\nf\ng",
3,
0,
"a\nb\nc\n... (4 lines omitted)",
),
(
"a\nb\nc\nd\ne\nf\ng",
0,
3,
"... (4 lines omitted)\ne\nf\ng",
),
];
for (input, head, tail, expected) in cases {
let result = format_sandwich(input, *head, *tail, "omitted");
assert_eq!(
result, *expected,
"format_sandwich({input:?}, {head}, {tail})"
);
}
}
struct FinishCase {
name: &'static str,
combined: &'static str,
elapsed: Duration,
pre: Option<&'static str>,
check: &'static [&'static str], not_check: &'static [&'static str], eq: Option<&'static str>,
}
fn check_finish(cases: &[FinishCase]) {
for case in cases {
let pre_owned = case.pre.map(|s| s.repeat(TOOL_OUTPUT_BUDGET_BYTES + 1));
let result = finish_shell_output(
case.combined.to_string(),
case.elapsed,
pre_owned.as_deref(),
);
assert_contains_not_contains(case.name, &result, case.check, case.not_check);
if let Some(expected) = case.eq {
assert_eq!(result.trim(), expected, "[{}] expected eq", case.name);
}
}
}
#[test]
fn finish_shell_output_cases() {
check_finish(&[
FinishCase {
name: "pre-scrubbed input passes through",
combined: "API_KEY=abcd*[REDACTED]",
elapsed: Duration::ZERO,
pre: None,
check: &["abcd*[REDACTED]"],
not_check: &["abcdefghijklmnop"],
eq: None,
},
FinishCase {
name: "pre-scrubbed combined in spill path",
combined: "SECRET=wxyz*[REDACTED]",
elapsed: Duration::ZERO,
pre: Some("x"),
check: &["wxyz*[REDACTED]", "[Output saved to"],
not_check: &["wxyz1234abcdefgh"],
eq: None,
},
FinishCase {
name: "preserves clean output",
combined: "no credentials here",
elapsed: Duration::ZERO,
pre: None,
check: &[],
not_check: &[],
eq: Some("no credentials here"),
},
FinishCase {
name: "appends elapsed timing with pre-scrubbed input",
combined: "API_KEY=abcd*[REDACTED]",
elapsed: Duration::from_secs(5),
pre: None,
check: &["[took 5.0s]", "abcd*[REDACTED]"],
not_check: &["abcdefghijklmnop"],
eq: None,
},
FinishCase {
name: "scrub idempotent",
combined: "API_KEY=abcd*[REDACTED]",
elapsed: Duration::ZERO,
pre: None,
check: &[],
not_check: &[],
eq: Some("API_KEY=abcd*[REDACTED]"),
},
]);
}
}