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
19pub type EmitFn = Arc<dyn Fn(Value) + Send + Sync + 'static>;
23
24pub struct LocalToolConfig {
26 pub cwd: Option<PathBuf>,
30 pub approval: Arc<dyn ApprovalGate>,
34 pub emit: EmitFn,
38}
39
40#[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 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 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
150fn 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 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 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
427async 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
476async 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
496async 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
550const MAX_GREP_MATCHES: usize = 5_000;
556
557fn 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
579fn 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
586async 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 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
612fn glob_with_ignore(pattern: &str, base: &Path) -> (Vec<String>, bool) {
621 use ignore::overrides::OverrideBuilder;
622 use ignore::WalkBuilder;
623
624 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 .require_git(false)
640 .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 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
662async 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 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
683async 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), };
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 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 let timed_out = tokio::time::timeout(Duration::from_secs(30), read).await.is_err();
746 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
766async 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
821fn 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
837fn truncate(s: String) -> String {
840 crate::tools::clip_head(s)
841}
842
843fn 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 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 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 assert!(out.get("truncated").is_some(), "missing truncated flag: {out}");
1114 let matches = out["matches"].as_str().unwrap();
1115 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 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 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 assert!(matches.iter().all(|m| !m.starts_with('/')), "{matches:?}");
1178 }
1179
1180 #[test]
1181 fn glob_matches_bare_pattern_at_any_depth() {
1182 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 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 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}