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;
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/// Runs shell commands for the `bash` tool.
54#[async_trait]
55pub trait ShellExecutor: Send + Sync {
56 /// Runs `command` with `bash -c` and `cwd` as working directory,
57 /// streaming complete output lines through `progress` as they land
58 /// and returning what the command left behind. A command outliving
59 /// `timeout` is killed and reported as [`ShellError::Timeout`]; a
60 /// flip of `cancelled` mid-run stops the run with
61 /// [`ShellError::Cancelled`]. Implementations must not panic and
62 /// must not abort: every failure comes back as a [`ShellError`].
63 async fn run(
64 &self,
65 command: &str,
66 cwd: &Path,
67 timeout: Duration,
68 cancelled: &AtomicBool,
69 progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
70 ) -> Result<Executed, ShellError>;
71}
72
73/// How often the cancelled flag is sampled while a command's output is
74/// quiet — an `AtomicBool` carries no wakeup, so a silent command would
75/// otherwise never observe a flip until its deadline.
76#[cfg(feature = "local-shell")]
77const CANCEL_TICK: Duration = Duration::from_millis(100);
78
79/// One stream's capture window: everything the result renderer can
80/// show (`BASH_OUTPUT_LIMIT` of bytes, or the last `BASH_OUTPUT_LINES`
81/// rows — whichever cuts later) plus slack for line alignment, so the
82/// truncation the model sees matches an uncaptured run's. Bounds the
83/// memory a chatty command can pin: without it, `yes` streams through
84/// the channel into these buffers until the deadline.
85#[cfg(feature = "local-shell")]
86const CAPTURE_WINDOW: usize = 50 * 1024 + 1024 * 1024;
87
88/// The overshoot an eviction waits for: draining per line would shift
89/// the retained window on every line of a chatty stream (quadratic);
90/// waiting for a quarter-window of overshoot makes the cost
91/// amortized-linear. Peak residency is the window plus this.
92#[cfg(feature = "local-shell")]
93const CAPTURE_EVICT: usize = 256 * 1024;
94
95/// The local executor: commands run as `bash -c` child processes on this
96/// host, streamed line by line, killed past their timeout (`kill_on_drop`
97/// so the dropped child handle kills a still-running process).
98#[cfg(feature = "local-shell")]
99#[derive(Debug, Clone)]
100pub struct LocalShell {
101 /// Extra environment every command runs with, layered over the
102 /// inherited one — how a frontend provisions its agent's tools (a
103 /// daemon URL, a scoped token, a PATH addition) without touching
104 /// the process environment.
105 env: Arc<[(String, String)]>,
106}
107
108#[cfg(feature = "local-shell")]
109impl Default for LocalShell {
110 fn default() -> Self {
111 Self { env: Arc::from([]) }
112 }
113}
114
115#[cfg(feature = "local-shell")]
116#[async_trait]
117impl ShellExecutor for LocalShell {
118 async fn run(
119 &self,
120 command: &str,
121 cwd: &Path,
122 timeout: Duration,
123 cancelled: &AtomicBool,
124 progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
125 ) -> Result<Executed, ShellError> {
126 use std::process::Stdio;
127 use std::sync::atomic::Ordering;
128
129 // kill_on_drop so the timeout dropping the child handle kills the
130 // process; without it an expired command would keep running.
131 let mut child = tokio::process::Command::new("bash")
132 .arg("-c")
133 .arg(command)
134 .current_dir(cwd)
135 .envs(
136 self.env
137 .iter()
138 .map(|(key, value)| (key.as_str(), value.as_str())),
139 )
140 .stdout(Stdio::piped())
141 .stderr(Stdio::piped())
142 .kill_on_drop(true)
143 .spawn()
144 .map_err(|err| ShellError::Failed(format!("{err}")))?;
145 // One reader per stream, complete lines into one channel: each
146 // line streams live through `progress` and lands in its
147 // stream's tail for the result.
148 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
149 if let Some(stdout) = child.stdout.take() {
150 pump(stdout, false, tx.clone());
151 }
152 if let Some(stderr) = child.stderr.take() {
153 pump(stderr, true, tx.clone());
154 }
155 drop(tx);
156 let deadline = tokio::time::Instant::now() + timeout;
157 let mut stdout: Vec<u8> = Vec::new();
158 let mut stderr: Vec<u8> = Vec::new();
159 loop {
160 tokio::select! {
161 _ = tokio::time::sleep_until(deadline) => {
162 let _ = child.start_kill();
163 let _ = child.wait().await;
164 return Err(ShellError::Timeout(timeout.as_secs()));
165 }
166 line = rx.recv() => match line {
167 Some((is_stderr, line)) => {
168 let sink = if is_stderr { &mut stderr } else { &mut stdout };
169 sink.extend_from_slice(line.as_bytes());
170 sink.push(b'\n');
171 // Bound the capture: the renderer reads only
172 // the tail, so past the window drop whole head
173 // lines down to it. The cut lands on a newline
174 // when there is one, keeping the retained bytes
175 // line-framed for the renderer's tail logic.
176 if sink.len() >= CAPTURE_WINDOW + CAPTURE_EVICT {
177 let keep_from = sink.len() - CAPTURE_WINDOW;
178 let boundary = sink[keep_from..]
179 .iter()
180 .position(|&byte| byte == b'\n')
181 .map_or(keep_from, |position| keep_from + position + 1);
182 sink.drain(..boundary);
183 }
184 progress(&format!("{line}\n"));
185 }
186 // Both streams closed: only the exit status is left.
187 None => break,
188 },
189 // The cancelled flag carries no wakeup: without this tick
190 // a command that streams nothing would not observe a flip
191 // until its deadline. The tick is the cancellation check,
192 // not a timer kill — the budget belongs to the deadline.
193 _ = tokio::time::sleep(CANCEL_TICK) => {
194 if cancelled.load(Ordering::Relaxed) {
195 let _ = child.start_kill();
196 let _ = child.wait().await;
197 return Err(ShellError::Cancelled);
198 }
199 }
200 }
201 if cancelled.load(Ordering::Relaxed) {
202 let _ = child.start_kill();
203 let _ = child.wait().await;
204 return Err(ShellError::Cancelled);
205 }
206 }
207 let status = match tokio::time::timeout(timeout, child.wait()).await {
208 Ok(Ok(status)) => status,
209 // A process that closed its streams but never exits dies with
210 // the handle; the caller reads a timeout like any other.
211 _ => return Err(ShellError::Timeout(timeout.as_secs())),
212 };
213 Ok(Executed {
214 stdout,
215 stderr,
216 code: status.code(),
217 })
218 }
219}
220
221/// Reads `stream` line by line into `tx`, tagged with its stream, until
222/// EOF — the live side of the command's output. Detached: it ends with
223/// the pipe, whether the command finishes, times out, or is killed.
224#[cfg(feature = "local-shell")]
225fn pump<R>(
226 stream: R,
227 is_stderr: bool,
228 tx: tokio::sync::mpsc::UnboundedSender<(bool, String)>,
229) -> tokio::task::JoinHandle<()>
230where
231 R: tokio::io::AsyncRead + Unpin + Send + 'static,
232{
233 tokio::spawn(async move {
234 let mut lines = BufReader::new(stream).lines();
235 while let Ok(Some(line)) = lines.next_line().await {
236 if tx.send((is_stderr, line)).is_err() {
237 break; // the resolver is gone; nothing to stream to
238 }
239 }
240 })
241}
242
243/// The no-shell placeholder: every command fails in-band. What
244/// `ShellTools::new` installs when the build carries no local shell —
245/// the model sees the failure, the operator sees the missing setup.
246#[cfg(not(feature = "local-shell"))]
247#[derive(Debug, Clone, Copy, Default)]
248pub struct NoShell;
249
250#[cfg(not(feature = "local-shell"))]
251#[async_trait]
252impl ShellExecutor for NoShell {
253 async fn run(
254 &self,
255 _command: &str,
256 _cwd: &Path,
257 _timeout: Duration,
258 _cancelled: &AtomicBool,
259 _progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
260 ) -> Result<Executed, ShellError> {
261 Err(ShellError::Failed(
262 "this build has no local shell; install a shell executor \
263 (ShellTools::with_executor)"
264 .into(),
265 ))
266 }
267}
268
269/// The executor `ShellTools::new` installs: the local process shell where
270/// the build has one, the in-band no-shell stub where it does not.
271#[cfg(feature = "local-shell")]
272pub fn default_executor() -> std::sync::Arc<dyn ShellExecutor> {
273 std::sync::Arc::new(LocalShell::default())
274}
275
276/// The default executor with `env` layered over every command's
277/// environment — the provisioning seam a frontend's agent tools ride
278/// in on. A build without the `local-shell` feature has no commands to
279/// provision; the stub ignores `env`.
280#[cfg(feature = "local-shell")]
281pub fn default_executor_with(env: &BTreeMap<String, String>) -> std::sync::Arc<dyn ShellExecutor> {
282 std::sync::Arc::new(LocalShell {
283 env: Arc::from(
284 env.iter()
285 .map(|(key, value)| (key.clone(), value.clone()))
286 .collect::<Vec<_>>(),
287 ),
288 })
289}
290
291/// The stub counterpart of [`default_executor_with`]: nothing runs, so
292/// there is nothing to provision.
293#[cfg(not(feature = "local-shell"))]
294pub fn default_executor_with(_env: &BTreeMap<String, String>) -> std::sync::Arc<dyn ShellExecutor> {
295 std::sync::Arc::new(NoShell)
296}
297
298/// The executor `ShellTools::new` installs: the local process shell where
299/// the build has one, the in-band no-shell stub where it does not.
300#[cfg(not(feature = "local-shell"))]
301pub fn default_executor() -> std::sync::Arc<dyn ShellExecutor> {
302 std::sync::Arc::new(NoShell)
303}
304
305#[cfg(all(test, feature = "local-shell"))]
306mod tests {
307 use super::*;
308 use std::sync::atomic::AtomicBool;
309
310 fn silent(_line: &str) {}
311
312 #[tokio::test]
313 async fn a_chatty_command_cannot_grow_the_capture_without_bound() {
314 let shell = LocalShell::default();
315 let cancelled = Arc::new(AtomicBool::new(false));
316 // ~1.8 MB of stdout: far past the capture window, far under
317 // the deadline, so the cap — not the timeout — is what held.
318 let executed = shell
319 .run(
320 "seq 1 300000",
321 Path::new("/tmp"),
322 Duration::from_secs(60),
323 &cancelled,
324 &mut silent,
325 )
326 .await
327 .unwrap();
328 assert_eq!(executed.code, Some(0));
329 assert!(
330 executed.stdout.len() < CAPTURE_WINDOW + CAPTURE_EVICT,
331 "capture must stay bounded, got {} bytes",
332 executed.stdout.len()
333 );
334 // The tail survives: the last lines are what the model reads.
335 let tail = String::from_utf8_lossy(&executed.stdout);
336 assert!(
337 tail.ends_with("300000\n"),
338 "tail lost: …{}",
339 &tail[tail.len().saturating_sub(40)..]
340 );
341 }
342}