diff --git a/Cargo.lock b/Cargo.lock index 699db4e..7bed947 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 53a5975..e901c8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/harness-core/src/engine/processor.rs b/crates/harness-core/src/engine/processor.rs index b771a25..e66d81c 100644 --- a/crates/harness-core/src/engine/processor.rs +++ b/crates/harness-core/src/engine/processor.rs @@ -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, diff --git a/crates/harness-core/src/tool/mod.rs b/crates/harness-core/src/tool/mod.rs index 9c06e08..92a9433 100644 --- a/crates/harness-core/src/tool/mod.rs +++ b/crates/harness-core/src/tool/mod.rs @@ -122,6 +122,7 @@ pub struct ToolCtx { pub metadata: MetadataSink, } +#[derive(Debug)] pub struct ToolOutput { pub title: String, pub output: String, diff --git a/crates/harness-tools/Cargo.toml b/crates/harness-tools/Cargo.toml index c6b3e04..69a756c 100644 --- a/crates/harness-tools/Cargo.toml +++ b/crates/harness-tools/Cargo.toml @@ -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 diff --git a/crates/harness-tools/src/bash.rs b/crates/harness-tools/src/bash.rs new file mode 100644 index 0000000..257c39d --- /dev/null +++ b/crates/harness-tools/src/bash.rs @@ -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, + cwd: Option, + #[allow(dead_code)] + description: String, +} + +pub struct BashTool; + +/// The "always allow" pattern is coarser than the exact command: opencode/docs/05-tools.md +/// grants ` *` (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 { + 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())); + } +} diff --git a/crates/harness-tools/src/glob.rs b/crates/harness-tools/src/glob.rs new file mode 100644 index 0000000..2e6bdcb --- /dev/null +++ b/crates/harness-tools/src/glob.rs @@ -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, +} + +/// 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 { + 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::>() + .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)"); + } +} diff --git a/crates/harness-tools/src/grep.rs b/crates/harness-tools/src/grep.rs new file mode 100644 index 0000000..f9556ae --- /dev/null +++ b/crates/harness-tools/src/grep.rs @@ -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, + include: Option, +} + +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 { + 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(¶ms.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 = 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::>() + .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)"); + } +} diff --git a/crates/harness-tools/src/lib.rs b/crates/harness-tools/src/lib.rs index 31b3852..5a16ca7 100644 --- a/crates/harness-tools/src/lib.rs +++ b/crates/harness-tools/src/lib.rs @@ -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)); +} diff --git a/crates/harness-tools/src/paths.rs b/crates/harness-tools/src/paths.rs new file mode 100644 index 0000000..01eb777 --- /dev/null +++ b/crates/harness-tools/src/paths.rs @@ -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"); + } +} diff --git a/crates/harness-tools/src/read.rs b/crates/harness-tools/src/read.rs new file mode 100644 index 0000000..5e20a6c --- /dev/null +++ b/crates/harness-tools/src/read.rs @@ -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, + limit: Option, +} + +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 { + let params: ReadParams = + serde_json::from_value(input).map_err(|e| ToolError::Invalid(e.to_string()))?; + let path = paths::resolve(&ctx.cwd, ¶ms.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 = 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(_))); + } +} diff --git a/crates/harness-tools/src/write.rs b/crates/harness-tools/src/write.rs new file mode 100644 index 0000000..a3268db --- /dev/null +++ b/crates/harness-tools/src/write.rs @@ -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 { + let params: WriteParams = + serde_json::from_value(input).map_err(|e| ToolError::Invalid(e.to_string()))?; + let path = paths::resolve(&ctx.cwd, ¶ms.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, ¶ms.content, ¶ms.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, ¶ms.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); + } +}