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