M1: read, write, bash, glob, and grep tools
Implements the first five harness-tools: read, write, bash (with timeout/output truncation), glob, and grep, plus shared path-resolution helpers. Wires them into the core tool registry and processor.
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use harness_core::tool::{Tool, ToolCtx, ToolError, ToolOutput};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 2 * 60 * 1000;
|
||||
const MAX_TIMEOUT_MS: u64 = 10 * 60 * 1000;
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct BashParams {
|
||||
command: String,
|
||||
timeout_ms: Option<u64>,
|
||||
cwd: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
description: String,
|
||||
}
|
||||
|
||||
pub struct BashTool;
|
||||
|
||||
/// The "always allow" pattern is coarser than the exact command: opencode/docs/05-tools.md
|
||||
/// grants `<first word> *` (e.g. `git *`), not the literal command string.
|
||||
fn always_pattern(command: &str) -> String {
|
||||
shell_words::split(command)
|
||||
.ok()
|
||||
.and_then(|words| words.into_iter().next())
|
||||
.map(|first| format!("{first} *"))
|
||||
.unwrap_or_else(|| "*".to_string())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[allow(unsafe_code)]
|
||||
fn kill_group(pid: u32) {
|
||||
// Negative pid signals the whole process group (spawned with `process_group(0)`).
|
||||
// Safety: `kill` is a plain libc syscall; passing a negative pid targets the group,
|
||||
// which is exactly the process tree we spawned and want to tear down.
|
||||
unsafe {
|
||||
libc::kill(-(pid as i32), libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn kill_group(_pid: u32) {}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for BashTool {
|
||||
fn name(&self) -> &str {
|
||||
"bash"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Runs a shell command and returns its combined stdout/stderr."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> serde_json::Value {
|
||||
serde_json::to_value(schemars::schema_for!(BashParams)).unwrap()
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
input: serde_json::Value,
|
||||
ctx: ToolCtx,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: BashParams =
|
||||
serde_json::from_value(input).map_err(|e| ToolError::Invalid(e.to_string()))?;
|
||||
let timeout_ms = params
|
||||
.timeout_ms
|
||||
.unwrap_or(DEFAULT_TIMEOUT_MS)
|
||||
.min(MAX_TIMEOUT_MS);
|
||||
|
||||
ctx.ask
|
||||
.ask(
|
||||
"bash",
|
||||
params.command.clone(),
|
||||
always_pattern(¶ms.command),
|
||||
serde_json::json!({"command": params.command}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cwd = params
|
||||
.cwd
|
||||
.as_ref()
|
||||
.map(|c| crate::paths::resolve(&ctx.cwd, c))
|
||||
.unwrap_or_else(|| ctx.cwd.clone());
|
||||
|
||||
let mut cmd = tokio::process::Command::new("sh");
|
||||
cmd.arg("-c")
|
||||
.arg(¶ms.command)
|
||||
.current_dir(&cwd)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
#[cfg(unix)]
|
||||
cmd.process_group(0);
|
||||
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| ToolError::Other(format!("spawn failed: {e}")))?;
|
||||
let pid = child.id();
|
||||
|
||||
tokio::select! {
|
||||
result = child.wait_with_output() => {
|
||||
let output = result.map_err(|e| ToolError::Other(format!("wait failed: {e}")))?;
|
||||
let mut combined = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
combined.push_str(&String::from_utf8_lossy(&output.stderr));
|
||||
let title = format!("{} (exit {})", params.command, output.status.code().unwrap_or(-1));
|
||||
Ok(ToolOutput::new(title, combined))
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_millis(timeout_ms)) => {
|
||||
if let Some(pid) = pid { kill_group(pid); }
|
||||
Err(ToolError::Other(format!("command timed out after {timeout_ms}ms")))
|
||||
}
|
||||
_ = ctx.cancel.cancelled() => {
|
||||
if let Some(pid) = pid { kill_group(pid); }
|
||||
Err(ToolError::Cancelled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use harness_core::event::EventBus;
|
||||
use harness_core::permission::{spawn_auto_approve, PermissionService};
|
||||
use harness_core::tool::{MetadataSink, PermissionHandle};
|
||||
use harness_core::types::SessionId;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
fn ctx(cwd: std::path::PathBuf) -> ToolCtx {
|
||||
let bus = EventBus::new();
|
||||
let service = Arc::new(PermissionService::new(bus.clone()));
|
||||
spawn_auto_approve(bus, service.clone());
|
||||
let (metadata, _rx) = MetadataSink::channel();
|
||||
ToolCtx {
|
||||
session_id: SessionId::new(),
|
||||
message_id: harness_core::types::MessageId::new(),
|
||||
call_id: "call_1".into(),
|
||||
data_dir: cwd.join("tool-output"),
|
||||
cwd,
|
||||
cancel: CancellationToken::new(),
|
||||
ask: PermissionHandle::new(
|
||||
service,
|
||||
SessionId::new(),
|
||||
Vec::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
CancellationToken::new(),
|
||||
),
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn always_pattern_uses_first_word() {
|
||||
assert_eq!(always_pattern("git push origin main"), "git *");
|
||||
assert_eq!(always_pattern("ls -la"), "ls *");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runs_command_and_captures_stdout() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = BashTool
|
||||
.execute(
|
||||
serde_json::json!({"command": "echo hello", "description": "say hi"}),
|
||||
ctx(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(output.output.contains("hello"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn captures_stderr_too() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = BashTool
|
||||
.execute(
|
||||
serde_json::json!({"command": "echo err 1>&2", "description": "stderr"}),
|
||||
ctx(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(output.output.contains("err"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn times_out_long_running_commands() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let err = BashTool
|
||||
.execute(
|
||||
serde_json::json!({"command": "sleep 5", "timeout_ms": 50, "description": "slow"}),
|
||||
ctx(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ToolError::Other(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn respects_cwd_override() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = BashTool
|
||||
.execute(
|
||||
serde_json::json!({"command": "pwd", "description": "where"}),
|
||||
ctx(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(output
|
||||
.output
|
||||
.trim()
|
||||
.ends_with(dir.path().file_name().unwrap().to_str().unwrap()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user