diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs index 920fc9764e..647c84fc5a 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs @@ -10,7 +10,8 @@ use serde_json::{json, Value}; use std::path::{Path, PathBuf}; use tool_runtime::search::glob_search::{ build_remote_find_command, build_remote_rg_command, collect_remote_glob_result, - derive_walk_root, execute_local_glob, normalize_path, LocalGlobRequest, + derive_walk_root, execute_local_glob, extract_glob_base_directory, normalize_path, + LocalGlobRequest, }; pub struct GlobTool; @@ -29,6 +30,54 @@ impl GlobTool { const GLOB_RESULT_LIMIT: usize = 100; +#[derive(Debug, Clone, PartialEq, Eq)] +struct EffectiveGlobSearch { + search_path: String, + pattern: String, +} + +/// Converts an absolute glob pattern into a search root plus a glob relative to +/// that root. `rg --glob` matches paths relative to its search root, so passing +/// an absolute pattern through unchanged can never match a workspace walk. +fn resolve_effective_glob_search( + search_path: &str, + pattern: &str, + is_remote_workspace: bool, +) -> EffectiveGlobSearch { + let (base_dir, relative_pattern) = extract_glob_base_directory(pattern); + let is_absolute_base = if is_remote_workspace { + base_dir.starts_with('/') + } else { + Path::new(&base_dir).is_absolute() + }; + + if is_absolute_base { + EffectiveGlobSearch { + search_path: base_dir, + pattern: relative_pattern, + } + } else { + EffectiveGlobSearch { + search_path: search_path.to_string(), + pattern: pattern.to_string(), + } + } +} + +/// Selects whether the local workspace-search backend can serve this path. +/// Flashgrep sessions are scoped to one canonical workspace root; paths outside +/// that root skip this backend and continue through the caller's fallback chain. +fn workspace_search_supports_search_path(workspace_root: &Path, search_path: &Path) -> bool { + let Ok(workspace_root) = dunce::canonicalize(workspace_root) else { + return false; + }; + let Ok(search_path) = dunce::canonicalize(search_path) else { + return false; + }; + + search_path.starts_with(workspace_root) +} + fn render_glob_result_text( pattern: &str, matches: &[String], @@ -78,6 +127,13 @@ fn relative_base_note(original_search_path: &Path, walk_root: &Path) -> Option Option { + relative_base_note(Path::new(original_search_path), remote_walk_root) +} + fn relative_json_field(base_note: Option<&str>) -> Value { base_note.map_or(Value::Null, |base| json!(base)) } @@ -97,12 +153,13 @@ impl Tool for GlobTool { } async fn description(&self) -> BitFunResult { - Ok(r#"Fast file pattern matching tool support Standard Unix-style glob syntax + Ok(r#"Fast file pattern matching tool - Supports glob patterns like "**/*.js" or "src/**/*.ts" - Returns matching file paths - Use this tool when you need to find files by name patterns - The path parameter may be workspace-relative, an absolute path inside the current workspace, or an exact `bitfun://...` URI returned by another tool -- Omit path to search the current workspace. Do not use host roots or placeholder paths such as `/workspace`. +- An absolute pattern is searched from its static parent directory (for example, `C:/logs/*.log` searches `C:/logs` with `*.log`) +- Omit path to search the current workspace. Do not use placeholder paths such as `/workspace`. - Returns up to 100 matching paths. Narrow the pattern or search a more specific path if the result is truncated. - You can call multiple tools in a single response. It is always better to speculatively perform multiple searches in parallel if they are potentially useful. "#.to_string()) @@ -122,7 +179,7 @@ impl Tool for GlobTool { }, "path": { "type": "string", - "description": "The directory to search in. Omit this field to search the current workspace. If provided, use a workspace-relative path, an absolute path inside the current workspace, or an exact bitfun:// URI. Do not enter \"undefined\", \"null\", host roots, or placeholder paths such as /workspace." + "description": "The directory to search in. Omit this field to search the current workspace. Do not enter \"undefined\", \"null\", host roots, or placeholder paths such as /workspace." } }, "required": ["pattern"] @@ -178,6 +235,16 @@ impl Tool for GlobTool { } }; let limit = GLOB_RESULT_LIMIT; + let mut effective_glob = resolve_effective_glob_search( + &resolved.resolved_path, + pattern, + resolved.uses_remote_workspace_backend(), + ); + + if resolved.uses_remote_workspace_backend() { + effective_glob.search_path = + context.resolve_workspace_tool_path(&effective_glob.search_path)?; + } if resolved.uses_remote_workspace_backend() { if workspace_search_feature_enabled().await { @@ -191,9 +258,9 @@ impl Tool for GlobTool { "workspace_path is required when Glob path is omitted".to_string(), ) })?; - let resolved_path = PathBuf::from(&resolved.resolved_path); + let resolved_path = PathBuf::from(&effective_glob.search_path); let (_walk_root, effective_pattern) = - resolve_effective_glob_scope(&resolved_path, pattern); + resolve_effective_glob_scope(&resolved_path, &effective_glob.pattern); let repo_root = workspace_root.to_string_lossy().to_string(); let preferred_connection_id = context .workspace @@ -210,7 +277,7 @@ impl Tool for GlobTool { .glob(GlobSearchRequest { repo_root: workspace_root.clone(), search_path: (resolved_path != workspace_root).then_some(resolved_path), - pattern: pattern.to_string(), + pattern: effective_glob.pattern.clone(), limit, }) .await @@ -266,11 +333,12 @@ impl Tool for GlobTool { .ws_shell() .ok_or_else(|| BitFunError::tool("Workspace shell not available".to_string()))?; - let search_dir = resolved.resolved_path.clone(); + let search_dir = effective_glob.search_path.clone(); let search_dir_path = PathBuf::from(&search_dir); let (remote_walk_root, _remote_pattern) = - resolve_effective_glob_scope(&search_dir_path, pattern); - let relative_base = relative_base_note(&search_dir_path, &remote_walk_root); + resolve_effective_glob_scope(&search_dir_path, &effective_glob.pattern); + let relative_base = + remote_shell_result_relative_base(&resolved.resolved_path, &remote_walk_root); let (_stdout, _stderr, exit_code) = ws_shell .exec("command -v rg >/dev/null 2>&1", Some(5_000)) .await @@ -281,14 +349,17 @@ impl Tool for GlobTool { "Glob backend selected: backend=remote_rg, search_path={}, pattern={}", search_dir, pattern ); - (build_remote_rg_command(&search_dir, pattern), true) + ( + build_remote_rg_command(&search_dir, &effective_glob.pattern), + true, + ) } else { info!( "Glob backend selected: backend=remote_find, reason=rg_not_found, search_path={}, pattern={}", search_dir, pattern ); ( - build_remote_find_command(&search_dir, pattern, limit), + build_remote_find_command(&search_dir, &effective_glob.pattern, limit), false, ) }; @@ -335,65 +406,76 @@ impl Tool for GlobTool { } let resolved_str = resolved.resolved_path.clone(); - - if workspace_search_runtime_available().await { - if let Some(search_service) = get_global_workspace_search_service() { - let workspace_root = context - .workspace - .as_ref() - .map(|workspace| PathBuf::from(workspace.root_path_string())) - .ok_or_else(|| { - BitFunError::tool( - "workspace_path is required when Glob path is omitted".to_string(), - ) - })?; - let resolved_path = PathBuf::from(&resolved_str); - let (_walk_root, effective_pattern) = - resolve_effective_glob_scope(&resolved_path, pattern); - let glob_result = search_service - .glob(GlobSearchRequest { - repo_root: workspace_root.clone(), - search_path: (resolved_path != workspace_root).then_some(resolved_path), - pattern: pattern.to_string(), - limit, - }) - .await?; - - let match_count = glob_result.paths.len(); - let total_matches = glob_result.total_matches; - let truncated = glob_result.truncated; - let result_relative_base = result_relative_base_note( - &glob_result.matches_relative_to, - &PathBuf::from(&resolved_str), - ); - let result_text = render_glob_result_text( - pattern, - &glob_result.paths, - total_matches, - truncated, - result_relative_base.as_deref(), - ); - - return Ok(vec![ToolResult::Result { - data: json!({ - "pattern": pattern, - "path": resolved_str, - "effective_pattern": effective_pattern, - "matches_relative_to": relative_json_field(result_relative_base.as_deref()), - "matches": glob_result.paths, - "match_count": match_count, - "total_matches": total_matches, - "truncated": truncated, - "repo_phase": glob_result.repo_status.phase, - "rebuild_recommended": glob_result.repo_status.rebuild_recommended - }), - result_for_assistant: Some(result_text), - image_attachments: None, - }]); + let effective_search_path = PathBuf::from(&effective_glob.search_path); + let workspace_root = context + .workspace + .as_ref() + .map(|workspace| PathBuf::from(workspace.root_path_string())); + + if let Some(workspace_root) = workspace_root.filter(|workspace_root| { + workspace_search_supports_search_path(workspace_root, &effective_search_path) + }) { + if workspace_search_runtime_available().await { + if let Some(search_service) = get_global_workspace_search_service() { + let resolved_path = effective_search_path.clone(); + let (_walk_root, effective_pattern) = + resolve_effective_glob_scope(&resolved_path, &effective_glob.pattern); + let workspace_glob_result = search_service + .glob(GlobSearchRequest { + repo_root: workspace_root.clone(), + search_path: (resolved_path != workspace_root).then_some(resolved_path), + pattern: effective_glob.pattern.clone(), + limit, + }) + .await; + + match workspace_glob_result { + Ok(glob_result) => { + let match_count = glob_result.paths.len(); + let total_matches = glob_result.total_matches; + let truncated = glob_result.truncated; + let result_relative_base = result_relative_base_note( + &glob_result.matches_relative_to, + &PathBuf::from(&resolved_str), + ); + let result_text = render_glob_result_text( + pattern, + &glob_result.paths, + total_matches, + truncated, + result_relative_base.as_deref(), + ); + + return Ok(vec![ToolResult::Result { + data: json!({ + "pattern": pattern, + "path": resolved_str, + "effective_pattern": effective_pattern, + "matches_relative_to": relative_json_field(result_relative_base.as_deref()), + "matches": glob_result.paths, + "match_count": match_count, + "total_matches": total_matches, + "truncated": truncated, + "repo_phase": glob_result.repo_status.phase, + "rebuild_recommended": glob_result.repo_status.rebuild_recommended + }), + result_for_assistant: Some(result_text), + image_attachments: None, + }]); + } + Err(error) => { + warn!( + "Glob tool workspace-search failed; falling back to local rg: {}", + error + ); + } + } + } } } - let resolved_str_for_rg = resolved_str.clone(); - let pattern_for_rg = pattern.to_string(); + + let resolved_str_for_rg = effective_glob.search_path.clone(); + let pattern_for_rg = effective_glob.pattern.clone(); let glob_result = tokio::task::spawn_blocking(move || { execute_local_glob(LocalGlobRequest { search_path: PathBuf::from(resolved_str_for_rg), @@ -444,8 +526,15 @@ impl Tool for GlobTool { #[cfg(test)] mod tests { - use super::{render_glob_result_text, GlobTool}; - use crate::agentic::tools::framework::Tool; + use super::{ + remote_shell_result_relative_base, render_glob_result_text, resolve_effective_glob_search, + workspace_search_supports_search_path, GlobTool, + }; + use crate::agentic::tools::framework::{Tool, ToolUseContext}; + use crate::agentic::tools::ToolRuntimeRestrictions; + use crate::agentic::WorkspaceBinding; + use serde_json::json; + use std::collections::HashMap; use std::fs; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; @@ -464,6 +553,35 @@ mod tests { dir } + fn remote_context(root: &str) -> ToolUseContext { + let session_identity = + crate::service::remote_ssh::workspace_state::workspace_session_identity( + root, + Some("conn-1"), + Some("ssh.dev"), + ) + .expect("remote identity"); + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: Some(WorkspaceBinding::new_remote( + None, + PathBuf::from(root), + "conn-1".to_string(), + "Dev SSH".to_string(), + session_identity, + )), + unlocked_collapsed_tools: Vec::new(), + primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), + custom_data: HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), + } + } + #[test] fn input_schema_does_not_expose_model_controlled_limit() { let schema = GlobTool::new().input_schema(); @@ -522,6 +640,102 @@ mod tests { assert_eq!(relative_pattern, "../*.rs".to_string()); } + #[test] + fn absolute_pattern_uses_its_static_parent_as_the_search_path() { + let root = make_temp_dir("absolute-pattern"); + let transcript_dir = root.join("terminal-transcripts"); + fs::create_dir_all(&transcript_dir).unwrap(); + + let pattern = format!("{}/*.log", transcript_dir.display()); + let effective = resolve_effective_glob_search("E:/workspace", &pattern, false); + + assert_eq!(PathBuf::from(effective.search_path), transcript_dir); + assert_eq!(effective.pattern, "*.log"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn absolute_pattern_searches_its_external_parent_with_local_rg() { + let workspace_root = make_temp_dir("absolute-pattern-workspace"); + let transcript_dir = make_temp_dir("absolute-pattern-transcripts"); + fs::write(transcript_dir.join("session.log"), "transcript").unwrap(); + + let pattern = format!("{}/*.log", transcript_dir.display()); + let effective = + resolve_effective_glob_search(&workspace_root.to_string_lossy(), &pattern, false); + let result = execute_local_glob(LocalGlobRequest { + search_path: PathBuf::from(effective.search_path), + pattern: effective.pattern, + limit: 100, + }) + .unwrap(); + + assert_eq!( + result + .matches + .into_iter() + .map(|path| normalize_path(&path)) + .collect::>(), + vec!["session.log"] + ); + + let _ = fs::remove_dir_all(workspace_root); + let _ = fs::remove_dir_all(transcript_dir); + } + + #[test] + fn remote_absolute_pattern_uses_a_posix_search_path() { + let effective = resolve_effective_glob_search("/workspace", "/var/log/*.log", true); + + assert_eq!(effective.search_path, "/var/log"); + assert_eq!(effective.pattern, "*.log"); + } + + #[tokio::test] + async fn remote_absolute_pattern_outside_workspace_is_rejected() { + let error = GlobTool::new() + .call_impl( + &json!({ "pattern": "/etc/*.conf" }), + &remote_context("/workspace"), + ) + .await + .expect_err("external remote pattern should be rejected"); + + assert!(error + .to_string() + .contains("resolves outside current workspace")); + } + + #[test] + fn remote_absolute_pattern_reports_its_effective_result_base() { + let effective = resolve_effective_glob_search("/workspace", "/workspace/src/*.rs", true); + let base = + remote_shell_result_relative_base("/workspace", &PathBuf::from(effective.search_path)); + + assert_eq!(base.as_deref(), Some("/workspace/src")); + } + + #[test] + fn workspace_search_is_limited_to_the_workspace_root() { + let workspace_root = make_temp_dir("workspace-search-root"); + let workspace_child = workspace_root.join("src"); + fs::create_dir_all(&workspace_child).unwrap(); + let external_root = make_temp_dir("workspace-search-external"); + + assert!(workspace_search_supports_search_path( + &workspace_root, + &workspace_child + )); + assert!(!workspace_search_supports_search_path( + &workspace_root, + &external_root + )); + + let _ = fs::remove_dir_all(workspace_root); + let _ = fs::remove_dir_all(external_root); + } + #[test] fn keeps_shallowest_matches_from_rg_results() { let root = make_temp_dir("limit"); diff --git a/src/crates/execution/tool-execution/src/search/glob_search.rs b/src/crates/execution/tool-execution/src/search/glob_search.rs index c72b2ed17b..de1d3ff8c4 100644 --- a/src/crates/execution/tool-execution/src/search/glob_search.rs +++ b/src/crates/execution/tool-execution/src/search/glob_search.rs @@ -41,10 +41,28 @@ pub fn extract_glob_base_directory(pattern: &str) -> (String, String) { .map(|(idx, _)| idx); if let Some(separator_index) = last_separator { - ( - static_prefix[..separator_index].to_string(), - pattern[separator_index + 1..].to_string(), - ) + let mut base_dir = static_prefix[..separator_index].to_string(); + + // Preserve the root for patterns such as `/*.txt`. On Windows, + // also preserve the separator after a drive prefix: `C:/*.txt` + // must search from `C:/`, not from the drive-relative `C:`. + if base_dir.is_empty() && separator_index == 0 { + base_dir = static_prefix[..1].to_string(); + } + #[cfg(windows)] + if base_dir.len() == 2 + && base_dir.as_bytes()[1] == b':' + && base_dir.as_bytes()[0].is_ascii_alphabetic() + { + base_dir.push( + static_prefix[separator_index..] + .chars() + .next() + .expect("separator index must point to a character"), + ); + } + + (base_dir, pattern[separator_index + 1..].to_string()) } else { (String::new(), pattern.to_string()) } @@ -549,7 +567,7 @@ pub fn build_remote_find_command(search_dir: &str, pattern: &str, limit: usize) #[cfg(test)] mod tests { - use super::{collect_with_walk_fallback, normalize_path}; + use super::{collect_with_walk_fallback, extract_glob_base_directory, normalize_path}; use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -621,4 +639,18 @@ mod tests { assert_eq!(directory_name_matches.total_matches, Some(0)); assert!(!directory_name_matches.truncated); } + + #[test] + fn extract_glob_base_directory_preserves_absolute_roots() { + assert_eq!( + extract_glob_base_directory("/*.txt"), + ("/".to_string(), "*.txt".to_string()) + ); + + #[cfg(windows)] + assert_eq!( + extract_glob_base_directory("C:/*.txt"), + ("C:/".to_string(), "*.txt".to_string()) + ); + } }