use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use car_engine::{agent_basics, LocalSubstrate, Substrate, ToolExecutor};
use car_policy::InspectorChain;
use serde_json::{json, Value};
use super::policy::{coder_inspector_chain, stays_under};
const DEFAULT_SHELL_TIMEOUT_SECS: u64 = 120;
const MAX_SHELL_TIMEOUT_SECS: u64 = 600;
const MAX_OUTPUT_BYTES: usize = 64 * 1024;
pub(crate) fn tail(s: &str, cap: usize) -> String {
if s.len() <= cap {
return s.to_string();
}
let mut start = s.len() - cap;
while !s.is_char_boundary(start) {
start += 1;
}
format!("…[truncated]…{}", &s[start..])
}
pub struct WorktreeExecutor {
worktree: PathBuf,
inspectors: InspectorChain,
delegate: Option<Arc<dyn ToolExecutor>>,
delegate_defs: Vec<Value>,
}
impl WorktreeExecutor {
pub fn new(worktree: impl Into<PathBuf>) -> Self {
let worktree: PathBuf = worktree.into();
let worktree = worktree.canonicalize().unwrap_or(worktree);
let inspectors = coder_inspector_chain(&worktree);
Self {
worktree,
inspectors,
delegate: None,
delegate_defs: Vec::new(),
}
}
pub fn with_chain(mut self, chain: InspectorChain) -> Self {
self.inspectors = chain;
self
}
pub fn with_delegate(mut self, delegate: Arc<dyn ToolExecutor>, defs: Vec<Value>) -> Self {
self.delegate = Some(delegate);
self.delegate_defs = defs;
self
}
pub fn all_tool_defs(&self) -> Vec<Value> {
let mut defs = Self::tool_defs();
defs.extend(self.delegate_defs.iter().cloned());
defs
}
pub fn worktree(&self) -> &Path {
&self.worktree
}
pub fn tool_defs() -> Vec<Value> {
let mut defs: Vec<Value> = agent_basics::entries()
.iter()
.map(|e| {
json!({
"name": e.schema.name,
"description": e.schema.description,
"parameters": e.schema.parameters,
})
})
.filter(|d| d["name"] != "calculate") .collect();
defs.push(json!({
"name": "shell",
"description": "Run a shell command at the repository root (the worktree). \
Use for builds, tests, and anything the file tools can't do. \
Output is the combined stdout+stderr tail. Some commands \
(git push, sudo, destructive operations outside the repo) \
are denied by policy.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Command executed via sh -c at the repository root"
},
"timeout_secs": {
"type": "integer",
"description": "Wall-clock limit (default 120, max 600)"
}
},
"required": ["command"]
}
}));
defs
}
fn clamp_paths(&self, tool: &str, params: &Value) -> Result<Value, String> {
let mut params = params.clone();
let Some(obj) = params.as_object_mut() else {
return Ok(params);
};
if let Some(Value::String(p)) = obj.get("path") {
if !stays_under(&self.worktree, p) && matches!(tool, "write_file" | "edit_file") {
return Err(format!("path '{p}' resolves outside the worktree"));
}
if Path::new(p).is_relative() {
let abs = self.worktree.join(p);
obj.insert("path".into(), json!(abs.to_string_lossy()));
}
} else if tool == "list_dir" || tool == "find_files" || tool == "grep_files" {
obj.entry("path")
.or_insert_with(|| json!(self.worktree.to_string_lossy()));
}
Ok(params)
}
pub async fn run_shell(&self, command: &str, timeout_secs: Option<u64>) -> Result<Value, String> {
if let Some(reason) = self.inspectors.check("shell", &json!({ "command": command })) {
return Err(format!("denied by policy: {reason}"));
}
let timeout = Duration::from_secs(
timeout_secs
.unwrap_or(DEFAULT_SHELL_TIMEOUT_SECS)
.clamp(1, MAX_SHELL_TIMEOUT_SECS),
);
let mut cmd = tokio::process::Command::new("/bin/sh");
cmd.arg("-lc")
.arg(command)
.current_dir(&self.worktree)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
#[cfg(unix)]
cmd.process_group(0);
let child = cmd
.spawn()
.map_err(|e| format!("failed to spawn shell: {e}"))?;
#[cfg(unix)]
let pgid = child.id();
match tokio::time::timeout(timeout, child.wait_with_output()).await {
Ok(Ok(out)) => {
let mut combined = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr);
if !stderr.is_empty() {
if !combined.is_empty() && !combined.ends_with('\n') {
combined.push('\n');
}
combined.push_str(&stderr);
}
Ok(json!({
"exit_code": out.status.code().unwrap_or(-1),
"output": tail(&combined, MAX_OUTPUT_BYTES),
"timed_out": false,
}))
}
Ok(Err(e)) => Err(format!("shell wait failed: {e}")),
Err(_elapsed) => {
#[cfg(unix)]
if let Some(pid) = pgid {
unsafe {
libc::killpg(pid as i32, libc::SIGKILL);
}
}
Ok(json!({
"exit_code": Value::Null,
"output": format!("command timed out after {}s and was killed", timeout.as_secs()),
"timed_out": true,
}))
}
}
}
}
#[async_trait]
impl ToolExecutor for WorktreeExecutor {
async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
if tool == "shell" {
let command = params
.get("command")
.and_then(Value::as_str)
.ok_or("missing 'command' parameter")?;
let timeout_secs = params.get("timeout_secs").and_then(Value::as_u64);
return self.run_shell(command, timeout_secs).await;
}
if self.delegate_defs.iter().any(|d| d["name"] == tool) {
if let Some(delegate) = &self.delegate {
return delegate.execute(tool, params).await;
}
}
let clamped = self.clamp_paths(tool, params)?;
if let Some(reason) = self.inspectors.check(tool, &clamped) {
return Err(format!("denied by policy: {reason}"));
}
let substrate: Arc<dyn Substrate> = Arc::new(LocalSubstrate::new());
match agent_basics::execute(&substrate, tool, &clamped).await {
Some(result) => result,
None => Err(format!("unknown tool: {tool}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn executor() -> (tempfile::TempDir, WorktreeExecutor) {
let dir = tempfile::tempdir().unwrap();
let exec = WorktreeExecutor::new(dir.path());
(dir, exec)
}
#[tokio::test]
async fn shell_runs_at_worktree_root() {
let (dir, exec) = executor();
let out = exec.run_shell("pwd", Some(10)).await.unwrap();
let cwd = out["output"].as_str().unwrap().trim();
assert_eq!(
PathBuf::from(cwd).canonicalize().unwrap(),
dir.path().canonicalize().unwrap()
);
assert_eq!(out["exit_code"], 0);
}
#[tokio::test]
async fn shell_reports_nonzero_exit_as_value() {
let (_dir, exec) = executor();
let out = exec.run_shell("exit 3", Some(10)).await.unwrap();
assert_eq!(out["exit_code"], 3);
assert_eq!(out["timed_out"], false);
}
#[tokio::test]
async fn shell_captures_stderr() {
let (_dir, exec) = executor();
let out = exec
.run_shell("echo to-out; echo to-err 1>&2", Some(10))
.await
.unwrap();
let text = out["output"].as_str().unwrap();
assert!(text.contains("to-out") && text.contains("to-err"));
}
#[tokio::test]
async fn shell_timeout_kills_and_reports() {
let (_dir, exec) = executor();
let started = std::time::Instant::now();
let out = exec.run_shell("sleep 30", Some(1)).await.unwrap();
assert!(started.elapsed() < Duration::from_secs(10), "did not wait out the sleep");
assert_eq!(out["timed_out"], true);
assert!(out["exit_code"].is_null());
}
#[tokio::test]
async fn shell_denied_by_policy() {
let (_dir, exec) = executor();
let err = exec.run_shell("git push origin main", Some(5)).await.unwrap_err();
assert!(err.contains("denied by policy"), "{err}");
}
#[tokio::test]
async fn relative_file_writes_land_in_worktree() {
let (dir, exec) = executor();
exec.execute("write_file", &json!({"path": "sub/out.txt", "content": "hi"}))
.await
.unwrap();
assert_eq!(
std::fs::read_to_string(dir.path().join("sub/out.txt")).unwrap(),
"hi"
);
}
#[tokio::test]
async fn escaping_writes_are_rejected_in_code() {
let (_dir, exec) = executor();
let err = exec
.execute("write_file", &json!({"path": "../escape.txt", "content": "x"}))
.await
.unwrap_err();
assert!(err.contains("outside the worktree"), "{err}");
let err = exec
.execute("write_file", &json!({"path": "/tmp/abs-escape.txt", "content": "x"}))
.await
.unwrap_err();
assert!(err.contains("outside the worktree"), "{err}");
}
#[tokio::test]
async fn list_dir_defaults_to_worktree_not_process_cwd() {
let (dir, exec) = executor();
std::fs::write(dir.path().join("marker.txt"), "x").unwrap();
let out = exec.execute("list_dir", &json!({})).await.unwrap();
assert!(
out.to_string().contains("marker.txt"),
"expected worktree listing, got: {out}"
);
}
#[tokio::test]
async fn output_is_tail_capped() {
let (_dir, exec) = executor();
let out = exec
.run_shell("i=0; while [ $i -lt 5000 ]; do echo 'line of output 40 bytes long....'; i=$((i+1)); done", Some(30))
.await
.unwrap();
let text = out["output"].as_str().unwrap();
assert!(text.len() <= MAX_OUTPUT_BYTES + 32, "len={}", text.len());
assert!(text.starts_with("…[truncated]…"));
}
#[tokio::test]
async fn unknown_tool_errors() {
let (_dir, exec) = executor();
assert!(exec.execute("teleport", &json!({})).await.is_err());
}
struct StubDelegate;
#[async_trait]
impl ToolExecutor for StubDelegate {
async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
Ok(json!({ "via": "delegate", "tool": tool, "echo": params.clone() }))
}
}
#[tokio::test]
async fn delegate_tool_routes_through_delegate_and_is_advertised() {
let dir = tempfile::tempdir().unwrap();
let defs = vec![json!({
"name": "ext_tool",
"description": "external",
"parameters": { "type": "object", "properties": {} }
})];
let exec = WorktreeExecutor::new(dir.path())
.with_delegate(Arc::new(StubDelegate), defs);
let names: Vec<String> = exec
.all_tool_defs()
.iter()
.filter_map(|d| d["name"].as_str().map(String::from))
.collect();
assert!(names.iter().any(|n| n == "ext_tool"));
assert!(names.iter().any(|n| n == "read_file"));
let out = exec
.execute("ext_tool", &json!({ "x": 1 }))
.await
.unwrap();
assert_eq!(out["via"], "delegate");
assert_eq!(out["tool"], "ext_tool");
assert_eq!(out["echo"]["x"], 1);
assert!(exec.execute("teleport", &json!({})).await.is_err());
}
#[test]
fn tail_respects_char_boundaries() {
let s = "ééééé"; let t = tail(s, 3);
assert!(t.ends_with('é'));
}
}