1#[cfg(test)]
34mod tests;
35
36use std::collections::HashMap;
37use std::fmt;
38use std::io::{self, Read};
39use std::path::{Path, PathBuf};
40use std::process::{Command, Stdio};
41use std::sync::atomic::{AtomicUsize, Ordering};
42use std::sync::{Arc, Mutex};
43use std::thread::JoinHandle;
44use std::time::{Duration, Instant};
45
46#[cfg(windows)]
47use process_wrap::std::JobObject;
48#[cfg(unix)]
49use process_wrap::std::ProcessGroup;
50use process_wrap::std::{ChildWrapper, CommandWrap};
51
52use super::{MAX_OUTPUT_BYTES, TIMEOUT_SECS, ToolDefinition, ToolOutput, ToolUseRequest, path};
53use crate::app::ToolStatus;
54use crate::tools::registry::{ToolContext, ToolError, ToolExecution};
55use crate::utils;
56use thndrs_agent::CancelToken;
57
58const MAX_OUTPUT_LINES: usize = 200;
60pub const NAME: &str = "run_shell";
61
62type OwnedChild = Box<dyn ChildWrapper>;
63
64enum WaitOutcome {
66 Exited(i32),
67 Timeout,
68 Cancelled,
69}
70
71#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
79pub enum ProcessStatus {
80 Running,
82 Ok,
84 Failed,
86 Timeout,
88 Cancelled,
90}
91
92impl ProcessStatus {
93 pub fn label(&self) -> &'static str {
95 match self {
96 ProcessStatus::Running => "running",
97 ProcessStatus::Ok => "ok",
98 ProcessStatus::Failed => "failed",
99 ProcessStatus::Timeout => "timeout",
100 ProcessStatus::Cancelled => "cancelled",
101 }
102 }
103
104 pub const fn to_tool_status(self) -> ToolStatus {
106 match self {
107 ProcessStatus::Running => ToolStatus::Running,
108 ProcessStatus::Ok => ToolStatus::Ok,
109 ProcessStatus::Failed | ProcessStatus::Timeout => ToolStatus::Failed,
110 ProcessStatus::Cancelled => ToolStatus::Cancelled,
111 }
112 }
113}
114
115impl From<ProcessStatus> for ToolStatus {
116 fn from(status: ProcessStatus) -> Self {
117 status.to_tool_status()
118 }
119}
120
121#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
124pub enum ProcessKind {
125 OneShot,
127 Background,
129}
130
131impl ProcessKind {
132 pub fn label(&self) -> &'static str {
134 match self {
135 ProcessKind::OneShot => "one-shot",
136 ProcessKind::Background => "background",
137 }
138 }
139}
140
141impl fmt::Display for ProcessKind {
142 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143 f.write_str(self.label())
144 }
145}
146
147#[derive(Clone, Debug, Eq, PartialEq)]
154pub struct ProcessResult {
155 pub process_id: Option<u64>,
157 pub command: Vec<String>,
159 pub cwd: PathBuf,
161 pub status: ProcessStatus,
163 pub exit_code: Option<i32>,
165 pub stdout: Vec<String>,
167 pub stderr: Vec<String>,
169 pub elapsed: Duration,
171 pub kind: ProcessKind,
173}
174
175impl ProcessResult {
176 pub fn summary(&self) -> String {
178 let argv = self.command.join(" ");
179 let elapsed_ms = self.elapsed.as_millis();
180 match self.status {
181 ProcessStatus::Running => format!("$ {argv} [{}]", self.kind.label()),
182 other => format!("$ {argv} [{} {} {}ms]", self.kind.label(), other.label(), elapsed_ms),
183 }
184 }
185
186 pub fn to_output_lines(&self) -> Vec<String> {
190 let mut lines = vec![redact_secrets(&self.summary())];
191 if !self.stdout.is_empty() {
192 lines.push(String::from("── stdout ──"));
193 lines.extend(self.stdout.iter().cloned());
194 }
195 if !self.stderr.is_empty() {
196 lines.push(String::from("── stderr ──"));
197 lines.extend(self.stderr.iter().cloned());
198 }
199 lines
200 }
201
202 pub fn to_failed_output(&self) -> ToolOutput {
204 let err = match self.status {
205 ProcessStatus::Timeout => {
206 format!("command timed out after {}ms", self.elapsed.as_millis())
207 }
208 ProcessStatus::Cancelled => String::from("command cancelled"),
209 _ => {
210 let code = self.exit_code.map(|c| c.to_string()).unwrap_or_else(|| "?".to_string());
211 format!("command failed (exit {code})")
212 }
213 };
214 ToolOutput::failed("run_shell", err)
215 }
216
217 pub fn to_tool_output(&self) -> ToolOutput {
219 match ToolStatus::from(self.status) {
220 ToolStatus::Running | ToolStatus::Ok => ToolOutput::ok(NAME, self.to_output_lines()),
221 _ => {
222 let mut output = self.to_failed_output();
223 let lines = self.to_output_lines();
224 output.display.lines = lines.clone();
225 output.model.lines = lines;
226 output
227 }
228 }
229 }
230}
231
232#[derive(Clone, Debug, Default, Eq, PartialEq)]
234pub struct ProcessOutput {
235 pub stdout: Vec<String>,
237 pub stderr: Vec<String>,
239}
240
241#[derive(Clone, Debug)]
243pub struct ActiveProcess {
244 pub id: u64,
246 pub command: Vec<String>,
248 pub cwd: PathBuf,
250 pub kind: ProcessKind,
252 pub cancel: CancelToken,
254 pub started: Instant,
256 pub status: ProcessStatus,
258 pub output: ProcessOutput,
260 control: Option<Arc<ProcessControl>>,
261}
262
263impl ActiveProcess {
264 pub fn elapsed(&self) -> Duration {
266 self.started.elapsed()
267 }
268
269 pub fn cancel(&self) {
271 if let Some(control) = &self.control {
272 control.cancel();
273 } else {
274 self.cancel.cancel();
275 }
276 }
277}
278
279#[derive(Clone, Debug, Default)]
287pub struct ProcessRegistry {
288 inner: Arc<RegistryInner>,
289}
290
291#[derive(Debug, Default)]
292struct RegistryInner {
293 state: Mutex<RegistryState>,
294}
295
296#[derive(Debug, Default)]
297struct RegistryState {
298 next_id: u64,
299 active: HashMap<u64, TrackedProcess>,
300}
301
302#[derive(Debug)]
303struct TrackedProcess {
304 id: u64,
305 command: Vec<String>,
306 cwd: PathBuf,
307 kind: ProcessKind,
308 cancel: CancelToken,
309 started: Instant,
310 control: Option<Arc<ProcessControl>>,
311 output: Arc<OutputCapture>,
312 result: Arc<Mutex<Option<ProcessResult>>>,
313 worker: Option<JoinHandle<()>>,
314 announced: bool,
315}
316
317impl TrackedProcess {
318 fn synthetic(id: u64, command: Vec<String>, cwd: PathBuf, kind: ProcessKind, cancel: CancelToken) -> Self {
319 Self {
320 id,
321 command,
322 cwd,
323 kind,
324 cancel,
325 started: Instant::now(),
326 control: None,
327 output: Arc::new(OutputCapture::default()),
328 result: Arc::new(Mutex::new(None)),
329 worker: None,
330 announced: true,
331 }
332 }
333
334 fn snapshot(&self) -> ActiveProcess {
335 let result = self.result.lock().ok().and_then(|result| result.clone());
336 let output = result.as_ref().map_or_else(
337 || self.output.snapshot(),
338 |result| ProcessOutput { stdout: result.stdout.clone(), stderr: result.stderr.clone() },
339 );
340 ActiveProcess {
341 id: self.id,
342 command: self.command.clone(),
343 cwd: self.cwd.clone(),
344 kind: self.kind,
345 cancel: self.cancel.clone(),
346 started: self.started,
347 status: result.map_or(ProcessStatus::Running, |result| result.status),
348 output,
349 control: self.control.clone(),
350 }
351 }
352}
353
354#[derive(Debug)]
355struct ProcessControl {
356 cancel: CancelToken,
357 child: Arc<Mutex<Option<OwnedChild>>>,
358}
359
360impl ProcessControl {
361 fn cancel(&self) {
362 self.cancel.cancel();
363 if let Ok(mut child) = self.child.lock()
364 && let Some(child) = child.as_mut()
365 {
366 let _ = child.start_kill();
367 }
368 }
369}
370
371#[derive(Debug, Default)]
372struct OutputCapture {
373 stdout: Mutex<Vec<u8>>,
374 stderr: Mutex<Vec<u8>>,
375 readers: AtomicUsize,
376}
377
378impl OutputCapture {
379 fn append(&self, stdout: bool, bytes: &[u8]) {
380 let target = if stdout { &self.stdout } else { &self.stderr };
381 let Ok(mut target) = target.lock() else {
382 return;
383 };
384 let remaining = MAX_OUTPUT_BYTES.saturating_sub(target.len());
385 target.extend_from_slice(&bytes[..bytes.len().min(remaining)]);
386 }
387
388 fn snapshot(&self) -> ProcessOutput {
389 let stdout = self.stdout.lock().map(|bytes| bytes.clone()).unwrap_or_default();
390 let stderr = self.stderr.lock().map(|bytes| bytes.clone()).unwrap_or_default();
391 ProcessOutput { stdout: split_and_cap(&stdout), stderr: split_and_cap(&stderr) }
392 }
393}
394
395struct BackgroundMonitor {
396 id: u64,
397 command: Vec<String>,
398 cwd: PathBuf,
399 timeout: Duration,
400 start: Instant,
401 cancel: CancelToken,
402 child: Arc<Mutex<Option<OwnedChild>>>,
403 output: Arc<OutputCapture>,
404 result_slot: Arc<Mutex<Option<ProcessResult>>>,
405}
406
407impl ProcessRegistry {
408 pub fn new() -> Self {
410 Self::default()
411 }
412
413 #[cfg(test)]
415 pub fn len(&self) -> usize {
416 self.inner.state.lock().map(|state| state.active.len()).unwrap_or(0)
417 }
418
419 #[cfg(test)]
421 pub fn is_empty(&self) -> bool {
422 self.len() == 0
423 }
424
425 #[cfg(test)]
427 pub fn background_count(&self) -> usize {
428 self.inner
429 .state
430 .lock()
431 .map(|state| {
432 state
433 .active
434 .values()
435 .filter(|process| {
436 process.kind == ProcessKind::Background && process.snapshot().status == ProcessStatus::Running
437 })
438 .count()
439 })
440 .unwrap_or(0)
441 }
442
443 #[cfg(test)]
445 pub fn one_shot_count(&self) -> usize {
446 self.inner
447 .state
448 .lock()
449 .map(|state| state.active.values().filter(|p| p.kind == ProcessKind::OneShot).count())
450 .unwrap_or(0)
451 }
452
453 pub fn register(&self, command: Vec<String>, cwd: PathBuf, kind: ProcessKind, cancel: CancelToken) -> u64 {
455 let mut state = recover_lock(&self.inner.state);
456 let id = state.next_id;
457 state.next_id += 1;
458 state
459 .active
460 .insert(id, TrackedProcess::synthetic(id, command, cwd, kind, cancel));
461 id
462 }
463
464 pub fn get(&self, id: u64) -> Option<ActiveProcess> {
466 let state = self.inner.state.lock().ok()?;
467 state.active.get(&id).map(TrackedProcess::snapshot)
468 }
469
470 pub fn cancel(&self, id: u64) -> bool {
474 let Some(process) = self.get(id) else {
475 return false;
476 };
477 if process.status != ProcessStatus::Running {
478 return false;
479 }
480 process.cancel();
481 true
482 }
483
484 pub fn remove(&self, id: u64) -> Option<ActiveProcess> {
486 let mut tracked = self.inner.state.lock().ok()?.active.remove(&id)?;
487 if let Some(control) = &tracked.control {
488 control.cancel();
489 }
490 join_worker(tracked.worker.take());
491 Some(tracked.snapshot())
492 }
493
494 pub fn cancel_all(&self) {
496 let state = recover_lock(&self.inner.state);
497 for process in state.active.values() {
498 if process.snapshot().status == ProcessStatus::Running {
499 if let Some(control) = &process.control {
500 control.cancel();
501 } else {
502 process.cancel.cancel();
503 }
504 }
505 }
506 }
507
508 #[cfg(test)]
510 pub fn ids(&self) -> impl Iterator<Item = u64> {
511 self.inner
512 .state
513 .lock()
514 .map(|state| state.active.keys().copied().collect::<Vec<_>>())
515 .unwrap_or_default()
516 .into_iter()
517 }
518
519 pub fn background_ids(&self) -> impl Iterator<Item = u64> {
521 self.inner
522 .state
523 .lock()
524 .map(|state| {
525 state
526 .active
527 .values()
528 .filter(|process| {
529 process.kind == ProcessKind::Background && process.snapshot().status == ProcessStatus::Running
530 })
531 .map(|process| process.id)
532 .collect::<Vec<_>>()
533 })
534 .unwrap_or_default()
535 .into_iter()
536 }
537
538 pub fn announce(&self, id: u64) -> bool {
540 let Ok(mut state) = self.inner.state.lock() else {
541 return false;
542 };
543 let Some(process) = state.active.get_mut(&id) else {
544 return false;
545 };
546 process.announced = true;
547 true
548 }
549
550 pub fn drain_completed(&self) -> Vec<ProcessResult> {
552 self.drain_completed_inner(false)
553 }
554
555 pub fn shutdown(&self) -> Vec<ProcessResult> {
557 let tracked = {
558 let mut state = recover_lock(&self.inner.state);
559 for process in state.active.values() {
560 if let Some(control) = &process.control {
561 control.cancel();
562 } else {
563 process.cancel.cancel();
564 }
565 }
566 state.active.drain().map(|(_, process)| process).collect::<Vec<_>>()
567 };
568
569 let mut results = Vec::new();
570 for process in tracked {
571 join_worker(process.worker);
572 if let Ok(mut result) = process.result.lock()
573 && let Some(result) = result.take()
574 {
575 results.push(result);
576 }
577 }
578 results.sort_by_key(|result| result.process_id);
579 results
580 }
581
582 fn drain_completed_inner(&self, include_unannounced: bool) -> Vec<ProcessResult> {
583 let tracked = {
584 let mut state = recover_lock(&self.inner.state);
585 if include_unannounced {
586 for process in state.active.values() {
587 if let Some(control) = &process.control {
588 control.cancel();
589 } else {
590 process.cancel.cancel();
591 }
592 }
593 }
594 let mut ids = Vec::new();
595 for (id, process) in &state.active {
596 let completed = process.result.lock().ok().is_some_and(|result| result.is_some());
597 if (include_unannounced || process.announced) && completed {
598 ids.push(*id);
599 }
600 }
601 ids.into_iter()
602 .filter_map(|id| state.active.remove(&id))
603 .collect::<Vec<_>>()
604 };
605
606 let mut results = Vec::new();
607 for process in tracked {
608 join_worker(process.worker);
609 if let Ok(mut result) = process.result.lock()
610 && let Some(result) = result.take()
611 {
612 results.push(result);
613 }
614 }
615 results.sort_by_key(|result| result.process_id);
616 results
617 }
618
619 pub(crate) fn spawn_background(
620 &self, args: &ShellArgs, cwd: PathBuf, child: OwnedChild, start: Instant, cancel: CancelToken,
621 ) -> u64 {
622 let argv = args.argv();
623 let timeout = args.timeout.unwrap_or(Duration::from_secs(TIMEOUT_SECS));
624 let child = Arc::new(Mutex::new(Some(child)));
625 let control = Arc::new(ProcessControl { cancel: cancel.clone(), child: child.clone() });
626 let output = Arc::new(OutputCapture::default());
627 let result = Arc::new(Mutex::new(None));
628 if let Ok(mut child_guard) = child.lock()
629 && let Some(child) = child_guard.as_mut()
630 {
631 if let Some(stdout) = child.stdout().take() {
632 spawn_output_reader(stdout, output.clone(), true);
633 }
634 if let Some(stderr) = child.stderr().take() {
635 spawn_output_reader(stderr, output.clone(), false);
636 }
637 }
638
639 let mut state = recover_lock(&self.inner.state);
640 let id = state.next_id;
641 state.next_id += 1;
642 let output_for_worker = output.clone();
643 let result_for_worker = result.clone();
644 let cancel_for_worker = cancel.clone();
645 let cwd_for_worker = cwd.clone();
646 let worker = std::thread::spawn(move || {
647 BackgroundMonitor {
648 id,
649 command: argv,
650 cwd: cwd_for_worker,
651 timeout,
652 start,
653 cancel: cancel_for_worker,
654 child,
655 output: output_for_worker,
656 result_slot: result_for_worker,
657 }
658 .run();
659 });
660 state.active.insert(
661 id,
662 TrackedProcess {
663 id,
664 command: args.argv(),
665 cwd,
666 kind: ProcessKind::Background,
667 cancel,
668 started: start,
669 control: Some(control),
670 output,
671 result,
672 worker: Some(worker),
673 announced: false,
674 },
675 );
676 id
677 }
678}
679
680impl Drop for ProcessRegistry {
681 fn drop(&mut self) {
682 if Arc::strong_count(&self.inner) == 1 {
683 let _ = self.shutdown();
684 }
685 }
686}
687
688#[derive(Clone, Debug)]
690pub struct ShellArgs {
691 pub program: String,
693 pub args: Vec<String>,
695 pub cwd: Option<PathBuf>,
698 pub timeout: Option<Duration>,
700 pub kind: ProcessKind,
702}
703
704impl ShellArgs {
705 pub fn argv(&self) -> Vec<String> {
707 let mut v = vec![self.program.clone()];
708 v.extend(self.args.iter().cloned());
709 v
710 }
711}
712
713pub fn definition() -> ToolDefinition {
715 ToolDefinition::new(
716 NAME,
717 r#"run_shell
718
719Run an argv command in the workspace and capture stdout, stderr, and exit status.
720
721Prefer narrower tools when they fit. Use for build, test, format, and inspection.
722
723Runs as thndrs with its permissions, not in a sandbox. Output is capped,
724truncated, and redacted; timeouts are enforced. With background=true, the
725interactive app owns the child, returns its registry id immediately, and
726supports :bg listing and cancellation."#,
727 serde_json::json!({
728 "type": "object",
729 "properties": {
730 "argv": { "type": "array", "minItems": 1, "items": { "type": "string" }, "description": "Full argv: program followed by its arguments." },
731 "cwd": { "type": "string", "description": "Optional working directory relative to the workspace root." },
732 "timeout_ms": { "type": "integer", "minimum": 1, "description": "Optional timeout in milliseconds." },
733 "background": { "type": "boolean", "description": "If true, run as a long-lived background process." }
734 },
735 "required": ["argv"]
736 }),
737 )
738}
739
740pub fn parse_arguments(arguments: &str) -> Result<ShellArgs, ToolError> {
742 let args = serde_json::from_str::<serde_json::Value>(arguments)
743 .map_err(|error| ToolError::InvalidArguments(format!("invalid JSON: {error}")))?;
744 let (program, cmd_args) = parse_argv(&args)?;
745 let cwd = args.get("cwd").and_then(|value| value.as_str()).map(PathBuf::from);
746 let timeout = match optional_u64(&args, "timeout_ms")? {
747 Some(0) => {
748 return Err(ToolError::InvalidArguments(
749 "'timeout_ms' must be greater than zero".to_string(),
750 ));
751 }
752 Some(milliseconds) => Some(Duration::from_millis(milliseconds)),
753 None => optional_u64(&args, "timeout_secs")?.map(Duration::from_secs),
754 };
755 let kind = if args
756 .get("background")
757 .and_then(|value| value.as_bool())
758 .unwrap_or(false)
759 {
760 ProcessKind::Background
761 } else {
762 ProcessKind::OneShot
763 };
764
765 Ok(ShellArgs { program, args: cmd_args, cwd, timeout, kind })
766}
767
768pub fn execute_request(request: &ToolUseRequest, ctx: &ToolContext<'_>) -> ToolExecution {
770 let cancel = CancelToken::new();
771 execute_request_with_cancel_and_registry(request, ctx.root, &cancel, ctx.process_registry.as_ref())
772}
773
774pub fn execute_request_with_cancel(request: &ToolUseRequest, root: &Path, cancel: &CancelToken) -> ToolExecution {
781 execute_request_with_cancel_and_registry(request, root, cancel, None)
782}
783
784pub fn execute_request_with_cancel_and_registry(
787 request: &ToolUseRequest, root: &Path, cancel: &CancelToken, registry: Option<&ProcessRegistry>,
788) -> ToolExecution {
789 match parse_arguments(&request.arguments) {
790 Ok(args) => execute_args(&args, root, cancel, registry),
791 Err(error) => ToolExecution::output(ToolOutput::failed(NAME, error.to_string())),
792 }
793}
794
795pub fn run_command(args: &ShellArgs, root: &Path, cancel: &CancelToken) -> Result<ProcessResult, String> {
813 run_command_with_registry(args, root, cancel, None)
814}
815
816pub fn run_command_with_registry(
818 args: &ShellArgs, root: &Path, cancel: &CancelToken, registry: Option<&ProcessRegistry>,
819) -> Result<ProcessResult, String> {
820 let cwd = resolve_cwd(root, &args.cwd)?;
821 let argv = args.argv();
822
823 if args.kind == ProcessKind::Background {
824 let registry = registry
825 .ok_or_else(|| String::from("background commands require an application-owned process registry"))?;
826 let mut cmd = Command::new(&args.program);
827 cmd.args(&args.args)
828 .current_dir(&cwd)
829 .stdout(Stdio::piped())
830 .stderr(Stdio::piped())
831 .stdin(Stdio::null());
832 let start = Instant::now();
833 let child = spawn_owned_command(cmd).map_err(|error| format!("failed to spawn '{}': {error}", args.program))?;
834 let process_cancel = CancelToken::new();
838 let id = registry.spawn_background(args, cwd.clone(), child, start, process_cancel);
839 return Ok(ProcessResult {
840 process_id: Some(id),
841 command: argv,
842 cwd,
843 status: ProcessStatus::Running,
844 exit_code: None,
845 stdout: Vec::new(),
846 stderr: Vec::new(),
847 elapsed: start.elapsed(),
848 kind: ProcessKind::Background,
849 });
850 }
851
852 run_foreground_command(args, cwd, argv, cancel)
853}
854
855#[cfg(test)]
862pub fn exec(args: &ShellArgs, root: &Path) -> ToolOutput {
863 let cancel = CancelToken::new();
864 match run_command(args, root, &cancel) {
865 Ok(result) => output_from_result(&result),
866 Err(e) => ToolOutput::failed(NAME, e),
867 }
868}
869
870pub fn redact_secrets(line: &str) -> String {
880 let mut result = line.to_string();
881 let sk_re = regex_lite::Regex::new(r"\bsk-[A-Za-z0-9_]{8,}").expect("valid regex");
882 result = sk_re.replace_all(&result, "sk-[REDACTED]").to_string();
883
884 let bearer_re = regex_lite::Regex::new(r"(?i)bearer\s+[A-Za-z0-9_\-\.]{10,}").expect("valid regex");
885 result = bearer_re.replace_all(&result, "Bearer [REDACTED]").to_string();
886
887 let assign_re = regex_lite::Regex::new(r"(?i)(password|passwd|api_key|apikey|access_token|secret)\s*[:=]\s*\S{4,}")
888 .expect("valid regex");
889
890 assign_re.replace_all(&result, "$1=[REDACTED]").to_string()
891}
892
893fn wait_with_timeout(
896 child: &mut dyn ChildWrapper, timeout: &Duration, cancel: &CancelToken, start: &Instant,
897) -> WaitOutcome {
898 loop {
899 match child.try_wait() {
900 Ok(Some(status)) => {
901 let _ = child.start_kill();
904 return WaitOutcome::Exited(status.code().unwrap_or(-1));
905 }
906 Ok(None) => {
907 if cancel.is_cancelled() {
908 let _ = child.kill();
909 return WaitOutcome::Cancelled;
910 }
911 if start.elapsed() > *timeout {
912 let _ = child.kill();
913 return WaitOutcome::Timeout;
914 }
915 std::thread::sleep(Duration::from_millis(20));
916 }
917 Err(_) => {
918 let _ = child.kill();
919 return WaitOutcome::Cancelled;
920 }
921 }
922 }
923}
924
925fn resolve_cwd(root: &Path, cwd: &Option<PathBuf>) -> Result<PathBuf, String> {
928 match cwd {
929 None => Ok(root.to_path_buf()),
930 Some(rel) => {
931 let resolved = path::resolve_within_root(root, &rel.to_string_lossy()).map_err(|e| e.to_string())?;
932 if !resolved.is_dir() {
933 return Err(format!("working directory is not a directory: {}", resolved.display()));
934 }
935 Ok(resolved)
936 }
937 }
938}
939
940fn read_to_capped_vec<R: Read>(mut stream: R) -> Vec<u8> {
943 let max_bytes: usize = MAX_OUTPUT_BYTES;
944 let mut buf = Vec::with_capacity(4096);
945 let mut chunk = [0u8; 4096];
946
947 loop {
948 match stream.read(&mut chunk) {
949 Ok(0) => break,
950 Ok(n) => {
951 let remaining = max_bytes.saturating_sub(buf.len());
952 if remaining == 0 {
953 continue;
956 }
957 let take = n.min(remaining);
958 buf.extend_from_slice(&chunk[..take]);
959 }
960 Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
961 Err(_) => break,
962 }
963 }
964
965 buf
966}
967
968fn split_and_cap(buf: &[u8]) -> Vec<String> {
971 let content = String::from_utf8_lossy(buf);
972 let mut lines: Vec<String> = content
973 .lines()
974 .map(redact_secrets)
975 .map(|line| utils::truncate_line(&line))
976 .take(MAX_OUTPUT_LINES)
977 .collect();
978
979 let total_lines = content.lines().count();
980 if total_lines > MAX_OUTPUT_LINES {
981 let extra = total_lines - MAX_OUTPUT_LINES;
982 lines.push(format!("…({extra} more lines)"));
983 }
984
985 lines
986}
987
988fn execute_args(
989 args: &ShellArgs, root: &Path, cancel: &CancelToken, registry: Option<&ProcessRegistry>,
990) -> ToolExecution {
991 if args.program.is_empty() {
992 return ToolExecution::output(ToolOutput::failed(
993 NAME,
994 "missing command: provide non-empty 'argv', 'command', or 'program'".to_string(),
995 ));
996 }
997
998 match run_command_with_registry(args, root, cancel, registry) {
999 Ok(result) => ToolExecution::full(output_from_result(&result), None, Some(result)),
1000 Err(error) => ToolExecution::output(ToolOutput::failed(NAME, error)),
1001 }
1002}
1003
1004fn output_from_result(result: &ProcessResult) -> ToolOutput {
1005 result.to_tool_output()
1006}
1007
1008fn join_worker(worker: Option<JoinHandle<()>>) {
1009 if let Some(worker) = worker {
1010 let _ = worker.join();
1011 }
1012}
1013
1014fn recover_lock<T>(lock: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
1015 lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
1016}
1017
1018fn spawn_output_reader<R: Read + Send + 'static>(mut reader: R, output: Arc<OutputCapture>, stdout: bool) {
1019 output.readers.fetch_add(1, Ordering::SeqCst);
1020 std::thread::spawn(move || {
1021 let mut chunk = [0_u8; 4096];
1022 loop {
1023 match reader.read(&mut chunk) {
1024 Ok(0) => break,
1025 Ok(n) => output.append(stdout, &chunk[..n]),
1026 Err(ref error) if error.kind() == io::ErrorKind::Interrupted => continue,
1027 Err(_) => break,
1028 }
1029 }
1030 output.readers.fetch_sub(1, Ordering::SeqCst);
1031 });
1032}
1033
1034impl BackgroundMonitor {
1035 fn run(self) {
1036 let Self { id, command, cwd, timeout, start, cancel, child, output, result_slot } = self;
1037 let outcome = loop {
1038 match try_wait_owned(&child) {
1039 Ok(Some(_status)) if cancel.is_cancelled() => break WaitOutcome::Cancelled,
1040 Ok(Some(status)) => break WaitOutcome::Exited(status.code().unwrap_or(-1)),
1041 Ok(None) => {
1042 if cancel.is_cancelled() {
1043 kill_and_reap(&child);
1044 break WaitOutcome::Cancelled;
1045 }
1046 if start.elapsed() > timeout {
1047 kill_and_reap(&child);
1048 break WaitOutcome::Timeout;
1049 }
1050 std::thread::sleep(Duration::from_millis(20));
1051 }
1052 Err(_) => {
1053 kill_and_reap(&child);
1054 break WaitOutcome::Cancelled;
1055 }
1056 }
1057 };
1058
1059 let drain_deadline = Instant::now() + Duration::from_millis(100);
1060 while output.readers.load(Ordering::SeqCst) > 0 && Instant::now() < drain_deadline {
1061 std::thread::sleep(Duration::from_millis(1));
1062 }
1063
1064 let (status, exit_code) = match outcome {
1065 WaitOutcome::Exited(code) if code == 0 => (ProcessStatus::Ok, Some(code)),
1066 WaitOutcome::Exited(code) => (ProcessStatus::Failed, Some(code)),
1067 WaitOutcome::Timeout => (ProcessStatus::Timeout, None),
1068 WaitOutcome::Cancelled => (ProcessStatus::Cancelled, None),
1069 };
1070 let captured = output.snapshot();
1071 let result = ProcessResult {
1072 process_id: Some(id),
1073 command,
1074 cwd,
1075 status,
1076 exit_code,
1077 stdout: captured.stdout,
1078 stderr: captured.stderr,
1079 elapsed: start.elapsed(),
1080 kind: ProcessKind::Background,
1081 };
1082 let mut slot = recover_lock(&result_slot);
1083 *slot = Some(result);
1084 }
1085}
1086
1087fn try_wait_owned(child: &Arc<Mutex<Option<OwnedChild>>>) -> io::Result<Option<std::process::ExitStatus>> {
1088 let mut guard = child
1089 .lock()
1090 .map_err(|_| io::Error::other("process child lock poisoned"))?;
1091 let Some(child) = guard.as_mut() else {
1092 return Ok(None);
1093 };
1094 match child.try_wait()? {
1095 Some(status) => {
1096 let _ = child.start_kill();
1099 *guard = None;
1100 Ok(Some(status))
1101 }
1102 None => Ok(None),
1103 }
1104}
1105
1106fn kill_and_reap(child: &Arc<Mutex<Option<OwnedChild>>>) {
1107 let Ok(mut guard) = child.lock() else {
1108 return;
1109 };
1110 if let Some(child) = guard.as_mut() {
1111 let _ = child.kill();
1112 }
1113 *guard = None;
1114}
1115
1116fn parse_argv(args: &serde_json::Value) -> Result<(String, Vec<String>), ToolError> {
1117 if let Some((field, argv)) = args
1118 .get("argv")
1119 .map(|argv| ("argv", argv))
1120 .or_else(|| args.get("command").map(|command| ("command", command)))
1121 {
1122 let argv = argv
1123 .as_array()
1124 .ok_or_else(|| ToolError::InvalidArguments(format!("'{field}' must be an array")))?;
1125 let argv = argv
1126 .iter()
1127 .enumerate()
1128 .map(|(index, value)| {
1129 value
1130 .as_str()
1131 .map(str::to_string)
1132 .ok_or_else(|| ToolError::InvalidArguments(format!("{field}[{index}] must be a string")))
1133 })
1134 .collect::<Result<Vec<_>, _>>()?;
1135 let (program, command_args) = argv
1136 .split_first()
1137 .ok_or_else(|| ToolError::InvalidArguments(format!("'{field}' must contain a program")))?;
1138 if program.is_empty() {
1139 return Err(ToolError::InvalidArguments(format!("{field}[0] must not be empty")));
1140 }
1141 return Ok((program.clone(), command_args.to_vec()));
1142 }
1143
1144 let program = args
1145 .get("program")
1146 .and_then(|value| value.as_str())
1147 .unwrap_or("")
1148 .to_string();
1149 let command_args = args
1150 .get("args")
1151 .and_then(|value| value.as_array())
1152 .map(|items| {
1153 items
1154 .iter()
1155 .filter_map(|value| value.as_str().map(str::to_string))
1156 .collect()
1157 })
1158 .unwrap_or_default();
1159 Ok((program, command_args))
1160}
1161
1162fn optional_u64(args: &serde_json::Value, field: &str) -> Result<Option<u64>, ToolError> {
1163 match args.get(field) {
1164 None => Ok(None),
1165 Some(value) => value
1166 .as_u64()
1167 .map(Some)
1168 .ok_or_else(|| ToolError::InvalidArguments(format!("'{field}' must be a non-negative integer"))),
1169 }
1170}
1171
1172fn spawn_owned_command(command: Command) -> io::Result<OwnedChild> {
1173 let mut command = CommandWrap::from(command);
1174 #[cfg(unix)]
1175 command.wrap(ProcessGroup::leader());
1176 #[cfg(windows)]
1177 command.wrap(JobObject);
1178 command.spawn()
1179}
1180
1181fn run_foreground_command(
1182 args: &ShellArgs, cwd: PathBuf, argv: Vec<String>, cancel: &CancelToken,
1183) -> Result<ProcessResult, String> {
1184 let mut cmd = Command::new(&args.program);
1185 cmd.args(&args.args)
1186 .current_dir(&cwd)
1187 .stdout(Stdio::piped())
1188 .stderr(Stdio::piped())
1189 .stdin(Stdio::null());
1190
1191 let timeout = args.timeout.unwrap_or(Duration::from_secs(TIMEOUT_SECS));
1192 let start = Instant::now();
1193
1194 let mut child = spawn_owned_command(cmd).map_err(|e| format!("failed to spawn '{}': {e}", args.program))?;
1195
1196 let stdout = child
1197 .stdout()
1198 .take()
1199 .ok_or_else(|| String::from("failed to capture child stdout"))?;
1200 let stderr = child
1201 .stderr()
1202 .take()
1203 .ok_or_else(|| String::from("failed to capture child stderr"))?;
1204
1205 let stdout_handle = std::thread::spawn(move || read_to_capped_vec(stdout));
1206 let stderr_handle = std::thread::spawn(move || read_to_capped_vec(stderr));
1207
1208 let final_status = wait_with_timeout(child.as_mut(), &timeout, cancel, &start);
1209
1210 let elapsed = start.elapsed();
1211 let (status, exit_code) = match final_status {
1212 WaitOutcome::Exited(code) => {
1213 if code == 0 {
1214 (ProcessStatus::Ok, Some(code))
1215 } else {
1216 (ProcessStatus::Failed, Some(code))
1217 }
1218 }
1219 WaitOutcome::Timeout => (ProcessStatus::Timeout, None),
1220 WaitOutcome::Cancelled => (ProcessStatus::Cancelled, None),
1221 };
1222
1223 let stdout_buf = stdout_handle.join().unwrap_or_default();
1224 let stderr_buf = stderr_handle.join().unwrap_or_default();
1225
1226 Ok(ProcessResult {
1227 process_id: None,
1228 command: argv,
1229 cwd,
1230 status,
1231 exit_code,
1232 stdout: split_and_cap(&stdout_buf),
1233 stderr: split_and_cap(&stderr_buf),
1234 elapsed,
1235 kind: ProcessKind::OneShot,
1236 })
1237}