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:
2026-07-08 17:12:48 +02:00
parent 5ac646b3c6
commit a5af873924
12 changed files with 1224 additions and 1 deletions
Generated
+162
View File
@@ -47,6 +47,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79"
dependencies = [
"memchr",
"regex-automata",
"serde_core",
]
@@ -78,6 +79,31 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "crossbeam-deque"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "dirs"
version = "5.0.1"
@@ -105,6 +131,24 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "encoding_rs_io"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83"
dependencies = [
"encoding_rs",
]
[[package]]
name = "errno"
version = "0.3.14"
@@ -263,6 +307,43 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "grep-matcher"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36d7b71093325ab22d780b40d7df3066ae4aebb518ba719d38c697a8228a8023"
dependencies = [
"memchr",
]
[[package]]
name = "grep-regex"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce0c256c3ad82bcc07b812c15a45ec1d398122e8e15124f96695234db7112ef"
dependencies = [
"bstr",
"grep-matcher",
"log",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "grep-searcher"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac63295322dc48ebb20a25348147905d816318888e64f531bfc2a2bc0577dc34"
dependencies = [
"bstr",
"encoding_rs",
"encoding_rs_io",
"grep-matcher",
"log",
"memchr",
"memmap2",
]
[[package]]
name = "harness-app"
version = "0.1.0"
@@ -320,7 +401,23 @@ dependencies = [
name = "harness-tools"
version = "0.1.0"
dependencies = [
"async-trait",
"globset",
"grep-matcher",
"grep-regex",
"grep-searcher",
"harness-core",
"ignore",
"libc",
"schemars",
"serde",
"serde_json",
"shell-words",
"similar",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-util",
]
[[package]]
@@ -348,6 +445,22 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "ignore"
version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe112b004901c62c2faa11f4f75e9864e0cc5af8da71c9115d184a3aa888749f"
dependencies = [
"crossbeam-deque",
"globset",
"log",
"memchr",
"regex-automata",
"same-file",
"walkdir",
"winapi-util",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -427,6 +540,15 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memmap2"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
dependencies = [
"libc",
]
[[package]]
name = "mio"
version = "1.2.1"
@@ -617,6 +739,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -701,6 +832,12 @@ dependencies = [
"zmij",
]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]]
name = "shlex"
version = "2.0.1"
@@ -717,6 +854,12 @@ dependencies = [
"libc",
]
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "slab"
version = "0.4.12"
@@ -904,6 +1047,16 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -974,6 +1127,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "windows-link"
version = "0.2.1"
+1
View File
@@ -57,6 +57,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2"
dirs = "5"
shell-words = "1"
libc = "0.2"
tempfile = "3"
insta = "1"
wiremock = "0.6"
@@ -424,6 +424,11 @@ impl<'a> Run<'a> {
}
};
// docs/05-tools.md: cap tool output at 30k chars regardless of which tool produced it.
let output = crate::tool::truncate::truncate(&output, &self.ctx.data_dir, &call_id)
.map(|t| t.text)
.unwrap_or(output);
let final_state = if is_error {
ToolState::Error {
input,
+1
View File
@@ -122,6 +122,7 @@ pub struct ToolCtx {
pub metadata: MetadataSink,
}
#[derive(Debug)]
pub struct ToolOutput {
pub title: String,
pub output: String,
+21
View File
@@ -6,6 +6,27 @@ license.workspace = true
[dependencies]
harness-core = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true }
async-trait = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
schemars = { workspace = true }
ignore = { workspace = true }
globset = { workspace = true }
grep-searcher = { workspace = true }
grep-regex = { workspace = true }
grep-matcher = { workspace = true }
similar = { workspace = true }
shell-words = { workspace = true }
thiserror = { workspace = true }
[target.'cfg(unix)'.dependencies]
libc = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
tokio = { workspace = true, features = ["test-util", "macros"] }
[lints]
workspace = true
+215
View File
@@ -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(&params.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(&params.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()));
}
}
+181
View File
@@ -0,0 +1,181 @@
use std::path::PathBuf;
use std::time::SystemTime;
use async_trait::async_trait;
use globset::GlobBuilder;
use harness_core::tool::{Tool, ToolCtx, ToolError, ToolOutput};
use ignore::WalkBuilder;
use schemars::JsonSchema;
use serde::Deserialize;
use crate::paths;
const MAX_RESULTS: usize = 100;
#[derive(Debug, Deserialize, JsonSchema)]
struct GlobParams {
pattern: String,
path: Option<String>,
}
/// Gitignore-aware file finder — read-only, no permission ask.
pub struct GlobTool;
#[async_trait]
impl Tool for GlobTool {
fn name(&self) -> &str {
"glob"
}
fn description(&self) -> &str {
"Finds files by glob pattern (gitignore-aware), most recently modified first."
}
fn parameters(&self) -> serde_json::Value {
serde_json::to_value(schemars::schema_for!(GlobParams)).unwrap()
}
async fn execute(
&self,
input: serde_json::Value,
ctx: ToolCtx,
) -> Result<ToolOutput, ToolError> {
let params: GlobParams =
serde_json::from_value(input).map_err(|e| ToolError::Invalid(e.to_string()))?;
let root = params
.path
.as_ref()
.map(|p| paths::resolve(&ctx.cwd, p))
.unwrap_or_else(|| ctx.cwd.clone());
let pattern = params.pattern.clone();
let matcher = GlobBuilder::new(&pattern)
.literal_separator(true)
.build()
.map_err(|e| ToolError::Invalid(e.to_string()))?
.compile_matcher();
let mut matches: Vec<(PathBuf, SystemTime)> = Vec::new();
for entry in WalkBuilder::new(&root)
.hidden(false)
.require_git(false)
.build()
{
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let rel = entry.path().strip_prefix(&root).unwrap_or(entry.path());
if !matcher.is_match(rel) {
continue;
}
let mtime = entry
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.unwrap_or(SystemTime::UNIX_EPOCH);
matches.push((entry.path().to_path_buf(), mtime));
}
matches.sort_by_key(|(_, mtime)| std::cmp::Reverse(*mtime));
let truncated = matches.len() > MAX_RESULTS;
matches.truncate(MAX_RESULTS);
let mut output = matches
.iter()
.map(|(p, _)| p.display().to_string())
.collect::<Vec<_>>()
.join("\n");
if truncated {
output.push_str(
"\n\n(Results are truncated: showing first 100 results. Consider using a more specific path or pattern.)",
);
}
if output.is_empty() {
output = "(no matches)".to_string();
}
Ok(ToolOutput::new(pattern, output))
}
}
#[cfg(test)]
mod tests {
use super::*;
use harness_core::event::EventBus;
use harness_core::permission::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));
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,
}
}
#[tokio::test]
async fn finds_files_matching_pattern() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.rs"), "").unwrap();
std::fs::write(dir.path().join("b.txt"), "").unwrap();
let output = GlobTool
.execute(
serde_json::json!({"pattern": "*.rs"}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap();
assert!(output.output.ends_with("a.rs"));
assert!(!output.output.contains("b.txt"));
}
#[tokio::test]
async fn respects_gitignore() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".gitignore"), "ignored.rs\n").unwrap();
std::fs::write(dir.path().join("ignored.rs"), "").unwrap();
std::fs::write(dir.path().join("kept.rs"), "").unwrap();
let output = GlobTool
.execute(
serde_json::json!({"pattern": "*.rs"}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap();
assert!(output.output.contains("kept.rs"));
assert!(!output.output.contains("ignored.rs"));
}
#[tokio::test]
async fn no_matches_reports_clearly() {
let dir = tempfile::tempdir().unwrap();
let output = GlobTool
.execute(
serde_json::json!({"pattern": "*.nonexistent"}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap();
assert_eq!(output.output, "(no matches)");
}
}
+209
View File
@@ -0,0 +1,209 @@
use std::path::PathBuf;
use std::time::SystemTime;
use async_trait::async_trait;
use globset::GlobBuilder;
use grep_regex::RegexMatcher;
use grep_searcher::sinks::UTF8;
use grep_searcher::SearcherBuilder;
use harness_core::tool::{Tool, ToolCtx, ToolError, ToolOutput};
use ignore::WalkBuilder;
use schemars::JsonSchema;
use serde::Deserialize;
use crate::paths;
const MAX_RESULTS: usize = 100;
#[derive(Debug, Deserialize, JsonSchema)]
struct GrepParams {
pattern: String,
path: Option<String>,
include: Option<String>,
}
struct Hit {
file: PathBuf,
line_number: u64,
line: String,
mtime: SystemTime,
}
/// Content search via ripgrep's engine as a library — read-only, no permission ask.
pub struct GrepTool;
#[async_trait]
impl Tool for GrepTool {
fn name(&self) -> &str {
"grep"
}
fn description(&self) -> &str {
"Searches file contents by regex (gitignore-aware), most recently modified first."
}
fn parameters(&self) -> serde_json::Value {
serde_json::to_value(schemars::schema_for!(GrepParams)).unwrap()
}
async fn execute(
&self,
input: serde_json::Value,
ctx: ToolCtx,
) -> Result<ToolOutput, ToolError> {
let params: GrepParams =
serde_json::from_value(input).map_err(|e| ToolError::Invalid(e.to_string()))?;
let root = params
.path
.as_ref()
.map(|p| paths::resolve(&ctx.cwd, p))
.unwrap_or_else(|| ctx.cwd.clone());
let matcher =
RegexMatcher::new(&params.pattern).map_err(|e| ToolError::Invalid(e.to_string()))?;
let include_matcher = params
.include
.as_ref()
.map(|inc| GlobBuilder::new(inc).literal_separator(true).build())
.transpose()
.map_err(|e| ToolError::Invalid(e.to_string()))?
.map(|g| g.compile_matcher());
let mut hits: Vec<Hit> = Vec::new();
for entry in WalkBuilder::new(&root)
.hidden(false)
.require_git(false)
.build()
{
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let rel = entry.path().strip_prefix(&root).unwrap_or(entry.path());
if let Some(glob) = &include_matcher {
if !glob.is_match(rel) {
continue;
}
}
let mtime = entry
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.unwrap_or(SystemTime::UNIX_EPOCH);
let path = entry.path().to_path_buf();
let mut searcher = SearcherBuilder::new().line_number(true).build();
let _ = searcher.search_path(
&matcher,
&path,
UTF8(|line_number, line| {
hits.push(Hit {
file: path.clone(),
line_number,
line: line.trim_end().to_string(),
mtime,
});
Ok(true)
}),
);
}
hits.sort_by_key(|h| std::cmp::Reverse(h.mtime));
let truncated = hits.len() > MAX_RESULTS;
hits.truncate(MAX_RESULTS);
let mut output = hits
.iter()
.map(|h| format!("{}:{}:{}", h.file.display(), h.line_number, h.line))
.collect::<Vec<_>>()
.join("\n");
if truncated {
output.push_str("\n\n(Results are truncated: showing first 100 matches. Consider a more specific pattern.)");
}
if output.is_empty() {
output = "(no matches)".to_string();
}
Ok(ToolOutput::new(params.pattern, output))
}
}
#[cfg(test)]
mod tests {
use super::*;
use harness_core::event::EventBus;
use harness_core::permission::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));
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,
}
}
#[tokio::test]
async fn finds_matching_lines() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.rs"), "fn main() {}\nfn helper() {}\n").unwrap();
let output = GrepTool
.execute(
serde_json::json!({"pattern": "fn main"}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap();
assert!(output.output.contains("a.rs:1:fn main() {}"));
assert!(!output.output.contains("helper"));
}
#[tokio::test]
async fn filters_by_include_glob() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.rs"), "needle\n").unwrap();
std::fs::write(dir.path().join("b.txt"), "needle\n").unwrap();
let output = GrepTool
.execute(
serde_json::json!({"pattern": "needle", "include": "*.rs"}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap();
assert!(output.output.contains("a.rs"));
assert!(!output.output.contains("b.txt"));
}
#[tokio::test]
async fn no_matches_reports_clearly() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.rs"), "content\n").unwrap();
let output = GrepTool
.execute(
serde_json::json!({"pattern": "nonexistent"}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap();
assert_eq!(output.output, "(no matches)");
}
}
+25 -1
View File
@@ -1 +1,25 @@
// Built-in tools (bash, read, edit, write, glob, grep, todo, webfetch, task) land here in M1/M4.
mod bash;
mod glob;
mod grep;
mod paths;
mod read;
mod write;
pub use bash::BashTool;
pub use glob::GlobTool;
pub use grep::GrepTool;
pub use read::ReadTool;
pub use write::WriteTool;
use std::sync::Arc;
use harness_core::tool::ToolRegistry;
/// Registers the M1 built-ins (`edit` is added separately once its replacer chain lands).
pub fn register_builtins(registry: &mut ToolRegistry) {
registry.register(Arc::new(ReadTool));
registry.register(Arc::new(WriteTool));
registry.register(Arc::new(BashTool));
registry.register(Arc::new(GlobTool));
registry.register(Arc::new(GrepTool));
}
+52
View File
@@ -0,0 +1,52 @@
use std::path::{Path, PathBuf};
/// Resolves a (possibly relative) tool-supplied path against `cwd`.
pub fn resolve(cwd: &Path, file_path: &str) -> PathBuf {
let path = Path::new(file_path);
if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
}
}
/// Slash-separated path relative to `cwd`, used as the permission pattern — falls back to
/// the absolute path (still slash-separated) when `path` isn't under `cwd`.
pub fn relative_pattern(cwd: &Path, path: &Path) -> String {
let rel = path.strip_prefix(cwd).unwrap_or(path);
rel.to_string_lossy().replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_joins_relative_paths_to_cwd() {
let cwd = Path::new("/home/user/project");
assert_eq!(
resolve(cwd, "src/main.rs"),
PathBuf::from("/home/user/project/src/main.rs")
);
}
#[test]
fn resolve_leaves_absolute_paths_untouched() {
let cwd = Path::new("/home/user/project");
assert_eq!(resolve(cwd, "/etc/hosts"), PathBuf::from("/etc/hosts"));
}
#[test]
fn relative_pattern_strips_cwd_prefix() {
let cwd = Path::new("/home/user/project");
let path = Path::new("/home/user/project/src/main.rs");
assert_eq!(relative_pattern(cwd, path), "src/main.rs");
}
#[test]
fn relative_pattern_falls_back_to_absolute_outside_cwd() {
let cwd = Path::new("/home/user/project");
let path = Path::new("/etc/hosts");
assert_eq!(relative_pattern(cwd, path), "/etc/hosts");
}
}
+202
View File
@@ -0,0 +1,202 @@
use async_trait::async_trait;
use harness_core::tool::{Tool, ToolCtx, ToolError, ToolOutput};
use schemars::JsonSchema;
use serde::Deserialize;
use crate::paths;
const DEFAULT_LIMIT: usize = 2000;
const LINE_CHAR_CLAMP: usize = 2000;
const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "svg"];
#[derive(Debug, Deserialize, JsonSchema)]
struct ReadParams {
file_path: String,
offset: Option<usize>,
limit: Option<usize>,
}
pub struct ReadTool;
fn is_binary(bytes: &[u8]) -> bool {
bytes.iter().take(8000).any(|&b| b == 0)
}
fn clamp_line(line: &str) -> String {
if line.chars().count() <= LINE_CHAR_CLAMP {
line.to_string()
} else {
let clamped: String = line.chars().take(LINE_CHAR_CLAMP).collect();
format!("{clamped}... [line truncated]")
}
}
#[async_trait]
impl Tool for ReadTool {
fn name(&self) -> &str {
"read"
}
fn description(&self) -> &str {
"Reads a file from the local filesystem, returning `cat -n`-style numbered lines."
}
fn parameters(&self) -> serde_json::Value {
serde_json::to_value(schemars::schema_for!(ReadParams)).unwrap()
}
async fn execute(
&self,
input: serde_json::Value,
ctx: ToolCtx,
) -> Result<ToolOutput, ToolError> {
let params: ReadParams =
serde_json::from_value(input).map_err(|e| ToolError::Invalid(e.to_string()))?;
let path = paths::resolve(&ctx.cwd, &params.file_path);
let pattern = paths::relative_pattern(&ctx.cwd, &path);
ctx.ask
.ask(
"read",
pattern.clone(),
pattern,
serde_json::json!({"file_path": params.file_path}),
)
.await?;
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if IMAGE_EXTENSIONS.contains(&ext.to_lowercase().as_str()) {
return Ok(ToolOutput::new(
params.file_path.clone(),
format!(
"{}: image file — binary content not shown",
params.file_path
),
));
}
}
let bytes = tokio::fs::read(&path)
.await
.map_err(|e| ToolError::Other(format!("{}: {e}", path.display())))?;
if is_binary(&bytes) {
return Ok(ToolOutput::new(
params.file_path.clone(),
format!("{}: binary file — content not shown", params.file_path),
));
}
let text = String::from_utf8_lossy(&bytes);
let offset = params.offset.unwrap_or(0);
let limit = params.limit.unwrap_or(DEFAULT_LIMIT);
let numbered: Vec<String> = text
.lines()
.enumerate()
.skip(offset)
.take(limit)
.map(|(i, line)| format!("{:>6}\t{}", i + 1, clamp_line(line)))
.collect();
if numbered.is_empty() {
return Ok(ToolOutput::new(
params.file_path.clone(),
"(empty file or offset past end)".to_string(),
));
}
Ok(ToolOutput::new(
params.file_path.clone(),
numbered.join("\n"),
))
}
}
#[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,
}
}
#[tokio::test]
async fn reads_and_numbers_lines() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.txt"), "line one\nline two\nline three").unwrap();
let output = ReadTool
.execute(
serde_json::json!({"file_path": "a.txt"}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap();
assert_eq!(
output.output,
" 1\tline one\n 2\tline two\n 3\tline three"
);
}
#[tokio::test]
async fn respects_offset_and_limit() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.txt"), "a\nb\nc\nd\ne").unwrap();
let output = ReadTool
.execute(
serde_json::json!({"file_path": "a.txt", "offset": 1, "limit": 2}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap();
assert_eq!(output.output, " 2\tb\n 3\tc");
}
#[tokio::test]
async fn detects_binary_content() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("bin.dat"), [0u8, 1, 2, 3]).unwrap();
let output = ReadTool
.execute(
serde_json::json!({"file_path": "bin.dat"}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap();
assert!(output.output.contains("binary file"));
}
#[tokio::test]
async fn missing_file_is_a_tool_error() {
let dir = tempfile::tempdir().unwrap();
let err = ReadTool
.execute(
serde_json::json!({"file_path": "missing.txt"}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap_err();
assert!(matches!(err, ToolError::Other(_)));
}
}
+150
View File
@@ -0,0 +1,150 @@
use async_trait::async_trait;
use harness_core::tool::{Tool, ToolCtx, ToolError, ToolOutput};
use schemars::JsonSchema;
use serde::Deserialize;
use similar::TextDiff;
use crate::paths;
#[derive(Debug, Deserialize, JsonSchema)]
struct WriteParams {
file_path: String,
content: String,
}
pub struct WriteTool;
fn unified_diff(before: &str, after: &str, file_path: &str) -> String {
TextDiff::from_lines(before, after)
.unified_diff()
.header(&format!("a/{file_path}"), &format!("b/{file_path}"))
.to_string()
}
#[async_trait]
impl Tool for WriteTool {
fn name(&self) -> &str {
"write"
}
fn description(&self) -> &str {
"Writes content to a file, creating it (and parent directories) if needed."
}
fn parameters(&self) -> serde_json::Value {
serde_json::to_value(schemars::schema_for!(WriteParams)).unwrap()
}
async fn execute(
&self,
input: serde_json::Value,
ctx: ToolCtx,
) -> Result<ToolOutput, ToolError> {
let params: WriteParams =
serde_json::from_value(input).map_err(|e| ToolError::Invalid(e.to_string()))?;
let path = paths::resolve(&ctx.cwd, &params.file_path);
let pattern = paths::relative_pattern(&ctx.cwd, &path);
let before = tokio::fs::read_to_string(&path).await.unwrap_or_default();
let diff = unified_diff(&before, &params.content, &params.file_path);
// Same permission key as opencode's edit tool — writing is an edit.
ctx.ask
.ask(
"edit",
pattern,
"*",
serde_json::json!({"file_path": params.file_path, "diff": diff}),
)
.await?;
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| ToolError::Other(format!("{}: {e}", parent.display())))?;
}
tokio::fs::write(&path, &params.content)
.await
.map_err(|e| ToolError::Other(format!("{}: {e}", path.display())))?;
let added = diff
.lines()
.filter(|l| l.starts_with('+') && !l.starts_with("+++"))
.count();
let removed = diff
.lines()
.filter(|l| l.starts_with('-') && !l.starts_with("---"))
.count();
let mut output = ToolOutput::new(
params.file_path.clone(),
format!("wrote {} bytes", params.content.len()),
);
output.metadata = serde_json::json!({"diff": diff, "added": added, "removed": removed});
Ok(output)
}
}
#[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,
}
}
#[tokio::test]
async fn creates_file_and_parent_dirs() {
let dir = tempfile::tempdir().unwrap();
WriteTool
.execute(
serde_json::json!({"file_path": "nested/dir/a.txt", "content": "hello"}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap();
let content = std::fs::read_to_string(dir.path().join("nested/dir/a.txt")).unwrap();
assert_eq!(content, "hello");
}
#[tokio::test]
async fn overwrites_existing_file_and_reports_diff() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.txt"), "old\n").unwrap();
let output = WriteTool
.execute(
serde_json::json!({"file_path": "a.txt", "content": "new\n"}),
ctx(dir.path().to_path_buf()),
)
.await
.unwrap();
let content = std::fs::read_to_string(dir.path().join("a.txt")).unwrap();
assert_eq!(content, "new\n");
assert_eq!(output.metadata["added"], 1);
assert_eq!(output.metadata["removed"], 1);
}
}