sim_lib_agent_runner_process/
process.rs1use sim_kernel::{Error, Result};
2use std::{
3 io::{BufRead, BufReader, Read, Write},
4 process::{Child, Command, Stdio},
5 sync::{Arc, Mutex, mpsc},
6 thread,
7 time::{Duration, Instant},
8};
9
10#[derive(Clone, Debug)]
12pub struct ProcessCommandSpec {
13 command: String,
14 label: String,
15 timeout: Duration,
16 max_output_bytes: usize,
17}
18
19impl ProcessCommandSpec {
20 pub fn new(
23 command: impl Into<String>,
24 label: impl Into<String>,
25 timeout: Duration,
26 max_output_bytes: usize,
27 ) -> Self {
28 Self {
29 command: command.into(),
30 label: label.into(),
31 timeout,
32 max_output_bytes,
33 }
34 }
35}
36
37pub fn run_process_command(spec: &ProcessCommandSpec, stdin: Vec<u8>) -> Result<Vec<u8>> {
39 run_command(
40 &spec.command,
41 stdin,
42 &spec.label,
43 spec.timeout,
44 spec.max_output_bytes,
45 )
46}
47
48pub fn stream_process_command_lines(
51 spec: &ProcessCommandSpec,
52 stdin: Vec<u8>,
53 on_line: impl FnMut(&[u8]) -> Result<()>,
54) -> Result<Vec<u8>> {
55 stream_command_lines(
56 &spec.command,
57 stdin,
58 &spec.label,
59 spec.timeout,
60 spec.max_output_bytes,
61 on_line,
62 )
63}
64
65pub(super) fn shell_child(command: &str) -> Command {
66 let mut child = Command::new("/bin/sh");
67 child.arg("-c").arg(command);
70 child
71 .stdin(Stdio::piped())
72 .stdout(Stdio::piped())
73 .stderr(Stdio::null());
74 child
75}
76
77pub(super) fn capture_child_output(
78 mut child: Child,
79 stdin: Vec<u8>,
80 label: &str,
81 timeout: Duration,
82 max_output_bytes: usize,
83) -> Result<Vec<u8>> {
84 let stdout = child
85 .stdout
86 .take()
87 .ok_or_else(|| Error::HostError(format!("{label} stdout was not captured")))?;
88 let stdin_handle = child
89 .stdin
90 .take()
91 .ok_or_else(|| Error::HostError(format!("{label} stdin was not captured")))?;
92 let child = Arc::new(Mutex::new(child));
93
94 let writer = thread::spawn(move || -> std::io::Result<()> {
97 let mut stdin_handle = stdin_handle;
98 stdin_handle.write_all(&stdin)
99 });
100 let (tx, rx) = mpsc::channel();
101 let reader = thread::spawn(move || {
102 let mut reader = stdout;
103 let mut bytes = Vec::new();
104 let mut chunk = [0_u8; 4096];
105 loop {
106 match reader.read(&mut chunk) {
107 Ok(0) => break,
108 Ok(read) => {
109 let remaining = max_output_bytes
110 .saturating_add(1)
111 .saturating_sub(bytes.len());
112 if remaining == 0 {
113 break;
114 }
115 let take = remaining.min(read);
116 bytes.extend_from_slice(&chunk[..take]);
117 if bytes.len() > max_output_bytes {
118 break;
119 }
120 }
121 Err(err) => {
122 let _ = tx.send(Err(io_error_to_host(err)));
123 return;
124 }
125 }
126 }
127 let _ = tx.send(Ok(bytes));
128 });
129
130 let deadline = Instant::now() + timeout;
135 let mut status: Option<std::process::ExitStatus> = None;
136 let mut captured: Option<Result<Vec<u8>>> = None;
137 loop {
138 if status.is_none() {
139 let mut child = child
140 .lock()
141 .map_err(|_| Error::HostError(format!("{label} mutex poisoned")))?;
142 status = child.try_wait().map_err(io_error_to_host)?;
143 }
144 if captured.is_none() {
145 match rx.recv_timeout(Duration::from_millis(10)) {
146 Ok(message) => captured = Some(message),
147 Err(mpsc::RecvTimeoutError::Timeout) => {}
148 Err(mpsc::RecvTimeoutError::Disconnected) => captured = Some(Ok(Vec::new())),
149 }
150 } else if status.is_none() {
151 thread::sleep(Duration::from_millis(10));
152 }
153 if status.is_some() && captured.is_some() {
154 break;
155 }
156 if Instant::now() >= deadline {
157 let mut child = child
158 .lock()
159 .map_err(|_| Error::HostError(format!("{label} mutex poisoned")))?;
160 let _ = child.kill();
161 let _ = child.wait();
162 return Err(Error::Eval(format!(
165 "{label} timed out after {}ms",
166 timeout.as_millis()
167 )));
168 }
169 }
170
171 let status =
172 status.ok_or_else(|| Error::HostError(format!("{label} status was not captured")))?;
173 let bytes =
174 captured.ok_or_else(|| Error::HostError(format!("{label} stdout reader failed")))??;
175 reader
176 .join()
177 .map_err(|_| Error::HostError(format!("{label} stdout reader panicked")))?;
178 let writer_outcome = writer
179 .join()
180 .map_err(|_| Error::HostError(format!("{label} stdin writer panicked")))?;
181 if bytes.len() > max_output_bytes {
182 return Err(Error::Eval(format!(
183 "{label} exceeded max output bytes {max_output_bytes}"
184 )));
185 }
186 if !status.success() {
187 return Err(Error::Eval(format!(
190 "{label} exited with status {}",
191 status
192 .code()
193 .map(|code| code.to_string())
194 .unwrap_or_else(|| "signal".to_owned())
195 )));
196 }
197 if let Err(err) = writer_outcome
200 && !is_benign_stdin_pipe_error(&err)
201 {
202 return Err(io_error_to_host(err));
203 }
204 Ok(bytes)
205}
206
207fn is_benign_stdin_pipe_error(err: &std::io::Error) -> bool {
210 matches!(
211 err.kind(),
212 std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::WriteZero
213 )
214}
215
216pub(super) fn run_command(
217 command: &str,
218 stdin: Vec<u8>,
219 label: &str,
220 timeout: Duration,
221 max_output_bytes: usize,
222) -> Result<Vec<u8>> {
223 let child = shell_child(command).spawn().map_err(io_error_to_host)?;
224 capture_child_output(child, stdin, label, timeout, max_output_bytes)
225}
226
227pub(super) fn stream_command_lines(
228 command: &str,
229 stdin: Vec<u8>,
230 label: &str,
231 timeout: Duration,
232 max_output_bytes: usize,
233 mut on_line: impl FnMut(&[u8]) -> Result<()>,
234) -> Result<Vec<u8>> {
235 let mut child = shell_child(command).spawn().map_err(io_error_to_host)?;
236 let stdout = child
237 .stdout
238 .take()
239 .ok_or_else(|| Error::HostError(format!("{label} stdout was not captured")))?;
240 let stdin_handle = child
241 .stdin
242 .take()
243 .ok_or_else(|| Error::HostError(format!("{label} stdin was not captured")))?;
244 let child = Arc::new(Mutex::new(child));
245 let writer = thread::spawn(move || -> std::io::Result<()> {
249 let mut stdin_handle = stdin_handle;
250 stdin_handle.write_all(&stdin)
251 });
252 let (tx, rx) = mpsc::channel();
253 let reader = thread::spawn(move || {
254 let mut reader = BufReader::new(stdout);
255 loop {
256 let mut line = Vec::new();
257 match reader.read_until(b'\n', &mut line) {
258 Ok(0) => break,
259 Ok(_) => {
260 if tx.send(Ok(line)).is_err() {
261 return;
262 }
263 }
264 Err(err) => {
265 let _ = tx.send(Err(io_error_to_host(err)));
266 return;
267 }
268 }
269 }
270 });
271
272 let deadline = Instant::now() + timeout;
273 let mut bytes = Vec::new();
274 let mut status = None;
275 let mut reader_done = false;
276 while !reader_done || status.is_none() {
277 match rx.recv_timeout(Duration::from_millis(10)) {
278 Ok(Ok(line)) => {
279 if bytes.len().saturating_add(line.len()) > max_output_bytes {
280 kill_child(&child);
281 return Err(Error::Eval(format!(
286 "{label} exceeded max output bytes {max_output_bytes}"
287 )));
288 }
289 on_line(&line)?;
290 bytes.extend_from_slice(&line);
291 }
292 Ok(Err(err)) => {
293 kill_child(&child);
294 return Err(err);
297 }
298 Err(mpsc::RecvTimeoutError::Timeout) => {}
299 Err(mpsc::RecvTimeoutError::Disconnected) => {
300 reader_done = true;
301 }
302 }
303 if status.is_none() {
304 let mut child = child
305 .lock()
306 .map_err(|_| Error::HostError(format!("{label} mutex poisoned")))?;
307 status = child.try_wait().map_err(io_error_to_host)?;
308 }
309 if Instant::now() >= deadline {
310 kill_child(&child);
311 return Err(Error::Eval(format!(
315 "{label} timed out after {}ms",
316 timeout.as_millis()
317 )));
318 }
319 }
320 reader
321 .join()
322 .map_err(|_| Error::HostError(format!("{label} stdout reader panicked")))?;
323 let writer_outcome = writer
324 .join()
325 .map_err(|_| Error::HostError(format!("{label} stdin writer panicked")))?;
326 let status =
327 status.ok_or_else(|| Error::HostError(format!("{label} status was not captured")))?;
328 if !status.success() {
329 return Err(Error::Eval(format!(
332 "{label} exited with status {}",
333 status
334 .code()
335 .map(|code| code.to_string())
336 .unwrap_or_else(|| "signal".to_owned())
337 )));
338 }
339 if let Err(err) = writer_outcome
342 && !is_benign_stdin_pipe_error(&err)
343 {
344 return Err(io_error_to_host(err));
345 }
346 Ok(bytes)
347}
348
349fn kill_child(child: &Arc<Mutex<Child>>) {
350 if let Ok(mut child) = child.lock() {
351 let _ = child.kill();
352 let _ = child.wait();
353 }
354}
355
356pub(super) fn io_error_to_host(err: std::io::Error) -> Error {
357 Error::HostError(err.to_string())
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use crate::{ProcessProtocol, ProcessRunner, effects::host_process_capability};
364 use sim_kernel::{CapabilityName, Cx, DefaultFactory, Expr, NoopEvalPolicy, Symbol};
365 use sim_lib_agent_runner_core::ModelRequest;
366
367 #[test]
368 fn denied_host_process_refuses_before_spawn() {
369 let mut cx = Cx::new(Arc::new(NoopEvalPolicy), Arc::new(DefaultFactory));
370 let runner = ProcessRunner::new(
371 Symbol::new("p"),
372 "m",
373 "echo should-not-run",
374 ProcessProtocol::LineText,
375 Duration::from_secs(5),
376 1024,
377 );
378 let request = ModelRequest::new(Expr::String("hi".to_owned()), Vec::new());
379
380 let err = crate::effects::resolve_process_effect(&runner, &mut cx, request, |_, _| {
381 panic!("subprocess spawned despite denied host-process capability")
382 })
383 .expect_err("denied capability must refuse before spawn");
384
385 assert!(matches!(
386 err,
387 Error::CapabilityDenied { capability }
388 if capability == host_process_capability()
389 ));
390 }
391
392 #[test]
393 fn granted_host_process_allows_spawn() {
394 let mut cx = Cx::new(Arc::new(NoopEvalPolicy), Arc::new(DefaultFactory));
395 cx.grant(CapabilityName::new("host.process"));
396 assert!(cx.require(&host_process_capability()).is_ok());
399 let bytes = run_command(
400 "printf ok",
401 Vec::new(),
402 "test",
403 Duration::from_secs(5),
404 1024,
405 )
406 .expect("granted command runs");
407 assert_eq!(bytes, b"ok");
408 }
409
410 #[test]
411 fn streaming_tolerates_benign_stdin_epipe_from_early_exit() {
412 let mut lines = Vec::new();
419 let bytes = stream_command_lines(
420 "printf 'one\\ntwo\\n'",
421 vec![b'x'; 1024 * 1024],
422 "test",
423 Duration::from_secs(5),
424 1024,
425 |line| {
426 lines.push(
427 String::from_utf8_lossy(line)
428 .trim_end_matches(['\r', '\n'])
429 .to_owned(),
430 );
431 Ok(())
432 },
433 )
434 .expect("a stdin-ignoring streamed command must not fail on benign EPIPE");
435 assert_eq!(lines, vec!["one".to_owned(), "two".to_owned()]);
436 assert_eq!(bytes, b"one\ntwo\n");
437 }
438
439 #[test]
440 fn backgrounded_grandchild_returns_at_timeout_not_hang() {
441 let start = Instant::now();
442 let err = run_command(
443 "sleep 0.01; (sleep 60 &)",
444 Vec::new(),
445 "test",
446 Duration::from_millis(300),
447 4096,
448 )
449 .expect_err("a grandchild holding stdout must not hang past the deadline");
450
451 assert!(matches!(err, Error::Eval(message) if message.contains("timed out")));
452 assert!(start.elapsed() < Duration::from_secs(5));
455 }
456
457 #[test]
458 fn streaming_backgrounded_grandchild_returns_at_timeout_not_hang() {
459 let start = Instant::now();
464 let mut lines = Vec::new();
465 let err = stream_command_lines(
466 "printf 'ready\\n'; (sleep 60 &); sleep 60",
467 Vec::new(),
468 "test",
469 Duration::from_millis(300),
470 4096,
471 |line| {
472 lines.push(
473 String::from_utf8_lossy(line)
474 .trim_end_matches(['\r', '\n'])
475 .to_owned(),
476 );
477 Ok(())
478 },
479 )
480 .expect_err("a streaming grandchild must not hang past the deadline");
481
482 assert!(matches!(err, Error::Eval(message) if message.contains("timed out")));
483 assert!(lines.contains(&"ready".to_owned()));
484 assert!(start.elapsed() < Duration::from_secs(5));
486 }
487
488 #[test]
489 fn streaming_max_output_with_grandchild_returns_not_hang() {
490 let start = Instant::now();
494 let err = stream_command_lines(
495 "(sleep 60 &); printf 'aaaaaaaaaa\\nbbbbbbbbbb\\n'; sleep 60",
496 Vec::new(),
497 "test",
498 Duration::from_secs(5),
499 4,
500 |_line| Ok(()),
501 )
502 .expect_err("exceeding max output must return, not hang on a grandchild");
503
504 assert!(matches!(err, Error::Eval(message) if message.contains("max output bytes")));
505 assert!(start.elapsed() < Duration::from_secs(5));
506 }
507
508 #[test]
509 fn early_exit_reports_status_not_stdin_pipe_error() {
510 let stdin = vec![b'x'; 1 << 20];
513 let err = run_command(
514 "head -c1 >/dev/null; exit 3",
515 stdin,
516 "test",
517 Duration::from_secs(5),
518 1 << 20,
519 )
520 .expect_err("a non-zero exit must surface as an error");
521
522 match err {
523 Error::Eval(message) => {
524 assert!(
525 message.contains("exited with status 3"),
526 "expected exit-3 status, got: {message}"
527 );
528 assert!(
529 !message.to_lowercase().contains("pipe"),
530 "stdin pipe error masked the real exit status: {message}"
531 );
532 }
533 other => panic!("expected an Eval status error, got: {other}"),
534 }
535 }
536}