use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use openpaw_core::{Result, ToolCallOutcome, ToolCategory, ToolConcurrency, ToolSpec};
use serde::Deserialize;
use super::environment::{Environment, LocalEnvironment};
use crate::bashast::{analyze_command_safety, destructive_rm_verdict, CommandSafety};
const DEFAULT_TIMEOUT_MS: u64 = 120_000;
const MAX_TIMEOUT_MS: u64 = 600_000;
const MAX_OUTPUT_CHARS: usize = 30_000;
#[derive(Deserialize)]
struct Args {
command: String,
#[serde(default = "default_timeout")]
timeout_ms: u64,
}
fn default_timeout() -> u64 {
DEFAULT_TIMEOUT_MS
}
pub fn spec() -> ToolSpec {
ToolSpec {
name: "bash".into(),
description: "Run a shell command in the workspace directory. Prefer specialized tools (read_file, write_file, edit_file, glob, grep) over bash for file operations — they are safer and produce fewer output tokens. Use bash for tasks those tools cannot handle (e.g. running tests, installing packages, git operations). Commands time out after 120s by default; set timeout_ms for longer runs. Do not use for file creation or editing — use write_file or edit_file instead.".into(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"command": {"type": "string", "description": "Shell command to execute"},
"timeout_ms": {"type": "integer", "description": "Timeout in milliseconds (default 120000, max 600000)"}
},
"required": ["command"]
}),
category: Some(ToolCategory::Destructive),
concurrency: Some(ToolConcurrency::NeverParallel),
requires_approval: true,
is_destructive: Some(true),
..Default::default()
}
.with_description_fn(Arc::new(|input: &serde_json::Value| {
input
.get("command")
.and_then(|v| v.as_str())
.map(|cmd| format!("Running {}", truncate_chars(cmd, 60)))
.unwrap_or_else(|| "Running".to_string())
}))
}
pub async fn run(workspace: &Path, args: serde_json::Value) -> Result<ToolCallOutcome> {
let env = LocalEnvironment;
run_with_env(workspace, args, &env).await
}
pub async fn run_with_env(
workspace: &Path,
args: serde_json::Value,
env: &dyn Environment,
) -> Result<ToolCallOutcome> {
let a: Args = match serde_json::from_value(args) {
Ok(a) => a,
Err(e) => return Ok(ToolCallOutcome::error(format!("bash: bad args: {e}"))),
};
let safety = analyze_command_safety(&a.command, |sub| destructive_rm_verdict(sub).is_some());
match safety {
CommandSafety::Safe => {}
CommandSafety::Dangerous(reason) => {
return Ok(ToolCallOutcome::error(format!("blocked: {reason}")))
}
CommandSafety::RequiresApproval(reason) => {
return Ok(ToolCallOutcome::error(format!(
"requires approval: {reason}"
)))
}
}
execute_in_env(&a.command, workspace, a.timeout_ms, env).await
}
async fn execute_in_env(
command: &str,
workspace: &Path,
timeout_ms: u64,
env: &dyn Environment,
) -> Result<ToolCallOutcome> {
let timeout = Duration::from_millis(timeout_ms.min(MAX_TIMEOUT_MS));
let result = tokio::time::timeout(timeout, env.exec(command, Some(workspace))).await;
match result {
Ok(env_result) => {
let mut out = String::new();
if !env_result.stdout.is_empty() {
out.push_str(&env_result.stdout);
}
if !env_result.stderr.is_empty() {
if !out.is_empty() {
out.push('\n');
}
out.push_str("[stderr]\n");
out.push_str(&env_result.stderr);
}
if out.is_empty() {
out.push_str("(no output)");
}
out.push_str(&format!("\n[exit code: {}]", env_result.exit_code));
let content = truncate_output(out);
if env_result.exit_code != 0 {
Ok(ToolCallOutcome::error(content))
} else {
Ok(ToolCallOutcome::ok(content))
}
}
Err(_) => Ok(ToolCallOutcome::error(format!(
"bash timeout after {}ms",
timeout.as_millis()
))),
}
}
fn truncate_chars(s: &str, max: usize) -> &str {
match s.char_indices().nth(max) {
Some((idx, _)) => &s[..idx],
None => s,
}
}
fn truncate_output(out: String) -> String {
if out.chars().count() <= MAX_OUTPUT_CHARS {
return out;
}
let truncated: String = out.chars().take(MAX_OUTPUT_CHARS).collect();
format!("{truncated}\n... (output truncated at {MAX_OUTPUT_CHARS} characters)")
}