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}