use crate::{Tool, Workspace};
use async_trait::async_trait;
use directories::UserDirs;
use futures_util::stream::{FuturesUnordered, StreamExt};
use regex::RegexSet;
use serde_json::json;
use std::collections::HashSet;
use std::ffi::OsString;
use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::process::Stdio;
#[cfg(windows)]
use std::sync::OnceLock;
use std::time::Duration;
use super::listing::{ListingEntry, format_listing, human_readable_size};
use super::with_note;
use crate::util::TOOL_OUTPUT_BUDGET_BYTES;
use crate::util::UnwrapPoison;
use crate::util::scrub_credentials;
use crate::util::strip_ansi_escapes;
mod bg;
pub(crate) mod grep_engine;
mod mem;
mod plan;
mod profiles;
mod readonly;
mod scan;
mod tree;
mod windows_line;
mod windows_text;
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;
use self::tree::{RunOwner, Tree};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ShellPlatform {
Unix,
Windows,
}
pub(super) const SHELL_PLATFORM: ShellPlatform = if cfg!(windows) {
ShellPlatform::Windows
} else {
ShellPlatform::Unix
};
pub(crate) const WINDOWS_COMMAND_INTERPRETER: &str = "cmd.exe";
const WINDOWS_COMMAND_SWITCHES: &str = "/D /E:ON /V:OFF";
pub(super) const REFUSAL_FRAME: &str = "Command not run: ";
pub(super) fn framed_refusal(frame: &str, cmd: &str, why: &str, suggestion: &str) -> String {
format!("{frame}{why}\nCommand: `{cmd}`\nSuggestion: {suggestion}")
}
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 MEMORY_SAMPLE_INTERVAL: Duration = Duration::from_millis(250);
const SHELL_PIPE_READ_CAP: usize = 256 * 1024;
const TIMEOUT_OUTPUT_TAIL_CHARS: usize = 2_000;
const ENGINE_FAILURE_DETAIL_CHARS: usize = 200;
#[cfg(not(target_os = "windows"))]
const FALLBACK_ENV_VARS: &[&str] = &[
"PATH", "HOME", "TERM", "LANG", "LC_ALL", "LC_CTYPE", "USER", "SHELL",
];
#[cfg(target_os = "windows")]
const FALLBACK_ENV_VARS: &[&str] = &[
"PATH",
"PATHEXT",
"HOME",
"USERPROFILE",
"HOMEDRIVE",
"HOMEPATH",
"SYSTEMROOT",
"SYSTEMDRIVE",
"WINDIR",
"COMSPEC",
"TERM",
"LANG",
"USERNAME",
];
#[must_use]
pub(crate) fn internal_env_pairs() -> Vec<(OsString, OsString)> {
let mut pairs = Vec::new();
for &name in FALLBACK_ENV_VARS {
if let Some(value) = baseline_env_value(name) {
pairs.push((OsString::from(name), OsString::from(value)));
}
}
for (name, value) in crate::temp::shell_temp_vars() {
pairs.push((OsString::from(name), OsString::from(value)));
}
pairs
}
#[must_use]
pub(crate) fn agent_env_pairs() -> Vec<(OsString, OsString)> {
crate::shell_env::snapshot()
.map_or_else(internal_env_pairs, |owner| agent_env_pairs_from(&owner))
}
#[must_use]
pub(crate) fn agent_env_pairs_from(
owner: &crate::shell_env::OwnerEnv,
) -> Vec<(OsString, OsString)> {
let mut pairs = owner.vars().to_vec();
let mut overrides: Vec<(OsString, OsString)> = Vec::new();
if let Some(home) = baseline_env_value("HOME") {
overrides.push((OsString::from("HOME"), OsString::from(home)));
}
for (name, value) in crate::temp::shell_temp_vars() {
overrides.push((OsString::from(name), OsString::from(value)));
}
for (name, value) in overrides {
match pairs.iter().position(|(existing, _)| *existing == name) {
Some(index) => pairs[index].1 = value,
None => pairs.push((name, value)),
}
}
pairs
}
fn apply_env_pairs(cmd: &mut std::process::Command, pairs: &[(OsString, OsString)]) {
cmd.env_clear();
cmd.envs(pairs.iter().map(|(name, value)| (name, value)));
}
pub(crate) fn apply_agent_env(cmd: &mut tokio::process::Command) {
apply_env_pairs(cmd.as_std_mut(), &agent_env_pairs());
}
#[must_use]
pub(crate) fn program_command(
program: &str,
env: &[(OsString, OsString)],
dir: Option<&Path>,
) -> std::process::Command {
#[cfg(target_os = "macos")]
let resolved = if program.contains('/') {
None
} else {
resolve_program_path(program, env, dir).filter(|path| is_mach_o(path))
};
#[cfg(not(target_os = "macos"))]
let resolved: Option<PathBuf> = None;
let mut cmd =
std::process::Command::new(resolved.as_deref().unwrap_or_else(|| Path::new(program)));
if resolved.is_some() {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.arg0(program);
}
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
}
if let Some(dir) = dir {
cmd.current_dir(dir);
}
apply_env_pairs(&mut cmd, env);
cmd
}
#[cfg(target_os = "macos")]
const DEFAULT_SEARCH_PATH: &str = "/usr/bin:/bin";
#[cfg(target_os = "macos")]
fn resolve_program_path(
program: &str,
env: &[(OsString, OsString)],
base_dir: Option<&Path>,
) -> Option<PathBuf> {
let search = env.iter().find(|(name, _)| name == "PATH").map_or_else(
|| OsString::from(DEFAULT_SEARCH_PATH),
|(_, value)| value.clone(),
);
let mut search_dir: Option<PathBuf> = base_dir.map(Path::to_path_buf);
std::env::split_paths(&search)
.map(|entry| {
if entry.is_absolute() {
entry.join(program)
} else {
search_dir
.get_or_insert_with(|| std::env::current_dir().unwrap_or_default())
.join(&entry)
.join(program)
}
})
.find(|candidate| is_executable_file(candidate))
.and_then(|candidate| std::path::absolute(candidate).ok())
}
#[cfg(target_os = "macos")]
fn is_executable_file(path: &Path) -> bool {
use std::os::unix::ffi::OsStrExt;
if !path.is_file() {
return false;
}
let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
return false;
};
unsafe { libc::access(c_path.as_ptr(), libc::X_OK) == 0 }
}
#[cfg(target_os = "macos")]
fn is_mach_o(path: &Path) -> bool {
use std::io::Read;
let Ok(mut file) = std::fs::File::open(path) else {
return false;
};
let mut head = [0u8; 4];
if file.read_exact(&mut head).is_err() {
return false;
}
matches!(
&head,
b"\xCF\xFA\xED\xFE"
| b"\xFE\xED\xFA\xCF"
| b"\xCE\xFA\xED\xFE"
| b"\xFE\xED\xFA\xCE"
| b"\xCA\xFE\xBA\xBE"
| b"\xBE\xBA\xFE\xCA"
| b"\xCA\xFE\xBA\xBF"
| b"\xBF\xBA\xFE\xCA"
)
}
fn build_shell_command(command: &str, workspace_root: &Path) -> tokio::process::Command {
let env = agent_env_pairs();
#[cfg(not(target_os = "windows"))]
let process = {
let mut p: tokio::process::Command =
program_command("sh", &env, Some(workspace_root)).into();
p.arg("-c").arg(command);
#[cfg(unix)]
{
p.process_group(0);
}
p
};
#[cfg(target_os = "windows")]
let process = {
let mut p: tokio::process::Command =
program_command(WINDOWS_COMMAND_INTERPRETER, &env, Some(workspace_root)).into();
p.raw_arg(format!("{WINDOWS_COMMAND_SWITCHES} /C \"{command}\""));
p
};
process
}
fn program_command_with(
program: &Path,
args: &[String],
cwd: &Path,
apply_env: impl FnOnce(&mut tokio::process::Command),
) -> tokio::process::Command {
let mut process = tokio::process::Command::new(program);
process.args(args);
#[cfg(unix)]
{
process.process_group(0);
}
#[cfg(target_os = "windows")]
process.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
process.current_dir(cwd);
apply_env(&mut process);
process
}
fn build_program_command(program: &Path, args: &[String], cwd: &Path) -> tokio::process::Command {
program_command_with(program, args, cwd, apply_agent_env)
}
fn build_internal_program_command(
program: &Path,
args: &[String],
cwd: &Path,
) -> tokio::process::Command {
program_command_with(program, args, cwd, |process| {
apply_env_pairs(process.as_std_mut(), &internal_env_pairs());
})
}
#[derive(Debug)]
enum ShellRunResult {
Completed {
stdout: Vec<u8>,
stderr: Vec<u8>,
status: std::process::ExitStatus,
elapsed: Duration,
},
ExitedWithLeftovers {
stdout: Vec<u8>,
stderr: Vec<u8>,
status: std::process::ExitStatus,
scope: Option<u32>,
elapsed: Duration,
},
EndedWithLeftovers {
stdout: Vec<u8>,
stderr: Vec<u8>,
elapsed: Duration,
},
TimedOut {
stdout: Vec<u8>,
stderr: Vec<u8>,
pid: Option<u32>,
elapsed: Duration,
},
MemoryExceeded {
stdout: Vec<u8>,
stderr: Vec<u8>,
pid: Option<u32>,
elapsed: Duration,
used: u64,
limit: u64,
},
SpawnFailed(std::io::Error),
}
#[derive(Clone, Copy)]
pub(super) enum LeftoverPolicy {
Keep,
End,
}
enum WaitOutcome {
Exited(std::io::Result<std::process::ExitStatus>),
TimedOut,
MemoryExceeded { used: u64, limit: u64 },
}
struct MemorySample {
used: u64,
limit: u64,
}
struct Watchdog {
deadline: std::pin::Pin<Box<tokio::time::Sleep>>,
sample: tokio::time::Interval,
measure_failed: bool,
}
impl Watchdog {
pub(in crate::tools::shell) fn new(timeout: Duration) -> Self {
let mut sample = tokio::time::interval(MEMORY_SAMPLE_INTERVAL);
sample.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
Self {
deadline: Box::pin(tokio::time::sleep(timeout)),
sample,
measure_failed: false,
}
}
pub(in crate::tools::shell) fn measure(
&mut self,
pids: &[u32],
limit: u64,
) -> Option<MemorySample> {
if pids.is_empty() {
return None;
}
let mut used = 0_u64;
let mut measured = false;
for pid in pids {
if let Some(rss) = mem::tree_rss(*pid) {
measured = true;
used = used.saturating_add(rss);
}
}
if !measured {
if !self.measure_failed {
self.measure_failed = true;
tracing::warn!(
pid = pids[0],
"memory watchdog cannot measure the command's tree; the run continues \
without a memory bound"
);
}
return None;
}
(used > limit).then_some(MemorySample { used, limit })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Stream {
Stdin,
Stdout,
Stderr,
}
#[derive(Clone)]
struct CaptureBudget(std::sync::Arc<std::sync::atomic::AtomicUsize>);
impl CaptureBudget {
fn new(cap: usize) -> Self {
Self(std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(
cap,
)))
}
fn claim(&self, bytes: usize) -> usize {
use std::sync::atomic::Ordering;
let mut remaining = self.0.load(Ordering::Relaxed);
loop {
let take = bytes.min(remaining);
if take == 0 {
return 0;
}
match self.0.compare_exchange_weak(
remaining,
remaining - take,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => return take,
Err(actual) => remaining = actual,
}
}
}
}
async fn read_stream_limited(
reader: &mut (impl tokio::io::AsyncRead + Unpin),
budget: &CaptureBudget,
cancel: tokio_util::sync::CancellationToken,
buf: &std::sync::Mutex<Vec<u8>>,
) {
use tokio::io::AsyncReadExt;
let mut chunk = [0u8; 8192];
loop {
tokio::select! {
biased;
result = reader.read(&mut chunk) => {
match result {
Ok(0) | Err(_) => break,
Ok(n) => {
let keep = budget.claim(n);
if keep > 0 {
buf.lock().unwrap_poison().extend_from_slice(&chunk[..keep]);
}
}
}
}
() = cancel.cancelled() => break,
}
}
}
fn spawn_pipe_reader(
pipe: impl tokio::io::AsyncRead + Unpin + Send + 'static,
budget: CaptureBudget,
cancel: tokio_util::sync::CancellationToken,
buf: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut pipe = pipe;
read_stream_limited(&mut pipe, &budget, cancel, &buf).await;
})
}
struct Reader {
stream: Captured,
buf: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
task: ReaderTask,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Captured {
Stdout,
Stderr,
}
impl Captured {
fn label(self) -> &'static str {
match self {
Captured::Stdout => "stdout",
Captured::Stderr => "stderr",
}
}
}
enum ReaderTask {
Reading(tokio::task::JoinHandle<()>),
Done,
}
#[derive(Default)]
struct Readers {
readers: Vec<Reader>,
}
impl Readers {
fn push(
&mut self,
stream: Captured,
pipe: impl tokio::io::AsyncRead + Unpin + Send + 'static,
budget: CaptureBudget,
cancel: tokio_util::sync::CancellationToken,
) {
let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
self.readers.push(Reader {
stream,
buf: std::sync::Arc::clone(&buf),
task: ReaderTask::Reading(spawn_pipe_reader(pipe, budget, cancel, buf)),
});
}
async fn drain(&mut self, drain_limit: Duration) -> bool {
let limit = tokio::time::sleep(drain_limit);
tokio::pin!(limit);
loop {
if self
.readers
.iter()
.all(|reader| matches!(reader.task, ReaderTask::Done))
{
return true;
}
let finished = {
let mut pending = FuturesUnordered::new();
for (index, reader) in self.readers.iter_mut().enumerate() {
if let ReaderTask::Reading(handle) = &mut reader.task {
pending.push(async move { (index, handle.await) });
}
}
tokio::select! {
biased;
next = pending.next() => next,
() = &mut limit => None,
}
};
let Some((index, result)) = finished else {
return false;
};
self.readers[index].store(result);
}
}
async fn collect(&mut self) -> (Vec<u8>, Vec<u8>) {
futures_util::future::join_all(self.readers.iter_mut().map(Reader::finish)).await;
self.take()
}
fn snapshot_and_detach(&mut self) -> (Vec<u8>, Vec<u8>) {
for reader in &mut self.readers {
if let ReaderTask::Reading(handle) =
std::mem::replace(&mut reader.task, ReaderTask::Done)
{
crate::util::leftover_channels::retain_channel(handle);
}
}
self.take()
}
fn take(&mut self) -> (Vec<u8>, Vec<u8>) {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
for reader in &mut self.readers {
let bytes = std::mem::take(&mut *reader.buf.lock().unwrap_poison());
match reader.stream {
Captured::Stdout => stdout.extend_from_slice(&bytes),
Captured::Stderr => stderr.extend_from_slice(&bytes),
}
}
(stdout, stderr)
}
}
impl Reader {
fn store(&mut self, result: std::result::Result<(), tokio::task::JoinError>) {
if let Err(e) = result {
tracing::warn!(%e, "{} reader task panicked", self.stream.label());
}
self.task = ReaderTask::Done;
}
async fn finish(&mut self) {
match std::mem::replace(&mut self.task, ReaderTask::Done) {
ReaderTask::Done => {}
ReaderTask::Reading(handle) => {
await_pipe_reader_with_cancellation_timeout(
handle,
self.stream.label(),
DRAIN_CANCEL_GRACE,
)
.await;
}
}
}
}
async fn await_pipe_reader_with_cancellation_timeout(
mut handle: tokio::task::JoinHandle<()>,
label: &str,
cancellation_timeout: Duration,
) {
if tokio::time::timeout(cancellation_timeout, &mut handle)
.await
.is_err()
{
tracing::warn!(
"{label} reader did not respond to cancellation within {cancellation_timeout:?} — \
it stays detached and keeps draining its pipe"
);
crate::util::leftover_channels::retain_channel(handle);
}
}
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"
);
}
}
struct SpecFiles(Vec<PathBuf>);
impl Drop for SpecFiles {
fn drop(&mut self) {
grep_engine::discard_spec_files(&self.0);
}
}
fn unserved_refusal(
platform: ShellPlatform,
grep_serve: &grep_engine::GrepServe,
applied: bool,
) -> Option<String> {
if platform != ShellPlatform::Windows {
return None;
}
grep_serve.refusal.clone().or_else(|| {
(!applied && grep_serve.rewritten.is_some()).then(|| GUARD_REJECTED_REASON.to_string())
})
}
const GUARD_REJECTED_REASON: &str = "read-only guard rejected rewrite";
enum EngineFailure {
ReRun,
Refused(String),
}
fn engine_failure(
status_code: Option<i32>,
stderr: &[u8],
platform: ShellPlatform,
) -> Option<EngineFailure> {
let text = windows_text::decode(stderr);
let marked = platform == ShellPlatform::Windows
&& text
.lines()
.any(|line| line.trim() == grep_engine::ENGINE_REFUSAL_MARKER);
if status_code != Some(grep_engine::ENGINE_FAILED_EXIT)
&& !text.contains(grep_engine::STALE_BINARY_LOCK_MSG)
&& !marked
{
return None;
}
if platform != ShellPlatform::Windows {
return Some(EngineFailure::ReRun);
}
Some(EngineFailure::Refused(engine_cause(&text, marked)))
}
fn engine_cause(text: &str, marked: bool) -> String {
let detail = if marked {
text.lines()
.map(str::trim)
.skip_while(|line| *line != grep_engine::ENGINE_REFUSAL_MARKER)
.nth(1)
} else {
let lines: Vec<&str> = text.lines().map(str::trim).collect();
lines
.iter()
.find(|line| line.contains(grep_engine::STALE_BINARY_LOCK_MSG))
.or_else(|| lines.iter().find(|line| !line.is_empty()))
.copied()
};
detail.map_or_else(
|| "engine could not serve the search".to_string(),
|line| crate::util::truncate(line, ENGINE_FAILURE_DETAIL_CHARS),
)
}
struct KillOnDrop {
tree: Tree,
armed: bool,
}
impl KillOnDrop {
fn new(tree: Tree) -> Self {
Self { tree, armed: true }
}
fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for KillOnDrop {
fn drop(&mut self) {
if self.armed {
self.tree.terminate();
}
}
}
async fn run_command_with_timeout(
cmd: &mut tokio::process::Command,
timeout: Duration,
drain_limit: Duration,
memory_limit: Option<u64>,
owner: RunOwner,
leftovers: LeftoverPolicy,
) -> ShellRunResult {
let start = std::time::Instant::now();
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut tree = Tree::new(owner);
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => return ShellRunResult::SpawnFailed(e),
};
let pid = child.id();
if let Some(pid) = pid {
tree.attach(pid);
}
let mut kill_guard = KillOnDrop::new(tree.clone());
let stdout_pipe = child.stdout.take().expect("stdout piped");
let stderr_pipe = child.stderr.take().expect("stderr piped");
let cancel = tokio_util::sync::CancellationToken::new();
let mut readers = Readers::default();
readers.push(
Captured::Stdout,
stdout_pipe,
CaptureBudget::new(SHELL_PIPE_READ_CAP),
cancel.clone(),
);
readers.push(
Captured::Stderr,
stderr_pipe,
CaptureBudget::new(SHELL_PIPE_READ_CAP),
cancel.clone(),
);
match await_run_end(&mut child, timeout, memory_limit, pid).await {
WaitOutcome::Exited(Ok(status)) => {
kill_guard.disarm();
if readers.drain(drain_limit).await {
tree.retain_after_completion();
let (stdout, stderr) = readers.collect().await;
ShellRunResult::Completed {
stdout,
stderr,
status,
elapsed: start.elapsed(),
}
} else {
match leftovers {
LeftoverPolicy::Keep => {
tree.retain_after_completion();
let (stdout, stderr) = readers.snapshot_and_detach();
ShellRunResult::ExitedWithLeftovers {
stdout,
stderr,
status,
scope: pid,
elapsed: start.elapsed(),
}
}
LeftoverPolicy::End => {
tree.terminate();
cancel.cancel();
let (stdout, stderr) = readers.collect().await;
ShellRunResult::EndedWithLeftovers {
stdout,
stderr,
elapsed: start.elapsed(),
}
}
}
}
}
WaitOutcome::Exited(Err(e)) => ShellRunResult::SpawnFailed(e),
WaitOutcome::TimedOut => {
let (stdout, stderr) =
stop_and_collect(&mut child, &tree, &mut kill_guard, &cancel, &mut readers).await;
ShellRunResult::TimedOut {
stdout,
stderr,
pid,
elapsed: start.elapsed(),
}
}
WaitOutcome::MemoryExceeded { used, limit } => {
let (stdout, stderr) =
stop_and_collect(&mut child, &tree, &mut kill_guard, &cancel, &mut readers).await;
ShellRunResult::MemoryExceeded {
stdout,
stderr,
pid,
elapsed: start.elapsed(),
used,
limit,
}
}
}
}
async fn await_run_end(
child: &mut tokio::process::Child,
timeout: Duration,
memory_limit: Option<u64>,
pid: Option<u32>,
) -> WaitOutcome {
let wait = child.wait();
tokio::pin!(wait);
let mut watchdog = Watchdog::new(timeout);
loop {
tokio::select! {
biased;
status = &mut wait => return WaitOutcome::Exited(status),
() = &mut watchdog.deadline => return WaitOutcome::TimedOut,
_ = watchdog.sample.tick() => {
if let Some(limit) = memory_limit
&& let Some(sample) = watchdog.measure(pid.as_slice(), limit)
{
return WaitOutcome::MemoryExceeded {
used: sample.used,
limit: sample.limit,
};
}
}
}
}
}
async fn stop_and_collect(
child: &mut tokio::process::Child,
tree: &Tree,
kill_guard: &mut KillOnDrop,
cancel: &tokio_util::sync::CancellationToken,
readers: &mut Readers,
) -> (Vec<u8>, Vec<u8>) {
let _ = child.start_kill();
tree.terminate();
let _ = child.wait().await;
kill_guard.disarm();
cancel.cancel();
readers.collect().await
}
pub(crate) struct ProgramOutcome {
pub success: bool,
pub detail: String,
pub output: String,
pub has_output: bool,
}
fn program_outcome(success: bool, detail: String, stdout: &[u8], stderr: &[u8]) -> ProgramOutcome {
ProgramOutcome {
success,
detail,
output: decode_raw_streams(stdout, stderr),
has_output: !stdout.is_empty() || !stderr.is_empty(),
}
}
async fn run_program(
cwd: &Path,
program: &Path,
args: &[String],
owner: RunOwner,
) -> ShellRunResult {
run_bounded(build_program_command(program, args, cwd), owner).await
}
async fn run_bounded(mut cmd: tokio::process::Command, owner: RunOwner) -> ShellRunResult {
run_command_with_timeout(
&mut cmd,
Duration::from_secs(DEFAULT_SHELL_TIMEOUT_SECS),
output_drain_timeout(),
mem::default_limit(),
owner,
LeftoverPolicy::End,
)
.await
}
pub(crate) async fn run_program_outcome(
cwd: &Path,
program: &Path,
args: &[String],
) -> ProgramOutcome {
match run_program(cwd, program, args, RunOwner::Service).await {
ShellRunResult::Completed {
stdout,
stderr,
status,
..
} => {
let code = status.code();
program_outcome(
code == Some(0),
match code {
Some(n) => format!("exit status {n}"),
None => "terminated by signal".to_string(),
},
&stdout,
&stderr,
)
}
ShellRunResult::TimedOut { stdout, stderr, .. } => program_outcome(
false,
format!("timed out after {DEFAULT_SHELL_TIMEOUT_SECS}s"),
&stdout,
&stderr,
),
ShellRunResult::ExitedWithLeftovers { stdout, stderr, .. }
| ShellRunResult::EndedWithLeftovers { stdout, stderr, .. } => program_outcome(
false,
"output drain overrun — a leftover process held the pipes".to_string(),
&stdout,
&stderr,
),
ShellRunResult::MemoryExceeded {
stdout,
stderr,
used,
limit,
..
} => program_outcome(false, mem::exceeded_failure(used, limit), &stdout, &stderr),
ShellRunResult::SpawnFailed(e) => {
program_outcome(false, format!("failed to start: {e}"), &[], &[])
}
}
}
pub(crate) async fn run_program_with_timeout(
cwd: &Path,
program: &Path,
args: &[String],
label: &str,
) -> anyhow::Result<String> {
annotate_run(
run_program(cwd, program, args, RunOwner::Agent).await,
label,
)
}
pub(crate) async fn run_internal_program_with_timeout(
cwd: &Path,
program: &Path,
args: &[String],
label: &str,
) -> anyhow::Result<String> {
annotate_run(
run_bounded(
build_internal_program_command(program, args, cwd),
RunOwner::Agent,
)
.await,
label,
)
}
fn annotate_run(run: ShellRunResult, label: &str) -> anyhow::Result<String> {
match run {
ShellRunResult::Completed {
stdout,
stderr,
status,
..
} => {
let code = status.code();
let output = decode_raw_streams(&stdout, &stderr);
if code == Some(0) {
return Ok(output);
}
Ok(with_note(&output, &format_exit_status_note(code)))
}
ShellRunResult::TimedOut {
stdout,
stderr,
elapsed,
..
} => Err(anyhow::anyhow!(
"timeout: {label} did not finish within {DEFAULT_SHELL_TIMEOUT_SECS}s and was killed\n{}",
program_error_tail(elapsed, &stdout, &stderr),
)),
ShellRunResult::ExitedWithLeftovers {
stdout,
stderr,
elapsed,
..
}
| ShellRunResult::EndedWithLeftovers {
stdout,
stderr,
elapsed,
} => Err(anyhow::anyhow!(
"timeout: {label} exited but a leftover process kept its output pipes open \
past the drain limit — hint: keep any process the script launches inside its \
own lifetime\n{}",
program_error_tail(elapsed, &stdout, &stderr),
)),
ShellRunResult::MemoryExceeded {
stdout,
stderr,
elapsed,
used,
limit,
..
} => Err(anyhow::anyhow!(
"{label} {}\n{}",
mem::exceeded_failure(used, limit),
program_error_tail(elapsed, &stdout, &stderr),
)),
ShellRunResult::SpawnFailed(e) => Err(anyhow::anyhow!(
"io: cannot run {label}: {e} — hint: the program must exist and be executable"
)),
}
}
fn program_error_tail(elapsed: Duration, stdout: &[u8], stderr: &[u8]) -> String {
let mut msg = format!("elapsed: {:.1}s", elapsed.as_secs_f64());
append_output_tail(&mut msg, "stdout", stdout);
append_output_tail(&mut msg, "stderr", stderr);
msg
}
fn decode_raw_streams(stdout: &[u8], stderr: &[u8]) -> String {
let stdout = decode_and_strip_ansi(stdout);
let stderr = decode_and_strip_ansi(stderr);
if stderr.trim().is_empty() {
stdout
} else if stdout.trim().is_empty() {
stderr
} else {
format!("{stdout}\n{stderr}")
}
}
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_memory_error(
command: &str,
elapsed: Duration,
used: u64,
limit: u64,
pid: Option<u32>,
stdout: &[u8],
stderr: &[u8],
) -> String {
let mut msg = format!(
"Shell command {}.\n\
command: {command}\n\
elapsed: {:.1}s\n\
memory_limit: {}",
mem::exceeded_failure(used, limit),
elapsed.as_secs_f64(),
mem::gib(limit),
);
if let Some(p) = pid {
let _ = write!(msg, "\npid: {p}");
}
msg.push_str("\nreason: command was killed after exceeding the memory limit");
msg.push_str(
"\nhint: the command's whole process tree grew past the ceiling — bound the \
input it reads or split it into smaller commands.",
);
append_output_tail(&mut msg, "stdout", stdout);
append_output_tail(&mut msg, "stderr", stderr);
msg
}
fn format_leftover_note(mode: ShellMode, platform: ShellPlatform, scope: Option<u32>) -> String {
let mut msg = crate::prompt::load_prompt_sections("tool/shell_leftover.md")
.into_iter()
.nth(1)
.expect("tool/shell_leftover.md carries the note's lead as its second section");
if matches!(mode, ShellMode::Full) {
let _ = write!(msg, "\n{}", leftover_stop_recipe(platform, scope));
}
let _ = write!(msg, "\n{}", leftover_mode_sentence(mode));
msg
}
fn leftover_mode_sentence(mode: ShellMode) -> String {
crate::prompt::load_prompt(match mode {
ShellMode::Full => "tool/shell_leftover_full.md",
ShellMode::ReadOnly => "tool/shell_leftover_read_only.md",
})
.trim()
.to_owned()
}
fn leftover_stop_recipe(platform: ShellPlatform, scope: Option<u32>) -> String {
match (scope, platform) {
(Some(pid), ShellPlatform::Unix) => {
format!("leftover process group: {pid}\nstop it with: kill -TERM -{pid}")
}
(Some(pid), ShellPlatform::Windows) => format!(
"leftover process tree: started by pid {pid}, which has already exited\n\
list what it left: tasklist /FI \"PPID eq {pid}\"\n\
stop each one: taskkill /PID <listed pid> /T /F"
),
(None, _) => "the leftover could not be identified, so it cannot be named for a stop — \
remember it is still running."
.to_string(),
}
}
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,
executed: windows_line::Executed<'_>,
written: windows_line::Written<'_>,
) -> anyhow::Result<(String, Option<i32>)> {
let sessions = Self::background_sessions_handle()?;
let path = sessions
.launch(executed, written, ws.as_path(), self.mode)
.await
.map_err(anyhow::Error::msg)?;
Ok((
format!(
"Background session started.\n\
output file: {}\n\
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(),
written.0
),
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")?;
let exec_str = windows_line::executable(command_str, SHELL_PLATFORM, self.mode)
.map_err(anyhow::Error::msg)?;
readonly::check_line_divergences(command_str, SHELL_PLATFORM)
.map_err(anyhow::Error::msg)?;
return self
.launch_background(
ws,
windows_line::Executed(&exec_str),
windows_line::Written(command_str),
)
.await;
}
}
let command_str = super::get_str(&args, "command")?;
let base = windows_line::executable(command_str, SHELL_PLATFORM, self.mode)
.map_err(anyhow::Error::msg)?;
let mut exec_str = base.as_ref().to_owned();
let mut grep_serve = grep_engine::try_serve_command(&exec_str, ws.as_path());
let _spec_files = SpecFiles(std::mem::take(&mut grep_serve.spec_files));
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}");
}
if let Some(rewritten) = grep_serve.rewritten.as_deref() {
if check_command(rewritten, &ctx).is_ok() {
exec_str = rewritten.to_string();
}
}
} else {
readonly::check_line_divergences(command_str, SHELL_PLATFORM)
.map_err(anyhow::Error::msg)?;
if let Some(rewritten) = grep_serve.rewritten.as_deref() {
exec_str = rewritten.to_string();
}
}
if let Some(cause) =
unserved_refusal(SHELL_PLATFORM, &grep_serve, exec_str != base.as_ref())
{
self.write_grep_telemetry(ws, command_str, &grep_serve, false, &cause, None)
.await;
let remedy = exec_str
.contains('\n')
.then(|| windows_line::remedy(self.mode));
anyhow::bail!(
"{}",
grep_engine::unserved_failure(&cause, remedy.as_deref())
);
}
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();
let mut plan_run = grep_serve.plan.take();
if plan_run.is_none() {
match plan::plan_for_command(
windows_line::Executed(&exec_str),
windows_line::Written(command_str),
ws.as_path(),
) {
plan::OwnImage::None => {}
plan::OwnImage::Direct(direct) => plan_run = Some(direct),
plan::OwnImage::Refused(cause) => {
anyhow::bail!("{}", plan::refusal_message(&cause));
}
}
}
match &plan_run {
Some(plan_run) => plan_run
.steps
.iter()
.try_for_each(|step| match &step.run {
plan::Run::Shell { text } => {
windows_line::check_command_line(text, SHELL_PLATFORM, self.mode)
}
plan::Run::Own { .. } => Ok(()),
})
.map_err(anyhow::Error::msg)?,
None => windows_line::check_command_line(&exec_str, SHELL_PLATFORM, self.mode)
.map_err(anyhow::Error::msg)?,
}
let mut result = if let Some(plan_run) = &plan_run {
plan::run(
plan_run,
timeout,
drain_limit,
mem::default_limit(),
RunOwner::Agent,
)
.await
} else {
let mut cmd = build_shell_command(&exec_str, ws.as_path());
run_command_with_timeout(
&mut cmd,
timeout,
drain_limit,
mem::default_limit(),
RunOwner::Agent,
LeftoverPolicy::Keep,
)
.await
};
match &mut result {
ShellRunResult::Completed { stderr, .. }
| ShellRunResult::ExitedWithLeftovers { stderr, .. }
| ShellRunResult::EndedWithLeftovers { stderr, .. }
| ShellRunResult::TimedOut { stderr, .. }
| ShellRunResult::MemoryExceeded { stderr, .. } => {
if exec_str != base.as_ref() {
grep_engine::strip_stream_size_marker(stderr);
}
}
ShellRunResult::SpawnFailed(_) => {}
}
let engine_failure = match &result {
ShellRunResult::Completed { status, stderr, .. }
| ShellRunResult::ExitedWithLeftovers { status, stderr, .. }
if exec_str != base.as_ref() =>
{
engine_failure(status.code(), stderr, SHELL_PLATFORM)
}
_ => None,
};
let mut sentinel_rerun = false;
let result = match engine_failure {
Some(EngineFailure::Refused(cause)) => {
self.write_grep_telemetry(
ws,
command_str,
&grep_serve,
false,
&cause,
Some(&result),
)
.await;
anyhow::bail!("{}", grep_engine::unserved_failure(&cause, None));
}
Some(EngineFailure::ReRun) => {
sentinel_rerun = true;
let mut original = build_shell_command(base.as_ref(), ws.as_path());
run_command_with_timeout(
&mut original,
timeout,
drain_limit,
mem::default_limit(),
RunOwner::Agent,
LeftoverPolicy::Keep,
)
.await
}
None => result,
};
if exec_str != base.as_ref() {
let exit_code = match &result {
ShellRunResult::Completed { status, .. }
| ShellRunResult::ExitedWithLeftovers { status, .. } => status.code(),
_ => None,
};
tracing::debug!(
command = command_str,
?exit_code,
"grep engine: served exit"
);
}
let applied = exec_str != base.as_ref();
let served = applied && !sentinel_rerun;
let reason = if sentinel_rerun {
"engine sentinel re-run (real grep)"
} else if !applied && grep_serve.rewritten.is_some() {
GUARD_REJECTED_REASON
} else if applied {
grep_serve
.outcomes
.iter()
.find(|o| !o.served)
.map(|o| o.reason.as_str())
.unwrap_or_default()
} else {
grep_serve
.outcomes
.iter()
.find(|o| !o.reason.is_empty())
.map_or("no rewrite produced", |o| o.reason.as_str())
};
self.write_grep_telemetry(ws, command_str, &grep_serve, served, reason, Some(&result))
.await;
let leftover_scope = match &result {
ShellRunResult::ExitedWithLeftovers { scope, .. } => Some(*scope),
_ => None,
};
match result {
ShellRunResult::Completed {
stdout,
stderr,
status,
elapsed,
}
| ShellRunResult::ExitedWithLeftovers {
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 = format_exit_status_note(exit_code);
let processed = process_shell_output(
command_str,
&stdout,
&stderr,
exit_code.unwrap_or(-1),
elapsed,
);
let combined = if exit_code == Some(0) {
processed
} else {
with_note(&processed, &exit_note)
};
let leftover_note = match leftover_scope {
Some(scope) => {
let scope_text = scope.map_or_else(String::new, |pid| pid.to_string());
let workspace = ws.as_path().to_string_lossy().into_owned();
crate::logs::record_stray_event(crate::logs::StrayEvent {
source: crate::logs::STRAY_SOURCE_SHELL,
message: "the command exited while a process it left running still held \
its output channel, so no more of that output could be \
collected",
recipe: &leftover_stop_recipe(SHELL_PLATFORM, scope),
scope: &scope_text,
session: "",
workspace: &workspace,
duration_ms: Some(crate::util::millis_i64(elapsed)),
})
.await;
Some(format_leftover_note(self.mode, SHELL_PLATFORM, scope))
}
None => None,
};
let combined = if let Some(note) = leftover_note {
with_note(&combined, ¬e)
} else {
combined
};
Ok((combined, exit_code))
}
ShellRunResult::EndedWithLeftovers { elapsed, .. } => anyhow::bail!(
"Shell command exited but a leftover process held its output pipes open \
past the drain limit; the run's tree was ended (elapsed: {:.1}s)",
elapsed.as_secs_f64()
),
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::MemoryExceeded {
stdout,
stderr,
pid,
elapsed,
used,
limit,
} => {
tracing::info!(
command = command_str,
elapsed_secs = elapsed.as_secs_f64(),
?pid,
used = used,
limit = limit,
stdout_bytes = stdout.len(),
stderr_bytes = stderr.len(),
"Shell command terminated: exceeded memory limit"
);
let msg =
format_memory_error(command_str, elapsed, used, limit, pid, &stdout, &stderr);
anyhow::bail!("{msg}");
}
ShellRunResult::SpawnFailed(e) => anyhow::bail!(
"Failed to start shell command.\n\
command: {command_str}\n\
reason: {e}"
),
}
}
async fn write_grep_telemetry(
&self,
ws: &Workspace,
command: &str,
grep_serve: &grep_engine::GrepServe,
served: bool,
reason: &str,
result: Option<&ShellRunResult>,
) {
if grep_serve.outcomes.is_empty() && grep_serve.refusal.is_none() {
return;
}
let shape = grep_serve.telemetry_shape(served);
let mode = match self.mode {
ShellMode::ReadOnly => "ReadOnly",
ShellMode::Full => "Full",
};
let workspace = ws.as_path().to_string_lossy().into_owned();
let (duration_ms, exit_code) = match result {
Some(
ShellRunResult::Completed {
elapsed, status, ..
}
| ShellRunResult::ExitedWithLeftovers {
elapsed, status, ..
},
) => (Some(crate::util::millis_i64(*elapsed)), status.code()),
Some(
ShellRunResult::TimedOut { elapsed, .. }
| ShellRunResult::MemoryExceeded { elapsed, .. }
| ShellRunResult::EndedWithLeftovers { elapsed, .. },
) => (Some(crate::util::millis_i64(*elapsed)), None),
Some(ShellRunResult::SpawnFailed(_)) => (Some(0), None),
None => (None, None),
};
if let Some(store) = crate::logs::LOG_STORE.get() {
let row = crate::logs::GrepTelemetryRow {
command,
served,
reason,
recursive: shape.recursive,
piped: shape.piped,
operand_count: shape.operand_count,
flags: shape.flags.as_str(),
mode,
workspace: workspace.as_str(),
grep_count: shape.grep_count,
served_count: shape.served_count,
skipped_count: shape.skipped_count,
duration_ms,
exit_code,
};
let _ = store.record_grep_telemetry(row).await;
}
}
}
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"));
}
if let Some(dir) = crate::util::managed_bin::mahbot_install_dir() {
v.push(dir);
}
#[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"));
}
if let Some(dir) = crate::util::managed_bin::bun_bin_dir() {
v.push(dir);
}
v
}
#[cfg(unix)]
const fn default_search_path_without_parent_env() -> &'static str {
"/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
}
#[cfg(windows)]
#[must_use]
fn windows_system_root() -> &'static str {
static ROOT: OnceLock<String> = OnceLock::new();
ROOT.get_or_init(|| {
system_root_from_api()
.or_else(|| env_system_root("SystemRoot"))
.or_else(|| env_system_root("WINDIR"))
.unwrap_or_else(|| {
tracing::error!(
"Windows system root unavailable: GetWindowsDirectoryW failed and \
%SystemRoot%/%WINDIR% are unset"
);
String::new()
})
})
}
#[cfg(windows)]
#[must_use]
fn system_root_from_api() -> Option<String> {
use windows_sys::Win32::System::SystemInformation::GetWindowsDirectoryW;
let needed = unsafe { GetWindowsDirectoryW(std::ptr::null_mut(), 0) };
if needed == 0 {
return None;
}
let mut buf = vec![0u16; needed as usize];
let written = unsafe { GetWindowsDirectoryW(buf.as_mut_ptr(), needed) };
if written == 0 {
return None;
}
let root = String::from_utf16_lossy(&buf[..(written as usize).min(buf.len())]);
let root = root.trim_end_matches(['\\', '/', '\0']);
if root.is_empty() {
return None;
}
Some(root.to_string())
}
#[cfg(windows)]
#[must_use]
fn env_system_root(name: &str) -> Option<String> {
let value = std::env::var(name).ok()?;
let trimmed = value.trim_end_matches(['\\', '/']);
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
#[cfg(windows)]
fn default_search_path_without_parent_env() -> String {
let root = windows_system_root();
if root.is_empty() {
return String::new();
}
format!(r"{root}\System32;{root};{root}\System32\Wbem;{root}\System32\WindowsPowerShell\v1.0")
}
fn prepend_path_entries(base: impl AsRef<str>, extras: &[PathBuf]) -> String {
let base = base.as_ref();
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 {
prepend_path_entries(
default_search_path_without_parent_env(),
&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()),
_ => {
#[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" => {
let root = windows_system_root();
(!root.is_empty()).then(|| root.to_string())
}
"SYSTEMDRIVE" => inherited_env_value("SystemDrive").or_else(|| {
let root = windows_system_root();
(root.as_bytes().get(1) == Some(&b':')).then(|| root[..2].to_string())
}),
"COMSPEC" => inherited_env_value("ComSpec").or_else(|| {
let root = windows_system_root();
(!root.is_empty()).then(|| format!(r"{root}\System32\cmd.exe"))
}),
_ => None,
}
}
#[cfg(windows)]
#[must_use]
fn inherited_env_value(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|value| !value.is_empty())
}
fn render_readonly_banner() -> String {
let platform_checks = crate::prompt::load_prompt(match SHELL_PLATFORM {
ShellPlatform::Windows => "tool/shell_readonly_banner_windows.md",
ShellPlatform::Unix => "tool/shell_readonly_banner_unix.md",
})
.trim()
.to_owned();
crate::prompt::substitute(
&crate::prompt::load_prompt("tool/shell_readonly_banner.md"),
&[
("{{temp_root}}", &crate::temp::shell_tmpdir()),
("{{platform_checks}}", &platform_checks),
],
)
}
fn render_grep_notes() -> String {
let platform_notes = crate::prompt::load_prompt(match SHELL_PLATFORM {
ShellPlatform::Windows => "tool/shell_grep_notes_windows.md",
ShellPlatform::Unix => "tool/shell_grep_notes_unix.md",
})
.trim()
.to_owned();
crate::prompt::substitute(
&crate::prompt::load_prompt("tool/shell_grep_notes.md"),
&[("{{platform_notes}}", &platform_notes)],
)
}
fn render_command_line_notes(platform: ShellPlatform, mode: ShellMode) -> Option<String> {
if platform != ShellPlatform::Windows {
return None;
}
let escaped_target = crate::prompt::load_prompt(match mode {
ShellMode::Full => "tool/shell_command_lines_escaped_full.md",
ShellMode::ReadOnly => "tool/shell_command_lines_escaped_read_only.md",
})
.trim()
.to_owned();
let remedy = windows_line::remedy(mode);
let cap = windows_line::COMMAND_LINE_CAP.to_string();
let limit = windows_line::TEXT_UNIT_LIMIT.to_string();
let overhead = (windows_line::COMMAND_LINE_CAP - windows_line::TEXT_UNIT_LIMIT).to_string();
let mut notes = crate::prompt::substitute(
&crate::prompt::load_prompt("tool/shell_command_lines.md"),
&[
("{{escaped_target}}", &escaped_target),
("{{remedy}}", &remedy),
("{{cap}}", &cap),
("{{limit}}", &limit),
("{{overhead}}", &overhead),
],
);
if mode == ShellMode::ReadOnly {
notes = format!(
"{}\n\n{}",
notes.trim_end(),
crate::prompt::load_prompt("tool/shell_command_lines_read_only_aside.md").trim()
);
}
Some(notes)
}
fn render_full_mode_notes() -> String {
let stop = stop_semantics();
let trailing = crate::prompt::load_prompt(match SHELL_PLATFORM {
ShellPlatform::Windows => "tool/shell_full_trailing_windows.md",
ShellPlatform::Unix => "tool/shell_full_trailing_unix.md",
})
.trim()
.to_owned();
crate::prompt::substitute(
&crate::prompt::load_prompt("tool/shell_full.md"),
&[
("{{stop_semantics}}", &stop),
("{{trailing_separator}}", &trailing),
],
)
}
fn render_leftover_notes(mode: ShellMode) -> String {
let mode_sentence = leftover_mode_sentence(mode);
let skeleton = crate::prompt::load_prompt_sections("tool/shell_leftover.md")
.into_iter()
.next()
.expect("tool/shell_leftover.md carries the description skeleton as its first section");
crate::prompt::substitute(&skeleton, &[("{{mode_sentence}}", &mode_sentence)])
}
fn render_platform_notes(platform: ShellPlatform) -> Option<String> {
match platform {
ShellPlatform::Windows => Some(
crate::prompt::load_prompt("tool/shell_platform_windows.md")
.trim()
.to_owned(),
),
ShellPlatform::Unix => None,
}
}
fn stop_semantics() -> String {
crate::prompt::load_prompt(match SHELL_PLATFORM {
ShellPlatform::Windows => "tool/shell_full_stop_windows.md",
ShellPlatform::Unix => "tool/shell_full_stop_unix.md",
})
.trim()
.to_owned()
}
#[async_trait]
impl Tool for ShellTool {
fn name(&self) -> &'static str {
"shell"
}
fn description(&self) -> String {
let base = crate::prompt::load_prompt("tool/shell.md");
let mut sections: Vec<String> = match self.mode {
ShellMode::ReadOnly => vec![render_readonly_banner(), base],
ShellMode::Full => vec![base, render_full_mode_notes()],
};
sections.push(render_leftover_notes(self.mode));
if let Some(platform_notes) = render_platform_notes(SHELL_PLATFORM) {
sections.push(platform_notes);
}
sections.push(render_grep_notes());
if let Some(command_lines) = render_command_line_notes(SHELL_PLATFORM, self.mode) {
sections.push(command_lines);
}
sections
.iter()
.map(|s| s.trim_end())
.collect::<Vec<_>>()
.join("\n\n")
}
fn parameters_schema(&self) -> serde_json::Value {
let timeout_secs = json!({
"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
});
match self.mode {
ShellMode::ReadOnly => super::tool_params_schema(
&json!({
"command": {
"type": "string",
"description": "The shell command to execute"
},
"timeout_secs": timeout_secs,
}),
&["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": timeout_secs,
"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": format!(
"Output-file path of a background session (as returned by a \
background launch) to stop. {} 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.",
stop_semantics()
)
},
}),
&[],
),
}
}
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)
}
}
pub(crate) 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)
}
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()));
pub(crate) fn record_spill_owner(path: std::path::PathBuf) {
let agent = crate::agent::CURRENT_TOOL_AGENT_ID
.try_with(Clone::clone)
.unwrap_or(None);
if let Some(ref agent_id) = agent {
debug_assert_ne!(
agent_id,
crate::agent::role::DIAGNOSTICS_ROLE,
"agent id must not collide with diagnostics spill owner"
);
}
let key = agent.unwrap_or_else(|| crate::agent::role::DIAGNOSTICS_ROLE.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 _ = if p.is_dir() {
std::fs::remove_dir_all(&p)
} else {
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)]
enum EmptySegPolicy {
Skip,
Error,
}
const fn is_escape_sensitive(c: char) -> bool {
matches!(
c,
'\\' | '\'' | '"' | '>' | '<' | '&' | '|' | ';' | '$' | '`'
)
}
fn at_word_start(segment: &str) -> bool {
match segment.chars().next_back() {
None => true,
Some(c) if !c.is_whitespace() => false,
Some(_) => segment
.chars()
.rev()
.skip(1)
.take_while(|c| *c == '\\')
.count()
.is_multiple_of(2),
}
}
fn segment_command_word(segment: &str, keyword: &str) -> bool {
let mut words = segment.split_whitespace();
match words.next() {
Some(w) if w == keyword => true,
Some("do" | "then" | "else" | "elif" | "if") => words.next() == Some(keyword),
_ => 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_command_word(t, "case") || (*in_case && !segment_command_word(t, "esac"));
}
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 mode == SegmentMode::Grep => {
current.push(c);
if chars.peek() == Some(&'&') {
current.push(chars.next().expect("peeked `&`"));
}
continue;
}
'&' if mode == SegmentMode::Grep && chars.peek() != Some(&'>') => {
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;
}
'#' if mode == SegmentMode::Grep && at_word_start(¤t) => {
let _ = chars.find(|c| *c == '\n');
if !flush(
&mut current,
&mut out,
"\n",
EmptySegPolicy::Skip,
&mut in_case,
) {
return None;
}
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)?;
Some((idx, command_word_basename(words[idx]), words))
}
pub(super) fn command_word_basename(word: &str) -> &str {
#[cfg(unix)]
let word = match readonly::classify_verb_word(word) {
readonly::VerbClass::Literal(content) => content,
readonly::VerbClass::Unprovable => word,
};
word.rsplit('/')
.next()
.expect("rsplit always yields at least one element")
}
pub(super) fn first_command_word(segment: &str) -> &str {
let Some((_, cmd, _)) = command_word_from_segment(segment) else {
return "";
};
cmd
}
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));
let with_stderr_section = |body: &str| {
if stdout.is_empty() {
format!("stderr:\n{body}")
} else {
format!("{stdout}\nstderr:\n{body}")
}
};
match (exit_code == 0, filtered) {
(_, Some(relevant)) => with_stderr_section(&relevant),
(false, None) if keep_stderr.is_none() => with_stderr_section(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<ListingEntry> {
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 is_dir = permissions.starts_with('d');
parts.next(); parts.next(); parts.next(); let size = parts.next()?;
let size = size
.parse::<u64>()
.ok()
.map_or_else(|| size.to_string(), human_readable_size);
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(ListingEntry {
name,
is_dir,
size: if is_dir { None } else { Some(size) },
})
}
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 entries: Vec<ListingEntry> = Vec::new();
let mut lines_seen = 0usize;
for line in output.lines() {
if line.starts_with("total ") || line.trim().is_empty() {
continue;
}
lines_seen += 1;
if let Some(entry) = parse_ls_line(line) {
entries.push(entry);
}
}
if lines_seen == 0 {
return output.to_string();
}
format_listing(&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 {
strip_ansi_escapes(&windows_text::decode(data))
}
pub(crate) fn decode_session_output(path: &Path, bytes: &[u8]) -> Option<String> {
bg::is_output_file(path).then(|| windows_text::decode(bytes))
}
pub(super) fn format_exit_status_note(exit_code: Option<i32>) -> String {
match exit_code {
Some(c) => format!("[exit status: {c}]"),
None => "[exit status: terminated by signal]".to_string(),
}
}
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)
}
pub(crate) 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;
#[cfg(unix)]
use crate::util::test::env_lock;
use crate::util::test::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);
}
#[test]
fn mahbot_chrome_envelope_line_bypasses_the_line_cap() {
let envelope = format!(
r#"{{"schema":1,"action":"open","ok":true,"kind":"ok","content":"{}","url":"https://example.com/"}}"#,
"x".repeat(600)
);
let result = process_shell_output(
"mahbot chrome open https://example.com",
&envelope,
"",
0,
Duration::from_secs_f64(1.0),
);
assert_contains_not_contains(
"mahbot chrome envelope",
&result,
&[r#""content":""#],
&["more chars on this line"],
);
assert!(
result.contains(&envelope),
"envelope line must survive intact"
);
}
#[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 fallback_env_vars_carry_the_essentials_and_no_credentials() {
for var in FALLBACK_ENV_VARS {
let lower = var.to_lowercase();
assert!(
!lower.contains("key") && !lower.contains("secret") && !lower.contains("token")
);
}
assert!(FALLBACK_ENV_VARS.contains(&"PATH"));
assert!(FALLBACK_ENV_VARS.contains(&"HOME") || FALLBACK_ENV_VARS.contains(&"USERPROFILE"));
assert!(FALLBACK_ENV_VARS.contains(&"TERM"));
}
#[cfg(unix)]
async fn shell_env_vars(workspace: &Path) -> std::collections::HashMap<String, String> {
let mut cmd = {
let _guard = env_lock().lock().unwrap_poison();
build_shell_command("env", workspace)
};
let output = cmd.output().await.expect("env should run");
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.split_once('='))
.map(|(name, value)| (name.to_string(), value.to_string()))
.collect()
}
#[cfg(unix)]
#[serial_test::serial(shell_env)]
#[tokio::test]
async fn build_shell_command_uses_the_owner_environment_once_read() {
crate::shell_env::set_snapshot(Some(crate::shell_env::OwnerEnv::new(vec![
(
OsString::from("OWNER_ONLY"),
OsString::from("from-the-snapshot"),
),
(
OsString::from("AWS_SECRET_ACCESS_KEY"),
OsString::from("owner-secret-value"),
),
(
OsString::from("PATH"),
OsString::from("/usr/local/bin:/usr/bin:/bin"),
),
])));
let tmp = TempDir::new().expect("tempdir");
let vars = shell_env_vars(tmp.path()).await;
crate::shell_env::set_snapshot(None);
assert_eq!(
vars.get("OWNER_ONLY").map(String::as_str),
Some("from-the-snapshot"),
"a snapshot variable must reach the child: {vars:?}"
);
assert_eq!(
vars.get("AWS_SECRET_ACCESS_KEY").map(String::as_str),
Some("owner-secret-value"),
"the owner's own environment is unfiltered: {vars:?}"
);
assert!(
!vars.contains_key("TERM"),
"the fallback must not be merged into the owner's environment: {vars:?}"
);
match std::env::var("HOME") {
Ok(home) => assert_eq!(vars.get("HOME"), Some(&home), "{vars:?}"),
Err(_) => assert!(vars.contains_key("HOME"), "{vars:?}"),
}
for (name, value) in crate::temp::shell_temp_vars() {
assert_eq!(
vars.get(&name).map(String::as_str),
Some(value.as_str()),
"{name} must stay the private temp root: {vars:?}"
);
}
}
#[cfg(unix)]
#[serial_test::serial(shell_env)]
#[tokio::test]
async fn build_shell_command_falls_back_until_the_owner_environment_is_read() {
crate::shell_env::set_snapshot(None);
let tmp = TempDir::new().expect("tempdir");
let vars = shell_env_vars(tmp.path()).await;
assert!(
vars.contains_key("HOME"),
"HOME must be in the fallback env"
);
assert!(
vars.contains_key("PATH"),
"PATH must be in the fallback env"
);
assert!(
!vars.contains_key("CARGO_HOME"),
"CARGO_HOME must not leak into the child env: {vars:?}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn run_program_with_timeout_passes_argv_and_annotates_exit() {
let tmp = TempDir::new().expect("tempdir");
let ws = crate::workspace::test_ws(tmp.path());
let output = run_program_with_timeout(
ws.as_path(),
Path::new("/bin/sh"),
&[
"-c".to_string(),
"printf '%s' \"$1\"; exit 3".to_string(),
"sh".to_string(),
"a; echo pwned".to_string(),
],
"the test program",
)
.await
.expect("a completed run");
assert_eq!(
output.lines().collect::<Vec<_>>(),
["a; echo pwned", "", "[exit status: 3]"]
);
}
#[cfg(unix)]
#[tokio::test]
async fn run_program_outcome_reports_output_and_status_without_annotations() {
let tmp = TempDir::new().expect("tempdir");
let ws = crate::workspace::test_ws(tmp.path());
let out = run_program_outcome(
ws.as_path(),
Path::new("/bin/sh"),
&sh_args("echo hello-prog-out"),
)
.await;
assert!(out.success);
assert!(out.has_output);
assert!(out.output.contains("hello-prog-out"), "got: {}", out.output);
assert!(!out.output.contains("[exit status"), "got: {}", out.output);
assert_eq!(out.detail, "exit status 0");
let out = run_program_outcome(
ws.as_path(),
Path::new("/bin/sh"),
&sh_args("echo bad; exit 3"),
)
.await;
assert!(!out.success);
assert!(out.has_output);
assert!(out.output.contains("bad"), "got: {}", out.output);
assert!(!out.output.contains("[exit status"), "got: {}", out.output);
assert_eq!(out.detail, "exit status 3");
let out =
run_program_outcome(ws.as_path(), Path::new("/bin/sh"), &sh_args("printf ''")).await;
assert!(out.success);
assert!(!out.has_output);
assert!(out.output.is_empty());
let out =
run_program_outcome(ws.as_path(), Path::new("/bin/sh"), &sh_args("printf '\\n'")).await;
assert!(out.has_output);
let out = run_program_outcome(
ws.as_path(),
Path::new("/bin/sh"),
&sh_args("printf '\\033[32m\\033[0m'"),
)
.await;
assert!(out.has_output);
assert!(out.output.is_empty(), "got: {}", out.output);
let out =
run_program_outcome(ws.as_path(), Path::new("/bin/sh"), &sh_args("echo hi >&2")).await;
assert!(out.has_output);
assert!(out.output.contains("hi"), "got: {}", out.output);
let raw = concat!("API_KEY=", "abcd1234");
assert!(
scrub_credentials(raw).contains("*[REDACTED]"),
"the scrubber must rewrite this value, or the pin below is vacuous"
);
let out = run_program_outcome(
ws.as_path(),
Path::new("/bin/sh"),
&sh_args(&format!("echo {raw}")),
)
.await;
assert!(out.has_output);
assert_eq!(
out.output.trim(),
raw,
"the check's output must reach the alarm unredacted"
);
}
#[cfg(unix)]
fn sh_args(script: &str) -> Vec<String> {
vec!["-c".to_string(), script.to_string()]
}
#[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!(
bg::is_output_file(&path),
"the launch message must name the product's own output file: {path:?}"
);
assert!(sessions.contains(&path), "session must be registered");
let content = crate::tools::ReadTool::general()
.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"
);
}
#[test]
fn read_only_description_covers_grep_notes_without_background_capability() {
let description = ShellTool::new(ShellMode::ReadOnly).description();
assert!(
description.contains("## Grep notes"),
"read-only description must carry the grep-engine disclosure"
);
assert!(
!description.contains("Background mode"),
"read-only description must not advertise the full-only background capability"
);
}
#[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),
mem::default_limit(),
RunOwner::Agent,
LeftoverPolicy::Keep,
)
.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),
mem::default_limit(),
RunOwner::Agent,
LeftoverPolicy::Keep,
)
.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),
mem::default_limit(),
RunOwner::Agent,
LeftoverPolicy::Keep,
)
.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}"
);
}
#[cfg(unix)]
async fn wait_for_death(pid: i32) {
let deadline = std::time::Instant::now() + Duration::from_secs(2);
while std::time::Instant::now() < deadline {
if unsafe { libc::kill(pid, 0) } != 0 {
return;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
panic!("pid {pid} is still alive");
}
#[cfg(unix)]
fn run_with_backgrounded_sleep(dir: &TempDir) -> (std::path::PathBuf, tokio::process::Command) {
let pid_path = dir.path().join("grandchild.pid");
let cmd_str = format!(
"sleep 999 & echo $! > {}; wait",
pid_path.to_str().expect("valid utf-8 path")
);
(pid_path, build_shell_command(&cmd_str, dir.path()))
}
#[cfg(unix)]
fn stopped_grandchild(pid_path: &std::path::Path) -> i32 {
std::fs::read_to_string(pid_path)
.expect("grandchild PID file must exist — grandchild was launched")
.trim()
.parse()
.expect("valid PID from file")
}
#[cfg(unix)]
#[tokio::test]
async fn a_run_over_the_memory_ceiling_is_killed_and_reported() {
const CEILING: u64 = 16 * 1024 * 1024;
const PAYLOAD: usize = 64 * 1024 * 1024;
let dir = TempDir::new().expect("tempdir");
let mut cmd = build_shell_command(
&format!("(x=$(yes x | head -c {PAYLOAD}); sleep 30) & wait"),
dir.path(),
);
let result = run_command_with_timeout(
&mut cmd,
Duration::from_secs(30),
Duration::from_secs(5),
Some(CEILING),
RunOwner::Agent,
LeftoverPolicy::Keep,
)
.await;
let ShellRunResult::MemoryExceeded {
used,
limit,
elapsed,
..
} = result
else {
panic!("expected MemoryExceeded, got {result:?}");
};
assert_eq!(limit, CEILING);
assert!(
used > limit,
"the sample that tripped the watchdog must exceed the ceiling: {used} > {limit}"
);
assert!(
elapsed < Duration::from_secs(20),
"the watchdog must not wait out the command: {elapsed:?}"
);
let msg = format_memory_error("test", elapsed, used, limit, None, &[], &[]);
assert!(
msg.starts_with("Shell command terminated: exceeded memory limit (used ~"),
"msg: {msg}"
);
assert!(msg.contains("\ncommand: test"), "msg: {msg}");
assert!(msg.contains("\nelapsed: "), "msg: {msg}");
assert!(msg.contains("\nmemory_limit: "), "msg: {msg}");
assert!(
msg.contains("\nreason: command was killed after exceeding the memory limit"),
"msg: {msg}"
);
assert!(!msg.contains("timed out"), "msg: {msg}");
}
#[cfg(unix)]
#[tokio::test]
async fn timeout_kills_the_command_process_tree() {
let dir = TempDir::new().expect("tempdir");
let (pid_path, mut cmd) = run_with_backgrounded_sleep(&dir);
let result = run_command_with_timeout(
&mut cmd,
Duration::from_secs(2),
Duration::from_secs(5),
mem::default_limit(),
RunOwner::Agent,
LeftoverPolicy::Keep,
)
.await;
assert!(
matches!(result, ShellRunResult::TimedOut { .. }),
"expected TimedOut, got {result:?}"
);
wait_for_death(stopped_grandchild(&pid_path)).await;
}
#[cfg(unix)]
#[tokio::test]
async fn dropping_the_run_kills_the_command_process_tree() {
let dir = TempDir::new().expect("tempdir");
let (pid_path, mut cmd) = run_with_backgrounded_sleep(&dir);
let dropped = tokio::time::timeout(
Duration::from_secs(2),
run_command_with_timeout(
&mut cmd,
Duration::from_secs(30),
Duration::from_secs(5),
mem::default_limit(),
RunOwner::Agent,
LeftoverPolicy::Keep,
),
)
.await;
assert!(
dropped.is_err(),
"the run must still be live when it is dropped"
);
wait_for_death(stopped_grandchild(&pid_path)).await;
}
#[cfg(unix)]
#[tokio::test]
async fn output_drain_leaves_the_leftover_running_and_its_channel_open() {
let dir = TempDir::new().expect("tempdir");
let pid_path = dir.path().join("bg.pid");
let marker = dir.path().join("after.txt");
let pid_path_str = pid_path.to_str().expect("valid utf-8 path");
let marker_str = marker.to_str().expect("valid utf-8 path");
let cmd_str = format!(
"echo before-drain; (sleep 1.5; echo after-run; echo done > {marker_str}) & \
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(150),
mem::default_limit(),
RunOwner::Agent,
LeftoverPolicy::Keep,
)
.await;
let ShellRunResult::ExitedWithLeftovers {
stdout,
status,
scope,
elapsed,
..
} = result
else {
panic!("expected ExitedWithLeftovers, got {result:?}");
};
let out = String::from_utf8_lossy(&stdout);
assert!(out.contains("before-drain"), "partial stdout: {out}");
assert_eq!(status.code(), Some(0), "the command's own status");
assert!(scope.is_some(), "the containment root is named");
assert!(
elapsed < Duration::from_secs(5),
"the run must return within the bound: {elapsed:?}"
);
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");
assert_eq!(
unsafe { libc::kill(pid, 0) },
0,
"the leftover must be left running"
);
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while !marker.exists() && std::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(25)).await;
}
assert!(
marker.exists(),
"the leftover's later write must reach the still-open channel"
);
}
#[test]
fn leftover_note_names_the_scope_and_the_platforms_stop_command() {
let full_sentence = leftover_mode_sentence(ShellMode::Full);
let read_only_sentence = leftover_mode_sentence(ShellMode::ReadOnly);
let unix = format_leftover_note(ShellMode::Full, ShellPlatform::Unix, Some(1234));
assert!(unix.contains("leftover process group: 1234"), "{unix}");
assert!(unix.contains("kill -TERM -1234"), "{unix}");
assert!(unix.contains(&full_sentence), "{unix}");
assert!(!unix.contains(&read_only_sentence), "{unix}");
assert!(!unix.contains("drain"), "{unix}");
assert!(!unix.contains("timed out"), "{unix}");
let windows = format_leftover_note(ShellMode::Full, ShellPlatform::Windows, Some(42));
assert!(
windows.contains("leftover process tree: started by pid 42, which has already exited"),
"{windows}"
);
assert!(
windows.contains(r#"tasklist /FI "PPID eq 42""#),
"{windows}"
);
assert!(
windows.contains("taskkill /PID <listed pid> /T /F"),
"{windows}"
);
assert!(!windows.contains("taskkill /PID 42"), "{windows}");
let readonly = format_leftover_note(ShellMode::ReadOnly, ShellPlatform::Unix, Some(7));
assert!(readonly.contains(&read_only_sentence), "{readonly}");
assert!(!readonly.contains(&full_sentence), "{readonly}");
assert!(!readonly.contains("background: true"), "{readonly}");
assert!(!readonly.contains("kill"), "{readonly}");
assert!(!readonly.contains("taskkill"), "{readonly}");
let readonly_windows =
format_leftover_note(ShellMode::ReadOnly, ShellPlatform::Windows, Some(42));
assert!(!readonly_windows.contains("taskkill"), "{readonly_windows}");
assert!(!readonly_windows.contains("tasklist"), "{readonly_windows}");
let unknown = format_leftover_note(ShellMode::Full, ShellPlatform::Unix, None);
assert!(unknown.contains("could not be identified"), "{unknown}");
assert!(!unknown.contains("kill -TERM"), "{unknown}");
}
#[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 0.2 &", dir.path());
let result = run_command_with_timeout(
&mut cmd,
Duration::from_secs(30),
Duration::from_secs(5),
mem::default_limit(),
RunOwner::Agent,
LeftoverPolicy::Keep,
)
.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_keeps_the_completed_side_of_a_leftover() {
let dir = TempDir::new().expect("tempdir");
let mut cmd = build_shell_command("echo out; sleep 1.5 >/dev/null &", dir.path());
let result = run_command_with_timeout(
&mut cmd,
Duration::from_secs(30),
Duration::from_millis(150),
mem::default_limit(),
RunOwner::Agent,
LeftoverPolicy::Keep,
)
.await;
let ShellRunResult::ExitedWithLeftovers { stdout, status, .. } = result else {
panic!("expected ExitedWithLeftovers, got {result:?}");
};
assert_eq!(status.code(), Some(0));
assert!(
String::from_utf8_lossy(&stdout).contains("out"),
"completed stdout side must be preserved: {stdout:?}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn the_end_policy_kills_the_leftover_holder() {
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-end; sleep 5 & 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(150),
mem::default_limit(),
RunOwner::Agent,
LeftoverPolicy::End,
)
.await;
let ShellRunResult::EndedWithLeftovers { stdout, .. } = result else {
panic!("expected EndedWithLeftovers, got {result:?}");
};
assert!(
String::from_utf8_lossy(&stdout).contains("before-end"),
"the output collected up to the bound is the failure's to report: {stdout:?}"
);
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");
wait_for_death(pid).await;
}
#[cfg(unix)]
#[tokio::test]
async fn a_command_that_exited_0_with_a_leftover_is_a_success() {
let _env = set_env_var("MAHBOT_SHELL_DRAIN_TIMEOUT_SECS", Some("1"));
let tmp = TempDir::new().expect("tempdir");
let ws = test_ws(tmp.path());
let (output, code) = ShellTool::new(ShellMode::Full)
.execute_with_status(&ws, json!({"command": "echo out; sleep 1.5 &"}))
.await
.expect("a completed command is not a tool error");
assert_eq!(code, Some(0), "the command's own status: {output}");
assert!(output.contains("out"), "output: {output}");
assert!(output.contains("[leftover process]"), "output: {output}");
assert!(output.contains("kill -TERM -"), "output: {output}");
assert!(
!output.contains("drain"),
"never the drain error on a finished command: {output}"
);
}
#[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"
);
}
#[test]
fn resolved_shell_path_covers_tool_dirs() {
#[cfg(unix)]
{
let path = resolved_shell_path();
assert!(
path.contains(".npm-global/bin"),
"PATH should include ~/.npm-global/bin for globally installed npm tools: {path}"
);
assert!(
path.contains(".cargo/bin"),
"PATH should include ~/.cargo/bin: {path}"
);
assert!(
path.contains(".local/bin"),
"PATH should include the per-user programs directory ~/.local/bin, where the product installs itself: {path}"
);
{
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}"
);
}
if let Some(dirs) = UserDirs::new() {
let default_cargo_home = dirs
.home_dir()
.join(".cargo")
.to_string_lossy()
.into_owned();
let _guard = set_env_var("CARGO_HOME", Some(&default_cargo_home));
let path = resolved_shell_path();
let count = path
.split(':')
.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: {path}"
);
}
}
#[cfg(target_os = "macos")]
{
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:?})",
);
}
}
#[cfg(unix)]
#[test]
fn quoted_path_spelling_selects_the_same_profile_as_its_unquoted_twin() {
let selected = |command: &str| {
let segments = extract_command_segments(command);
select_profile(&segments, false)
.match_command
.as_str()
.to_owned()
};
let quoted = selected("'/usr/local/bin/cargo' build");
assert_eq!(quoted, selected("/usr/local/bin/cargo build"));
assert_ne!(quoted, selected("'/usr/local/bin/true' build"));
}
#[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]"),
},
]);
}
#[test]
fn platform_prompt_assets_are_embedded() {
for (skeleton, keys, fragments) in [
(
"tool/shell_readonly_banner.md",
["{{temp_root}}", "{{platform_checks}}"].as_slice(),
[
"tool/shell_readonly_banner_unix.md",
"tool/shell_readonly_banner_windows.md",
]
.as_slice(),
),
(
"tool/shell_grep_notes.md",
["{{platform_notes}}"].as_slice(),
[
"tool/shell_grep_notes_unix.md",
"tool/shell_grep_notes_windows.md",
]
.as_slice(),
),
(
"tool/shell_full.md",
["{{stop_semantics}}", "{{trailing_separator}}"].as_slice(),
[
"tool/shell_full_stop_unix.md",
"tool/shell_full_stop_windows.md",
"tool/shell_full_trailing_unix.md",
"tool/shell_full_trailing_windows.md",
]
.as_slice(),
),
(
"tool/shell_command_lines.md",
[
"{{escaped_target}}",
"{{remedy}}",
"{{cap}}",
"{{overhead}}",
"{{limit}}",
]
.as_slice(),
[
"tool/shell_command_lines_escaped_full.md",
"tool/shell_command_lines_escaped_read_only.md",
"tool/shell_command_lines_read_only_aside.md",
"tool/shell_command_lines_remedy_full.md",
"tool/shell_command_lines_remedy_read_only.md",
]
.as_slice(),
),
(
"tool/shell_leftover.md",
["{{mode_sentence}}"].as_slice(),
[
"tool/shell_leftover_full.md",
"tool/shell_leftover_read_only.md",
]
.as_slice(),
),
] {
let shared = crate::prompt::load_prompt(skeleton);
let mut rest = shared.clone();
for key in keys {
assert!(shared.contains(key), "{skeleton} lost {key}");
rest = rest.replace(key, "");
}
assert!(!rest.contains("{{"), "{skeleton} carries an unrendered key");
for asset in fragments {
let text = crate::prompt::load_prompt(asset);
assert!(!text.contains("{{"), "{asset} carries a placeholder");
}
}
let leftover = crate::prompt::load_prompt_sections("tool/shell_leftover.md");
assert_eq!(leftover.len(), 2, "tool/shell_leftover.md lost a section");
assert!(leftover[0].contains("{{mode_sentence}}"), "{leftover:?}");
assert!(
!leftover[1].contains("{{"),
"the note lead carries an unrendered key: {}",
leftover[1]
);
}
fn command_line_notes(mode: ShellMode) -> String {
render_command_line_notes(ShellPlatform::Windows, mode)
.expect("the platform whose interpreter ends a command at a break has notes")
}
fn has_blank_paragraph(text: &str) -> bool {
text.split("\n\n").any(|para| para.trim().is_empty())
}
#[test]
fn the_guidance_states_this_platforms_own_rules() {
assert!(render_platform_notes(ShellPlatform::Unix).is_none());
let guidance = render_platform_notes(ShellPlatform::Windows)
.expect("the platform with rules of its own has a section");
for sentence in [
"no POSIX tool set",
"`;` is not a command separator",
"`#` is not a comment",
"auto-run scripts",
"console encoding",
] {
assert!(guidance.contains(sentence), "{sentence}: {guidance}");
}
assert!(!guidance.contains("{{"), "{guidance}");
}
#[test]
fn the_platform_notes_reach_both_modes() {
let this = render_platform_notes(SHELL_PLATFORM);
let other = render_platform_notes(match SHELL_PLATFORM {
ShellPlatform::Unix => ShellPlatform::Windows,
ShellPlatform::Windows => ShellPlatform::Unix,
});
for mode in [ShellMode::Full, ShellMode::ReadOnly] {
let description = ShellTool::new(mode).description();
assert!(!description.contains("{{"), "{description}");
if let Some(notes) = this.as_deref() {
assert!(
description.contains(notes),
"each mode's description must carry this platform's own rules"
);
}
if let Some(other) = other.as_deref() {
assert!(
!description.contains(other),
"no mode's description may carry the other platform's rules"
);
}
}
}
#[test]
fn the_guidance_states_the_same_command_line_limit() {
for mode in [ShellMode::Full, ShellMode::ReadOnly] {
let guidance = command_line_notes(mode);
for number in [
windows_line::COMMAND_LINE_CAP,
windows_line::TEXT_UNIT_LIMIT,
windows_line::COMMAND_LINE_CAP - windows_line::TEXT_UNIT_LIMIT,
] {
assert!(
guidance.contains(&number.to_string()),
"{number}: {guidance}"
);
}
assert!(!guidance.contains("{{"), "{guidance}");
}
}
#[test]
fn the_guidance_names_each_modes_own_fragments() {
let full = command_line_notes(ShellMode::Full);
let read_only = command_line_notes(ShellMode::ReadOnly);
let (full_escaped, read_only_escaped, aside) = (
crate::prompt::load_prompt("tool/shell_command_lines_escaped_full.md")
.trim()
.to_owned(),
crate::prompt::load_prompt("tool/shell_command_lines_escaped_read_only.md")
.trim()
.to_owned(),
crate::prompt::load_prompt("tool/shell_command_lines_read_only_aside.md")
.trim()
.to_owned(),
);
assert!(!full.contains(&read_only_escaped));
assert!(!full.contains(&aside));
assert!(!read_only.contains(&full_escaped));
for (guidance, fragment) in [
(&full, full_escaped.as_str()),
(&full, windows_line::remedy(ShellMode::Full).as_str()),
(&read_only, read_only_escaped.as_str()),
(
&read_only,
windows_line::remedy(ShellMode::ReadOnly).as_str(),
),
(&read_only, aside.as_str()),
] {
assert_eq!(guidance.matches(fragment).count(), 1, "{fragment}");
}
assert!(
read_only.lines().any(|line| line == aside),
"the aside is a paragraph of its own: {read_only}"
);
assert!(!has_blank_paragraph(&full), "{full:?}");
assert!(!has_blank_paragraph(&read_only), "{read_only:?}");
assert!(render_command_line_notes(ShellPlatform::Unix, ShellMode::Full).is_none());
}
#[test]
fn the_leftover_notes_name_each_modes_own_mechanism() {
let full = render_leftover_notes(ShellMode::Full);
let read_only = render_leftover_notes(ShellMode::ReadOnly);
let full_sentence = crate::prompt::load_prompt("tool/shell_leftover_full.md")
.trim()
.to_owned();
let read_only_sentence = crate::prompt::load_prompt("tool/shell_leftover_read_only.md")
.trim()
.to_owned();
assert_eq!(full.matches(&full_sentence).count(), 1, "{full}");
assert_eq!(
read_only.matches(&read_only_sentence).count(),
1,
"{read_only}"
);
assert!(!full.contains(&read_only_sentence), "{full}");
assert!(!read_only.contains(&full_sentence), "{read_only}");
for rendered in [&full, &read_only] {
assert!(!rendered.contains("{{"), "{rendered}");
for sentence in ["FINISHED", "RUNNING"] {
assert!(rendered.contains(sentence), "{sentence}: {rendered}");
}
}
for sentence in ["background: true", "`stop`"] {
assert!(full.contains(sentence), "{sentence}: {full}");
}
assert!(!read_only.contains("background: true"), "{read_only}");
for (mode, notes) in [
(ShellMode::Full, full.as_str()),
(ShellMode::ReadOnly, read_only.as_str()),
] {
let description = ShellTool::new(mode).description();
assert!(
description.contains(notes.trim()),
"each mode's description must carry its leftover notes"
);
}
}
fn platform_text(stem: &str) -> (String, String) {
let read = |platform: &str| {
crate::prompt::load_prompt(&format!("tool/shell_{stem}_{platform}.md"))
.trim()
.to_owned()
};
match SHELL_PLATFORM {
ShellPlatform::Unix => (read("unix"), read("windows")),
ShellPlatform::Windows => (read("windows"), read("unix")),
}
}
#[test]
fn platform_selected_texts_are_this_platforms_own() {
let full = ShellTool::new(ShellMode::Full);
let description = full.description();
assert!(!description.contains("{{"), "{description}");
for stem in ["full_stop", "full_trailing", "grep_notes"] {
let (this, other) = platform_text(stem);
assert!(
description.contains(&this),
"the description must carry this platform's {stem} text"
);
assert!(
!description.contains(&other),
"the description must not carry the other platform's {stem} text"
);
}
let (this, other) = platform_text("full_stop");
let schema = full.parameters_schema();
let stop = schema["properties"]["stop"]["description"]
.as_str()
.expect("the stop argument carries a description");
assert!(
stop.contains(&this),
"the stop schema must carry this platform's stop text"
);
assert!(
!stop.contains(&other),
"the stop schema must not promise the other platform's stop text"
);
}
#[test]
fn unserved_refusal_is_windows_only_and_covers_both_cases() {
let produced = grep_engine::GrepServe {
rewritten: Some("engine".into()),
outcomes: Vec::new(),
spec_files: Vec::new(),
plan: None,
refusal: None,
};
assert!(unserved_refusal(ShellPlatform::Unix, &produced, true).is_none());
assert!(unserved_refusal(ShellPlatform::Unix, &produced, false).is_none());
assert!(unserved_refusal(ShellPlatform::Windows, &produced, true).is_none());
let guard = unserved_refusal(ShellPlatform::Windows, &produced, false)
.expect("a rejected rewrite is refused on Windows");
assert!(guard.contains("read-only guard"), "{guard}");
let refused = grep_engine::GrepServe {
rewritten: None,
outcomes: Vec::new(),
spec_files: Vec::new(),
plan: None,
refusal: Some("nested grep".into()),
};
assert_eq!(
unserved_refusal(ShellPlatform::Windows, &refused, false).as_deref(),
Some("nested grep")
);
}
#[test]
fn engine_failure_covers_the_masked_windows_sentinel() {
let marker = grep_engine::ENGINE_REFUSAL_MARKER;
let reason = "grep: engine: working directory diverged from the analyzed command";
let masked = format!("{marker}\n{reason}\n");
let Some(EngineFailure::Refused(cause)) =
engine_failure(Some(0), masked.as_bytes(), ShellPlatform::Windows)
else {
panic!("the marker refuses the Windows call");
};
assert!(cause.contains("diverged"), "{cause}");
assert!(
!cause.contains(marker),
"the marker is not agent-facing: {cause}"
);
assert!(
engine_failure(Some(0), masked.as_bytes(), ShellPlatform::Unix).is_none(),
"unix re-runs the original command instead of refusing"
);
let echoed = format!("f.txt:{marker}\n");
assert!(
engine_failure(Some(0), echoed.as_bytes(), ShellPlatform::Windows).is_none(),
"a matched line carrying the token is not a refusal: {echoed:?}"
);
let noisy = format!("f.txt: a line mentioning {reason}\n{marker}\nengine detail\n");
assert!(
matches!(
engine_failure(Some(0), noisy.as_bytes(), ShellPlatform::Windows),
Some(EngineFailure::Refused(cause)) if cause == "engine detail"
),
"the marker's own detail line is the cause"
);
assert!(matches!(
engine_failure(
Some(grep_engine::ENGINE_FAILED_EXIT),
reason.as_bytes(),
ShellPlatform::Unix
),
Some(EngineFailure::ReRun)
));
let bare = engine_failure(
Some(0),
format!("{marker}\n").as_bytes(),
ShellPlatform::Windows,
);
let Some(EngineFailure::Refused(bare)) = bare else {
panic!("a marker alone is a refusal");
};
assert!(bare.contains("could not serve"), "{bare}");
assert!(matches!(
engine_failure(
Some(1),
grep_engine::STALE_BINARY_LOCK_MSG.as_bytes(),
ShellPlatform::Unix
),
Some(EngineFailure::ReRun)
));
assert!(matches!(
engine_failure(
Some(1),
grep_engine::STALE_BINARY_LOCK_MSG.as_bytes(),
ShellPlatform::Windows
),
Some(EngineFailure::Refused(cause)) if cause.contains(grep_engine::STALE_BINARY_LOCK_MSG)
));
let recorded = format!(
"start-up failure recorded in C:\\Users\\owner\\.mahbot\\error.log\n\
Error: {} (the instance lock is held)\n",
grep_engine::STALE_BINARY_LOCK_MSG
);
assert!(
matches!(
engine_failure(Some(1), recorded.as_bytes(), ShellPlatform::Windows),
Some(EngineFailure::Refused(cause)) if cause.contains(grep_engine::STALE_BINARY_LOCK_MSG)
),
"the lock line is the cause, not the pointer line before it: {recorded:?}"
);
for code in [0, 1] {
assert!(engine_failure(Some(code), b"", ShellPlatform::Windows).is_none());
}
}
#[cfg(target_os = "macos")]
mod program_command_tests {
use super::*;
use std::ffi::OsStr;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use tempfile::TempDir;
fn program(dir: &Path, name: &str) -> PathBuf {
let path = dir.join(name);
std::fs::copy("/bin/echo", &path).expect("copy a real program");
path
}
fn script(dir: &Path, name: &str) -> PathBuf {
let path = dir.join(name);
std::fs::write(&path, "#!/bin/sh\nprintf '%s' \"$0\"\n").expect("write fixture");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
.expect("make the fixture executable");
path
}
fn search_path(value: &str) -> Vec<(OsString, OsString)> {
vec![(OsString::from("PATH"), OsString::from(value))]
}
#[test]
fn a_bare_name_becomes_the_path_its_own_search_list_finds() {
let dir = TempDir::new().expect("tempdir");
let found = program(dir.path(), "prog");
let env = search_path(&dir.path().to_string_lossy());
let cmd = program_command("prog", &env, Some(dir.path()));
assert_eq!(cmd.get_program(), found.as_os_str());
}
#[test]
fn a_name_is_left_alone_where_a_resolved_path_would_change_what_runs() {
let dir = TempDir::new().expect("tempdir");
let env = search_path(&dir.path().to_string_lossy());
let unresolvable = dir.path().join("prog");
std::fs::write(&unresolvable, "").expect("write an empty fixture");
std::fs::set_permissions(&unresolvable, std::fs::Permissions::from_mode(0o755))
.expect("make the fixture executable");
assert_eq!(
program_command("prog", &env, Some(dir.path())).get_program(),
OsStr::new("prog"),
"an executable the kernel cannot start must keep the bare name"
);
std::fs::remove_file(&unresolvable).expect("clear the fixture");
script(dir.path(), "prog");
assert_eq!(
program_command("prog", &env, Some(dir.path())).get_program(),
OsStr::new("prog"),
"a script must keep the bare name"
);
std::fs::remove_file(dir.path().join("prog")).expect("clear the fixture");
let found = program(dir.path(), "prog");
assert_eq!(
program_command(&found.to_string_lossy(), &env, Some(dir.path())).get_program(),
found.as_os_str()
);
}
#[test]
fn an_empty_search_entry_resolves_against_the_commands_directory() {
let dir = TempDir::new().expect("tempdir");
let found = program(dir.path(), "prog");
let cmd = program_command("prog", &search_path(""), Some(dir.path()));
assert_eq!(cmd.get_program(), found.as_os_str());
}
#[test]
fn an_environment_without_a_path_falls_back_to_the_default_search_path() {
let cmd = program_command("true", &[], None);
assert_eq!(cmd.get_program(), OsStr::new("/usr/bin/true"));
}
#[test]
fn a_directory_and_a_non_executable_file_are_skipped_for_the_next_entry() {
let root = TempDir::new().expect("tempdir");
let as_dir = root.path().join("as-dir");
std::fs::create_dir_all(as_dir.join("prog")).expect("directory named like the program");
let no_exec = root.path().join("no-exec");
std::fs::create_dir(&no_exec).expect("tempdir");
std::fs::write(no_exec.join("prog"), "#!/bin/sh\n").expect("non-executable fixture");
let good = root.path().join("good");
std::fs::create_dir(&good).expect("tempdir");
let found = program(&good, "prog");
let search = std::env::join_paths([&as_dir, &no_exec, &good])
.expect("join the search list")
.to_string_lossy()
.into_owned();
let cmd = program_command("prog", &search_path(&search), Some(root.path()));
assert_eq!(cmd.get_program(), found.as_os_str());
}
#[test]
fn a_file_its_owner_may_not_execute_is_not_the_program() {
if unsafe { libc::geteuid() } == 0 {
return;
}
let root = TempDir::new().expect("tempdir");
let not_ours = root.path().join("not-ours");
std::fs::create_dir(¬_ours).expect("tempdir");
let foreign = program(¬_ours, "prog");
std::fs::set_permissions(&foreign, std::fs::Permissions::from_mode(0o001))
.expect("executable for someone else only");
let good = root.path().join("good");
std::fs::create_dir(&good).expect("tempdir");
let found = program(&good, "prog");
let search = std::env::join_paths([¬_ours, &good])
.expect("join the search list")
.to_string_lossy()
.into_owned();
let cmd = program_command("prog", &search_path(&search), Some(root.path()));
assert_eq!(cmd.get_program(), found.as_os_str());
}
#[test]
fn nothing_resolving_leaves_the_bare_name() {
let dir = TempDir::new().expect("tempdir");
let env = search_path(&dir.path().to_string_lossy());
let cmd = program_command("prog", &env, Some(dir.path()));
assert_eq!(cmd.get_program(), OsStr::new("prog"));
}
#[tokio::test]
async fn build_shell_command_names_sh_by_path_without_changing_its_argv0() {
let dir = TempDir::new().expect("tempdir");
let mut cmd = build_shell_command("printf '%s' \"$0\"", dir.path());
let program = cmd.as_std().get_program().to_owned();
assert!(
Path::new(&program).is_absolute(),
"the interpreter is still named bare: {program:?}"
);
let ran = cmd.output().await.expect("the interpreter runs");
assert_eq!(ran.stdout, b"sh");
}
}
}