use crate::engine::{EngineEvent, EngineSink};
use crate::providers::ToolDefinition;
use crate::tools::bg_process::BgRegistry;
use anyhow::Result;
use serde_json::{Value, json};
use std::path::Path;
use tokio::io::{AsyncBufReadExt, BufReader};
const DEFAULT_TIMEOUT_SECS: u64 = 60;
const MAX_TIMEOUT_SECS: u64 = 300;
const SUMMARY_STDERR_LINES: usize = 50;
const SUMMARY_STDOUT_TAIL: usize = 20;
const MAX_COLLECT_BYTES: usize = 10 * 1024 * 1024;
#[derive(Debug, Clone)]
pub struct ShellOutput {
pub summary: String,
pub full_output: Option<String>,
}
pub fn definitions() -> Vec<ToolDefinition> {
vec![ToolDefinition {
name: "Bash".to_string(),
description: "Execute a shell command. Use ONLY for builds, tests, git, \
and commands without a dedicated tool. Never use for file ops \
(use Read/Write/Edit/Grep/List instead). Suppress verbose output: \
pipe to tail, use --quiet, avoid -v flags. \
Set background=true for long-running processes (dev servers, watchers) \
— returns immediately with the PID."
.to_string(),
parameters: json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute"
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds (default: 60, ignored when background=true)"
},
"background": {
"type": "boolean",
"description": "Run in background and return immediately with PID (default: false). \
Use for dev servers, file watchers, and other long-running processes."
}
},
"required": ["command"]
}),
}]
}
pub async fn run_shell_command(
project_root: &Path,
args: &Value,
max_lines: usize,
bg: &BgRegistry,
sink: Option<(&dyn EngineSink, &str)>,
trust: &crate::trust::TrustMode,
) -> Result<ShellOutput> {
let command = args["command"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'command' argument"))?;
let background = args["background"].as_bool().unwrap_or(false);
tracing::info!(
"Running shell command (background={background}): [{} chars]",
command.len()
);
if background {
let msg = spawn_background(project_root, command, bg, trust)?;
return Ok(ShellOutput {
summary: msg,
full_output: None,
});
}
let timeout_secs = args["timeout"]
.as_u64()
.unwrap_or(DEFAULT_TIMEOUT_SECS)
.min(MAX_TIMEOUT_SECS);
let mut child = crate::sandbox::build(command, project_root, trust)?
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to execute command: {e}"))?;
let stdout = child.stdout.take().unwrap();
let stderr = child.stderr.take().unwrap();
let mut stdout_lines: Vec<String> = Vec::new();
let mut stderr_lines: Vec<String> = Vec::new();
let sink_info = sink.map(|(s, id)| (s, id.to_string()));
let result = tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs),
read_streams(
stdout,
stderr,
&mut stdout_lines,
&mut stderr_lines,
max_lines,
&sink_info,
),
)
.await;
match result {
Ok(Ok(())) => {
let status = child
.wait()
.await
.map_err(|e| anyhow::anyhow!("wait: {e}"))?;
let exit_code = status.code().unwrap_or(-1);
let summary = format_summary(exit_code, &stdout_lines, &stderr_lines);
let full = format_full_output(exit_code, &stdout_lines, &stderr_lines);
Ok(ShellOutput {
summary,
full_output: Some(full),
})
}
Ok(Err(e)) => Err(anyhow::anyhow!("Stream read error: {e}")),
Err(_) => {
let _ = child.kill().await;
let msg = format!("Command timed out after {timeout_secs}s: {command}");
Ok(ShellOutput {
summary: msg.clone(),
full_output: Some(msg),
})
}
}
}
async fn read_streams(
stdout: tokio::process::ChildStdout,
stderr: tokio::process::ChildStderr,
stdout_lines: &mut Vec<String>,
stderr_lines: &mut Vec<String>,
max_lines: usize,
sink_info: &Option<(&dyn EngineSink, String)>,
) -> std::io::Result<()> {
let mut stdout_reader = BufReader::new(stdout).lines();
let mut stderr_reader = BufReader::new(stderr).lines();
let mut stdout_done = false;
let mut stderr_done = false;
let mut collected_bytes: usize = 0;
let mut collected_lines: usize = 0;
while !stdout_done || !stderr_done {
tokio::select! {
line = stdout_reader.next_line(), if !stdout_done => {
match line? {
Some(l) => {
if let Some((sink, id)) = sink_info {
sink.emit(EngineEvent::ToolOutputLine {
id: id.clone(),
line: l.clone(),
is_stderr: false,
});
}
if collected_lines < max_lines
&& collected_bytes < MAX_COLLECT_BYTES
{
collected_bytes += l.len();
collected_lines += 1;
stdout_lines.push(l);
}
}
None => stdout_done = true,
}
}
line = stderr_reader.next_line(), if !stderr_done => {
match line? {
Some(l) => {
if let Some((sink, id)) = sink_info {
sink.emit(EngineEvent::ToolOutputLine {
id: id.clone(),
line: l.clone(),
is_stderr: true,
});
}
if collected_lines < max_lines
&& collected_bytes < MAX_COLLECT_BYTES
{
collected_bytes += l.len();
collected_lines += 1;
stderr_lines.push(l);
}
}
None => stderr_done = true,
}
}
}
}
Ok(())
}
fn spawn_background(
project_root: &Path,
command: &str,
bg: &BgRegistry,
trust: &crate::trust::TrustMode,
) -> Result<String> {
let child = crate::sandbox::build(command, project_root, trust)?
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to spawn background command: {e}"))?;
let pid = child
.id()
.ok_or_else(|| anyhow::anyhow!("Spawned process has no PID (already exited)"))?;
bg.insert(pid, command.to_string(), child);
Ok(format!(
"Background process started.\n PID: {pid}\n Command: {command}\n\
To stop: Bash{{command: \"kill {pid}\"}}\n\
To force: Bash{{command: \"kill -9 {pid}\"}}\n\
Note: process will be stopped automatically when the session ends."
))
}
fn format_summary(exit_code: i32, stdout_lines: &[String], stderr_lines: &[String]) -> String {
let mut out = format!(
"Exit code: {exit_code} | stdout: {} lines | stderr: {} lines",
stdout_lines.len(),
stderr_lines.len(),
);
if !stderr_lines.is_empty() {
let (label, text) = if stderr_lines.len() > SUMMARY_STDERR_LINES {
let skipped = stderr_lines.len() - SUMMARY_STDERR_LINES;
(
format!(
"\n\n--- stderr (last {} of {}, {skipped} skipped) ---",
SUMMARY_STDERR_LINES,
stderr_lines.len(),
),
stderr_lines[stderr_lines.len() - SUMMARY_STDERR_LINES..].join("\n"),
)
} else {
(
format!("\n\n--- stderr ({} lines) ---", stderr_lines.len()),
stderr_lines.join("\n"),
)
};
out.push_str(&label);
out.push('\n');
out.push_str(&text);
}
if !stdout_lines.is_empty() {
let (label, text) = if stdout_lines.len() > SUMMARY_STDOUT_TAIL {
(
format!(
"\n\n--- stdout (last {} of {}) ---",
SUMMARY_STDOUT_TAIL,
stdout_lines.len(),
),
stdout_lines[stdout_lines.len() - SUMMARY_STDOUT_TAIL..].join("\n"),
)
} else {
(
format!("\n\n--- stdout ({} lines) ---", stdout_lines.len()),
stdout_lines.join("\n"),
)
};
out.push_str(&label);
out.push('\n');
out.push_str(&text);
}
if stdout_lines.len() > SUMMARY_STDOUT_TAIL || stderr_lines.len() > SUMMARY_STDERR_LINES {
out.push_str("\n\nFull output stored. Use RecallContext to search if needed.");
}
out
}
fn format_full_output(exit_code: i32, stdout_lines: &[String], stderr_lines: &[String]) -> String {
const MAX_FULL_OUTPUT_BYTES: usize = 2 * 1024 * 1024;
let mut out = format!("Exit code: {exit_code}\n");
if !stdout_lines.is_empty() {
out.push_str("\n--- stdout ---\n");
out.push_str(&stdout_lines.join("\n"));
}
if !stderr_lines.is_empty() {
out.push_str("\n\n--- stderr ---\n");
out.push_str(&stderr_lines.join("\n"));
}
if out.len() > MAX_FULL_OUTPUT_BYTES {
out.truncate(MAX_FULL_OUTPUT_BYTES);
while !out.is_char_boundary(out.len()) {
out.pop();
}
out.push_str("\n\n[... output truncated at 2MB ...]");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::bg_process::BgRegistry;
fn bg() -> BgRegistry {
BgRegistry::new()
}
#[tokio::test]
async fn shell_timeout_returns_timeout_message() {
let tmp = tempfile::tempdir().unwrap();
let args = serde_json::json!({"command": "sleep 5", "timeout": 1});
let result = run_shell_command(
tmp.path(),
&args,
256,
&bg(),
None,
&crate::trust::TrustMode::Safe,
)
.await
.unwrap();
assert!(
result.summary.contains("timed out"),
"Expected timeout message, got: {}",
result.summary
);
}
#[tokio::test]
async fn shell_respects_custom_timeout_parameter() {
let tmp = tempfile::tempdir().unwrap();
let args = serde_json::json!({"command": "echo hello", "timeout": 5});
let result = run_shell_command(
tmp.path(),
&args,
256,
&bg(),
None,
&crate::trust::TrustMode::Safe,
)
.await
.unwrap();
assert!(
result.summary.contains("hello"),
"Fast command should succeed: {}",
result.summary
);
}
#[tokio::test]
async fn shell_default_timeout_is_applied_when_not_specified() {
let tmp = tempfile::tempdir().unwrap();
let args = serde_json::json!({"command": "echo world"});
let result = run_shell_command(
tmp.path(),
&args,
256,
&bg(),
None,
&crate::trust::TrustMode::Safe,
)
.await
.unwrap();
assert!(
result.summary.contains("world"),
"Command without explicit timeout should work: {}",
result.summary
);
}
#[tokio::test]
async fn background_spawn_returns_pid() {
let tmp = tempfile::tempdir().unwrap();
let registry = BgRegistry::new();
let args = serde_json::json!({"command": "sleep 60", "background": true});
let result = run_shell_command(
tmp.path(),
&args,
256,
®istry,
None,
&crate::trust::TrustMode::Safe,
)
.await
.unwrap();
assert!(
result.summary.contains("Background process started"),
"{}",
result.summary
);
assert!(result.summary.contains("PID:"), "{}", result.summary);
assert!(result.summary.contains("kill"), "{}", result.summary);
assert!(
result.full_output.is_none(),
"background has no full_output"
);
assert_eq!(registry.len(), 1);
}
#[tokio::test]
async fn background_false_runs_synchronously() {
let tmp = tempfile::tempdir().unwrap();
let args = serde_json::json!({"command": "echo sync", "background": false});
let result = run_shell_command(
tmp.path(),
&args,
256,
&bg(),
None,
&crate::trust::TrustMode::Safe,
)
.await
.unwrap();
assert!(result.summary.contains("sync"), "{}", result.summary);
assert!(
!result.summary.contains("PID:"),
"foreground should not have PID line: {}",
result.summary
);
}
#[test]
fn test_format_summary_short_output() {
let stdout: Vec<String> = vec!["hello", "world"]
.into_iter()
.map(String::from)
.collect();
let stderr: Vec<String> = vec![];
let summary = format_summary(0, &stdout, &stderr);
assert!(summary.contains("Exit code: 0"));
assert!(summary.contains("stdout: 2 lines"));
assert!(summary.contains("hello"));
assert!(summary.contains("world"));
assert!(!summary.contains("RecallContext"));
}
#[test]
fn test_format_summary_long_stdout_truncated() {
let stdout: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
let stderr: Vec<String> = vec!["warning: something".into()];
let summary = format_summary(0, &stdout, &stderr);
assert!(summary.contains("line 99"));
assert!(summary.contains("line 80"));
assert!(!summary.contains("line 0\n"));
assert!(summary.contains("last 20 of 100"));
assert!(summary.contains("warning: something"));
assert!(summary.contains("RecallContext"));
}
#[test]
fn test_format_full_output_includes_everything() {
let stdout: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
let stderr: Vec<String> = vec!["err1".into(), "err2".into()];
let full = format_full_output(1, &stdout, &stderr);
assert!(full.contains("Exit code: 1"));
assert!(full.contains("line 0"));
assert!(full.contains("line 99"));
assert!(full.contains("err1"));
assert!(full.contains("err2"));
}
#[test]
fn test_format_full_output_capped_at_2mb() {
let stdout: Vec<String> = (0..200_000).map(|i| format!("line {i}: padding")).collect();
let full = format_full_output(0, &stdout, &[]);
assert!(full.len() <= 2 * 1024 * 1024 + 50); assert!(full.contains("truncated at 2MB"));
}
#[test]
fn test_shell_output_has_full_output() {
let so = ShellOutput {
summary: "Exit code: 0".into(),
full_output: Some("full output here".into()),
};
assert_eq!(so.summary, "Exit code: 0");
assert_eq!(so.full_output.unwrap(), "full output here");
}
#[tokio::test]
async fn collection_stops_at_max_lines() {
let tmp = tempfile::tempdir().unwrap();
let args = serde_json::json!({
"command": "seq 1 50"
});
let result = run_shell_command(
tmp.path(),
&args,
10,
&bg(),
None,
&crate::trust::TrustMode::Safe,
)
.await
.unwrap();
assert!(
result.summary.contains("stdout: 10 lines"),
"Expected 10 collected lines, got: {}",
result.summary
);
let full = result.full_output.unwrap();
assert!(full.contains("1"), "Should contain first line");
assert!(!full.contains("\n50\n"), "Should NOT contain line 50");
}
#[test]
fn test_timeout_capped_at_max() {
let args = serde_json::json!({"command": "echo hi", "timeout": 99999});
let t = args["timeout"]
.as_u64()
.unwrap_or(DEFAULT_TIMEOUT_SECS)
.min(MAX_TIMEOUT_SECS);
assert_eq!(t, MAX_TIMEOUT_SECS);
}
#[tokio::test]
async fn streaming_emits_lines_to_sink() {
use std::sync::{Arc, Mutex};
#[derive(Debug, Default)]
struct CaptureSink {
lines: Mutex<Vec<(String, bool)>>,
}
impl crate::engine::EngineSink for CaptureSink {
fn emit(&self, event: EngineEvent) {
if let EngineEvent::ToolOutputLine {
line, is_stderr, ..
} = event
{
self.lines.lock().unwrap().push((line, is_stderr));
}
}
}
let tmp = tempfile::tempdir().unwrap();
let sink = Arc::new(CaptureSink::default());
let args = serde_json::json!({
"command": "echo alpha && echo bravo && echo charlie >&2"
});
let result = run_shell_command(
tmp.path(),
&args,
256,
&bg(),
Some((sink.as_ref(), "test_id")),
&crate::trust::TrustMode::Safe,
)
.await
.unwrap();
assert!(result.summary.contains("alpha"));
assert!(result.summary.contains("bravo"));
assert!(result.summary.contains("charlie"));
let full = result.full_output.unwrap();
assert!(full.contains("alpha"));
assert!(full.contains("bravo"));
assert!(full.contains("charlie"));
let lines = sink.lines.lock().unwrap();
assert!(
lines.len() >= 3,
"Expected at least 3 streamed lines, got {}: {lines:?}",
lines.len()
);
assert!(lines.iter().any(|(_, is_stderr)| !is_stderr));
assert!(lines.iter().any(|(_, is_stderr)| *is_stderr));
}
}