Skip to main content

harness/tools/
local.rs

1use std::path::{Path, PathBuf};
2use std::sync::atomic::{AtomicU64, Ordering};
3use std::sync::{Arc, Mutex, OnceLock};
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6use async_trait::async_trait;
7use serde_json::{json, Value};
8use tokio::io::AsyncBufReadExt;
9use tokio::process::Command;
10use tokio_util::sync::CancellationToken;
11
12use crate::shell_risk::{classify_shell_command, ShellRiskLevel};
13use crate::tools::{
14    builtin_tool_specs, ToolFailure, ToolFailureKind, ToolInvocation,
15    ToolOutcome, ToolRuntime, ToolRuntimeError, ToolSpec, MAX_FS_GLOB_RESULTS, MAX_OUTPUT_BYTES,
16};
17use crate::tools::approval::{is_read_only, ApprovalGate};
18
19/// Opaque event emitter: receives structured JSON events produced during tool
20/// execution (e.g. `bash_stdout_line`). Use an `Arc<dyn Fn(Value) + ...>`
21/// or provide a no-op with `Arc::new(|_| {})`.
22pub type EmitFn = Arc<dyn Fn(Value) + Send + Sync + 'static>;
23
24/// Configuration for `LocalToolRuntime`.
25pub struct LocalToolConfig {
26    /// Absolute path to the working directory for all tool operations.
27    /// Relative paths inside tool arguments are resolved against this.
28    /// Defaults to the process's `std::env::current_dir()` when `None`.
29    pub cwd: Option<PathBuf>,
30    /// Controls which tool calls are allowed and which tool specs are
31    /// advertised to the model. Typically `YoloApproval`, `PlanApproval`,
32    /// or a custom gate (e.g. `TauriApproval` in a desktop app).
33    pub approval: Arc<dyn ApprovalGate>,
34    /// Receives structured events produced during tool execution.
35    /// Most callers forward these to a UI channel. Pass `Arc::new(|_| {})`
36    /// to discard them.
37    pub emit: EmitFn,
38}
39
40/// Tool runtime that executes bash, read, write, edit, glob and grep
41/// directly on the local filesystem.
42///
43/// All tool output is capped at `MAX_TOOL_CHARS` characters so large
44/// files and noisy commands don't blow out the model's context window.
45/// Glob/grep skip dependency and build directories automatically.
46#[derive(Clone)]
47pub struct LocalToolRuntime {
48    cwd: PathBuf,
49    approval: Arc<dyn ApprovalGate>,
50    emit: EmitFn,
51}
52
53impl LocalToolRuntime {
54    pub fn new(config: LocalToolConfig) -> Self {
55        let cwd = config.cwd
56            .filter(|p| !p.as_os_str().is_empty())
57            .or_else(|| std::env::current_dir().ok())
58            .unwrap_or_else(|| PathBuf::from("/"));
59        Self { cwd, approval: config.approval, emit: config.emit }
60    }
61
62    fn resolve(&self, path: &str) -> PathBuf {
63        let p = Path::new(path);
64        if p.is_absolute() { p.to_path_buf() } else { self.cwd.join(p) }
65    }
66
67    /// Gate that decides whether a tool invocation may proceed.
68    ///
69    /// * Hard-blocked bash commands are rejected in every mode.
70    /// * Read-only bash / read / glob / grep pass without hitting the gate.
71    /// * Everything else goes to `approval.approve()`.
72    async fn gate(
73        &self,
74        inv: &ToolInvocation,
75        cancel: Option<&CancellationToken>,
76    ) -> Result<(), String> {
77        if inv.name == "bash" {
78            let cmd = inv.input.get("command").and_then(Value::as_str).unwrap_or("");
79            let decision = classify_shell_command(cmd);
80            match decision.level {
81                ShellRiskLevel::Blocked => {
82                    return Err(format!("命令在禁止清单上,已拒绝:{}", decision.reason));
83                }
84                ShellRiskLevel::SafeRead => return Ok(()),
85                ShellRiskLevel::BoundedWrite
86                    if self.approval.advertise_mutating_tools() =>
87                {
88                    return Ok(());
89                }
90                _ => {}
91            }
92        } else if is_read_only(&inv.name) {
93            return Ok(());
94        }
95
96        // Non-read-only tool → delegate to the approval gate.
97        let approved = if let Some(tok) = cancel {
98            tokio::select! {
99                biased;
100                _ = tok.cancelled() => return Err("已取消".into()),
101                result = self.approval.approve(inv) => result,
102            }
103        } else {
104            self.approval.approve(inv).await
105        };
106
107        if approved { Ok(()) } else { Err("操作被拒绝".into()) }
108    }
109}
110
111#[async_trait]
112impl ToolRuntime for LocalToolRuntime {
113    fn specs(&self) -> Vec<ToolSpec> {
114        let all = builtin_tool_specs();
115        if self.approval.advertise_mutating_tools() {
116            all
117        } else {
118            all.into_iter().filter(|s| is_read_only(&s.name)).collect()
119        }
120    }
121
122    async fn invoke(&self, inv: ToolInvocation) -> Result<ToolOutcome, ToolRuntimeError> {
123        self.invoke_cancellable(inv, None).await
124    }
125
126    async fn invoke_cancellable(
127        &self,
128        inv: ToolInvocation,
129        cancel: Option<&CancellationToken>,
130    ) -> Result<ToolOutcome, ToolRuntimeError> {
131        if let Err(reason) = self.gate(&inv, cancel).await {
132            return Ok(ToolOutcome {
133                output: Err(ToolFailure::new(ToolFailureKind::Denied, reason)),
134                attachments: vec![],
135            });
136        }
137        match inv.name.as_str() {
138            "bash"  => bash_invoke(inv, cancel, &self.cwd, self.emit.clone()).await,
139            "read"  => read_invoke(inv, self).await,
140            "write" => write_invoke(inv, self).await,
141            "edit"  => edit_invoke(inv, self).await,
142            "glob"  => glob_invoke(inv, self).await,
143            "grep"  => grep_invoke(inv, self).await,
144            "web_fetch" => crate::tools::web_fetch::invoke(inv).await,
145            other   => Err(ToolRuntimeError::UnknownTool(other.into())),
146        }
147    }
148}
149
150// ── bash ──────────────────────────────────────────────────────────────────────
151
152fn epoch_ms() -> u64 {
153    SystemTime::now()
154        .duration_since(UNIX_EPOCH)
155        .unwrap_or_default()
156        .as_millis() as u64
157}
158
159const CANCEL_TERMINATION_GRACE: Duration = Duration::from_millis(50);
160const CHILD_WAIT_AFTER_KILL: Duration = Duration::from_millis(500);
161const OUTPUT_DRAIN_TIMEOUT: Duration = Duration::from_millis(2_000);
162
163enum BashCompletion {
164    Exited(std::io::Result<std::process::ExitStatus>),
165    SoftTimeout { total_ms: u64, silent_ms: u64 },
166    HardTimeout,
167    Cancelled,
168}
169
170async fn drain_output_tasks(
171    mut stdout_task: tokio::task::JoinHandle<()>,
172    mut stderr_task: tokio::task::JoinHandle<()>,
173) {
174    let drain = async {
175        let _ = (&mut stdout_task).await;
176        let _ = (&mut stderr_task).await;
177    };
178
179    if tokio::time::timeout(OUTPUT_DRAIN_TIMEOUT, drain).await.is_err() {
180        stdout_task.abort();
181        stderr_task.abort();
182        let _ = stdout_task.await;
183        let _ = stderr_task.await;
184    }
185}
186
187#[cfg(unix)]
188fn signal_process_group(process_group_id: u32, signal: libc::c_int) {
189    let pgid = process_group_id as libc::pid_t;
190    if pgid <= 0 {
191        return;
192    }
193
194    let rc = unsafe { libc::killpg(pgid, signal) };
195    if rc == 0 {
196        return;
197    }
198
199    let err = std::io::Error::last_os_error();
200    if err.raw_os_error() != Some(libc::ESRCH) {
201        tracing::debug!(
202            process_group_id,
203            signal,
204            error = %err,
205            "failed to signal bash process group"
206        );
207    }
208}
209
210#[cfg(unix)]
211async fn kill_process_group(process_group_id: u32, child: &mut tokio::process::Child) {
212    signal_process_group(process_group_id, libc::SIGKILL);
213    let _ = child.start_kill();
214    let _ = tokio::time::timeout(CHILD_WAIT_AFTER_KILL, child.wait()).await;
215}
216
217#[cfg(not(unix))]
218async fn kill_process_group(_: u32, child: &mut tokio::process::Child) {
219    let _ = child.start_kill();
220    let _ = tokio::time::timeout(CHILD_WAIT_AFTER_KILL, child.wait()).await;
221}
222
223#[cfg(unix)]
224async fn terminate_process_group(process_group_id: u32, child: &mut tokio::process::Child) {
225    signal_process_group(process_group_id, libc::SIGTERM);
226
227    let child_exited =
228        tokio::time::timeout(CANCEL_TERMINATION_GRACE, child.wait()).await.is_ok();
229
230    // Match Codex's cancellation behavior: give the shell a short SIGTERM
231    // cleanup window, then SIGKILL the group so TERM-ignoring descendants do
232    // not survive after the parent exits.
233    signal_process_group(process_group_id, libc::SIGKILL);
234    if !child_exited {
235        let _ = child.start_kill();
236        let _ = tokio::time::timeout(CHILD_WAIT_AFTER_KILL, child.wait()).await;
237    }
238}
239
240#[cfg(not(unix))]
241async fn terminate_process_group(_: u32, child: &mut tokio::process::Child) {
242    kill_process_group(0, child).await;
243}
244
245async fn bash_invoke(
246    inv: ToolInvocation,
247    cancel: Option<&CancellationToken>,
248    cwd: &Path,
249    emit: EmitFn,
250) -> Result<ToolOutcome, ToolRuntimeError> {
251    let command = req_str(&inv, "command")?;
252    let id = &*inv.id;
253
254    // Dual-layer timeout:
255    //   soft_timeout_ms — no-output detector: if the process produces
256    //     nothing for this long it is killed and the model is told to retry.
257    //     Streaming output resets the clock, so a long build that prints
258    //     progress will never hit this.
259    //   timeout_ms — absolute hard ceiling, regardless of output activity.
260    let soft_ms: u64 = inv.input.get("soft_timeout_ms")
261        .and_then(|v| v.as_u64())
262        .unwrap_or(10_000);
263    let hard_ms: u64 = inv.input.get("timeout_ms")
264        .and_then(|v| v.as_u64())
265        .unwrap_or(120_000)
266        .min(3_600_000);
267
268    let last_out = Arc::new(AtomicU64::new(epoch_ms()));
269    let stdout_buf = Arc::new(Mutex::new(String::new()));
270    let stderr_buf = Arc::new(Mutex::new(String::new()));
271
272    let shell = if Path::new("/bin/bash").exists() { "/bin/bash" } else { "/bin/sh" };
273    let mut cmd = Command::new(shell);
274    cmd.args(["-lc", command])
275        .current_dir(cwd)
276        .kill_on_drop(true)
277        .stdout(std::process::Stdio::piped())
278        .stderr(std::process::Stdio::piped());
279
280    #[cfg(unix)]
281    cmd.process_group(0);
282
283    let mut child = cmd
284        .spawn()
285        .map_err(|e| ToolRuntimeError::Runtime(format!("spawn failed: {e}")))?;
286    let child_pid = child.id();
287
288    let raw_stdout = child.stdout.take().expect("stdout piped");
289    let raw_stderr = child.stderr.take().expect("stderr piped");
290
291    let act1 = last_out.clone();
292    let emit_out = emit.clone();
293    let stdout_acc = stdout_buf.clone();
294    let stdout_task = tokio::spawn(async move {
295        let mut lines = tokio::io::BufReader::new(raw_stdout).lines();
296        while let Ok(Some(line)) = lines.next_line().await {
297            emit_out(json!({ "type": "bash_stdout_line", "line": line, "stream": "stdout" }));
298            act1.store(epoch_ms(), Ordering::Relaxed);
299            if let Ok(mut acc) = stdout_acc.lock() {
300                acc.push_str(&line);
301                acc.push('\n');
302            }
303        }
304    });
305
306    let act2 = last_out.clone();
307    let emit_err = emit.clone();
308    let stderr_acc = stderr_buf.clone();
309    let stderr_task = tokio::spawn(async move {
310        let mut lines = tokio::io::BufReader::new(raw_stderr).lines();
311        while let Ok(Some(line)) = lines.next_line().await {
312            emit_err(json!({ "type": "bash_stdout_line", "line": line, "stream": "stderr" }));
313            act2.store(epoch_ms(), Ordering::Relaxed);
314            if let Ok(mut acc) = stderr_acc.lock() {
315                acc.push_str(&line);
316                acc.push('\n');
317            }
318        }
319    });
320
321    let watcher_ts = last_out.clone();
322    let soft_watcher = async move {
323        let start = epoch_ms();
324        loop {
325            tokio::time::sleep(Duration::from_millis(500)).await;
326            let now = epoch_ms();
327            if now.saturating_sub(start) >= soft_ms
328                && now.saturating_sub(watcher_ts.load(Ordering::Relaxed)) >= soft_ms
329            {
330                return (now.saturating_sub(start), now.saturating_sub(watcher_ts.load(Ordering::Relaxed)));
331            }
332        }
333    };
334
335    let hard_timer = tokio::time::sleep(Duration::from_millis(hard_ms));
336    let cancellation = async {
337        if let Some(tok) = cancel {
338            tok.cancelled().await;
339        } else {
340            std::future::pending::<()>().await;
341        }
342    };
343
344    let timeout_outcome = |kind: &str, message: String| ToolOutcome {
345        output: Ok(json!({
346            "command": command,
347            "shell": shell,
348            "stdout": bound_output(stdout_buf.lock().map(|s| s.clone()).unwrap_or_default(), id, "stdout"),
349            "stderr": bound_output(stderr_buf.lock().map(|s| s.clone()).unwrap_or_default(), id, "stderr"),
350            "exit_code": null,
351            "success": false,
352            "timed_out": true,
353            "timeout_kind": kind,
354            "message": message,
355        })),
356        attachments: vec![],
357    };
358    let soft_err = |tot: u64, sil: u64| timeout_outcome(
359        "soft",
360        format!(
361            "Command produced no output for {sil}ms (total {tot}ms). \
362Retry with larger `soft_timeout_ms` or `timeout_ms` if it is expected to take longer."
363        ),
364    );
365    let hard_err = || timeout_outcome(
366        "hard",
367        format!(
368            "Command did not finish in {hard_ms}ms. Retry with a larger `timeout_ms` if it is expected to take longer."
369        ),
370    );
371
372    let completion = tokio::select! {
373        biased;
374        _ = cancellation => BashCompletion::Cancelled,
375        status = child.wait() => BashCompletion::Exited(status),
376        (tot, sil) = soft_watcher => BashCompletion::SoftTimeout { total_ms: tot, silent_ms: sil },
377        _ = hard_timer => BashCompletion::HardTimeout,
378    };
379
380    let status_result = match completion {
381        BashCompletion::Exited(status) => status,
382        BashCompletion::SoftTimeout { total_ms, silent_ms } => {
383            if let Some(pid) = child_pid {
384                kill_process_group(pid, &mut child).await;
385            }
386            drain_output_tasks(stdout_task, stderr_task).await;
387            return Ok(soft_err(total_ms, silent_ms));
388        }
389        BashCompletion::HardTimeout => {
390            if let Some(pid) = child_pid {
391                kill_process_group(pid, &mut child).await;
392            }
393            drain_output_tasks(stdout_task, stderr_task).await;
394            return Ok(hard_err());
395        }
396        BashCompletion::Cancelled => {
397            if let Some(pid) = child_pid {
398                terminate_process_group(pid, &mut child).await;
399            }
400            drain_output_tasks(stdout_task, stderr_task).await;
401            return Ok(ToolOutcome {
402                output: Err(ToolFailure::new(ToolFailureKind::Runtime, "cancelled")),
403                attachments: vec![],
404            });
405        }
406    };
407
408    drain_output_tasks(stdout_task, stderr_task).await;
409    let stdout = stdout_buf.lock().map(|s| s.clone()).unwrap_or_default();
410    let stderr = stderr_buf.lock().map(|s| s.clone()).unwrap_or_default();
411
412    let exit_code = status_result.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1);
413
414    Ok(ToolOutcome {
415        output: Ok(json!({
416            "command": command,
417            "shell": shell,
418            "stdout": bound_output(stdout, id, "stdout"),
419            "stderr": bound_output(stderr, id, "stderr"),
420            "exit_code": exit_code,
421            "success": exit_code == 0,
422        })),
423        attachments: vec![],
424    })
425}
426
427// ── read ──────────────────────────────────────────────────────────────────────
428
429async fn read_invoke(inv: ToolInvocation, rt: &LocalToolRuntime) -> Result<ToolOutcome, ToolRuntimeError> {
430    let path = req_str(&inv, "path")?;
431    let resolved = rt.resolve(path);
432    match tokio::fs::read_to_string(&resolved).await {
433        Ok(content) => {
434            let total = content.lines().count();
435            let offset = inv.input.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize;
436            let limit = inv.input.get("limit").and_then(Value::as_u64)
437                .map(|v| v.clamp(1, 2_000) as usize);
438            let selected: Vec<&str> = match limit {
439                Some(n) => content.lines().skip(offset).take(n).collect(),
440                None    => content.lines().skip(offset).collect(),
441            };
442            let end = offset + selected.len();
443            let text = if selected.is_empty() {
444                String::new()
445            } else {
446                let mut t = selected.join("\n");
447                if content.ends_with('\n') && end == total { t.push('\n'); }
448                t
449            };
450            Ok(ToolOutcome {
451                output: Ok(json!({
452                    "path": resolved.to_string_lossy(),
453                    "content": truncate(text),
454                    "offset": offset,
455                    "limit": limit,
456                    "start_line": if selected.is_empty() { Value::Null } else { json!(offset + 1) },
457                    "end_line": if selected.is_empty() { Value::Null } else { json!(end) },
458                    "total_lines": total,
459                    "truncated": limit.map(|n| offset + n < total).unwrap_or(false),
460                })),
461                attachments: vec![],
462            })
463        }
464        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ToolOutcome {
465            output: Err(ToolFailure::new(ToolFailureKind::NotFound,
466                format!("file not found: {}", resolved.display()))),
467            attachments: vec![],
468        }),
469        Err(e) => Ok(ToolOutcome {
470            output: Err(ToolFailure::new(ToolFailureKind::Runtime, format!("read error: {e}"))),
471            attachments: vec![],
472        }),
473    }
474}
475
476// ── write ─────────────────────────────────────────────────────────────────────
477
478async fn write_invoke(inv: ToolInvocation, rt: &LocalToolRuntime) -> Result<ToolOutcome, ToolRuntimeError> {
479    let path = req_str(&inv, "path")?;
480    let content = req_str(&inv, "content")?;
481    let resolved = rt.resolve(path);
482    if let Some(parent) = resolved.parent() {
483        if !parent.as_os_str().is_empty() {
484            tokio::fs::create_dir_all(parent).await
485                .map_err(|e| ToolRuntimeError::Runtime(format!("mkdir: {e}")))?;
486        }
487    }
488    tokio::fs::write(&resolved, content).await
489        .map_err(|e| ToolRuntimeError::Runtime(format!("write error: {e}")))?;
490    Ok(ToolOutcome {
491        output: Ok(json!({ "path": resolved.to_string_lossy(), "written": true })),
492        attachments: vec![],
493    })
494}
495
496// ── edit ──────────────────────────────────────────────────────────────────────
497
498async fn edit_invoke(inv: ToolInvocation, rt: &LocalToolRuntime) -> Result<ToolOutcome, ToolRuntimeError> {
499    let path = req_str(&inv, "path")?;
500    let old_string = req_str(&inv, "old_string")?;
501    let new_string = inv.input.get("new_string").and_then(Value::as_str).unwrap_or("");
502    let replace_all = inv.input.get("replace_all").and_then(Value::as_bool).unwrap_or(false);
503    let resolved = rt.resolve(path);
504
505    let content = match tokio::fs::read_to_string(&resolved).await {
506        Ok(c) => c,
507        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ToolOutcome {
508            output: Err(ToolFailure::new(ToolFailureKind::NotFound,
509                format!("file not found: {}", resolved.display()))),
510            attachments: vec![],
511        }),
512        Err(e) => return Err(ToolRuntimeError::Runtime(e.to_string())),
513    };
514
515    let occurrences = content.matches(old_string).count();
516    if occurrences == 0 {
517        return Ok(ToolOutcome {
518            output: Err(ToolFailure::new(ToolFailureKind::InvalidInput,
519                "Could not find old_string in the file. It must match exactly, including whitespace and indentation. Read the file again before retrying.".to_string())),
520            attachments: vec![],
521        });
522    }
523    if !replace_all && occurrences > 1 {
524        return Ok(ToolOutcome {
525            output: Err(ToolFailure::new(ToolFailureKind::InvalidInput,
526                format!("Found {occurrences} exact matches for old_string. Provide more surrounding context or set replace_all=true."))),
527            attachments: vec![],
528        });
529    }
530
531    let replaced = if replace_all { occurrences } else { 1 };
532    let new_content = if replace_all {
533        content.replace(old_string, new_string)
534    } else {
535        content.replacen(old_string, new_string, 1)
536    };
537    tokio::fs::write(&resolved, new_content).await
538        .map_err(|e| ToolRuntimeError::Runtime(e.to_string()))?;
539    Ok(ToolOutcome {
540        output: Ok(json!({
541            "path": resolved.to_string_lossy(),
542            "replaced": replaced,
543            "old_lines": old_string.lines().count(),
544            "new_lines": new_string.lines().count(),
545        })),
546        attachments: vec![],
547    })
548}
549
550// ── external-tool detection ─────────────────────────────────────────────────
551
552/// Cap on grep matches assembled from a streamed `rg --json` run. Hitting it
553/// stops the read and kills rg (rather than draining the whole match set),
554/// mirroring how the glob tools cap a walk at [`MAX_FS_GLOB_RESULTS`].
555const MAX_GREP_MATCHES: usize = 5_000;
556
557/// Probe PATH for an external search tool, trying each candidate name in order
558/// and returning the first that answers `--version`. The probe spawns a child
559/// once; the result is cached for the process lifetime, so every later call is
560/// a lock-free read. `None` means the built-in implementation is used instead
561/// — this library never downloads a binary.
562fn probe_tool(names: &[&str]) -> Option<String> {
563    for name in names {
564        let ok = std::process::Command::new(name)
565            .arg("--version")
566            .stdin(std::process::Stdio::null())
567            .stdout(std::process::Stdio::null())
568            .stderr(std::process::Stdio::null())
569            .status()
570            .map(|s| s.success())
571            .unwrap_or(false);
572        if ok {
573            return Some((*name).to_string());
574        }
575    }
576    None
577}
578
579/// Path/command name for ripgrep, or `None` when it is not installed. Used by
580/// the grep tool; glob uses the `ignore` crate directly and needs no binary.
581fn ripgrep_bin() -> Option<&'static str> {
582    static RG: OnceLock<Option<String>> = OnceLock::new();
583    RG.get_or_init(|| probe_tool(&["rg"])).as_deref()
584}
585
586// ── glob ──────────────────────────────────────────────────────────────────────
587
588async fn glob_invoke(inv: ToolInvocation, rt: &LocalToolRuntime) -> Result<ToolOutcome, ToolRuntimeError> {
589    let pattern = req_str(&inv, "pattern")?.to_string();
590    let base = match inv.input.get("path").and_then(Value::as_str).filter(|s| !s.is_empty()) {
591        Some(p) => rt.resolve(p),
592        None    => rt.cwd.clone(),
593    };
594
595    // Glob is served entirely by the `ignore` crate — the same walker +
596    // gitignore engine ripgrep/fd are built on. Using it directly (rather than
597    // shelling out to fd) gives one deterministic, gitignore-aware behaviour
598    // with no subprocess, no PATH dependency, and no fd-vs-fallback divergence.
599    let (matches, truncated) = glob_with_ignore(&pattern, &base);
600
601    Ok(ToolOutcome {
602        output: Ok(json!({
603            "pattern": pattern,
604            "count": matches.len(),
605            "matches": matches,
606            "truncated": truncated,
607        })),
608        attachments: vec![],
609    })
610}
611
612/// Glob implementation, built on the `ignore` crate — the same walker +
613/// gitignore engine that ripgrep and fd use internally. A recursive,
614/// .gitignore/.ignore-aware walk in which a slash-less pattern like `*.rs`
615/// matches by file name at any depth, `**/*.rs` includes top-level files, and a
616/// `/`-bearing pattern (`src/*.rs`) is anchored to the search root (matching
617/// `rg -g` / git semantics). Returns files only, relative to `base`, sorted,
618/// capped at [`MAX_FS_GLOB_RESULTS`]. Honours gitignore in and out of a repo;
619/// hidden paths are searched only for dot patterns.
620fn glob_with_ignore(pattern: &str, base: &Path) -> (Vec<String>, bool) {
621    use ignore::overrides::OverrideBuilder;
622    use ignore::WalkBuilder;
623
624    // The pattern becomes a whitelist override. Override globs use the same
625    // gitignore matching semantics fd applies, which is what keeps the two
626    // backends in lockstep.
627    let mut ob = OverrideBuilder::new(base);
628    if ob.add(pattern).is_err() {
629        return (Vec::new(), false);
630    }
631    let overrides = match ob.build() {
632        Ok(o) => o,
633        Err(_) => return (Vec::new(), false),
634    };
635
636    let mut wb = WalkBuilder::new(base);
637    wb.overrides(overrides)
638        // fd --no-require-git: honour .gitignore/.ignore in and out of a repo.
639        .require_git(false)
640        // hidden(true) skips hidden entries; only search them for dot patterns.
641        .hidden(!pattern.starts_with('.'));
642
643    let mut matches: Vec<String> = Vec::new();
644    let mut truncated = false;
645    for result in wb.build() {
646        let Ok(entry) = result else { continue };
647        // Files only (the tool lists files; fd uses --type f).
648        if entry.file_type().is_none_or(|t| t.is_dir()) {
649            continue;
650        }
651        let Ok(rel) = entry.path().strip_prefix(base) else { continue };
652        if matches.len() >= MAX_FS_GLOB_RESULTS {
653            truncated = true;
654            break;
655        }
656        matches.push(rel.to_string_lossy().replace('\\', "/"));
657    }
658    matches.sort();
659    (matches, truncated)
660}
661
662// ── grep ──────────────────────────────────────────────────────────────────────
663
664async fn grep_invoke(inv: ToolInvocation, rt: &LocalToolRuntime) -> Result<ToolOutcome, ToolRuntimeError> {
665    let pattern = req_str(&inv, "pattern")?.to_string();
666    let ci = inv.input.get("case_insensitive").and_then(Value::as_bool).unwrap_or(false);
667    let search = match inv.input.get("path").and_then(Value::as_str).filter(|s| !s.is_empty()) {
668        Some(p) => rt.resolve(p),
669        None    => rt.cwd.clone(),
670    };
671
672    // Prefer ripgrep: gitignore-aware, faster, and streamed via `--json` so we
673    // can stop (and kill the child) the moment we have enough matches. Falls
674    // back to system grep when rg is absent or fails to spawn.
675    if let Some(rg) = ripgrep_bin() {
676        if let Some(outcome) = grep_with_rg(rg, &pattern, ci, &search, &rt.cwd, &inv.id).await? {
677            return Ok(outcome);
678        }
679    }
680    grep_with_system(&pattern, ci, &search, &rt.cwd, &inv.id).await
681}
682
683/// Stream `rg --json` and rebuild the same `path:line:text` lines the
684/// system-grep fallback emits. Returns `Ok(None)` when rg could not be spawned
685/// (caller falls back); `Ok(Some(outcome))` otherwise.
686async fn grep_with_rg(
687    rg: &str,
688    pattern: &str,
689    ci: bool,
690    search: &Path,
691    cwd: &Path,
692    id: &str,
693) -> Result<Option<ToolOutcome>, ToolRuntimeError> {
694    let mut cmd = Command::new(rg);
695    cmd.arg("--json");
696    if ci {
697        cmd.arg("-i");
698    }
699    cmd.arg("-e").arg(pattern).arg("--").arg(search);
700    cmd.current_dir(cwd)
701        .kill_on_drop(true)
702        .stdin(std::process::Stdio::null())
703        .stdout(std::process::Stdio::piped())
704        .stderr(std::process::Stdio::null());
705
706    let mut child = match cmd.spawn() {
707        Ok(c) => c,
708        Err(_) => return Ok(None), // rg vanished between probe and spawn → fall back
709    };
710    let raw_stdout = child.stdout.take().expect("stdout piped");
711
712    let mut out = String::new();
713    let mut count = 0usize;
714    let mut truncated = false;
715    let mut lines = tokio::io::BufReader::new(raw_stdout).lines();
716    let read = async {
717        while let Some(line) = lines.next_line().await.ok().flatten() {
718            let Ok(ev) = serde_json::from_str::<Value>(&line) else { continue };
719            if ev.get("type").and_then(Value::as_str) != Some("match") {
720                continue;
721            }
722            let data = &ev["data"];
723            // Non-UTF8 matches arrive as {"bytes": …} instead of {"text": …}; skip them.
724            let (Some(path), Some(line_no), Some(text)) = (
725                data["path"]["text"].as_str(),
726                data["line_number"].as_u64(),
727                data["lines"]["text"].as_str(),
728            ) else {
729                continue;
730            };
731            out.push_str(path);
732            out.push(':');
733            out.push_str(&line_no.to_string());
734            out.push(':');
735            out.push_str(text.trim_end_matches('\n'));
736            out.push('\n');
737            count += 1;
738            if count >= MAX_GREP_MATCHES || out.len() > MAX_OUTPUT_BYTES {
739                truncated = true;
740                break;
741            }
742        }
743    };
744    // Same 30s ceiling as the system-grep path.
745    let timed_out = tokio::time::timeout(Duration::from_secs(30), read).await.is_err();
746    // Stop rg regardless of why we finished (limit hit / EOF / timeout).
747    let _ = child.start_kill();
748    let _ = tokio::time::timeout(CHILD_WAIT_AFTER_KILL, child.wait()).await;
749
750    if timed_out {
751        return Ok(Some(ToolOutcome {
752            output: Err(ToolFailure::new(ToolFailureKind::Timeout, "grep timed out after 30s")),
753            attachments: vec![],
754        }));
755    }
756    Ok(Some(ToolOutcome {
757        output: Ok(json!({
758            "pattern": pattern,
759            "matches": bound_output(out, id, "matches"),
760            "truncated": truncated,
761        })),
762        attachments: vec![],
763    }))
764}
765
766/// Fallback grep: shell out to system `grep -rnE` with a fixed set of pruned
767/// directories. Used when ripgrep is not installed.
768async fn grep_with_system(
769    pattern: &str,
770    ci: bool,
771    search: &Path,
772    cwd: &Path,
773    id: &str,
774) -> Result<ToolOutcome, ToolRuntimeError> {
775    let mut cmd = Command::new("grep");
776    cmd.args(["-rn", "-E"]);
777    if ci { cmd.arg("-i"); }
778    cmd.args([
779        "--exclude-dir=node_modules",
780        "--exclude-dir=target",
781        "--exclude-dir=.git",
782        "--exclude-dir=dist",
783        "--exclude-dir=build",
784        "--exclude-dir=__pycache__",
785        "--exclude-dir=.venv",
786        "--exclude-dir=vendor",
787        "--exclude-dir=.next",
788    ]);
789    cmd.arg("-e").arg(pattern).arg("--").arg(search);
790    cmd.current_dir(cwd);
791
792    match tokio::time::timeout(Duration::from_secs(30), cmd.output()).await {
793        Err(_) => Ok(ToolOutcome {
794            output: Err(ToolFailure::new(ToolFailureKind::Timeout, "grep timed out after 30s")),
795            attachments: vec![],
796        }),
797        Ok(Err(e)) => Err(ToolRuntimeError::Runtime(format!("grep spawn failed: {e}"))),
798        Ok(Ok(out)) => {
799            let code = out.status.code().unwrap_or(-1);
800            if code >= 2 {
801                let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
802                return Ok(ToolOutcome {
803                    output: Err(ToolFailure::new(ToolFailureKind::Runtime,
804                        truncate(format!("grep error: {stderr}")))),
805                    attachments: vec![],
806                });
807            }
808            let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
809            Ok(ToolOutcome {
810                output: Ok(json!({
811                    "pattern": pattern,
812                    "matches": bound_output(stdout, id, "matches"),
813                    "truncated": false,
814                })),
815                attachments: vec![],
816            })
817        }
818    }
819}
820
821// ── output cap ────────────────────────────────────────────────────────────────
822
823/// Write `content` to `/tmp/harness_out_{id}_{suffix}.txt` and return an
824/// error-aware head+tail preview with the path. Used for bash stdout/stderr
825/// and grep matches. Spills only when over budget (see `bounded_preview`).
826fn bound_output(content: String, id: &str, suffix: &str) -> String {
827    let path = format!("/tmp/harness_out_{id}_{suffix}.txt");
828    match crate::tools::bounded_preview(&content, &path) {
829        None => content,
830        Some(preview) => {
831            let _ = std::fs::write(&path, &content);
832            preview
833        }
834    }
835}
836
837/// Simple safety truncation used only by the read tool as a backstop for
838/// pages that exceed the output budget after pagination.
839fn truncate(s: String) -> String {
840    crate::tools::clip_head(s)
841}
842
843// ── helpers ───────────────────────────────────────────────────────────────────
844
845fn req_str<'a>(inv: &'a ToolInvocation, key: &str) -> Result<&'a str, ToolRuntimeError> {
846    inv.input
847        .get(key)
848        .and_then(Value::as_str)
849        .filter(|s| !s.is_empty())
850        .ok_or_else(|| ToolRuntimeError::InvalidInput {
851            tool: inv.name.clone(),
852            message: format!("missing field `{key}`"),
853        })
854}
855
856#[cfg(test)]
857mod tests {
858    use super::*;
859    use crate::tools::approval::YoloApproval;
860
861    fn runtime() -> LocalToolRuntime {
862        LocalToolRuntime::new(LocalToolConfig {
863            cwd: Some(std::env::temp_dir()),
864            approval: Arc::new(YoloApproval),
865            emit: Arc::new(|_| {}),
866        })
867    }
868
869    #[cfg(unix)]
870    async fn processes_matching_marker(marker: &str) -> Vec<(libc::pid_t, libc::pid_t, String)> {
871        let output = Command::new("ps")
872            .args(["-axo", "pid=,pgid=,command="])
873            .output()
874            .await
875            .expect("ps should run");
876        String::from_utf8_lossy(&output.stdout)
877            .lines()
878            .filter(|line| line.contains(marker))
879            .filter_map(|line| {
880                let mut parts = line.split_whitespace();
881                let pid = parts.next()?.parse().ok()?;
882                let pgid = parts.next()?.parse().ok()?;
883                let command = parts.collect::<Vec<_>>().join(" ");
884                Some((pid, pgid, command))
885            })
886            .collect()
887    }
888
889    #[cfg(unix)]
890    async fn cleanup_marker_processes(marker: &str) {
891        for (_, pgid, _) in processes_matching_marker(marker).await {
892            unsafe {
893                libc::kill(-pgid, libc::SIGKILL);
894            }
895        }
896    }
897
898    #[cfg(unix)]
899    fn shell_quote(value: &str) -> String {
900        format!("'{}'", value.replace('\'', "'\\''"))
901    }
902
903    #[cfg(unix)]
904    async fn wait_for_marker_process(marker: &str) -> bool {
905        let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
906        while tokio::time::Instant::now() < deadline {
907            if !processes_matching_marker(marker).await.is_empty() {
908                return true;
909            }
910            tokio::time::sleep(Duration::from_millis(50)).await;
911        }
912        false
913    }
914
915    #[tokio::test]
916    async fn bash_non_zero_exit_returns_structured_result() {
917        let out = runtime()
918            .invoke(ToolInvocation {
919                id: "tc_nonzero".into(),
920                name: "bash".into(),
921                input: json!({"command": "printf nope >&2; exit 7"}),
922                raw_emitted_args: None,
923            })
924            .await
925            .unwrap()
926            .output
927            .unwrap();
928        assert_eq!(out["exit_code"], 7);
929        assert_eq!(out["success"], false);
930        assert_eq!(out["stderr"], "nope\n");
931    }
932
933    #[tokio::test]
934    async fn bash_timeout_returns_structured_result() {
935        let out = runtime()
936            .invoke(ToolInvocation {
937                id: "tc_timeout".into(),
938                name: "bash".into(),
939                input: json!({
940                    "command": "sleep 2",
941                    "soft_timeout_ms": 1000,
942                    "timeout_ms": 5000
943                }),
944                raw_emitted_args: None,
945            })
946            .await
947            .unwrap()
948            .output
949            .unwrap();
950        assert_eq!(out["success"], false);
951        assert_eq!(out["timed_out"], true);
952        assert_eq!(out["timeout_kind"], "soft");
953    }
954
955    #[cfg(unix)]
956    #[tokio::test]
957    async fn bash_timeout_kills_process_group_children() {
958        let marker = format!("harness-timeout-pgid-{}", epoch_ms());
959        cleanup_marker_processes(&marker).await;
960
961        let command = format!("sh -c 'while :; do sleep 5; done' {marker} & wait");
962        let out = runtime()
963            .invoke(ToolInvocation {
964                id: "tc_timeout_pgid".into(),
965                name: "bash".into(),
966                input: json!({
967                    "command": command,
968                    "soft_timeout_ms": 200,
969                    "timeout_ms": 5000
970                }),
971                raw_emitted_args: None,
972            })
973            .await
974            .unwrap()
975            .output
976            .unwrap();
977        assert_eq!(out["success"], false);
978        assert_eq!(out["timed_out"], true);
979
980        tokio::time::sleep(Duration::from_millis(500)).await;
981        let leftovers = processes_matching_marker(&marker).await;
982        cleanup_marker_processes(&marker).await;
983        assert!(
984            leftovers.is_empty(),
985            "timeout left child processes running: {leftovers:?}"
986        );
987    }
988
989    #[cfg(unix)]
990    #[tokio::test]
991    async fn bash_cancel_sends_sigterm_then_kills_process_group_children() {
992        let marker = format!("harness-cancel-pgid-{}", epoch_ms());
993        let cleanup_path = std::env::temp_dir().join(format!("{marker}.cleanup"));
994        let _ = tokio::fs::remove_file(&cleanup_path).await;
995        cleanup_marker_processes(&marker).await;
996
997        let command = format!(
998            r#"trap "printf cleanup > {}; exit 0" TERM; sh -c 'trap "" TERM; while :; do sleep 5; done' {} & wait"#,
999            shell_quote(&cleanup_path.to_string_lossy()),
1000            shell_quote(&marker),
1001        );
1002        let cancel = CancellationToken::new();
1003        let cancel_for_task = cancel.clone();
1004        let handle = tokio::spawn(async move {
1005            runtime()
1006                .invoke_cancellable(
1007                    ToolInvocation {
1008                        id: "tc_cancel_pgid".into(),
1009                        name: "bash".into(),
1010                        input: json!({
1011                            "command": command,
1012                            "soft_timeout_ms": 5000,
1013                            "timeout_ms": 10000
1014                        }),
1015                        raw_emitted_args: None,
1016                    },
1017                    Some(&cancel_for_task),
1018                )
1019                .await
1020        });
1021
1022        assert!(
1023            wait_for_marker_process(&marker).await,
1024            "test command did not start"
1025        );
1026
1027        cancel.cancel();
1028        let outcome = tokio::time::timeout(Duration::from_secs(5), handle)
1029            .await
1030            .expect("cancelled bash invocation should return promptly")
1031            .expect("join should succeed")
1032            .expect("runtime should return a ToolOutcome");
1033        let failure = outcome.output.expect_err("cancel should be surfaced as failure");
1034        assert_eq!(failure.kind, ToolFailureKind::Runtime);
1035        assert_eq!(failure.message, "cancelled");
1036
1037        tokio::time::sleep(Duration::from_millis(500)).await;
1038        assert!(
1039            cleanup_path.exists(),
1040            "parent shell did not get SIGTERM cleanup window"
1041        );
1042        let leftovers = processes_matching_marker(&marker).await;
1043        cleanup_marker_processes(&marker).await;
1044        let _ = tokio::fs::remove_file(&cleanup_path).await;
1045        assert!(
1046            leftovers.is_empty(),
1047            "cancel left child processes running: {leftovers:?}"
1048        );
1049    }
1050
1051    #[tokio::test]
1052    async fn bash_tool_supports_bash_syntax_when_bash_exists() {
1053        if !Path::new("/bin/bash").exists() {
1054            return;
1055        }
1056        let out = runtime()
1057            .invoke(ToolInvocation {
1058                id: "tc_bash_syntax".into(),
1059                name: "bash".into(),
1060                input: json!({"command": "diff <(printf a) <(printf a)"}),
1061                raw_emitted_args: None,
1062            })
1063            .await
1064            .unwrap()
1065            .output
1066            .unwrap();
1067        assert_eq!(out["success"], true);
1068        assert_eq!(out["exit_code"], 0);
1069        assert_eq!(out["shell"], "/bin/bash");
1070    }
1071
1072    // ── grep / glob: rg/fd with fallback ────────────────────────────────────
1073
1074    /// A runtime rooted at `dir` (grep/glob resolve relative paths and default
1075    /// their search root to it).
1076    fn runtime_in(dir: &Path) -> LocalToolRuntime {
1077        LocalToolRuntime::new(LocalToolConfig {
1078            cwd: Some(dir.to_path_buf()),
1079            approval: Arc::new(YoloApproval),
1080            emit: Arc::new(|_| {}),
1081        })
1082    }
1083
1084    /// Fresh, empty temp dir unique to this test name + process.
1085    fn scratch(tag: &str) -> PathBuf {
1086        let dir = std::env::temp_dir().join(format!("harness_{tag}_{}", std::process::id()));
1087        let _ = std::fs::remove_dir_all(&dir);
1088        std::fs::create_dir_all(&dir).unwrap();
1089        dir
1090    }
1091
1092    #[tokio::test]
1093    async fn grep_returns_path_line_text_and_truncated_flag() {
1094        let dir = scratch("grep_shape");
1095        std::fs::create_dir_all(dir.join("src")).unwrap();
1096        std::fs::write(dir.join("src/a.rs"), "let needle = 1;\nother\n").unwrap();
1097        std::fs::write(dir.join("b.txt"), "no match here\n").unwrap();
1098
1099        let out = runtime_in(&dir)
1100            .invoke(ToolInvocation {
1101                id: "tc_grep_shape".into(),
1102                name: "grep".into(),
1103                input: json!({ "pattern": "needle" }),
1104                raw_emitted_args: None,
1105            })
1106            .await
1107            .unwrap()
1108            .output
1109            .unwrap();
1110        let _ = std::fs::remove_dir_all(&dir);
1111
1112        // Shape is identical whether ripgrep or system grep served the request.
1113        assert!(out.get("truncated").is_some(), "missing truncated flag: {out}");
1114        let matches = out["matches"].as_str().unwrap();
1115        // One `path:line:text` line pointing at src/a.rs line 1.
1116        assert!(matches.contains("a.rs:1:"), "unexpected matches: {matches:?}");
1117        assert!(matches.contains("needle"), "unexpected matches: {matches:?}");
1118        assert!(!matches.contains("no match here"), "unexpected matches: {matches:?}");
1119    }
1120
1121    #[tokio::test]
1122    async fn grep_with_rg_honours_ignore_file() {
1123        // `.ignore` is honoured by ripgrep unconditionally; system grep is not,
1124        // so this asserts the rg path specifically.
1125        let Some(rg) = ripgrep_bin() else { return };
1126        let dir = scratch("grep_ignore");
1127        std::fs::create_dir_all(dir.join("skip")).unwrap();
1128        std::fs::write(dir.join("keep.txt"), "needle\n").unwrap();
1129        std::fs::write(dir.join("skip/hit.txt"), "needle\n").unwrap();
1130        std::fs::write(dir.join(".ignore"), "skip/\n").unwrap();
1131
1132        let outcome = grep_with_rg(rg, "needle", false, &dir, &dir, "tc_grep_ignore")
1133            .await
1134            .unwrap()
1135            .expect("rg should have produced an outcome");
1136        let out = outcome.output.unwrap();
1137        let _ = std::fs::remove_dir_all(&dir);
1138
1139        let matches = out["matches"].as_str().unwrap();
1140        assert!(matches.contains("keep.txt"), "{matches:?}");
1141        assert!(!matches.contains("hit.txt"), "ignored dir leaked: {matches:?}");
1142    }
1143
1144    #[tokio::test]
1145    async fn glob_returns_relative_paths_and_truncated_flag() {
1146        let dir = scratch("glob_shape");
1147        std::fs::create_dir_all(dir.join("src/sub")).unwrap();
1148        std::fs::write(dir.join("top.rs"), "").unwrap();
1149        std::fs::write(dir.join("src/lib.rs"), "").unwrap();
1150        std::fs::write(dir.join("src/sub/deep.rs"), "").unwrap();
1151
1152        let out = runtime_in(&dir)
1153            .invoke(ToolInvocation {
1154                id: "tc_glob_shape".into(),
1155                name: "glob".into(),
1156                input: json!({ "pattern": "**/*.rs" }),
1157                raw_emitted_args: None,
1158            })
1159            .await
1160            .unwrap()
1161            .output
1162            .unwrap();
1163        let _ = std::fs::remove_dir_all(&dir);
1164
1165        assert!(out.get("truncated").is_some(), "missing truncated flag: {out}");
1166        let matches: Vec<String> = out["matches"]
1167            .as_array()
1168            .unwrap()
1169            .iter()
1170            .map(|v| v.as_str().unwrap().to_string())
1171            .collect();
1172        // `**/*.rs` is recursive AND includes top-level files under both backends.
1173        assert!(matches.iter().any(|m| m == "top.rs"), "{matches:?}");
1174        assert!(matches.iter().any(|m| m == "src/lib.rs"), "{matches:?}");
1175        assert!(matches.iter().any(|m| m == "src/sub/deep.rs"), "{matches:?}");
1176        // Paths are relative to the search root, never absolute.
1177        assert!(matches.iter().all(|m| !m.starts_with('/')), "{matches:?}");
1178    }
1179
1180    #[test]
1181    fn glob_matches_bare_pattern_at_any_depth() {
1182        // A slash-less pattern recurses (matches by file name at any depth) —
1183        // unlike the old hand-written walk, which only matched the top level.
1184        let dir = scratch("glob_recurse");
1185        std::fs::create_dir_all(dir.join("src/sub")).unwrap();
1186        std::fs::write(dir.join("top.rs"), "").unwrap();
1187        std::fs::write(dir.join("src/a.rs"), "").unwrap();
1188        std::fs::write(dir.join("src/sub/deep.rs"), "").unwrap();
1189
1190        let (matches, truncated) = glob_with_ignore("*.rs", &dir);
1191        let _ = std::fs::remove_dir_all(&dir);
1192
1193        assert!(!truncated);
1194        assert_eq!(
1195            matches,
1196            vec!["src/a.rs", "src/sub/deep.rs", "top.rs"],
1197            "bare `*.rs` should match .rs files at every depth"
1198        );
1199    }
1200
1201    #[test]
1202    fn glob_slash_pattern_is_anchored_to_root() {
1203        // A `/`-bearing pattern anchors to the search root (rg -g / git
1204        // semantics): `src/*.rs` matches src/a.rs but not sub/src or top-level.
1205        let dir = scratch("glob_anchor");
1206        std::fs::create_dir_all(dir.join("src/sub")).unwrap();
1207        std::fs::write(dir.join("top.rs"), "").unwrap();
1208        std::fs::write(dir.join("src/a.rs"), "").unwrap();
1209        std::fs::write(dir.join("src/sub/deep.rs"), "").unwrap();
1210
1211        let (matches, _) = glob_with_ignore("src/*.rs", &dir);
1212        let _ = std::fs::remove_dir_all(&dir);
1213
1214        assert_eq!(matches, vec!["src/a.rs"], "`src/*.rs` should anchor to the root");
1215    }
1216
1217    #[test]
1218    fn glob_honours_ignore_file() {
1219        let dir = scratch("glob_ignore");
1220        std::fs::create_dir_all(dir.join("skip")).unwrap();
1221        std::fs::write(dir.join("keep.rs"), "").unwrap();
1222        std::fs::write(dir.join("skip/hidden.rs"), "").unwrap();
1223        std::fs::write(dir.join(".ignore"), "skip/\n").unwrap();
1224
1225        let (matches, _) = glob_with_ignore("**/*.rs", &dir);
1226        let _ = std::fs::remove_dir_all(&dir);
1227
1228        assert!(matches.iter().any(|m| m == "keep.rs"), "{matches:?}");
1229        assert!(!matches.iter().any(|m| m.contains("hidden.rs")), "ignored dir leaked: {matches:?}");
1230    }
1231
1232    #[tokio::test]
1233    async fn grep_system_fallback_produces_expected_shape() {
1234        // Directly exercise the fallback so it is covered even where rg exists.
1235        let dir = scratch("grep_fallback");
1236        std::fs::write(dir.join("f.rs"), "let needle = 1;\n").unwrap();
1237
1238        let out = grep_with_system("needle", false, &dir, &dir, "tc_grep_fallback")
1239            .await
1240            .unwrap()
1241            .output
1242            .unwrap();
1243        let _ = std::fs::remove_dir_all(&dir);
1244
1245        assert_eq!(out["truncated"], false);
1246        assert!(out["matches"].as_str().unwrap().contains("needle"), "{out}");
1247    }
1248}