Skip to main content

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}