Skip to main content

oino_tools/bash/
executor.rs

1//! Core's `!cmd` executor building block (`core/bash-executor.ts`
2//! `executeBashWithOperations` over the local shell operations).
3
4use std::collections::VecDeque;
5use std::fs::File;
6use std::io::Write;
7
8use tokio::time::Instant;
9use tokio_util::sync::CancellationToken;
10
11use super::accumulator::{Utf8Decoder, create_output_file};
12use super::exec::{ExecEnd, ExecError, ExecRequest, OutputSink, ShellSpec, exec};
13use super::shell::{sanitize_binary_output, shell_env, split_incomplete_ansi_suffix, strip_ansi};
14use super::temp_dir;
15use crate::env::ExecutionEnv;
16use crate::js;
17use crate::truncate::{DEFAULT_MAX_BYTES, TruncationOptions, truncate_tail};
18
19/// Options of [`run_shell_command`].
20#[derive(Clone, Debug, Default, PartialEq, Eq)]
21pub struct ShellRunOptions {
22    /// Explicit shell (setting `shellPath`).
23    pub shell_path: Option<String>,
24    /// Agent directory: temp files in `<agent_dir>/tmp`, `<agent_dir>/bin` on `PATH`.
25    pub agent_dir: String,
26    /// Base environment; `None` uses this process's environment.
27    pub process_env: Option<Vec<(String, String)>>,
28}
29
30/// The bash result record.
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct BashOutcome {
33    /// Combined, sanitised output (ANSI stripped, binary garbage removed, no `\r`), tail
34    /// truncated when needed.
35    pub output: String,
36    /// Exit code (`None` when cancelled).
37    pub exit_code: Option<i32>,
38    /// The command was cancelled.
39    pub cancelled: bool,
40    /// `output` was truncated.
41    pub truncated: bool,
42    /// Temp file with the full output (written once output exceeded 50KB).
43    pub full_output_path: Option<String>,
44}
45
46/// A command that could not run (rethrows the `exec` error).
47#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
48#[error("{0}")]
49pub struct ShellRunError(pub String);
50
51struct ExecutorSink<'a> {
52    on_chunk: &'a mut (dyn FnMut(&str) + Send),
53    decoder: Utf8Decoder,
54    pending_ansi: String,
55    chunks: VecDeque<String>,
56    chunk_units: usize,
57    total_bytes: u64,
58    temp_dir: String,
59    temp: Option<(String, File)>,
60}
61
62impl ExecutorSink<'_> {
63    fn ensure_temp_file(&mut self) {
64        if self.temp.is_some() {
65            return;
66        }
67        if let Ok((path, mut file)) = create_output_file(&self.temp_dir, "oino-bash") {
68            for chunk in &self.chunks {
69                // Stream write errors are ignored here.
70                let _ = file.write_all(chunk.as_bytes());
71            }
72            self.temp = Some((path, file));
73        }
74    }
75
76    fn append_text(&mut self, raw: &str) {
77        let text = sanitize_binary_output(&strip_ansi(raw)).replace('\r', "");
78        if text.is_empty() {
79            return;
80        }
81        if self.total_bytes > DEFAULT_MAX_BYTES {
82            self.ensure_temp_file();
83        }
84        if let Some((_, file)) = &mut self.temp {
85            let _ = file.write_all(text.as_bytes());
86        }
87        let max_units = usize::try_from(DEFAULT_MAX_BYTES * 2).unwrap_or(usize::MAX);
88        self.chunk_units += js::utf16_len(&text);
89        self.chunks.push_back(text.clone());
90        while self.chunk_units > max_units && self.chunks.len() > 1 {
91            if let Some(removed) = self.chunks.pop_front() {
92                self.chunk_units -= js::utf16_len(&removed);
93            }
94        }
95        (self.on_chunk)(&text);
96    }
97
98    fn flush_output(&mut self) {
99        let rest = format!(
100            "{}{}",
101            std::mem::take(&mut self.pending_ansi),
102            self.decoder.finish()
103        );
104        self.append_text(&rest);
105    }
106}
107
108impl OutputSink for ExecutorSink<'_> {
109    fn data(&mut self, chunk: &[u8]) {
110        self.total_bytes += chunk.len() as u64;
111        let decoded = format!("{}{}", self.pending_ansi, self.decoder.decode(chunk));
112        let (complete, pending) = split_incomplete_ansi_suffix(&decoded);
113        self.pending_ansi = pending;
114        self.append_text(&complete);
115    }
116
117    fn next_flush(&self) -> Option<Instant> {
118        None
119    }
120
121    fn flush(&mut self) {}
122}
123
124/// Runs a user `!cmd` (`executeBash`): the configured shell, the process environment
125/// with `<agent_dir>/bin` on `PATH`, sanitised streaming chunks via `on_chunk`, a rolling
126/// buffer and a temp file for long output. Cancelling kills the process tree and returns the
127/// output so far with `cancelled: true`.
128///
129/// # Errors
130/// The command could not run (missing working directory, shell not found, spawn failure).
131pub async fn run_shell_command(
132    env: &dyn ExecutionEnv,
133    command: &str,
134    cwd: &str,
135    options: &ShellRunOptions,
136    on_chunk: &mut (dyn FnMut(&str) + Send),
137    cancel: &CancellationToken,
138) -> Result<BashOutcome, ShellRunError> {
139    let mut sink = ExecutorSink {
140        on_chunk,
141        decoder: Utf8Decoder::default(),
142        pending_ansi: String::new(),
143        chunks: VecDeque::new(),
144        chunk_units: 0,
145        total_bytes: 0,
146        temp_dir: temp_dir(&options.agent_dir),
147        temp: None,
148    };
149    let request = ExecRequest {
150        env,
151        shell: ShellSpec::Bash(options.shell_path.as_deref()),
152        command: command.to_owned(),
153        cwd: cwd.to_owned(),
154        vars: shell_env(options.process_env.as_deref(), &options.agent_dir),
155        timeout: None,
156    };
157    let exit_code = match exec(request, &mut sink, cancel, None).await {
158        Ok(ExecEnd::Exited(code)) => Some(code),
159        Ok(ExecEnd::Detached(..)) | Err(ExecError::Aborted | ExecError::Timeout(_)) => None,
160        Err(ExecError::Failed(message)) => {
161            if !cancel.is_cancelled() {
162                return Err(ShellRunError(message));
163            }
164            None
165        }
166    };
167    sink.flush_output();
168    let full_output: String = sink.chunks.iter().map(String::as_str).collect();
169    let truncation = truncate_tail(&full_output, TruncationOptions::default());
170    if truncation.truncated {
171        sink.ensure_temp_file();
172    }
173    let cancelled = cancel.is_cancelled();
174    let full_output_path = sink.temp.take().map(|(path, mut file)| {
175        let _ = file.flush();
176        path
177    });
178    Ok(BashOutcome {
179        output: if truncation.truncated {
180            truncation.content
181        } else {
182            full_output
183        },
184        exit_code: if cancelled { None } else { exit_code },
185        cancelled,
186        truncated: truncation.truncated,
187        full_output_path,
188    })
189}