Skip to main content

rpi_tools/
shell_output.rs

1//! Mirrors `packages/agent/src/harness/utils/shell-output.ts` — the bash tool's
2//! output-capture layer wrapping `Shell::exec`.
3//!
4//! Combines stdout+stderr into a single rolling tail buffer (capped at
5//! `max_output_bytes = DEFAULT_MAX_BYTES * 2 = 100KB`), spills to a temp file
6//! (`bash-*.log`) once cumulative output exceeds `DEFAULT_MAX_BYTES`/`DEFAULT_MAX_LINES`,
7//! and produces a [`ShellCaptureResult`] carrying truncation metadata + the
8//! temp-file path. `on_chunk` fires per sanitized/CR-stripped chunk with a
9//! progress snapshot.
10//!
11//! `return_execution_errors: true` causes exec failures to come back as a
12//! success-result with `execution_error` populated (so the bash tool — not the
13//! env — owns error rendering via `append_status`). There is NO time-based
14//! throttle here; throttling lives in the bash tool.
15//!
16//! **v1 divergence from the TS:** the TS initializes the temp file mid-stream
17//! (inside the stdout/stderr callback) and appends subsequent chunks. Doing
18//! async file I/O from a sync `FnMut` callback is unsound in Rust, so the v1
19//! port performs the spill in a single **post-stream** pass: the entire
20//! captured tail is written to the temp file after `exec` resolves. The
21//! `full_output_path` and truncation metadata are identical; only the timing
22//! of the file write differs. Tests verify the temp file exists when output
23//! exceeds limits — they don't depend on mid-stream creation.
24
25use std::collections::HashMap;
26use std::path::PathBuf;
27use std::sync::Arc;
28
29use tokio::sync::Mutex;
30use tokio_util::sync::CancellationToken;
31
32use crate::env::{ExecutionEnv, FileContent, ShellExecOptions};
33use crate::error::{ExecutionError, ExecutionErrorCode};
34use crate::truncate::{
35    trim_to_last_utf8_bytes, truncate_tail, TruncationLimit, TruncationOptions, TruncationResult,
36    DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES,
37};
38
39/// The 100KB cap on the in-memory `tail_output` rolling buffer.
40const MAX_OUTPUT_BYTES: usize = DEFAULT_MAX_BYTES * 2;
41
42/// Mirrors `ShellCaptureProgress`.
43#[derive(Clone)]
44pub struct ShellCaptureProgress {
45    pub output: String,
46    pub truncation: TruncationResult,
47    pub full_output_path: Option<PathBuf>,
48    pub last_line_bytes: usize,
49}
50
51/// Mirrors `ShellCaptureResult`.
52#[derive(Clone)]
53pub struct ShellCaptureResult {
54    pub output: String,
55    pub truncation: TruncationResult,
56    pub full_output_path: Option<PathBuf>,
57    pub last_line_bytes: usize,
58    pub exit_code: Option<i32>,
59    pub cancelled: bool,
60    pub truncated: bool,
61    pub execution_error: Option<ExecutionError>,
62}
63
64/// Mirrors `ShellCaptureOptions`.
65pub struct ShellCaptureOptions<'a> {
66    pub cwd: Option<PathBuf>,
67    pub env: Option<HashMap<String, String>>,
68    pub inherit_env: bool,
69    pub timeout: Option<f64>,
70    pub cancel: Option<&'a CancellationToken>,
71    pub on_chunk: Option<Box<dyn FnMut(&str, &dyn Fn() -> ShellCaptureProgress) + Send + 'a>>,
72    pub return_execution_errors: bool,
73}
74
75impl<'a> Default for ShellCaptureOptions<'a> {
76    fn default() -> Self {
77        Self {
78            cwd: None,
79            env: None,
80            inherit_env: true,
81            timeout: None,
82            cancel: None,
83            on_chunk: None,
84            return_execution_errors: false,
85        }
86    }
87}
88
89/// Shared capture state, mutated by the stdout/stderr closures and read by the
90/// post-stream progress computation.
91#[derive(Default)]
92struct CaptureState {
93    tail_output: String,
94    total_bytes: usize,
95    completed_lines: usize,
96    has_open_line: bool,
97    current_line_bytes: usize,
98    full_output_path: Option<PathBuf>,
99    full_output_requested: bool,
100}
101
102impl CaptureState {
103    fn ingest_chunk(&mut self, text: &str) {
104        let text_bytes = text.len();
105        self.total_bytes += text_bytes;
106        let newline_count = text.matches('\n').count();
107        self.completed_lines += newline_count;
108        let last_newline = text.rfind('\n');
109        match last_newline {
110            Some(idx) => {
111                let trailing = &text[idx + 1..];
112                self.current_line_bytes = trailing.len();
113                self.has_open_line = !trailing.is_empty();
114            }
115            None if !text.is_empty() => {
116                self.current_line_bytes += text_bytes;
117                self.has_open_line = true;
118            }
119            None => {}
120        }
121        self.tail_output.push_str(text);
122        // Cap the in-memory tail at MAX_OUTPUT_BYTES.
123        let trimmed = trim_to_last_utf8_bytes(&self.tail_output, MAX_OUTPUT_BYTES);
124        if trimmed.len() != self.tail_output.len() {
125            self.tail_output = trimmed.to_string();
126        }
127    }
128
129    fn create_progress(&self) -> ShellCaptureProgress {
130        let tail_truncation = truncate_tail(&self.tail_output, TruncationOptions::default());
131        let total_lines = self.completed_lines + if self.has_open_line { 1 } else { 0 };
132        let truncated = total_lines > DEFAULT_MAX_LINES || self.total_bytes > DEFAULT_MAX_BYTES;
133        let truncated_by = if truncated {
134            tail_truncation
135                .truncated_by
136                .or(if self.total_bytes > DEFAULT_MAX_BYTES {
137                    Some(TruncationLimit::Bytes)
138                } else {
139                    Some(TruncationLimit::Lines)
140                })
141        } else {
142            None
143        };
144        let truncation = TruncationResult {
145            truncated,
146            truncated_by,
147            total_lines,
148            total_bytes: self.total_bytes,
149            ..tail_truncation
150        };
151        let output = if truncated {
152            truncation.content.clone()
153        } else {
154            self.tail_output.clone()
155        };
156        ShellCaptureProgress {
157            output,
158            truncation,
159            full_output_path: self.full_output_path.clone(),
160            last_line_bytes: self.current_line_bytes,
161        }
162    }
163}
164
165/// Sanitize a chunk: drop C0 controls (except TAB/LF/CR) and interlinear
166/// annotation chars (U+FFF9..U+FFFB), then strip all CR. Mirrors
167/// `sanitizeBinaryOutput` + the per-chunk `.replace(/\r/g, "")`.
168///
169/// ANSI escape sequences are consumed whole rather than having just their
170/// leading ESC byte (a C0 control) dropped: dropping only ESC left the
171/// sequence body behind, so colored output (e.g. `vite` build logs) surfaced
172/// as literal `[36m`-style garbage in the tool response.
173fn sanitize_and_strip_cr(chunk: &str) -> String {
174    let mut out = String::with_capacity(chunk.len());
175    let mut chars = chunk.chars().peekable();
176    while let Some(c) = chars.next() {
177        let u = c as u32;
178        if c == '\x1b' {
179            skip_escape_sequence(&mut chars);
180        } else if u == 0x09 || u == 0x0a || u == 0x0d {
181            out.push(c);
182        } else if u <= 0x1f {
183            // other C0 controls → drop
184        } else if (0xfff9..=0xfffb).contains(&u) {
185            // interlinear annotation → drop
186        } else {
187            out.push(c);
188        }
189    }
190    out.replace('\r', "")
191}
192
193/// Consume one ANSI escape sequence, starting just after the ESC byte the
194/// caller already read. Handles CSI (`ESC [ … final`), OSC (`ESC ] … BEL |
195/// `ESC \`), and charset designators (`ESC ( X`); any other escape swallows a
196/// single following char.
197fn skip_escape_sequence(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
198    match chars.peek() {
199        Some('[') => {
200            chars.next();
201            // CSI: parameter/intermediate bytes, terminated by a final byte in
202            // the 0x40..=0x7E range.
203            while let Some(c) = chars.next() {
204                if (0x40..=0x7e).contains(&(c as u32)) {
205                    break;
206                }
207            }
208        }
209        Some(']') => {
210            chars.next();
211            // OSC: terminated by BEL (0x07) or ST (ESC \).
212            while let Some(c) = chars.next() {
213                if c == '\x07' {
214                    break;
215                }
216                if c == '\x1b' {
217                    if let Some('\\') = chars.peek() {
218                        chars.next();
219                    }
220                    break;
221                }
222            }
223        }
224        Some('(') | Some(')') => {
225            // Charset designator: ESC ( or ESC ) followed by one char.
226            chars.next();
227            chars.next();
228        }
229        Some(_) => {
230            chars.next();
231        }
232        None => {}
233    }
234}
235
236/// Mirrors `executeShellWithCapture`. Drives `env.exec` with on_stdout/on_stderr
237/// callbacks that fold into [`CaptureState`], spills to a temp file in a
238/// post-stream pass, and returns the terminal [`ShellCaptureResult`].
239pub async fn execute_shell_with_capture(
240    env: &Arc<dyn ExecutionEnv>,
241    command: &str,
242    options: ShellCaptureOptions<'_>,
243) -> Result<ShellCaptureResult, ExecutionError> {
244    let state = Arc::new(Mutex::new(CaptureState::default()));
245
246    // The user on_chunk callback. Both streams fire it (the TS wires both to the
247    // same onChunk); we share it via Arc<Mutex<Option<Box>>> so both closures can
248    // call it without moving the Box twice.
249    let user_cb: Arc<
250        Mutex<Option<Box<dyn FnMut(&str, &dyn Fn() -> ShellCaptureProgress) + Send>>>,
251    > = Arc::new(Mutex::new(options.on_chunk));
252
253    let state_stdout = state.clone();
254    let user_cb_stdout = user_cb.clone();
255    let on_stdout: Box<dyn FnMut(&str) + Send> = Box::new(move |chunk: &str| {
256        let text = sanitize_and_strip_cr(chunk);
257        // Snapshot the progress BEFORE ingest so on_chunk sees the pre-chunk
258        // state? The TS calls onChunk after updating, with the post-chunk
259        // progress. We match: ingest first, then snapshot.
260        let snap = {
261            let mut s = match state_stdout.try_lock() {
262                Ok(g) => g,
263                Err(_) => return, // contention — skip this chunk's callback
264            };
265            s.ingest_chunk(&text);
266            s.create_progress()
267        };
268        let mut cbg = match user_cb_stdout.try_lock() {
269            Ok(g) => g,
270            Err(_) => return,
271        };
272        if let Some(cb) = cbg.as_mut() {
273            // The TS onChunk receives (chunk_text, getProgress). We pass the
274            // snapshot getter as a closure returning the already-captured value.
275            let snap = snap.clone();
276            let getter = move || snap.clone();
277            cb(chunk, &getter);
278        }
279    });
280
281    let state_stderr = state.clone();
282    let user_cb_stderr = user_cb.clone();
283    let on_stderr: Box<dyn FnMut(&str) + Send> = Box::new(move |chunk: &str| {
284        let text = sanitize_and_strip_cr(chunk);
285        let snap = {
286            let mut s = match state_stderr.try_lock() {
287                Ok(g) => g,
288                Err(_) => return,
289            };
290            s.ingest_chunk(&text);
291            s.create_progress()
292        };
293        let mut cbg = match user_cb_stderr.try_lock() {
294            Ok(g) => g,
295            Err(_) => return,
296        };
297        if let Some(cb) = cbg.as_mut() {
298            let snap = snap.clone();
299            let getter = move || snap.clone();
300            cb(chunk, &getter);
301        }
302    });
303
304    let exec_opts = ShellExecOptions {
305        cwd: options.cwd.clone(),
306        env: options.env.clone(),
307        inherit_env: options.inherit_env,
308        timeout: options.timeout,
309        cancel: options.cancel,
310        on_stdout: Some(on_stdout),
311        on_stderr: Some(on_stderr),
312    };
313
314    let exec_result = env.exec(command, exec_opts).await;
315
316    // Post-stream safety net: if truncated and no temp file yet, init it now
317    // with the captured tail.
318    {
319        let progress = state.lock().await.create_progress();
320        if progress.truncation.truncated {
321            let s = state.lock().await;
322            if !s.full_output_requested {
323                let initial = s.tail_output.clone();
324                drop(s);
325                match init_full_output_file(env.clone(), &initial, options.cancel).await {
326                    Ok(path) => {
327                        let mut s2 = state.lock().await;
328                        s2.full_output_path = Some(path);
329                        s2.full_output_requested = true;
330                    }
331                    Err(e) => {
332                        // Spill failure is non-fatal — the capture still
333                        // returns; the truncation metadata is present, just no
334                        // fullOutputPath. Log via tracing.
335                        tracing::warn!("bash full-output temp file init failed: {e}");
336                    }
337                }
338            }
339        }
340    }
341
342    let progress = state.lock().await.create_progress();
343    // Pull the scalar/move fields out once so we never read a partially-moved
344    // `progress` (e.g. `progress.truncation.truncated` after `truncation:` moved it).
345    let ShellCaptureProgress {
346        output,
347        truncation,
348        full_output_path,
349        last_line_bytes,
350    } = progress;
351    let truncated_flag = truncation.truncated;
352    match exec_result {
353        Ok(out) => {
354            let cancelled = options.cancel.map(|t| t.is_cancelled()).unwrap_or(false);
355            Ok(ShellCaptureResult {
356                output: output.clone(),
357                truncation: truncation.clone(),
358                full_output_path: full_output_path.clone(),
359                last_line_bytes,
360                exit_code: if cancelled { None } else { Some(out.exit_code) },
361                cancelled,
362                truncated: truncated_flag,
363                execution_error: None,
364            })
365        }
366        Err(e) => {
367            let is_aborted = e.code == ExecutionErrorCode::Aborted
368                || options.cancel.map(|t| t.is_cancelled()).unwrap_or(false);
369            if is_aborted {
370                Ok(ShellCaptureResult {
371                    output,
372                    truncation,
373                    full_output_path,
374                    last_line_bytes,
375                    exit_code: None,
376                    cancelled: true,
377                    truncated: truncated_flag,
378                    execution_error: None,
379                })
380            } else if options.return_execution_errors {
381                Ok(ShellCaptureResult {
382                    output,
383                    truncation,
384                    full_output_path,
385                    last_line_bytes,
386                    exit_code: None,
387                    cancelled: false,
388                    truncated: truncated_flag,
389                    execution_error: Some(e),
390                })
391            } else {
392                Err(e)
393            }
394        }
395    }
396}
397
398/// Create the temp file with initial content. Mirrors `ensureFullOutputFile`'s
399/// first-call path: `env.createTempFile({prefix:"bash-", suffix:".log"})` +
400/// `appendFile(path, initialContent)`.
401async fn init_full_output_file(
402    env: Arc<dyn ExecutionEnv>,
403    initial: &str,
404    cancel: Option<&CancellationToken>,
405) -> Result<PathBuf, ExecutionError> {
406    let path = env
407        .create_temp_file("bash-", ".log", cancel)
408        .await
409        .map_err(|e| ExecutionError::new(ExecutionErrorCode::Unknown, e.to_string()))?;
410    env.append_file(
411        &path.to_string_lossy(),
412        FileContent::Text(initial.to_string()),
413        cancel,
414    )
415    .await
416    .map_err(|e| ExecutionError::new(ExecutionErrorCode::Unknown, e.to_string()))?;
417    Ok(path)
418}
419
420/// Re-export `format_size` for the bash tool's truncation footer messages.
421pub use crate::truncate::format_size;
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn sanitize_strips_controls_keeps_tab_lf() {
429        let s = "a\x01b\tc\nd\x0be";
430        let out = sanitize_and_strip_cr(&s);
431        assert_eq!(out, "ab\tc\nde");
432    }
433
434    #[test]
435    fn sanitize_strips_cr() {
436        let s = "a\r\nb\rc";
437        let out = sanitize_and_strip_cr(&s);
438        assert_eq!(out, "a\nbc");
439    }
440
441    #[test]
442    fn sanitize_strips_interlinear_annotation() {
443        let s = "a\u{fff9}b\u{fffb}c";
444        let out = sanitize_and_strip_cr(&s);
445        assert_eq!(out, "abc");
446    }
447
448    #[test]
449    fn sanitize_strips_full_ansi_sequences() {
450        // Regression: dropping only the ESC byte used to leave `[36m`-style
451        // literal garbage in the captured/output text.
452        let s = "\x1b[36mvite v6.4.3\x1b[39m \x1b[32m\u{2713}\x1b[39m built\n";
453        assert_eq!(sanitize_and_strip_cr(s), "vite v6.4.3 \u{2713} built\n");
454        let s = "a\x1b[1m\x1b[2mX\x1b[22m\x1b[22mb";
455        assert_eq!(sanitize_and_strip_cr(s), "aXb");
456    }
457
458    #[test]
459    fn sanitize_strips_osc_sequences() {
460        assert_eq!(sanitize_and_strip_cr("a\x1b]0;title\x07b"), "ab");
461        assert_eq!(sanitize_and_strip_cr("a\x1b]8;;http://x\x1b\\b"), "ab");
462    }
463
464    #[test]
465    fn capture_state_ingest_and_progress() {
466        let mut s = CaptureState::default();
467        s.ingest_chunk("line1\nline2\n");
468        s.ingest_chunk("line3");
469        let p = s.create_progress();
470        // 3 lines total (line1, line2, line3-open).
471        assert_eq!(p.truncation.total_lines, 3);
472        assert!(!p.truncation.truncated);
473        assert_eq!(p.output, "line1\nline2\nline3");
474    }
475}