1use std::collections::{HashMap, HashSet};
27use std::path::{Path, PathBuf};
28use std::sync::Arc;
29
30use async_trait::async_trait;
31use tokio::sync::Mutex;
32use tokio_util::sync::CancellationToken;
33
34use crate::env::{
35 check_cancel_exec, check_cancel_file, ExecutionEnv, FileContent, FileInfo, FileKind,
36 FileSystem, Shell, ShellExecOptions, ShellOutput,
37};
38use crate::error::{
39 io_to_file_error, ExecutionError, ExecutionErrorCode, FileError, FileErrorCode,
40};
41use crate::file_mutation_queue::{MutatingEnv, MutationQueueRegistry};
42
43const MAX_TIMEOUT_MS: u64 = 2_147_483_647;
45const MAX_TIMEOUT_SECONDS: f64 = 2_147_483.647;
47const EXIT_STDIO_GRACE_MS: u64 = 100;
50
51#[derive(Clone, Copy)]
53enum CommandTransport {
54 Argv,
56 Stdin,
58}
59
60struct ShellConfig {
62 shell: PathBuf,
63 args: Vec<String>,
64 transport: CommandTransport,
65}
66
67pub const HOST_PRIVATE_ENV: &[&str] = &[rpi_plugin_sdk::SESSION_ID_ENV];
83
84pub fn is_host_private_env(name: &str) -> bool {
103 HOST_PRIVATE_ENV.contains(&name) || rpi_plugin_sdk::SUBAGENT_PARENT_ENV.contains(&name)
104}
105
106pub struct OsExecutionEnv {
108 cwd: PathBuf,
109 shell_path: Option<PathBuf>,
110 shell_env: Option<HashMap<String, String>>,
111 active_pids: Arc<Mutex<HashSet<u32>>>,
112 registry: Arc<MutationQueueRegistry>,
113}
114
115impl OsExecutionEnv {
116 pub fn new() -> Self {
117 Self {
118 cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
119 shell_path: None,
120 shell_env: None,
121 active_pids: Arc::new(Mutex::new(HashSet::new())),
122 registry: Arc::new(MutationQueueRegistry::new()),
123 }
124 }
125
126 pub fn with_cwd(cwd: PathBuf) -> Self {
127 Self {
128 cwd,
129 shell_path: None,
130 shell_env: None,
131 active_pids: Arc::new(Mutex::new(HashSet::new())),
132 registry: Arc::new(MutationQueueRegistry::new()),
133 }
134 }
135
136 pub fn with_shell_path(mut self, shell_path: PathBuf) -> Self {
137 self.shell_path = Some(shell_path);
138 self
139 }
140
141 pub fn with_shell_env(mut self, env: HashMap<String, String>) -> Self {
142 self.shell_env = Some(env);
143 self
144 }
145
146 fn resolve_path(cwd: &Path, path: &str) -> PathBuf {
148 let mut p = path.to_string();
149 if p == "~" {
151 p = home_dir_string();
152 } else if let Some(rest) = p.strip_prefix("~/") {
153 p = format!("{}/{}", home_dir_string(), rest);
154 } else if cfg!(windows) {
155 if let Some(rest) = p.strip_prefix("~\\") {
156 p = format!("{}\\{}", home_dir_string(), rest);
157 }
158 }
159 if p.starts_with("file://") {
161 if let Some(local) = file_url_to_path(&p) {
162 p = local;
163 }
164 }
165 let pb = PathBuf::from(&p);
166 if pb.is_absolute() {
167 normalize_absolute(&pb)
168 } else {
169 normalize_absolute(&cwd.join(pb))
170 }
171 }
172
173 async fn get_shell_config(&self) -> Result<ShellConfig, ExecutionError> {
176 if let Some(sp) = &self.shell_path {
178 if tokio::fs::metadata(sp).await.is_ok() {
179 return Ok(bash_shell_config(sp.clone()));
180 }
181 return Err(ExecutionError::new(
182 ExecutionErrorCode::ShellUnavailable,
183 format!("Custom shell path not found: {}", sp.display()),
184 ));
185 }
186 #[cfg(windows)]
188 {
189 if let Some(cfg) = windows_probe_bash().await {
190 return Ok(cfg);
191 }
192 return Err(ExecutionError::new(
193 ExecutionErrorCode::ShellUnavailable,
194 "bash not found. Install Git for Windows / MSYS2 / Cygwin, or set shellPath.",
195 ));
196 }
197 #[cfg(not(windows))]
199 {
200 if tokio::fs::metadata("/bin/bash").await.is_ok() {
201 return Ok(ShellConfig {
202 shell: PathBuf::from("/bin/bash"),
203 args: vec!["-c".to_string()],
204 transport: CommandTransport::Argv,
205 });
206 }
207 if let Some(found) =
209 run_command("which", &["bash"], std::time::Duration::from_millis(5000)).await
210 {
211 let trimmed = found.trim();
212 if !trimmed.is_empty() && tokio::fs::metadata(trimmed).await.is_ok() {
213 return Ok(ShellConfig {
214 shell: PathBuf::from(trimmed),
215 args: vec!["-c".to_string()],
216 transport: CommandTransport::Argv,
217 });
218 }
219 }
220 return Ok(ShellConfig {
222 shell: PathBuf::from("sh"),
223 args: vec!["-c".to_string()],
224 transport: CommandTransport::Argv,
225 });
226 }
227 }
228}
229
230impl Default for OsExecutionEnv {
231 fn default() -> Self {
232 Self::new()
233 }
234}
235
236fn home_dir_string() -> String {
237 dirs::home_dir()
238 .map(|p| p.to_string_lossy().into_owned())
239 .unwrap_or_default()
240}
241
242fn normalize_absolute(p: &Path) -> PathBuf {
243 let mut out = PathBuf::new();
246 for comp in p.components() {
247 match comp {
248 std::path::Component::ParentDir => {
249 out.pop();
250 }
251 std::path::Component::CurDir => {}
252 other => out.push(other),
253 }
254 }
255 out
256}
257
258fn file_url_to_path(url: &str) -> Option<String> {
259 let rest = url.strip_prefix("file://")?;
262 if let Some(drive_rest) = rest.strip_prefix('/') {
263 if drive_rest.len() >= 2 && drive_rest.as_bytes()[1] == b':' {
265 return Some(drive_rest.to_string());
266 }
267 return Some(format!("/{}", drive_rest));
268 }
269 Some(rest.to_string())
270}
271
272fn bash_shell_config(shell: PathBuf) -> ShellConfig {
273 if is_legacy_wsl_bash_path(&shell) {
274 ShellConfig {
275 shell,
276 args: vec!["-s".to_string()],
277 transport: CommandTransport::Stdin,
278 }
279 } else {
280 ShellConfig {
281 shell,
282 args: vec!["-c".to_string()],
283 transport: CommandTransport::Argv,
284 }
285 }
286}
287
288fn is_legacy_wsl_bash_path(p: &Path) -> bool {
289 let s = p.to_string_lossy().replace('/', "\\").to_lowercase();
290 let re = regex::Regex::new(r"^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$").unwrap();
291 re.is_match(&s)
292}
293
294#[cfg(windows)]
295async fn windows_probe_bash() -> Option<ShellConfig> {
296 let pf = std::env::var("ProgramFiles").ok();
297 let pf86 = std::env::var("ProgramFiles(x86)").ok();
298 let mut candidates: Vec<PathBuf> = Vec::new();
299 if let Some(p) = pf {
300 candidates.push(PathBuf::from(p).join("Git").join("bin").join("bash.exe"));
301 }
302 if let Some(p) = pf86 {
303 candidates.push(PathBuf::from(p).join("Git").join("bin").join("bash.exe"));
304 }
305 for c in &candidates {
306 if tokio::fs::metadata(c).await.is_ok() {
307 return Some(bash_shell_config(c.clone()));
308 }
309 }
310 if let Some(found) = run_command(
312 "where",
313 &["bash.exe"],
314 std::time::Duration::from_millis(5000),
315 )
316 .await
317 {
318 let first = found.lines().next().unwrap_or("").trim().to_string();
319 if !first.is_empty() && tokio::fs::metadata(&first).await.is_ok() {
320 return Some(bash_shell_config(PathBuf::from(first)));
321 }
322 }
323 None
324}
325
326async fn run_command(cmd: &str, args: &[&str], timeout: std::time::Duration) -> Option<String> {
330 let mut command = tokio::process::Command::new(cmd);
331 command.args(args);
332 command.stdout(std::process::Stdio::piped());
333 command.stderr(std::process::Stdio::null());
334 command.stdin(std::process::Stdio::null());
335 #[cfg(windows)]
336 {
337 use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW;
339 command.creation_flags(CREATE_NO_WINDOW);
340 }
341 let child = command.spawn().ok()?;
342 let out = match tokio::time::timeout(timeout, child.wait_with_output()).await {
343 Ok(Ok(o)) => o,
344 _ => return None,
345 };
346 if !out.status.success() {
347 return None;
348 }
349 let s = String::from_utf8_lossy(&out.stdout).into_owned();
350 if s.trim().is_empty() {
351 None
352 } else {
353 Some(s)
354 }
355}
356
357async fn kill_process_tree(pid: u32) {
360 #[cfg(windows)]
361 {
362 use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW;
363 let mut cmd = tokio::process::Command::new("taskkill");
364 cmd.args(&["/F", "/T", "/PID", &pid.to_string()]);
365 cmd.creation_flags(CREATE_NO_WINDOW);
366 cmd.stdin(std::process::Stdio::null());
367 cmd.stdout(std::process::Stdio::null());
368 cmd.stderr(std::process::Stdio::null());
369 let _ = cmd.status().await;
374 }
375 #[cfg(unix)]
376 {
377 use nix::sys::signal::{kill, Signal};
378 use nix::unistd::Pid;
379 let pg = Pid::from_raw(-(pid as i32));
381 if kill(pg, Signal::SIGKILL).is_err() {
382 let _ = kill(Pid::from_raw(pid as i32), Signal::SIGKILL);
383 }
384 }
385 #[cfg(not(any(windows, unix)))]
386 {
387 let _ = pid;
388 }
389}
390
391#[async_trait]
392impl FileSystem for OsExecutionEnv {
393 fn cwd(&self) -> &Path {
394 &self.cwd
395 }
396
397 async fn absolute_path(
398 &self,
399 path: &str,
400 cancel: Option<&CancellationToken>,
401 ) -> Result<PathBuf, FileError> {
402 check_cancel_file(cancel, Some(path))?;
403 Ok(Self::resolve_path(&self.cwd, path))
404 }
405
406 async fn join_path(
407 &self,
408 parts: &[&str],
409 cancel: Option<&CancellationToken>,
410 ) -> Result<PathBuf, FileError> {
411 check_cancel_file(cancel, None)?;
412 let mut out = PathBuf::new();
413 for p in parts {
414 out.push(p);
415 }
416 Ok(out)
417 }
418
419 async fn read_text_file(
420 &self,
421 path: &str,
422 cancel: Option<&CancellationToken>,
423 ) -> Result<String, FileError> {
424 check_cancel_file(cancel, Some(path))?;
425 let abs = Self::resolve_path(&self.cwd, path);
426 let bytes = read_cancellable(&abs, cancel).await?;
427 String::from_utf8(bytes).map_err(|e| {
428 FileError::new(FileErrorCode::Invalid, format!("invalid utf-8: {e}"))
429 .with_path(abs.to_string_lossy())
430 })
431 }
432
433 async fn read_text_lines(
434 &self,
435 path: &str,
436 max_lines: Option<usize>,
437 cancel: Option<&CancellationToken>,
438 ) -> Result<Vec<String>, FileError> {
439 check_cancel_file(cancel, Some(path))?;
440 if max_lines.map(|m| m == 0).unwrap_or(false) {
441 return Ok(Vec::new());
442 }
443 let text = self.read_text_file(path, cancel).await?;
444 let mut lines: Vec<String> = text.split('\n').map(|s| s.to_string()).collect();
445 if text.ends_with('\n') {
446 if lines.last().map(|s| s.is_empty()).unwrap_or(false) {
447 lines.pop();
448 }
449 }
450 if let Some(m) = max_lines {
451 lines.truncate(m);
452 }
453 Ok(lines)
454 }
455
456 async fn read_binary_file(
457 &self,
458 path: &str,
459 cancel: Option<&CancellationToken>,
460 ) -> Result<Vec<u8>, FileError> {
461 check_cancel_file(cancel, Some(path))?;
462 let abs = Self::resolve_path(&self.cwd, path);
463 read_cancellable(&abs, cancel).await
464 }
465
466 async fn write_file(
467 &self,
468 path: &str,
469 content: FileContent,
470 cancel: Option<&CancellationToken>,
471 ) -> Result<(), FileError> {
472 check_cancel_file(cancel, Some(path))?;
473 let abs = Self::resolve_path(&self.cwd, path);
474 if let Some(parent) = abs.parent() {
476 if !parent.as_os_str().is_empty() {
477 if let Err(e) = tokio::fs::create_dir_all(parent).await {
478 return Err(io_to_file_error(e, Some(&abs.to_string_lossy())));
479 }
480 }
481 }
482 check_cancel_file(cancel, Some(&abs.to_string_lossy()))?;
483 write_cancellable(&abs, content.as_bytes(), cancel).await
484 }
485
486 async fn append_file(
487 &self,
488 path: &str,
489 content: FileContent,
490 cancel: Option<&CancellationToken>,
491 ) -> Result<(), FileError> {
492 check_cancel_file(cancel, Some(path))?;
493 let abs = Self::resolve_path(&self.cwd, path);
494 if let Some(parent) = abs.parent() {
495 if !parent.as_os_str().is_empty() {
496 let _ = tokio::fs::create_dir_all(parent).await;
497 }
498 }
499 use tokio::io::AsyncWriteExt;
500 let res = match tokio::fs::OpenOptions::new()
501 .create(true)
502 .append(true)
503 .open(&abs)
504 .await
505 {
506 Ok(mut f) => {
507 match f.write_all(content.as_bytes()).await {
511 Ok(()) => f.flush().await,
512 Err(error) => Err(error),
513 }
514 }
515 Err(e) => return Err(io_to_file_error(e, Some(&abs.to_string_lossy()))),
516 };
517 res.map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))
518 }
519
520 async fn rename_file(
521 &self,
522 source: &str,
523 dest: &str,
524 cancel: Option<&CancellationToken>,
525 ) -> Result<(), FileError> {
526 check_cancel_file(cancel, Some(source))?;
527 let src = Self::resolve_path(&self.cwd, source);
528 let dst = Self::resolve_path(&self.cwd, dest);
529 tokio::fs::rename(&src, &dst)
530 .await
531 .map_err(|e| io_to_file_error(e, Some(&src.to_string_lossy())))
532 }
533
534 async fn file_info(
535 &self,
536 path: &str,
537 cancel: Option<&CancellationToken>,
538 ) -> Result<FileInfo, FileError> {
539 check_cancel_file(cancel, Some(path))?;
540 let abs = Self::resolve_path(&self.cwd, path);
541 let meta = tokio::fs::symlink_metadata(&abs)
542 .await
543 .map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))?;
544 let ft = meta.file_type();
545 let kind = if ft.is_file() {
546 FileKind::File
547 } else if ft.is_dir() {
548 FileKind::Directory
549 } else if ft.is_symlink() {
550 FileKind::Symlink
551 } else {
552 return Err(
553 FileError::new(FileErrorCode::Invalid, "Unsupported file type")
554 .with_path(abs.to_string_lossy()),
555 );
556 };
557 let mtime_ms = meta
558 .modified()
559 .ok()
560 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
561 .map(|d| d.as_millis() as i64)
562 .unwrap_or(0);
563 Ok(FileInfo {
564 name: abs
565 .file_name()
566 .map(|n| n.to_string_lossy().into_owned())
567 .unwrap_or_else(|| abs.to_string_lossy().into_owned()),
568 path: abs.clone(),
569 kind,
570 size: meta.len(),
571 mtime_ms,
572 })
573 }
574
575 async fn list_dir(
576 &self,
577 path: &str,
578 cancel: Option<&CancellationToken>,
579 ) -> Result<Vec<FileInfo>, FileError> {
580 check_cancel_file(cancel, Some(path))?;
581 let abs = Self::resolve_path(&self.cwd, path);
582 let mut entries = tokio::fs::read_dir(&abs)
583 .await
584 .map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))?;
585 let mut out = Vec::new();
586 while let Some(entry) = entries
587 .next_entry()
588 .await
589 .map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))?
590 {
591 if let Some(t) = cancel {
592 if t.is_cancelled() {
593 return Err(FileError::aborted().with_path(abs.to_string_lossy()));
594 }
595 }
596 let ep = entry.path();
597 let meta = match tokio::fs::symlink_metadata(&ep).await {
598 Ok(m) => m,
599 Err(e) => {
600 let code = crate::error::io_to_file_code(&e);
602 if code == FileErrorCode::NotSupported {
603 continue;
604 }
605 return Err(io_to_file_error(e, Some(&ep.to_string_lossy())));
606 }
607 };
608 let ft = meta.file_type();
609 let kind = if ft.is_file() {
610 FileKind::File
611 } else if ft.is_dir() {
612 FileKind::Directory
613 } else if ft.is_symlink() {
614 FileKind::Symlink
615 } else {
616 continue; };
618 let mtime_ms = meta
619 .modified()
620 .ok()
621 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
622 .map(|d| d.as_millis() as i64)
623 .unwrap_or(0);
624 out.push(FileInfo {
625 name: entry.file_name().to_string_lossy().into_owned(),
626 path: ep,
627 kind,
628 size: meta.len(),
629 mtime_ms,
630 });
631 }
632 Ok(out)
633 }
634
635 async fn canonical_path(
636 &self,
637 path: &str,
638 cancel: Option<&CancellationToken>,
639 ) -> Result<PathBuf, FileError> {
640 check_cancel_file(cancel, Some(path))?;
641 let abs = Self::resolve_path(&self.cwd, path);
642 tokio::fs::canonicalize(&abs)
643 .await
644 .map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))
645 }
646
647 async fn exists(
648 &self,
649 path: &str,
650 cancel: Option<&CancellationToken>,
651 ) -> Result<bool, FileError> {
652 check_cancel_file(cancel, Some(path))?;
653 let abs = Self::resolve_path(&self.cwd, path);
654 match tokio::fs::symlink_metadata(&abs).await {
655 Ok(_) => Ok(true),
656 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
657 Err(e) => Err(io_to_file_error(e, Some(&abs.to_string_lossy()))),
658 }
659 }
660
661 async fn create_dir(
662 &self,
663 path: &str,
664 recursive: bool,
665 cancel: Option<&CancellationToken>,
666 ) -> Result<(), FileError> {
667 check_cancel_file(cancel, Some(path))?;
668 let abs = Self::resolve_path(&self.cwd, path);
669 let res = if recursive {
670 tokio::fs::create_dir_all(&abs).await
671 } else {
672 tokio::fs::create_dir(&abs).await
673 };
674 res.map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))
675 }
676
677 async fn remove(
678 &self,
679 path: &str,
680 recursive: bool,
681 force: bool,
682 cancel: Option<&CancellationToken>,
683 ) -> Result<(), FileError> {
684 check_cancel_file(cancel, Some(path))?;
685 let abs = Self::resolve_path(&self.cwd, path);
686 let res = if recursive {
687 tokio::fs::remove_dir_all(&abs).await
688 } else {
689 match tokio::fs::remove_file(&abs).await {
691 Ok(()) => Ok(()),
692 Err(_) => tokio::fs::remove_dir(&abs).await,
693 }
694 };
695 match res {
696 Ok(()) => Ok(()),
697 Err(e) if e.kind() == std::io::ErrorKind::NotFound && force => Ok(()),
698 Err(e) => Err(io_to_file_error(e, Some(&abs.to_string_lossy()))),
699 }
700 }
701
702 async fn create_temp_dir(
703 &self,
704 prefix: Option<&str>,
705 cancel: Option<&CancellationToken>,
706 ) -> Result<PathBuf, FileError> {
707 check_cancel_file(cancel, None)?;
708 let prefix = prefix.unwrap_or("tmp-");
709 let tmp =
711 tempfile::tempdir_in(std::env::temp_dir()).map_err(|e| io_to_file_error(e, None))?;
712 let path = tmp.path().to_path_buf();
714 std::mem::forget(tmp); let final_path = std::env::temp_dir().join(format!("{}{}", prefix, random_suffix()));
717 if let Err(e) = tokio::fs::rename(&path, &final_path).await {
719 let _ = e;
721 return Ok(path);
722 }
723 Ok(final_path)
724 }
725
726 async fn create_temp_file(
727 &self,
728 prefix: &str,
729 suffix: &str,
730 cancel: Option<&CancellationToken>,
731 ) -> Result<PathBuf, FileError> {
732 check_cancel_file(cancel, None)?;
733 let dir = self.create_temp_dir(Some("tmp-"), cancel).await?;
736 let name = format!("{}{}{}", prefix, random_suffix(), suffix);
737 let path = dir.join(name);
738 tokio::fs::File::create(&path)
739 .await
740 .map_err(|e| io_to_file_error(e, Some(&path.to_string_lossy())))?;
741 Ok(path)
742 }
743
744 async fn cleanup(&self) {
745 let pids: Vec<u32> = self.active_pids.lock().await.iter().copied().collect();
746 for pid in pids {
747 kill_process_tree(pid).await;
748 }
749 self.active_pids.lock().await.clear();
750 }
751}
752
753async fn read_cancellable(
755 path: &Path,
756 cancel: Option<&CancellationToken>,
757) -> Result<Vec<u8>, FileError> {
758 let read = tokio::fs::read(path);
759 let res = match cancel {
760 Some(t) => tokio::select! {
761 r = read => r,
762 _ = t.cancelled() => return Err(FileError::aborted().with_path(path.to_string_lossy())),
763 },
764 None => read.await,
765 };
766 res.map_err(|e| io_to_file_error(e, Some(&path.to_string_lossy())))
767}
768
769async fn write_cancellable(
771 path: &Path,
772 bytes: &[u8],
773 cancel: Option<&CancellationToken>,
774) -> Result<(), FileError> {
775 use tokio::io::AsyncWriteExt;
776 let f = tokio::fs::File::create(path)
777 .await
778 .map_err(|e| io_to_file_error(e, Some(&path.to_string_lossy())))?;
779 let write = async {
780 let mut f = f;
781 f.write_all(bytes).await?;
782 f.flush().await?;
783 Ok::<(), std::io::Error>(())
784 };
785 let res = match cancel {
786 Some(t) => tokio::select! {
787 r = write => r,
788 _ = t.cancelled() => return Err(FileError::aborted().with_path(path.to_string_lossy())),
789 },
790 None => write.await,
791 };
792 res.map_err(|e| io_to_file_error(e, Some(&path.to_string_lossy())))
793}
794
795fn random_suffix() -> String {
796 uuid::Uuid::new_v4().simple().to_string()
798}
799
800#[async_trait]
801impl Shell for OsExecutionEnv {
802 async fn exec<'a>(
803 &'a self,
804 command: &str,
805 options: ShellExecOptions<'a>,
806 ) -> Result<ShellOutput, ExecutionError> {
807 check_cancel_exec(options.cancel)?;
809 let timeout = match resolve_timeout(options.timeout) {
811 Ok(t) => t,
812 Err(e) => return Err(e),
813 };
814 let cwd = match &options.cwd {
816 Some(c) => Self::resolve_path(&self.cwd, &c.to_string_lossy()),
817 None => self.cwd.clone(),
818 };
819 let shell_cfg = match &options.shell {
822 Some(override_shell) => ShellConfig {
823 shell: override_shell.program.clone(),
824 args: override_shell.args.clone(),
825 transport: CommandTransport::Argv,
826 },
827 None => self.get_shell_config().await?,
828 };
829 if !cwd.exists() {
831 return Err(ExecutionError::new(
832 ExecutionErrorCode::SpawnError,
833 format!(
834 "Working directory does not exist: {}\nCannot execute bash commands.",
835 cwd.display()
836 ),
837 ));
838 }
839
840 let mut cmd = tokio::process::Command::new(&shell_cfg.shell);
842 cmd.current_dir(&cwd);
843 cmd.env_clear();
844 if options.inherit_env {
851 for (k, v) in std::env::vars() {
852 if is_host_private_env(&k) {
853 continue;
854 }
855 cmd.env(k, v);
856 }
857 }
858 if let Some(base) = &self.shell_env {
859 for (k, v) in base {
860 cmd.env(k, v);
861 }
862 }
863 if let Some(extra) = &options.env {
864 for (k, v) in extra {
865 cmd.env(k, v);
866 }
867 }
868 cmd.stdin(std::process::Stdio::piped());
870 cmd.stdout(std::process::Stdio::piped());
871 cmd.stderr(std::process::Stdio::piped());
872 #[cfg(unix)]
873 {
874 use std::os::unix::process::CommandExt;
875 cmd.process_group(0); }
877 #[cfg(windows)]
878 {
879 use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW;
880 cmd.creation_flags(CREATE_NO_WINDOW);
881 }
882
883 let transport = shell_cfg.transport;
885 cmd.args(&shell_cfg.args);
886 if matches!(transport, CommandTransport::Argv) {
887 cmd.arg(command);
888 }
889
890 let mut child = match cmd.spawn() {
891 Ok(c) => c,
892 Err(e) => {
893 return Err(ExecutionError::new(
894 ExecutionErrorCode::SpawnError,
895 format!("failed to spawn shell: {e}"),
896 ));
897 }
898 };
899 let pid = child.id();
900 if let Some(pid) = pid {
901 self.active_pids.lock().await.insert(pid);
902 }
903
904 let mut stdin = child.stdin.take();
911 if matches!(transport, CommandTransport::Stdin) {
912 if let Some(stdin) = stdin.as_mut() {
913 use tokio::io::AsyncWriteExt;
914 let _ = stdin.write_all(command.as_bytes()).await;
915 let _ = stdin.flush().await;
916 }
917 }
918 drop(stdin);
919
920 let mut stdout_buf = String::new();
922 let mut stderr_buf = String::new();
923 let cancel = options.cancel;
924 let mut on_stdout = options.on_stdout;
925 let mut on_stderr = options.on_stderr;
926
927 let mut stdout = child.stdout.take();
928 let mut stderr = child.stderr.take();
929
930 let stdout_task = async {
931 if let Some(s) = stdout.as_mut() {
932 let mut buf = vec![0u8; 8192];
933 use tokio::io::AsyncReadExt;
934 loop {
935 let read = s.read(&mut buf);
936 let read_result = match cancel {
937 Some(token) => tokio::select! {
938 result = read => result,
939 _ = token.cancelled() => break,
940 },
941 None => read.await,
942 };
943 let n = match read_result {
944 Ok(0) => break,
945 Ok(n) => n,
946 Err(_) => break,
947 };
948 let chunk = String::from_utf8_lossy(&buf[..n]).into_owned();
949 if let Some(cb) = on_stdout.as_mut() {
950 cb(&chunk);
953 }
954 stdout_buf.push_str(&chunk);
955 }
956 }
957 };
958 let stderr_task = async {
959 if let Some(s) = stderr.as_mut() {
960 let mut buf = vec![0u8; 8192];
961 use tokio::io::AsyncReadExt;
962 loop {
963 let read = s.read(&mut buf);
964 let read_result = match cancel {
965 Some(token) => tokio::select! {
966 result = read => result,
967 _ = token.cancelled() => break,
968 },
969 None => read.await,
970 };
971 let n = match read_result {
972 Ok(0) => break,
973 Ok(n) => n,
974 Err(_) => break,
975 };
976 let chunk = String::from_utf8_lossy(&buf[..n]).into_owned();
977 if let Some(cb) = on_stderr.as_mut() {
978 cb(&chunk);
979 }
980 stderr_buf.push_str(&chunk);
981 }
982 }
983 };
984
985 let mut timed_out = false;
987 let exit_status = {
988 let status_fut = async {
993 match (timeout, cancel) {
994 (Some(to), Some(token)) => {
995 tokio::select! {
996 status = child.wait() => status,
997 _ = tokio::time::sleep(to) => {
998 timed_out = true;
999 if let Some(pid) = child.id() {
1000 kill_process_tree(pid).await;
1001 }
1002 child.wait().await
1003 }
1004 _ = token.cancelled() => {
1005 if let Some(pid) = child.id() {
1006 kill_process_tree(pid).await;
1007 }
1008 child.wait().await
1009 }
1010 }
1011 }
1012 (Some(to), None) => match tokio::time::timeout(to, child.wait()).await {
1013 Ok(status) => status,
1014 Err(_) => {
1015 timed_out = true;
1016 if let Some(pid) = child.id() {
1017 kill_process_tree(pid).await;
1018 }
1019 child.wait().await
1020 }
1021 },
1022 (None, Some(token)) => {
1023 tokio::select! {
1024 status = child.wait() => status,
1025 _ = token.cancelled() => {
1026 if let Some(pid) = child.id() {
1027 kill_process_tree(pid).await;
1028 }
1029 child.wait().await
1030 }
1031 }
1032 }
1033 (None, None) => child.wait().await,
1034 }
1035 };
1036 tokio::pin!(status_fut, stdout_task, stderr_task);
1046 let status_res = (&mut status_fut).await;
1047 let _ = tokio::time::timeout(
1052 std::time::Duration::from_millis(EXIT_STDIO_GRACE_MS),
1053 async {
1054 let _ = tokio::join!(&mut stdout_task, &mut stderr_task);
1055 },
1056 )
1057 .await;
1058 status_res
1059 };
1060
1061 if timed_out {
1065 let _ = tokio::time::sleep(std::time::Duration::from_millis(EXIT_STDIO_GRACE_MS)).await;
1066 }
1067
1068 if let Some(pid) = pid {
1070 self.active_pids.lock().await.remove(&pid);
1071 }
1072
1073 let status = match exit_status {
1075 Ok(s) => s,
1076 Err(e) => {
1077 return Err(ExecutionError::new(
1078 ExecutionErrorCode::SpawnError,
1079 format!("child wait failed: {e}"),
1080 ));
1081 }
1082 };
1083 if timed_out {
1084 return Err(ExecutionError::timeout(
1085 options.timeout.map(|f| f as u64).unwrap_or(0),
1086 ));
1087 }
1088 if cancel.map(|t| t.is_cancelled()).unwrap_or(false) {
1089 return Err(ExecutionError::aborted());
1090 }
1091 let exit_code = status.code().unwrap_or(0);
1092 Ok(ShellOutput {
1093 stdout: stdout_buf,
1094 stderr: stderr_buf,
1095 exit_code,
1096 })
1097 }
1098
1099 async fn cleanup(&self) {
1100 FileSystem::cleanup(self).await
1101 }
1102}
1103
1104#[async_trait]
1105impl ExecutionEnv for OsExecutionEnv {}
1106
1107#[async_trait]
1108impl MutatingEnv for OsExecutionEnv {
1109 fn as_env(&self) -> &dyn ExecutionEnv {
1110 self
1111 }
1112 fn mutation_registry(&self) -> &MutationQueueRegistry {
1113 &self.registry
1114 }
1115}
1116
1117fn resolve_timeout(timeout: Option<f64>) -> Result<Option<std::time::Duration>, ExecutionError> {
1119 match timeout {
1120 None => Ok(None),
1121 Some(secs) => {
1122 if !secs.is_finite() || secs <= 0.0 {
1123 return Err(ExecutionError::new(
1124 ExecutionErrorCode::Timeout,
1125 "Invalid timeout: must be a finite number of seconds",
1126 ));
1127 }
1128 let ms = secs * 1000.0;
1129 if ms > MAX_TIMEOUT_MS as f64 {
1130 return Err(ExecutionError::new(
1131 ExecutionErrorCode::Timeout,
1132 format!(
1133 "Invalid timeout: maximum is {} seconds",
1134 MAX_TIMEOUT_SECONDS
1135 ),
1136 ));
1137 }
1138 Ok(Some(std::time::Duration::from_secs_f64(secs)))
1139 }
1140 }
1141}