Skip to main content

microsandbox_agentd/
session.rs

1//! Exec session management: spawning processes with PTY or pipe I/O.
2
3use std::ffi::{CStr, CString};
4use std::mem::MaybeUninit;
5use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
6use std::process::Stdio;
7use std::{iter, mem, ptr};
8
9use nix::pty;
10use nix::sys::signal::{self, Signal};
11use nix::unistd::Pid;
12use tokio::io::AsyncReadExt;
13use tokio::process::{Child, Command};
14use tokio::sync::mpsc;
15
16use microsandbox_protocol::exec::{ExecFailed, ExecFailureKind, ExecRequest};
17
18use crate::config::SecurityProfile;
19use crate::error::{AgentdError, AgentdResult};
20use crate::rlimit;
21
22//--------------------------------------------------------------------------------------------------
23// Constants
24//--------------------------------------------------------------------------------------------------
25
26const LINUX_CAPABILITY_VERSION_3: u32 = 0x20080522;
27const CAP_SYS_ADMIN: u32 = 21;
28const CAP_WORD_BITS: u32 = 32;
29const PR_CAPBSET_DROP: libc::c_int = 24;
30const PR_CAP_AMBIENT: libc::c_int = 47;
31const PR_CAP_AMBIENT_CLEAR_ALL: libc::c_int = 4;
32const DEFAULT_USER_SPEC: &str = "0:0";
33
34//--------------------------------------------------------------------------------------------------
35// Functions: classify
36//--------------------------------------------------------------------------------------------------
37
38/// Map an `errno` integer to its standard symbolic name. Returns
39/// `None` for unrecognized values; we only enumerate the ones that
40/// can plausibly come out of fork/exec/setrlimit/setuid paths.
41fn errno_name(e: i32) -> Option<&'static str> {
42    match e {
43        libc::E2BIG => Some("E2BIG"),
44        libc::EACCES => Some("EACCES"),
45        libc::EAGAIN => Some("EAGAIN"),
46        libc::EBUSY => Some("EBUSY"),
47        libc::EFAULT => Some("EFAULT"),
48        libc::EINVAL => Some("EINVAL"),
49        libc::EIO => Some("EIO"),
50        libc::EISDIR => Some("EISDIR"),
51        libc::ELOOP => Some("ELOOP"),
52        libc::EMFILE => Some("EMFILE"),
53        libc::ENAMETOOLONG => Some("ENAMETOOLONG"),
54        libc::ENFILE => Some("ENFILE"),
55        libc::ENOENT => Some("ENOENT"),
56        libc::ENOEXEC => Some("ENOEXEC"),
57        libc::ENOMEM => Some("ENOMEM"),
58        libc::ENOSYS => Some("ENOSYS"),
59        libc::ENOTDIR => Some("ENOTDIR"),
60        libc::ENXIO => Some("ENXIO"),
61        libc::EPERM => Some("EPERM"),
62        libc::ETXTBSY => Some("ETXTBSY"),
63        _ => None,
64    }
65}
66
67/// Classify a fork/exec-time `errno` into one of the
68/// `ExecFailureKind` buckets.
69///
70/// ENOENT is ambiguous in principle (missing binary vs. missing
71/// cwd), but in practice it's overwhelmingly the binary — the cwd
72/// is set in `pre_exec` *before* execvp, and a bad cwd would more
73/// commonly produce ENOTDIR (path component isn't a directory) or
74/// EACCES (no permission to chdir). We classify ENOENT as
75/// `NotFound` and ENOTDIR as `BadCwd`. Edge cases of "bad cwd that
76/// happens to ENOENT" fall through with the message "spawn 'cmd':
77/// No such file or directory" which is still understandable.
78fn classify_spawn_errno(errno: i32) -> ExecFailureKind {
79    match errno {
80        libc::ENOENT => ExecFailureKind::NotFound,
81        libc::ENOTDIR => ExecFailureKind::BadCwd,
82        libc::EACCES | libc::EPERM => ExecFailureKind::PermissionDenied,
83        libc::ENOEXEC => ExecFailureKind::NotExecutable,
84        libc::EISDIR => ExecFailureKind::NotExecutable,
85        libc::ETXTBSY => ExecFailureKind::NotExecutable,
86        libc::E2BIG | libc::ELOOP | libc::ENAMETOOLONG | libc::EFAULT => ExecFailureKind::BadArgs,
87        libc::EMFILE | libc::ENFILE => ExecFailureKind::ResourceLimit,
88        libc::EAGAIN => ExecFailureKind::ResourceLimit,
89        libc::ENOMEM => ExecFailureKind::OutOfMemory,
90        libc::EINVAL => ExecFailureKind::Other,
91        _ => ExecFailureKind::Other,
92    }
93}
94
95/// Build a `ExecFailed` payload from a spawn-time `io::Error`.
96fn exec_failed_from_io_error(err: &std::io::Error, cmd: &str, stage: &str) -> ExecFailed {
97    let errno = err.raw_os_error();
98    let kind = errno
99        .map(classify_spawn_errno)
100        .unwrap_or(ExecFailureKind::Other);
101    let errno_name = errno.and_then(errno_name).map(str::to_string);
102    let message = format!("spawn {cmd:?}: {err}");
103    ExecFailed {
104        kind,
105        errno,
106        errno_name,
107        message,
108        stage: Some(stage.to_string()),
109    }
110}
111
112//--------------------------------------------------------------------------------------------------
113// Types
114//--------------------------------------------------------------------------------------------------
115
116/// An active exec session handle for sending input to a running process.
117///
118/// Output reading is handled by a background task that sends events
119/// via the `mpsc` channel provided at spawn time.
120#[derive(Debug)]
121pub struct ExecSession {
122    /// The PID of the spawned process.
123    pid: i32,
124
125    /// The PTY master fd (only for PTY mode, used for writing and resize).
126    pty_master: Option<OwnedFd>,
127
128    /// The child's stdin (only for pipe mode).
129    stdin: Option<tokio::process::ChildStdin>,
130}
131
132/// Output from a session that the agent loop should forward to the host.
133pub enum SessionOutput {
134    /// Data from stdout (or PTY master).
135    Stdout(Vec<u8>),
136
137    /// Data from stderr (pipe mode only).
138    Stderr(Vec<u8>),
139
140    /// The process has exited with the given code.
141    Exited(i32),
142
143    /// Pre-encoded frame bytes to write directly to the serial output buffer.
144    Raw(RawSessionOutput),
145}
146
147/// Pre-encoded session output plus the accounting metadata known by its producer.
148pub struct RawSessionOutput {
149    /// Encoded protocol frame bytes.
150    pub frame: Vec<u8>,
151
152    /// Activity represented by the frame.
153    pub activity: RawActivity,
154
155    /// Session table entry completed by the frame, if any.
156    pub completion: Option<RawSessionCompletion>,
157}
158
159/// Activity represented by a pre-encoded session frame.
160#[derive(Debug, Clone, Copy, Default)]
161pub struct RawActivity {
162    /// Whether this frame is a meaningful guest-to-host protocol message.
163    pub guest_message: bool,
164
165    /// Filesystem bytes moved by this frame.
166    pub fs_bytes: usize,
167
168    /// TCP bytes moved by this frame.
169    pub tcp_bytes: usize,
170}
171
172/// Session table entry completed by a pre-encoded session frame.
173#[derive(Debug, Clone, Copy)]
174pub enum RawSessionCompletion {
175    /// A filesystem read stream completed.
176    FsRead,
177
178    /// A TCP stream completed.
179    Tcp,
180}
181
182struct ResolvedUser {
183    uid: libc::uid_t,
184    gid: libc::gid_t,
185    initgroups_user: Option<CString>,
186    home_dir: Option<CString>,
187}
188
189struct PasswdEntry {
190    name: String,
191    uid: libc::uid_t,
192    gid: libc::gid_t,
193    home_dir: Option<String>,
194}
195
196struct GroupEntry {
197    gid: libc::gid_t,
198}
199
200struct ExecErrorPipe {
201    read_end: OwnedFd,
202    write_end: OwnedFd,
203}
204
205#[repr(C)]
206#[derive(Clone, Copy)]
207struct CapUserHeader {
208    version: u32,
209    pid: libc::c_int,
210}
211
212#[repr(C)]
213#[derive(Clone, Copy)]
214struct CapUserData {
215    effective: u32,
216    permitted: u32,
217    inheritable: u32,
218}
219
220//--------------------------------------------------------------------------------------------------
221// Methods
222//--------------------------------------------------------------------------------------------------
223
224impl RawSessionOutput {
225    /// Creates pre-encoded output with activity metadata.
226    pub fn new(
227        frame: Vec<u8>,
228        activity: RawActivity,
229        completion: Option<RawSessionCompletion>,
230    ) -> Self {
231        Self {
232            frame,
233            activity,
234            completion,
235        }
236    }
237}
238
239impl RawActivity {
240    /// A guest-to-host frame with no byte counter.
241    pub fn guest_message() -> Self {
242        Self {
243            guest_message: true,
244            ..Self::default()
245        }
246    }
247
248    /// A guest-to-host filesystem data frame.
249    pub fn fs_bytes(len: usize) -> Self {
250        Self {
251            guest_message: true,
252            fs_bytes: len,
253            tcp_bytes: 0,
254        }
255    }
256
257    /// A guest-to-host TCP data frame.
258    pub fn tcp_bytes(len: usize) -> Self {
259        Self {
260            guest_message: true,
261            fs_bytes: 0,
262            tcp_bytes: len,
263        }
264    }
265}
266
267impl ExecSession {
268    /// Spawns a new exec session.
269    ///
270    /// If `req.tty` is true, uses a PTY. Otherwise, uses piped stdin/stdout/stderr.
271    /// A background task is spawned to read output and send events via `tx`.
272    pub fn spawn(
273        id: u32,
274        req: &ExecRequest,
275        tx: mpsc::UnboundedSender<(u32, SessionOutput)>,
276        default_user: Option<&str>,
277        security_profile: SecurityProfile,
278    ) -> AgentdResult<Self> {
279        if req.tty {
280            Self::spawn_pty(id, req, tx, default_user, security_profile)
281        } else {
282            Self::spawn_pipe(id, req, tx, default_user, security_profile)
283        }
284    }
285
286    /// Returns the PID of the spawned process (as u32 for the protocol).
287    pub fn pid(&self) -> u32 {
288        self.pid as u32
289    }
290
291    /// Writes data to the process's stdin (or PTY master).
292    pub async fn write_stdin(&self, data: &[u8]) -> AgentdResult<()> {
293        if let Some(ref master) = self.pty_master {
294            blocking_write_fd(master.as_raw_fd(), data).await
295        } else if let Some(ref stdin) = self.stdin {
296            blocking_write_fd(stdin.as_raw_fd(), data).await
297        } else {
298            Ok(())
299        }
300    }
301
302    /// Resizes the PTY (only applicable for TTY sessions).
303    pub fn resize(&self, rows: u16, cols: u16) -> AgentdResult<()> {
304        if let Some(ref master) = self.pty_master {
305            let ws = libc::winsize {
306                ws_row: rows,
307                ws_col: cols,
308                ws_xpixel: 0,
309                ws_ypixel: 0,
310            };
311            let ret = unsafe { libc::ioctl(master.as_raw_fd(), libc::TIOCSWINSZ, &ws) };
312            if ret < 0 {
313                return Err(std::io::Error::last_os_error().into());
314            }
315        }
316        Ok(())
317    }
318
319    /// Sends a signal to the spawned process.
320    pub fn send_signal(&self, signum: i32) -> AgentdResult<()> {
321        let sig = Signal::try_from(signum)
322            .map_err(|e| AgentdError::ExecSession(format!("invalid signal {signum}: {e}")))?;
323        signal::kill(Pid::from_raw(self.pid), sig)?;
324        Ok(())
325    }
326
327    /// Closes the process's stdin.
328    ///
329    /// For pipe mode, drops the `ChildStdin` handle which closes the fd.
330    /// For PTY mode, this is a no-op (the PTY master stays open for output).
331    pub fn close_stdin(&mut self) {
332        self.stdin.take();
333    }
334}
335
336impl ExecSession {
337    /// Spawns a process with a PTY.
338    fn spawn_pty(
339        id: u32,
340        req: &ExecRequest,
341        tx: mpsc::UnboundedSender<(u32, SessionOutput)>,
342        default_user: Option<&str>,
343        security_profile: SecurityProfile,
344    ) -> AgentdResult<Self> {
345        let pty = pty::openpty(None, None)?;
346        let err_pipe = new_exec_error_pipe()?;
347
348        // Set initial window size.
349        let ws = libc::winsize {
350            ws_row: req.rows,
351            ws_col: req.cols,
352            ws_xpixel: 0,
353            ws_ypixel: 0,
354        };
355        let ret = unsafe { libc::ioctl(pty.master.as_raw_fd(), libc::TIOCSWINSZ, &ws) };
356        if ret < 0 {
357            return Err(std::io::Error::last_os_error().into());
358        }
359
360        let slave_fd = pty.slave.as_raw_fd();
361
362        // Pre-build all strings before fork to avoid allocating in the child.
363        let c_cmd = CString::new(req.cmd.as_str())
364            .map_err(|e| AgentdError::ExecSession(format!("invalid command: {e}")))?;
365        let mut c_args: Vec<CString> = vec![c_cmd.clone()];
366        for arg in &req.args {
367            c_args.push(
368                CString::new(arg.as_str())
369                    .map_err(|e| AgentdError::ExecSession(format!("invalid arg: {e}")))?,
370            );
371        }
372
373        // Build argv pointer array (null-terminated).
374        let argv_ptrs: Vec<*const libc::c_char> = c_args
375            .iter()
376            .map(|s| s.as_ptr())
377            .chain(iter::once(ptr::null()))
378            .collect();
379
380        // Pre-parse environment variables into CStrings.
381        let c_env: Vec<(CString, CString)> = req
382            .env
383            .iter()
384            .filter_map(|var| {
385                let (key, val) = var.split_once('=')?;
386                let k = CString::new(key).ok()?;
387                let v = CString::new(val).ok()?;
388                Some((k, v))
389            })
390            .collect();
391
392        // Pre-build cwd CString.
393        let c_cwd = req
394            .cwd
395            .as_ref()
396            .map(|dir| CString::new(dir.as_str()))
397            .transpose()
398            .map_err(|e| AgentdError::ExecSession(format!("invalid cwd: {e}")))?;
399
400        let resolved_user = resolve_requested_user(req, default_user)?;
401        let default_home = default_home_dir(req, resolved_user.as_ref())?;
402        let home_key = default_home
403            .as_ref()
404            .map(|_| {
405                CString::new("HOME")
406                    .map_err(|e| AgentdError::ExecSession(format!("invalid home env key: {e}")))
407            })
408            .transpose()?;
409
410        // Pre-parse rlimits before fork (no allocations in child).
411        let parsed_rlimits = rlimit::to_libc(&req.rlimits);
412
413        // Fork.
414        let pid = unsafe { libc::fork() };
415        if pid < 0 {
416            let io_err = std::io::Error::last_os_error();
417            return Err(AgentdError::ExecSpawnFailed(exec_failed_from_io_error(
418                &io_err, &req.cmd, "fork",
419            )));
420        }
421
422        #[allow(unreachable_code)]
423        if pid == 0 {
424            // Child process — only async-signal-safe operations from here.
425            drop(pty.master);
426            drop(err_pipe.read_end);
427
428            // Create new session.
429            if unsafe { libc::setsid() } < 0 {
430                unsafe { libc::_exit(1) };
431            }
432
433            // Set controlling terminal.
434            if unsafe { libc::ioctl(slave_fd, libc::TIOCSCTTY, 0) } < 0 {
435                unsafe { libc::_exit(1) };
436            }
437
438            // Dup slave to stdin/stdout/stderr.
439            unsafe {
440                if libc::dup2(slave_fd, 0) < 0 {
441                    libc::_exit(1);
442                }
443                if libc::dup2(slave_fd, 1) < 0 {
444                    libc::_exit(1);
445                }
446                if libc::dup2(slave_fd, 2) < 0 {
447                    libc::_exit(1);
448                }
449                if slave_fd > 2 {
450                    libc::close(slave_fd);
451                }
452            }
453
454            // Set environment variables using pre-built CStrings.
455            for (key, val) in &c_env {
456                unsafe {
457                    libc::setenv(key.as_ptr(), val.as_ptr(), 1);
458                }
459            }
460
461            // Set working directory.
462            if let Some(ref dir) = c_cwd {
463                unsafe {
464                    libc::chdir(dir.as_ptr());
465                }
466            }
467
468            if apply_exec_security_profile(security_profile).is_err() {
469                unsafe { libc::_exit(1) };
470            }
471
472            if let Some(ref user) = resolved_user
473                && apply_resolved_user(user).is_err()
474            {
475                unsafe { libc::_exit(1) };
476            }
477
478            if let (Some(key), Some(home)) = (&home_key, &default_home) {
479                unsafe {
480                    libc::setenv(key.as_ptr(), home.as_ptr(), 1);
481                }
482            }
483
484            // Apply resource limits.
485            for (resource, limit) in &parsed_rlimits {
486                if unsafe { libc::setrlimit(*resource as _, limit) } != 0 {
487                    unsafe { libc::_exit(1) };
488                }
489            }
490
491            // execvp — on success this never returns.
492            unsafe {
493                libc::execvp(argv_ptrs[0], argv_ptrs.as_ptr());
494            }
495
496            // If execvp returns, it failed.
497            write_exec_error_and_exit(err_pipe.write_end.as_raw_fd());
498        }
499
500        // Parent process.
501        drop(pty.slave);
502        drop(err_pipe.write_end);
503
504        if let Some(exec_errno) = read_exec_error(err_pipe.read_end.as_raw_fd())? {
505            let _ = wait_for_exec_failure_child(pid);
506            let io_err = std::io::Error::from_raw_os_error(exec_errno);
507            return Err(AgentdError::ExecSpawnFailed(exec_failed_from_io_error(
508                &io_err, &req.cmd, "execvp",
509            )));
510        }
511
512        // Dup the master fd for the reader task.
513        let reader_fd = unsafe { libc::dup(pty.master.as_raw_fd()) };
514        if reader_fd < 0 {
515            return Err(std::io::Error::last_os_error().into());
516        }
517        let reader_fd = unsafe { OwnedFd::from_raw_fd(reader_fd) };
518
519        // Spawn background reader task.
520        tokio::spawn(pty_reader_task(id, pid, reader_fd, tx));
521
522        Ok(Self {
523            pid,
524            pty_master: Some(pty.master),
525            stdin: None,
526        })
527    }
528
529    /// Spawns a process with piped stdio.
530    fn spawn_pipe(
531        id: u32,
532        req: &ExecRequest,
533        tx: mpsc::UnboundedSender<(u32, SessionOutput)>,
534        default_user: Option<&str>,
535        security_profile: SecurityProfile,
536    ) -> AgentdResult<Self> {
537        let mut cmd = Command::new(&req.cmd);
538        cmd.args(&req.args)
539            .stdin(Stdio::piped())
540            .stdout(Stdio::piped())
541            .stderr(Stdio::piped());
542
543        for var in &req.env {
544            if let Some((key, val)) = var.split_once('=') {
545                cmd.env(key, val);
546            }
547        }
548
549        if let Some(ref dir) = req.cwd {
550            cmd.current_dir(dir);
551        }
552
553        let resolved_user = resolve_requested_user(req, default_user)?;
554        if let Some(home) = default_home_dir(req, resolved_user.as_ref())? {
555            cmd.env("HOME", home.to_string_lossy().into_owned());
556        }
557
558        // Apply the security profile and resource limits in the child before exec.
559        let parsed_rlimits = rlimit::to_libc(&req.rlimits);
560        unsafe {
561            cmd.pre_exec(move || {
562                apply_exec_security_profile(security_profile).map_err(agentd_to_io_error)?;
563                if let Some(ref user) = resolved_user {
564                    apply_resolved_user(user).map_err(agentd_to_io_error)?;
565                }
566                for (resource, limit) in &parsed_rlimits {
567                    if libc::setrlimit(*resource as _, limit) != 0 {
568                        return Err(std::io::Error::last_os_error());
569                    }
570                }
571                Ok(())
572            });
573        }
574
575        let cmd_label = req.cmd.clone();
576        let mut child = cmd.spawn().map_err(|err| {
577            AgentdError::ExecSpawnFailed(exec_failed_from_io_error(
578                &err,
579                &cmd_label,
580                "Command::spawn",
581            ))
582        })?;
583        let pid = child.id().unwrap_or(0) as i32;
584        let stdin = child.stdin.take();
585        let stdout = child.stdout.take();
586        let stderr = child.stderr.take();
587
588        // Spawn background reader task.
589        tokio::spawn(pipe_reader_task(id, child, stdout, stderr, tx));
590
591        Ok(Self {
592            pid,
593            pty_master: None,
594            stdin,
595        })
596    }
597}
598
599//--------------------------------------------------------------------------------------------------
600// Functions
601//--------------------------------------------------------------------------------------------------
602
603fn new_exec_error_pipe() -> AgentdResult<ExecErrorPipe> {
604    let mut fds = [0; 2];
605    let ret = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) };
606    if ret != 0 {
607        return Err(std::io::Error::last_os_error().into());
608    }
609
610    Ok(ExecErrorPipe {
611        read_end: unsafe { OwnedFd::from_raw_fd(fds[0]) },
612        write_end: unsafe { OwnedFd::from_raw_fd(fds[1]) },
613    })
614}
615
616fn write_exec_error_and_exit(err_fd: RawFd) -> ! {
617    let errno = unsafe { *libc::__errno_location() };
618    let bytes = errno.to_ne_bytes();
619    let _ = unsafe { libc::write(err_fd, bytes.as_ptr() as *const libc::c_void, bytes.len()) };
620    unsafe { libc::_exit(127) }
621}
622
623fn read_exec_error(err_fd: RawFd) -> AgentdResult<Option<i32>> {
624    let mut buf = [0u8; mem::size_of::<i32>()];
625    let n = unsafe { libc::read(err_fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
626    if n < 0 {
627        return Err(std::io::Error::last_os_error().into());
628    }
629    if n == 0 {
630        return Ok(None);
631    }
632    if n as usize != buf.len() {
633        return Err(AgentdError::ExecSession(format!(
634            "short exec error report: expected {} bytes, got {n}",
635            buf.len()
636        )));
637    }
638    Ok(Some(i32::from_ne_bytes(buf)))
639}
640
641fn wait_for_exec_failure_child(pid: i32) -> AgentdResult<()> {
642    let ret = unsafe { libc::waitpid(pid, ptr::null_mut(), 0) };
643    if ret < 0 {
644        return Err(std::io::Error::last_os_error().into());
645    }
646    Ok(())
647}
648
649fn apply_exec_security_profile(profile: SecurityProfile) -> AgentdResult<()> {
650    match profile {
651        SecurityProfile::Default => Ok(()),
652        SecurityProfile::Restricted => drop_mount_admin_privileges(),
653    }
654}
655
656fn drop_mount_admin_privileges() -> AgentdResult<()> {
657    if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
658        return Err(std::io::Error::last_os_error().into());
659    }
660
661    let ret = unsafe { libc::prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_CLEAR_ALL, 0, 0, 0) };
662    if ret != 0 {
663        let err = std::io::Error::last_os_error();
664        if err.raw_os_error() != Some(libc::EINVAL) {
665            return Err(err.into());
666        }
667    }
668
669    let mut header = CapUserHeader {
670        version: LINUX_CAPABILITY_VERSION_3,
671        pid: 0,
672    };
673    let mut data = [CapUserData {
674        effective: 0,
675        permitted: 0,
676        inheritable: 0,
677    }; 2];
678
679    if unsafe { libc::syscall(libc::SYS_capget, &mut header, data.as_mut_ptr()) } != 0 {
680        return Err(std::io::Error::last_os_error().into());
681    }
682
683    let index = (CAP_SYS_ADMIN / CAP_WORD_BITS) as usize;
684    let mask = 1u32 << (CAP_SYS_ADMIN % CAP_WORD_BITS);
685    let had_sys_admin = data[index].effective & mask != 0
686        || data[index].permitted & mask != 0
687        || data[index].inheritable & mask != 0;
688
689    if had_sys_admin {
690        data[index].effective &= !mask;
691        data[index].permitted &= !mask;
692        data[index].inheritable &= !mask;
693
694        if unsafe { libc::syscall(libc::SYS_capset, &mut header, data.as_ptr()) } != 0 {
695            return Err(std::io::Error::last_os_error().into());
696        }
697    }
698
699    let ret = unsafe { libc::prctl(PR_CAPBSET_DROP, CAP_SYS_ADMIN, 0, 0, 0) };
700    if ret != 0 {
701        let err = std::io::Error::last_os_error();
702        let errno = err.raw_os_error();
703        // Already-unprivileged callers may also lack CAP_SETPCAP for the bounding-set drop.
704        let already_unprivileged = !had_sys_admin && errno == Some(libc::EPERM);
705        if errno != Some(libc::EINVAL) && !already_unprivileged {
706            return Err(err.into());
707        }
708    }
709
710    Ok(())
711}
712
713pub(crate) fn resolve_default_user(default_user: Option<&str>) -> AgentdResult<(u32, u32)> {
714    let Some(spec) = default_user
715        .map(str::trim)
716        .filter(|value| !value.is_empty())
717    else {
718        return Ok((0, 0));
719    };
720
721    let resolved = resolve_user_spec(spec)?;
722    Ok((resolved.uid, resolved.gid))
723}
724
725fn resolve_requested_user(
726    req: &ExecRequest,
727    default_user: Option<&str>,
728) -> AgentdResult<Option<ResolvedUser>> {
729    let default_user = default_user
730        .map(str::trim)
731        .filter(|value| !value.is_empty());
732    let requested = req
733        .user
734        .as_deref()
735        .map(str::trim)
736        .filter(|value| !value.is_empty())
737        .or(default_user);
738
739    requested.map(resolve_user_spec).transpose()
740}
741
742fn resolve_user_spec(spec: &str) -> AgentdResult<ResolvedUser> {
743    let (user_part, group_part) = match spec.split_once(':') {
744        Some((user, group)) => (user.trim(), Some(group.trim())),
745        None => (spec.trim(), None),
746    };
747
748    if user_part.is_empty() {
749        return Err(AgentdError::ExecSession("user spec has empty user".into()));
750    }
751
752    let passwd = if let Ok(uid) = parse_id(user_part) {
753        lookup_passwd_by_uid(uid)?
754    } else {
755        lookup_passwd_by_name(user_part)?
756            .ok_or_else(|| AgentdError::ExecSession(format!("guest user not found: {user_part}")))?
757            .into()
758    };
759
760    let (uid, passwd_entry) = match passwd {
761        ResolvedUserLookup::Known(entry) => (entry.uid, Some(entry)),
762        ResolvedUserLookup::Numeric(uid) => (uid, None),
763    };
764
765    let gid = match group_part {
766        Some("") => {
767            return Err(AgentdError::ExecSession("user spec has empty group".into()));
768        }
769        Some(group) => resolve_group_spec(group)?,
770        None => passwd_entry
771            .as_ref()
772            .map(|entry| entry.gid)
773            .unwrap_or_else(|| unsafe { libc::getgid() }),
774    };
775
776    let initgroups_user = passwd_entry
777        .as_ref()
778        .map(|entry| CString::new(entry.name.as_str()))
779        .transpose()
780        .map_err(|e| AgentdError::ExecSession(format!("invalid guest user name: {e}")))?;
781
782    Ok(ResolvedUser {
783        uid,
784        gid,
785        initgroups_user,
786        home_dir: passwd_entry
787            .as_ref()
788            .and_then(|entry| entry.home_dir.as_deref())
789            .map(CString::new)
790            .transpose()
791            .map_err(|e| AgentdError::ExecSession(format!("invalid guest home directory: {e}")))?,
792    })
793}
794
795enum ResolvedUserLookup {
796    Known(PasswdEntry),
797    Numeric(libc::uid_t),
798}
799
800impl From<PasswdEntry> for ResolvedUserLookup {
801    fn from(value: PasswdEntry) -> Self {
802        Self::Known(value)
803    }
804}
805
806fn resolve_group_spec(spec: &str) -> AgentdResult<libc::gid_t> {
807    if let Ok(gid) = parse_id(spec) {
808        return Ok(gid);
809    }
810
811    lookup_group_by_name(spec)?
812        .map(|entry| entry.gid)
813        .ok_or_else(|| AgentdError::ExecSession(format!("guest group not found: {spec}")))
814}
815
816fn parse_id(value: &str) -> Result<u32, std::num::ParseIntError> {
817    value.parse::<u32>()
818}
819
820fn lookup_passwd_by_name(name: &str) -> AgentdResult<Option<PasswdEntry>> {
821    let name = CString::new(name)
822        .map_err(|e| AgentdError::ExecSession(format!("invalid guest user name: {e}")))?;
823    let mut pwd = MaybeUninit::<libc::passwd>::uninit();
824    let mut result = ptr::null_mut();
825    let mut buf = vec![0u8; lookup_buffer_len()];
826    let rc = unsafe {
827        libc::getpwnam_r(
828            name.as_ptr(),
829            pwd.as_mut_ptr(),
830            buf.as_mut_ptr().cast(),
831            buf.len(),
832            &mut result,
833        )
834    };
835    if rc != 0 {
836        return Err(AgentdError::ExecSession(format!(
837            "failed to resolve guest user {name:?}: {}",
838            std::io::Error::from_raw_os_error(rc)
839        )));
840    }
841    if result.is_null() {
842        return Ok(None);
843    }
844
845    let pwd = unsafe { pwd.assume_init() };
846    let name = unsafe { CStr::from_ptr(pwd.pw_name) }
847        .to_string_lossy()
848        .into_owned();
849    let home_dir = unsafe { CStr::from_ptr(pwd.pw_dir) }
850        .to_string_lossy()
851        .into_owned();
852    Ok(Some(PasswdEntry {
853        name,
854        uid: pwd.pw_uid,
855        gid: pwd.pw_gid,
856        home_dir: (!home_dir.is_empty()).then_some(home_dir),
857    }))
858}
859
860fn lookup_passwd_by_uid(uid: libc::uid_t) -> AgentdResult<ResolvedUserLookup> {
861    let mut pwd = MaybeUninit::<libc::passwd>::uninit();
862    let mut result = ptr::null_mut();
863    let mut buf = vec![0u8; lookup_buffer_len()];
864    let rc = unsafe {
865        libc::getpwuid_r(
866            uid,
867            pwd.as_mut_ptr(),
868            buf.as_mut_ptr().cast(),
869            buf.len(),
870            &mut result,
871        )
872    };
873    if rc != 0 {
874        return Err(AgentdError::ExecSession(format!(
875            "failed to resolve guest uid {uid}: {}",
876            std::io::Error::from_raw_os_error(rc)
877        )));
878    }
879    if result.is_null() {
880        return Ok(ResolvedUserLookup::Numeric(uid));
881    }
882
883    let pwd = unsafe { pwd.assume_init() };
884    let name = unsafe { CStr::from_ptr(pwd.pw_name) }
885        .to_string_lossy()
886        .into_owned();
887    let home_dir = unsafe { CStr::from_ptr(pwd.pw_dir) }
888        .to_string_lossy()
889        .into_owned();
890    Ok(ResolvedUserLookup::Known(PasswdEntry {
891        name,
892        uid: pwd.pw_uid,
893        gid: pwd.pw_gid,
894        home_dir: (!home_dir.is_empty()).then_some(home_dir),
895    }))
896}
897
898fn lookup_group_by_name(name: &str) -> AgentdResult<Option<GroupEntry>> {
899    let name = CString::new(name)
900        .map_err(|e| AgentdError::ExecSession(format!("invalid guest group name: {e}")))?;
901    let mut grp = MaybeUninit::<libc::group>::uninit();
902    let mut result = ptr::null_mut();
903    let mut buf = vec![0u8; lookup_buffer_len()];
904    let rc = unsafe {
905        libc::getgrnam_r(
906            name.as_ptr(),
907            grp.as_mut_ptr(),
908            buf.as_mut_ptr().cast(),
909            buf.len(),
910            &mut result,
911        )
912    };
913    if rc != 0 {
914        return Err(AgentdError::ExecSession(format!(
915            "failed to resolve guest group {name:?}: {}",
916            std::io::Error::from_raw_os_error(rc)
917        )));
918    }
919    if result.is_null() {
920        return Ok(None);
921    }
922
923    let grp = unsafe { grp.assume_init() };
924    Ok(Some(GroupEntry { gid: grp.gr_gid }))
925}
926
927fn lookup_buffer_len() -> usize {
928    let size = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) };
929    if size > 0 { size as usize } else { 16 * 1024 }
930}
931
932fn apply_resolved_user(user: &ResolvedUser) -> AgentdResult<()> {
933    if let Some(ref name) = user.initgroups_user {
934        if unsafe { libc::initgroups(name.as_ptr(), user.gid) } != 0 {
935            return Err(std::io::Error::last_os_error().into());
936        }
937    } else if unsafe { libc::setgroups(0, ptr::null()) } != 0 {
938        return Err(std::io::Error::last_os_error().into());
939    }
940
941    if unsafe { libc::setgid(user.gid) } != 0 {
942        return Err(std::io::Error::last_os_error().into());
943    }
944    if unsafe { libc::setuid(user.uid) } != 0 {
945        return Err(std::io::Error::last_os_error().into());
946    }
947
948    Ok(())
949}
950
951fn default_home_dir(
952    req: &ExecRequest,
953    user: Option<&ResolvedUser>,
954) -> AgentdResult<Option<CString>> {
955    if env_contains_key(&req.env, "HOME") {
956        return Ok(None);
957    }
958
959    if let Some(user) = user {
960        return Ok(user.home_dir.clone());
961    }
962
963    Ok(resolve_user_spec(DEFAULT_USER_SPEC)?.home_dir)
964}
965
966fn env_contains_key(env: &[String], key: &str) -> bool {
967    env.iter().any(|entry| {
968        entry
969            .split_once('=')
970            .map(|(entry_key, _)| entry_key == key)
971            .unwrap_or(false)
972    })
973}
974
975fn agentd_to_io_error(err: AgentdError) -> std::io::Error {
976    std::io::Error::other(err.to_string())
977}
978
979/// Writes data to a raw fd using a blocking task, handling short writes.
980async fn blocking_write_fd(fd: RawFd, data: &[u8]) -> AgentdResult<()> {
981    let data = data.to_vec();
982    tokio::task::spawn_blocking(move || {
983        let mut written = 0;
984        while written < data.len() {
985            let ptr = unsafe { data.as_ptr().add(written) as *const libc::c_void };
986            let ret = unsafe { libc::write(fd, ptr, data.len() - written) };
987            if ret < 0 {
988                let err = std::io::Error::last_os_error();
989                let code = err.raw_os_error();
990                if code == Some(libc::EAGAIN) || code == Some(libc::EWOULDBLOCK) {
991                    wait_fd_writable(fd)?;
992                    continue;
993                }
994                if code == Some(libc::EINTR) {
995                    continue;
996                }
997                return Err(AgentdError::Io(err));
998            }
999            if ret == 0 {
1000                wait_fd_writable(fd)?;
1001                continue;
1002            }
1003            written += ret as usize;
1004        }
1005        Ok(())
1006    })
1007    .await
1008    .map_err(|e| AgentdError::ExecSession(format!("stdin write join error: {e}")))?
1009}
1010
1011fn wait_fd_writable(fd: RawFd) -> AgentdResult<()> {
1012    let mut pollfd = libc::pollfd {
1013        fd,
1014        events: libc::POLLOUT,
1015        revents: 0,
1016    };
1017
1018    loop {
1019        let ret = unsafe { libc::poll(&mut pollfd, 1, -1) };
1020        if ret < 0 {
1021            let err = std::io::Error::last_os_error();
1022            if err.raw_os_error() == Some(libc::EINTR) {
1023                continue;
1024            }
1025            return Err(AgentdError::Io(err));
1026        }
1027        if ret == 0 {
1028            continue;
1029        }
1030        // Any positive return means the fd is actionable: POLLOUT lets the
1031        // next write make progress, and POLLHUP/POLLERR/POLLNVAL will cause
1032        // the next write to fail with a real errno (typically EPIPE) which
1033        // is more meaningful than poll's revents.
1034        return Ok(());
1035    }
1036}
1037
1038/// Background task that reads from a PTY master fd and sends output events.
1039async fn pty_reader_task(
1040    id: u32,
1041    pid: i32,
1042    master_fd: OwnedFd,
1043    tx: mpsc::UnboundedSender<(u32, SessionOutput)>,
1044) {
1045    let tx_output = tx.clone();
1046    let read_result = tokio::task::spawn_blocking(move || {
1047        // PTY masters are safer with a dedicated blocking read loop than with
1048        // edge-driven readiness. Fast writers followed by process exit can
1049        // strand the tail behind a missed wakeup/HUP transition.
1050        let raw = master_fd.as_raw_fd();
1051        let flags = unsafe { libc::fcntl(raw, libc::F_GETFL) };
1052        if flags >= 0 {
1053            unsafe { libc::fcntl(raw, libc::F_SETFL, flags & !libc::O_NONBLOCK) };
1054        }
1055
1056        loop {
1057            let mut buf = [0u8; 4096];
1058            let n = unsafe { libc::read(raw, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
1059
1060            if n > 0 {
1061                if tx_output
1062                    .send((id, SessionOutput::Stdout(buf[..n as usize].to_vec())))
1063                    .is_err()
1064                {
1065                    break;
1066                }
1067                continue;
1068            }
1069
1070            if n == 0 {
1071                break;
1072            }
1073
1074            let err = std::io::Error::last_os_error();
1075            match err.raw_os_error() {
1076                Some(libc::EINTR) => continue,
1077                Some(libc::EIO) => break,
1078                _ => break,
1079            }
1080        }
1081    })
1082    .await;
1083
1084    let _ = read_result;
1085
1086    let code = wait_for_pid(pid).await;
1087    let _ = tx.send((id, SessionOutput::Exited(code)));
1088}
1089
1090/// Background task that reads from piped stdout/stderr and sends output events.
1091async fn pipe_reader_task(
1092    id: u32,
1093    mut child: Child,
1094    stdout: Option<tokio::process::ChildStdout>,
1095    stderr: Option<tokio::process::ChildStderr>,
1096    tx: mpsc::UnboundedSender<(u32, SessionOutput)>,
1097) {
1098    let mut stdout = stdout;
1099    let mut stderr = stderr;
1100    let mut stdout_eof = stdout.is_none();
1101    let mut stderr_eof = stderr.is_none();
1102
1103    while !stdout_eof || !stderr_eof {
1104        let mut stdout_buf = [0u8; 4096];
1105        let mut stderr_buf = [0u8; 4096];
1106
1107        tokio::select! {
1108            result = async {
1109                match stdout.as_mut() {
1110                    Some(out) => out.read(&mut stdout_buf).await,
1111                    None => std::future::pending().await,
1112                }
1113            }, if !stdout_eof => {
1114                match result {
1115                    Ok(0) | Err(_) => {
1116                        stdout = None;
1117                        stdout_eof = true;
1118                    }
1119                    Ok(n) => {
1120                        let _ = tx.send((id, SessionOutput::Stdout(stdout_buf[..n].to_vec())));
1121                    }
1122                }
1123            }
1124            result = async {
1125                match stderr.as_mut() {
1126                    Some(err) => err.read(&mut stderr_buf).await,
1127                    None => std::future::pending().await,
1128                }
1129            }, if !stderr_eof => {
1130                match result {
1131                    Ok(0) | Err(_) => {
1132                        stderr = None;
1133                        stderr_eof = true;
1134                    }
1135                    Ok(n) => {
1136                        let _ = tx.send((id, SessionOutput::Stderr(stderr_buf[..n].to_vec())));
1137                    }
1138                }
1139            }
1140        }
1141    }
1142
1143    // Both streams are done — wait for process exit.
1144    let code = match child.wait().await {
1145        Ok(status) => status.code().unwrap_or(-1),
1146        Err(_) => -1,
1147    };
1148
1149    let _ = tx.send((id, SessionOutput::Exited(code)));
1150}
1151
1152/// Waits for a process to exit by PID and returns the exit code.
1153async fn wait_for_pid(pid: i32) -> i32 {
1154    tokio::task::spawn_blocking(move || {
1155        let mut status: i32 = 0;
1156        unsafe {
1157            libc::waitpid(pid, &mut status, 0);
1158        }
1159        if libc::WIFEXITED(status) {
1160            libc::WEXITSTATUS(status)
1161        } else {
1162            -1
1163        }
1164    })
1165    .await
1166    .unwrap_or(-1)
1167}
1168
1169//--------------------------------------------------------------------------------------------------
1170// Tests
1171//--------------------------------------------------------------------------------------------------
1172
1173#[cfg(test)]
1174mod tests {
1175    use std::time::Duration;
1176
1177    use tokio::time;
1178
1179    use microsandbox_protocol::exec::ExecRequest;
1180
1181    use super::*;
1182
1183    #[tokio::test]
1184    async fn test_pty_reader_drains_ready_fd() {
1185        let (tx, mut rx) = mpsc::unbounded_channel();
1186        let req = ExecRequest {
1187            cmd: "/bin/sh".to_string(),
1188            args: vec![
1189                "-c".to_string(),
1190                "i=0; while [ $i -lt 256 ]; do printf AAAA; i=$((i+1)); done; printf SECOND; sleep 0.1; printf '<END>\\n'; sleep 0.1; exit 0"
1191                    .to_string(),
1192            ],
1193            env: vec!["PATH=/usr/local/bin:/usr/bin:/bin".to_string()],
1194            cwd: None,
1195            user: None,
1196            tty: true,
1197            rows: 24,
1198            cols: 80,
1199            rlimits: Vec::new(),
1200        };
1201
1202        let session = ExecSession::spawn(7, &req, tx, None, SecurityProfile::Default)
1203            .expect("spawn pty session");
1204        let mut stdout = Vec::new();
1205        let mut exit = None;
1206
1207        let recv_result = time::timeout(Duration::from_secs(15), async {
1208            while let Some((id, output)) = rx.recv().await {
1209                assert_eq!(id, 7);
1210                match output {
1211                    SessionOutput::Stdout(data) => stdout.extend_from_slice(&data),
1212                    SessionOutput::Exited(code) => {
1213                        exit = Some(code);
1214                        break;
1215                    }
1216                    SessionOutput::Stderr(_) | SessionOutput::Raw(_) => {}
1217                }
1218            }
1219        })
1220        .await;
1221
1222        if recv_result.is_err() {
1223            let _ = session.send_signal(libc::SIGKILL);
1224            panic!("timed out waiting for PTY output");
1225        }
1226
1227        assert_eq!(exit, Some(0));
1228
1229        let second = stdout
1230            .windows(b"SECOND".len())
1231            .position(|window| window == b"SECOND");
1232        let end = stdout
1233            .windows(b"<END>".len())
1234            .position(|window| window == b"<END>");
1235
1236        assert!(
1237            matches!((second, end), (Some(second), Some(end)) if second < end),
1238            "expected immediate PTY write to arrive before later output; got {:?}",
1239            String::from_utf8_lossy(&stdout),
1240        );
1241    }
1242
1243    #[test]
1244    fn test_resolve_user_spec_for_current_uid_gid() {
1245        let uid = unsafe { libc::getuid() };
1246        let gid = unsafe { libc::getgid() };
1247        let resolved = resolve_user_spec(&format!("{uid}:{gid}")).expect("resolve numeric user");
1248        assert_eq!(resolved.uid, uid);
1249        assert_eq!(resolved.gid, gid);
1250    }
1251
1252    #[test]
1253    fn test_request_user_overrides_config_default() {
1254        let req = ExecRequest {
1255            cmd: "/bin/true".to_string(),
1256            args: Vec::new(),
1257            env: Vec::new(),
1258            cwd: None,
1259            user: Some("1:1".to_string()),
1260            tty: false,
1261            rows: 24,
1262            cols: 80,
1263            rlimits: Vec::new(),
1264        };
1265
1266        let resolved = resolve_requested_user(&req, Some("0:0")).expect("resolve requested user");
1267        assert_eq!(resolved.unwrap().uid, 1);
1268    }
1269
1270    #[test]
1271    fn test_config_default_user_used_when_request_has_none() {
1272        let req = ExecRequest {
1273            cmd: "/bin/true".to_string(),
1274            args: Vec::new(),
1275            env: Vec::new(),
1276            cwd: None,
1277            user: None,
1278            tty: false,
1279            rows: 24,
1280            cols: 80,
1281            rlimits: Vec::new(),
1282        };
1283
1284        let uid = unsafe { libc::getuid() };
1285        let gid = unsafe { libc::getgid() };
1286        let resolved = resolve_requested_user(&req, Some(&format!("{uid}:{gid}")))
1287            .expect("resolve with config default");
1288        let resolved = resolved.expect("should resolve to a user");
1289        assert_eq!(resolved.uid, uid);
1290        assert_eq!(resolved.gid, gid);
1291    }
1292
1293    #[test]
1294    fn test_request_without_user_does_not_apply_user_switch() {
1295        let req = ExecRequest {
1296            cmd: "/bin/true".to_string(),
1297            args: Vec::new(),
1298            env: Vec::new(),
1299            cwd: None,
1300            user: None,
1301            tty: false,
1302            rows: 24,
1303            cols: 80,
1304            rlimits: Vec::new(),
1305        };
1306
1307        let resolved = resolve_requested_user(&req, None).expect("resolve absent user");
1308        assert!(resolved.is_none());
1309    }
1310
1311    #[test]
1312    fn test_default_user_absent_resolves_to_root() {
1313        let resolved = resolve_default_user(None).expect("resolve absent default user");
1314        assert_eq!(resolved, (0, 0));
1315    }
1316
1317    #[test]
1318    fn test_default_home_dir_uses_resolved_user_home() {
1319        let req = ExecRequest {
1320            cmd: "/bin/true".to_string(),
1321            args: Vec::new(),
1322            env: Vec::new(),
1323            cwd: None,
1324            user: None,
1325            tty: false,
1326            rows: 24,
1327            cols: 80,
1328            rlimits: Vec::new(),
1329        };
1330        let user = ResolvedUser {
1331            uid: 1000,
1332            gid: 1000,
1333            initgroups_user: None,
1334            home_dir: Some(CString::new("/home/tester").unwrap()),
1335        };
1336
1337        assert_eq!(
1338            default_home_dir(&req, Some(&user))
1339                .expect("resolve default home")
1340                .as_deref()
1341                .map(CStr::to_string_lossy),
1342            Some("/home/tester".into()),
1343        );
1344    }
1345
1346    #[test]
1347    fn test_default_home_dir_uses_root_when_user_absent() {
1348        let req = ExecRequest {
1349            cmd: "/bin/true".to_string(),
1350            args: Vec::new(),
1351            env: Vec::new(),
1352            cwd: None,
1353            user: None,
1354            tty: false,
1355            rows: 24,
1356            cols: 80,
1357            rlimits: Vec::new(),
1358        };
1359        let root = resolve_user_spec(DEFAULT_USER_SPEC).expect("resolve implicit root");
1360
1361        assert_eq!(
1362            default_home_dir(&req, None)
1363                .expect("resolve default home")
1364                .as_deref()
1365                .map(CStr::to_string_lossy),
1366            root.home_dir.as_deref().map(CStr::to_string_lossy),
1367        );
1368    }
1369
1370    #[test]
1371    fn test_default_home_dir_respects_explicit_home_env() {
1372        let req = ExecRequest {
1373            cmd: "/bin/true".to_string(),
1374            args: Vec::new(),
1375            env: vec!["HOME=/tmp/custom".to_string()],
1376            cwd: None,
1377            user: None,
1378            tty: false,
1379            rows: 24,
1380            cols: 80,
1381            rlimits: Vec::new(),
1382        };
1383        let user = ResolvedUser {
1384            uid: 1000,
1385            gid: 1000,
1386            initgroups_user: None,
1387            home_dir: Some(CString::new("/home/tester").unwrap()),
1388        };
1389
1390        assert!(
1391            default_home_dir(&req, Some(&user))
1392                .expect("resolve default home")
1393                .is_none()
1394        );
1395    }
1396
1397    #[tokio::test]
1398    async fn test_spawn_pipe_error_does_not_include_probe_details() {
1399        let (tx, _rx) = mpsc::unbounded_channel();
1400        let req = ExecRequest {
1401            cmd: "/definitely/not/a/real/binary".to_string(),
1402            args: Vec::new(),
1403            env: Vec::new(),
1404            cwd: None,
1405            user: None,
1406            tty: false,
1407            rows: 24,
1408            cols: 80,
1409            rlimits: Vec::new(),
1410        };
1411
1412        let err = ExecSession::spawn(9, &req, tx, None, SecurityProfile::Default)
1413            .expect_err("spawn should fail");
1414
1415        // Spawn failures now produce the typed `ExecSpawnFailed` so
1416        // the host can render a useful message + hint. The classifier
1417        // maps ENOENT on the binary path to `NotFound`.
1418        let payload = match &err {
1419            AgentdError::ExecSpawnFailed(p) => p,
1420            other => panic!("expected ExecSpawnFailed, got: {other:?}"),
1421        };
1422        assert_eq!(payload.kind, ExecFailureKind::NotFound);
1423        assert_eq!(payload.errno, Some(libc::ENOENT));
1424        assert_eq!(payload.errno_name.as_deref(), Some("ENOENT"));
1425
1426        // The original intent of the test: probe internals leak into
1427        // the error message. The format is now
1428        // `spawn "<cmd>": <io::Error>` from
1429        // `exec_failed_from_io_error`. Verify that none of the old
1430        // probe-detail keys snuck back into the message.
1431        let message = &payload.message;
1432        assert!(message.contains("spawn"));
1433        assert!(!message.contains("symlink_metadata="));
1434        assert!(!message.contains("metadata="));
1435        assert!(!message.contains("magic="));
1436        assert!(!message.contains("path_probe="));
1437        assert!(!message.contains("cwd_probe="));
1438        assert!(!message.contains("target_probe="));
1439    }
1440}