Skip to main content

oxicode_agent/runtime/
shell.rs

1//! Persistent shell session — the reference [`ShellSession`] implementation
2//! behind the `coding-omp-v1` "Shell session" extension.
3//!
4//! Protocol: one long-lived `bash --noprofile --norc` child with piped
5//! stdio. Each [`ShellSession::execute`] writes the command followed by an
6//! exit-code marker line and reads stdout until the marker, so the working
7//! directory and exported environment persist across calls.
8//!
9//! Cancellation: the child runs in its own process group and installs a
10//! no-op `trap : INT`. [`ShellSession::cancel`] SIGINTs the whole group —
11//! bash swallows the signal (trap) and survives to run the marker line,
12//! while the foreground command inherits the DEFAULT disposition and dies,
13//! surfacing as exit code 130. The session init also merges stderr into
14//! stdout (`exec 2>&1`), so command diagnostics arrive in one stream.
15//! Output is bounded; the bound is reported via `ShellOutput::truncated`.
16//!
17//! Known edge (documented, same class as OMP): a command that consumes
18//! stdin itself will swallow the marker line — such commands should read
19//! from files/args, not the session's stdin.
20
21use super::ShellOutput;
22use async_trait::async_trait;
23use parking_lot::Mutex;
24use std::path::PathBuf;
25use std::sync::atomic::{AtomicU64, Ordering};
26use std::time::{Duration, Instant};
27use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
28use tokio::process::{Child, ChildStdin, ChildStdout};
29
30const MARKER: &str = "__OXI_SH_DONE__";
31const DEFAULT_MAX_OUTPUT: usize = 512 * 1024;
32
33struct ShellProc {
34    child: Child,
35    stdin: ChildStdin,
36    stdout: BufReader<ChildStdout>,
37    /// Whether the session's `trap : INT` init line has been sent.
38    initialized: bool,
39}
40
41/// Persistent bash session. Executes are serialized by an internal lock,
42/// matching a single terminal.
43pub struct PersistentShellSession {
44    workspace_root: PathBuf,
45    max_output: usize,
46    proc: Mutex<Option<ShellProc>>,
47    /// Process-group id of the bash child while a command runs (0 = idle).
48    /// Tracked separately because the child handle is checked out of
49    /// [`Self::proc`] for the duration of an execute.
50    active_pgid: AtomicU64,
51}
52
53impl std::fmt::Debug for PersistentShellSession {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.debug_struct("PersistentShellSession")
56            .field("workspace_root", &self.workspace_root)
57            .field("alive", &self.proc.lock().is_some())
58            .finish()
59    }
60}
61
62impl PersistentShellSession {
63    /// Session rooted at `workspace_root` (the reset working directory).
64    pub fn new(workspace_root: PathBuf) -> Self {
65        Self {
66            workspace_root,
67            max_output: DEFAULT_MAX_OUTPUT,
68            proc: Mutex::new(None),
69            active_pgid: AtomicU64::new(0),
70        }
71    }
72
73    /// Override the output bound (bytes).
74    pub fn with_max_output(mut self, max: usize) -> Self {
75        self.max_output = max;
76        self
77    }
78
79    fn spawn(&self) -> std::io::Result<ShellProc> {
80        use tokio::process::Command;
81        let mut cmd = Command::new("bash");
82        cmd.args(["--noprofile", "--norc"])
83            .current_dir(&self.workspace_root)
84            .stdin(std::process::Stdio::piped())
85            .stdout(std::process::Stdio::piped())
86            .stderr(std::process::Stdio::piped())
87            .kill_on_drop(true);
88        #[cfg(unix)]
89        {
90            // Own process group so cancel() can SIGINT the foreground command.
91            cmd.process_group(0);
92        }
93        let mut child = cmd.spawn()?;
94        let stdin = child
95            .stdin
96            .take()
97            .ok_or_else(|| std::io::Error::other("no stdin"))?;
98        let stdout = child
99            .stdout
100            .take()
101            .ok_or_else(|| std::io::Error::other("no stdout"))?;
102        // The session init (`exec 2>&1`) merges stderr into stdout; this
103        // drain only guards pre-init writes and is bounded.
104        if let Some(stderr) = child.stderr.take() {
105            tokio::spawn(async move {
106                let mut reader = BufReader::new(stderr);
107                let mut line = String::new();
108                let mut kept: usize = 0;
109                loop {
110                    match reader.read_line(&mut line).await {
111                        Ok(0) | Err(_) => break,
112                        Ok(_) => {
113                            kept = kept.saturating_add(line.len());
114                            line.clear();
115                            if kept >= DEFAULT_MAX_OUTPUT {
116                                break; // stop draining; pipe backpressure accepted
117                            }
118                        }
119                    }
120                }
121            });
122        }
123        Ok(ShellProc {
124            child,
125            stdin,
126            stdout: BufReader::new(stdout),
127            initialized: false,
128        })
129    }
130
131    /// Take the live proc out (guard never held across `.await`).
132    fn take_proc(&self) -> std::io::Result<ShellProc> {
133        let existing = self.proc.lock().take();
134        match existing {
135            Some(mut p) => {
136                let alive = p.child.try_wait().map_or(true, |s| s.is_none());
137                if alive { Ok(p) } else { self.spawn() }
138            }
139            None => self.spawn(),
140        }
141    }
142
143    fn put_proc(&self, proc: ShellProc) {
144        self.active_pgid.store(0, Ordering::SeqCst);
145        *self.proc.lock() = Some(proc);
146    }
147}
148
149/// SIGINT the bash process group to abort the foreground command; the
150/// `trap : INT` init makes bash itself survive so the marker line still
151/// runs. No-op when idle.
152fn interrupt_active(pgid: u64) {
153    #[cfg(unix)]
154    if pgid != 0 {
155        // process_group(0) made the child its own group leader, so a
156        // negative pid targets the whole group (bash + the foreground
157        // command).
158        unsafe {
159            libc::kill(-(pgid as i32), libc::SIGINT);
160        }
161    }
162}
163
164#[async_trait]
165impl super::ShellSession for PersistentShellSession {
166    async fn execute(&self, command: &str, timeout: Duration) -> Result<ShellOutput, String> {
167        let deadline = Instant::now() + timeout;
168        let mut proc = self.take_proc().map_err(|e| format!("spawn bash: {e}"))?;
169        if !proc.initialized {
170            // Session init: (1) merge stderr into stdout so callers see
171            // diagnostics in one stream, and (2) install a no-op INT trap —
172            // bash survives group SIGINT (so the marker runs) while child
173            // commands inherit the DEFAULT disposition and die with exit
174            // code 130.
175            proc.stdin
176                .write_all(b"exec 2>&1\ntrap : INT\n")
177                .await
178                .map_err(|e| format!("bash init write: {e}"))?;
179            proc.stdin
180                .flush()
181                .await
182                .map_err(|e| format!("bash init flush: {e}"))?;
183            proc.initialized = true;
184        }
185        self.active_pgid
186            .store(proc.child.id().unwrap_or(0) as u64, Ordering::SeqCst);
187
188        let payload = format!("{command}\nprintf '%s\\n' \"{MARKER}$?\"\n");
189        if let Err(e) = proc.stdin.write_all(payload.as_bytes()).await {
190            self.active_pgid.store(0, Ordering::SeqCst);
191            let msg = format!("bash stdin write: {e}");
192            let _ = proc.child.kill().await;
193            return Err(msg);
194        }
195        if let Err(e) = proc.stdin.flush().await {
196            self.active_pgid.store(0, Ordering::SeqCst);
197            let msg = format!("bash stdin flush: {e}");
198            let _ = proc.child.kill().await;
199            return Err(msg);
200        }
201
202        let mut stdout = String::new();
203        let mut truncated = false;
204        let mut exit_code: Option<i32> = None;
205        loop {
206            if Instant::now() >= deadline {
207                interrupt_active(self.active_pgid.load(Ordering::SeqCst));
208                break;
209            }
210            let mut line = String::new();
211            let read = tokio::time::timeout_at(
212                tokio::time::Instant::from(deadline),
213                proc.stdout.read_line(&mut line),
214            )
215            .await;
216            match read {
217                Err(_elapsed) => {
218                    interrupt_active(self.active_pgid.load(Ordering::SeqCst));
219                    break;
220                }
221                Ok(Err(e)) => {
222                    let msg = format!("bash stdout read: {e}");
223                    let _ = proc.child.kill().await;
224                    return Err(msg);
225                }
226                Ok(Ok(0)) => {
227                    let msg = "bash exited before the command completed".to_string();
228                    let _ = proc.child.kill().await;
229                    return Err(msg);
230                }
231                Ok(Ok(_)) => {
232                    if let Some(rest) = line.trim_end().strip_prefix(MARKER) {
233                        exit_code = rest.trim().parse::<i32>().ok();
234                        break;
235                    }
236                    if stdout.len() + line.len() > self.max_output {
237                        truncated = true;
238                    } else {
239                        stdout.push_str(&line);
240                    }
241                }
242            }
243        }
244        self.put_proc(proc);
245        Ok(ShellOutput {
246            stdout,
247            stderr: String::new(),
248            exit_code: exit_code.unwrap_or(124),
249            truncated: truncated || exit_code.is_none(),
250        })
251    }
252
253    fn cancel(&self) {
254        interrupt_active(self.active_pgid.load(Ordering::SeqCst));
255    }
256
257    async fn reset(&self) -> Result<(), String> {
258        let taken = self.proc.lock().take();
259        if let Some(mut p) = taken {
260            let _ = p.child.kill().await;
261        }
262        Ok(())
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::ShellSession as _;
270    use std::sync::Arc;
271
272    #[tokio::test]
273    async fn cwd_and_env_persist() {
274        let dir = tempfile::tempdir().unwrap();
275        let sub = dir.path().join("sub");
276        std::fs::create_dir(&sub).unwrap();
277        let session = PersistentShellSession::new(dir.path().to_path_buf());
278        let out = session
279            .execute("cd sub && export OXI_FIXTURE=1", Duration::from_secs(5))
280            .await
281            .unwrap();
282        assert_eq!(out.exit_code, 0);
283        let out = session
284            .execute("echo \"$PWD $OXI_FIXTURE\"", Duration::from_secs(5))
285            .await
286            .unwrap();
287        assert!(out.stdout.contains("sub"), "cwd must persist: {out:?}");
288        assert!(
289            out.stdout.trim_end().ends_with(" 1"),
290            "env must persist: {out:?}"
291        );
292    }
293
294    #[tokio::test]
295    async fn output_bound_reports_truncated() {
296        let dir = tempfile::tempdir().unwrap();
297        let session = PersistentShellSession::new(dir.path().to_path_buf()).with_max_output(4_096);
298        // seq emits newline-terminated lines so the reader keeps flowing
299        // past the bound and still sees the marker (fast, no deadline hit).
300        let out = session
301            .execute("seq 1 200000", Duration::from_secs(10))
302            .await
303            .unwrap();
304        assert!(out.truncated);
305        assert_eq!(out.exit_code, 0);
306        assert!(out.stdout.len() <= 4_096 + 8);
307    }
308
309    #[tokio::test]
310    async fn reset_returns_to_workspace_root() {
311        let dir = tempfile::tempdir().unwrap();
312        let sub = dir.path().join("sub");
313        std::fs::create_dir(&sub).unwrap();
314        let session = PersistentShellSession::new(dir.path().to_path_buf());
315        session
316            .execute("cd sub", Duration::from_secs(5))
317            .await
318            .unwrap();
319        session.reset().await.unwrap();
320        let out = session
321            .execute("pwd", Duration::from_secs(5))
322            .await
323            .unwrap();
324        assert!(
325            !out.stdout.contains("sub"),
326            "reset must restore root: {out:?}"
327        );
328    }
329
330    #[tokio::test]
331    async fn cancel_after_multiple_commands() {
332        let dir = tempfile::tempdir().unwrap();
333        let session = Arc::new(PersistentShellSession::new(dir.path().to_path_buf()));
334        let _ = session
335            .execute("echo one", Duration::from_secs(5))
336            .await
337            .unwrap();
338        let _ = session
339            .execute("echo two", Duration::from_secs(5))
340            .await
341            .unwrap();
342        let worker_session = session.clone();
343        let worker = tokio::spawn(async move {
344            worker_session
345                .execute("sleep 30", Duration::from_secs(60))
346                .await
347        });
348        tokio::time::sleep(Duration::from_millis(300)).await;
349        session.cancel();
350        let out = tokio::time::timeout(Duration::from_secs(10), worker)
351            .await
352            .expect("execute must return")
353            .expect("join")
354            .expect("execute ok");
355        assert_eq!(out.exit_code, 130, "{out:?}");
356    }
357
358    #[tokio::test]
359    async fn cancel_aborts_long_command() {
360        let dir = tempfile::tempdir().unwrap();
361        let session = Arc::new(PersistentShellSession::new(dir.path().to_path_buf()));
362        let worker = {
363            let session = session.clone();
364            tokio::spawn(async move { session.execute("sleep 30", Duration::from_secs(60)).await })
365        };
366        tokio::time::sleep(Duration::from_millis(300)).await;
367        session.cancel();
368        let started = Instant::now();
369        // SIGINT aborts the foreground `sleep`; the `trap : INT` init keeps
370        // bash alive so the marker line runs and reports 130.
371        let out = tokio::time::timeout(Duration::from_secs(10), worker)
372            .await
373            .expect("execute must return after cancel")
374            .expect("join")
375            .expect("execute ok");
376        assert!(
377            started.elapsed() < Duration::from_secs(20),
378            "cancel must be prompt"
379        );
380        assert_eq!(out.exit_code, 130, "SIGINT must surface as 130: {out:?}");
381    }
382}