From 3ace9fca4a58cdbbec7d29d7963b22de9ad1332b Mon Sep 17 00:00:00 2001 From: ADD-SP Date: Thu, 27 Aug 2026 15:49:42 +0000 Subject: [PATCH 1/5] feat(file-search): add bounded content search primitive --- codex-rs/Cargo.lock | 5 + codex-rs/file-search/Cargo.toml | 5 + codex-rs/file-search/src/content.rs | 377 ++++++++++++++++++++++ codex-rs/file-search/src/content_tests.rs | 305 +++++++++++++++++ codex-rs/file-search/src/lib.rs | 2 + codex-rs/file-system/src/lib.rs | 66 ++++ 6 files changed, 760 insertions(+) create mode 100644 codex-rs/file-search/src/content.rs create mode 100644 codex-rs/file-search/src/content_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b1c6a2ac96e8..b8183f0d7d6b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3225,14 +3225,19 @@ version = "0.0.0" dependencies = [ "anyhow", "clap", + "codex-file-system", + "codex-utils-path-uri", "crossbeam-channel", + "globset", "ignore", "nucleo", "pretty_assertions", + "regex", "serde", "serde_json", "tempfile", "tokio", + "tokio-util", ] [[package]] diff --git a/codex-rs/file-search/Cargo.toml b/codex-rs/file-search/Cargo.toml index e235898982f6..51e64ce475d6 100644 --- a/codex-rs/file-search/Cargo.toml +++ b/codex-rs/file-search/Cargo.toml @@ -20,11 +20,16 @@ workspace = true anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } crossbeam-channel = { workspace = true } +codex-file-system = { workspace = true } +codex-utils-path-uri = { workspace = true } +globset = { workspace = true } ignore = { workspace = true } nucleo = { workspace = true } +regex = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["full"] } +tokio-util = { workspace = true, features = ["rt"] } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/file-search/src/content.rs b/codex-rs/file-search/src/content.rs new file mode 100644 index 000000000000..a11931b18cfc --- /dev/null +++ b/codex-rs/file-search/src/content.rs @@ -0,0 +1,377 @@ +use codex_file_system::FileSearchCaseMode; +use codex_file_system::FileSearchMatch; +use codex_file_system::FileSearchMode; +use codex_file_system::FileSearchOptions; +use codex_file_system::FileSearchOutcome; +use codex_file_system::MAX_FILE_SEARCH_EXCERPT_CHARS; +use codex_file_system::MAX_FILE_SEARCH_RESULTS; +use codex_file_system::WalkError; +use codex_utils_path_uri::PathUri; +use globset::Glob; +use globset::GlobSet; +use globset::GlobSetBuilder; +use ignore::WalkBuilder; +use regex::Regex; +use regex::RegexBuilder; +use serde::Serialize; +use std::fs::File; +use std::io; +use std::io::BufRead; +use std::io::BufReader; +use std::io::Read; +use std::path::Path; +use std::path::PathBuf; +use tokio_util::sync::CancellationToken; + +const MAX_ENTRIES_VISITED: usize = 100_000; +const MAX_LINE_BYTES: u64 = 1024 * 1024; +const MAX_RECORDED_ERRORS: usize = 2; +const MAX_RESPONSE_BYTES: usize = 1024 * 1024; +const RESPONSE_ENVELOPE_OVERHEAD_BYTES: usize = 512; + +pub fn search_files( + root: &Path, + options: &FileSearchOptions, + cancelled: &CancellationToken, +) -> io::Result { + validate_options(options)?; + let metadata = std::fs::metadata(root)?; + if !metadata.is_dir() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "file search path must be a directory", + )); + } + + let include = build_glob_set(&options.include)?; + let exclude = build_glob_set(&options.exclude)?; + let matcher = build_matcher(options)?; + let mut outcome = FileSearchOutcome::default(); + let (mut paths, walk_truncated) = collect_paths( + root, + options, + cancelled, + MAX_ENTRIES_VISITED, + &mut outcome.errors, + &mut outcome.skipped_unreadable_entries, + )?; + outcome.truncated = walk_truncated; + paths.retain(|path| path_selected(root, path, include.as_ref(), exclude.as_ref())); + paths.sort_by_key(|left| normalized_relative_path(root, left)); + let mut response_bytes = RESPONSE_ENVELOPE_OVERHEAD_BYTES; + for error in &outcome.errors { + response_bytes = response_bytes.saturating_add(serialized_item_bytes(error)?); + } + + if options.mode == FileSearchMode::List { + outcome.truncated |= paths.len() > options.max_results; + for path in paths.into_iter().take(options.max_results) { + let path = path_uri(&path)?; + let item_bytes = serialized_item_bytes(&path)?; + if response_bytes.saturating_add(item_bytes) > MAX_RESPONSE_BYTES { + outcome.truncated = true; + break; + } + response_bytes += item_bytes; + outcome.files.push(path); + } + return Ok(outcome); + } + + let Some(matcher) = matcher else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "search mode did not produce a matcher", + )); + }; + for path in paths { + check_cancelled(cancelled)?; + let remaining_results = options.max_results.saturating_sub(outcome.matches.len()); + let file_matches = match search_one_file( + &path, + &matcher, + cancelled, + remaining_results.saturating_add(1), + ) { + Ok(FileSearchFileOutcome::Matches(matches)) => matches, + Ok(FileSearchFileOutcome::Binary) => { + outcome.skipped_binary_files += 1; + continue; + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => return Err(error), + Err(error) => { + outcome.skipped_unreadable_entries += 1; + if outcome.errors.len() < MAX_RECORDED_ERRORS { + let error = WalkError { + path: path_uri(&path)?, + message: error.to_string(), + }; + let item_bytes = serialized_item_bytes(&error)?; + if response_bytes.saturating_add(item_bytes) > MAX_RESPONSE_BYTES { + outcome.truncated = true; + return Ok(outcome); + } + response_bytes += item_bytes; + outcome.errors.push(error); + } + continue; + } + }; + for (index, (line_number, excerpt)) in file_matches.into_iter().enumerate() { + if index == remaining_results { + outcome.truncated = true; + return Ok(outcome); + } + let item = FileSearchMatch { + path: path_uri(&path)?, + line_number, + excerpt, + }; + let item_bytes = serialized_item_bytes(&item)?; + if response_bytes.saturating_add(item_bytes) > MAX_RESPONSE_BYTES { + outcome.truncated = true; + return Ok(outcome); + } + response_bytes += item_bytes; + outcome.matches.push(item); + } + } + Ok(outcome) +} + +fn serialized_item_bytes(item: &impl Serialize) -> io::Result { + serde_json::to_vec(item) + .map(|serialized| serialized.len().saturating_add(1)) + .map_err(io::Error::other) +} + +fn validate_options(options: &FileSearchOptions) -> io::Result<()> { + if options.max_results == 0 || options.max_results > MAX_FILE_SEARCH_RESULTS { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("max_results must be between 1 and {MAX_FILE_SEARCH_RESULTS}"), + )); + } + match options.mode { + FileSearchMode::Keyword | FileSearchMode::Regex => { + if options.query.as_deref().is_none_or(str::is_empty) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "query is required for keyword and regex search", + )); + } + if options.case_mode.is_none() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "case_mode is required for keyword and regex search", + )); + } + } + FileSearchMode::List => { + if options.query.is_some() || options.case_mode.is_some() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "query and case_mode are not valid for list mode", + )); + } + } + } + Ok(()) +} + +fn build_matcher(options: &FileSearchOptions) -> io::Result> { + let Some(query) = options.query.as_deref() else { + return Ok(None); + }; + let pattern = match options.mode { + FileSearchMode::Keyword => regex::escape(query), + FileSearchMode::Regex => query.to_string(), + FileSearchMode::List => return Ok(None), + }; + RegexBuilder::new(&pattern) + .case_insensitive(options.case_mode == Some(FileSearchCaseMode::Insensitive)) + .build() + .map(Some) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error)) +} + +fn build_glob_set(patterns: &[String]) -> io::Result> { + if patterns.is_empty() { + return Ok(None); + } + let mut builder = GlobSetBuilder::new(); + for pattern in patterns { + builder.add( + Glob::new(pattern) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?, + ); + } + builder + .build() + .map(Some) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error)) +} + +fn collect_paths( + root: &Path, + options: &FileSearchOptions, + cancelled: &CancellationToken, + max_entries: usize, + errors: &mut Vec, + skipped_unreadable_entries: &mut usize, +) -> io::Result<(Vec, bool)> { + let mut builder = WalkBuilder::new(root); + builder.follow_links(false); + builder.max_depth((!options.recursive).then_some(1)); + if options.include_ignored { + builder + .hidden(false) + .ignore(false) + .git_ignore(false) + .git_global(false) + .git_exclude(false) + .parents(false); + } else { + builder.add_custom_ignore_filename(".rgignore"); + } + + let mut paths = Vec::new(); + let mut truncated = false; + for (index, entry) in builder.build().enumerate() { + check_cancelled(cancelled)?; + if index == max_entries { + truncated = true; + break; + } + match entry { + Ok(entry) if entry.file_type().is_some_and(|kind| kind.is_file()) => { + paths.push(entry.into_path()); + } + Ok(_) => {} + Err(error) => { + *skipped_unreadable_entries += 1; + if errors.len() < MAX_RECORDED_ERRORS { + errors.push(WalkError { + path: path_uri(root)?, + message: error.to_string(), + }); + } + } + } + } + Ok((paths, truncated)) +} + +fn path_selected( + root: &Path, + path: &Path, + include: Option<&GlobSet>, + exclude: Option<&GlobSet>, +) -> bool { + let relative = normalized_relative_path(root, path); + include.is_none_or(|set| set.is_match(&relative)) + && !exclude.is_some_and(|set| set.is_match(&relative)) +} + +fn normalized_relative_path(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/") +} + +enum FileSearchFileOutcome { + Matches(Vec<(u64, String)>), + Binary, +} + +fn search_one_file( + path: &Path, + matcher: &Regex, + cancelled: &CancellationToken, + max_matches: usize, +) -> io::Result { + let mut reader = BufReader::new(File::open(path)?); + let mut matches = Vec::new(); + let mut line = Vec::new(); + let mut line_number = 0u64; + loop { + check_cancelled(cancelled)?; + line.clear(); + let read = reader + .by_ref() + .take(MAX_LINE_BYTES + 1) + .read_until(b'\n', &mut line)?; + if read == 0 { + break; + } + if read as u64 > MAX_LINE_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("line exceeds {MAX_LINE_BYTES} byte search limit"), + )); + } + line_number += 1; + if line.contains(&0) { + return Ok(FileSearchFileOutcome::Binary); + } + let Ok(text) = std::str::from_utf8(&line) else { + return Ok(FileSearchFileOutcome::Binary); + }; + let text = text.trim_end_matches(['\r', '\n']); + if let Some(found) = matcher.find(text) { + matches.push(( + line_number, + excerpt_around_match(text, found.start(), found.end()), + )); + if matches.len() == max_matches { + break; + } + } + } + Ok(FileSearchFileOutcome::Matches(matches)) +} + +fn excerpt_around_match(text: &str, match_start: usize, match_end: usize) -> String { + let chars = text.chars().collect::>(); + if chars.len() <= MAX_FILE_SEARCH_EXCERPT_CHARS { + return text.to_string(); + } + let match_start = text[..match_start].chars().count(); + let match_end = text[..match_end].chars().count(); + let content_chars = MAX_FILE_SEARCH_EXCERPT_CHARS.saturating_sub(2); + let match_center = match_start + match_end.saturating_sub(match_start) / 2; + let start = match_center.saturating_sub(content_chars / 2); + let end = (start + content_chars).min(chars.len()); + let start = end.saturating_sub(content_chars); + let mut excerpt = String::new(); + if start > 0 { + excerpt.push('…'); + } + excerpt.extend(chars[start..end].iter()); + if end < chars.len() { + excerpt.push('…'); + } + excerpt +} + +fn path_uri(path: &Path) -> io::Result { + PathUri::from_host_native_path(path) +} + +fn check_cancelled(cancelled: &CancellationToken) -> io::Result<()> { + if cancelled.is_cancelled() { + Err(io::Error::new( + io::ErrorKind::Interrupted, + "file search cancelled", + )) + } else { + Ok(()) + } +} + +#[cfg(test)] +#[path = "content_tests.rs"] +mod tests; diff --git a/codex-rs/file-search/src/content_tests.rs b/codex-rs/file-search/src/content_tests.rs new file mode 100644 index 000000000000..4abae5e03e4a --- /dev/null +++ b/codex-rs/file-search/src/content_tests.rs @@ -0,0 +1,305 @@ +use super::*; +use codex_file_system::FileSearchCaseMode; +use codex_file_system::FileSearchMode; +use pretty_assertions::assert_eq; +use std::fs; +use tempfile::TempDir; + +fn options(mode: FileSearchMode) -> FileSearchOptions { + FileSearchOptions { + mode, + query: None, + case_mode: None, + recursive: true, + include: Vec::new(), + exclude: Vec::new(), + include_ignored: false, + max_results: 100, + } +} + +fn relative_paths(root: &PathUri, outcome: &FileSearchOutcome) -> Vec { + outcome + .files + .iter() + .map(|path| path.relative_path_from(root).expect("descendant path")) + .collect() +} + +#[test] +fn keyword_and_regex_search_respect_case_mode() -> io::Result<()> { + let temp = TempDir::new()?; + fs::write(temp.path().join("sample.txt"), "Alpha\nalpha\nbeta-42\n")?; + let mut keyword = options(FileSearchMode::Keyword); + keyword.query = Some("alpha".to_string()); + keyword.case_mode = Some(FileSearchCaseMode::Sensitive); + + let sensitive = search_files(temp.path(), &keyword, &CancellationToken::new())?; + assert_eq!( + sensitive + .matches + .iter() + .map(|item| (item.line_number, item.excerpt.as_str())) + .collect::>(), + vec![(2, "alpha")] + ); + + keyword.case_mode = Some(FileSearchCaseMode::Insensitive); + let insensitive = search_files(temp.path(), &keyword, &CancellationToken::new())?; + assert_eq!( + insensitive + .matches + .iter() + .map(|item| item.line_number) + .collect::>(), + vec![1, 2] + ); + + let mut regex = options(FileSearchMode::Regex); + regex.query = Some(r"beta-\d+".to_string()); + regex.case_mode = Some(FileSearchCaseMode::Sensitive); + let regex_outcome = search_files(temp.path(), ®ex, &CancellationToken::new())?; + assert_eq!(regex_outcome.matches[0].excerpt, "beta-42"); + Ok(()) +} + +#[test] +fn keyword_is_literal_and_invalid_patterns_fail() -> io::Result<()> { + let temp = TempDir::new()?; + fs::write(temp.path().join("sample.txt"), "a+b\naaab\n")?; + let mut keyword = options(FileSearchMode::Keyword); + keyword.query = Some("a+b".to_string()); + keyword.case_mode = Some(FileSearchCaseMode::Sensitive); + let outcome = search_files(temp.path(), &keyword, &CancellationToken::new())?; + assert_eq!(outcome.matches.len(), 1); + assert_eq!(outcome.matches[0].excerpt, "a+b"); + + let mut regex = options(FileSearchMode::Regex); + regex.query = Some("[".to_string()); + regex.case_mode = Some(FileSearchCaseMode::Sensitive); + assert_eq!( + search_files(temp.path(), ®ex, &CancellationToken::new()) + .expect_err("invalid regex") + .kind(), + io::ErrorKind::InvalidInput + ); + + let mut list = options(FileSearchMode::List); + list.include = vec!["[".to_string()]; + assert_eq!( + search_files(temp.path(), &list, &CancellationToken::new()) + .expect_err("invalid glob") + .kind(), + io::ErrorKind::InvalidInput + ); + Ok(()) +} + +#[test] +fn list_respects_recursion_globs_and_ignore_overrides() -> io::Result<()> { + let temp = TempDir::new()?; + fs::create_dir_all(temp.path().join("nested"))?; + fs::write(temp.path().join("root.rs"), "")?; + fs::write(temp.path().join("root.txt"), "")?; + fs::write(temp.path().join("nested/keep.rs"), "")?; + fs::write(temp.path().join("nested/drop.rs"), "")?; + fs::write(temp.path().join(".ignore"), "root.txt\n")?; + fs::write(temp.path().join(".rgignore"), "nested/keep.rs\n")?; + let root = PathUri::from_host_native_path(temp.path())?; + let mut list = options(FileSearchMode::List); + list.include = vec!["*.rs".to_string()]; + list.exclude = vec!["**/drop.rs".to_string()]; + + let recursive = search_files(temp.path(), &list, &CancellationToken::new())?; + assert_eq!(relative_paths(&root, &recursive), vec!["root.rs"]); + + list.recursive = false; + let shallow = search_files(temp.path(), &list, &CancellationToken::new())?; + assert_eq!(relative_paths(&root, &shallow), vec!["root.rs"]); + + list.recursive = true; + list.include.clear(); + list.include_ignored = true; + let ignored = search_files(temp.path(), &list, &CancellationToken::new())?; + let ignored_paths = relative_paths(&root, &ignored); + assert!(ignored_paths.contains(&"root.txt".to_string())); + assert!(ignored_paths.contains(&"nested/keep.rs".to_string())); + Ok(()) +} + +#[test] +fn binary_files_and_result_limits_are_reported() -> io::Result<()> { + let temp = TempDir::new()?; + fs::write(temp.path().join("binary.bin"), b"needle\0tail")?; + let text = format!( + "needle\nneedle\n{}", + "x".repeat(MAX_LINE_BYTES as usize + 1) + ); + fs::write(temp.path().join("text.txt"), text)?; + let mut search = options(FileSearchMode::Keyword); + search.query = Some("needle".to_string()); + search.case_mode = Some(FileSearchCaseMode::Sensitive); + search.max_results = 1; + + let outcome = search_files(temp.path(), &search, &CancellationToken::new())?; + assert_eq!(outcome.skipped_binary_files, 1); + assert_eq!(outcome.matches.len(), 1); + assert!(outcome.truncated); + Ok(()) +} + +#[test] +fn traversal_limit_counts_directories() -> io::Result<()> { + let temp = TempDir::new()?; + fs::create_dir_all(temp.path().join("nested"))?; + fs::write(temp.path().join("nested/file.txt"), "")?; + let list = options(FileSearchMode::List); + let mut errors = Vec::new(); + let mut skipped_unreadable_entries = 0; + + let (paths, truncated) = collect_paths( + temp.path(), + &list, + &CancellationToken::new(), + 1, + &mut errors, + &mut skipped_unreadable_entries, + )?; + + assert!(paths.is_empty()); + assert!(truncated); + assert!(errors.is_empty()); + assert_eq!(skipped_unreadable_entries, 0); + Ok(()) +} + +#[test] +fn list_limit_and_cancellation_are_reported() -> io::Result<()> { + let temp = TempDir::new()?; + fs::write(temp.path().join("a.txt"), "")?; + fs::write(temp.path().join("b.txt"), "")?; + let mut list = options(FileSearchMode::List); + list.max_results = 1; + let outcome = search_files(temp.path(), &list, &CancellationToken::new())?; + assert_eq!(outcome.files.len(), 1); + assert!(outcome.truncated); + + let cancelled = CancellationToken::new(); + cancelled.cancel(); + assert_eq!( + search_files(temp.path(), &list, &cancelled) + .expect_err("cancelled search") + .kind(), + io::ErrorKind::Interrupted + ); + Ok(()) +} + +#[test] +fn content_search_stops_when_cancelled_in_flight() -> io::Result<()> { + let temp = TempDir::new()?; + fs::write(temp.path().join("large.txt"), "hay\n".repeat(4_000_000))?; + let mut search = options(FileSearchMode::Keyword); + search.query = Some("needle".to_string()); + search.case_mode = Some(FileSearchCaseMode::Sensitive); + let cancelled = CancellationToken::new(); + let cancel_from_thread = cancelled.clone(); + let canceller = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(1)); + cancel_from_thread.cancel(); + }); + + let result = search_files(temp.path(), &search, &cancelled); + canceller.join().expect("cancellation thread"); + + assert_eq!( + result + .expect_err("in-flight search should be cancelled") + .kind(), + io::ErrorKind::Interrupted + ); + Ok(()) +} + +#[test] +fn long_excerpt_keeps_the_match_and_is_bounded() -> io::Result<()> { + let temp = TempDir::new()?; + let line = format!("{}needle{}", "界".repeat(400), "文".repeat(400)); + fs::write(temp.path().join("long.txt"), line)?; + let mut search = options(FileSearchMode::Keyword); + search.query = Some("needle".to_string()); + search.case_mode = Some(FileSearchCaseMode::Sensitive); + + let outcome = search_files(temp.path(), &search, &CancellationToken::new())?; + let excerpt = &outcome.matches[0].excerpt; + assert!(excerpt.contains("needle")); + assert!(excerpt.chars().count() <= MAX_FILE_SEARCH_EXCERPT_CHARS); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn directory_symlinks_are_not_followed_and_unreadable_files_are_summarized() -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + + let temp = TempDir::new()?; + let outside = TempDir::new()?; + fs::write(outside.path().join("outside.txt"), "needle")?; + std::os::unix::fs::symlink(outside.path(), temp.path().join("linked"))?; + let unreadable = temp.path().join("unreadable.txt"); + fs::write(&unreadable, "needle")?; + fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o000))?; + + let mut search = options(FileSearchMode::Keyword); + search.query = Some("needle".to_string()); + search.case_mode = Some(FileSearchCaseMode::Sensitive); + let outcome = search_files(temp.path(), &search, &CancellationToken::new()); + fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o600))?; + let outcome = outcome?; + + assert!(outcome.matches.is_empty()); + assert_eq!(outcome.errors.len(), 1); + Ok(()) +} + +#[test] +fn oversized_lines_are_bounded_as_recoverable_errors() -> io::Result<()> { + let temp = TempDir::new()?; + fs::write( + temp.path().join("oversized.txt"), + vec![b'a'; MAX_LINE_BYTES as usize + 1], + )?; + let mut search = options(FileSearchMode::Keyword); + search.query = Some("a".to_string()); + search.case_mode = Some(FileSearchCaseMode::Sensitive); + + let outcome = search_files(temp.path(), &search, &CancellationToken::new())?; + assert!(outcome.matches.is_empty()); + assert_eq!(outcome.errors.len(), 1); + Ok(()) +} + +#[test] +fn structured_response_has_a_hard_byte_limit() -> io::Result<()> { + let temp = TempDir::new()?; + let line = format!("x{}\n", "\u{1}".repeat(MAX_FILE_SEARCH_EXCERPT_CHARS - 1)); + fs::write( + temp.path().join("many.txt"), + line.repeat(MAX_FILE_SEARCH_RESULTS), + )?; + let mut search = options(FileSearchMode::Keyword); + search.query = Some("x".to_string()); + search.case_mode = Some(FileSearchCaseMode::Sensitive); + search.max_results = MAX_FILE_SEARCH_RESULTS; + + let outcome = search_files(temp.path(), &search, &CancellationToken::new())?; + assert!(outcome.truncated); + assert!(outcome.matches.len() < MAX_FILE_SEARCH_RESULTS); + assert!( + serde_json::to_vec(&outcome) + .map_err(io::Error::other)? + .len() + <= MAX_RESPONSE_BYTES + ); + Ok(()) +} diff --git a/codex-rs/file-search/src/lib.rs b/codex-rs/file-search/src/lib.rs index bcad11f770d1..1e8c9837efc4 100644 --- a/codex-rs/file-search/src/lib.rs +++ b/codex-rs/file-search/src/lib.rs @@ -35,8 +35,10 @@ use nucleo::pattern::AtomKind; use nucleo::pattern::Pattern; mod cli; +mod content; pub use cli::Cli; +pub use content::search_files; /// A single match result returned from the search. /// diff --git a/codex-rs/file-system/src/lib.rs b/codex-rs/file-system/src/lib.rs index a921bbd1c503..573579391d1a 100644 --- a/codex-rs/file-system/src/lib.rs +++ b/codex-rs/file-system/src/lib.rs @@ -43,6 +43,57 @@ pub const MAX_WALK_ENTRIES: usize = 50_000; pub const MAX_WALK_RESPONSE_BYTES: usize = 4 * 1024 * 1024; /// Per-entry or per-error overhead charged to the walk response budget. pub const WALK_RESPONSE_ITEM_OVERHEAD_BYTES: usize = 64; +/// Maximum number of search results accepted by the filesystem search API. +pub const MAX_FILE_SEARCH_RESULTS: usize = 1_000; +/// Maximum characters retained for one matching line excerpt. +pub const MAX_FILE_SEARCH_EXCERPT_CHARS: usize = 300; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum FileSearchMode { + Keyword, + Regex, + List, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum FileSearchCaseMode { + Sensitive, + Insensitive, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileSearchOptions { + pub mode: FileSearchMode, + pub query: Option, + pub case_mode: Option, + pub recursive: bool, + pub include: Vec, + pub exclude: Vec, + pub include_ignored: bool, + pub max_results: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileSearchMatch { + pub path: PathUri, + pub line_number: u64, + pub excerpt: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileSearchOutcome { + pub files: Vec, + pub matches: Vec, + pub skipped_binary_files: usize, + pub skipped_unreadable_entries: usize, + pub errors: Vec, + pub truncated: bool, +} #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct ReadFileOptions { @@ -540,6 +591,21 @@ pub trait ExecutorFileSystem: Send + Sync { sandbox: Option<&'a FileSystemSandboxContext>, ) -> ExecutorFileSystemFuture<'a, WalkOutcome>; + /// Searches file contents or lists files below a directory. + fn search<'a>( + &'a self, + _path: &'a PathUri, + _options: FileSearchOptions, + _sandbox: Option<&'a FileSystemSandboxContext>, + ) -> ExecutorFileSystemFuture<'a, FileSearchOutcome> { + Box::pin(async { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "filesystem search is not supported", + )) + }) + } + fn remove<'a>( &'a self, path: &'a PathUri, From ec996de04869963d32643877ed8b7ff5cf533213 Mon Sep 17 00:00:00 2001 From: ADD-SP Date: Thu, 27 Aug 2026 15:49:54 +0000 Subject: [PATCH 2/5] feat(exec-server): expose filesystem search RPC --- codex-rs/Cargo.lock | 1 + codex-rs/exec-server-protocol/src/protocol.rs | 65 +++++++ codex-rs/exec-server/Cargo.toml | 1 + codex-rs/exec-server/src/client.rs | 16 ++ codex-rs/exec-server/src/fs_helper.rs | 22 +++ codex-rs/exec-server/src/lib.rs | 7 + codex-rs/exec-server/src/local_file_system.rs | 54 ++++++ .../exec-server/src/remote_file_system.rs | 30 +++ codex-rs/exec-server/src/rpc.rs | 98 +++++++++- .../exec-server/src/sandboxed_file_system.rs | 33 ++++ .../src/server/file_system_handler.rs | 12 ++ codex-rs/exec-server/src/server/handler.rs | 10 + codex-rs/exec-server/src/server/registry.rs | 8 + .../src/server/request_dispatcher.rs | 84 ++++++++- .../src/server/request_dispatcher_tests.rs | 175 ++++++++++++++++++ .../exec-server/tests/file_system/shared.rs | 140 ++++++++++++++ 16 files changed, 751 insertions(+), 5 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b8183f0d7d6b..4e727a9781f9 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3027,6 +3027,7 @@ dependencies = [ "codex-config", "codex-exec-server-protocol", "codex-exec-server-test-support", + "codex-file-search", "codex-file-system", "codex-http-client", "codex-network-proxy", diff --git a/codex-rs/exec-server-protocol/src/protocol.rs b/codex-rs/exec-server-protocol/src/protocol.rs index 95213a63d978..8540b5376e7c 100644 --- a/codex-rs/exec-server-protocol/src/protocol.rs +++ b/codex-rs/exec-server-protocol/src/protocol.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; use std::sync::Arc; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +pub use codex_file_system::FileSearchOptions; +pub use codex_file_system::FileSearchOutcome; use codex_file_system::FileSystemSandboxContext; pub use codex_file_system::WalkOptions; pub use codex_file_system::WalkOutcome; @@ -15,6 +17,7 @@ use serde::Deserialize; use serde::Serialize; use crate::ProcessId; +use crate::rpc::RequestId; pub const INITIALIZE_METHOD: &str = "initialize"; pub const INITIALIZED_METHOD: &str = "initialized"; @@ -38,6 +41,8 @@ pub const FS_GET_METADATA_METHOD: &str = "fs/getMetadata"; pub const FS_CANONICALIZE_METHOD: &str = "fs/canonicalize"; pub const FS_READ_DIRECTORY_METHOD: &str = "fs/readDirectory"; pub const FS_WALK_METHOD: &str = "fs/walk"; +pub const FS_SEARCH_METHOD: &str = "fs/search"; +pub const FS_SEARCH_CANCEL_METHOD: &str = "fs/searchCancel"; pub const FS_REMOVE_METHOD: &str = "fs/remove"; pub const FS_COPY_METHOD: &str = "fs/copy"; /// Discovers capability manifests below selected roots using executor-local filesystem access. @@ -128,6 +133,9 @@ pub struct EnvironmentCapabilities { /// Whether filesystem streams can use the requested platform sandbox. #[serde(default)] pub sandboxed_file_streaming: bool, + /// Whether this executor supports bounded filesystem content search. + #[serde(default)] + pub file_search: bool, /// Whether shell state can be cached and restored entirely inside the executor. #[serde(default)] pub shell_snapshot_v2: bool, @@ -217,6 +225,7 @@ impl EnvironmentInfo { environment_config_read: true, http_header_env_vars: true, sandboxed_file_streaming: true, + file_search: true, shell_snapshot_v2: cfg!(unix), }, } @@ -551,6 +560,22 @@ pub struct FsWalkParams { pub type FsWalkResponse = WalkOutcome; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FsSearchParams { + pub path: PathUri, + pub options: FileSearchOptions, + pub sandbox: Option, +} + +pub type FsSearchResponse = FileSearchOutcome; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FsSearchCancelParams { + pub request_id: RequestId, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FsRemoveParams { @@ -867,10 +892,12 @@ mod tests { use super::ExecParams; use super::ExecResponse; use super::FsReadFileParams; + use super::FsSearchParams; use super::HttpRequestParams; use super::ProcessId; use super::ProcessSandboxType; use super::ShellInfo; + use codex_file_system::FileSearchOptions; use codex_file_system::FileSystemSandboxContext; use codex_network_proxy::ManagedNetworkSandboxContext; use codex_network_proxy::NetworkProxyAuditMetadata; @@ -996,11 +1023,48 @@ mod tests { environment_config_read: false, http_header_env_vars: false, sandboxed_file_streaming: false, + file_search: false, shell_snapshot_v2: false, } ); } + #[test] + fn file_search_params_use_path_uris_and_camel_case_options() { + let params = FsSearchParams { + path: PathUri::parse("file:///workspace").expect("path URI"), + options: FileSearchOptions { + mode: codex_file_system::FileSearchMode::Keyword, + query: Some("needle".to_string()), + case_mode: Some(codex_file_system::FileSearchCaseMode::Sensitive), + recursive: true, + include: vec!["*.rs".to_string()], + exclude: Vec::new(), + include_ignored: false, + max_results: 100, + }, + sandbox: None, + }; + + assert_eq!( + serde_json::to_value(params).expect("serialize search params"), + serde_json::json!({ + "path": "file:///workspace", + "options": { + "mode": "keyword", + "query": "needle", + "caseMode": "sensitive", + "recursive": true, + "include": ["*.rs"], + "exclude": [], + "includeIgnored": false, + "maxResults": 100 + }, + "sandbox": null + }) + ); + } + #[test] fn environment_info_preserves_executor_temporary_directories() { let expected = serde_json::json!({ @@ -1013,6 +1077,7 @@ mod tests { "environmentConfigRead": false, "httpHeaderEnvVars": false, "sandboxedFileStreaming": false, + "fileSearch": false, "shellSnapshotV2": false, }, }); diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 746bfb231d61..e54531b7ca2d 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -20,6 +20,7 @@ codex-api = { workspace = true } codex-config = { workspace = true } codex-http-client = { workspace = true } codex-exec-server-protocol = { workspace = true } +codex-file-search = { workspace = true } codex-file-system = { workspace = true } codex-network-proxy = { workspace = true } codex-otel = { workspace = true } diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index bd758cdd9ca3..04555c1d76b5 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -76,6 +76,8 @@ use crate::protocol::FS_READ_BLOCK_METHOD; use crate::protocol::FS_READ_DIRECTORY_METHOD; use crate::protocol::FS_READ_FILE_METHOD; use crate::protocol::FS_REMOVE_METHOD; +use crate::protocol::FS_SEARCH_CANCEL_METHOD; +use crate::protocol::FS_SEARCH_METHOD; use crate::protocol::FS_WALK_METHOD; use crate::protocol::FS_WRITE_FILE_METHOD; use crate::protocol::FsCanonicalizeParams; @@ -98,6 +100,8 @@ use crate::protocol::FsReadFileParams; use crate::protocol::FsReadFileResponse; use crate::protocol::FsRemoveParams; use crate::protocol::FsRemoveResponse; +use crate::protocol::FsSearchParams; +use crate::protocol::FsSearchResponse; use crate::protocol::FsWalkParams; use crate::protocol::FsWalkResponse; use crate::protocol::FsWriteFileParams; @@ -899,6 +903,18 @@ impl ExecServerClient { self.call(FS_WALK_METHOD, ¶ms).await } + pub async fn fs_search( + &self, + params: FsSearchParams, + ) -> Result { + let rpc_client = self.rpc_client().await?; + self.map_rpc_call_result( + rpc_client + .call_cancellable(FS_SEARCH_METHOD, ¶ms, FS_SEARCH_CANCEL_METHOD) + .await, + ) + } + pub async fn fs_remove( &self, params: FsRemoveParams, diff --git a/codex-rs/exec-server/src/fs_helper.rs b/codex-rs/exec-server/src/fs_helper.rs index ad6eacf93cfd..fa554c33bc8c 100644 --- a/codex-rs/exec-server/src/fs_helper.rs +++ b/codex-rs/exec-server/src/fs_helper.rs @@ -24,6 +24,7 @@ use crate::protocol::FS_OPEN_METHOD; use crate::protocol::FS_READ_DIRECTORY_METHOD; use crate::protocol::FS_READ_FILE_METHOD; use crate::protocol::FS_REMOVE_METHOD; +use crate::protocol::FS_SEARCH_METHOD; use crate::protocol::FS_WALK_METHOD; use crate::protocol::FS_WRITE_FILE_METHOD; use crate::protocol::FsCanonicalizeParams; @@ -41,6 +42,8 @@ use crate::protocol::FsReadFileParams; use crate::protocol::FsReadFileResponse; use crate::protocol::FsRemoveParams; use crate::protocol::FsRemoveResponse; +use crate::protocol::FsSearchParams; +use crate::protocol::FsSearchResponse; use crate::protocol::FsWalkParams; use crate::protocol::FsWalkResponse; use crate::protocol::FsWriteFileParams; @@ -72,6 +75,8 @@ pub(crate) enum FsHelperRequest { ReadDirectory(FsReadDirectoryParams), #[serde(rename = "fs/walk")] Walk(FsWalkParams), + #[serde(rename = "fs/search")] + Search(FsSearchParams), #[serde(rename = "fs/remove")] Remove(FsRemoveParams), #[serde(rename = "fs/copy")] @@ -117,6 +122,8 @@ pub(crate) enum FsHelperPayload { ReadDirectory(FsReadDirectoryResponse), #[serde(rename = "fs/walk")] Walk(FsWalkResponse), + #[serde(rename = "fs/search")] + Search(FsSearchResponse), #[serde(rename = "fs/remove")] Remove(FsRemoveResponse), #[serde(rename = "fs/copy")] @@ -135,6 +142,7 @@ impl FsHelperPayload { Self::Canonicalize(_) => FS_CANONICALIZE_METHOD, Self::ReadDirectory(_) => FS_READ_DIRECTORY_METHOD, Self::Walk(_) => FS_WALK_METHOD, + Self::Search(_) => FS_SEARCH_METHOD, Self::Remove(_) => FS_REMOVE_METHOD, Self::Copy(_) => FS_COPY_METHOD, } @@ -217,6 +225,13 @@ impl FsHelperPayload { } } + pub(crate) fn expect_search(self) -> Result { + match self { + Self::Search(response) => Ok(response), + other => Err(unexpected_response(FS_SEARCH_METHOD, other.operation())), + } + } + pub(crate) fn expect_remove(self) -> Result { match self { Self::Remove(response) => Ok(response), @@ -354,6 +369,13 @@ pub(crate) async fn run_direct_request( .map_err(map_fs_error)?; Ok(FsHelperPayload::Walk(outcome)) } + FsHelperRequest::Search(params) => { + let outcome = file_system + .search(¶ms.path, params.options, /*sandbox*/ None) + .await + .map_err(map_fs_error)?; + Ok(FsHelperPayload::Search(outcome)) + } FsHelperRequest::Remove(params) => { file_system .remove( diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index f0e23038bbd8..3421992b8d36 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -72,6 +72,11 @@ pub use codex_file_system::ExecutorFileSystem; pub use codex_file_system::ExecutorFileSystemFuture; pub use codex_file_system::FILE_READ_CHUNK_SIZE; pub use codex_file_system::FileMetadata; +pub use codex_file_system::FileSearchCaseMode; +pub use codex_file_system::FileSearchMatch; +pub use codex_file_system::FileSearchMode; +pub use codex_file_system::FileSearchOptions; +pub use codex_file_system::FileSearchOutcome; pub use codex_file_system::FileSystemReadStream; pub use codex_file_system::FileSystemResult; pub use codex_file_system::FileSystemSandboxContext; @@ -170,6 +175,8 @@ pub use protocol::FsReadFileParams; pub use protocol::FsReadFileResponse; pub use protocol::FsRemoveParams; pub use protocol::FsRemoveResponse; +pub use protocol::FsSearchParams; +pub use protocol::FsSearchResponse; pub use protocol::FsWalkParams; pub use protocol::FsWalkResponse; pub use protocol::FsWriteFileParams; diff --git a/codex-rs/exec-server/src/local_file_system.rs b/codex-rs/exec-server/src/local_file_system.rs index 66a4876d4502..6208c8b90a8d 100644 --- a/codex-rs/exec-server/src/local_file_system.rs +++ b/codex-rs/exec-server/src/local_file_system.rs @@ -25,6 +25,8 @@ use crate::ExecutorFileSystem; use crate::ExecutorFileSystemFuture; use crate::FILE_READ_CHUNK_SIZE; use crate::FileMetadata; +use crate::FileSearchOptions; +use crate::FileSearchOutcome; use crate::FileSystemReadStream; use crate::FileSystemResult; use crate::FileSystemSandboxContext; @@ -199,6 +201,16 @@ impl LocalFileSystem { file_system.walk(path, options, sandbox).await } + async fn search( + &self, + path: &PathUri, + options: FileSearchOptions, + sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult { + let (file_system, sandbox) = self.file_system_for(sandbox)?; + file_system.search(path, options, sandbox).await + } + async fn remove( &self, path: &PathUri, @@ -298,6 +310,15 @@ impl ExecutorFileSystem for LocalFileSystem { Box::pin(LocalFileSystem::walk(self, path, options, sandbox)) } + fn search<'a>( + &'a self, + path: &'a PathUri, + options: FileSearchOptions, + sandbox: Option<&'a FileSystemSandboxContext>, + ) -> ExecutorFileSystemFuture<'a, FileSearchOutcome> { + Box::pin(LocalFileSystem::search(self, path, options, sandbox)) + } + fn remove<'a>( &'a self, path: &'a PathUri, @@ -529,6 +550,20 @@ impl ExecutorFileSystem for UnsandboxedFileSystem { }) } + fn search<'a>( + &'a self, + path: &'a PathUri, + options: FileSearchOptions, + sandbox: Option<&'a FileSystemSandboxContext>, + ) -> ExecutorFileSystemFuture<'a, FileSearchOutcome> { + Box::pin(async move { + reject_platform_sandbox_context(sandbox)?; + self.file_system + .search(path, options, /*sandbox*/ None) + .await + }) + } + fn remove<'a>( &'a self, path: &'a PathUri, @@ -1026,6 +1061,25 @@ impl ExecutorFileSystem for DirectFileSystem { }) } + fn search<'a>( + &'a self, + path: &'a PathUri, + options: FileSearchOptions, + sandbox: Option<&'a FileSystemSandboxContext>, + ) -> ExecutorFileSystemFuture<'a, FileSearchOutcome> { + Box::pin(async move { + reject_sandbox_context(sandbox)?; + let path = path.to_abs_path()?; + let cancelled = CancellationToken::new(); + let _cancel_on_drop = cancelled.clone().drop_guard(); + tokio::task::spawn_blocking(move || { + codex_file_search::search_files(path.as_path(), &options, &cancelled) + }) + .await + .map_err(|err| io::Error::other(format!("filesystem task failed: {err}")))? + }) + } + fn remove<'a>( &'a self, path: &'a PathUri, diff --git a/codex-rs/exec-server/src/remote_file_system.rs b/codex-rs/exec-server/src/remote_file_system.rs index d574cece8c7e..6e458fa71242 100644 --- a/codex-rs/exec-server/src/remote_file_system.rs +++ b/codex-rs/exec-server/src/remote_file_system.rs @@ -15,6 +15,8 @@ use crate::ExecServerError; use crate::ExecutorFileSystem; use crate::ExecutorFileSystemFuture; use crate::FileMetadata; +use crate::FileSearchOptions; +use crate::FileSearchOutcome; use crate::FileSystemReadStream; use crate::FileSystemResult; use crate::FileSystemSandboxContext; @@ -33,6 +35,7 @@ use crate::protocol::FsGetMetadataParams; use crate::protocol::FsReadDirectoryParams; use crate::protocol::FsReadFileParams; use crate::protocol::FsRemoveParams; +use crate::protocol::FsSearchParams; use crate::protocol::FsWalkParams; use crate::protocol::FsWriteFileParams; @@ -261,6 +264,24 @@ impl RemoteFileSystem { .map_err(map_remote_error) } + async fn search( + &self, + path: &PathUri, + options: FileSearchOptions, + sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult { + trace!("remote fs search"); + let client = self.client.get().await.map_err(map_remote_error)?; + client + .fs_search(FsSearchParams { + path: path.clone(), + options, + sandbox: remote_sandbox_context(sandbox), + }) + .await + .map_err(map_remote_error) + } + async fn remove( &self, path: &PathUri, @@ -381,6 +402,15 @@ impl ExecutorFileSystem for RemoteFileSystem { Box::pin(RemoteFileSystem::walk(self, path, options, sandbox)) } + fn search<'a>( + &'a self, + path: &'a PathUri, + options: FileSearchOptions, + sandbox: Option<&'a FileSystemSandboxContext>, + ) -> ExecutorFileSystemFuture<'a, FileSearchOutcome> { + Box::pin(RemoteFileSystem::search(self, path, options, sandbox)) + } + fn remove<'a>( &'a self, path: &'a PathUri, diff --git a/codex-rs/exec-server/src/rpc.rs b/codex-rs/exec-server/src/rpc.rs index 83d43783445a..dd179b36999a 100644 --- a/codex-rs/exec-server/src/rpc.rs +++ b/codex-rs/exec-server/src/rpc.rs @@ -24,6 +24,7 @@ use tokio::sync::OwnedSemaphorePermit; use tokio::sync::Semaphore; use tokio::sync::SemaphorePermit; use tokio::sync::mpsc; +use tokio::sync::mpsc::error::TrySendError; use tokio::sync::oneshot; use tokio::sync::watch; use tokio::task::JoinHandle; @@ -66,6 +67,33 @@ enum RpcCallTimeout { After(Duration), } +struct CancelRequestOnDrop { + write_tx: mpsc::Sender, + notification: Option, +} + +impl CancelRequestOnDrop { + fn disarm(&mut self) { + self.notification = None; + } +} + +impl Drop for CancelRequestOnDrop { + fn drop(&mut self) { + let Some(notification) = self.notification.take() else { + return; + }; + if let Err(TrySendError::Full(notification)) = self.write_tx.try_send(notification) + && let Ok(runtime) = tokio::runtime::Handle::try_current() + { + let write_tx = self.write_tx.clone(); + runtime.spawn(async move { + let _ = write_tx.send(notification).await; + }); + } + } +} + #[derive(Debug)] pub(crate) enum RpcClientEvent { Request { @@ -531,7 +559,23 @@ impl RpcClient { T: DeserializeOwned, { let _call_slot = self.acquire_regular_call_slot()?; - self.call_inner(method, params, RpcCallTimeout::None).await + self.call_inner(method, params, RpcCallTimeout::None, None) + .await + } + + pub(crate) async fn call_cancellable( + &self, + method: &str, + params: &P, + cancel_method: &'static str, + ) -> Result + where + P: Serialize, + T: DeserializeOwned, + { + let _call_slot = self.acquire_regular_call_slot()?; + self.call_inner(method, params, RpcCallTimeout::None, Some(cancel_method)) + .await } pub(crate) async fn call_with_timeout( @@ -545,7 +589,7 @@ impl RpcClient { T: DeserializeOwned, { let _call_slot = self.acquire_regular_call_slot()?; - self.call_inner(method, params, RpcCallTimeout::After(call_timeout)) + self.call_inner(method, params, RpcCallTimeout::After(call_timeout), None) .await } @@ -578,7 +622,8 @@ impl RpcClient { } }, }; - self.call_inner(method, params, RpcCallTimeout::None).await + self.call_inner(method, params, RpcCallTimeout::None, None) + .await } async fn call_inner( @@ -586,6 +631,7 @@ impl RpcClient { method: &str, params: &P, call_timeout: RpcCallTimeout, + cancel_method: Option<&'static str>, ) -> Result where P: Serialize, @@ -627,6 +673,14 @@ impl RpcClient { return Err(RpcCallError::Closed); } + let mut cancel_on_drop = cancel_method.map(|method| CancelRequestOnDrop { + write_tx: self.write_tx.clone(), + notification: Some(JSONRPCMessage::Notification(JSONRPCNotification { + method: method.to_string(), + params: Some(serde_json::json!({ "requestId": request_id.clone() })), + })), + }); + // Do not race in-flight requests directly against the transport-close // watch value. The connection reader receives JSON-RPC messages and // the terminal disconnect event on one ordered queue, then drains any @@ -646,6 +700,9 @@ impl RpcClient { } }, }; + if let Some(cancel_on_drop) = &mut cancel_on_drop { + cancel_on_drop.disarm(); + } let result: Result = response.map_err(|_| RpcCallError::Closed)?; let response = match result { Ok(response) => response, @@ -1077,6 +1134,41 @@ mod tests { assert_eq!(call.await.expect("RPC response"), expected); } + #[tokio::test] + async fn dropping_cancellable_call_sends_request_id_notification() { + let (client_stdin, server_reader) = tokio::io::duplex(4096); + let (_server_writer, client_stdout) = tokio::io::duplex(4096); + let connection = + JsonRpcConnection::from_stdio(client_stdout, client_stdin, "test-rpc".to_string()); + let (client, _events_rx) = RpcClient::new(connection); + let mut lines = BufReader::new(server_reader).lines(); + + let params = serde_json::json!({}); + let mut call = Box::pin(client.call_cancellable::<_, serde_json::Value>( + "fs/search", + ¶ms, + "fs/searchCancel", + )); + assert!(futures::poll!(call.as_mut()).is_pending()); + let request = match read_jsonrpc_line(&mut lines).await { + JSONRPCMessage::Request(request) => request, + other => panic!("expected JSON-RPC request, got {other:?}"), + }; + + drop(call); + let cancellation = match read_jsonrpc_line(&mut lines).await { + JSONRPCMessage::Notification(notification) => notification, + other => panic!("expected JSON-RPC notification, got {other:?}"), + }; + assert_eq!( + cancellation, + JSONRPCNotification { + method: "fs/searchCancel".to_string(), + params: Some(serde_json::json!({ "requestId": request.id })), + } + ); + } + #[tokio::test] async fn rpc_client_timeout_removes_pending_request() { let (client_stdin, server_reader) = tokio::io::duplex(4096); diff --git a/codex-rs/exec-server/src/sandboxed_file_system.rs b/codex-rs/exec-server/src/sandboxed_file_system.rs index 61c3c061267b..dc1101ab0562 100644 --- a/codex-rs/exec-server/src/sandboxed_file_system.rs +++ b/codex-rs/exec-server/src/sandboxed_file_system.rs @@ -14,6 +14,8 @@ use crate::ExecutorFileSystem; use crate::ExecutorFileSystemFuture; use crate::FILE_READ_CHUNK_SIZE; use crate::FileMetadata; +use crate::FileSearchOptions; +use crate::FileSearchOutcome; use crate::FileSystemReadStream; use crate::FileSystemResult; use crate::FileSystemSandboxContext; @@ -34,6 +36,7 @@ use crate::protocol::FsGetMetadataParams; use crate::protocol::FsReadDirectoryParams; use crate::protocol::FsReadFileParams; use crate::protocol::FsRemoveParams; +use crate::protocol::FsSearchParams; use crate::protocol::FsWalkParams; use crate::protocol::FsWriteFileParams; @@ -272,6 +275,27 @@ impl SandboxedFileSystem { Ok(response) } + async fn search( + &self, + path: &PathUri, + options: FileSearchOptions, + sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult { + let sandbox = require_platform_sandbox(sandbox)?; + validate_native_path(path)?; + self.run_sandboxed( + sandbox, + FsHelperRequest::Search(FsSearchParams { + path: path.clone(), + options, + sandbox: None, + }), + ) + .await? + .expect_search() + .map_err(map_sandbox_error) + } + async fn remove( &self, path: &PathUri, @@ -405,6 +429,15 @@ impl ExecutorFileSystem for SandboxedFileSystem { Box::pin(SandboxedFileSystem::walk(self, path, options, sandbox)) } + fn search<'a>( + &'a self, + path: &'a PathUri, + options: FileSearchOptions, + sandbox: Option<&'a FileSystemSandboxContext>, + ) -> ExecutorFileSystemFuture<'a, FileSearchOutcome> { + Box::pin(SandboxedFileSystem::search(self, path, options, sandbox)) + } + fn remove<'a>( &'a self, path: &'a PathUri, diff --git a/codex-rs/exec-server/src/server/file_system_handler.rs b/codex-rs/exec-server/src/server/file_system_handler.rs index be6802e98cee..96433e199933 100644 --- a/codex-rs/exec-server/src/server/file_system_handler.rs +++ b/codex-rs/exec-server/src/server/file_system_handler.rs @@ -40,6 +40,8 @@ use crate::protocol::FsReadFileParams; use crate::protocol::FsReadFileResponse; use crate::protocol::FsRemoveParams; use crate::protocol::FsRemoveResponse; +use crate::protocol::FsSearchParams; +use crate::protocol::FsSearchResponse; use crate::protocol::FsWalkParams; use crate::protocol::FsWalkResponse; use crate::protocol::FsWriteFileParams; @@ -294,6 +296,16 @@ impl FileSystemHandler { .map_err(map_fs_error) } + pub(crate) async fn search( + &self, + params: FsSearchParams, + ) -> Result { + self.file_system + .search(¶ms.path, params.options, params.sandbox.as_ref()) + .await + .map_err(map_fs_error) + } + pub(crate) async fn remove( &self, params: FsRemoveParams, diff --git a/codex-rs/exec-server/src/server/handler.rs b/codex-rs/exec-server/src/server/handler.rs index 087ddef2bd0b..d009da8fd781 100644 --- a/codex-rs/exec-server/src/server/handler.rs +++ b/codex-rs/exec-server/src/server/handler.rs @@ -47,6 +47,8 @@ use crate::protocol::FsReadFileParams; use crate::protocol::FsReadFileResponse; use crate::protocol::FsRemoveParams; use crate::protocol::FsRemoveResponse; +use crate::protocol::FsSearchParams; +use crate::protocol::FsSearchResponse; use crate::protocol::FsWalkParams; use crate::protocol::FsWalkResponse; use crate::protocol::FsWriteFileParams; @@ -367,6 +369,14 @@ impl ExecServerHandler { self.file_system.walk(params).await } + pub(crate) async fn fs_search( + &self, + params: FsSearchParams, + ) -> Result { + self.require_initialized_for("filesystem")?; + self.file_system.search(params).await + } + pub(crate) async fn fs_remove( &self, params: FsRemoveParams, diff --git a/codex-rs/exec-server/src/server/registry.rs b/codex-rs/exec-server/src/server/registry.rs index b1a117308569..7c2889f2e3d9 100644 --- a/codex-rs/exec-server/src/server/registry.rs +++ b/codex-rs/exec-server/src/server/registry.rs @@ -22,6 +22,7 @@ use crate::protocol::FS_READ_BLOCK_METHOD; use crate::protocol::FS_READ_DIRECTORY_METHOD; use crate::protocol::FS_READ_FILE_METHOD; use crate::protocol::FS_REMOVE_METHOD; +use crate::protocol::FS_SEARCH_METHOD; use crate::protocol::FS_WALK_METHOD; use crate::protocol::FS_WRITE_FILE_METHOD; use crate::protocol::FsCanonicalizeParams; @@ -34,6 +35,7 @@ use crate::protocol::FsReadBlockParams; use crate::protocol::FsReadDirectoryParams; use crate::protocol::FsReadFileParams; use crate::protocol::FsRemoveParams; +use crate::protocol::FsSearchParams; use crate::protocol::FsWalkParams; use crate::protocol::FsWriteFileParams; use crate::protocol::HTTP_REQUEST_METHOD; @@ -176,6 +178,12 @@ pub(crate) fn build_router() -> RpcRouter { handler.fs_walk(params).await }, ); + router.request( + FS_SEARCH_METHOD, + |handler: Arc, params: FsSearchParams| async move { + handler.fs_search(params).await + }, + ); router.request( FS_REMOVE_METHOD, |handler: Arc, params: FsRemoveParams| async move { diff --git a/codex-rs/exec-server/src/server/request_dispatcher.rs b/codex-rs/exec-server/src/server/request_dispatcher.rs index 5460921e685e..23b0f0178455 100644 --- a/codex-rs/exec-server/src/server/request_dispatcher.rs +++ b/codex-rs/exec-server/src/server/request_dispatcher.rs @@ -1,7 +1,9 @@ +use std::collections::HashMap; use std::num::NonZeroUsize; use std::num::ParseIntError; use std::str::FromStr; use std::sync::Arc; +use std::sync::Mutex as StdMutex; use std::time::Instant; use codex_exec_server_protocol::JSONRPCError; @@ -13,6 +15,7 @@ use tokio::sync::Semaphore; use tokio::sync::mpsc; use tokio::sync::watch; use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; use tracing::Instrument; use tracing::debug; use tracing::warn; @@ -22,6 +25,9 @@ use crate::protocol::ENVIRONMENT_STATUS_METHOD; use crate::protocol::EXEC_SIGNAL_METHOD; use crate::protocol::EXEC_TERMINATE_METHOD; use crate::protocol::FS_CLOSE_METHOD; +use crate::protocol::FS_SEARCH_CANCEL_METHOD; +use crate::protocol::FS_SEARCH_METHOD; +use crate::protocol::FsSearchCancelParams; use crate::protocol::INITIALIZE_METHOD; use crate::protocol::INITIALIZED_METHOD; use crate::rpc::RpcCallError; @@ -41,10 +47,26 @@ pub(super) struct RequestDispatcher { requests: RpcServerRequestSender, telemetry: ExecServerTelemetry, lanes: Option, + inline_lane: Arc, tasks: JoinSet, + cancellable_requests: Arc>>, initialized: bool, } +struct CancellableRequestGuard { + request_id: RequestId, + requests: Arc>>, +} + +impl Drop for CancellableRequestGuard { + fn drop(&mut self) { + self.requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&self.request_id); + } +} + impl RequestDispatcher { pub(super) fn new( router: Arc>, @@ -73,7 +95,9 @@ impl RequestDispatcher { requests, telemetry, lanes, + inline_lane: Arc::new(Semaphore::new(1)), tasks: JoinSet::new(), + cancellable_requests: Arc::new(StdMutex::new(HashMap::new())), initialized: false, } } @@ -114,6 +138,28 @@ impl RequestDispatcher { &mut self, notification: JSONRPCNotification, ) -> RequestTaskResult { + if notification.method == FS_SEARCH_CANCEL_METHOD { + let params = notification.params.unwrap_or(serde_json::Value::Null); + let params = match serde_json::from_value::(params) { + Ok(params) => params, + Err(error) => { + warn!( + "closing exec-server connection after invalid search cancellation: {error}" + ); + return RequestTaskResult::ConnectionClosed; + } + }; + if let Some(cancelled) = self + .cancellable_requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(¶ms.request_id) + .cloned() + { + cancelled.cancel(); + } + return RequestTaskResult::Completed; + } let is_initialized = notification.method == INITIALIZED_METHOD; let Some(route) = self.router.notification_route(notification.method.as_str()) else { warn!( @@ -206,6 +252,20 @@ impl RequestDispatcher { }; request_span.record("otel.name", method); + let request_id = request.id.clone(); + let cancellation_response_id = request_id.clone(); + let is_cancellable = method == FS_SEARCH_METHOD; + let cancellation = is_cancellable.then(CancellationToken::new); + let cancellation_guard = cancellation.as_ref().map(|cancelled| { + self.cancellable_requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(request_id.clone(), cancelled.clone()); + CancellableRequestGuard { + request_id, + requests: Arc::clone(&self.cancellable_requests), + } + }); let route_setup_started_at = Instant::now(); let route = route(Arc::clone(&self.handler), request); let route_setup_duration = route_setup_started_at.elapsed(); @@ -213,12 +273,22 @@ impl RequestDispatcher { let mut disconnected_rx = self.disconnected_rx.clone(); let telemetry = self.telemetry.clone(); let task = async move { + let _cancellation_guard = cancellation_guard; telemetry.request_queue_completed( method, queued_at.elapsed().saturating_sub(route_setup_duration), ); let message = tokio::select! { message = route.instrument(request_span.clone()) => message, + _ = async { + match cancellation { + Some(cancelled) => cancelled.cancelled().await, + None => std::future::pending().await, + } + } => Some(RpcServerOutboundMessage::Error { + request_id: cancellation_response_id, + error: invalid_request("filesystem search cancelled".to_string()), + }), _ = disconnected_rx.changed() => { request_span.record("result", "disconnected"); telemetry.request_completed(method, "disconnected", started_at.elapsed()); @@ -244,8 +314,18 @@ impl RequestDispatcher { }; let Some(RequestLanes { ordinary, control }) = &self.lanes else { - // Keep requests ordered when concurrent dispatch is not enabled. - return task.await; + // Finish the handshake before queued requests can observe session state. + if method == INITIALIZE_METHOD || !self.initialized { + return task.await; + } + let admission = Arc::clone(&self.inline_lane); + self.tasks.spawn(async move { + let Ok(_permit) = admission.acquire_owned().await else { + return RequestTaskResult::ConnectionClosed; + }; + task.await + }); + return RequestTaskResult::Completed; }; // Finish the handshake before concurrent requests can observe session state. if method == INITIALIZE_METHOD || !self.initialized { diff --git a/codex-rs/exec-server/src/server/request_dispatcher_tests.rs b/codex-rs/exec-server/src/server/request_dispatcher_tests.rs index 3772e621ae70..29b5396880bb 100644 --- a/codex-rs/exec-server/src/server/request_dispatcher_tests.rs +++ b/codex-rs/exec-server/src/server/request_dispatcher_tests.rs @@ -1,7 +1,10 @@ use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; use std::time::Duration; use codex_exec_server_protocol::JSONRPCMessage; +use codex_exec_server_protocol::JSONRPCNotification; use codex_exec_server_protocol::JSONRPCRequest; use codex_exec_server_protocol::RequestId; use codex_http_client::HttpClientFactory; @@ -38,6 +41,14 @@ use crate::server::ExecServerHandler; use crate::server::session_registry::SessionRegistry; use crate::telemetry::ExecServerTelemetry; +struct DropNotifier(Arc); + +impl Drop for DropNotifier { + fn drop(&mut self) { + self.0.notify_one(); + } +} + /// Public limits reject values that cannot safely enable semaphore-backed concurrency. #[test] fn concurrent_request_limit_rejects_invalid_values() { @@ -78,6 +89,170 @@ fn request_dispatch_mode_parses_bounded_concurrency() { assert_eq!(max_concurrent_requests.get(), Semaphore::MAX_PERMITS); } +#[tokio::test] +async fn search_cancellation_drops_the_in_flight_route() { + let metrics = MetricsClient::new(MetricsConfig::in_memory( + "test", + "codex-exec-server", + env!("CARGO_PKG_VERSION"), + InMemoryMetricExporter::default(), + )) + .expect("metrics client"); + let telemetry = ExecServerTelemetry::new(metrics.clone()); + let (outgoing_tx, mut outgoing_rx) = mpsc::channel(/*buffer*/ 1); + let notifications = RpcNotificationSender::new(outgoing_tx.clone()); + let requests = notifications.request_sender(); + let handler = Arc::new(ExecServerHandler::new( + SessionRegistry::new(telemetry.clone()), + notifications, + ExecServerRuntimePaths::new( + std::env::current_exe().expect("current executable"), + /*codex_linux_sandbox_exe*/ None, + ) + .expect("runtime paths"), + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + )); + let dropped = Arc::new(Notify::new()); + let dropped_by_route = Arc::clone(&dropped); + let started = Arc::new(Notify::new()); + let started_by_route = Arc::clone(&started); + let started_count = Arc::new(AtomicUsize::new(0)); + let started_count_by_route = Arc::clone(&started_count); + let mut router = RpcRouter::new(); + router.request( + crate::protocol::FS_SEARCH_METHOD, + move |_handler: Arc, _params: serde_json::Value| { + let drop_notifier = DropNotifier(Arc::clone(&dropped_by_route)); + let started = Arc::clone(&started_by_route); + let started_count = Arc::clone(&started_count_by_route); + async move { + let _drop_notifier = drop_notifier; + started_count.fetch_add(1, Ordering::SeqCst); + started.notify_one(); + std::future::pending::>() + .await + } + }, + ); + let (_disconnected_tx, disconnected_rx) = watch::channel(/*init*/ false); + let mut dispatcher = RequestDispatcher::new( + Arc::new(router), + handler, + outgoing_tx, + disconnected_rx, + requests, + telemetry, + RequestDispatchMode::Inline, + ); + dispatcher.initialized = true; + let request_id = RequestId::Integer(7); + let JsonRpcConnectionEvent::QueuedRequest { + request, + request_span, + queued_at, + } = JsonRpcConnectionEvent::message(JSONRPCMessage::Request(JSONRPCRequest { + id: request_id.clone(), + method: crate::protocol::FS_SEARCH_METHOD.to_string(), + params: Some(serde_json::json!({})), + trace: None, + })) + else { + panic!("requests should be queued"); + }; + assert!(matches!( + dispatcher + .dispatch_request(request, request_span, queued_at) + .await, + RequestTaskResult::Completed + )); + timeout(Duration::from_secs(1), started.notified()) + .await + .expect("first search should start"); + + let second_request_id = RequestId::Integer(8); + let JsonRpcConnectionEvent::QueuedRequest { + request, + request_span, + queued_at, + } = JsonRpcConnectionEvent::message(JSONRPCMessage::Request(JSONRPCRequest { + id: second_request_id.clone(), + method: crate::protocol::FS_SEARCH_METHOD.to_string(), + params: Some(serde_json::json!({})), + trace: None, + })) + else { + panic!("requests should be queued"); + }; + assert!(matches!( + dispatcher + .dispatch_request(request, request_span, queued_at) + .await, + RequestTaskResult::Completed + )); + tokio::task::yield_now().await; + assert_eq!(started_count.load(Ordering::SeqCst), 1); + + assert!(matches!( + dispatcher + .handle_notification(JSONRPCNotification { + method: crate::protocol::FS_SEARCH_CANCEL_METHOD.to_string(), + params: Some(serde_json::json!({ "requestId": request_id })), + }) + .await, + RequestTaskResult::Completed + )); + assert!(matches!( + dispatcher.join_next().await, + RequestTaskResult::Completed + )); + timeout(Duration::from_secs(1), dropped.notified()) + .await + .expect("cancellation should drop the route future"); + assert!(matches!( + outgoing_rx.recv().await, + Some(RpcServerOutboundMessage::Error { + request_id: RequestId::Integer(7), + .. + }) + )); + timeout(Duration::from_secs(1), started.notified()) + .await + .expect("second search should start after cancellation"); + assert_eq!(started_count.load(Ordering::SeqCst), 2); + + assert!(matches!( + dispatcher + .handle_notification(JSONRPCNotification { + method: crate::protocol::FS_SEARCH_CANCEL_METHOD.to_string(), + params: Some(serde_json::json!({ "requestId": second_request_id })), + }) + .await, + RequestTaskResult::Completed + )); + assert!(matches!( + dispatcher.join_next().await, + RequestTaskResult::Completed + )); + timeout(Duration::from_secs(1), dropped.notified()) + .await + .expect("second cancellation should drop the route future"); + assert!(matches!( + outgoing_rx.recv().await, + Some(RpcServerOutboundMessage::Error { + request_id: RequestId::Integer(8), + .. + }) + )); + assert!( + dispatcher + .cancellable_requests + .lock() + .expect("cancellable request lock") + .is_empty() + ); + metrics.shutdown().expect("shutdown metrics"); +} + /// End-to-end request spans retain the wire method and inbound trace with bounded names. #[test] fn request_span_uses_bounded_name_wire_method_and_inbound_trace_parent() { diff --git a/codex-rs/exec-server/tests/file_system/shared.rs b/codex-rs/exec-server/tests/file_system/shared.rs index 96dfa143492f..d99192e9f5b1 100644 --- a/codex-rs/exec-server/tests/file_system/shared.rs +++ b/codex-rs/exec-server/tests/file_system/shared.rs @@ -8,6 +8,11 @@ use codex_exec_server::ExecServerRuntimePaths; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::FILE_READ_CHUNK_SIZE; use codex_exec_server::FileMetadata; +use codex_exec_server::FileSearchCaseMode; +use codex_exec_server::FileSearchMatch; +use codex_exec_server::FileSearchMode; +use codex_exec_server::FileSearchOptions; +use codex_exec_server::FileSearchOutcome; #[cfg(unix)] use codex_exec_server::LocalFileSystem; use codex_exec_server::ReadDirectoryEntry; @@ -573,6 +578,65 @@ async fn file_system_walk_returns_a_bounded_tree( Ok(()) } +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_search_returns_structured_matches( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let source_dir = tmp.path().join("source"); + let nested_dir = source_dir.join("nested"); + std::fs::create_dir_all(&nested_dir)?; + std::fs::write(source_dir.join("root.txt"), "needle at root\n")?; + std::fs::write(nested_dir.join("note.txt"), "first line\nneedle nested\n")?; + + let outcome = file_system + .search( + &PathUri::from_host_native_path(&source_dir)?, + FileSearchOptions { + mode: FileSearchMode::Keyword, + query: Some("needle".to_string()), + case_mode: Some(FileSearchCaseMode::Sensitive), + recursive: true, + include: Vec::new(), + exclude: Vec::new(), + include_ignored: false, + max_results: 10, + }, + /*sandbox*/ None, + ) + .await + .with_context(|| format!("mode={implementation}"))?; + assert_eq!( + outcome, + FileSearchOutcome { + files: Vec::new(), + matches: vec![ + FileSearchMatch { + path: PathUri::from_host_native_path(nested_dir.join("note.txt"))?, + line_number: 2, + excerpt: "needle nested".to_string(), + }, + FileSearchMatch { + path: PathUri::from_host_native_path(source_dir.join("root.txt"))?, + line_number: 1, + excerpt: "needle at root".to_string(), + }, + ], + skipped_binary_files: 0, + skipped_unreadable_entries: 0, + errors: Vec::new(), + truncated: false, + } + ); + + Ok(()) +} + #[test_case(FileSystemImplementation::Local ; "local")] #[test_case(FileSystemImplementation::Remote ; "remote")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -701,6 +765,82 @@ async fn file_system_walk_honors_read_sandbox( Ok(()) } +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_search_honors_read_sandbox( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let source_dir = tmp.path().join("source"); + let file_path = source_dir.join("note.txt"); + std::fs::create_dir_all(&source_dir)?; + std::fs::write(&file_path, "sandbox needle\n")?; + let sandbox = read_only_sandbox(source_dir.clone()); + + let outcome = file_system + .search( + &PathUri::from_host_native_path(&source_dir)?, + FileSearchOptions { + mode: FileSearchMode::Keyword, + query: Some("needle".to_string()), + case_mode: Some(FileSearchCaseMode::Sensitive), + recursive: true, + include: Vec::new(), + exclude: Vec::new(), + include_ignored: false, + max_results: 10, + }, + Some(&sandbox), + ) + .await + .with_context(|| format!("mode={implementation}"))?; + assert_eq!( + outcome, + FileSearchOutcome { + files: Vec::new(), + matches: vec![FileSearchMatch { + path: PathUri::from_host_native_path(file_path)?, + line_number: 1, + excerpt: "sandbox needle".to_string(), + }], + skipped_binary_files: 0, + skipped_unreadable_entries: 0, + errors: Vec::new(), + truncated: false, + } + ); + + let denied_dir = tmp.path().join("denied"); + std::fs::create_dir_all(&denied_dir)?; + std::fs::write(denied_dir.join("secret.txt"), "sandbox needle\n")?; + let denied = file_system + .search( + &PathUri::from_host_native_path(&denied_dir)?, + FileSearchOptions { + mode: FileSearchMode::Keyword, + query: Some("needle".to_string()), + case_mode: Some(FileSearchCaseMode::Sensitive), + recursive: true, + include: Vec::new(), + exclude: Vec::new(), + include_ignored: false, + max_results: 10, + }, + Some(&sandbox), + ) + .await; + assert!( + denied.is_err(), + "search outside the sandbox should fail for mode={implementation}" + ); + + Ok(()) +} + #[test_case(FileSystemImplementation::Local ; "local")] #[test_case(FileSystemImplementation::Remote ; "remote")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From c3d96a2b815ea320ddee2611a6fa9852c9dcf838 Mon Sep 17 00:00:00 2001 From: ADD-SP Date: Thu, 27 Aug 2026 16:14:19 +0000 Subject: [PATCH 3/5] feat(core): add experimental search file tool --- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/environment_selection.rs | 69 +++--- codex-rs/core/src/session/turn_context.rs | 3 + codex-rs/core/src/tools/handlers/mod.rs | 4 + .../core/src/tools/handlers/search_file.rs | 209 ++++++++++++++++++ .../tools/handlers/search_file_formatter.rs | 159 +++++++++++++ .../handlers/search_file_formatter_tests.rs | 97 ++++++++ .../src/tools/handlers/search_file_spec.rs | 101 +++++++++ .../tools/handlers/search_file_spec_tests.rs | 32 +++ .../src/tools/handlers/search_file_tests.rs | 62 ++++++ codex-rs/core/src/tools/spec_plan.rs | 14 ++ codex-rs/core/src/tools/spec_plan_tests.rs | 42 ++++ codex-rs/features/src/lib.rs | 8 + 13 files changed, 778 insertions(+), 28 deletions(-) create mode 100644 codex-rs/core/src/tools/handlers/search_file.rs create mode 100644 codex-rs/core/src/tools/handlers/search_file_formatter.rs create mode 100644 codex-rs/core/src/tools/handlers/search_file_formatter_tests.rs create mode 100644 codex-rs/core/src/tools/handlers/search_file_spec.rs create mode 100644 codex-rs/core/src/tools/handlers/search_file_spec_tests.rs create mode 100644 codex-rs/core/src/tools/handlers/search_file_tests.rs diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 0af689ee60c2..b8a51120fbd2 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -871,6 +871,9 @@ "runtime_metrics": { "type": "boolean" }, + "search_file": { + "type": "boolean" + }, "search_tool": { "type": "boolean" }, @@ -6024,6 +6027,9 @@ "runtime_metrics": { "type": "boolean" }, + "search_file": { + "type": "boolean" + }, "search_tool": { "type": "boolean" }, diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index 465aaf4fecf8..a0d993a248af 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -140,6 +140,7 @@ struct ResolvedEnvironment { temporary_directories: Option>, shell_snapshot: ShellSnapshotTask, shell_snapshot_v2_supported: bool, + file_search_supported: bool, installed_config: Option, } @@ -260,6 +261,7 @@ impl ThreadEnvironments { temporary_directories: environment.temporary_directories, shell_snapshot: environment.shell_snapshot, shell_snapshot_v2_supported: environment.shell_snapshot_v2_supported, + file_search_supported: environment.file_search_supported, installed_config: None, })) .boxed() @@ -623,35 +625,44 @@ impl ThreadEnvironments { }; // Resolve the attachment only after both prerequisites are ready. let ((), installed_config) = tokio::try_join!(connection_ready, configuration_ready)?; - let (shell, temporary_directories, shell_snapshot_v2_supported) = if environment.is_remote() - { - match environment.info().await { - Ok(info) => { - let temporary_directories = info.temporary_directories; - let shell_snapshot_v2_supported = info.capabilities.shell_snapshot_v2; - let shell = match Shell::from_environment_shell_info(info.shell) { - Ok(shell) => Some(shell), - Err(err) => { - tracing::warn!( - "failed to resolve shell for environment `{environment_id}`: {err}" - ); - None - } - }; - (shell, temporary_directories, shell_snapshot_v2_supported) - } - Err(err) => { - tracing::warn!("failed to get info for environment `{environment_id}`: {err}"); - (None, None, false) + let (shell, temporary_directories, shell_snapshot_v2_supported, file_search_supported) = + if environment.is_remote() { + match environment.info().await { + Ok(info) => { + let temporary_directories = info.temporary_directories; + let shell_snapshot_v2_supported = info.capabilities.shell_snapshot_v2; + let file_search_supported = info.capabilities.file_search; + let shell = match Shell::from_environment_shell_info(info.shell) { + Ok(shell) => Some(shell), + Err(err) => { + tracing::warn!( + "failed to resolve shell for environment `{environment_id}`: {err}" + ); + None + } + }; + ( + shell, + temporary_directories, + shell_snapshot_v2_supported, + file_search_supported, + ) + } + Err(err) => { + tracing::warn!( + "failed to get info for environment `{environment_id}`: {err}" + ); + (None, None, false, false) + } } - } - } else { - ( - Some(local_shell), - Some(EnvironmentInfo::local_temporary_directories()), - cfg!(unix), - ) - }; + } else { + ( + Some(local_shell), + Some(EnvironmentInfo::local_temporary_directories()), + cfg!(unix), + true, + ) + }; let task = shell_snapshot .build(Arc::clone(&environment), selection.cwd, shell.clone()) .boxed() @@ -665,6 +676,7 @@ impl ThreadEnvironments { temporary_directories, shell_snapshot: task, shell_snapshot_v2_supported, + file_search_supported, installed_config, }) } @@ -742,6 +754,7 @@ impl TurnEnvironmentState { turn_environment.shell_snapshot = environment.shell_snapshot; turn_environment.shell_snapshot_v2_supported = environment.shell_snapshot_v2_supported; + turn_environment.file_search_supported = environment.file_search_supported; turn_environment.temporary_directories = environment.temporary_directories; Some(Self::Ready(turn_environment)) } diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 04d04a7bcb01..fe159f6deaca 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -50,6 +50,7 @@ pub(crate) struct TurnEnvironment { pub(crate) shell: Option, pub(crate) shell_snapshot: ShellSnapshotTask, pub(crate) shell_snapshot_v2_supported: bool, + pub(crate) file_search_supported: bool, } impl TurnEnvironment { @@ -60,6 +61,7 @@ impl TurnEnvironment { shell: Option, ) -> Self { debug_assert!(matches!(selection.config, EnvironmentConfigState::Ready(_))); + let file_search_supported = !environment.is_remote(); Self { selection, config_origin, @@ -68,6 +70,7 @@ impl TurnEnvironment { shell, shell_snapshot: futures::future::ready(None).boxed().shared(), shell_snapshot_v2_supported: false, + file_search_supported, } } diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 1217131be27d..c71ba2ffd0ef 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -23,6 +23,9 @@ mod request_plugin_install; pub(crate) mod request_plugin_install_spec; mod request_user_input; pub(crate) mod request_user_input_spec; +mod search_file; +mod search_file_formatter; +pub(crate) mod search_file_spec; mod send_user_message_async; pub(crate) mod shell_spec; mod sleep; @@ -68,6 +71,7 @@ pub use plan::PlanHandler; pub use request_permissions::RequestPermissionsHandler; pub use request_plugin_install::RequestPluginInstallHandler; pub use request_user_input::RequestUserInputHandler; +pub(crate) use search_file::SearchFileHandler; pub use send_user_message_async::SendUserMessageAsyncHandler; pub use sleep::SleepHandler; pub use test_sync::TestSyncHandler; diff --git a/codex-rs/core/src/tools/handlers/search_file.rs b/codex-rs/core/src/tools/handlers/search_file.rs new file mode 100644 index 000000000000..f4a2ca582f78 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/search_file.rs @@ -0,0 +1,209 @@ +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; +use crate::tools::handlers::parse_arguments; +use crate::tools::handlers::resolve_tool_environment; +use crate::tools::handlers::search_file_formatter::format_search_file_error; +use crate::tools::handlers::search_file_formatter::format_search_file_output; +use crate::tools::handlers::search_file_spec::SearchFileToolOptions; +use crate::tools::handlers::search_file_spec::create_search_file_tool; +use crate::tools::registry::CoreToolRuntime; +use crate::tools::registry::ToolExecutor; +use codex_exec_server::FileSearchCaseMode; +use codex_exec_server::FileSearchMode; +use codex_exec_server::FileSearchOptions; +use codex_file_system::MAX_FILE_SEARCH_RESULTS; +use codex_tools::ToolName; +use codex_tools::ToolSpec; +use serde::Deserialize; + +const DEFAULT_SEARCH_RESULTS: usize = 100; +const DEFAULT_LIST_RESULTS: usize = 200; + +pub(crate) struct SearchFileHandler { + options: SearchFileToolOptions, +} + +impl SearchFileHandler { + pub(crate) fn new(options: SearchFileToolOptions) -> Self { + Self { options } + } +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "snake_case")] +enum SearchFileModeArg { + Keyword, + Regex, + List, +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "snake_case")] +enum SearchFileCaseModeArg { + Sensitive, + Insensitive, +} + +#[derive(Deserialize)] +struct SearchFileArgs { + mode: SearchFileModeArg, + path: String, + query: Option, + case_mode: Option, + #[serde(default = "default_recursive")] + recursive: bool, + #[serde(default)] + include: Vec, + #[serde(default)] + exclude: Vec, + #[serde(default)] + include_ignored: bool, + max_results: Option, + environment_id: Option, +} + +fn default_recursive() -> bool { + true +} + +impl ToolExecutor for SearchFileHandler { + fn tool_name(&self) -> ToolName { + ToolName::plain("search_file") + } + + fn spec(&self) -> ToolSpec { + create_search_file_tool(self.options) + } + + fn supports_parallel_tool_calls(&self) -> bool { + true + } + + fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a> + where + ToolInvocation: 'a, + { + Box::pin(async move { + let ToolPayload::Function { arguments } = &invocation.payload else { + return Err(FunctionCallError::RespondToModel( + "search_file handler received unsupported payload".to_string(), + )); + }; + let args: SearchFileArgs = + parse_arguments(arguments).map_err(bound_model_visible_error)?; + let options = validated_options(&args).map_err(bound_model_visible_error)?; + let Some(environment) = resolve_tool_environment( + &invocation.step_context.environments, + args.environment_id.as_deref(), + ) + .map_err(bound_model_visible_error)? + else { + return Err(FunctionCallError::RespondToModel( + "search_file is unavailable in this session".to_string(), + )); + }; + if !environment.file_search_supported { + return Err(model_visible_error(format!( + "search_file is not supported by environment `{}`", + environment.selection.environment_id + ))); + } + let path = environment.cwd().join(&args.path).map_err(|error| { + model_visible_error(format!( + "unable to resolve search path `{}` against environment cwd `{}`: {error}", + args.path, + environment.cwd() + )) + })?; + let sandbox = environment.sandbox_context(/*additional_permissions*/ None); + let outcome = environment + .environment + .get_filesystem() + .search(&path, options, Some(&sandbox)) + .await + .map_err(|error| model_visible_error(error.to_string()))?; + Ok(boxed_tool_output(FunctionToolOutput::from_text( + format_search_file_output(&path, &outcome), + Some(true), + ))) + }) + } +} + +fn model_visible_error(message: impl Into) -> FunctionCallError { + FunctionCallError::RespondToModel(format_search_file_error(message)) +} + +fn bound_model_visible_error(error: FunctionCallError) -> FunctionCallError { + match error { + FunctionCallError::RespondToModel(message) => model_visible_error(message), + FunctionCallError::Fatal(message) => FunctionCallError::Fatal(message), + } +} + +fn validated_options(args: &SearchFileArgs) -> Result { + let (mode, query, case_mode, default_max_results) = match args.mode { + SearchFileModeArg::Keyword | SearchFileModeArg::Regex => { + let query = args + .query + .clone() + .filter(|query| !query.is_empty()) + .ok_or_else(|| { + FunctionCallError::RespondToModel( + "query is required for keyword and regex modes".to_string(), + ) + })?; + let case_mode = args.case_mode.ok_or_else(|| { + FunctionCallError::RespondToModel( + "case_mode is required for keyword and regex modes".to_string(), + ) + })?; + ( + match args.mode { + SearchFileModeArg::Keyword => FileSearchMode::Keyword, + SearchFileModeArg::Regex => FileSearchMode::Regex, + SearchFileModeArg::List => unreachable!(), + }, + Some(query), + Some(match case_mode { + SearchFileCaseModeArg::Sensitive => FileSearchCaseMode::Sensitive, + SearchFileCaseModeArg::Insensitive => FileSearchCaseMode::Insensitive, + }), + DEFAULT_SEARCH_RESULTS, + ) + } + SearchFileModeArg::List => { + if args.query.is_some() || args.case_mode.is_some() { + return Err(FunctionCallError::RespondToModel( + "query and case_mode are not valid for list mode".to_string(), + )); + } + (FileSearchMode::List, None, None, DEFAULT_LIST_RESULTS) + } + }; + let max_results = args.max_results.unwrap_or(default_max_results); + if !(1..=MAX_FILE_SEARCH_RESULTS).contains(&max_results) { + return Err(FunctionCallError::RespondToModel(format!( + "max_results must be between 1 and {MAX_FILE_SEARCH_RESULTS}" + ))); + } + Ok(FileSearchOptions { + mode, + query, + case_mode, + recursive: args.recursive, + include: args.include.clone(), + exclude: args.exclude.clone(), + include_ignored: args.include_ignored, + max_results, + }) +} + +impl CoreToolRuntime for SearchFileHandler {} + +#[cfg(test)] +#[path = "search_file_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/search_file_formatter.rs b/codex-rs/core/src/tools/handlers/search_file_formatter.rs new file mode 100644 index 000000000000..013fdf745109 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/search_file_formatter.rs @@ -0,0 +1,159 @@ +use codex_exec_server::FileSearchMatch; +use codex_exec_server::FileSearchOutcome; +use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; +use std::collections::BTreeMap; + +const MAX_OUTPUT_BYTES: usize = 8 * 1024; +const TRUNCATION_MARKER: &str = + "\n[output truncated at 8 KiB; narrow the search or raise specificity]\n"; + +#[derive(Default)] +struct TreeNode { + directories: BTreeMap, + files: BTreeMap>, +} + +pub(crate) fn format_search_file_output(root: &PathUri, outcome: &FileSearchOutcome) -> String { + let mut tree = TreeNode::default(); + for path in &outcome.files { + insert_file(&mut tree, relative_components(root, path), None); + } + for FileSearchMatch { + path, + line_number, + excerpt, + } in &outcome.matches + { + insert_file( + &mut tree, + relative_components(root, path), + Some((*line_number, excerpt.clone())), + ); + } + + let result_count = outcome.files.len() + outcome.matches.len(); + let mut output = String::new(); + if result_count == 0 { + output.push_str("No results under "); + output.push_str(&root.inferred_native_path_string()); + output.push_str(".\n"); + } else { + output.push_str(&root.inferred_native_path_string()); + output.push(match root.infer_path_convention() { + Some(PathConvention::Windows) => '\\', + Some(PathConvention::Posix) | None => '/', + }); + output.push('\n'); + render_node(&tree, 1, &mut output); + } + if outcome.skipped_binary_files > 0 { + output.push_str(&format!( + "[skipped {} binary files]\n", + outcome.skipped_binary_files + )); + } + if outcome.skipped_unreadable_entries > 0 { + output.push_str(&format!( + "[skipped {} unreadable entries]\n", + outcome.skipped_unreadable_entries + )); + } + if outcome.truncated { + output.push_str("[results truncated by executor limits]\n"); + } + truncate_search_file_output(output) +} + +pub(crate) fn format_search_file_error(error: impl Into) -> String { + truncate_search_file_output(error.into()) +} + +fn relative_components(root: &PathUri, path: &PathUri) -> Vec { + let relative = path + .relative_path_from(root) + .unwrap_or_else(|| path.basename().unwrap_or_else(|| path.to_string())); + let separator = match path.infer_path_convention() { + Some(PathConvention::Posix) | None => '/', + Some(PathConvention::Windows) => '\\', + }; + relative + .split(separator) + .filter(|component| !component.is_empty()) + .map(ToString::to_string) + .collect() +} + +fn insert_file( + root: &mut TreeNode, + mut components: Vec, + file_match: Option<(u64, String)>, +) { + let Some(file_name) = components.pop() else { + return; + }; + let mut node = root; + for component in components { + node = node.directories.entry(component).or_default(); + } + let matches = node.files.entry(file_name).or_default(); + if let Some(file_match) = file_match { + matches.push(file_match); + } +} + +fn render_node(node: &TreeNode, depth: usize, output: &mut String) { + for (directory, child) in &node.directories { + let (collapsed, child) = collapse_directory(directory, child); + push_indent(output, depth); + output.push_str(&collapsed); + output.push_str("/\n"); + render_node(child, depth + 1, output); + } + for (file, matches) in &node.files { + push_indent(output, depth); + output.push_str(file); + output.push('\n'); + for (line_number, excerpt) in matches { + push_indent(output, depth + 1); + output.push_str(&format!("{line_number}: {excerpt}\n")); + } + } +} + +fn collapse_directory<'a>(directory: &str, mut node: &'a TreeNode) -> (String, &'a TreeNode) { + let mut collapsed = directory.to_string(); + while node.files.is_empty() && node.directories.len() == 1 { + let Some((next, child)) = node.directories.first_key_value() else { + break; + }; + collapsed.push('/'); + collapsed.push_str(next); + node = child; + } + (collapsed, node) +} + +fn push_indent(output: &mut String, depth: usize) { + for _ in 0..depth { + output.push_str(" "); + } +} + +fn truncate_search_file_output(mut output: String) -> String { + if output.len() <= MAX_OUTPUT_BYTES { + return output; + } + let keep = MAX_OUTPUT_BYTES - TRUNCATION_MARKER.len(); + let mut boundary = keep; + while !output.is_char_boundary(boundary) { + boundary -= 1; + } + output.truncate(boundary); + output.push_str(TRUNCATION_MARKER); + output +} + +#[cfg(test)] +#[path = "search_file_formatter_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/search_file_formatter_tests.rs b/codex-rs/core/src/tools/handlers/search_file_formatter_tests.rs new file mode 100644 index 000000000000..bb3df580a4a4 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/search_file_formatter_tests.rs @@ -0,0 +1,97 @@ +use super::*; +use codex_exec_server::FileSearchMatch; +use codex_exec_server::WalkError; +use pretty_assertions::assert_eq; + +fn root() -> PathUri { + PathUri::parse("file:///repo").expect("path URI") +} + +#[test] +fn groups_common_path_prefixes_and_matching_lines() { + let root = root(); + let outcome = FileSearchOutcome { + matches: vec![ + FileSearchMatch { + path: root.join("src/core/a.rs").expect("path URI"), + line_number: 4, + excerpt: "first match".to_string(), + }, + FileSearchMatch { + path: root.join("src/core/b.rs").expect("path URI"), + line_number: 9, + excerpt: "second match".to_string(), + }, + ], + ..Default::default() + }; + + assert_eq!( + format_search_file_output(&root, &outcome), + "/repo/\n src/core/\n a.rs\n 4: first match\n b.rs\n 9: second match\n" + ); +} + +#[test] +fn groups_windows_path_prefixes() { + let root = PathUri::parse("file:///C:/repo").expect("path URI"); + let outcome = FileSearchOutcome { + files: vec![ + root.join(r"src\core\a.rs").expect("path URI"), + root.join(r"src\core\b.rs").expect("path URI"), + ], + ..Default::default() + }; + + assert_eq!( + format_search_file_output(&root, &outcome), + "C:\\repo\\\n src/core/\n a.rs\n b.rs\n" + ); +} + +#[test] +fn reports_empty_results_and_recoverable_skips() { + let root = root(); + let outcome = FileSearchOutcome { + skipped_binary_files: 2, + errors: vec![WalkError { + path: root.join("private").expect("path URI"), + message: "denied".to_string(), + }], + skipped_unreadable_entries: 1, + truncated: true, + ..Default::default() + }; + + assert_eq!( + format_search_file_output(&root, &outcome), + "No results under /repo.\n[skipped 2 binary files]\n[skipped 1 unreadable entries]\n[results truncated by executor limits]\n" + ); +} + +#[test] +fn enforces_model_safe_output_limit() { + let root = root(); + let outcome = FileSearchOutcome { + matches: (0..100) + .map(|line_number| FileSearchMatch { + path: root.join("large.rs").expect("path URI"), + line_number, + excerpt: "x".repeat(300), + }) + .collect(), + ..Default::default() + }; + + let output = format_search_file_output(&root, &outcome); + assert!(output.len() <= MAX_OUTPUT_BYTES); + assert!(output.ends_with(TRUNCATION_MARKER)); +} + +#[test] +fn enforces_model_safe_error_limit() { + let output = format_search_file_error("x".repeat(MAX_OUTPUT_BYTES * 2)); + + assert!(output.len() <= MAX_OUTPUT_BYTES); + assert!(output.ends_with(TRUNCATION_MARKER)); +} diff --git a/codex-rs/core/src/tools/handlers/search_file_spec.rs b/codex-rs/core/src/tools/handlers/search_file_spec.rs new file mode 100644 index 000000000000..722325e911b3 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/search_file_spec.rs @@ -0,0 +1,101 @@ +use codex_tools::JsonSchema; +use codex_tools::ResponsesApiTool; +use codex_tools::ToolSpec; +use serde_json::json; +use std::collections::BTreeMap; + +#[derive(Clone, Copy)] +pub(crate) struct SearchFileToolOptions { + pub(crate) include_environment_id: bool, +} + +pub(crate) fn create_search_file_tool(options: SearchFileToolOptions) -> ToolSpec { + let mut properties = BTreeMap::from([ + ( + "mode".to_string(), + JsonSchema::string_enum( + vec![json!("keyword"), json!("regex"), json!("list")], + Some("Operation to perform.".to_string()), + ), + ), + ( + "path".to_string(), + JsonSchema::string(Some("Directory to search.".to_string())), + ), + ( + "query".to_string(), + JsonSchema::string(Some( + "Required for keyword and regex modes; invalid for list mode.".to_string(), + )), + ), + ( + "case_mode".to_string(), + JsonSchema::string_enum( + vec![json!("sensitive"), json!("insensitive")], + Some("Required for keyword and regex modes; invalid for list mode.".to_string()), + ), + ), + ( + "recursive".to_string(), + JsonSchema::boolean(Some( + "Whether to search recursively. Defaults to true.".to_string(), + )), + ), + ( + "include".to_string(), + JsonSchema::array( + JsonSchema::string(None), + Some("Optional glob patterns for paths to include.".to_string()), + ), + ), + ( + "exclude".to_string(), + JsonSchema::array( + JsonSchema::string(None), + Some("Optional glob patterns for paths to exclude; exclusions win.".to_string()), + ), + ), + ( + "include_ignored".to_string(), + JsonSchema::boolean(Some( + "Include hidden and ignored files. Defaults to false.".to_string(), + )), + ), + ( + "max_results".to_string(), + JsonSchema::integer(Some( + "Maximum results from 1 to 1000. Defaults to 100 matches or 200 files.".to_string(), + )), + ), + ]); + if options.include_environment_id { + properties.insert( + "environment_id".to_string(), + JsonSchema::string(Some( + "Environment id from . Omit to use the primary environment." + .to_string(), + )), + ); + } + + ToolSpec::Function(ResponsesApiTool { + name: "search_file".to_string(), + description: "Search file contents with a literal keyword or regular expression, or list files. Results are grouped by common path prefixes and bounded for compact model context." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + properties, + Some(vec!["mode".to_string(), "path".to_string()]), + Some(false.into()), + ), + output_schema: Some(json!({ + "type": "string", + "description": "Compact path tree with matching lines or listed files." + })), + }) +} + +#[cfg(test)] +#[path = "search_file_spec_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/search_file_spec_tests.rs b/codex-rs/core/src/tools/handlers/search_file_spec_tests.rs new file mode 100644 index 000000000000..f0f2d13abc75 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/search_file_spec_tests.rs @@ -0,0 +1,32 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn environment_id_is_only_exposed_for_multiple_environments() { + let ToolSpec::Function(single) = create_search_file_tool(SearchFileToolOptions { + include_environment_id: false, + }) else { + panic!("expected function tool"); + }; + let ToolSpec::Function(multiple) = create_search_file_tool(SearchFileToolOptions { + include_environment_id: true, + }) else { + panic!("expected function tool"); + }; + + assert_eq!(single.name, "search_file"); + assert!( + !single + .parameters + .properties + .expect("properties") + .contains_key("environment_id") + ); + assert!( + multiple + .parameters + .properties + .expect("properties") + .contains_key("environment_id") + ); +} diff --git a/codex-rs/core/src/tools/handlers/search_file_tests.rs b/codex-rs/core/src/tools/handlers/search_file_tests.rs new file mode 100644 index 000000000000..2f64b34a722a --- /dev/null +++ b/codex-rs/core/src/tools/handlers/search_file_tests.rs @@ -0,0 +1,62 @@ +use super::*; +use pretty_assertions::assert_eq; + +fn args(mode: SearchFileModeArg) -> SearchFileArgs { + SearchFileArgs { + mode, + path: ".".to_string(), + query: None, + case_mode: None, + recursive: true, + include: Vec::new(), + exclude: Vec::new(), + include_ignored: false, + max_results: None, + environment_id: None, + } +} + +#[test] +fn validates_keyword_options_and_defaults() { + let mut args = args(SearchFileModeArg::Keyword); + args.query = Some("needle".to_string()); + args.case_mode = Some(SearchFileCaseModeArg::Insensitive); + + assert_eq!( + validated_options(&args).expect("valid options"), + FileSearchOptions { + mode: FileSearchMode::Keyword, + query: Some("needle".to_string()), + case_mode: Some(FileSearchCaseMode::Insensitive), + recursive: true, + include: Vec::new(), + exclude: Vec::new(), + include_ignored: false, + max_results: DEFAULT_SEARCH_RESULTS, + } + ); +} + +#[test] +fn list_rejects_search_only_fields() { + let mut args = args(SearchFileModeArg::List); + args.query = Some("unexpected".to_string()); + + let error = validated_options(&args).expect_err("list query must fail"); + assert_eq!( + error.to_string(), + "query and case_mode are not valid for list mode" + ); +} + +#[test] +fn rejects_out_of_range_result_limit() { + let mut args = args(SearchFileModeArg::List); + args.max_results = Some(MAX_FILE_SEARCH_RESULTS + 1); + + let error = validated_options(&args).expect_err("oversized limit must fail"); + assert_eq!( + error.to_string(), + format!("max_results must be between 1 and {MAX_FILE_SEARCH_RESULTS}") + ); +} diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 9e50225a29ce..8a17ab28f7dd 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -24,6 +24,7 @@ use crate::tools::handlers::ReadMcpResourceHandler; use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::RequestPluginInstallHandler; use crate::tools::handlers::RequestUserInputHandler; +use crate::tools::handlers::SearchFileHandler; use crate::tools::handlers::SendUserMessageAsyncHandler; use crate::tools::handlers::SleepHandler; use crate::tools::handlers::TestSyncHandler; @@ -48,6 +49,7 @@ use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHand use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2; use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2; use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2; +use crate::tools::handlers::search_file_spec::SearchFileToolOptions; use crate::tools::handlers::tool_search_spec::ToolSearchSourceListing; use crate::tools::handlers::view_image_spec::ViewImageToolOptions; use crate::tools::hosted_spec::WebSearchToolOptions; @@ -1140,6 +1142,18 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, registry: &mut Tool include_environment_id, })); } + + if environment_mode.has_environment() + && features.enabled(Feature::SearchFile) + && context + .environments + .turn_environments() + .any(|environment| environment.file_search_supported) + { + registry.add(SearchFileHandler::new(SearchFileToolOptions { + include_environment_id: matches!(environment_mode, ToolEnvironmentMode::Multiple), + })); + } } #[instrument(level = "trace", skip_all)] diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index a334cd4679f0..8114d10714a8 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -484,6 +484,48 @@ fn apply_patch_accepts_environment_id(spec: &ToolSpec) -> bool { } } +#[tokio::test] +async fn search_file_is_gated_by_its_feature() { + let disabled = probe(|turn| { + set_feature(turn, Feature::SearchFile, /*enabled*/ false); + }) + .await; + disabled.assert_registered_lacks(&["search_file"]); + + let enabled = probe(|turn| { + set_feature(turn, Feature::SearchFile, /*enabled*/ true); + }) + .await; + enabled.assert_registered_contains(&["search_file"]); +} + +#[tokio::test] +async fn search_file_requires_a_capable_environment() { + let unsupported = probe(|turn| { + set_feature(turn, Feature::SearchFile, /*enabled*/ true); + for environment in &mut turn.environments.environments { + if let TurnEnvironmentState::Ready(environment) = environment { + environment.file_search_supported = false; + } + } + }) + .await; + unsupported.assert_registered_lacks(&["search_file"]); +} + +#[tokio::test] +async fn search_file_exposes_environment_id_for_multiple_environments() { + let multiple = probe(|turn| { + set_feature(turn, Feature::SearchFile, /*enabled*/ true); + duplicate_primary_environment(turn); + }) + .await; + assert!(has_parameter( + multiple.visible_spec("search_file"), + "environment_id" + )); +} + #[tokio::test] async fn internal_guardian_sessions_exclude_optional_core_tools() { let (session, mut turn) = make_session_and_context().await; diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index a4d275cac837..8db9a01aed61 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -148,6 +148,8 @@ pub enum Feature { WebSearchCached, /// Expose the extension-backed standalone web search tool. StandaloneWebSearch, + /// Expose the built-in filesystem search tool. + SearchFile, /// Use the legacy Landlock Linux sandbox fallback instead of the default /// bubblewrap pipeline. UseLegacyLandlock, @@ -1003,6 +1005,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::SearchFile, + key: "search_file", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::SearchTool, key: "search_tool", From aa8a2c29a911165fe455d465a290049e8470fdc4 Mon Sep 17 00:00:00 2001 From: ADD-SP Date: Thu, 27 Aug 2026 16:14:19 +0000 Subject: [PATCH 4/5] test(core): cover search file tool end to end --- codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/search_file.rs | 427 +++++++++++++++++++++++ 2 files changed, 428 insertions(+) create mode 100644 codex-rs/core/tests/suite/search_file.rs diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index a34c08a90239..6e5c1c83bbae 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -145,6 +145,7 @@ mod rollout_budget; mod rollout_list_find; mod safety_buffering; mod safety_check_downgrade; +mod search_file; mod search_tool; mod send_user_message_async; mod settings_commits; diff --git a/codex-rs/core/tests/suite/search_file.rs b/codex-rs/core/tests/suite/search_file.rs new file mode 100644 index 000000000000..3d0733a79ba7 --- /dev/null +++ b/codex-rs/core/tests/suite/search_file.rs @@ -0,0 +1,427 @@ +#![allow(clippy::expect_used)] + +use anyhow::Result; +use anyhow::bail; +use codex_exec_server::CreateDirectoryOptions; +use codex_exec_server::EnvironmentInfo; +use codex_exec_server::InitializeResponse; +use codex_exec_server::REMOTE_ENVIRONMENT_ID; +use codex_features::Feature; +use codex_protocol::protocol::EnvironmentConfigState; +use codex_protocol::protocol::TurnEnvironmentSelection; +use core_test_support::responses; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_custom_tool_call; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::skip_if_no_remote_env; +use core_test_support::test_codex::local; +use core_test_support::test_codex::test_codex; +use futures::SinkExt; +use futures::StreamExt; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio_tungstenite::accept_async; +use tokio_tungstenite::tungstenite::Message; + +async fn start_search_unsupported_exec_server() -> Result<(String, JoinHandle>)> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let websocket_url = format!("ws://{}", listener.local_addr()?); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await?; + let mut websocket = accept_async(stream).await?; + let Some(message) = websocket.next().await.transpose()? else { + bail!("exec-server client disconnected before initialize"); + }; + let Message::Text(text) = message else { + bail!("expected initialize request, got {message:?}"); + }; + let initialize: Value = serde_json::from_str(&text)?; + if initialize["method"] != "initialize" { + bail!("expected initialize request, got {initialize}"); + } + + let mut environment_info = EnvironmentInfo::local(); + environment_info.capabilities = Default::default(); + let result = serde_json::to_value(InitializeResponse { + session_id: "search-file-legacy-executor".to_string(), + environment_info: Some(environment_info.clone()), + })?; + websocket + .send(Message::Text( + json!({ + "jsonrpc": "2.0", + "id": initialize["id"], + "result": result, + }) + .to_string() + .into(), + )) + .await?; + + while let Some(message) = websocket.next().await.transpose()? { + match message { + Message::Text(text) => { + let message: Value = serde_json::from_str(&text)?; + let Some(id) = message.get("id") else { + continue; + }; + let result = match message["method"].as_str() { + Some("environment/info") => serde_json::to_value(&environment_info)?, + Some("environment/status") => json!({ "status": "ready" }), + method => bail!("unexpected exec-server request: {method:?}"), + }; + websocket + .send(Message::Text( + json!({ + "jsonrpc": "2.0", + "id": id, + "result": result, + }) + .to_string() + .into(), + )) + .await?; + } + Message::Ping(payload) => websocket.send(Message::Pong(payload)).await?, + Message::Close(_) => break, + Message::Binary(_) | Message::Pong(_) | Message::Frame(_) => {} + } + } + Ok(()) + }); + Ok((websocket_url, server)) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn search_file_tool_returns_grouped_matches_to_the_model() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::SearchFile) + .expect("enable search_file feature"); + }); + let test = builder.build_with_auto_env(&server).await?; + let fixture_dir = test.workspace_path_uri("search-fixtures")?; + test.fs() + .create_directory( + &fixture_dir, + CreateDirectoryOptions { + recursive: true, + follow_symlinks: true, + }, + /*sandbox*/ None, + ) + .await?; + for (name, contents) in [ + ("first.txt", b"before\nneedle one\n".as_slice()), + ("second.txt", b"needle two\nafter\n".as_slice()), + ] { + test.fs() + .write_file( + &fixture_dir.join(name)?, + contents.to_vec(), + Default::default(), + /*sandbox*/ None, + ) + .await?; + } + + let call_id = "search-file-call"; + let arguments = json!({ + "mode": "keyword", + "path": "search-fixtures", + "query": "needle", + "case_mode": "sensitive" + }); + let first_mock = responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "search_file", &arguments.to_string()), + ev_completed("resp-1"), + ]), + ) + .await; + let final_mock = responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + + test.submit_text_turn("search the fixture files").await?; + + let first_request = first_mock.single_request(); + assert!( + first_request.body_json()["tools"] + .as_array() + .expect("tools") + .iter() + .any(|tool| tool.get("name").and_then(Value::as_str) == Some("search_file")) + ); + let final_request = final_mock.single_request(); + let (output, success) = final_request + .function_call_output_content_and_success(call_id) + .expect("search_file output"); + assert_eq!(success, None); + let output = output.expect("text output"); + assert!(output.contains("first.txt\n 2: needle one")); + assert!(output.contains("second.txt\n 1: needle two")); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn search_file_is_available_through_code_mode() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex() + .with_model("test-gpt-5.1-codex") + .with_code_mode_host_program(codex_utils_cargo_bin::cargo_bin("codex-code-mode-host")?) + .with_config(|config| { + config + .features + .enable(Feature::CodeMode) + .expect("enable code mode feature"); + config + .features + .enable(Feature::SearchFile) + .expect("enable search_file feature"); + }); + let test = builder.build_with_auto_env(&server).await?; + let fixture_dir = test.workspace_path_uri("code-mode-search-fixtures")?; + test.fs() + .create_directory( + &fixture_dir, + CreateDirectoryOptions { + recursive: true, + follow_symlinks: true, + }, + /*sandbox*/ None, + ) + .await?; + test.fs() + .write_file( + &fixture_dir.join("nested.txt")?, + b"code mode needle\n".to_vec(), + Default::default(), + /*sandbox*/ None, + ) + .await?; + + let call_id = "search-file-code-mode"; + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-code-mode-1"), + ev_custom_tool_call( + call_id, + "exec", + r#" +const result = await tools.search_file({ + mode: "keyword", + path: "code-mode-search-fixtures", + query: "needle", + case_mode: "sensitive", +}); +text(result); +"#, + ), + ev_completed("resp-code-mode-1"), + ]), + ) + .await; + let final_mock = responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-code-mode-2"), + ev_assistant_message("msg-code-mode", "done"), + ev_completed("resp-code-mode-2"), + ]), + ) + .await; + + test.submit_text_turn("search through code mode").await?; + + let request = final_mock.single_request(); + let (output, success) = request + .custom_tool_call_output_content_and_success(call_id) + .expect("code mode output"); + assert_ne!(success, Some(false)); + assert!( + output + .as_deref() + .is_some_and(|output| output.contains("1: code mode needle")), + "code mode output should contain the nested search result: {output:?}" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn search_file_routes_to_an_explicit_remote_environment() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_no_remote_env!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::SearchFile) + .expect("enable search_file feature"); + }); + let test = builder.build_with_remote_and_local_env(&server).await?; + let remote_selection = test.executor_environment().selection().clone(); + let fixture_dir = remote_selection.cwd.join("selected-search-fixtures")?; + test.fs() + .create_directory( + &fixture_dir, + CreateDirectoryOptions { + recursive: true, + follow_symlinks: true, + }, + /*sandbox*/ None, + ) + .await?; + test.fs() + .write_file( + &fixture_dir.join("remote.txt")?, + b"selected remote needle\n".to_vec(), + Default::default(), + /*sandbox*/ None, + ) + .await?; + + let call_id = "search-file-explicit-remote"; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-remote-1"), + ev_function_call( + call_id, + "search_file", + &json!({ + "mode": "keyword", + "path": "selected-search-fixtures", + "query": "needle", + "case_mode": "sensitive", + "environment_id": REMOTE_ENVIRONMENT_ID, + }) + .to_string(), + ), + ev_completed("resp-remote-1"), + ]), + sse(vec![ + ev_response_created("resp-remote-2"), + ev_assistant_message("msg-remote", "done"), + ev_completed("resp-remote-2"), + ]), + ], + ) + .await; + + test.submit_turn_with_environments( + "search the selected remote environment", + Some(vec![local(test.config.cwd.clone()), remote_selection]), + ) + .await?; + + let request = response_mock + .last_request() + .expect("model should receive remote search output"); + let (output, _) = request + .function_call_output_content_and_success(call_id) + .expect("search_file output"); + assert!( + output.is_some_and(|output| output.contains("1: selected remote needle")), + "selected remote search result should be model visible" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn search_file_reports_an_explicit_unsupported_environment() -> Result<()> { + skip_if_no_network!(Ok(())); + + let (exec_server_url, exec_server) = start_search_unsupported_exec_server().await?; + let server = start_mock_server().await; + let mut builder = test_codex() + .with_exec_server_url(exec_server_url) + .with_config(|config| { + config + .features + .enable(Feature::SearchFile) + .expect("enable search_file feature"); + }); + let test = builder.build_with_remote_and_local_env(&server).await?; + let call_id = "search-file-unsupported-remote"; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-unsupported-1"), + ev_function_call( + call_id, + "search_file", + &json!({ + "mode": "list", + "path": ".", + "environment_id": REMOTE_ENVIRONMENT_ID, + }) + .to_string(), + ), + ev_completed("resp-unsupported-1"), + ]), + sse(vec![ + ev_response_created("resp-unsupported-2"), + ev_assistant_message("msg-unsupported", "done"), + ev_completed("resp-unsupported-2"), + ]), + ], + ) + .await; + let remote_selection = TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: test.executor_environment().selection().cwd.clone(), + workspace_roots: test + .executor_environment() + .selection() + .workspace_roots + .clone(), + config: EnvironmentConfigState::FromThread, + }; + + test.submit_turn_with_environments( + "search the unsupported remote environment", + Some(vec![local(test.config.cwd.clone()), remote_selection]), + ) + .await?; + + let request = response_mock + .last_request() + .expect("model should receive unsupported-environment error"); + let (output, success) = request + .function_call_output_content_and_success(call_id) + .expect("search_file output"); + assert_ne!(success, Some(true)); + assert!(output.is_some_and(|output| { + output.contains("search_file is not supported by environment `remote`") + })); + exec_server.abort(); + let _ = exec_server.await; + Ok(()) +} From 80779fe9336f68ae5f3e6f4513f0ce4666961949 Mon Sep 17 00:00:00 2001 From: ADD-SP Date: Mon, 31 Aug 2026 12:30:31 +0000 Subject: [PATCH 5/5] Fix inline request ordering and search output formatting --- .../tools/handlers/search_file_formatter.rs | 88 +++++++++++++------ .../handlers/search_file_formatter_tests.rs | 69 ++++++++++++++- .../src/server/request_dispatcher.rs | 83 ++++++++++------- .../src/server/request_dispatcher_tests.rs | 84 +++++++++++++++++- .../exec-server/tests/file_system/shared.rs | 49 ++++++----- codex-rs/file-search/src/content.rs | 2 +- codex-rs/file-search/src/content_tests.rs | 24 ++++- codex-rs/file-system/src/lib.rs | 60 ++----------- codex-rs/file-system/src/search.rs | 56 ++++++++++++ 9 files changed, 369 insertions(+), 146 deletions(-) create mode 100644 codex-rs/file-system/src/search.rs diff --git a/codex-rs/core/src/tools/handlers/search_file_formatter.rs b/codex-rs/core/src/tools/handlers/search_file_formatter.rs index 013fdf745109..573a4e8cdc5c 100644 --- a/codex-rs/core/src/tools/handlers/search_file_formatter.rs +++ b/codex-rs/core/src/tools/handlers/search_file_formatter.rs @@ -1,12 +1,16 @@ use codex_exec_server::FileSearchMatch; use codex_exec_server::FileSearchOutcome; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::approx_bytes_for_tokens; +use codex_utils_output_truncation::approx_token_count; +use codex_utils_output_truncation::truncate_text; use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; use std::collections::BTreeMap; -const MAX_OUTPUT_BYTES: usize = 8 * 1024; +const MAX_OUTPUT_TOKENS: usize = 10_000; const TRUNCATION_MARKER: &str = - "\n[output truncated at 8 KiB; narrow the search or raise specificity]\n"; + "\n[output truncated at 10K tokens; narrow the search or raise specificity]\n"; #[derive(Default)] struct TreeNode { @@ -39,11 +43,15 @@ pub(crate) fn format_search_file_output(root: &PathUri, outcome: &FileSearchOutc output.push_str(&root.inferred_native_path_string()); output.push_str(".\n"); } else { - output.push_str(&root.inferred_native_path_string()); - output.push(match root.infer_path_convention() { + let root_path = root.inferred_native_path_string(); + let separator = match root.infer_path_convention() { Some(PathConvention::Windows) => '\\', Some(PathConvention::Posix) | None => '/', - }); + }; + output.push_str(&root_path); + if !root_path.ends_with(separator) { + output.push(separator); + } output.push('\n'); render_node(&tree, 1, &mut output); } @@ -103,20 +111,45 @@ fn insert_file( } fn render_node(node: &TreeNode, depth: usize, output: &mut String) { - for (directory, child) in &node.directories { - let (collapsed, child) = collapse_directory(directory, child); - push_indent(output, depth); - output.push_str(&collapsed); - output.push_str("/\n"); - render_node(child, depth + 1, output); - } - for (file, matches) in &node.files { - push_indent(output, depth); - output.push_str(file); - output.push('\n'); - for (line_number, excerpt) in matches { - push_indent(output, depth + 1); - output.push_str(&format!("{line_number}: {excerpt}\n")); + let mut directories = node.directories.iter().collect::>(); + directories.sort_by(|(left, _), (right, _)| { + left.bytes() + .chain(std::iter::once(b'/')) + .cmp(right.bytes().chain(std::iter::once(b'/'))) + }); + let mut directories = directories.into_iter().peekable(); + let mut files = node.files.iter().peekable(); + while directories.peek().is_some() || files.peek().is_some() { + let render_directory = match (directories.peek(), files.peek()) { + (Some((directory, _)), Some((file, _))) => directory + .bytes() + .chain(std::iter::once(b'/')) + .cmp(file.bytes()) + .is_le(), + (Some(_), None) => true, + (None, Some(_)) => false, + (None, None) => break, + }; + if render_directory { + let Some((directory, child)) = directories.next() else { + unreachable!("directory iterator was checked above"); + }; + let (collapsed, child) = collapse_directory(directory, child); + push_indent(output, depth); + output.push_str(&collapsed); + output.push_str("/\n"); + render_node(child, depth + 1, output); + } else { + let Some((file, matches)) = files.next() else { + unreachable!("file iterator was checked above"); + }; + push_indent(output, depth); + output.push_str(file); + output.push('\n'); + for (line_number, excerpt) in matches { + push_indent(output, depth + 1); + output.push_str(&format!("{line_number}: {excerpt}\n")); + } } } } @@ -141,15 +174,20 @@ fn push_indent(output: &mut String, depth: usize) { } fn truncate_search_file_output(mut output: String) -> String { - if output.len() <= MAX_OUTPUT_BYTES { + if approx_token_count(&output) <= MAX_OUTPUT_TOKENS { return output; } - let keep = MAX_OUTPUT_BYTES - TRUNCATION_MARKER.len(); - let mut boundary = keep; - while !output.is_char_boundary(boundary) { - boundary -= 1; + let content_token_budget = + MAX_OUTPUT_TOKENS.saturating_sub(approx_token_count(TRUNCATION_MARKER)); + output = truncate_text(&output, TruncationPolicy::Tokens(content_token_budget)); + let content_byte_budget = approx_bytes_for_tokens(content_token_budget); + if output.len() > content_byte_budget { + let mut boundary = content_byte_budget; + while !output.is_char_boundary(boundary) { + boundary -= 1; + } + output.truncate(boundary); } - output.truncate(boundary); output.push_str(TRUNCATION_MARKER); output } diff --git a/codex-rs/core/src/tools/handlers/search_file_formatter_tests.rs b/codex-rs/core/src/tools/handlers/search_file_formatter_tests.rs index bb3df580a4a4..6c6cfb7cd0ac 100644 --- a/codex-rs/core/src/tools/handlers/search_file_formatter_tests.rs +++ b/codex-rs/core/src/tools/handlers/search_file_formatter_tests.rs @@ -32,6 +32,42 @@ fn groups_common_path_prefixes_and_matching_lines() { ); } +#[test] +fn sorts_interleaved_files_and_directories_by_relative_path() { + let root = root(); + let outcome = FileSearchOutcome { + files: vec![ + root.join("a.txt").expect("path URI"), + root.join("a/b.txt").expect("nested path URI"), + root.join("a0.txt").expect("path URI"), + ], + ..Default::default() + }; + + assert_eq!( + format_search_file_output(&root, &outcome), + "/repo/\n a.txt\n a/\n b.txt\n a0.txt\n" + ); +} + +#[test] +fn sorts_directory_names_by_their_normalized_path_prefix() { + let root = root(); + let outcome = FileSearchOutcome { + files: vec![ + root.join("a/b.txt").expect("nested path URI"), + root.join("a./b.txt").expect("nested path URI"), + root.join("a0/b.txt").expect("nested path URI"), + ], + ..Default::default() + }; + + assert_eq!( + format_search_file_output(&root, &outcome), + "/repo/\n a./\n b.txt\n a/\n b.txt\n a0/\n b.txt\n" + ); +} + #[test] fn groups_windows_path_prefixes() { let root = PathUri::parse("file:///C:/repo").expect("path URI"); @@ -49,6 +85,31 @@ fn groups_windows_path_prefixes() { ); } +#[test] +fn does_not_duplicate_posix_root_separator() { + let root = PathUri::parse("file:///").expect("path URI"); + let outcome = FileSearchOutcome { + files: vec![root.join("a.txt").expect("path URI")], + ..Default::default() + }; + + assert_eq!(format_search_file_output(&root, &outcome), "/\n a.txt\n"); +} + +#[test] +fn does_not_duplicate_windows_root_separator() { + let root = PathUri::parse("file:///C:/").expect("path URI"); + let outcome = FileSearchOutcome { + files: vec![root.join(r"a.txt").expect("path URI")], + ..Default::default() + }; + + assert_eq!( + format_search_file_output(&root, &outcome), + "C:\\\n a.txt\n" + ); +} + #[test] fn reports_empty_results_and_recoverable_skips() { let root = root(); @@ -77,21 +138,21 @@ fn enforces_model_safe_output_limit() { .map(|line_number| FileSearchMatch { path: root.join("large.rs").expect("path URI"), line_number, - excerpt: "x".repeat(300), + excerpt: "🦀".repeat(300), }) .collect(), ..Default::default() }; let output = format_search_file_output(&root, &outcome); - assert!(output.len() <= MAX_OUTPUT_BYTES); + assert!(approx_token_count(&output) <= MAX_OUTPUT_TOKENS); assert!(output.ends_with(TRUNCATION_MARKER)); } #[test] fn enforces_model_safe_error_limit() { - let output = format_search_file_error("x".repeat(MAX_OUTPUT_BYTES * 2)); + let output = format_search_file_error("🦀".repeat(MAX_OUTPUT_TOKENS + 1)); - assert!(output.len() <= MAX_OUTPUT_BYTES); + assert!(approx_token_count(&output) <= MAX_OUTPUT_TOKENS); assert!(output.ends_with(TRUNCATION_MARKER)); } diff --git a/codex-rs/exec-server/src/server/request_dispatcher.rs b/codex-rs/exec-server/src/server/request_dispatcher.rs index 23b0f0178455..e8e2fdf6d0a9 100644 --- a/codex-rs/exec-server/src/server/request_dispatcher.rs +++ b/codex-rs/exec-server/src/server/request_dispatcher.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::future::Future; use std::num::NonZeroUsize; use std::num::ParseIntError; use std::str::FromStr; @@ -13,6 +14,7 @@ use codex_exec_server_protocol::JSONRPCResponse; use codex_exec_server_protocol::RequestId; use tokio::sync::Semaphore; use tokio::sync::mpsc; +use tokio::sync::oneshot; use tokio::sync::watch; use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; @@ -47,7 +49,7 @@ pub(super) struct RequestDispatcher { requests: RpcServerRequestSender, telemetry: ExecServerTelemetry, lanes: Option, - inline_lane: Arc, + inline_tail: Option>, tasks: JoinSet, cancellable_requests: Arc>>, initialized: bool, @@ -95,7 +97,7 @@ impl RequestDispatcher { requests, telemetry, lanes, - inline_lane: Arc::new(Semaphore::new(1)), + inline_tail: None, tasks: JoinSet::new(), cancellable_requests: Arc::new(StdMutex::new(HashMap::new())), initialized: false, @@ -225,30 +227,34 @@ impl RequestDispatcher { let started_at = Instant::now(); let Some((method, route)) = self.router.request_route(request.method.as_str()) else { let method = "unknown"; - self.telemetry - .request_queue_completed(method, queued_at.elapsed()); request_span.record("otel.name", method); - if self - .outgoing_tx - .send(RpcServerOutboundMessage::Error { - request_id: request.id, - error: method_not_found(format!( - "exec-server stub does not implement `{}` yet", - request.method - )), - }) - .await - .is_err() - { - request_span.record("result", "disconnected"); - self.telemetry - .request_completed(method, "disconnected", started_at.elapsed()); - return RequestTaskResult::ConnectionClosed; + let outgoing_tx = self.outgoing_tx.clone(); + let telemetry = self.telemetry.clone(); + let task = async move { + telemetry.request_queue_completed(method, queued_at.elapsed()); + if outgoing_tx + .send(RpcServerOutboundMessage::Error { + request_id: request.id, + error: method_not_found(format!( + "exec-server stub does not implement `{}` yet", + request.method + )), + }) + .await + .is_err() + { + request_span.record("result", "disconnected"); + telemetry.request_completed(method, "disconnected", started_at.elapsed()); + return RequestTaskResult::ConnectionClosed; + } + request_span.record("result", "error"); + telemetry.request_completed(method, "error", started_at.elapsed()); + RequestTaskResult::Completed + }; + if self.lanes.is_none() && self.initialized { + return self.spawn_inline_task(task); } - request_span.record("result", "error"); - self.telemetry - .request_completed(method, "error", started_at.elapsed()); - return RequestTaskResult::Completed; + return task.await; }; request_span.record("otel.name", method); @@ -315,17 +321,10 @@ impl RequestDispatcher { let Some(RequestLanes { ordinary, control }) = &self.lanes else { // Finish the handshake before queued requests can observe session state. - if method == INITIALIZE_METHOD || !self.initialized { + if !self.initialized { return task.await; } - let admission = Arc::clone(&self.inline_lane); - self.tasks.spawn(async move { - let Ok(_permit) = admission.acquire_owned().await else { - return RequestTaskResult::ConnectionClosed; - }; - task.await - }); - return RequestTaskResult::Completed; + return self.spawn_inline_task(task); }; // Finish the handshake before concurrent requests can observe session state. if method == INITIALIZE_METHOD || !self.initialized { @@ -356,6 +355,24 @@ impl RequestDispatcher { RequestTaskResult::Completed } + fn spawn_inline_task( + &mut self, + task: impl Future + Send + 'static, + ) -> RequestTaskResult { + let predecessor = self.inline_tail.take(); + let (completion_tx, completion_rx) = oneshot::channel(); + self.inline_tail = Some(completion_rx); + self.tasks.spawn(async move { + if let Some(predecessor) = predecessor { + let _ = predecessor.await; + } + let result = task.await; + let _ = completion_tx.send(()); + result + }); + RequestTaskResult::Completed + } + pub(super) async fn shutdown(mut self) { self.tasks.abort_all(); while self.tasks.join_next().await.is_some() {} diff --git a/codex-rs/exec-server/src/server/request_dispatcher_tests.rs b/codex-rs/exec-server/src/server/request_dispatcher_tests.rs index 29b5396880bb..8394c425f506 100644 --- a/codex-rs/exec-server/src/server/request_dispatcher_tests.rs +++ b/codex-rs/exec-server/src/server/request_dispatcher_tests.rs @@ -119,6 +119,12 @@ async fn search_cancellation_drops_the_in_flight_route() { let started_count = Arc::new(AtomicUsize::new(0)); let started_count_by_route = Arc::clone(&started_count); let mut router = RpcRouter::new(); + router.request( + crate::protocol::INITIALIZE_METHOD, + |_handler: Arc, _params: serde_json::Value| async { + Ok::<_, codex_exec_server_protocol::JSONRPCErrorError>(()) + }, + ); router.request( crate::protocol::FS_SEARCH_METHOD, move |_handler: Arc, _params: serde_json::Value| { @@ -165,9 +171,6 @@ async fn search_cancellation_drops_the_in_flight_route() { .await, RequestTaskResult::Completed )); - timeout(Duration::from_secs(1), started.notified()) - .await - .expect("first search should start"); let second_request_id = RequestId::Integer(8); let JsonRpcConnectionEvent::QueuedRequest { @@ -189,8 +192,53 @@ async fn search_cancellation_drops_the_in_flight_route() { .await, RequestTaskResult::Completed )); + let JsonRpcConnectionEvent::QueuedRequest { + request, + request_span, + queued_at, + } = JsonRpcConnectionEvent::message(JSONRPCMessage::Request(JSONRPCRequest { + id: RequestId::Integer(9), + method: "unknown/method".to_string(), + params: None, + trace: None, + })) + else { + panic!("requests should be queued"); + }; + assert!(matches!( + dispatcher + .dispatch_request(request, request_span, queued_at) + .await, + RequestTaskResult::Completed + )); + let JsonRpcConnectionEvent::QueuedRequest { + request, + request_span, + queued_at, + } = JsonRpcConnectionEvent::message(JSONRPCMessage::Request(JSONRPCRequest { + id: RequestId::Integer(10), + method: crate::protocol::INITIALIZE_METHOD.to_string(), + params: Some(serde_json::json!({})), + trace: None, + })) + else { + panic!("requests should be queued"); + }; + assert!(matches!( + dispatcher + .dispatch_request(request, request_span, queued_at) + .await, + RequestTaskResult::Completed + )); + timeout(Duration::from_secs(1), started.notified()) + .await + .expect("first search should start before queued searches"); tokio::task::yield_now().await; assert_eq!(started_count.load(Ordering::SeqCst), 1); + assert!(matches!( + outgoing_rx.try_recv(), + Err(mpsc::error::TryRecvError::Empty) + )); assert!(matches!( dispatcher @@ -202,7 +250,9 @@ async fn search_cancellation_drops_the_in_flight_route() { RequestTaskResult::Completed )); assert!(matches!( - dispatcher.join_next().await, + timeout(Duration::from_secs(1), dispatcher.join_next()) + .await + .expect("cancelling the first search should release the inline queue"), RequestTaskResult::Completed )); timeout(Duration::from_secs(1), dropped.notified()) @@ -243,6 +293,32 @@ async fn search_cancellation_drops_the_in_flight_route() { .. }) )); + assert!(matches!( + timeout(Duration::from_secs(1), dispatcher.join_next()) + .await + .expect("unknown request should run after earlier inline searches"), + RequestTaskResult::Completed + )); + assert!(matches!( + outgoing_rx.recv().await, + Some(RpcServerOutboundMessage::Error { + request_id: RequestId::Integer(9), + .. + }) + )); + assert!(matches!( + timeout(Duration::from_secs(1), dispatcher.join_next()) + .await + .expect("repeated initialize should run last in the inline queue"), + RequestTaskResult::Completed + )); + assert!(matches!( + outgoing_rx.recv().await, + Some(RpcServerOutboundMessage::Response { + request_id: RequestId::Integer(10), + .. + }) + )); assert!( dispatcher .cancellable_requests diff --git a/codex-rs/exec-server/tests/file_system/shared.rs b/codex-rs/exec-server/tests/file_system/shared.rs index d99192e9f5b1..925b80091ac0 100644 --- a/codex-rs/exec-server/tests/file_system/shared.rs +++ b/codex-rs/exec-server/tests/file_system/shared.rs @@ -814,29 +814,32 @@ async fn file_system_search_honors_read_sandbox( } ); - let denied_dir = tmp.path().join("denied"); - std::fs::create_dir_all(&denied_dir)?; - std::fs::write(denied_dir.join("secret.txt"), "sandbox needle\n")?; - let denied = file_system - .search( - &PathUri::from_host_native_path(&denied_dir)?, - FileSearchOptions { - mode: FileSearchMode::Keyword, - query: Some("needle".to_string()), - case_mode: Some(FileSearchCaseMode::Sensitive), - recursive: true, - include: Vec::new(), - exclude: Vec::new(), - include_ignored: false, - max_results: 10, - }, - Some(&sandbox), - ) - .await; - assert!( - denied.is_err(), - "search outside the sandbox should fail for mode={implementation}" - ); + #[cfg(not(windows))] + { + let denied_dir = tmp.path().join("denied"); + std::fs::create_dir_all(&denied_dir)?; + std::fs::write(denied_dir.join("secret.txt"), "sandbox needle\n")?; + let denied = file_system + .search( + &PathUri::from_host_native_path(&denied_dir)?, + FileSearchOptions { + mode: FileSearchMode::Keyword, + query: Some("needle".to_string()), + case_mode: Some(FileSearchCaseMode::Sensitive), + recursive: true, + include: Vec::new(), + exclude: Vec::new(), + include_ignored: false, + max_results: 10, + }, + Some(&sandbox), + ) + .await; + assert!( + denied.is_err(), + "search outside the sandbox should fail for mode={implementation}" + ); + } Ok(()) } diff --git a/codex-rs/file-search/src/content.rs b/codex-rs/file-search/src/content.rs index a11931b18cfc..4b26096f26d8 100644 --- a/codex-rs/file-search/src/content.rs +++ b/codex-rs/file-search/src/content.rs @@ -57,7 +57,7 @@ pub fn search_files( )?; outcome.truncated = walk_truncated; paths.retain(|path| path_selected(root, path, include.as_ref(), exclude.as_ref())); - paths.sort_by_key(|left| normalized_relative_path(root, left)); + paths.sort_by_cached_key(|path| normalized_relative_path(root, path)); let mut response_bytes = RESPONSE_ENVELOPE_OVERHEAD_BYTES; for error in &outcome.errors { response_bytes = response_bytes.saturating_add(serialized_item_bytes(error)?); diff --git a/codex-rs/file-search/src/content_tests.rs b/codex-rs/file-search/src/content_tests.rs index 4abae5e03e4a..ae5d32c3939d 100644 --- a/codex-rs/file-search/src/content_tests.rs +++ b/codex-rs/file-search/src/content_tests.rs @@ -99,11 +99,17 @@ fn keyword_is_literal_and_invalid_patterns_fail() -> io::Result<()> { fn list_respects_recursion_globs_and_ignore_overrides() -> io::Result<()> { let temp = TempDir::new()?; fs::create_dir_all(temp.path().join("nested"))?; + fs::create_dir_all(temp.path().join(".git/info"))?; fs::write(temp.path().join("root.rs"), "")?; - fs::write(temp.path().join("root.txt"), "")?; + fs::write(temp.path().join("ignored.rs"), "")?; + fs::write(temp.path().join(".hidden.rs"), "")?; + fs::write(temp.path().join("git-ignored.rs"), "")?; + fs::write(temp.path().join("git-excluded.rs"), "")?; fs::write(temp.path().join("nested/keep.rs"), "")?; fs::write(temp.path().join("nested/drop.rs"), "")?; - fs::write(temp.path().join(".ignore"), "root.txt\n")?; + fs::write(temp.path().join(".ignore"), "ignored.rs\n")?; + fs::write(temp.path().join(".gitignore"), "git-ignored.rs\n")?; + fs::write(temp.path().join(".git/info/exclude"), "git-excluded.rs\n")?; fs::write(temp.path().join(".rgignore"), "nested/keep.rs\n")?; let root = PathUri::from_host_native_path(temp.path())?; let mut list = options(FileSearchMode::List); @@ -122,8 +128,18 @@ fn list_respects_recursion_globs_and_ignore_overrides() -> io::Result<()> { list.include_ignored = true; let ignored = search_files(temp.path(), &list, &CancellationToken::new())?; let ignored_paths = relative_paths(&root, &ignored); - assert!(ignored_paths.contains(&"root.txt".to_string())); - assert!(ignored_paths.contains(&"nested/keep.rs".to_string())); + for expected in [ + ".hidden.rs", + "git-excluded.rs", + "git-ignored.rs", + "ignored.rs", + "nested/keep.rs", + ] { + assert!( + ignored_paths.iter().any(|path| path == expected), + "include_ignored should restore {expected}" + ); + } Ok(()) } diff --git a/codex-rs/file-system/src/lib.rs b/codex-rs/file-system/src/lib.rs index 573579391d1a..fe4bdcca4c0b 100644 --- a/codex-rs/file-system/src/lib.rs +++ b/codex-rs/file-system/src/lib.rs @@ -1,4 +1,5 @@ mod find_up; +mod search; use bytes::Bytes; use codex_protocol::config_types::WindowsSandboxLevel; @@ -21,6 +22,13 @@ pub use find_up::FindUpErrorPolicy; pub use find_up::find_nearest_ancestor_with_markers; pub use find_up::find_nearest_native_ancestor_with_markers; use futures::Stream; +pub use search::FileSearchCaseMode; +pub use search::FileSearchMatch; +pub use search::FileSearchMode; +pub use search::FileSearchOptions; +pub use search::FileSearchOutcome; +pub use search::MAX_FILE_SEARCH_EXCERPT_CHARS; +pub use search::MAX_FILE_SEARCH_RESULTS; use serde::Deserialize; use serde::Serialize; use std::future::Future; @@ -43,58 +51,6 @@ pub const MAX_WALK_ENTRIES: usize = 50_000; pub const MAX_WALK_RESPONSE_BYTES: usize = 4 * 1024 * 1024; /// Per-entry or per-error overhead charged to the walk response budget. pub const WALK_RESPONSE_ITEM_OVERHEAD_BYTES: usize = 64; -/// Maximum number of search results accepted by the filesystem search API. -pub const MAX_FILE_SEARCH_RESULTS: usize = 1_000; -/// Maximum characters retained for one matching line excerpt. -pub const MAX_FILE_SEARCH_EXCERPT_CHARS: usize = 300; - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum FileSearchMode { - Keyword, - Regex, - List, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum FileSearchCaseMode { - Sensitive, - Insensitive, -} - -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct FileSearchOptions { - pub mode: FileSearchMode, - pub query: Option, - pub case_mode: Option, - pub recursive: bool, - pub include: Vec, - pub exclude: Vec, - pub include_ignored: bool, - pub max_results: usize, -} - -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct FileSearchMatch { - pub path: PathUri, - pub line_number: u64, - pub excerpt: String, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct FileSearchOutcome { - pub files: Vec, - pub matches: Vec, - pub skipped_binary_files: usize, - pub skipped_unreadable_entries: usize, - pub errors: Vec, - pub truncated: bool, -} - #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct ReadFileOptions { pub follow_symlinks: bool, diff --git a/codex-rs/file-system/src/search.rs b/codex-rs/file-system/src/search.rs new file mode 100644 index 000000000000..4581bf523034 --- /dev/null +++ b/codex-rs/file-system/src/search.rs @@ -0,0 +1,56 @@ +use crate::WalkError; +use codex_utils_path_uri::PathUri; +use serde::Deserialize; +use serde::Serialize; + +/// Maximum number of search results accepted by the filesystem search API. +pub const MAX_FILE_SEARCH_RESULTS: usize = 1_000; +/// Maximum characters retained for one matching line excerpt. +pub const MAX_FILE_SEARCH_EXCERPT_CHARS: usize = 300; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum FileSearchMode { + Keyword, + Regex, + List, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum FileSearchCaseMode { + Sensitive, + Insensitive, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileSearchOptions { + pub mode: FileSearchMode, + pub query: Option, + pub case_mode: Option, + pub recursive: bool, + pub include: Vec, + pub exclude: Vec, + pub include_ignored: bool, + pub max_results: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileSearchMatch { + pub path: PathUri, + pub line_number: u64, + pub excerpt: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileSearchOutcome { + pub files: Vec, + pub matches: Vec, + pub skipped_binary_files: usize, + pub skipped_unreadable_entries: usize, + pub errors: Vec, + pub truncated: bool, +}