1use super::{BashSandbox, SandboxCommandRequest, SandboxExecutionOutput, SandboxOutput};
13use crate::tools::MAX_OUTPUT_SIZE;
14use anyhow::{Context, Result};
15use async_trait::async_trait;
16use std::collections::VecDeque;
17use std::path::{Path, PathBuf};
18use std::process::Stdio;
19use std::time::{Duration, Instant};
20use tokio::io::AsyncReadExt;
21use tokio::process::{Child, Command};
22
23const MAX_CAPTURE_BYTES: usize = MAX_OUTPUT_SIZE;
24const OUTPUT_HEAD_BYTES: usize = 64 * 1024;
25const READ_CHUNK_BYTES: usize = 8 * 1024;
26const PROCESS_SETTLEMENT_MS: u64 = 500;
27
28#[derive(Debug)]
33pub struct ProcessHostBashSandbox {
34 workspace: PathBuf,
35 deadline: Option<Instant>,
36}
37
38impl ProcessHostBashSandbox {
39 pub fn new(workspace: impl Into<PathBuf>, deadline: Option<Instant>) -> Self {
44 Self {
45 workspace: workspace.into(),
46 deadline,
47 }
48 }
49
50 pub fn workspace(&self) -> &Path {
51 &self.workspace
52 }
53
54 fn remaining_timeout_ms(&self, requested_ms: u64) -> u64 {
55 let Some(deadline) = self.deadline else {
56 return requested_ms;
57 };
58 let remaining = deadline
59 .saturating_duration_since(Instant::now())
60 .as_millis()
61 .min(u128::from(u64::MAX)) as u64;
62 requested_ms.min(remaining)
63 }
64}
65
66fn process_host_command(workspace: &Path, command: &str) -> (PathBuf, Vec<String>) {
71 (
72 workspace.to_path_buf(),
73 vec!["-c".to_string(), command.to_string()],
74 )
75}
76
77#[async_trait]
78impl BashSandbox for ProcessHostBashSandbox {
79 async fn exec_command(&self, command: &str, guest_workspace: &str) -> Result<SandboxOutput> {
80 let output = self
81 .exec(SandboxCommandRequest {
82 command: command.to_string(),
83 guest_workspace: guest_workspace.to_string(),
84 timeout_ms: 120_000,
85 output_observer: None,
86 env: None,
87 })
88 .await?;
89 Ok(SandboxOutput {
90 stdout: output.stdout,
91 stderr: output.stderr,
92 exit_code: output.exit_code,
93 })
94 }
95
96 async fn exec(&self, request: SandboxCommandRequest) -> Result<SandboxExecutionOutput> {
97 let timeout_ms = self.remaining_timeout_ms(request.timeout_ms);
98 if timeout_ms == 0 {
99 let output = SandboxExecutionOutput {
100 stdout: String::new(),
101 stderr: "command skipped because the run deadline expired\n".to_string(),
102 exit_code: 124,
103 timed_out: true,
104 };
105 if let Some(observer) = request.output_observer {
106 observer.on_output_delta(&output.stderr).await;
107 observer
108 .on_output_complete(&crate::workspace::CommandOutputSummary {
109 total_bytes: output.stderr.len(),
110 captured_bytes: output.stderr.len(),
111 truncated: false,
112 timed_out: true,
113 })
114 .await;
115 }
116 return Ok(output);
117 }
118
119 let (current_dir, args) = process_host_command(&self.workspace, &request.command);
122 let mut shell = Command::new("bash");
123 shell
124 .args(&args)
125 .current_dir(current_dir)
126 .stdin(Stdio::null())
127 .stdout(Stdio::piped())
128 .stderr(Stdio::piped())
129 .kill_on_drop(true);
130 #[cfg(unix)]
131 {
132 shell.process_group(0);
136 }
137 if let Some(env) = request.env.as_deref() {
138 shell.envs(env);
139 }
140 let mut child = shell.spawn().context("spawn process-host bash")?;
141 let captured =
142 capture_process_output(&mut child, timeout_ms, request.output_observer.as_deref())
143 .await
144 .context("capture process-host bash output")?;
145 let exit_code = if captured.timed_out {
146 124
147 } else {
148 captured
149 .status
150 .and_then(|status| status.code())
151 .unwrap_or(1)
152 };
153 Ok(SandboxExecutionOutput {
154 stdout: captured.stdout,
155 stderr: captured.stderr,
156 exit_code,
157 timed_out: captured.timed_out,
158 })
159 }
160
161 async fn shutdown(&self) {}
162}
163
164#[derive(Clone, Copy)]
165enum OutputStream {
166 Stdout,
167 Stderr,
168}
169
170#[derive(Clone, Copy)]
171struct CapturedByte {
172 stream: OutputStream,
173 byte: u8,
174}
175
176struct BoundedCapture {
177 head: Vec<CapturedByte>,
178 tail: VecDeque<CapturedByte>,
179 total_bytes: usize,
180 stdout_bytes: usize,
181 stderr_bytes: usize,
182}
183
184impl BoundedCapture {
185 fn new() -> Self {
186 Self {
187 head: Vec::with_capacity(OUTPUT_HEAD_BYTES),
188 tail: VecDeque::with_capacity(MAX_CAPTURE_BYTES - OUTPUT_HEAD_BYTES),
189 total_bytes: 0,
190 stdout_bytes: 0,
191 stderr_bytes: 0,
192 }
193 }
194
195 fn push(&mut self, stream: OutputStream, bytes: &[u8]) {
196 self.total_bytes = self.total_bytes.saturating_add(bytes.len());
197 match stream {
198 OutputStream::Stdout => {
199 self.stdout_bytes = self.stdout_bytes.saturating_add(bytes.len())
200 }
201 OutputStream::Stderr => {
202 self.stderr_bytes = self.stderr_bytes.saturating_add(bytes.len())
203 }
204 }
205
206 let head_remaining = OUTPUT_HEAD_BYTES.saturating_sub(self.head.len());
207 let head_bytes = head_remaining.min(bytes.len());
208 self.head
209 .extend(bytes[..head_bytes].iter().map(|byte| CapturedByte {
210 stream,
211 byte: *byte,
212 }));
213 self.tail
214 .extend(bytes[head_bytes..].iter().map(|byte| CapturedByte {
215 stream,
216 byte: *byte,
217 }));
218 while self.tail.len() > MAX_CAPTURE_BYTES - OUTPUT_HEAD_BYTES {
219 self.tail.pop_front();
220 }
221 }
222
223 fn summary(&self, timed_out: bool) -> crate::workspace::CommandOutputSummary {
224 crate::workspace::CommandOutputSummary {
225 total_bytes: self.total_bytes,
226 captured_bytes: self.head.len() + self.tail.len(),
227 truncated: self.total_bytes > MAX_CAPTURE_BYTES,
228 timed_out,
229 }
230 }
231
232 #[cfg(test)]
233 fn render_combined(&self) -> String {
234 let mut rendered = String::new();
235 append_captured_bytes(&mut rendered, self.head.iter().copied());
236 if self.total_bytes > MAX_CAPTURE_BYTES {
237 rendered.push_str(&format!(
238 "\n\n[command output truncated: retained the first {} and last {} of {} bytes]\n\n",
239 self.head.len(),
240 self.tail.len(),
241 self.total_bytes
242 ));
243 }
244 append_captured_bytes(&mut rendered, self.tail.iter().copied());
245 rendered
246 }
247
248 fn render_stream(&self, stream: OutputStream) -> String {
249 let head = self
250 .head
251 .iter()
252 .copied()
253 .filter(|captured| matches_stream(captured.stream, stream))
254 .collect::<Vec<_>>();
255 let tail = self
256 .tail
257 .iter()
258 .copied()
259 .filter(|captured| matches_stream(captured.stream, stream))
260 .collect::<Vec<_>>();
261 let total_bytes = match stream {
262 OutputStream::Stdout => self.stdout_bytes,
263 OutputStream::Stderr => self.stderr_bytes,
264 };
265 let mut rendered = String::new();
266 append_captured_bytes(&mut rendered, head.iter().copied());
267 if total_bytes > head.len() + tail.len() {
268 let label = match stream {
269 OutputStream::Stdout => "stdout",
270 OutputStream::Stderr => "stderr",
271 };
272 rendered.push_str(&format!(
273 "\n\n[command {label} truncated by the global output limit: retained the first {} and last {} of {} bytes]\n\n",
274 head.len(),
275 tail.len(),
276 total_bytes
277 ));
278 }
279 append_captured_bytes(&mut rendered, tail.iter().copied());
280 rendered
281 }
282}
283
284fn matches_stream(left: OutputStream, right: OutputStream) -> bool {
285 matches!(
286 (left, right),
287 (OutputStream::Stdout, OutputStream::Stdout) | (OutputStream::Stderr, OutputStream::Stderr)
288 )
289}
290
291fn append_captured_bytes(rendered: &mut String, captured: impl IntoIterator<Item = CapturedByte>) {
292 let bytes = captured
293 .into_iter()
294 .map(|captured| captured.byte)
295 .collect::<Vec<_>>();
296 rendered.push_str(&String::from_utf8_lossy(&bytes));
297}
298
299struct CapturedProcessOutput {
300 stdout: String,
301 stderr: String,
302 status: Option<std::process::ExitStatus>,
303 timed_out: bool,
304}
305
306async fn capture_process_output(
307 child: &mut Child,
308 timeout_ms: u64,
309 observer: Option<&dyn crate::workspace::CommandOutputObserver>,
310) -> std::io::Result<CapturedProcessOutput> {
311 let mut stdout = child
312 .stdout
313 .take()
314 .ok_or_else(|| std::io::Error::other("child stdout was not piped"))?;
315 let mut stderr = child
316 .stderr
317 .take()
318 .ok_or_else(|| std::io::Error::other("child stderr was not piped"))?;
319 let mut process_group = ProcessGroupGuard::for_child(child);
320 let mut capture = BoundedCapture::new();
321 let mut stdout_done = false;
322 let mut stderr_done = false;
323 let mut stdout_buffer = vec![0_u8; READ_CHUNK_BYTES];
324 let mut stderr_buffer = vec![0_u8; READ_CHUNK_BYTES];
325
326 let execution = tokio::time::timeout(Duration::from_millis(timeout_ms.max(1)), async {
327 while !stdout_done || !stderr_done {
328 tokio::select! {
329 read = stdout.read(&mut stdout_buffer), if !stdout_done => {
330 match read {
331 Ok(0) => stdout_done = true,
332 Ok(count) => {
333 let bytes = &stdout_buffer[..count];
334 capture.push(OutputStream::Stdout, bytes);
335 if let Some(observer) = observer {
336 observer.on_output_delta(&String::from_utf8_lossy(bytes)).await;
337 }
338 }
339 Err(error) => {
340 let message = format!("\n[failed to read command stdout: {error}]\n");
341 capture.push(OutputStream::Stderr, message.as_bytes());
342 stdout_done = true;
343 }
344 }
345 }
346 read = stderr.read(&mut stderr_buffer), if !stderr_done => {
347 match read {
348 Ok(0) => stderr_done = true,
349 Ok(count) => {
350 let bytes = &stderr_buffer[..count];
351 capture.push(OutputStream::Stderr, bytes);
352 if let Some(observer) = observer {
353 observer.on_output_delta(&String::from_utf8_lossy(bytes)).await;
354 }
355 }
356 Err(error) => {
357 let message = format!("\n[failed to read command stderr: {error}]\n");
358 capture.push(OutputStream::Stderr, message.as_bytes());
359 stderr_done = true;
360 }
361 }
362 }
363 }
364 }
365 child.wait().await
366 })
367 .await;
368
369 let (status, timed_out) = match execution {
370 Ok(status) => {
371 process_group.disarm();
372 (Some(status?), false)
373 }
374 Err(_) => {
375 process_group.kill();
376 child.start_kill().ok();
377 let status = match tokio::time::timeout(
378 Duration::from_millis(PROCESS_SETTLEMENT_MS),
379 child.wait(),
380 )
381 .await
382 {
383 Ok(Ok(status)) => Some(status),
384 Ok(Err(_)) | Err(_) => None,
385 };
386 (status, true)
387 }
388 };
389
390 let summary = capture.summary(timed_out);
391 if let Some(observer) = observer {
392 observer.on_output_complete(&summary).await;
393 }
394 Ok(CapturedProcessOutput {
395 stdout: capture.render_stream(OutputStream::Stdout),
396 stderr: capture.render_stream(OutputStream::Stderr),
397 status,
398 timed_out,
399 })
400}
401
402struct ProcessGroupGuard {
403 #[cfg(unix)]
404 process_group: Option<i32>,
405}
406
407impl ProcessGroupGuard {
408 fn for_child(child: &Child) -> Self {
409 #[cfg(unix)]
410 {
411 Self {
412 process_group: child.id().and_then(|id| i32::try_from(id).ok()),
413 }
414 }
415 #[cfg(not(unix))]
416 {
417 let _ = child;
418 Self {}
419 }
420 }
421
422 fn kill(&mut self) {
423 #[cfg(unix)]
424 if let Some(process_group) = self.process_group.take() {
425 let _ = unsafe { libc::kill(-process_group, libc::SIGKILL) };
426 }
427 }
428
429 fn disarm(&mut self) {
430 #[cfg(unix)]
431 {
432 self.process_group = None;
433 }
434 }
435}
436
437impl Drop for ProcessGroupGuard {
438 fn drop(&mut self) {
439 self.kill();
440 }
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446 use std::sync::Arc;
447 use tokio::sync::Mutex;
448
449 struct RecordingObserver {
450 deltas: Mutex<Vec<String>>,
451 completed: Mutex<Option<crate::workspace::CommandOutputSummary>>,
452 }
453
454 #[async_trait]
455 impl crate::workspace::CommandOutputObserver for RecordingObserver {
456 async fn on_output_delta(&self, delta: &str) {
457 self.deltas.lock().await.push(delta.to_string());
458 }
459
460 async fn on_output_complete(&self, summary: &crate::workspace::CommandOutputSummary) {
461 *self.completed.lock().await = Some(summary.clone());
462 }
463 }
464
465 #[test]
466 fn bounded_capture_keeps_global_memory_with_head_and_tail() {
467 let mut capture = BoundedCapture::new();
468 let input = vec![b'x'; MAX_CAPTURE_BYTES + 1];
469 capture.push(OutputStream::Stdout, &input);
470 assert_eq!(capture.head.len() + capture.tail.len(), MAX_CAPTURE_BYTES);
471 assert_eq!(capture.total_bytes, MAX_CAPTURE_BYTES + 1);
472 assert!(capture.summary(false).truncated);
473 assert!(capture
474 .render_combined()
475 .contains("command output truncated"));
476 }
477
478 #[test]
479 fn bounded_capture_labels_stderr_truncation() {
480 let mut capture = BoundedCapture::new();
481 let input = vec![b'y'; MAX_CAPTURE_BYTES + 1];
482 capture.push(OutputStream::Stderr, &input);
483 let rendered = capture.render_stream(OutputStream::Stderr);
484 assert!(
485 rendered.contains("command stderr truncated"),
486 "stderr truncation marker missing: {rendered}"
487 );
488 }
489
490 #[test]
491 fn workspace_accessor_returns_configured_root() {
492 let directory = tempfile::tempdir().expect("temporary directory");
493 let sandbox = ProcessHostBashSandbox::new(directory.path().to_path_buf(), None);
494 assert_eq!(sandbox.workspace(), directory.path());
495 }
496
497 #[test]
498 fn process_host_does_not_filter_commands_that_name_paths_outside_the_workspace() {
499 let workspace = Path::new(r"C:\workspace");
500 let command = "printf OUTSIDE-91 > /tmp/outside-91.txt";
501 let (current_dir, args) = process_host_command(workspace, command);
502 assert_eq!(current_dir, workspace);
503 assert_eq!(args, vec!["-c".to_string(), command.to_string()]);
504 assert!(
505 args[1].contains("/tmp/outside-91.txt"),
506 "outside path must stay in the command; the outer isolator owns the deny"
507 );
508 }
509
510 #[tokio::test]
511 async fn shutdown_is_a_noop() {
512 let directory = tempfile::tempdir().expect("temporary directory");
513 let sandbox = ProcessHostBashSandbox::new(directory.path().to_path_buf(), None);
514 sandbox.shutdown().await;
515 }
516
517 #[tokio::test]
518 async fn expired_deadline_skips_spawn_and_notifies_observer() {
519 let directory = tempfile::tempdir().expect("temporary directory");
520 let sandbox = ProcessHostBashSandbox::new(
521 directory.path().to_path_buf(),
522 Some(Instant::now() - Duration::from_secs(1)),
523 );
524 let observer = Arc::new(RecordingObserver {
525 deltas: Mutex::new(Vec::new()),
526 completed: Mutex::new(None),
527 });
528 let output = sandbox
529 .exec(SandboxCommandRequest {
530 command: "printf should-not-run".into(),
531 guest_workspace: directory.path().to_string_lossy().into_owned(),
532 timeout_ms: 5_000,
533 output_observer: Some(observer.clone()),
534 env: None,
535 })
536 .await
537 .expect("deadline skip must succeed");
538 assert!(output.timed_out);
539 assert_eq!(output.exit_code, 124);
540 assert!(output.stderr.contains("run deadline expired"));
541 assert!(!observer.deltas.lock().await.is_empty());
542 assert!(observer.completed.lock().await.is_some());
543 }
544
545 #[cfg(unix)]
548 #[tokio::test]
549 async fn high_volume_output_is_bounded_before_returning_to_the_tool() {
550 let directory = tempfile::tempdir().expect("temporary directory");
551 let sandbox = ProcessHostBashSandbox::new(
552 directory.path().to_path_buf(),
553 Some(Instant::now() + Duration::from_secs(5)),
554 );
555 let output = sandbox
556 .exec(SandboxCommandRequest {
557 command: "yes x | head -c 200000".to_string(),
558 guest_workspace: directory.path().to_string_lossy().into_owned(),
559 timeout_ms: 5_000,
560 output_observer: None,
561 env: None,
562 })
563 .await
564 .expect("sandbox execution");
565 assert!(
566 !output.timed_out,
567 "high-volume capture timed out: exit={} stderr={}",
568 output.exit_code, output.stderr
569 );
570 assert!(
571 output.stdout.contains("command stdout truncated"),
572 "expected truncation marker; exit={} stdout_len={} stderr={}",
573 output.exit_code,
574 output.stdout.len(),
575 output.stderr
576 );
577 assert!(output.stdout.len() <= MAX_CAPTURE_BYTES + 256);
578 }
579
580 #[cfg(unix)]
581 #[tokio::test]
582 async fn timeout_kills_process_group_after_streams_close() {
583 let directory = tempfile::tempdir().expect("temporary directory");
584 let sandbox = ProcessHostBashSandbox::new(
585 directory.path().to_path_buf(),
586 Some(Instant::now() + Duration::from_secs(5)),
587 );
588 let output = sandbox
589 .exec(SandboxCommandRequest {
590 command: "exec 1>&- 2>&-; (sleep 0.3; touch leaked) & wait".to_string(),
591 guest_workspace: directory.path().to_string_lossy().into_owned(),
592 timeout_ms: 20,
593 output_observer: None,
594 env: None,
595 })
596 .await
597 .expect("sandbox execution");
598 assert!(output.timed_out);
599 tokio::time::sleep(Duration::from_millis(400)).await;
600 assert!(!directory.path().join("leaked").exists());
601 }
602
603 #[cfg(unix)]
604 #[tokio::test]
605 async fn exec_command_and_observer_cover_stream_capture() {
606 let directory = tempfile::tempdir().expect("temporary directory");
607 let sandbox = ProcessHostBashSandbox::new(directory.path().to_path_buf(), None);
608 let observer = Arc::new(RecordingObserver {
609 deltas: Mutex::new(Vec::new()),
610 completed: Mutex::new(None),
611 });
612 let output = sandbox
613 .exec(SandboxCommandRequest {
614 command: "printf 'out\\n'; printf 'err\\n' >&2".into(),
615 guest_workspace: directory.path().to_string_lossy().into_owned(),
616 timeout_ms: 5_000,
617 output_observer: Some(observer.clone()),
618 env: Some(Arc::new(std::collections::HashMap::from([(
619 "PROCESS_HOST_COV".into(),
620 "1".into(),
621 )]))),
622 })
623 .await
624 .expect("process-host exec");
625 assert_eq!(output.exit_code, 0, "stderr={}", output.stderr);
626 assert!(output.stdout.contains("out"));
627 assert!(output.stderr.contains("err"));
628 assert!(!observer.deltas.lock().await.is_empty());
629 assert!(observer.completed.lock().await.is_some());
630
631 let via_exec_command = sandbox
632 .exec_command("printf 'via-exec-command\\n'", "/workspace")
633 .await
634 .expect("exec_command");
635 assert_eq!(via_exec_command.exit_code, 0);
636 assert!(via_exec_command.stdout.contains("via-exec-command"));
637 }
638}