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