rhema_agent_tools/exec.rs
1//! The execution seam behind the `bash` tool: where commands actually run.
2//!
3//! [`crate::shell::ShellTools`] owns the tool's contract — the spec, the
4//! approval gate, the output caps, the timeout, the error shaping — and
5//! delegates the run itself to a [`ShellExecutor`]. The local executor
6//! spawns `bash -c` child processes (feature `local-shell`, default on);
7//! hosts whose shell lives elsewhere bring their own executor — the cloud
8//! deployment runs every command in a remote sandbox through this same
9//! seam. The trait's futures are `Send` like every seam in this tree, so
10//! the executor object crosses await points freely.
11
12use std::collections::BTreeMap;
13use std::path::{Path, PathBuf};
14#[cfg(feature = "local-shell")]
15use std::sync::Arc;
16use std::sync::atomic::AtomicBool;
17use std::time::Duration;
18
19use async_trait::async_trait;
20#[cfg(feature = "local-shell")]
21use tokio::io::{AsyncBufReadExt, BufReader};
22
23/// What one command left behind: the captured streams and the exit code.
24/// The streams carry whatever the executor captured; the tool — not the
25/// executor — applies the display caps to them.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Executed {
28 /// Everything the command wrote to stdout.
29 pub stdout: Vec<u8>,
30 /// Everything the command wrote to stderr.
31 pub stderr: Vec<u8>,
32 /// The exit code; `None` when the process was terminated by a signal.
33 pub code: Option<i32>,
34}
35
36/// Why a command did not reach an exit code. In-band: the tool turns
37/// every variant into an `is_error` result for the model, never an abort.
38#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
39pub enum ShellError {
40 /// The command ran past its timeout and was killed.
41 #[error("command timed out after {0}s and was killed")]
42 Timeout(u64),
43 /// The driver cancelled the command mid-run: the context's cancelled
44 /// flag flipped before the command finished.
45 #[error("cancelled")]
46 Cancelled,
47 /// The command never started — spawning failed, or the executor's
48 /// transport to its host failed.
49 #[error("failed to run bash: {0}")]
50 Failed(String),
51}
52
53/// The bash executable the shell tool and the startup probe run under.
54/// `RHEMA_SHELL` answers first, everywhere — a frontend provisioning a
55/// known bash, a container, a nonstandard layout. Windows has no bash
56/// of its own, so the resolution goes for Git Bash: the one bash most
57/// windows dev boxes have, and what keeps the agent's unix vocabulary
58/// true — the Git for Windows layouts first, then `bash` on PATH (a
59/// git bash that put itself there, MSYS2, cygwin). Everywhere else,
60/// plain `bash` on PATH.
61pub fn bash_executable() -> PathBuf {
62 if let Ok(shell) = std::env::var("RHEMA_SHELL")
63 && !shell.trim().is_empty()
64 {
65 return shell.into();
66 }
67 #[cfg(windows)]
68 for base in ["ProgramFiles", "ProgramFiles(x86)"] {
69 if let Ok(dir) = std::env::var(base) {
70 let candidate = PathBuf::from(dir).join("Git").join("bin").join("bash.exe");
71 if candidate.is_file() {
72 return candidate;
73 }
74 }
75 }
76 PathBuf::from("bash")
77}
78
79/// What a spawn that could not find bash reports: the fact plus the
80/// one install that fixes it, so a windows session names the gap
81/// instead of retrying unix commands into it. In-band: callers shape
82/// this into an error the model reads.
83pub fn bash_missing_message() -> String {
84 if cfg!(windows) {
85 "bash was not found on this windows host — install Git for \
86 Windows (https://git-scm.com/download/win), or point \
87 RHEMA_SHELL at a bash.exe"
88 } else {
89 "bash was not found on this host — install bash, or point \
90 RHEMA_SHELL at the bash executable"
91 }
92 .to_owned()
93}
94
95/// Shapes a spawn failure: not-found names the gap (see
96/// [`bash_missing_message`]), anything else rides the io error text.
97fn spawn_error(err: std::io::Error) -> ShellError {
98 match err.kind() {
99 std::io::ErrorKind::NotFound => ShellError::Failed(bash_missing_message()),
100 _ => ShellError::Failed(format!("{err}")),
101 }
102}
103
104/// Runs shell commands for the `bash` tool.
105#[async_trait]
106pub trait ShellExecutor: Send + Sync {
107 /// Runs `command` with `bash -c` and `cwd` as working directory,
108 /// streaming complete output lines through `progress` as they land
109 /// and returning what the command left behind. A command outliving
110 /// `timeout` is killed and reported as [`ShellError::Timeout`]; a
111 /// flip of `cancelled` mid-run stops the run with
112 /// [`ShellError::Cancelled`]. Implementations must not panic and
113 /// must not abort: every failure comes back as a [`ShellError`].
114 async fn run(
115 &self,
116 command: &str,
117 cwd: &Path,
118 timeout: Duration,
119 cancelled: &AtomicBool,
120 progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
121 ) -> Result<Executed, ShellError>;
122}
123
124/// How often the cancelled flag is sampled while a command's output is
125/// quiet — an `AtomicBool` carries no wakeup, so a silent command would
126/// otherwise never observe a flip until its deadline.
127#[cfg(feature = "local-shell")]
128const CANCEL_TICK: Duration = Duration::from_millis(100);
129
130/// One stream's capture window: everything the result renderer can
131/// show (`BASH_OUTPUT_LIMIT` of bytes, or the last `BASH_OUTPUT_LINES`
132/// rows — whichever cuts later) plus slack for line alignment, so the
133/// truncation the model sees matches an uncaptured run's. Bounds the
134/// memory a chatty command can pin: without it, `yes` streams through
135/// the channel into these buffers until the deadline.
136#[cfg(feature = "local-shell")]
137const CAPTURE_WINDOW: usize = 50 * 1024 + 1024 * 1024;
138
139/// The overshoot an eviction waits for: draining per line would shift
140/// the retained window on every line of a chatty stream (quadratic);
141/// waiting for a quarter-window of overshoot makes the cost
142/// amortized-linear. Peak residency is the window plus this.
143#[cfg(feature = "local-shell")]
144const CAPTURE_EVICT: usize = 256 * 1024;
145
146/// The local executor: commands run as `bash -c` child processes on this
147/// host, streamed line by line, killed past their timeout (`kill_on_drop`
148/// so the dropped child handle kills a still-running process).
149#[cfg(feature = "local-shell")]
150#[derive(Debug, Clone)]
151pub struct LocalShell {
152 /// Extra environment every command runs with, layered over the
153 /// inherited one — how a frontend provisions its agent's tools (a
154 /// daemon URL, a scoped token, a PATH addition) without touching
155 /// the process environment.
156 env: Arc<[(String, String)]>,
157}
158
159#[cfg(feature = "local-shell")]
160impl Default for LocalShell {
161 fn default() -> Self {
162 Self { env: Arc::from([]) }
163 }
164}
165
166#[cfg(feature = "local-shell")]
167#[async_trait]
168impl ShellExecutor for LocalShell {
169 async fn run(
170 &self,
171 command: &str,
172 cwd: &Path,
173 timeout: Duration,
174 cancelled: &AtomicBool,
175 progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
176 ) -> Result<Executed, ShellError> {
177 use std::process::Stdio;
178 use std::sync::atomic::Ordering;
179
180 // kill_on_drop so the timeout dropping the child handle kills the
181 // process; without it an expired command would keep running.
182 let mut child = tokio::process::Command::new(bash_executable())
183 .arg("-c")
184 .arg(command)
185 .current_dir(cwd)
186 .envs(
187 self.env
188 .iter()
189 .map(|(key, value)| (key.as_str(), value.as_str())),
190 )
191 .stdout(Stdio::piped())
192 .stderr(Stdio::piped())
193 .kill_on_drop(true)
194 .spawn()
195 .map_err(spawn_error)?;
196 // One reader per stream, complete lines into one channel: each
197 // line streams live through `progress` and lands in its
198 // stream's tail for the result.
199 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
200 if let Some(stdout) = child.stdout.take() {
201 pump(stdout, false, tx.clone());
202 }
203 if let Some(stderr) = child.stderr.take() {
204 pump(stderr, true, tx.clone());
205 }
206 drop(tx);
207 let deadline = tokio::time::Instant::now() + timeout;
208 let mut stdout: Vec<u8> = Vec::new();
209 let mut stderr: Vec<u8> = Vec::new();
210 loop {
211 tokio::select! {
212 _ = tokio::time::sleep_until(deadline) => {
213 let _ = child.start_kill();
214 let _ = child.wait().await;
215 return Err(ShellError::Timeout(timeout.as_secs()));
216 }
217 line = rx.recv() => match line {
218 Some((is_stderr, line)) => {
219 let sink = if is_stderr { &mut stderr } else { &mut stdout };
220 sink.extend_from_slice(line.as_bytes());
221 sink.push(b'\n');
222 // Bound the capture: the renderer reads only
223 // the tail, so past the window drop whole head
224 // lines down to it. The cut lands on a newline
225 // when there is one, keeping the retained bytes
226 // line-framed for the renderer's tail logic.
227 if sink.len() >= CAPTURE_WINDOW + CAPTURE_EVICT {
228 let keep_from = sink.len() - CAPTURE_WINDOW;
229 let boundary = sink[keep_from..]
230 .iter()
231 .position(|&byte| byte == b'\n')
232 .map_or(keep_from, |position| keep_from + position + 1);
233 sink.drain(..boundary);
234 }
235 progress(&format!("{line}\n"));
236 }
237 // Both streams closed: only the exit status is left.
238 None => break,
239 },
240 // The cancelled flag carries no wakeup: without this tick
241 // a command that streams nothing would not observe a flip
242 // until its deadline. The tick is the cancellation check,
243 // not a timer kill — the budget belongs to the deadline.
244 _ = tokio::time::sleep(CANCEL_TICK) => {
245 if cancelled.load(Ordering::Relaxed) {
246 let _ = child.start_kill();
247 let _ = child.wait().await;
248 return Err(ShellError::Cancelled);
249 }
250 }
251 }
252 if cancelled.load(Ordering::Relaxed) {
253 let _ = child.start_kill();
254 let _ = child.wait().await;
255 return Err(ShellError::Cancelled);
256 }
257 }
258 let status = match tokio::time::timeout(timeout, child.wait()).await {
259 Ok(Ok(status)) => status,
260 // A process that closed its streams but never exits dies with
261 // the handle; the caller reads a timeout like any other.
262 _ => return Err(ShellError::Timeout(timeout.as_secs())),
263 };
264 Ok(Executed {
265 stdout,
266 stderr,
267 code: status.code(),
268 })
269 }
270}
271
272/// Reads `stream` line by line into `tx`, tagged with its stream, until
273/// EOF — the live side of the command's output. Detached: it ends with
274/// the pipe, whether the command finishes, times out, or is killed.
275#[cfg(feature = "local-shell")]
276fn pump<R>(
277 stream: R,
278 is_stderr: bool,
279 tx: tokio::sync::mpsc::UnboundedSender<(bool, String)>,
280) -> tokio::task::JoinHandle<()>
281where
282 R: tokio::io::AsyncRead + Unpin + Send + 'static,
283{
284 tokio::spawn(async move {
285 let mut lines = BufReader::new(stream).lines();
286 while let Ok(Some(line)) = lines.next_line().await {
287 if tx.send((is_stderr, line)).is_err() {
288 break; // the resolver is gone; nothing to stream to
289 }
290 }
291 })
292}
293
294/// The no-shell placeholder: every command fails in-band. What
295/// `ShellTools::new` installs when the build carries no local shell —
296/// the model sees the failure, the operator sees the missing setup.
297#[cfg(not(feature = "local-shell"))]
298#[derive(Debug, Clone, Copy, Default)]
299pub struct NoShell;
300
301#[cfg(not(feature = "local-shell"))]
302#[async_trait]
303impl ShellExecutor for NoShell {
304 async fn run(
305 &self,
306 _command: &str,
307 _cwd: &Path,
308 _timeout: Duration,
309 _cancelled: &AtomicBool,
310 _progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
311 ) -> Result<Executed, ShellError> {
312 Err(ShellError::Failed(
313 "this build has no local shell; install a shell executor \
314 (ShellTools::with_executor)"
315 .into(),
316 ))
317 }
318}
319
320/// The executor `ShellTools::new` installs: the local process shell where
321/// the build has one, the in-band no-shell stub where it does not.
322#[cfg(feature = "local-shell")]
323pub fn default_executor() -> std::sync::Arc<dyn ShellExecutor> {
324 std::sync::Arc::new(LocalShell::default())
325}
326
327/// The default executor with `env` layered over every command's
328/// environment — the provisioning seam a frontend's agent tools ride
329/// in on. A build without the `local-shell` feature has no commands to
330/// provision; the stub ignores `env`.
331#[cfg(feature = "local-shell")]
332pub fn default_executor_with(env: &BTreeMap<String, String>) -> std::sync::Arc<dyn ShellExecutor> {
333 std::sync::Arc::new(LocalShell {
334 env: Arc::from(
335 env.iter()
336 .map(|(key, value)| (key.clone(), value.clone()))
337 .collect::<Vec<_>>(),
338 ),
339 })
340}
341
342/// The stub counterpart of [`default_executor_with`]: nothing runs, so
343/// there is nothing to provision.
344#[cfg(not(feature = "local-shell"))]
345pub fn default_executor_with(_env: &BTreeMap<String, String>) -> std::sync::Arc<dyn ShellExecutor> {
346 std::sync::Arc::new(NoShell)
347}
348
349/// The executor `ShellTools::new` installs: the local process shell where
350/// the build has one, the in-band no-shell stub where it does not.
351#[cfg(not(feature = "local-shell"))]
352pub fn default_executor() -> std::sync::Arc<dyn ShellExecutor> {
353 std::sync::Arc::new(NoShell)
354}
355
356#[cfg(all(test, feature = "local-shell"))]
357mod tests {
358 use super::*;
359 use std::sync::atomic::AtomicBool;
360
361 fn silent(_line: &str) {}
362
363 #[tokio::test]
364 async fn a_chatty_command_cannot_grow_the_capture_without_bound() {
365 let shell = LocalShell::default();
366 let cancelled = Arc::new(AtomicBool::new(false));
367 // ~1.8 MB of stdout: far past the capture window, far under
368 // the deadline, so the cap — not the timeout — is what held.
369 let executed = shell
370 .run(
371 "seq 1 300000",
372 Path::new("/tmp"),
373 Duration::from_secs(60),
374 &cancelled,
375 &mut silent,
376 )
377 .await
378 .unwrap();
379 assert_eq!(executed.code, Some(0));
380 assert!(
381 executed.stdout.len() < CAPTURE_WINDOW + CAPTURE_EVICT,
382 "capture must stay bounded, got {} bytes",
383 executed.stdout.len()
384 );
385 // The tail survives: the last lines are what the model reads.
386 let tail = String::from_utf8_lossy(&executed.stdout);
387 assert!(
388 tail.ends_with("300000\n"),
389 "tail lost: …{}",
390 &tail[tail.len().saturating_sub(40)..]
391 );
392 }
393}
394
395#[cfg(test)]
396mod bash_resolution_tests {
397 use super::*;
398 use std::io::ErrorKind;
399
400 // `RHEMA_SHELL` unset, no Git Bash on a unix CI: the PATH name.
401 #[cfg(unix)]
402 #[test]
403 fn bash_executable_defaults_to_the_path_name() {
404 assert_eq!(bash_executable(), PathBuf::from("bash"));
405 }
406
407 #[test]
408 fn a_missing_bash_names_the_gap_not_the_io_error() {
409 let ShellError::Failed(message) = spawn_error(ErrorKind::NotFound.into()) else {
410 panic!("a not-found spawn is a Failed error");
411 };
412 assert!(message.contains("bash was not found"), "{message}");
413 #[cfg(windows)]
414 assert!(message.contains("Git for Windows"), "{message}");
415 }
416}