Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:07:23.952Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

darwin.rs

1120 lines · 35.3 KB · rust
1use mach2::exception_types::{
2    EXC_MASK_ALL, EXCEPTION_DEFAULT, exception_behavior_t, exception_mask_t,
3};
4use mach2::port::{MACH_PORT_NULL, mach_port_t};
5use mach2::thread_status::{THREAD_STATE_NONE, thread_state_flavor_t};
6use smol::Async;
7use std::collections::BTreeMap;
8use std::ffi::{CString, OsStr, OsString};
9use std::io;
10use std::os::fd::AsRawFd;
11use std::os::unix::ffi::{OsStrExt, OsStringExt};
12use std::os::unix::io::FromRawFd;
13use std::path::{Path, PathBuf};
14use std::process::{ExitStatus, Output};
15use std::ptr;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum Stdio {
19    /// A new pipe should be arranged to connect the parent and child processes.
20    #[default]
21    Piped,
22    /// The child inherits from the corresponding parent descriptor.
23    Inherit,
24    /// This stream will be ignored (redirected to `/dev/null`).
25    Null,
26}
27
28impl Stdio {
29    pub fn piped() -> Self {
30        Self::Piped
31    }
32
33    pub fn inherit() -> Self {
34        Self::Inherit
35    }
36
37    pub fn null() -> Self {
38        Self::Null
39    }
40}
41
42unsafe extern "C" {
43    fn posix_spawnattr_setexceptionports_np(
44        attr: *mut libc::posix_spawnattr_t,
45        mask: exception_mask_t,
46        new_port: mach_port_t,
47        behavior: exception_behavior_t,
48        new_flavor: thread_state_flavor_t,
49    ) -> libc::c_int;
50
51    fn posix_spawn_file_actions_addchdir_np(
52        file_actions: *mut libc::posix_spawn_file_actions_t,
53        path: *const libc::c_char,
54    ) -> libc::c_int;
55
56    fn posix_spawn_file_actions_addinherit_np(
57        file_actions: *mut libc::posix_spawn_file_actions_t,
58        filedes: libc::c_int,
59    ) -> libc::c_int;
60
61    static environ: *const *mut libc::c_char;
62}
63
64#[derive(Debug)]
65pub struct Command {
66    program: OsString,
67    args: Vec<OsString>,
68    envs: BTreeMap<OsString, Option<OsString>>,
69    env_clear: bool,
70    current_dir: Option<PathBuf>,
71    stdin_cfg: Option<Stdio>,
72    stdout_cfg: Option<Stdio>,
73    stderr_cfg: Option<Stdio>,
74    kill_on_drop: bool,
75}
76
77impl Command {
78    pub fn new(program: impl AsRef<OsStr>) -> Self {
79        Self {
80            program: program.as_ref().to_owned(),
81            args: Vec::new(),
82            envs: BTreeMap::new(),
83            env_clear: false,
84            current_dir: None,
85            stdin_cfg: None,
86            stdout_cfg: None,
87            stderr_cfg: None,
88            kill_on_drop: false,
89        }
90    }
91
92    pub fn arg(&mut self, arg: impl AsRef<OsStr>) -> &mut Self {
93        self.args.push(arg.as_ref().to_owned());
94        self
95    }
96
97    pub fn args<I, S>(&mut self, args: I) -> &mut Self
98    where
99        I: IntoIterator<Item = S>,
100        S: AsRef<OsStr>,
101    {
102        self.args
103            .extend(args.into_iter().map(|a| a.as_ref().to_owned()));
104        self
105    }
106
107    pub fn get_args(&self) -> impl Iterator<Item = &OsStr> {
108        self.args.iter().map(|s| s.as_os_str())
109    }
110
111    pub fn env(&mut self, key: impl AsRef<OsStr>, val: impl AsRef<OsStr>) -> &mut Self {
112        self.envs
113            .insert(key.as_ref().to_owned(), Some(val.as_ref().to_owned()));
114        self
115    }
116
117    pub fn envs<I, K, V>(&mut self, vars: I) -> &mut Self
118    where
119        I: IntoIterator<Item = (K, V)>,
120        K: AsRef<OsStr>,
121        V: AsRef<OsStr>,
122    {
123        for (key, val) in vars {
124            self.envs
125                .insert(key.as_ref().to_owned(), Some(val.as_ref().to_owned()));
126        }
127        self
128    }
129
130    pub fn env_remove(&mut self, key: impl AsRef<OsStr>) -> &mut Self {
131        let key = key.as_ref().to_owned();
132        if self.env_clear {
133            self.envs.remove(&key);
134        } else {
135            self.envs.insert(key, None);
136        }
137        self
138    }
139
140    pub fn env_clear(&mut self) -> &mut Self {
141        self.env_clear = true;
142        self.envs.clear();
143        self
144    }
145
146    pub fn current_dir(&mut self, dir: impl AsRef<Path>) -> &mut Self {
147        self.current_dir = Some(dir.as_ref().to_owned());
148        self
149    }
150
151    pub fn stdin(&mut self, cfg: Stdio) -> &mut Self {
152        self.stdin_cfg = Some(cfg);
153        self
154    }
155
156    pub fn stdout(&mut self, cfg: Stdio) -> &mut Self {
157        self.stdout_cfg = Some(cfg);
158        self
159    }
160
161    pub fn stderr(&mut self, cfg: Stdio) -> &mut Self {
162        self.stderr_cfg = Some(cfg);
163        self
164    }
165
166    pub fn kill_on_drop(&mut self, kill_on_drop: bool) -> &mut Self {
167        self.kill_on_drop = kill_on_drop;
168        self
169    }
170
171    pub fn spawn(&mut self) -> io::Result<Child> {
172        let current_dir = self
173            .current_dir
174            .as_deref()
175            .unwrap_or_else(|| Path::new("."));
176
177        // Optimization: if no environment modifications were requested, pass None
178        // to spawn_posix so it uses the `environ` global directly, avoiding a
179        // full copy of the environment. This matches std::process::Command behavior.
180        let envs = if self.env_clear || !self.envs.is_empty() {
181            let mut result = BTreeMap::<OsString, OsString>::new();
182            if !self.env_clear {
183                for (key, val) in std::env::vars_os() {
184                    result.insert(key, val);
185                }
186            }
187            for (key, maybe_val) in &self.envs {
188                if let Some(val) = maybe_val {
189                    result.insert(key.clone(), val.clone());
190                } else {
191                    result.remove(key);
192                }
193            }
194            Some(result.into_iter().collect::<Vec<_>>())
195        } else {
196            None
197        };
198
199        spawn_posix_spawn(
200            &self.program,
201            &self.args,
202            current_dir,
203            envs.as_deref(),
204            self.stdin_cfg.unwrap_or_default(),
205            self.stdout_cfg.unwrap_or_default(),
206            self.stderr_cfg.unwrap_or_default(),
207            self.kill_on_drop,
208        )
209    }
210
211    pub async fn output(&mut self) -> io::Result<Output> {
212        self.stdin_cfg.get_or_insert(Stdio::null());
213        self.stdout_cfg.get_or_insert(Stdio::piped());
214        self.stderr_cfg.get_or_insert(Stdio::piped());
215
216        let child = self.spawn()?;
217        child.output().await
218    }
219
220    pub async fn status(&mut self) -> io::Result<ExitStatus> {
221        let mut child = self.spawn()?;
222        child.status().await
223    }
224
225    pub fn get_program(&self) -> &OsStr {
226        self.program.as_os_str()
227    }
228}
229
230#[derive(Debug)]
231pub struct Child {
232    inner: smol::process::Child,
233    pub stdin: Option<Async<std::fs::File>>,
234    pub stdout: Option<Async<std::fs::File>>,
235    pub stderr: Option<Async<std::fs::File>>,
236}
237
238impl Child {
239    pub fn id(&self) -> u32 {
240        self.inner.id()
241    }
242
243    pub fn kill(&mut self) -> io::Result<()> {
244        self.inner.kill()
245    }
246
247    pub fn try_status(&mut self) -> io::Result<Option<ExitStatus>> {
248        self.inner.try_status()
249    }
250
251    pub fn status(
252        &mut self,
253    ) -> impl std::future::Future<Output = io::Result<ExitStatus>> + Send + 'static {
254        self.stdin.take();
255        self.inner.status()
256    }
257
258    pub async fn output(mut self) -> io::Result<Output> {
259        use futures_lite::AsyncReadExt;
260
261        let status = self.status();
262
263        let stdout = self.stdout.take();
264        let stdout_future = async move {
265            let mut data = Vec::new();
266            if let Some(mut stdout) = stdout {
267                stdout.read_to_end(&mut data).await?;
268            }
269            io::Result::Ok(data)
270        };
271
272        let stderr = self.stderr.take();
273        let stderr_future = async move {
274            let mut data = Vec::new();
275            if let Some(mut stderr) = stderr {
276                stderr.read_to_end(&mut data).await?;
277            }
278            io::Result::Ok(data)
279        };
280
281        let (stdout_data, stderr_data) =
282            futures_lite::future::try_zip(stdout_future, stderr_future).await?;
283        let status = status.await?;
284
285        Ok(Output {
286            status,
287            stdout: stdout_data,
288            stderr: stderr_data,
289        })
290    }
291}
292
293fn spawn_posix_spawn(
294    program: &OsStr,
295    args: &[OsString],
296    current_dir: &Path,
297    envs: Option<&[(OsString, OsString)]>,
298    stdin_cfg: Stdio,
299    stdout_cfg: Stdio,
300    stderr_cfg: Stdio,
301    kill_on_drop: bool,
302) -> io::Result<Child> {
303    // posix_spawnp resolves programs against the parent's cwd/PATH, not the child's.
304    let resolved_program = if program.as_bytes().contains(&b'/') {
305        std::path::absolute(current_dir.join(program)).map_or_else(
306            |_| program.as_bytes().to_vec(),
307            |p| p.into_os_string().into_vec(),
308        )
309    } else {
310        envs.and_then(|e| {
311            e.iter()
312                .find(|(k, _)| k.as_os_str() == OsStr::new("PATH"))
313                .and_then(|(_, v)| which::which_in(program, Some(v.as_os_str()), current_dir).ok())
314        })
315        .map_or_else(
316            || program.as_bytes().to_vec(),
317            |path| path.into_os_string().into_vec(),
318        )
319    };
320    let program_cstr = CString::new(resolved_program).map_err(|_| invalid_input_error())?;
321    let argv0_cstr = CString::new(program.as_bytes()).map_err(|_| invalid_input_error())?;
322
323    let current_dir_cstr =
324        CString::new(current_dir.as_os_str().as_bytes()).map_err(|_| invalid_input_error())?;
325
326    let mut argv_cstrs = vec![argv0_cstr];
327    for arg in args {
328        let cstr = CString::new(arg.as_bytes()).map_err(|_| invalid_input_error())?;
329        argv_cstrs.push(cstr);
330    }
331    let mut argv_ptrs: Vec<*mut libc::c_char> = argv_cstrs
332        .iter()
333        .map(|s| s.as_ptr() as *mut libc::c_char)
334        .collect();
335    argv_ptrs.push(ptr::null_mut());
336
337    let envp: Vec<CString> = if let Some(envs) = envs {
338        envs.iter()
339            .map(|(key, value)| {
340                let mut env_str = key.as_bytes().to_vec();
341                env_str.push(b'=');
342                env_str.extend_from_slice(value.as_bytes());
343                CString::new(env_str)
344            })
345            .collect::<Result<Vec<_>, _>>()
346            .map_err(|_| invalid_input_error())?
347    } else {
348        Vec::new()
349    };
350    let mut envp_ptrs: Vec<*mut libc::c_char> = envp
351        .iter()
352        .map(|s| s.as_ptr() as *mut libc::c_char)
353        .collect();
354    envp_ptrs.push(ptr::null_mut());
355
356    let (stdin_read, stdin_write) = match stdin_cfg {
357        Stdio::Piped => {
358            let (r, w) = create_pipe()?;
359            (Some(r), Some(w))
360        }
361        Stdio::Null => {
362            let fd = open_dev_null(libc::O_RDONLY)?;
363            (Some(fd), None)
364        }
365        Stdio::Inherit => (None, None),
366    };
367
368    let (stdout_read, stdout_write) = match stdout_cfg {
369        Stdio::Piped => {
370            let (r, w) = create_pipe()?;
371            (Some(r), Some(w))
372        }
373        Stdio::Null => {
374            let fd = open_dev_null(libc::O_WRONLY)?;
375            (None, Some(fd))
376        }
377        Stdio::Inherit => (None, None),
378    };
379
380    let (stderr_read, stderr_write) = match stderr_cfg {
381        Stdio::Piped => {
382            let (r, w) = create_pipe()?;
383            (Some(r), Some(w))
384        }
385        Stdio::Null => {
386            let fd = open_dev_null(libc::O_WRONLY)?;
387            (None, Some(fd))
388        }
389        Stdio::Inherit => (None, None),
390    };
391
392    let mut attr: libc::posix_spawnattr_t = ptr::null_mut();
393    let mut file_actions: libc::posix_spawn_file_actions_t = ptr::null_mut();
394
395    unsafe {
396        cvt_nz(libc::posix_spawnattr_init(&mut attr))?;
397        cvt_nz(libc::posix_spawn_file_actions_init(&mut file_actions))?;
398
399        // The Rust runtime sets SIGPIPE to SIG_IGN before `main`, and ignored
400        // dispositions survive exec, so without this children would never die
401        // from writing to a closed pipe. Reset it to SIG_DFL, like std does
402        // (rust-lang/rust#101077). Like std, we don't touch the signal mask,
403        // so deliberately blocked signals (e.g. via `nohup`) stay blocked.
404        let mut default_set: libc::sigset_t = std::mem::zeroed();
405        if libc::sigemptyset(&mut default_set) == -1 {
406            return Err(io::Error::last_os_error());
407        }
408        if libc::sigaddset(&mut default_set, libc::SIGPIPE) == -1 {
409            return Err(io::Error::last_os_error());
410        }
411        cvt_nz(libc::posix_spawnattr_setsigdefault(&mut attr, &default_set))?;
412
413        cvt_nz(libc::posix_spawnattr_setflags(
414            &mut attr,
415            (libc::POSIX_SPAWN_CLOEXEC_DEFAULT | libc::POSIX_SPAWN_SETSIGDEF) as libc::c_short,
416        ))?;
417
418        cvt_nz(posix_spawnattr_setexceptionports_np(
419            &mut attr,
420            EXC_MASK_ALL,
421            MACH_PORT_NULL,
422            EXCEPTION_DEFAULT as exception_behavior_t,
423            THREAD_STATE_NONE,
424        ))?;
425
426        cvt_nz(posix_spawn_file_actions_addchdir_np(
427            &mut file_actions,
428            current_dir_cstr.as_ptr(),
429        ))?;
430
431        // With POSIX_SPAWN_CLOEXEC_DEFAULT, any fd without a file action is
432        // closed in the child, so inheriting a stdio fd requires an explicit
433        // addinherit_np action; without one the child's fd 0/1/2 would start
434        // out closed and could be silently reused by its first open().
435        if let Some(fd) = &stdin_read {
436            cvt_nz(libc::posix_spawn_file_actions_adddup2(
437                &mut file_actions,
438                fd.as_raw_fd(),
439                libc::STDIN_FILENO,
440            ))?;
441        }
442        if stdin_read.is_some() || stdin_cfg == Stdio::Inherit {
443            cvt_nz(posix_spawn_file_actions_addinherit_np(
444                &mut file_actions,
445                libc::STDIN_FILENO,
446            ))?;
447        }
448
449        if let Some(fd) = &stdout_write {
450            cvt_nz(libc::posix_spawn_file_actions_adddup2(
451                &mut file_actions,
452                fd.as_raw_fd(),
453                libc::STDOUT_FILENO,
454            ))?;
455        }
456        if stdout_write.is_some() || stdout_cfg == Stdio::Inherit {
457            cvt_nz(posix_spawn_file_actions_addinherit_np(
458                &mut file_actions,
459                libc::STDOUT_FILENO,
460            ))?;
461        }
462
463        if let Some(fd) = &stderr_write {
464            cvt_nz(libc::posix_spawn_file_actions_adddup2(
465                &mut file_actions,
466                fd.as_raw_fd(),
467                libc::STDERR_FILENO,
468            ))?;
469        }
470        if stderr_write.is_some() || stderr_cfg == Stdio::Inherit {
471            cvt_nz(posix_spawn_file_actions_addinherit_np(
472                &mut file_actions,
473                libc::STDERR_FILENO,
474            ))?;
475        }
476
477        let mut pid: libc::pid_t = 0;
478
479        let spawn_result = libc::posix_spawnp(
480            &mut pid,
481            program_cstr.as_ptr(),
482            &file_actions,
483            &attr,
484            argv_ptrs.as_ptr(),
485            if envs.is_some() {
486                envp_ptrs.as_ptr()
487            } else {
488                environ
489            },
490        );
491
492        libc::posix_spawnattr_destroy(&mut attr);
493        libc::posix_spawn_file_actions_destroy(&mut file_actions);
494
495        cvt_nz(spawn_result)?;
496
497        let inner = smol::process::Child::adopt_raw_pid(pid as u32, true, kill_on_drop)?;
498
499        Ok(Child {
500            inner,
501            stdin: stdin_write.map(Async::new).transpose()?,
502            stdout: stdout_read.map(Async::new).transpose()?,
503            stderr: stderr_read.map(Async::new).transpose()?,
504        })
505    }
506}
507
508fn create_pipe() -> io::Result<(std::fs::File, std::fs::File)> {
509    let mut fds: [libc::c_int; 2] = [0; 2];
510    unsafe {
511        let result = libc::pipe(fds.as_mut_ptr());
512        if result == -1 {
513            let error = io::Error::last_os_error();
514            return Err(error);
515        }
516
517        // Set close-on-exec on both ends of the pipe.
518        //
519        // Without this, unrelated spawns elsewhere in the process (e.g.
520        // `smol::process` or `async_process`, which on Apple platforms use
521        // `posix_spawn` *without* `POSIX_SPAWN_CLOEXEC_DEFAULT`) would inherit
522        // these file descriptors and keep the pipes open even after we drop our
523        // side.
524        for &fd in &fds {
525            let result = libc::ioctl(fd, libc::FIOCLEX);
526            if result == -1 {
527                let error = io::Error::last_os_error();
528                libc::close(fds[0]);
529                libc::close(fds[1]);
530                return Err(error);
531            }
532        }
533
534        Ok((
535            std::fs::File::from_raw_fd(fds[0]),
536            std::fs::File::from_raw_fd(fds[1]),
537        ))
538    }
539}
540
541fn open_dev_null(flags: libc::c_int) -> io::Result<std::fs::File> {
542    // Set close-on-exec for this pipe, for the same reason as in `create_pipe`.
543    let fd = unsafe {
544        libc::open(
545            c"/dev/null".as_ptr() as *const libc::c_char,
546            flags | libc::O_CLOEXEC,
547        )
548    };
549    if fd == -1 {
550        return Err(io::Error::last_os_error());
551    }
552    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
553}
554
555/// Zero means `Ok()`, all other values are treated as raw OS errors. Does not look at `errno`.
556/// Mirrored after Rust's std `cvt_nz` function.
557fn cvt_nz(error: libc::c_int) -> io::Result<()> {
558    if error == 0 {
559        Ok(())
560    } else {
561        Err(io::Error::from_raw_os_error(error))
562    }
563}
564
565fn invalid_input_error() -> io::Error {
566    io::Error::new(
567        io::ErrorKind::InvalidInput,
568        "invalid argument: path or argument contains null byte",
569    )
570}
571
572#[cfg(test)]
573mod tests {
574    use std::os::unix::process::ExitStatusExt as _;
575
576    use super::*;
577    use futures_lite::AsyncWriteExt;
578
579    // Verifies that pipes returned by `create_pipe` aren't visible to unrelated
580    // child processes spawned via `std::process::Command`. On macOS, `std`
581    // uses `posix_spawn` without `POSIX_SPAWN_CLOEXEC_DEFAULT`, so any
582    // non-CLOEXEC fd in the parent leaks into the child. Without
583    // `FD_CLOEXEC` on our pipe fds, an unrelated spawn (a terminal, the crash
584    // handler, etc.) running concurrently with a piped git child would hold
585    // git's stdin write end open and deadlock the git child on `read()`.
586    #[test]
587    fn test_create_pipe_not_inherited_by_unrelated_spawn() {
588        let (read_file, write_file) = create_pipe().expect("create_pipe failed");
589        let read_fd = read_file.as_raw_fd();
590        let write_fd = write_file.as_raw_fd();
591
592        // Probe with the exact fds returned by `create_pipe` (no dup), since
593        // duping with `F_DUPFD` would lose CLOEXEC and `F_DUPFD_CLOEXEC` would
594        // unconditionally set it, either of which would defeat the test.
595        #[allow(clippy::disallowed_methods)]
596        let output = std::process::Command::new("/bin/sh")
597            .arg("-c")
598            .arg(format!(
599                "for fd in {read_fd} {write_fd}; do \
600                    if [ -e /dev/fd/$fd ]; then \
601                        echo $fd WAS INHERITED; \
602                    else \
603                        echo $fd WAS NOT INHERITED; \
604                    fi; \
605                done; \
606                echo DONE"
607            ))
608            .output()
609            .expect("failed to spawn sh");
610
611        let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
612
613        assert_eq!(
614            stdout,
615            format!("{read_fd} WAS NOT INHERITED\n{write_fd} WAS NOT INHERITED\nDONE\n")
616        );
617    }
618
619    fn wait_until_gone(pid: u32) {
620        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
621        loop {
622            let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
623            if result == -1 && io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) {
624                return;
625            }
626            assert!(
627                std::time::Instant::now() < deadline,
628                "process {pid} was not reaped"
629            );
630            std::thread::sleep(std::time::Duration::from_millis(10));
631        }
632    }
633
634    #[test]
635    fn test_drop_reaps_child() {
636        smol::block_on(async {
637            let child = Command::new("/usr/bin/true")
638                .spawn()
639                .expect("failed to spawn command");
640            let pid = child.id();
641            drop(child);
642            wait_until_gone(pid);
643        });
644    }
645
646    #[test]
647    fn test_kill_on_drop_kills_and_reaps_child() {
648        smol::block_on(async {
649            let child = Command::new("/bin/sleep")
650                .arg("60")
651                .kill_on_drop(true)
652                .spawn()
653                .expect("failed to spawn command");
654            let pid = child.id();
655            drop(child);
656            wait_until_gone(pid);
657        });
658    }
659
660    #[test]
661    fn test_spawn_echo() {
662        smol::block_on(async {
663            let output = Command::new("/bin/echo")
664                .args(["-n", "hello world"])
665                .output()
666                .await
667                .expect("failed to run command");
668
669            assert!(output.status.success());
670            assert_eq!(output.stdout, b"hello world");
671        });
672    }
673
674    #[test]
675    fn test_spawn_cat_stdin() {
676        smol::block_on(async {
677            let mut child = Command::new("/bin/cat")
678                .stdin(Stdio::piped())
679                .stdout(Stdio::piped())
680                .spawn()
681                .expect("failed to spawn");
682
683            if let Some(ref mut stdin) = child.stdin {
684                stdin
685                    .write_all(b"hello from stdin")
686                    .await
687                    .expect("failed to write");
688                stdin.close().await.expect("failed to close");
689            }
690            drop(child.stdin.take());
691
692            let output = child.output().await.expect("failed to get output");
693            assert!(output.status.success());
694            assert_eq!(output.stdout, b"hello from stdin");
695        });
696    }
697
698    #[test]
699    fn test_spawn_stderr() {
700        smol::block_on(async {
701            let output = Command::new("/bin/sh")
702                .args(["-c", "echo error >&2"])
703                .output()
704                .await
705                .expect("failed to run command");
706
707            assert!(output.status.success());
708            assert_eq!(output.stderr, b"error\n");
709        });
710    }
711
712    #[test]
713    fn test_spawn_exit_code() {
714        smol::block_on(async {
715            let output = Command::new("/bin/sh")
716                .args(["-c", "exit 42"])
717                .output()
718                .await
719                .expect("failed to run command");
720
721            assert!(!output.status.success());
722            assert_eq!(output.status.code(), Some(42));
723        });
724    }
725
726    #[test]
727    fn test_spawn_current_dir() {
728        smol::block_on(async {
729            let output = Command::new("/bin/pwd")
730                .current_dir("/tmp")
731                .output()
732                .await
733                .expect("failed to run command");
734
735            assert!(output.status.success());
736            let pwd = String::from_utf8_lossy(&output.stdout);
737            assert!(pwd.trim() == "/tmp" || pwd.trim() == "/private/tmp");
738        });
739    }
740
741    #[test]
742    fn test_spawn_env() {
743        smol::block_on(async {
744            let output = Command::new("/bin/sh")
745                .args(["-c", "echo $MY_TEST_VAR"])
746                .env("MY_TEST_VAR", "test_value")
747                .output()
748                .await
749                .expect("failed to run command");
750
751            assert!(output.status.success());
752            assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "test_value");
753        });
754    }
755
756    #[test]
757    fn test_spawn_status() {
758        smol::block_on(async {
759            let status = Command::new("/usr/bin/true")
760                .status()
761                .await
762                .expect("failed to run command");
763
764            assert!(status.success());
765
766            let status = Command::new("/usr/bin/false")
767                .status()
768                .await
769                .expect("failed to run command");
770
771            assert!(!status.success());
772        });
773    }
774
775    #[test]
776    fn test_env_remove_removes_set_env() {
777        smol::block_on(async {
778            let output = Command::new("/bin/sh")
779                .args(["-c", "echo ${MY_VAR:-unset}"])
780                .env("MY_VAR", "set_value")
781                .env_remove("MY_VAR")
782                .output()
783                .await
784                .expect("failed to run command");
785
786            assert!(output.status.success());
787            assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "unset");
788        });
789    }
790
791    #[test]
792    fn test_env_remove_removes_inherited_env() {
793        smol::block_on(async {
794            // SAFETY: This test is single-threaded and we clean up the var at the end
795            unsafe { std::env::set_var("TEST_INHERITED_VAR", "inherited_value") };
796
797            let output = Command::new("/bin/sh")
798                .args(["-c", "echo ${TEST_INHERITED_VAR:-unset}"])
799                .env_remove("TEST_INHERITED_VAR")
800                .output()
801                .await
802                .expect("failed to run command");
803
804            assert!(output.status.success());
805            assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "unset");
806
807            // SAFETY: Cleaning up test env var
808            unsafe { std::env::remove_var("TEST_INHERITED_VAR") };
809        });
810    }
811
812    #[test]
813    fn test_env_after_env_remove() {
814        smol::block_on(async {
815            let output = Command::new("/bin/sh")
816                .args(["-c", "echo ${MY_VAR:-unset}"])
817                .env_remove("MY_VAR")
818                .env("MY_VAR", "new_value")
819                .output()
820                .await
821                .expect("failed to run command");
822
823            assert!(output.status.success());
824            assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "new_value");
825        });
826    }
827
828    #[test]
829    fn test_env_remove_after_env_clear() {
830        smol::block_on(async {
831            let output = Command::new("/bin/sh")
832                .args(["-c", "echo ${MY_VAR:-unset}"])
833                .env_clear()
834                .env("MY_VAR", "set_value")
835                .env_remove("MY_VAR")
836                .output()
837                .await
838                .expect("failed to run command");
839
840            assert!(output.status.success());
841            assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "unset");
842        });
843    }
844
845    #[test]
846    fn test_stdio_null_stdin() {
847        smol::block_on(async {
848            let child = Command::new("/bin/cat")
849                .stdin(Stdio::null())
850                .stdout(Stdio::piped())
851                .spawn()
852                .expect("failed to spawn");
853
854            let output = child.output().await.expect("failed to get output");
855            assert!(output.status.success());
856            assert!(
857                output.stdout.is_empty(),
858                "stdin from /dev/null should produce no output from cat"
859            );
860        });
861    }
862
863    #[test]
864    fn test_stdio_null_stdout() {
865        smol::block_on(async {
866            let mut child = Command::new("/bin/echo")
867                .args(["hello"])
868                .stdout(Stdio::null())
869                .spawn()
870                .expect("failed to spawn");
871
872            assert!(
873                child.stdout.is_none(),
874                "stdout should be None when Stdio::null() is used"
875            );
876
877            let status = child.status().await.expect("failed to get status");
878            assert!(status.success());
879        });
880    }
881
882    #[test]
883    fn test_stdio_null_stderr() {
884        smol::block_on(async {
885            let mut child = Command::new("/bin/sh")
886                .args(["-c", "echo error >&2"])
887                .stderr(Stdio::null())
888                .spawn()
889                .expect("failed to spawn");
890
891            assert!(
892                child.stderr.is_none(),
893                "stderr should be None when Stdio::null() is used"
894            );
895
896            let status = child.status().await.expect("failed to get status");
897            assert!(status.success());
898        });
899    }
900
901    #[test]
902    fn test_stdio_piped_stdin() {
903        smol::block_on(async {
904            let mut child = Command::new("/bin/cat")
905                .stdin(Stdio::piped())
906                .stdout(Stdio::piped())
907                .spawn()
908                .expect("failed to spawn");
909
910            assert!(
911                child.stdin.is_some(),
912                "stdin should be Some when Stdio::piped() is used"
913            );
914
915            if let Some(ref mut stdin) = child.stdin {
916                stdin
917                    .write_all(b"piped input")
918                    .await
919                    .expect("failed to write");
920                stdin.close().await.expect("failed to close");
921            }
922            drop(child.stdin.take());
923
924            let output = child.output().await.expect("failed to get output");
925            assert!(output.status.success());
926            assert_eq!(output.stdout, b"piped input");
927        });
928    }
929
930    #[test]
931    fn test_bare_program_resolved_via_custom_path() {
932        let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
933
934        let link_path = temp_dir.path().join("zed-test-echo");
935        std::os::unix::fs::symlink("/bin/echo", &link_path).expect("failed to create symlink");
936
937        let custom_path = temp_dir.path().to_string_lossy().into_owned();
938
939        smol::block_on(async {
940            let output = Command::new("zed-test-echo")
941                .args(["-n", "from-custom-path"])
942                .env("PATH", &custom_path)
943                .output()
944                .await
945                .expect("failed to spawn with custom PATH");
946
947            assert!(output.status.success());
948            assert_eq!(output.stdout, b"from-custom-path");
949        });
950    }
951
952    #[test]
953    fn test_bare_program_preserves_argv0_when_resolved_via_custom_path() {
954        let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
955
956        let link_path = temp_dir.path().join("zed-test-sh");
957        std::os::unix::fs::symlink("/bin/sh", &link_path).expect("failed to create symlink");
958
959        let custom_path = temp_dir.path().to_string_lossy().into_owned();
960
961        smol::block_on(async {
962            let output = Command::new("zed-test-sh")
963                .args(["-c", "printf %s \"$0\""])
964                .env("PATH", &custom_path)
965                .output()
966                .await
967                .expect("failed to spawn with custom PATH");
968
969            assert!(output.status.success());
970            assert_eq!(output.stdout, b"zed-test-sh");
971        });
972    }
973
974    #[test]
975    fn test_bare_program_with_custom_path_falls_back_when_not_found() {
976        smol::block_on(async {
977            let result = Command::new("zed-nonexistent-binary-xyz")
978                .env("PATH", "/nonexistent/path")
979                .spawn();
980
981            assert!(result.is_err());
982        });
983    }
984
985    #[test]
986    fn test_bare_program_with_custom_env_no_path_key() {
987        smol::block_on(async {
988            let output = Command::new("echo")
989                .args(["-n", "from-inherited-path"])
990                .env("ZED_TEST_VAR", "test")
991                .output()
992                .await
993                .expect("failed to spawn with custom env but no PATH override");
994
995            assert!(output.status.success());
996            assert_eq!(output.stdout, b"from-inherited-path");
997        });
998    }
999
1000    #[test]
1001    fn test_relative_path_resolved_against_current_dir() {
1002        let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
1003
1004        let link_path = temp_dir.path().join("zed-test-echo");
1005        std::os::unix::fs::symlink("/bin/echo", &link_path).expect("failed to create symlink");
1006
1007        let relative_path = "./zed-test-echo";
1008
1009        smol::block_on(async {
1010            let output = Command::new(relative_path)
1011                .args(["-n", "from-relative-path"])
1012                .current_dir(temp_dir.path())
1013                .env("PATH", "/nonexistent/path")
1014                .output()
1015                .await
1016                .expect("failed to spawn with relative path");
1017
1018            assert!(output.status.success());
1019            assert_eq!(output.stdout, b"from-relative-path");
1020        });
1021    }
1022
1023    #[test]
1024    fn test_absolute_path_passes_through_unchanged() {
1025        smol::block_on(async {
1026            let output = Command::new("/bin/echo")
1027                .args(["-n", "from-absolute-path"])
1028                .env("PATH", "/nonexistent/path")
1029                .output()
1030                .await
1031                .expect("failed to spawn with absolute path");
1032
1033            assert!(output.status.success());
1034            assert_eq!(output.stdout, b"from-absolute-path");
1035        });
1036    }
1037
1038    #[test]
1039    fn test_stdio_inherit_keeps_stdio_open() {
1040        smol::block_on(async {
1041            let status = Command::new("/bin/sh")
1042                .args([
1043                    "-c",
1044                    "[ -e /dev/fd/0 ] && [ -e /dev/fd/1 ] && [ -e /dev/fd/2 ]",
1045                ])
1046                .stdin(Stdio::inherit())
1047                .stdout(Stdio::inherit())
1048                .stderr(Stdio::inherit())
1049                .status()
1050                .await
1051                .expect("failed to run command");
1052
1053            assert!(
1054                status.success(),
1055                "stdio fds should be open in the child when using Stdio::inherit()"
1056            );
1057        });
1058    }
1059
1060    #[test]
1061    fn test_child_sigpipe_disposition_matches_std() {
1062        const SCRIPT: &str = "kill -s PIPE $$; echo survived";
1063
1064        #[allow(clippy::disallowed_methods)]
1065        let std_output = std::process::Command::new("/bin/sh")
1066            .args(["-c", SCRIPT])
1067            .output()
1068            .expect("failed to run std command");
1069
1070        let our_output = smol::block_on(async {
1071            Command::new("/bin/sh")
1072                .args(["-c", SCRIPT])
1073                .output()
1074                .await
1075                .expect("failed to run command")
1076        });
1077
1078        assert_eq!(
1079            std_output.status.signal(),
1080            Some(libc::SIGPIPE),
1081            "expected std to reset SIGPIPE to SIG_DFL in children; did its default change?"
1082        );
1083        assert_eq!(
1084            our_output.status.signal(),
1085            std_output.status.signal(),
1086            "child SIGPIPE disposition diverges from std::process::Command"
1087        );
1088        assert_eq!(our_output.stdout, std_output.stdout);
1089    }
1090
1091    #[test]
1092    fn test_stdio_fds_closed_on_error() {
1093        fn count_open_fds() -> usize {
1094            let limit = unsafe { libc::getdtablesize() };
1095            (0..limit)
1096                .filter(|&fd| unsafe { libc::fcntl(fd, libc::F_GETFD) } != -1)
1097                .count()
1098        }
1099
1100        const ATTEMPTS: usize = 100;
1101        const SLACK: usize = 32;
1102
1103        let before = count_open_fds();
1104        for _ in 0..ATTEMPTS {
1105            Command::new("/bin/binarythatdoesnotexist")
1106                .stdin(Stdio::piped())
1107                .stdout(Stdio::piped())
1108                .stderr(Stdio::piped())
1109                .spawn()
1110                .expect_err("spawn should fail for a nonexistent binary");
1111        }
1112        let after = count_open_fds();
1113
1114        assert!(
1115            after <= before + SLACK,
1116            "fd leak detected: {before} open fds before, {after} after {ATTEMPTS} failed spawns"
1117        );
1118    }
1119}
1120
Served at tenant.openagents/omega Member data and write actions are omitted.