Skip to main content

shell_mcp/
exec.rs

1//! Command execution with separate stdout/stderr capture, CRLF
2//! normalisation, and a per-stream truncation cap.
3//!
4//! The cap is "200 lines or 8KB, whichever comes first" per stream. We
5//! normalise CRLF to LF before counting lines so the output is consistent
6//! across platforms. The single `truncated` flag in [`ExecOutcome`] is true
7//! if either stream was clipped.
8
9use std::path::{Path, PathBuf};
10use std::process::Stdio;
11use std::time::Duration;
12
13use tokio::process::Command;
14use tokio::time::timeout;
15
16/// Per-stream output cap (bytes). Counted on the post-CRLF-normalised stream.
17pub const MAX_BYTES_PER_STREAM: usize = 8 * 1024;
18/// Per-stream output cap (lines). Counted after CRLF normalisation.
19pub const MAX_LINES_PER_STREAM: usize = 200;
20/// Hard wall-clock cap for any single command. Exceeding this returns a
21/// truncated result with whatever output has been gathered so far.
22pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
23
24/// Knobs for a single execution.
25#[derive(Debug, Clone)]
26pub struct ExecOptions {
27    pub cwd: PathBuf,
28    pub timeout: Duration,
29}
30
31impl ExecOptions {
32    pub fn new(cwd: impl Into<PathBuf>) -> Self {
33        Self {
34            cwd: cwd.into(),
35            timeout: DEFAULT_TIMEOUT,
36        }
37    }
38}
39
40/// Result of running a single command.
41#[derive(Debug, Clone)]
42pub struct ExecOutcome {
43    pub stdout: String,
44    pub stderr: String,
45    /// `None` when the process was killed (signal or timeout).
46    pub exit_code: Option<i32>,
47    /// True if any stream was clipped or the process was killed by timeout.
48    pub truncated: bool,
49    /// True if the command was terminated due to wall-clock timeout.
50    pub timed_out: bool,
51}
52
53/// Execute the given tokens. The first token is the program; the rest are
54/// passed as discrete arguments — no shell is invoked, which is why the
55/// metacharacter rejection in [`crate::safety`] is a hard prerequisite.
56pub async fn execute(tokens: &[String], opts: &ExecOptions) -> Result<ExecOutcome, ExecError> {
57    let (program, args) = tokens.split_first().ok_or(ExecError::EmptyCommand)?;
58
59    let mut cmd = Command::new(program);
60    cmd.args(args)
61        .current_dir(&opts.cwd)
62        .stdin(Stdio::null())
63        .stdout(Stdio::piped())
64        .stderr(Stdio::piped())
65        .kill_on_drop(true);
66
67    let output_future = cmd.output();
68    match timeout(opts.timeout, output_future).await {
69        Ok(Ok(output)) => {
70            let (stdout, stdout_truncated) = clip(&output.stdout);
71            let (stderr, stderr_truncated) = clip(&output.stderr);
72            Ok(ExecOutcome {
73                stdout,
74                stderr,
75                exit_code: output.status.code(),
76                truncated: stdout_truncated || stderr_truncated,
77                timed_out: false,
78            })
79        }
80        Ok(Err(e)) => Err(ExecError::Spawn {
81            program: program.clone(),
82            source: e,
83        }),
84        Err(_) => Ok(ExecOutcome {
85            stdout: String::new(),
86            stderr: format!(
87                "command timed out after {} seconds and was killed",
88                opts.timeout.as_secs()
89            ),
90            exit_code: None,
91            truncated: true,
92            timed_out: true,
93        }),
94    }
95}
96
97/// Normalise CRLF to LF and clip to the per-stream caps. Returns
98/// `(text, truncated)`.
99fn clip(raw: &[u8]) -> (String, bool) {
100    let text = String::from_utf8_lossy(raw).replace("\r\n", "\n");
101    let mut byte_truncated = false;
102    let mut line_truncated = false;
103
104    let bounded_bytes = if text.len() > MAX_BYTES_PER_STREAM {
105        byte_truncated = true;
106        // Find the largest valid UTF-8 boundary at or before MAX_BYTES_PER_STREAM
107        // so we never split a multi-byte codepoint.
108        let mut cut = MAX_BYTES_PER_STREAM;
109        while cut > 0 && !text.is_char_boundary(cut) {
110            cut -= 1;
111        }
112        &text[..cut]
113    } else {
114        text.as_str()
115    };
116
117    let mut out = String::with_capacity(bounded_bytes.len());
118    for (i, line) in bounded_bytes.split_inclusive('\n').enumerate() {
119        if i >= MAX_LINES_PER_STREAM {
120            line_truncated = true;
121            break;
122        }
123        out.push_str(line);
124    }
125    (out, byte_truncated || line_truncated)
126}
127
128#[derive(Debug, thiserror::Error)]
129pub enum ExecError {
130    #[error("cannot execute empty command")]
131    EmptyCommand,
132
133    #[error("could not spawn `{program}`: {source}")]
134    Spawn {
135        program: String,
136        #[source]
137        source: std::io::Error,
138    },
139}
140
141/// Helper used by integration tests and `shell_describe` to surface the
142/// resolved command directory as a string.
143pub fn cwd_label(cwd: &Path) -> String {
144    cwd.display().to_string()
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn clip_normalises_crlf() {
153        let raw = b"a\r\nb\r\nc\n";
154        let (text, truncated) = clip(raw);
155        assert_eq!(text, "a\nb\nc\n");
156        assert!(!truncated);
157    }
158
159    #[test]
160    fn clip_enforces_line_cap() {
161        let raw: Vec<u8> = (0..300)
162            .map(|i| format!("line-{i}\n"))
163            .collect::<String>()
164            .into_bytes();
165        let (text, truncated) = clip(&raw);
166        assert!(truncated);
167        let line_count = text.lines().count();
168        assert_eq!(line_count, MAX_LINES_PER_STREAM);
169    }
170
171    #[test]
172    fn clip_enforces_byte_cap() {
173        let raw = vec![b'x'; MAX_BYTES_PER_STREAM + 1024];
174        let (text, truncated) = clip(&raw);
175        assert!(truncated);
176        assert!(text.len() <= MAX_BYTES_PER_STREAM);
177    }
178
179    #[tokio::test]
180    async fn echo_runs_and_returns_zero() {
181        let tmp = tempfile::tempdir().unwrap();
182        let opts = ExecOptions::new(tmp.path());
183        let program = if cfg!(windows) { "cmd" } else { "echo" };
184        let tokens: Vec<String> = if cfg!(windows) {
185            ["cmd", "/C", "echo", "hi"]
186                .iter()
187                .map(|s| s.to_string())
188                .collect()
189        } else {
190            ["echo", "hi"].iter().map(|s| s.to_string()).collect()
191        };
192        let _ = program; // silence unused warning on non-windows
193        let out = execute(&tokens, &opts).await.unwrap();
194        assert_eq!(out.exit_code, Some(0));
195        assert!(out.stdout.contains("hi"));
196        assert!(!out.truncated);
197    }
198}