From 80c1f48f0e1e34a254b09c12808198beaeab319e Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 30 Aug 2026 19:50:57 +0900 Subject: [PATCH] fix: allow Grep to target a file --- crates/fs-operation/src/lib.rs | 147 +++++++++++++++++++++++ crates/fs-operation/src/search.rs | 8 +- crates/tools/src/grep.rs | 4 +- crates/workdir/src/local.rs | 2 +- crates/worker-runtime/src/http_server.rs | 38 +++++- 5 files changed, 191 insertions(+), 8 deletions(-) diff --git a/crates/fs-operation/src/lib.rs b/crates/fs-operation/src/lib.rs index 53999081..21bbe2ab 100644 --- a/crates/fs-operation/src/lib.rs +++ b/crates/fs-operation/src/lib.rs @@ -280,6 +280,153 @@ mod tests { assert!(!grep.output.contains("c.txt")); } + #[test] + fn grep_accepts_a_direct_file_without_searching_siblings() { + let temp = tempfile::tempdir().unwrap(); + let selected = temp.path().join("selected.txt"); + std::fs::write(&selected, "before\nneedle selected\nafter\n").unwrap(); + std::fs::write(temp.path().join("sibling.txt"), "needle sibling\n").unwrap(); + let root = temp.path().canonicalize().unwrap(); + let readable = RootAccess(root.clone()); + + let direct = run_grep( + &root, + selected, + GrepRequest { + pattern: "needle".to_string(), + path: FsPath::new("selected.txt").unwrap(), + glob: None, + file_type: None, + case_insensitive: false, + before_context: 1, + after_context: 1, + multiline: false, + output_mode: GrepOutputMode::Content, + limit: 10, + offset: 0, + }, + &readable, + ) + .unwrap(); + + assert_eq!(direct.match_count, 1); + assert_eq!(direct.matched_files, 1); + assert_eq!( + direct.output, + concat!( + "selected.txt\n", + " 1 │ before\n", + " > 2 │ needle selected\n", + " 3 │ after\n", + ) + ); + assert!(!direct.output.contains("sibling")); + + let directory = run_grep( + &root, + root.clone(), + GrepRequest { + pattern: "needle".to_string(), + path: FsPath::root(), + glob: None, + file_type: None, + case_insensitive: false, + before_context: 0, + after_context: 0, + multiline: false, + output_mode: GrepOutputMode::Content, + limit: 10, + offset: 0, + }, + &readable, + ) + .unwrap(); + assert_eq!(directory.match_count, 2); + assert_eq!(directory.matched_files, 2); + } + + #[test] + fn grep_returns_not_found_for_a_missing_direct_path() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let missing = root.join("missing.txt"); + let readable = RootAccess(root.clone()); + + let error = run_grep( + &root, + missing.clone(), + GrepRequest { + pattern: "needle".to_string(), + path: FsPath::new("missing.txt").unwrap(), + glob: None, + file_type: None, + case_insensitive: false, + before_context: 0, + after_context: 0, + multiline: false, + output_mode: GrepOutputMode::Content, + limit: 10, + offset: 0, + }, + &readable, + ) + .unwrap_err(); + + assert!(matches!(error, FsError::NotFound(path) if path == missing)); + } + + #[cfg(unix)] + #[test] + fn grep_keeps_direct_symlink_directory_and_broken_path_guards() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let readable = RootAccess(root.clone()); + std::fs::create_dir(root.join("target-dir")).unwrap(); + symlink(root.join("target-dir"), root.join("directory-link")).unwrap(); + symlink(root.join("missing-target"), root.join("broken-link")).unwrap(); + + let request = |path: &str| GrepRequest { + pattern: "needle".to_string(), + path: FsPath::new(path).unwrap(), + glob: None, + file_type: None, + case_insensitive: false, + before_context: 0, + after_context: 0, + multiline: false, + output_mode: GrepOutputMode::Content, + limit: 10, + offset: 0, + }; + + let directory_error = run_grep( + &root, + root.join("directory-link"), + request("directory-link"), + &readable, + ) + .unwrap_err(); + assert!(matches!( + directory_error, + FsError::SymlinkDirectoryNotTraversed { tool: "Grep", path, .. } + if path == root.join("directory-link") + )); + + let broken_error = run_grep( + &root, + root.join("broken-link"), + request("broken-link"), + &readable, + ) + .unwrap_err(); + assert!(matches!( + broken_error, + FsError::BrokenSymlink { path, .. } if path == root.join("broken-link") + )); + } + #[test] fn grep_content_groups_lines_by_file_and_marks_matches() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/fs-operation/src/search.rs b/crates/fs-operation/src/search.rs index 54420f6f..5a61d18b 100644 --- a/crates/fs-operation/src/search.rs +++ b/crates/fs-operation/src/search.rs @@ -221,13 +221,15 @@ pub fn run_grep( std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()), _ => FsError::io(&base, e), })?; - if !base_meta.is_dir() { + if !base_meta.is_file() && !base_meta.is_dir() { return Err(FsError::InvalidArgument(format!( - "grep search path is not a directory: {}", + "grep search path must be a regular file or directory: {}", base.display() ))); } - if let Some(info) = symlink.as_ref() { + if base_meta.is_dir() + && let Some(info) = symlink.as_ref() + { return Err(FsError::SymlinkDirectoryNotTraversed { tool: "Grep", path: base.clone(), diff --git a/crates/tools/src/grep.rs b/crates/tools/src/grep.rs index 27c26ad0..785470d4 100644 --- a/crates/tools/src/grep.rs +++ b/crates/tools/src/grep.rs @@ -22,7 +22,7 @@ enum OutputMode { #[derive(Debug, Deserialize, JsonSchema)] struct GrepParams { pattern: String, - /// Logical Workdir-relative path to search. Defaults to the Workdir root. + /// Logical Workdir-relative file or directory to search. Defaults to the Workdir root. #[serde(default)] path: Option, #[serde(default)] @@ -129,7 +129,7 @@ pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition { Arc::new(move || { let schema = schemars::schema_for!(GrepParams); let meta = ToolMeta::new("Grep") - .description("Search Workdir file contents with a regex. Content results group lines by file; `>` marks matching lines and unmarked lines are context. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.") + .description("Search a Workdir file or directory with a regex. Content results group lines by file; `>` marks matching lines and unmarked lines are context. Directory traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.") .input_schema(serde_json::to_value(schema).expect("Grep schema serialization")); let tool: Arc = Arc::new(GrepTool { session: session.clone(), diff --git a/crates/workdir/src/local.rs b/crates/workdir/src/local.rs index 16686cb4..ea500718 100644 --- a/crates/workdir/src/local.rs +++ b/crates/workdir/src/local.rs @@ -1943,7 +1943,7 @@ mod tests { &workdir, GrepRequest { pattern: "NEEDLE".into(), - path: WorkdirPath::root(), + path: WorkdirPath::new("src/main.rs").unwrap(), glob: None, file_type: None, case_insensitive: false, diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 20b9e442..a5fd8cb6 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -1886,8 +1886,8 @@ mod tests { use manifest::{Scope, SharedScope}; use tower::ServiceExt; use workdir::{ - LocalWorkdirSession, ReadRequest, StatRequest, Workdir, WorkdirPath, - WorkdirSessionCapabilities, + GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, StatRequest, Workdir, + WorkdirPath, WorkdirSessionCapabilities, }; fn test_bundle(profile: ProfileSelector) -> ConfigBundle { @@ -2348,6 +2348,40 @@ mod tests { .expect("owned operation"); assert!(matches!(result, WorkdirSessionOperationResult::Stat(_))); + let grep = WorkdirSessionOperationRequest { + delegations: Vec::new(), + operation: WorkdirSessionOperation::Grep(GrepRequest { + pattern: "hello".into(), + path: WorkdirPath::new("hello.txt").unwrap(), + glob: None, + file_type: None, + case_insensitive: false, + before_context: 0, + after_context: 0, + multiline: false, + output_mode: GrepOutputMode::Content, + limit: 10, + offset: 0, + }), + }; + let Json(result) = run_workdir_session_operation( + State(state.clone()), + Path("session-1".to_string()), + Some(Extension(auth.clone())), + Ok(Json(grep)), + ) + .await + .expect("grep direct file through provider operation"); + match result { + WorkdirSessionOperationResult::Grep(result) => { + assert_eq!(result.match_count, 1); + assert_eq!(result.matched_files, 1); + assert!(result.output.starts_with("hello.txt\n")); + assert!(result.output.contains("> 1 │ hello")); + } + other => panic!("unexpected workdir grep result: {other:?}"), + } + #[cfg(unix)] { let delegated_visible = WorkdirSessionOperationRequest {