Skip to main content

io_harness/tools/
exec.rs

1//! Running a project's own toolchain: a fixed argv, in the workspace, bounded.
2//!
3//! This is the module the 0.17.0 repositioning rests on. Until it existed the
4//! crate could edit a repository and could not build one, so "debug a production
5//! issue" or "fix a deployment" were not tasks a contract could express — there
6//! was no way to run `npm install`, `go build`, `pytest`, or `make`.
7//!
8//! ## What is fixed and what is supplied
9//!
10//! The opposite arrangement to [`git`](super::git), and deliberately. There the
11//! module owns the whole argv and the model supplies only paths, because `git`
12//! has subcommands that fetch code and options that execute it, so the safe set
13//! is a closed one. Here the model supplies the whole argv, because the point is
14//! to run a command this crate has never heard of — and the boundary moves
15//! accordingly: it is the [`Policy`](crate::Policy), checked on the program *and*
16//! on the joined argv, that decides which of them may start.
17//!
18//! What stays fixed is that there is no shell. The argv is an array of strings
19//! handed to the operating system as an array of strings, so `;`, `&&`, `$( )`
20//! and a backtick are ordinary bytes inside one argument rather than syntax. A
21//! command with a metacharacter in it does not become two commands, because
22//! nothing on this path ever parses one.
23//!
24//! ## What this does not bound
25//!
26//! A command runs in the workspace root **with the embedding program's
27//! privileges**, not inside the [`Sandbox`](crate::Sandbox). That is the owner's
28//! decision of 2026-07-29, recorded in `US-IO-HARNESS-0.17.0-I02`, and it is
29//! taken with its cost stated: the sandbox denies network egress by default and
30//! discards its working directory, which is right for a verification gate and
31//! makes `npm install` impossible. So the policy decides what may *start*, and
32//! not what a started process then does — the same honest bound this crate
33//! already states for a registered [`Tool`](super::Tool) and for a stdio MCP
34//! server.
35//!
36//! Two ceilings apply to what a started process may do to the *run*: a wall-clock
37//! timeout, so a wedged command dies naming itself instead of consuming the
38//! contract's whole time budget and reporting a budget stop; and the run's own
39//! per-observation ceiling applied head-and-tail, so a build log keeps what ran
40//! at the top and what failed at the bottom.
41
42use std::path::{Path, PathBuf};
43use std::process::Stdio;
44use std::time::Duration;
45
46use tokio::process::Command;
47
48use crate::error::{Error, Result};
49
50/// How long a command started by the `exec` tool may run before it is killed.
51///
52/// Generous on purpose. This is a ceiling on a *wedged* command, not a budget: a
53/// cold `npm install` or a from-scratch `cargo build` legitimately takes minutes,
54/// and a timeout tight enough to be interesting is a timeout that fails honest
55/// work. What it prevents is a command that will never return quietly eating the
56/// contract's time budget and being reported as a budget stop, which sends whoever
57/// reads the trace to the wrong diagnosis entirely.
58///
59/// Override it per contract when the project's own build is slower than this, or
60/// when a run should give up sooner:
61///
62/// ```
63/// use io_harness::{TaskContract, Verification, DEFAULT_EXEC_TIMEOUT};
64/// use std::time::Duration;
65///
66/// assert_eq!(DEFAULT_EXEC_TIMEOUT, Duration::from_secs(900));
67///
68/// // A monorepo whose cold build genuinely takes half an hour.
69/// let contract = TaskContract::workspace("build it", "/repo", Verification::None)
70///     .with_exec_timeout(Duration::from_secs(1800));
71///
72/// assert_eq!(contract.exec_timeout, Duration::from_secs(1800));
73/// ```
74pub const DEFAULT_EXEC_TIMEOUT: Duration = Duration::from_secs(900);
75
76/// What one `exec` spawn produced.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub(crate) enum ExecOutcome {
79    /// The child ran to completion. `code` is `None` when a signal killed it.
80    Ran {
81        /// The exit status code.
82        code: Option<i32>,
83        /// Standard output, head-and-tail bounded.
84        stdout: String,
85        /// Standard error, head-and-tail bounded.
86        stderr: String,
87        /// How many characters the two bounds together elided, or 0.
88        elided: usize,
89    },
90    /// The child outlived the wall-clock timeout and was killed.
91    TimedOut {
92        /// The ceiling it crossed.
93        after: Duration,
94    },
95    /// There is no such program on this machine. Not a failed run: the model is
96    /// told and carries on, exactly as it is told when `git` is missing.
97    Unavailable {
98        /// What was looked for, for the observation text.
99        reason: String,
100    },
101}
102
103/// Spawns caller-supplied argvs in one directory, bounded in time and output.
104///
105/// Holds no [`Policy`](crate::Policy) — unlike [`Git`](super::git::Git), whose
106/// argv it builds itself and can therefore check at the same moment. Here the
107/// argv arrives from the model and is checked by `dispatch`'s ordinary gate
108/// before this type is reached, so the approval tier ([`Effect::Ask`]) routes to
109/// the [`Approver`](crate::Approver) exactly as it does for a read or a write.
110/// A runner that re-checked would be a second boundary that could disagree with
111/// the first.
112///
113/// [`Effect::Ask`]: crate::Effect::Ask
114pub(crate) struct Exec {
115    workdir: PathBuf,
116    timeout: Duration,
117    cap: usize,
118}
119
120impl Exec {
121    /// Spawn in `workdir`, killing after `timeout`, bounding each captured stream
122    /// at `cap` chars.
123    ///
124    /// `cap` is the run's per-observation ceiling from
125    /// [`entry_cap_chars`](crate::context::entry_cap_chars), so a command's
126    /// output obeys the same bound as every other tool result rather than one of
127    /// its own — the same call [`Git`](super::git::Git) makes.
128    pub(crate) fn new(workdir: impl Into<PathBuf>, timeout: Duration, cap: usize) -> Self {
129        Self {
130            workdir: workdir.into(),
131            timeout,
132            cap,
133        }
134    }
135
136    /// Run `argv`, already authorised by the caller.
137    ///
138    /// `argv[0]` is the program. An empty argv is an [`Error::Config`] rather
139    /// than a panic: it reaches here from model-supplied JSON.
140    pub(crate) async fn run(&self, argv: &[String]) -> Result<ExecOutcome> {
141        let Some(program) = argv.first() else {
142            return Err(Error::Config("exec needs a non-empty argv".into()));
143        };
144        let mut cmd = command(program, &argv[1..], &self.workdir);
145        match tokio::time::timeout(self.timeout, cmd.output()).await {
146            // Dropping the `output()` future drops the `Child`, and the child was
147            // configured `kill_on_drop`, so the timeout *is* the kill. What that
148            // reaches is the direct child only: a process it started itself may
149            // outlive it on every platform this crate supports. Reported rather
150            // than hidden — see docs/CONTRACT.md.
151            Err(_elapsed) => Ok(ExecOutcome::TimedOut {
152                after: self.timeout,
153            }),
154            Ok(Err(e)) if e.kind() == std::io::ErrorKind::NotFound => {
155                Ok(ExecOutcome::Unavailable {
156                    reason: format!("no `{program}` on PATH"),
157                })
158            }
159            Ok(Err(e)) => Err(Error::Io(e)),
160            Ok(Ok(out)) => {
161                let (stdout, cut_out) =
162                    head_and_tail(&String::from_utf8_lossy(&out.stdout), self.cap);
163                let (stderr, cut_err) =
164                    head_and_tail(&String::from_utf8_lossy(&out.stderr), self.cap);
165                Ok(ExecOutcome::Ran {
166                    code: out.status.code(),
167                    stdout,
168                    stderr,
169                    elided: cut_out + cut_err,
170                })
171            }
172        }
173    }
174}
175
176/// The configured child. Separate from [`Exec::run`] so the argv and the
177/// environment it pins are inspectable in a test without spawning anything.
178///
179/// The environment is *inherited*, unlike [`git`](super::git)'s. A build needs
180/// `PATH`, `HOME`, the toolchain's own variables and whatever else the operator
181/// exported; stripping it would break the capability this module exists to
182/// provide. What is pinned is the parts that would hang or lie: stdin is the null
183/// device, because a command that prompts must fail rather than wait forever on a
184/// terminal that will never answer, and both output streams are piped so they can
185/// be captured and bounded instead of landing on the host's console.
186fn command(program: &str, args: &[String], workdir: &Path) -> Command {
187    let mut c = Command::new(program);
188    c.args(args)
189        .current_dir(workdir)
190        .stdin(Stdio::null())
191        .stdout(Stdio::piped())
192        .stderr(Stdio::piped())
193        // What makes the wall-clock timeout a kill rather than a detachment.
194        .kill_on_drop(true);
195    c
196}
197
198/// Keep `cap` characters of `s` from both ends, saying what was dropped.
199///
200/// Returns the bounded text and how many characters it elided (0 when it fitted).
201///
202/// Both ends, rather than the head-only cut [`cap_result`](super::cap_result)
203/// applies elsewhere, because this text is a build log and the two things a
204/// reader needs are at opposite ends of it: what ran, at the top, and what failed,
205/// at the bottom. A tail-only cut loses the command; a head-only cut loses the
206/// error, which is the more common and the more expensive of the two.
207///
208/// The elision is stated in the returned text, not only in the count, because the
209/// model reads the text and nothing else. An agent that cannot tell a truncated
210/// output from a complete one will conclude the build printed nothing after line
211/// 400.
212pub(crate) fn head_and_tail(s: &str, cap: usize) -> (String, usize) {
213    let total = s.chars().count();
214    if total <= cap {
215        return (s.to_string(), 0);
216    }
217    // Half each. The failure is usually at the bottom and the command at the top,
218    // and there is no principled ratio between "what ran" and "what went wrong".
219    let head = cap / 2;
220    let tail = cap - head;
221    let elided = total - head - tail;
222    let head_end = char_offset(s, head);
223    let tail_start = char_offset(s, total - tail);
224    (
225        format!(
226            "{}\n[... {elided} characters elided ...]\n{}",
227            &s[..head_end],
228            &s[tail_start..]
229        ),
230        elided,
231    )
232}
233
234/// The byte offset of the `n`th character, or the string's length.
235fn char_offset(s: &str, n: usize) -> usize {
236    s.char_indices().nth(n).map_or(s.len(), |(i, _)| i)
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    const CAP: usize = 100_000;
244
245    /// A short timeout for the tests that are *about* the timeout, and a long one
246    /// everywhere else so a slow machine does not fail an unrelated assertion.
247    fn exec(dir: &Path) -> Exec {
248        Exec::new(dir, Duration::from_secs(120), CAP)
249    }
250
251    #[test]
252    fn the_child_gets_the_argv_verbatim_with_no_shell_between() {
253        let dir = tempfile::tempdir().unwrap();
254        // Every metacharacter a shell would act on, inside ONE argument.
255        let nasty = "a;b && c $(id) `whoami` | d > e";
256        let cmd = command("prog", &[nasty.to_string(), "second".into()], dir.path());
257
258        let args: Vec<String> = cmd
259            .as_std()
260            .get_args()
261            .map(|a| a.to_string_lossy().into_owned())
262            .collect();
263        assert_eq!(
264            args,
265            vec![nasty.to_string(), "second".to_string()],
266            "the metacharacters stayed inside one argument and nothing split it"
267        );
268        assert_eq!(cmd.as_std().get_current_dir(), Some(dir.path()));
269    }
270
271    #[tokio::test]
272    async fn a_missing_program_is_an_unavailable_outcome_not_a_run_failure() {
273        let dir = tempfile::tempdir().unwrap();
274        let out = exec(dir.path())
275            .run(&["io-harness-no-such-program".to_string()])
276            .await
277            .unwrap();
278        assert!(matches!(out, ExecOutcome::Unavailable { .. }), "{out:?}");
279    }
280
281    #[tokio::test]
282    async fn an_empty_argv_is_a_config_error_rather_than_a_panic() {
283        let dir = tempfile::tempdir().unwrap();
284        assert!(matches!(
285            exec(dir.path()).run(&[]).await,
286            Err(Error::Config(_))
287        ));
288    }
289
290    /// `rustc` is the one program guaranteed present wherever `cargo test` runs,
291    /// which is what lets this assert capture and exit status without a fixture
292    /// toolchain or a network.
293    #[tokio::test]
294    async fn a_real_command_reports_its_exit_status_and_its_output() {
295        let dir = tempfile::tempdir().unwrap();
296        let out = exec(dir.path())
297            .run(&["rustc".to_string(), "--version".to_string()])
298            .await
299            .unwrap();
300        let ExecOutcome::Ran { code, stdout, .. } = out else {
301            panic!("rustc must be present wherever cargo test runs: {out:?}");
302        };
303        assert_eq!(code, Some(0));
304        assert!(stdout.starts_with("rustc "), "{stdout:?}");
305    }
306
307    #[tokio::test]
308    async fn a_failing_command_comes_back_as_a_nonzero_exit_with_its_stderr() {
309        let dir = tempfile::tempdir().unwrap();
310        let out = exec(dir.path())
311            .run(&["rustc".to_string(), "no-such-file.rs".to_string()])
312            .await
313            .unwrap();
314        let ExecOutcome::Ran { code, stderr, .. } = out else {
315            panic!("expected a run: {out:?}");
316        };
317        assert_ne!(code, Some(0));
318        assert!(!stderr.trim().is_empty(), "the compiler said why");
319    }
320
321    /// The command runs in the workspace root, proven by giving it a *relative*
322    /// path that only resolves there.
323    #[tokio::test]
324    async fn the_working_directory_is_the_one_it_was_given() {
325        let dir = tempfile::tempdir().unwrap();
326        std::fs::write(dir.path().join("lib.rs"), "pub fn f() -> u32 { 1 }\n").unwrap();
327        let out = exec(dir.path())
328            .run(&[
329                "rustc".to_string(),
330                "--crate-type".to_string(),
331                "lib".to_string(),
332                "lib.rs".to_string(),
333                "--out-dir".to_string(),
334                ".".to_string(),
335            ])
336            .await
337            .unwrap();
338        assert!(
339            matches!(out, ExecOutcome::Ran { code: Some(0), .. }),
340            "{out:?}"
341        );
342        assert!(
343            dir.path().join("liblib.rlib").exists(),
344            "the relative output path resolved against the workspace root"
345        );
346    }
347
348    #[test]
349    fn output_that_fits_is_returned_whole_and_reports_no_elision() {
350        let (out, elided) = head_and_tail("all of it", 100);
351        assert_eq!(out, "all of it");
352        assert_eq!(elided, 0);
353    }
354
355    #[test]
356    fn oversized_output_keeps_both_ends_and_says_how_much_it_dropped() {
357        let log: String = (0..200).map(|i| format!("line {i}\n")).collect();
358        let (out, elided) = head_and_tail(&log, 200);
359
360        assert!(out.contains("line 0\n"), "the head survived: {out}");
361        assert!(out.contains("line 199\n"), "the tail survived: {out}");
362        assert!(!out.contains("line 100\n"), "the middle went");
363        assert!(elided > 0);
364        assert!(
365            out.contains(&format!("{elided} characters elided")),
366            "the model is told what it is missing: {out}"
367        );
368    }
369
370    #[test]
371    fn the_elision_is_measured_in_characters_not_bytes() {
372        // Four-byte characters: a byte-indexed cut would slice one in half and
373        // panic, and a byte-counted elision would over-report.
374        let s: String = std::iter::repeat_n('🌍', 100).collect();
375        let (out, elided) = head_and_tail(&s, 20);
376        assert_eq!(elided, 80);
377        assert_eq!(out.chars().filter(|c| *c == '🌍').count(), 20);
378    }
379
380    #[tokio::test]
381    async fn a_command_that_outlives_the_timeout_is_killed_and_named_as_a_timeout() {
382        let dir = tempfile::tempdir().unwrap();
383        // A sleep, spelled the way each platform spells it. Both are present on
384        // every runner image this crate builds on.
385        let argv: Vec<String> = if cfg!(windows) {
386            ["ping", "-n", "30", "127.0.0.1"]
387        } else {
388            ["sleep", "30", "", ""]
389        }
390        .iter()
391        .filter(|a| !a.is_empty())
392        .map(|a| (*a).to_string())
393        .collect();
394
395        let started = std::time::Instant::now();
396        let out = Exec::new(dir.path(), Duration::from_millis(300), CAP)
397            .run(&argv)
398            .await
399            .unwrap();
400
401        assert!(
402            matches!(out, ExecOutcome::TimedOut { .. }),
403            "a wedged command must report itself, not the run's budget: {out:?}"
404        );
405        assert!(
406            started.elapsed() < Duration::from_secs(10),
407            "the run did not wait for the command to finish"
408        );
409    }
410
411    /// The negative control for the test above: the same runner and the same
412    /// short ceiling, over a command that finishes inside it.
413    #[tokio::test]
414    async fn a_command_that_finishes_inside_the_timeout_is_not_reported_as_one() {
415        let dir = tempfile::tempdir().unwrap();
416        let out = Exec::new(dir.path(), Duration::from_secs(120), CAP)
417            .run(&["rustc".to_string(), "--version".to_string()])
418            .await
419            .unwrap();
420        assert!(matches!(out, ExecOutcome::Ran { .. }), "{out:?}");
421    }
422}