oxicode_agent/runtime/
shell.rs1use 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 initialized: bool,
38}
39
40pub struct PersistentShellSession {
43 workspace_root: PathBuf,
44 max_output: usize,
45 proc: Mutex<Option<ShellProc>>,
46 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 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 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 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 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; }
117 }
118 }
119 }
120 });
121 }
122 Ok(ShellProc {
123 child,
124 stdin,
125 stdout: BufReader::new(stdout),
126 initialized: false,
127 })
128 }
129
130 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
148fn interrupt_active(pgid: u64) {
152 #[cfg(unix)]
153 if pgid != 0 {
154 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 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 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 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}