use crate::{Tool, ToolOutputPhase, 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::sync::atomic::AtomicBool;
use std::time::Duration;
use crate::util::scrub_credentials;
use crate::util::strip_ansi_escapes;
mod profiles;
mod readonly;
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 = 300;
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);
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,
},
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 run_command_with_timeout(
cmd: &mut tokio::process::Command,
timeout: 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 stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let cancel = tokio_util::sync::CancellationToken::new();
let stdout_handle = spawn_pipe_reader(stdout_pipe, cancel.clone());
let stderr_handle = spawn_pipe_reader(stderr_pipe, cancel.clone());
match tokio::time::timeout(timeout, child.wait()).await {
Ok(Ok(status)) => {
let stdout = stdout_handle.await.unwrap_or_else(|e| {
tracing::warn!(%e, "stdout reader task panicked");
Vec::new()
});
let stderr = stderr_handle.await.unwrap_or_else(|e| {
tracing::warn!(%e, "stderr reader task panicked");
Vec::new()
});
ShellRunResult::Completed {
stdout,
stderr,
status,
elapsed: start.elapsed(),
}
}
Ok(Err(e)) => ShellRunResult::SpawnFailed(e),
Err(_) => {
let _ = child.kill().await;
cancel.cancel();
let stdout = tokio::time::timeout(Duration::from_secs(2), stdout_handle)
.await
.ok()
.and_then(std::result::Result::ok)
.unwrap_or_else(|| {
tracing::warn!("stdout reader did not respond to cancellation within 2 s");
Vec::new()
});
let stderr = tokio::time::timeout(Duration::from_secs(2), stderr_handle)
.await
.ok()
.and_then(std::result::Result::ok)
.unwrap_or_else(|| {
tracing::warn!("stderr reader did not respond to cancellation within 2 s");
Vec::new()
});
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,
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: {DEFAULT_SHELL_TIMEOUT_SECS}s",
elapsed.as_secs_f64()
);
if let Some(p) = pid {
let _ = write!(msg, "\npid: {p}");
}
msg.push_str("\nreason: command was killed after exceeding the timeout");
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 }
}
pub(crate) async fn execute_with_status(
&self,
ws: &Workspace,
args: serde_json::Value,
) -> anyhow::Result<(String, Option<i32>)> {
let command_str = super::get_str(&args, "command")?;
if self.mode == ShellMode::ReadOnly
&& let Err(rejection) = check_command(command_str)
{
anyhow::bail!("{rejection}");
}
let mut cmd = build_shell_command(command_str, ws.as_path());
let result =
run_command_with_timeout(&mut cmd, Duration::from_secs(DEFAULT_SHELL_TIMEOUT_SECS))
.await;
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::warn!(
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, 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())
}
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("/tmp".into()),
_ => {
#[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 => {
const RESTRICTION: &str = "\
⚠️ READ-ONLY MODE: You are not permitted to modify the workspace. \
Commands that write files, delete files, or mutate git state will be rejected before execution. \
Writing to the OS temp directory is allowed. \
Use this tool only for inspection: reading files, listing directories, running cargo check/test/clippy, git status/log/diff, searching, etc.\n\n";
let base = crate::prompt::load_prompt(&format!("tool/{}.md", self.name()));
format!("{RESTRICTION}{base}")
}
ShellMode::Full => crate::prompt::load_prompt(&format!("tool/{}.md", self.name())),
}
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(
&json!({
"command": {
"type": "string",
"description": "The shell command to execute"
},
}),
&["command"],
)
}
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 debug_output(
&self,
phase: ToolOutputPhase,
args: &serde_json::Value,
outcome: Option<(&str, bool)>,
) -> Option<String> {
match phase {
ToolOutputPhase::Before => {
let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("?");
Some(cmd.to_owned())
}
ToolOutputPhase::After => {
let (output, _success) = outcome?;
let trimmed = output.trim();
if trimmed.is_empty() {
return None;
}
Some(crate::util::truncate_sandwich(trimmed, 2000, "debug"))
}
}
}
}
const SPILL_THRESHOLD_BYTES: usize = 5_000;
static SPILL_DIR_CLEANED: AtomicBool = AtomicBool::new(false);
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 extract_command_segments(command: &str) -> Vec<String> {
let mut segments = Vec::new();
let mut current = String::new();
let mut in_single = false;
let mut in_double = false;
let mut chars = command.chars().peekable();
let mut flush = |current: &mut String| {
if !current.trim().is_empty() {
segments.push(current.trim().to_string());
}
current.clear();
};
while let Some(c) = chars.next() {
if c == '\\' && !in_single {
if let Some(next) = chars.next() {
current.push(next);
} else {
current.push(c); }
continue;
}
if check_outside_quotes(c, &mut in_single, &mut in_double) {
match c {
'&' if chars.peek() == Some(&'&') => {
chars.next(); flush(&mut current);
continue;
}
'|' => {
if chars.peek() == Some(&'|') {
chars.next();
}
flush(&mut current);
continue;
}
';' => {
flush(&mut current);
continue;
}
_ => {}
}
}
current.push(c);
}
flush(&mut current);
segments
}
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 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| !SHELL_PREFIXES.contains(w) && !w.starts_with('-') && !is_env_assignment(w))
}
fn command_word_from_segment(segment: &str) -> Option<(usize, &str, Vec<&str>)> {
let trimmed = segment.trim();
let words: Vec<&str> = trimmed.split_whitespace().collect();
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();
let exit_ok = exit_code == 0;
if stderr_trimmed.is_empty() {
return stdout.to_string();
}
if exit_ok {
if let Some(patterns) = keep_stderr {
let relevant: Vec<&str> = stderr.lines().filter(|l| patterns.is_match(l)).collect();
if !relevant.is_empty() {
if stdout.is_empty() {
return format!("stderr:\n{}", relevant.join("\n"));
}
return format!("{stdout}\nstderr:\n{}", relevant.join("\n"));
}
}
return stdout.to_string();
}
if stdout.is_empty() {
return format!("stderr:\n{stderr_trimmed}");
}
format!("{stdout}\nstderr:\n{stderr_trimmed}")
}
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() > SPILL_THRESHOLD_BYTES,
"invariant: pre-truncation output ({}) must exceed threshold ({})",
pre.len(),
SPILL_THRESHOLD_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, SPILL_THRESHOLD_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))
}
#[allow(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) -> 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 omitted)");
} else {
let _ = write!(result, "\n... ({omitted} lines omitted)");
}
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() > SPILL_THRESHOLD_BYTES;
let pre_truncation = if should_sandwich {
Some(output.to_string())
} else {
None
};
let result = if should_sandwich {
format_sandwich(output, head, tail)
} else if let Some(max) = max {
cap_at_max_lines(output, max)
} 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 cap_at_max_lines(output: &str, max: usize) -> String {
let lines: Vec<&str> = output.lines().collect();
if lines.len() > max {
let truncated = lines.len() - max;
let mut capped = lines[..max].join("\n");
let _ = write!(capped, "\n... ({truncated} lines truncated)");
capped
} else {
output.to_string()
}
}
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
{
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))
}
fn agent_temp_dir() -> Option<std::path::PathBuf> {
let dir = std::env::temp_dir().join(".agent");
if !SPILL_DIR_CLEANED.swap(true, std::sync::atomic::Ordering::Relaxed) {
let _ = cleanup_temp_dir(&dir);
}
std::fs::create_dir_all(&dir).ok()?;
Some(dir)
}
fn cleanup_temp_dir(dir: &Path) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
let _ = std::fs::remove_file(&path);
}
}
Ok(())
}
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()?;
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::UnwrapPoison;
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);
}
#[allow(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: "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);
}
#[allow(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()
},
];
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:?}"
);
}
#[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)).await;
match result {
ShellRunResult::TimedOut { elapsed, .. } => {
assert!(
elapsed < Duration::from_secs(3),
"expected ~1s timeout, got {elapsed:?}"
);
}
other => panic!("expected TimedOut, got {other:?}"),
}
}
#[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)).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:?}"),
}
}
#[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)).await;
let ShellRunResult::TimedOut {
stdout,
stderr,
pid,
elapsed,
} = result
else {
panic!("expected timeout");
};
let msg = format_timeout_error("echo test", elapsed, pid, &stdout, &stderr);
assert!(msg.contains("elapsed:"), "msg: {msg}");
assert!(msg.contains("timeout_limit:"), "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, 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}"
);
}
#[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: "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();
assert_eq!(
try_spill_to_file(short.clone(), 5_000),
short,
"short output should pass through"
);
let large = "x".repeat(10_000);
let result = try_spill_to_file(large, 5_000);
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 > 5_000,
"test data {multi_len} must exceed spill threshold"
);
let result = try_spill_to_file(multi, 5_000);
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\""]),
];
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"),
("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() > SPILL_THRESHOLD_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);
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(SPILL_THRESHOLD_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]"),
},
]);
}
}