Skip to main content

thndrs_lib/core/tools/
shell.rs

1//! Shell/process manager.
2//!
3//! Runs commands from the workspace root with output streaming, timeouts,
4//! cancellation, and a process registry that tracks active commands.
5//!
6//! ## Design
7//!
8//! - Commands run from the workspace root by default. An optional `cwd` can be
9//!   specified relative to the root; paths escaping the root are rejected.
10//! - Output is captured from piped stdout/stderr. The blocking read happens on
11//!   a worker thread; the TUI drains the result through the normal tool event
12//!   channel so it never blocks.
13//! - Timeouts kill the process and produce a `Timeout` status.
14//! - Cancellation is cooperative: a shared [`CancelToken`] is checked by the
15//!   worker thread between reads; when signalled the process is killed and the
16//!   result is recorded as `Cancelled`.
17//! - A [`ProcessRegistry`] tracks active commands, separating one-shot commands
18//!   (waited on for completion) from long-lived background processes. A
19//!   background child gets an independent cancellation handle, stays owned by
20//!   the registry after the tool call returns, and is reaped on completion,
21//!   explicit cancellation, application quit, or registry drop.
22//!
23//! ## Safety
24//!
25//! - `fd --exec`, `rg --pre`, `sed -i`, `awk system()` and arbitrary
26//!   shell-string execution are not exposed by this module — the model provides
27//!   an argv array, never a shell string.
28//! - The command runs via `std::process::Command` argv; no `/bin/sh -c`.
29//! - stdout/stderr bytes are capped at [`MAX_OUTPUT_BYTES`]; lines truncate
30//!   at `MAX_LINE_LEN`.
31//! - Paths are contained to the workspace root.
32
33#[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
58/// Maximum number of output lines retained for the transcript/tool result.
59const MAX_OUTPUT_LINES: usize = 200;
60pub const NAME: &str = "run_shell";
61
62type OwnedChild = Box<dyn ChildWrapper>;
63
64/// Outcome of waiting for a process, honoring timeout and cancellation.
65enum WaitOutcome {
66    Exited(i32),
67    Timeout,
68    Cancelled,
69}
70
71/// Process lifecycle status recorded by the registry and in the transcript.
72///
73/// Mirrors [`ToolStatus`] but adds `Timeout` and `Cancelled` which are
74/// process-specific terminal states.
75///
76/// `Running` is only reached for background processes tracked by the registry;
77/// one-shot commands always complete before the status is observed.
78#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
79pub enum ProcessStatus {
80    /// Still running.
81    Running,
82    /// Exited with status 0.
83    Ok,
84    /// Exited with a non-zero status.
85    Failed,
86    /// Killed after exceeding the timeout.
87    Timeout,
88    /// Killed after a cancellation request.
89    Cancelled,
90}
91
92impl ProcessStatus {
93    /// One-word label used in transcript display and session records.
94    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    /// Convert to the transcript-level tool status.
105    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/// Whether a command is a one-shot (waited for completion) or a long-lived
122/// background process (tracked separately by the registry).
123#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
124pub enum ProcessKind {
125    /// Waited for completion; result is captured synchronously by the caller.
126    OneShot,
127    /// Left running after dispatch; tracked by id in the registry.
128    Background,
129}
130
131impl ProcessKind {
132    /// Lowercase label used in display and records.
133    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/// Structured result of a process execution.
148///
149/// Captures the command, working directory, exit status, stdout/stderr
150/// (capped and line-truncated), and elapsed time. This is the audit record
151/// persisted for session records; the full raw output is never stored beyond
152/// the byte cap.
153#[derive(Clone, Debug, Eq, PartialEq)]
154pub struct ProcessResult {
155    /// Registry id for an owned background process, if applicable.
156    pub process_id: Option<u64>,
157    /// The argv that was run (program + args).
158    pub command: Vec<String>,
159    /// Working directory the command ran in.
160    pub cwd: PathBuf,
161    /// Final lifecycle status.
162    pub status: ProcessStatus,
163    /// Exit code if the process exited normally, else `None`.
164    pub exit_code: Option<i32>,
165    /// Captured stdout, line-capped and byte-capped.
166    pub stdout: Vec<String>,
167    /// Captured stderr, line-capped and byte-capped.
168    pub stderr: Vec<String>,
169    /// Wall-clock elapsed time.
170    pub elapsed: Duration,
171    /// Whether this was a one-shot or background process.
172    pub kind: ProcessKind,
173}
174
175impl ProcessResult {
176    /// Render a compact single-line summary for transcript display.
177    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    /// Lines for the tool [`ToolOutput`]: summary followed by stdout/stderr
187    /// markers and content. The summary line is also redacted in case the
188    /// command argv itself contains secret-like values.
189    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    /// Build a failed [`ToolOutput`] from this result.
203    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    /// Build the [`ToolOutput`] corresponding to this process result.
218    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/// A bounded snapshot of output retained for an active process.
233#[derive(Clone, Debug, Default, Eq, PartialEq)]
234pub struct ProcessOutput {
235    /// Redacted, line-capped stdout retained so far.
236    pub stdout: Vec<String>,
237    /// Redacted, line-capped stderr retained so far.
238    pub stderr: Vec<String>,
239}
240
241/// A process snapshot returned by the registry.
242#[derive(Clone, Debug)]
243pub struct ActiveProcess {
244    /// Unique id assigned by the registry.
245    pub id: u64,
246    /// The argv that was run.
247    pub command: Vec<String>,
248    /// Working directory. Stored for audit/display.
249    pub cwd: PathBuf,
250    /// One-shot or background.
251    pub kind: ProcessKind,
252    /// Cancellation flag shared with the worker thread.
253    pub cancel: CancelToken,
254    /// When the process started.
255    pub started: Instant,
256    /// Current lifecycle status.
257    pub status: ProcessStatus,
258    /// Bounded output retained so far.
259    pub output: ProcessOutput,
260    control: Option<Arc<ProcessControl>>,
261}
262
263impl ActiveProcess {
264    /// Elapsed time since the process started.
265    pub fn elapsed(&self) -> Duration {
266        self.started.elapsed()
267    }
268
269    /// Request cancellation.
270    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/// Registry of active processes.
280///
281/// Tracks running commands by id. One-shot processes are removed when they
282/// complete; background processes remain until explicitly removed or cancelled.
283///
284/// Wired into the live app: background `run_shell` results are registered
285/// here, the `:bg` command lists them, and `cancel_all` runs on quit.
286#[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    /// Create an empty registry.
409    pub fn new() -> Self {
410        Self::default()
411    }
412
413    /// Number of currently active processes (one-shot + background).
414    #[cfg(test)]
415    pub fn len(&self) -> usize {
416        self.inner.state.lock().map(|state| state.active.len()).unwrap_or(0)
417    }
418
419    /// Whether the registry has no active processes.
420    #[cfg(test)]
421    pub fn is_empty(&self) -> bool {
422        self.len() == 0
423    }
424
425    /// Number of background processes.
426    #[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    /// Number of one-shot processes.
444    #[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    /// Register a new process and return its id.
454    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    /// Look up an active process by id.
465    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    /// Request cancellation of a process by id.
471    ///
472    /// Returns `true` if the process existed and cancellation was signalled.
473    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    /// Remove a completed process from the registry.
485    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    /// Cancel all active processes.
495    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    /// Iterate over active process ids.
509    #[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    /// Iterate over active background process ids.
520    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    /// Mark an application-visible background process as announced.
539    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    /// Return completed background results after their start event was observed.
551    pub fn drain_completed(&self) -> Vec<ProcessResult> {
552        self.drain_completed_inner(false)
553    }
554
555    /// Cancel and reap every owned process, returning all final results.
556    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/// Arguments for a shell command execution.
689#[derive(Clone, Debug)]
690pub struct ShellArgs {
691    /// Program to run (e.g. `"cargo"`, `"ls"`, `"echo"`).
692    pub program: String,
693    /// Argv after the program.
694    pub args: Vec<String>,
695    /// Optional working directory relative to the workspace root.
696    /// Defaults to the workspace root.
697    pub cwd: Option<PathBuf>,
698    /// Wall-clock timeout. Defaults to [`TIMEOUT_SECS`].
699    pub timeout: Option<Duration>,
700    /// One-shot or background.
701    pub kind: ProcessKind,
702}
703
704impl ShellArgs {
705    /// The full argv (program + args).
706    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
713/// Provider-visible definition for `run_shell`.
714pub 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
740/// Parse provider JSON arguments for `run_shell`.
741pub 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
768/// Execute a registry request for `run_shell`.
769pub 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
774/// Execute a `run_shell` request with the cancellation token for its enclosing
775/// agent run.
776///
777/// The registry entry uses [`execute_request`] to preserve its stable generic
778/// executor signature. The live agent dispatcher calls this variant so
779/// stopping an agent also terminates its active shell child.
780pub 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
784/// Execute a `run_shell` request with cancellation and an optional
785/// application-owned background-process registry.
786pub 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
795/// Run a shell command with streaming output capture, timeout, and
796/// cancellation.
797///
798/// This is the synchronous execution path used for one-shot commands. The
799/// blocking read runs on the calling thread; callers that need non-blocking
800/// behavior should run this on a worker thread and drain the returned
801/// [`ProcessResult`] through the agent event channel.
802///
803/// The process is killed if:
804/// - the timeout elapses, or
805/// - the [`CancelToken`] is signalled.
806///
807/// stdout/stderr are read on dedicated threads so that a process producing no
808/// output (e.g. `sleep 30`) can still be killed on timeout/cancellation. The
809/// captured output is capped at [`MAX_OUTPUT_BYTES`] bytes and
810/// `MAX_OUTPUT_LINES` lines. Lines longer than `MAX_LINE_LEN` chars are
811/// truncated with `...`.
812pub fn run_command(args: &ShellArgs, root: &Path, cancel: &CancelToken) -> Result<ProcessResult, String> {
813    run_command_with_registry(args, root, cancel, None)
814}
815
816/// Execute a shell command, handing background ownership to `registry`.
817pub 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        // Background ownership is independent from the enclosing agent turn:
835        // cancelling one registry entry must not cancel its sibling tools or
836        // the agent loop that started it.
837        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/// Execute a one-shot shell command and return a [`ToolOutput`] suitable for
856/// the transcript and tool-result channel.
857///
858/// This is the entry point wired into [`crate::tools::dispatch_full`]. It runs
859/// `run_command` on the calling thread (the agent loop already runs on a
860/// background thread), then converts the result into a [`ToolOutput`].
861#[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
870/// Redact known secret patterns from a line of command output.
871///
872/// This is a best-effort deterministic redaction — it covers common formats
873/// (API keys prefixed with `sk-`, bearer tokens, password assignments) but
874/// cannot catch every possible secret. The patterns are intentionally simple
875/// so they are predictable and auditable.
876///
877/// Redacted values are replaced with `[REDACTED]` so the user can see that a
878/// secret was present and scrubbed.
879pub 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
893/// Wait for a child to exit, killing it if the timeout elapses or cancellation
894/// is signalled.
895fn 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                // The direct child can exit while descendants retain its pipes.
902                // Terminate the remaining owned group before joining readers.
903                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
925/// Resolve the working directory for a command, defaulting to the workspace
926/// root. If `cwd` is provided it must be within `root`.
927fn 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
940/// Read a piped stream to a capped byte buffer. Runs on a dedicated reader
941/// thread so the main thread can still poll try_wait for timeout/cancellation.
942fn 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                    // Keep draining the pipe after the retained prefix is
954                    // full so a verbose child cannot block before it exits.
955                    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
968/// Split a byte buffer into lines, capping the line count, truncating long
969/// lines, and redacting known secret patterns.
970fn 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            // Do not let a successful direct child detach descendants from the
1097            // registry. They belong to this process entry and end with it.
1098            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}