use std::process::Stdio;
use std::sync::OnceLock;
use async_trait::async_trait;
use regex::Regex;
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use imp_llm::auth::{AuthStore, StoredCredential};
use super::{
truncate_head, truncate_tail, Tool, ToolContext, ToolOutput, ToolUpdate, TruncationResult,
};
use crate::error::{Error, Result};
const DEFAULT_TIMEOUT_SECS: u64 = 30;
const MAX_OUTPUT_LINES: usize = 2000;
const MAX_OUTPUT_BYTES: usize = 50 * 1024;
const SECRET_REDACTION: &str = "[REDACTED_SECRET]";
#[derive(Debug, Clone, PartialEq, Eq)]
struct SecretEnvBinding {
env: String,
provider: String,
field: String,
}
struct ResolvedSecretEnvBinding {
binding: SecretEnvBinding,
value: String,
}
fn parse_secret_env_bindings(value: Option<&Value>) -> Result<Vec<SecretEnvBinding>> {
let Some(value) = value else {
return Ok(Vec::new());
};
if value.is_null() {
return Ok(Vec::new());
}
let entries = value
.as_array()
.ok_or_else(|| Error::Tool("secret_env must be an array".into()))?;
let mut bindings = Vec::with_capacity(entries.len());
let mut env_names = std::collections::HashSet::new();
for entry in entries {
let object = entry
.as_object()
.ok_or_else(|| Error::Tool("secret_env entries must be objects".into()))?;
let env = required_secret_binding_string(object, "env")?;
let provider = required_secret_binding_string(object, "provider")?;
let field = required_secret_binding_string(object, "field")?;
if !is_valid_env_name(&env) {
return Err(Error::Tool(format!(
"secret_env env name '{env}' is invalid; use uppercase letters, digits, and underscores, not starting with a digit"
)));
}
if !env_names.insert(env.clone()) {
return Err(Error::Tool(format!(
"duplicate secret_env binding for env var {env}"
)));
}
bindings.push(SecretEnvBinding {
env,
provider,
field,
});
}
Ok(bindings)
}
fn required_secret_binding_string(
object: &serde_json::Map<String, Value>,
key: &str,
) -> Result<String> {
object
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.ok_or_else(|| Error::Tool(format!("secret_env entries require non-empty {key}")))
}
fn is_valid_env_name(value: &str) -> bool {
let mut chars = value.chars();
let Some(first) = chars.next() else {
return false;
};
(first == '_' || first.is_ascii_uppercase())
&& chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
}
fn binding_descriptor(binding: &SecretEnvBinding) -> String {
format!("{}<-{}.{}", binding.env, binding.provider, binding.field)
}
fn command_secret_binding_allowed(ctx: &ToolContext, binding: &SecretEnvBinding) -> bool {
let policy = &ctx.config.secrets.commands;
policy.enabled
&& policy.allowed.iter().any(|allowed| {
allowed.env == binding.env
&& allowed.provider == binding.provider
&& allowed.field == binding.field
})
}
fn resolve_secret_env_bindings(
ctx: &ToolContext,
bindings: Vec<SecretEnvBinding>,
) -> Result<Vec<ResolvedSecretEnvBinding>> {
if bindings.is_empty() {
return Ok(Vec::new());
}
for binding in &bindings {
if !command_secret_binding_allowed(ctx, binding) {
return Err(Error::Tool(format!(
"secret_env binding {} is not allowed by config policy",
binding_descriptor(binding)
)));
}
}
let auth_path = crate::storage::existing_global_auth_path()
.unwrap_or_else(crate::storage::global_auth_path);
let auth_store = AuthStore::load(&auth_path).unwrap_or_else(|_| AuthStore::new(auth_path));
bindings
.into_iter()
.map(|binding| {
let descriptor = binding_descriptor(&binding);
let has_auth_metadata = matches!(
auth_store.stored.get(&binding.provider),
Some(StoredCredential::SecretFields { fields })
if fields.iter().any(|field| field == &binding.field)
);
auth_store
.resolve_secret_field(&binding.provider, &binding.field)
.map(|value| ResolvedSecretEnvBinding { binding, value })
.map_err(|error| {
if has_auth_metadata {
Error::Tool(format!(
"missing keychain value for {descriptor}; auth metadata exists but secure storage read failed: {error}"
))
} else {
Error::Tool(format!("missing secret for {descriptor}: {error}"))
}
})
})
.collect()
}
fn redact_injected_secrets(text: &str, resolved: &[ResolvedSecretEnvBinding]) -> String {
resolved.iter().fold(text.to_string(), |redacted, binding| {
if binding.value.is_empty() {
redacted
} else {
redacted.replace(&binding.value, SECRET_REDACTION)
}
})
}
fn secret_binding_details(resolved: &[ResolvedSecretEnvBinding]) -> Vec<String> {
resolved
.iter()
.map(|binding| binding_descriptor(&binding.binding))
.collect()
}
#[cfg(feature = "rush-backend")]
fn use_rush_backend() -> bool {
match std::env::var("IMP_SHELL_BACKEND") {
Ok(val) => val.eq_ignore_ascii_case("rush"),
Err(_) => true,
}
}
#[cfg(feature = "rush-backend")]
fn run_via_rush(
command: &str,
timeout_secs: u64,
cwd: &std::path::Path,
json_output: bool,
) -> Option<(String, i32, bool, bool)> {
let result = rush::run(
command,
&rush::RunOptions {
cwd: Some(cwd.to_path_buf()),
timeout: Some(timeout_secs),
json_output,
max_output_bytes: Some(MAX_OUTPUT_BYTES),
..Default::default()
},
);
match result {
Ok(r) => {
let mut output = r.stdout;
if !r.stderr.is_empty() {
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
output.push_str(&r.stderr);
}
Some((output, r.exit_code, r.timed_out, r.truncated))
}
Err(_) => None,
}
}
fn detect_shell(config: &crate::config::ShellConfig) -> String {
if let Ok(shell) = std::env::var("IMP_SHELL") {
return shell;
}
if let Some(shell) = config
.command
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
return shell.to_string();
}
"bash".to_string()
}
fn sanitize_output_text(text: &str) -> String {
static ANSI_RE: OnceLock<Regex> = OnceLock::new();
let re =
ANSI_RE.get_or_init(|| Regex::new(r"\x1B\[[0-9;?]*[ -/]*[@-~]").expect("valid ansi regex"));
re.replace_all(text, "").replace('\r', "")
}
fn looks_like_search_command(command: &str) -> bool {
let trimmed = command.trim_start();
trimmed.starts_with("rg ")
|| trimmed == "rg"
|| trimmed.starts_with("grep ")
|| trimmed.starts_with("grep\n")
|| trimmed.starts_with("fd ")
|| trimmed == "fd"
|| trimmed.starts_with("find ")
|| trimmed == "find"
|| trimmed.starts_with("ls ")
|| trimmed == "ls"
}
fn no_match_exit_is_success(command: &str, exit_code: i32, output: &str) -> bool {
if exit_code != 1 || !output.trim().is_empty() {
return false;
}
let trimmed = command.trim_start();
trimmed.starts_with("rg ")
|| trimmed == "rg"
|| trimmed.starts_with("grep ")
|| trimmed.starts_with("grep\n")
}
fn command_failure_hint(command: &str, exit_code: i32, output: &str) -> Option<String> {
if no_match_exit_is_success(command, exit_code, output) {
return Some("No matches found.".to_string());
}
if exit_code == 127 {
return Some(
"Command not found. Check the executable name or use an installed alternative."
.to_string(),
);
}
None
}
#[cfg(feature = "rush-backend")]
fn should_try_rush_json(command: &str) -> bool {
if command.contains("|")
|| command.contains("&&")
|| command.contains("||")
|| command.contains(';')
|| command.contains('>')
|| command.contains('<')
{
return false;
}
looks_like_search_command(command)
}
#[cfg(feature = "rush-backend")]
fn parse_json_lines_to_text(command: &str, output: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(output).ok()?;
let items = value.as_array()?;
let mut lines = Vec::new();
let is_grep = command.trim_start().starts_with("grep");
let is_find = command.trim_start().starts_with("find");
let is_ls = command.trim_start().starts_with("ls");
for item in items {
if is_grep {
let file = item.get("file").and_then(|v| v.as_str()).unwrap_or("");
let line = item
.get("line_number")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let full_line = item
.get("full_line")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim_end_matches('\n');
if !file.is_empty() && line > 0 {
lines.push(format!("{file}:{line}:{full_line}"));
}
} else if is_find {
if let Some(path) = item.get("path").and_then(|v| v.as_str()) {
lines.push(path.to_string());
}
} else if is_ls {
if let Some(name) = item.get("name").and_then(|v| v.as_str()) {
let suffix = match item.get("type").and_then(|v| v.as_str()) {
Some("directory") => "/",
Some("symlink") => "@",
_ => "",
};
lines.push(format!("{name}{suffix}"));
}
}
}
if lines.is_empty() {
None
} else {
Some(lines.join("\n"))
}
}
fn truncate_command_output(command: &str, output: &str) -> TruncationResult {
if looks_like_search_command(command) {
truncate_head(output, MAX_OUTPUT_LINES, MAX_OUTPUT_BYTES)
} else {
truncate_tail(output, MAX_OUTPUT_LINES, MAX_OUTPUT_BYTES)
}
}
pub struct BashTool;
impl BashTool {
pub fn canonical() -> Self {
Self
}
}
#[async_trait]
impl Tool for BashTool {
fn name(&self) -> &str {
"shell"
}
fn label(&self) -> &str {
"Shell"
}
fn description(&self) -> &str {
"Run a shell command in the workspace or an optional workdir."
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"command": { "type": "string" },
"timeout": { "type": "number" },
"workdir": { "type": "string" },
"secret_env": {
"type": "array",
"description": "Metadata-only stored secret bindings to inject into the child environment. Raw secret values are resolved internally and redacted from output.",
"items": {
"type": "object",
"properties": {
"env": { "type": "string" },
"provider": { "type": "string" },
"field": { "type": "string" }
},
"required": ["env", "provider", "field"]
}
}
},
"required": ["command"]
})
}
fn is_readonly(&self) -> bool {
false
}
async fn execute(
&self,
_call_id: &str,
params: serde_json::Value,
ctx: ToolContext,
) -> Result<ToolOutput> {
let command = params["command"]
.as_str()
.ok_or_else(|| crate::error::Error::Tool("missing 'command' parameter".into()))?;
let timeout_secs = params["timeout"].as_u64().unwrap_or(DEFAULT_TIMEOUT_SECS);
let ctx = if let Some(workdir) = params["workdir"].as_str() {
let wd = super::resolve_path(&ctx.cwd, workdir);
if !wd.is_dir() {
return Ok(ToolOutput::error(format!(
"workdir not found or not a directory: {}",
wd.display()
)));
}
ToolContext { cwd: wd, ..ctx }
} else {
ctx
};
let secret_env = parse_secret_env_bindings(params.get("secret_env"))?;
run_command(command, timeout_secs, &ctx, secret_env).await
}
}
async fn run_command(
command: &str,
timeout_secs: u64,
ctx: &ToolContext,
secret_env: Vec<SecretEnvBinding>,
) -> Result<ToolOutput> {
if ctx.is_cancelled() {
return Ok(ToolOutput {
content: vec![imp_llm::ContentBlock::Text {
text: "[Command cancelled]".to_string(),
}],
details: json!({ "exit_code": -1, "timed_out": false, "cancelled": true, "truncated": false }),
is_error: true,
});
}
let resolved_secret_env = resolve_secret_env_bindings(ctx, secret_env)?;
#[cfg(feature = "rush-backend")]
if use_rush_backend() {
let rush_json = should_try_rush_json(command);
if let Some((output, exit_code, timed_out, truncated)) =
run_via_rush(command, timeout_secs, &ctx.cwd, rush_json)
{
let transformed = if rush_json {
parse_json_lines_to_text(command, &output).unwrap_or(output)
} else {
output
};
let sanitized = sanitize_output_text(&transformed);
let redacted = redact_injected_secrets(&sanitized, &resolved_secret_env);
for line in redacted.lines() {
let _ = ctx
.update_tx
.send(ToolUpdate {
content: vec![imp_llm::ContentBlock::Text {
text: line.to_string(),
}],
details: serde_json::Value::Null,
})
.await;
}
let mut result_text = redacted;
if let Some(hint) = command_failure_hint(command, exit_code, &result_text) {
if !result_text.is_empty() {
result_text.push('\n');
}
result_text.push_str(&format!("[{hint}]"));
}
if timed_out {
result_text.push_str(&format!("\n[Command timed out after {timeout_secs}s]"));
}
return Ok(ToolOutput {
content: vec![imp_llm::ContentBlock::Text { text: result_text }],
details: json!({
"exit_code": exit_code,
"timed_out": timed_out,
"cancelled": false,
"truncated": truncated,
"backend": "rush",
"injected_secrets": secret_binding_details(&resolved_secret_env),
}),
is_error: timed_out
|| (exit_code != 0
&& !no_match_exit_is_success(command, exit_code, &transformed)),
});
}
}
let mut child = {
let shell = detect_shell(&ctx.config.shell);
let mut cmd = Command::new(&shell);
cmd.arg("-c")
.arg(command)
.current_dir(&ctx.cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for binding in &resolved_secret_env {
cmd.env(&binding.binding.env, &binding.value);
}
#[cfg(unix)]
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
cmd.spawn()
.map_err(|e| crate::error::Error::Tool(format!("failed to spawn command: {e}")))?
};
let stdout = child.stdout.take().ok_or_else(|| {
crate::error::Error::Tool(
"failed to capture child stdout despite stdout being piped".to_string(),
)
})?;
let stderr = child.stderr.take().ok_or_else(|| {
crate::error::Error::Tool(
"failed to capture child stderr despite stderr being piped".to_string(),
)
})?;
let mut stdout_reader = BufReader::new(stdout).lines();
let mut stderr_reader = BufReader::new(stderr).lines();
let mut output = String::new();
let mut timed_out = false;
let mut stdout_done = false;
let mut stderr_done = false;
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
while !stdout_done || !stderr_done {
tokio::select! {
biased;
_ = tokio::time::sleep_until(deadline) => {
timed_out = true;
kill_process_group(&child).await;
break;
}
_ = wait_for_cancellation(&ctx.cancelled), if !ctx.is_cancelled() => {
kill_process_group(&child).await;
break;
}
line = stdout_reader.next_line(), if !stdout_done => {
match line {
Ok(Some(line)) => {
if !line.bytes().any(|b| b == 0) {
let clean = sanitize_output_text(&line);
let clean = redact_injected_secrets(&clean, &resolved_secret_env);
if !clean.is_empty() {
append_line(&mut output, &clean, &ctx.update_tx).await;
}
}
}
_ => { stdout_done = true; }
}
}
line = stderr_reader.next_line(), if !stderr_done => {
match line {
Ok(Some(line)) => {
if !line.bytes().any(|b| b == 0) {
let clean = sanitize_output_text(&line);
let clean = redact_injected_secrets(&clean, &resolved_secret_env);
if !clean.is_empty() {
append_line(&mut output, &clean, &ctx.update_tx).await;
}
}
}
_ => { stderr_done = true; }
}
}
}
}
let status = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait())
.await
.ok()
.and_then(|r| r.ok());
let exit_code = status.and_then(|s| s.code()).unwrap_or(-1);
let TruncationResult {
content: truncated_output,
truncated,
output_lines,
total_lines,
temp_file,
..
} = truncate_command_output(command, &output);
let mut result_text = truncated_output;
if truncated {
let note = if looks_like_search_command(command) {
format!(
"\n[Output truncated: showing first {output_lines} of {total_lines} lines{}]",
temp_file
.as_ref()
.map(|p| format!(". Full output saved to {}", p.display()))
.unwrap_or_default()
)
} else {
format!(
"\n[Output truncated: showing last {output_lines} of {total_lines} lines{}]",
temp_file
.as_ref()
.map(|p| format!(". Full output saved to {}", p.display()))
.unwrap_or_default()
)
};
result_text.push_str(¬e);
}
if timed_out {
result_text.push_str(&format!("\n[Command timed out after {timeout_secs}s]"));
}
if let Some(hint) = command_failure_hint(command, exit_code, &output) {
if !result_text.is_empty() {
result_text.push('\n');
}
result_text.push_str(&format!("[{hint}]"));
}
let cancelled = ctx.is_cancelled();
let details = json!({
"exit_code": exit_code,
"timed_out": timed_out,
"cancelled": cancelled,
"truncated": truncated,
"command": command,
"injected_secrets": secret_binding_details(&resolved_secret_env),
});
Ok(ToolOutput {
content: vec![imp_llm::ContentBlock::Text { text: result_text }],
details,
is_error: cancelled
|| timed_out
|| (exit_code != 0 && !no_match_exit_is_success(command, exit_code, &output)),
})
}
async fn wait_for_cancellation(cancelled: &std::sync::atomic::AtomicBool) {
while !cancelled.load(std::sync::atomic::Ordering::Relaxed) {
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
}
async fn append_line(
output: &mut String,
line: &str,
update_tx: &tokio::sync::mpsc::Sender<ToolUpdate>,
) {
if !output.is_empty() {
output.push('\n');
}
output.push_str(line);
let _ = update_tx
.send(ToolUpdate {
content: vec![imp_llm::ContentBlock::Text {
text: line.to_string(),
}],
details: serde_json::Value::Null,
})
.await;
}
#[cfg(unix)]
async fn kill_process_group(child: &tokio::process::Child) {
if let Some(pid) = child.id() {
let pgid = pid as i32;
unsafe {
libc::kill(-pgid, libc::SIGTERM);
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
unsafe {
libc::kill(-pgid, libc::SIGKILL);
}
}
}
#[cfg(not(unix))]
async fn kill_process_group(_child: &tokio::process::Child) {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{CommandSecretsConfig, Config, SecretEnvBindingPolicy, SecretsConfig};
use crate::ui::NullInterface;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
fn ensure_sh() {
std::env::set_var("IMP_SHELL", "sh");
}
fn test_ctx(dir: &std::path::Path) -> (ToolContext, tokio::sync::mpsc::Receiver<ToolUpdate>) {
ensure_sh();
let (tx, rx) = tokio::sync::mpsc::channel(1024);
let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel(16);
let ctx = ToolContext {
cwd: dir.to_path_buf(),
cancelled: Arc::new(AtomicBool::new(false)),
update_tx: tx,
command_tx: cmd_tx,
ui: Arc::new(NullInterface),
file_cache: Arc::new(crate::tools::FileCache::new()),
checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
file_tracker: Arc::new(std::sync::Mutex::new(crate::tools::FileTracker::new())),
anchor_store: Arc::new(crate::tools::AnchorStore::new()),
lua_tool_loader: None,
mode: crate::config::AgentMode::Full,
read_max_lines: 500,
turn_mana_review: Arc::new(std::sync::Mutex::new(
crate::mana_review::TurnManaReviewAccumulator::default(),
)),
config: Arc::new(crate::config::Config::default()),
};
(ctx, rx)
}
fn allow_test_secret(ctx: &mut ToolContext) {
ctx.config = Arc::new(Config {
secrets: SecretsConfig {
commands: CommandSecretsConfig {
enabled: true,
allowed: vec![SecretEnvBindingPolicy {
provider: "test-service".to_string(),
field: "api_key".to_string(),
env: "SECRET_TOKEN".to_string(),
}],
},
},
..Config::default()
});
}
#[tokio::test]
async fn secret_env_injects_allowed_secret_and_redacts_output() {
let tmp = tempfile::tempdir().unwrap();
std::env::set_var("TEST_SERVICE_API_KEY", "native-secret-value");
let (mut ctx, _rx) = test_ctx(tmp.path());
allow_test_secret(&mut ctx);
let result = run_command(
"printf '%s' \"$SECRET_TOKEN\"",
DEFAULT_TIMEOUT_SECS,
&ctx,
vec![SecretEnvBinding {
env: "SECRET_TOKEN".to_string(),
provider: "test-service".to_string(),
field: "api_key".to_string(),
}],
)
.await
.unwrap();
assert!(!result.is_error);
let text = result.text_content().unwrap();
assert!(text.contains(SECRET_REDACTION));
assert!(!text.contains("native-secret-value"));
assert_eq!(
result.details["injected_secrets"][0].as_str(),
Some("SECRET_TOKEN<-test-service.api_key")
);
assert!(!result.details.to_string().contains("native-secret-value"));
}
#[tokio::test]
async fn secret_env_denies_unconfigured_binding() {
let tmp = tempfile::tempdir().unwrap();
std::env::set_var("TEST_SERVICE_API_KEY", "native-secret-value");
let (ctx, _rx) = test_ctx(tmp.path());
let err = match run_command(
"true",
DEFAULT_TIMEOUT_SECS,
&ctx,
vec![SecretEnvBinding {
env: "SECRET_TOKEN".to_string(),
provider: "test-service".to_string(),
field: "api_key".to_string(),
}],
)
.await
{
Ok(_) => panic!("expected policy denial"),
Err(err) => err,
};
let message = err.to_string();
assert!(message.contains("not allowed by config policy"));
assert!(!message.contains("native-secret-value"));
}
#[tokio::test]
async fn secret_env_rejects_duplicate_env_bindings() {
let err = parse_secret_env_bindings(Some(&serde_json::json!([
{"env":"SECRET_TOKEN", "provider":"one", "field":"api_key"},
{"env":"SECRET_TOKEN", "provider":"two", "field":"api_key"}
])))
.unwrap_err();
assert!(err.to_string().contains("duplicate secret_env binding"));
}
#[tokio::test]
async fn secret_env_rejects_invalid_env_name() {
let err = parse_secret_env_bindings(Some(&serde_json::json!([
{"env":"secret-token", "provider":"one", "field":"api_key"}
])))
.unwrap_err();
assert!(err
.to_string()
.contains("env name 'secret-token' is invalid"));
}
#[tokio::test]
async fn bash_simple_command() {
let tmp = tempfile::tempdir().unwrap();
let (ctx, _rx) = test_ctx(tmp.path());
let result = run_command("echo hello world", DEFAULT_TIMEOUT_SECS, &ctx, Vec::new())
.await
.unwrap();
assert!(!result.is_error);
let text = match &result.content[0] {
imp_llm::ContentBlock::Text { text } => text.clone(),
_ => panic!("expected text"),
};
assert!(text.contains("hello world"));
assert_eq!(result.details["exit_code"], 0);
}
#[tokio::test]
async fn bash_exit_code() {
let tmp = tempfile::tempdir().unwrap();
let (ctx, _rx) = test_ctx(tmp.path());
let result = run_command("exit 42", DEFAULT_TIMEOUT_SECS, &ctx, Vec::new())
.await
.unwrap();
assert!(result.is_error);
assert_eq!(result.details["exit_code"], 42);
}
#[tokio::test]
async fn bash_timeout() {
let tmp = tempfile::tempdir().unwrap();
let (ctx, _rx) = test_ctx(tmp.path());
let result = run_command("sleep 60", 1, &ctx, Vec::new()).await.unwrap();
assert!(result.details["timed_out"].as_bool().unwrap());
let text = match &result.content[0] {
imp_llm::ContentBlock::Text { text } => text.clone(),
_ => panic!("expected text"),
};
assert!(text.contains("timed out"));
}
#[tokio::test]
async fn bash_cancellation() {
let tmp = tempfile::tempdir().unwrap();
let (ctx, _rx) = test_ctx(tmp.path());
ctx.cancelled
.store(true, std::sync::atomic::Ordering::Relaxed);
let result = run_command("sleep 60", DEFAULT_TIMEOUT_SECS, &ctx, Vec::new())
.await
.unwrap();
assert!(result.details["cancelled"].as_bool().unwrap());
let text = match &result.content[0] {
imp_llm::ContentBlock::Text { text } => text.clone(),
_ => panic!("expected text"),
};
assert!(text.contains("cancelled"));
}
#[tokio::test]
async fn bash_cancellation_during_execution() {
let tmp = tempfile::tempdir().unwrap();
let (ctx, _rx) = test_ctx(tmp.path());
let cancelled = Arc::clone(&ctx.cancelled);
let task = tokio::spawn(async move {
run_command("sleep 60", DEFAULT_TIMEOUT_SECS, &ctx, Vec::new()).await
});
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
let result = task.await.unwrap().unwrap();
assert!(result.details["cancelled"].as_bool().unwrap());
}
#[tokio::test]
async fn bash_streaming_output() {
let tmp = tempfile::tempdir().unwrap();
let (ctx, mut rx) = test_ctx(tmp.path());
let handle = tokio::spawn(async move {
run_command(
"echo line1; echo line2; echo line3",
DEFAULT_TIMEOUT_SECS,
&ctx,
Vec::new(),
)
.await
});
let mut updates = Vec::new();
while let Some(update) = rx.recv().await {
updates.push(update);
}
let result = handle.await.unwrap().unwrap();
assert!(!result.is_error);
assert!(
!updates.is_empty(),
"should have received streaming updates"
);
}
#[tokio::test]
async fn bash_stdout_and_stderr_merged() {
let tmp = tempfile::tempdir().unwrap();
let (ctx, _rx) = test_ctx(tmp.path());
let result = run_command(
"echo stdout_line; echo stderr_line >&2",
DEFAULT_TIMEOUT_SECS,
&ctx,
Vec::new(),
)
.await
.unwrap();
assert!(!result.is_error);
let text = match &result.content[0] {
imp_llm::ContentBlock::Text { text } => text.clone(),
_ => panic!("expected text"),
};
assert!(text.contains("stdout_line"));
assert!(text.contains("stderr_line"));
}
#[tokio::test]
async fn bash_writes_file_side_effect() {
let tmp = tempfile::tempdir().unwrap();
let (ctx, _rx) = test_ctx(tmp.path());
let result = run_command(
"echo 'side effect content' > side_effect.txt",
DEFAULT_TIMEOUT_SECS,
&ctx,
Vec::new(),
)
.await
.unwrap();
assert!(!result.is_error);
let written = std::fs::read_to_string(tmp.path().join("side_effect.txt")).unwrap();
assert!(written.contains("side effect content"));
}
#[tokio::test]
async fn bash_uses_cwd() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("testfile.txt"), "content").unwrap();
let (ctx, _rx) = test_ctx(tmp.path());
let result = run_command("ls testfile.txt", DEFAULT_TIMEOUT_SECS, &ctx, Vec::new())
.await
.unwrap();
assert!(!result.is_error);
let text = match &result.content[0] {
imp_llm::ContentBlock::Text { text } => text.clone(),
_ => panic!("expected text"),
};
assert!(text.contains("testfile.txt"));
}
#[tokio::test]
async fn bash_strips_ansi_sequences() {
let tmp = tempfile::tempdir().unwrap();
let (ctx, _rx) = test_ctx(tmp.path());
let result = run_command(
"printf '\\033[1;31mred\\033[0m\\n'",
DEFAULT_TIMEOUT_SECS,
&ctx,
Vec::new(),
)
.await
.unwrap();
assert!(!result.is_error);
let text = match &result.content[0] {
imp_llm::ContentBlock::Text { text } => text.clone(),
_ => panic!("expected text"),
};
assert!(text.contains("red"));
assert!(!text.contains("\u{1b}[1;31m"));
assert!(!text.contains("\u{1b}[0m"));
}
#[tokio::test]
async fn bash_workdir_override_executes_in_target_dir() {
let root = tempfile::tempdir().unwrap();
let subdir = root.path().join("subdir");
std::fs::create_dir(&subdir).unwrap();
std::fs::write(subdir.join("inside.txt"), "ok").unwrap();
let tool = BashTool;
let (ctx, _rx) = test_ctx(root.path());
let result = tool
.execute(
"c-workdir",
serde_json::json!({"command": "ls inside.txt", "workdir": "subdir"}),
ctx,
)
.await
.unwrap();
assert!(!result.is_error);
let text = match &result.content[0] {
imp_llm::ContentBlock::Text { text } => text.clone(),
_ => panic!("expected text"),
};
assert!(text.contains("inside.txt"));
}
#[tokio::test]
async fn bash_invalid_workdir_returns_error() {
let root = tempfile::tempdir().unwrap();
let tool = BashTool;
let (ctx, _rx) = test_ctx(root.path());
let result = tool
.execute(
"c-bad-workdir",
serde_json::json!({"command": "pwd", "workdir": "missing-dir"}),
ctx,
)
.await
.unwrap();
assert!(result.is_error);
let text = match &result.content[0] {
imp_llm::ContentBlock::Text { text } => text.clone(),
_ => panic!("expected text"),
};
assert!(text.contains("workdir not found"));
}
#[tokio::test]
async fn bash_treats_rg_no_matches_as_success() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("afile.txt"), "haystack\n").unwrap();
let (ctx, _rx) = test_ctx(tmp.path());
let result = run_command(
"rg definitely_not_present .",
DEFAULT_TIMEOUT_SECS,
&ctx,
Vec::new(),
)
.await
.unwrap();
assert!(!result.is_error);
assert_eq!(result.details["exit_code"], 1);
assert!(result.text_content().unwrap().contains("No matches found"));
}
#[tokio::test]
async fn bash_command_not_found_returns_actionable_hint() {
let tmp = tempfile::tempdir().unwrap();
let (ctx, _rx) = test_ctx(tmp.path());
let result = run_command(
"definitely_not_a_real_command_98765",
DEFAULT_TIMEOUT_SECS,
&ctx,
Vec::new(),
)
.await
.unwrap();
assert!(result.is_error);
assert_eq!(result.details["exit_code"], 127);
assert!(result.text_content().unwrap().contains("Command not found"));
}
#[test]
#[cfg(feature = "rush-backend")]
fn test_rush_backend_echo() {
let tmp = tempfile::tempdir().unwrap();
let (output, exit_code, timed_out, _truncated) =
run_via_rush("echo hello world", DEFAULT_TIMEOUT_SECS, tmp.path(), false)
.expect("rush should succeed");
assert_eq!(exit_code, 0);
assert!(!timed_out);
assert!(output.contains("hello world"), "stdout missing: {output}");
}
#[test]
#[cfg(feature = "rush-backend")]
fn test_rush_backend_builtin() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("afile.txt"), "content").unwrap();
let (output, exit_code, _, _) = run_via_rush("ls", DEFAULT_TIMEOUT_SECS, tmp.path(), false)
.expect("rush should succeed");
assert_eq!(exit_code, 0);
assert!(
output.contains("afile.txt"),
"ls should list file: {output}"
);
}
#[test]
#[cfg(feature = "rush-backend")]
fn test_rush_backend_ls_json_text_transform() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("afile.txt"), "content").unwrap();
let (output, exit_code, _, _) = run_via_rush("ls", DEFAULT_TIMEOUT_SECS, tmp.path(), true)
.expect("rush should succeed");
let text = parse_json_lines_to_text("ls", &output).expect("json should transform");
assert_eq!(exit_code, 0);
assert!(text.contains("afile.txt"));
}
#[test]
#[cfg(feature = "rush-backend")]
fn test_rush_backend_grep_json_text_transform() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("afile.txt"), "hello needle world\n").unwrap();
let (output, exit_code, _, _) =
run_via_rush("grep -r needle .", DEFAULT_TIMEOUT_SECS, tmp.path(), true)
.expect("rush should succeed");
let text =
parse_json_lines_to_text("grep -r needle .", &output).expect("json should transform");
assert_eq!(exit_code, 0);
assert!(text.contains("needle"));
assert!(
text.contains("afile.txt") || text.contains(":1:"),
"unexpected grep text: {text}"
);
}
#[test]
#[cfg(feature = "rush-backend")]
fn test_rush_backend_find_json_text_transform() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("afile.txt"), "content").unwrap();
let (output, exit_code, _, _) = run_via_rush(
"find . -name afile.txt",
DEFAULT_TIMEOUT_SECS,
tmp.path(),
true,
)
.expect("rush should succeed");
let text = parse_json_lines_to_text("find . -name afile.txt", &output)
.expect("json should transform");
assert_eq!(exit_code, 0);
assert!(text.contains("afile.txt"));
}
#[test]
#[cfg(feature = "rush-backend")]
fn test_rush_backend_pipeline() {
let tmp = tempfile::tempdir().unwrap();
let (output, exit_code, _, _) =
run_via_rush("echo foo | cat", DEFAULT_TIMEOUT_SECS, tmp.path(), false)
.expect("rush should succeed");
assert_eq!(exit_code, 0);
assert!(output.contains("foo"), "pipeline output missing: {output}");
}
#[test]
#[cfg(feature = "rush-backend")]
fn test_rush_backend_exit_code() {
let tmp = tempfile::tempdir().unwrap();
let (_, exit_code, _, _) = run_via_rush("exit 42", DEFAULT_TIMEOUT_SECS, tmp.path(), false)
.expect("rush should return result even on non-zero exit");
assert_eq!(exit_code, 42);
}
}