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 and everything it started.
320    ///
321    /// The child is made a session leader at spawn (both pipe and PTY modes),
322    /// so signalling the negative pid reaches its whole process group. A bare
323    /// kill(pid) here leaked orphans: killing `sh -c "job &"` took out the
324    /// shell while its backgrounded children survived reparented to init,
325    /// silently accumulating load in the guest.
326    pub fn send_signal(&self, signum: i32) -> AgentdResult<()> {
327        let sig = Signal::try_from(signum)
328            .map_err(|e| AgentdError::ExecSession(format!("invalid signal {signum}: {e}")))?;
329        match signal::kill(Pid::from_raw(-self.pid), sig) {
330            // The group can already be gone when the signal races the exit.
331            Err(nix::errno::Errno::ESRCH) => Ok(()),
332            other => Ok(other?),
333        }
334    }
335
336    /// Closes the process's stdin.
337    ///
338    /// For pipe mode, drops the `ChildStdin` handle which closes the fd.
339    /// For PTY mode, this is a no-op (the PTY master stays open for output).
340    pub fn close_stdin(&mut self) {
341        self.stdin.take();
342    }
343}
344
345impl ExecSession {
346    /// Spawns a process with a PTY.
347    fn spawn_pty(
348        id: u32,
349        req: &ExecRequest,
350        tx: mpsc::UnboundedSender<(u32, SessionOutput)>,
351        default_user: Option<&str>,
352        security_profile: SecurityProfile,
353    ) -> AgentdResult<Self> {
354        let pty = pty::openpty(None, None)?;
355        let err_pipe = new_exec_error_pipe()?;
356
357        // Set initial window size.
358        let ws = libc::winsize {
359            ws_row: req.rows,
360            ws_col: req.cols,
361            ws_xpixel: 0,
362            ws_ypixel: 0,
363        };
364        let ret = unsafe { libc::ioctl(pty.master.as_raw_fd(), libc::TIOCSWINSZ, &ws) };
365        if ret < 0 {
366            return Err(std::io::Error::last_os_error().into());
367        }
368
369        let slave_fd = pty.slave.as_raw_fd();
370
371        // Pre-build all strings before fork to avoid allocating in the child.
372        let c_cmd = CString::new(req.cmd.as_str())
373            .map_err(|e| AgentdError::ExecSession(format!("invalid command: {e}")))?;
374        let mut c_args: Vec<CString> = vec![c_cmd.clone()];
375        for arg in &req.args {
376            c_args.push(
377                CString::new(arg.as_str())
378                    .map_err(|e| AgentdError::ExecSession(format!("invalid arg: {e}")))?,
379            );
380        }
381
382        // Build argv pointer array (null-terminated).
383        let argv_ptrs: Vec<*const libc::c_char> = c_args
384            .iter()
385            .map(|s| s.as_ptr())
386            .chain(iter::once(ptr::null()))
387            .collect();
388
389        // Pre-parse environment variables into CStrings.
390        let c_env: Vec<(CString, CString)> = req
391            .env
392            .iter()
393            .filter_map(|var| {
394                let (key, val) = var.split_once('=')?;
395                let k = CString::new(key).ok()?;
396                let v = CString::new(val).ok()?;
397                Some((k, v))
398            })
399            .collect();
400
401        // Pre-build cwd CString.
402        let c_cwd = req
403            .cwd
404            .as_ref()
405            .map(|dir| CString::new(dir.as_str()))
406            .transpose()
407            .map_err(|e| AgentdError::ExecSession(format!("invalid cwd: {e}")))?;
408
409        let resolved_user = resolve_requested_user(req, default_user)?;
410        let default_home = default_home_dir(req, resolved_user.as_ref())?;
411        let home_key = default_home
412            .as_ref()
413            .map(|_| {
414                CString::new("HOME")
415                    .map_err(|e| AgentdError::ExecSession(format!("invalid home env key: {e}")))
416            })
417            .transpose()?;
418
419        // Pre-parse rlimits before fork (no allocations in child).
420        let parsed_rlimits = rlimit::to_libc(&req.rlimits);
421
422        // Fork.
423        let pid = unsafe { libc::fork() };
424        if pid < 0 {
425            let io_err = std::io::Error::last_os_error();
426            return Err(AgentdError::ExecSpawnFailed(exec_failed_from_io_error(
427                &io_err, &req.cmd, "fork",
428            )));
429        }
430
431        #[allow(unreachable_code)]
432        if pid == 0 {
433            // Child process — only async-signal-safe operations from here.
434            drop(pty.master);
435            drop(err_pipe.read_end);
436
437            // Create new session.
438            if unsafe { libc::setsid() } < 0 {
439                unsafe { libc::_exit(1) };
440            }
441
442            // Set controlling terminal.
443            if unsafe { libc::ioctl(slave_fd, libc::TIOCSCTTY, 0) } < 0 {
444                unsafe { libc::_exit(1) };
445            }
446
447            // Dup slave to stdin/stdout/stderr.
448            unsafe {
449                if libc::dup2(slave_fd, 0) < 0 {
450                    libc::_exit(1);
451                }
452                if libc::dup2(slave_fd, 1) < 0 {
453                    libc::_exit(1);
454                }
455                if libc::dup2(slave_fd, 2) < 0 {
456                    libc::_exit(1);
457                }
458                if slave_fd > 2 {
459                    libc::close(slave_fd);
460                }
461            }
462
463            // Set environment variables using pre-built CStrings.
464            for (key, val) in &c_env {
465                unsafe {
466                    libc::setenv(key.as_ptr(), val.as_ptr(), 1);
467                }
468            }
469
470            // Set working directory.
471            if let Some(ref dir) = c_cwd {
472                unsafe {
473                    libc::chdir(dir.as_ptr());
474                }
475            }
476
477            if apply_exec_security_profile(security_profile).is_err() {
478                unsafe { libc::_exit(1) };
479            }
480
481            if let Some(ref user) = resolved_user
482                && apply_resolved_user(user).is_err()
483            {
484                unsafe { libc::_exit(1) };
485            }
486
487            if let (Some(key), Some(home)) = (&home_key, &default_home) {
488                unsafe {
489                    libc::setenv(key.as_ptr(), home.as_ptr(), 1);
490                }
491            }
492
493            // Apply resource limits.
494            for (resource, limit) in &parsed_rlimits {
495                if unsafe { libc::setrlimit(*resource as _, limit) } != 0 {
496                    unsafe { libc::_exit(1) };
497                }
498            }
499
500            // execvp — on success this never returns.
501            unsafe {
502                libc::execvp(argv_ptrs[0], argv_ptrs.as_ptr());
503            }
504
505            // If execvp returns, it failed.
506            write_exec_error_and_exit(err_pipe.write_end.as_raw_fd());
507        }
508
509        // Parent process.
510        drop(pty.slave);
511        drop(err_pipe.write_end);
512
513        if let Some(exec_errno) = read_exec_error(err_pipe.read_end.as_raw_fd())? {
514            let _ = wait_for_exec_failure_child(pid);
515            let io_err = std::io::Error::from_raw_os_error(exec_errno);
516            return Err(AgentdError::ExecSpawnFailed(exec_failed_from_io_error(
517                &io_err, &req.cmd, "execvp",
518            )));
519        }
520
521        // Dup the master fd for the reader task.
522        let reader_fd = unsafe { libc::dup(pty.master.as_raw_fd()) };
523        if reader_fd < 0 {
524            return Err(std::io::Error::last_os_error().into());
525        }
526        let reader_fd = unsafe { OwnedFd::from_raw_fd(reader_fd) };
527
528        // Spawn background reader task.
529        tokio::spawn(pty_reader_task(id, pid, reader_fd, tx));
530
531        Ok(Self {
532            pid,
533            pty_master: Some(pty.master),
534            stdin: None,
535        })
536    }
537
538    /// Spawns a process with piped stdio.
539    fn spawn_pipe(
540        id: u32,
541        req: &ExecRequest,
542        tx: mpsc::UnboundedSender<(u32, SessionOutput)>,
543        default_user: Option<&str>,
544        security_profile: SecurityProfile,
545    ) -> AgentdResult<Self> {
546        let mut cmd = Command::new(&req.cmd);
547        cmd.args(&req.args)
548            .stdin(Stdio::piped())
549            .stdout(Stdio::piped())
550            .stderr(Stdio::piped());
551
552        for var in &req.env {
553            if let Some((key, val)) = var.split_once('=') {
554                cmd.env(key, val);
555            }
556        }
557
558        if let Some(ref dir) = req.cwd {
559            cmd.current_dir(dir);
560        }
561
562        let resolved_user = resolve_requested_user(req, default_user)?;
563        if let Some(home) = default_home_dir(req, resolved_user.as_ref())? {
564            cmd.env("HOME", home.to_string_lossy().into_owned());
565        }
566
567        // Apply the security profile and resource limits in the child before exec.
568        let parsed_rlimits = rlimit::to_libc(&req.rlimits);
569        unsafe {
570            cmd.pre_exec(move || {
571                // Become a session (and process-group) leader so signals sent
572                // to the group reach every descendant the command spawns, not
573                // just the direct child. The PTY path does the same for its
574                // controlling terminal; here it exists purely for group kills.
575                if libc::setsid() < 0 {
576                    return Err(std::io::Error::last_os_error());
577                }
578                apply_exec_security_profile(security_profile).map_err(agentd_to_io_error)?;
579                if let Some(ref user) = resolved_user {
580                    apply_resolved_user(user).map_err(agentd_to_io_error)?;
581                }
582                for (resource, limit) in &parsed_rlimits {
583                    if libc::setrlimit(*resource as _, limit) != 0 {
584                        return Err(std::io::Error::last_os_error());
585                    }
586                }
587                Ok(())
588            });
589        }
590
591        let cmd_label = req.cmd.clone();
592        let mut child = cmd.spawn().map_err(|err| {
593            AgentdError::ExecSpawnFailed(exec_failed_from_io_error(
594                &err,
595                &cmd_label,
596                "Command::spawn",
597            ))
598        })?;
599        let pid = child.id().unwrap_or(0) as i32;
600        let stdin = child.stdin.take();
601        let stdout = child.stdout.take();
602        let stderr = child.stderr.take();
603
604        // Spawn background reader task.
605        tokio::spawn(pipe_reader_task(id, child, stdout, stderr, tx));
606
607        Ok(Self {
608            pid,
609            pty_master: None,
610            stdin,
611        })
612    }
613}
614
615//--------------------------------------------------------------------------------------------------
616// Functions
617//--------------------------------------------------------------------------------------------------
618
619fn new_exec_error_pipe() -> AgentdResult<ExecErrorPipe> {
620    let mut fds = [0; 2];
621    let ret = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) };
622    if ret != 0 {
623        return Err(std::io::Error::last_os_error().into());
624    }
625
626    Ok(ExecErrorPipe {
627        read_end: unsafe { OwnedFd::from_raw_fd(fds[0]) },
628        write_end: unsafe { OwnedFd::from_raw_fd(fds[1]) },
629    })
630}
631
632fn write_exec_error_and_exit(err_fd: RawFd) -> ! {
633    let errno = unsafe { *libc::__errno_location() };
634    let bytes = errno.to_ne_bytes();
635    let _ = unsafe { libc::write(err_fd, bytes.as_ptr() as *const libc::c_void, bytes.len()) };
636    unsafe { libc::_exit(127) }
637}
638
639fn read_exec_error(err_fd: RawFd) -> AgentdResult<Option<i32>> {
640    let mut buf = [0u8; mem::size_of::<i32>()];
641    let n = unsafe { libc::read(err_fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
642    if n < 0 {
643        return Err(std::io::Error::last_os_error().into());
644    }
645    if n == 0 {
646        return Ok(None);
647    }
648    if n as usize != buf.len() {
649        return Err(AgentdError::ExecSession(format!(
650            "short exec error report: expected {} bytes, got {n}",
651            buf.len()
652        )));
653    }
654    Ok(Some(i32::from_ne_bytes(buf)))
655}
656
657fn wait_for_exec_failure_child(pid: i32) -> AgentdResult<()> {
658    let ret = unsafe { libc::waitpid(pid, ptr::null_mut(), 0) };
659    if ret < 0 {
660        return Err(std::io::Error::last_os_error().into());
661    }
662    Ok(())
663}
664
665fn apply_exec_security_profile(profile: SecurityProfile) -> AgentdResult<()> {
666    match profile {
667        SecurityProfile::Default => Ok(()),
668        SecurityProfile::Restricted => drop_mount_admin_privileges(),
669    }
670}
671
672fn drop_mount_admin_privileges() -> AgentdResult<()> {
673    if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
674        return Err(std::io::Error::last_os_error().into());
675    }
676
677    let ret = unsafe { libc::prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_CLEAR_ALL, 0, 0, 0) };
678    if ret != 0 {
679        let err = std::io::Error::last_os_error();
680        if err.raw_os_error() != Some(libc::EINVAL) {
681            return Err(err.into());
682        }
683    }
684
685    let mut header = CapUserHeader {
686        version: LINUX_CAPABILITY_VERSION_3,
687        pid: 0,
688    };
689    let mut data = [CapUserData {
690        effective: 0,
691        permitted: 0,
692        inheritable: 0,
693    }; 2];
694
695    if unsafe { libc::syscall(libc::SYS_capget, &mut header, data.as_mut_ptr()) } != 0 {
696        return Err(std::io::Error::last_os_error().into());
697    }
698
699    let index = (CAP_SYS_ADMIN / CAP_WORD_BITS) as usize;
700    let mask = 1u32 << (CAP_SYS_ADMIN % CAP_WORD_BITS);
701    let had_sys_admin = data[index].effective & mask != 0
702        || data[index].permitted & mask != 0
703        || data[index].inheritable & mask != 0;
704
705    if had_sys_admin {
706        data[index].effective &= !mask;
707        data[index].permitted &= !mask;
708        data[index].inheritable &= !mask;
709
710        if unsafe { libc::syscall(libc::SYS_capset, &mut header, data.as_ptr()) } != 0 {
711            return Err(std::io::Error::last_os_error().into());
712        }
713    }
714
715    let ret = unsafe { libc::prctl(PR_CAPBSET_DROP, CAP_SYS_ADMIN, 0, 0, 0) };
716    if ret != 0 {
717        let err = std::io::Error::last_os_error();
718        let errno = err.raw_os_error();
719        // Already-unprivileged callers may also lack CAP_SETPCAP for the bounding-set drop.
720        let already_unprivileged = !had_sys_admin && errno == Some(libc::EPERM);
721        if errno != Some(libc::EINVAL) && !already_unprivileged {
722            return Err(err.into());
723        }
724    }
725
726    Ok(())
727}
728
729pub(crate) fn resolve_default_user(default_user: Option<&str>) -> AgentdResult<(u32, u32)> {
730    let Some(spec) = default_user
731        .map(str::trim)
732        .filter(|value| !value.is_empty())
733    else {
734        return Ok((0, 0));
735    };
736
737    let resolved = resolve_user_spec(spec)?;
738    Ok((resolved.uid, resolved.gid))
739}
740
741fn resolve_requested_user(
742    req: &ExecRequest,
743    default_user: Option<&str>,
744) -> AgentdResult<Option<ResolvedUser>> {
745    let default_user = default_user
746        .map(str::trim)
747        .filter(|value| !value.is_empty());
748    let requested = req
749        .user
750        .as_deref()
751        .map(str::trim)
752        .filter(|value| !value.is_empty())
753        .or(default_user);
754
755    requested.map(resolve_user_spec).transpose()
756}
757
758fn resolve_user_spec(spec: &str) -> AgentdResult<ResolvedUser> {
759    let (user_part, group_part) = match spec.split_once(':') {
760        Some((user, group)) => (user.trim(), Some(group.trim())),
761        None => (spec.trim(), None),
762    };
763
764    if user_part.is_empty() {
765        return Err(AgentdError::ExecSession("user spec has empty user".into()));
766    }
767
768    let passwd = if let Ok(uid) = parse_id(user_part) {
769        lookup_passwd_by_uid(uid)?
770    } else {
771        lookup_passwd_by_name(user_part)?
772            .ok_or_else(|| AgentdError::ExecSession(format!("guest user not found: {user_part}")))?
773            .into()
774    };
775
776    let (uid, passwd_entry) = match passwd {
777        ResolvedUserLookup::Known(entry) => (entry.uid, Some(entry)),
778        ResolvedUserLookup::Numeric(uid) => (uid, None),
779    };
780
781    let gid = match group_part {
782        Some("") => {
783            return Err(AgentdError::ExecSession("user spec has empty group".into()));
784        }
785        Some(group) => resolve_group_spec(group)?,
786        None => passwd_entry
787            .as_ref()
788            .map(|entry| entry.gid)
789            .unwrap_or_else(|| unsafe { libc::getgid() }),
790    };
791
792    let initgroups_user = passwd_entry
793        .as_ref()
794        .map(|entry| CString::new(entry.name.as_str()))
795        .transpose()
796        .map_err(|e| AgentdError::ExecSession(format!("invalid guest user name: {e}")))?;
797
798    Ok(ResolvedUser {
799        uid,
800        gid,
801        initgroups_user,
802        home_dir: passwd_entry
803            .as_ref()
804            .and_then(|entry| entry.home_dir.as_deref())
805            .map(CString::new)
806            .transpose()
807            .map_err(|e| AgentdError::ExecSession(format!("invalid guest home directory: {e}")))?,
808    })
809}
810
811enum ResolvedUserLookup {
812    Known(PasswdEntry),
813    Numeric(libc::uid_t),
814}
815
816impl From<PasswdEntry> for ResolvedUserLookup {
817    fn from(value: PasswdEntry) -> Self {
818        Self::Known(value)
819    }
820}
821
822fn resolve_group_spec(spec: &str) -> AgentdResult<libc::gid_t> {
823    if let Ok(gid) = parse_id(spec) {
824        return Ok(gid);
825    }
826
827    lookup_group_by_name(spec)?
828        .map(|entry| entry.gid)
829        .ok_or_else(|| AgentdError::ExecSession(format!("guest group not found: {spec}")))
830}
831
832fn parse_id(value: &str) -> Result<u32, std::num::ParseIntError> {
833    value.parse::<u32>()
834}
835
836fn lookup_passwd_by_name(name: &str) -> AgentdResult<Option<PasswdEntry>> {
837    let name = CString::new(name)
838        .map_err(|e| AgentdError::ExecSession(format!("invalid guest user name: {e}")))?;
839    let mut pwd = MaybeUninit::<libc::passwd>::uninit();
840    let mut result = ptr::null_mut();
841    let mut buf = vec![0u8; lookup_buffer_len()];
842    let rc = unsafe {
843        libc::getpwnam_r(
844            name.as_ptr(),
845            pwd.as_mut_ptr(),
846            buf.as_mut_ptr().cast(),
847            buf.len(),
848            &mut result,
849        )
850    };
851    if rc != 0 {
852        return Err(AgentdError::ExecSession(format!(
853            "failed to resolve guest user {name:?}: {}",
854            std::io::Error::from_raw_os_error(rc)
855        )));
856    }
857    if result.is_null() {
858        return Ok(None);
859    }
860
861    let pwd = unsafe { pwd.assume_init() };
862    let name = unsafe { CStr::from_ptr(pwd.pw_name) }
863        .to_string_lossy()
864        .into_owned();
865    let home_dir = unsafe { CStr::from_ptr(pwd.pw_dir) }
866        .to_string_lossy()
867        .into_owned();
868    Ok(Some(PasswdEntry {
869        name,
870        uid: pwd.pw_uid,
871        gid: pwd.pw_gid,
872        home_dir: (!home_dir.is_empty()).then_some(home_dir),
873    }))
874}
875
876fn lookup_passwd_by_uid(uid: libc::uid_t) -> AgentdResult<ResolvedUserLookup> {
877    let mut pwd = MaybeUninit::<libc::passwd>::uninit();
878    let mut result = ptr::null_mut();
879    let mut buf = vec![0u8; lookup_buffer_len()];
880    let rc = unsafe {
881        libc::getpwuid_r(
882            uid,
883            pwd.as_mut_ptr(),
884            buf.as_mut_ptr().cast(),
885            buf.len(),
886            &mut result,
887        )
888    };
889    if rc != 0 {
890        return Err(AgentdError::ExecSession(format!(
891            "failed to resolve guest uid {uid}: {}",
892            std::io::Error::from_raw_os_error(rc)
893        )));
894    }
895    if result.is_null() {
896        return Ok(ResolvedUserLookup::Numeric(uid));
897    }
898
899    let pwd = unsafe { pwd.assume_init() };
900    let name = unsafe { CStr::from_ptr(pwd.pw_name) }
901        .to_string_lossy()
902        .into_owned();
903    let home_dir = unsafe { CStr::from_ptr(pwd.pw_dir) }
904        .to_string_lossy()
905        .into_owned();
906    Ok(ResolvedUserLookup::Known(PasswdEntry {
907        name,
908        uid: pwd.pw_uid,
909        gid: pwd.pw_gid,
910        home_dir: (!home_dir.is_empty()).then_some(home_dir),
911    }))
912}
913
914fn lookup_group_by_name(name: &str) -> AgentdResult<Option<GroupEntry>> {
915    let name = CString::new(name)
916        .map_err(|e| AgentdError::ExecSession(format!("invalid guest group name: {e}")))?;
917    let mut grp = MaybeUninit::<libc::group>::uninit();
918    let mut result = ptr::null_mut();
919    let mut buf = vec![0u8; lookup_buffer_len()];
920    let rc = unsafe {
921        libc::getgrnam_r(
922            name.as_ptr(),
923            grp.as_mut_ptr(),
924            buf.as_mut_ptr().cast(),
925            buf.len(),
926            &mut result,
927        )
928    };
929    if rc != 0 {
930        return Err(AgentdError::ExecSession(format!(
931            "failed to resolve guest group {name:?}: {}",
932            std::io::Error::from_raw_os_error(rc)
933        )));
934    }
935    if result.is_null() {
936        return Ok(None);
937    }
938
939    let grp = unsafe { grp.assume_init() };
940    Ok(Some(GroupEntry { gid: grp.gr_gid }))
941}
942
943fn lookup_buffer_len() -> usize {
944    let size = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) };
945    if size > 0 { size as usize } else { 16 * 1024 }
946}
947
948fn apply_resolved_user(user: &ResolvedUser) -> AgentdResult<()> {
949    if let Some(ref name) = user.initgroups_user {
950        if unsafe { libc::initgroups(name.as_ptr(), user.gid) } != 0 {
951            return Err(std::io::Error::last_os_error().into());
952        }
953    } else if unsafe { libc::setgroups(0, ptr::null()) } != 0 {
954        return Err(std::io::Error::last_os_error().into());
955    }
956
957    if unsafe { libc::setgid(user.gid) } != 0 {
958        return Err(std::io::Error::last_os_error().into());
959    }
960    if unsafe { libc::setuid(user.uid) } != 0 {
961        return Err(std::io::Error::last_os_error().into());
962    }
963
964    Ok(())
965}
966
967fn default_home_dir(
968    req: &ExecRequest,
969    user: Option<&ResolvedUser>,
970) -> AgentdResult<Option<CString>> {
971    if env_contains_key(&req.env, "HOME") {
972        return Ok(None);
973    }
974
975    if let Some(user) = user {
976        return Ok(user.home_dir.clone());
977    }
978
979    Ok(resolve_user_spec(DEFAULT_USER_SPEC)?.home_dir)
980}
981
982fn env_contains_key(env: &[String], key: &str) -> bool {
983    env.iter().any(|entry| {
984        entry
985            .split_once('=')
986            .map(|(entry_key, _)| entry_key == key)
987            .unwrap_or(false)
988    })
989}
990
991fn agentd_to_io_error(err: AgentdError) -> std::io::Error {
992    std::io::Error::other(err.to_string())
993}
994
995/// Writes data to a raw fd using a blocking task, handling short writes.
996async fn blocking_write_fd(fd: RawFd, data: &[u8]) -> AgentdResult<()> {
997    let data = data.to_vec();
998    tokio::task::spawn_blocking(move || {
999        let mut written = 0;
1000        while written < data.len() {
1001            let ptr = unsafe { data.as_ptr().add(written) as *const libc::c_void };
1002            let ret = unsafe { libc::write(fd, ptr, data.len() - written) };
1003            if ret < 0 {
1004                let err = std::io::Error::last_os_error();
1005                let code = err.raw_os_error();
1006                if code == Some(libc::EAGAIN) || code == Some(libc::EWOULDBLOCK) {
1007                    wait_fd_writable(fd)?;
1008                    continue;
1009                }
1010                if code == Some(libc::EINTR) {
1011                    continue;
1012                }
1013                return Err(AgentdError::Io(err));
1014            }
1015            if ret == 0 {
1016                wait_fd_writable(fd)?;
1017                continue;
1018            }
1019            written += ret as usize;
1020        }
1021        Ok(())
1022    })
1023    .await
1024    .map_err(|e| AgentdError::ExecSession(format!("stdin write join error: {e}")))?
1025}
1026
1027fn wait_fd_writable(fd: RawFd) -> AgentdResult<()> {
1028    let mut pollfd = libc::pollfd {
1029        fd,
1030        events: libc::POLLOUT,
1031        revents: 0,
1032    };
1033
1034    loop {
1035        let ret = unsafe { libc::poll(&mut pollfd, 1, -1) };
1036        if ret < 0 {
1037            let err = std::io::Error::last_os_error();
1038            if err.raw_os_error() == Some(libc::EINTR) {
1039                continue;
1040            }
1041            return Err(AgentdError::Io(err));
1042        }
1043        if ret == 0 {
1044            continue;
1045        }
1046        // Any positive return means the fd is actionable: POLLOUT lets the
1047        // next write make progress, and POLLHUP/POLLERR/POLLNVAL will cause
1048        // the next write to fail with a real errno (typically EPIPE) which
1049        // is more meaningful than poll's revents.
1050        return Ok(());
1051    }
1052}
1053
1054/// Background task that reads from a PTY master fd and sends output events.
1055async fn pty_reader_task(
1056    id: u32,
1057    pid: i32,
1058    master_fd: OwnedFd,
1059    tx: mpsc::UnboundedSender<(u32, SessionOutput)>,
1060) {
1061    let tx_output = tx.clone();
1062    let read_result = tokio::task::spawn_blocking(move || {
1063        // PTY masters are safer with a dedicated blocking read loop than with
1064        // edge-driven readiness. Fast writers followed by process exit can
1065        // strand the tail behind a missed wakeup/HUP transition.
1066        let raw = master_fd.as_raw_fd();
1067        let flags = unsafe { libc::fcntl(raw, libc::F_GETFL) };
1068        if flags >= 0 {
1069            unsafe { libc::fcntl(raw, libc::F_SETFL, flags & !libc::O_NONBLOCK) };
1070        }
1071
1072        loop {
1073            let mut buf = [0u8; 4096];
1074            let n = unsafe { libc::read(raw, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
1075
1076            if n > 0 {
1077                if tx_output
1078                    .send((id, SessionOutput::Stdout(buf[..n as usize].to_vec())))
1079                    .is_err()
1080                {
1081                    break;
1082                }
1083                continue;
1084            }
1085
1086            if n == 0 {
1087                break;
1088            }
1089
1090            let err = std::io::Error::last_os_error();
1091            match err.raw_os_error() {
1092                Some(libc::EINTR) => continue,
1093                Some(libc::EIO) => break,
1094                _ => break,
1095            }
1096        }
1097    })
1098    .await;
1099
1100    let _ = read_result;
1101
1102    let code = wait_for_pid(pid).await;
1103    let _ = tx.send((id, SessionOutput::Exited(code)));
1104}
1105
1106/// Background task that reads from piped stdout/stderr and sends output events.
1107async fn pipe_reader_task(
1108    id: u32,
1109    mut child: Child,
1110    stdout: Option<tokio::process::ChildStdout>,
1111    stderr: Option<tokio::process::ChildStderr>,
1112    tx: mpsc::UnboundedSender<(u32, SessionOutput)>,
1113) {
1114    let mut stdout = stdout;
1115    let mut stderr = stderr;
1116    let mut stdout_eof = stdout.is_none();
1117    let mut stderr_eof = stderr.is_none();
1118
1119    while !stdout_eof || !stderr_eof {
1120        let mut stdout_buf = [0u8; 4096];
1121        let mut stderr_buf = [0u8; 4096];
1122
1123        tokio::select! {
1124            result = async {
1125                match stdout.as_mut() {
1126                    Some(out) => out.read(&mut stdout_buf).await,
1127                    None => std::future::pending().await,
1128                }
1129            }, if !stdout_eof => {
1130                match result {
1131                    Ok(0) | Err(_) => {
1132                        stdout = None;
1133                        stdout_eof = true;
1134                    }
1135                    Ok(n) => {
1136                        let _ = tx.send((id, SessionOutput::Stdout(stdout_buf[..n].to_vec())));
1137                    }
1138                }
1139            }
1140            result = async {
1141                match stderr.as_mut() {
1142                    Some(err) => err.read(&mut stderr_buf).await,
1143                    None => std::future::pending().await,
1144                }
1145            }, if !stderr_eof => {
1146                match result {
1147                    Ok(0) | Err(_) => {
1148                        stderr = None;
1149                        stderr_eof = true;
1150                    }
1151                    Ok(n) => {
1152                        let _ = tx.send((id, SessionOutput::Stderr(stderr_buf[..n].to_vec())));
1153                    }
1154                }
1155            }
1156        }
1157    }
1158
1159    // Both streams are done — wait for process exit.
1160    let code = match child.wait().await {
1161        Ok(status) => status.code().unwrap_or(-1),
1162        Err(_) => -1,
1163    };
1164
1165    let _ = tx.send((id, SessionOutput::Exited(code)));
1166}
1167
1168/// Waits for a process to exit by PID and returns the exit code.
1169async fn wait_for_pid(pid: i32) -> i32 {
1170    tokio::task::spawn_blocking(move || {
1171        let mut status: i32 = 0;
1172        unsafe {
1173            libc::waitpid(pid, &mut status, 0);
1174        }
1175        if libc::WIFEXITED(status) {
1176            libc::WEXITSTATUS(status)
1177        } else {
1178            -1
1179        }
1180    })
1181    .await
1182    .unwrap_or(-1)
1183}
1184
1185//--------------------------------------------------------------------------------------------------
1186// Tests
1187//--------------------------------------------------------------------------------------------------
1188
1189#[cfg(test)]
1190mod tests {
1191    use std::time::Duration;
1192
1193    use tokio::time;
1194
1195    use microsandbox_protocol::exec::ExecRequest;
1196
1197    use super::*;
1198
1199    #[tokio::test]
1200    async fn test_pty_reader_drains_ready_fd() {
1201        let (tx, mut rx) = mpsc::unbounded_channel();
1202        let req = ExecRequest {
1203            cmd: "/bin/sh".to_string(),
1204            args: vec![
1205                "-c".to_string(),
1206                "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"
1207                    .to_string(),
1208            ],
1209            env: vec!["PATH=/usr/local/bin:/usr/bin:/bin".to_string()],
1210            cwd: None,
1211            user: None,
1212            tty: true,
1213            rows: 24,
1214            cols: 80,
1215            rlimits: Vec::new(),
1216        };
1217
1218        let session = ExecSession::spawn(7, &req, tx, None, SecurityProfile::Default)
1219            .expect("spawn pty session");
1220        let mut stdout = Vec::new();
1221        let mut exit = None;
1222
1223        let recv_result = time::timeout(Duration::from_secs(15), async {
1224            while let Some((id, output)) = rx.recv().await {
1225                assert_eq!(id, 7);
1226                match output {
1227                    SessionOutput::Stdout(data) => stdout.extend_from_slice(&data),
1228                    SessionOutput::Exited(code) => {
1229                        exit = Some(code);
1230                        break;
1231                    }
1232                    SessionOutput::Stderr(_) | SessionOutput::Raw(_) => {}
1233                }
1234            }
1235        })
1236        .await;
1237
1238        if recv_result.is_err() {
1239            let _ = session.send_signal(libc::SIGKILL);
1240            panic!("timed out waiting for PTY output");
1241        }
1242
1243        assert_eq!(exit, Some(0));
1244
1245        let second = stdout
1246            .windows(b"SECOND".len())
1247            .position(|window| window == b"SECOND");
1248        let end = stdout
1249            .windows(b"<END>".len())
1250            .position(|window| window == b"<END>");
1251
1252        assert!(
1253            matches!((second, end), (Some(second), Some(end)) if second < end),
1254            "expected immediate PTY write to arrive before later output; got {:?}",
1255            String::from_utf8_lossy(&stdout),
1256        );
1257    }
1258
1259    #[test]
1260    fn test_resolve_user_spec_for_current_uid_gid() {
1261        let uid = unsafe { libc::getuid() };
1262        let gid = unsafe { libc::getgid() };
1263        let resolved = resolve_user_spec(&format!("{uid}:{gid}")).expect("resolve numeric user");
1264        assert_eq!(resolved.uid, uid);
1265        assert_eq!(resolved.gid, gid);
1266    }
1267
1268    #[test]
1269    fn test_request_user_overrides_config_default() {
1270        let req = ExecRequest {
1271            cmd: "/bin/true".to_string(),
1272            args: Vec::new(),
1273            env: Vec::new(),
1274            cwd: None,
1275            user: Some("1:1".to_string()),
1276            tty: false,
1277            rows: 24,
1278            cols: 80,
1279            rlimits: Vec::new(),
1280        };
1281
1282        let resolved = resolve_requested_user(&req, Some("0:0")).expect("resolve requested user");
1283        assert_eq!(resolved.unwrap().uid, 1);
1284    }
1285
1286    #[test]
1287    fn test_config_default_user_used_when_request_has_none() {
1288        let req = ExecRequest {
1289            cmd: "/bin/true".to_string(),
1290            args: Vec::new(),
1291            env: Vec::new(),
1292            cwd: None,
1293            user: None,
1294            tty: false,
1295            rows: 24,
1296            cols: 80,
1297            rlimits: Vec::new(),
1298        };
1299
1300        let uid = unsafe { libc::getuid() };
1301        let gid = unsafe { libc::getgid() };
1302        let resolved = resolve_requested_user(&req, Some(&format!("{uid}:{gid}")))
1303            .expect("resolve with config default");
1304        let resolved = resolved.expect("should resolve to a user");
1305        assert_eq!(resolved.uid, uid);
1306        assert_eq!(resolved.gid, gid);
1307    }
1308
1309    #[test]
1310    fn test_request_without_user_does_not_apply_user_switch() {
1311        let req = ExecRequest {
1312            cmd: "/bin/true".to_string(),
1313            args: Vec::new(),
1314            env: Vec::new(),
1315            cwd: None,
1316            user: None,
1317            tty: false,
1318            rows: 24,
1319            cols: 80,
1320            rlimits: Vec::new(),
1321        };
1322
1323        let resolved = resolve_requested_user(&req, None).expect("resolve absent user");
1324        assert!(resolved.is_none());
1325    }
1326
1327    #[test]
1328    fn test_default_user_absent_resolves_to_root() {
1329        let resolved = resolve_default_user(None).expect("resolve absent default user");
1330        assert_eq!(resolved, (0, 0));
1331    }
1332
1333    #[test]
1334    fn test_default_home_dir_uses_resolved_user_home() {
1335        let req = ExecRequest {
1336            cmd: "/bin/true".to_string(),
1337            args: Vec::new(),
1338            env: Vec::new(),
1339            cwd: None,
1340            user: None,
1341            tty: false,
1342            rows: 24,
1343            cols: 80,
1344            rlimits: Vec::new(),
1345        };
1346        let user = ResolvedUser {
1347            uid: 1000,
1348            gid: 1000,
1349            initgroups_user: None,
1350            home_dir: Some(CString::new("/home/tester").unwrap()),
1351        };
1352
1353        assert_eq!(
1354            default_home_dir(&req, Some(&user))
1355                .expect("resolve default home")
1356                .as_deref()
1357                .map(CStr::to_string_lossy),
1358            Some("/home/tester".into()),
1359        );
1360    }
1361
1362    #[test]
1363    fn test_default_home_dir_uses_root_when_user_absent() {
1364        let req = ExecRequest {
1365            cmd: "/bin/true".to_string(),
1366            args: Vec::new(),
1367            env: Vec::new(),
1368            cwd: None,
1369            user: None,
1370            tty: false,
1371            rows: 24,
1372            cols: 80,
1373            rlimits: Vec::new(),
1374        };
1375        let root = resolve_user_spec(DEFAULT_USER_SPEC).expect("resolve implicit root");
1376
1377        assert_eq!(
1378            default_home_dir(&req, None)
1379                .expect("resolve default home")
1380                .as_deref()
1381                .map(CStr::to_string_lossy),
1382            root.home_dir.as_deref().map(CStr::to_string_lossy),
1383        );
1384    }
1385
1386    #[test]
1387    fn test_default_home_dir_respects_explicit_home_env() {
1388        let req = ExecRequest {
1389            cmd: "/bin/true".to_string(),
1390            args: Vec::new(),
1391            env: vec!["HOME=/tmp/custom".to_string()],
1392            cwd: None,
1393            user: None,
1394            tty: false,
1395            rows: 24,
1396            cols: 80,
1397            rlimits: Vec::new(),
1398        };
1399        let user = ResolvedUser {
1400            uid: 1000,
1401            gid: 1000,
1402            initgroups_user: None,
1403            home_dir: Some(CString::new("/home/tester").unwrap()),
1404        };
1405
1406        assert!(
1407            default_home_dir(&req, Some(&user))
1408                .expect("resolve default home")
1409                .is_none()
1410        );
1411    }
1412
1413    #[tokio::test]
1414    async fn test_spawn_pipe_error_does_not_include_probe_details() {
1415        let (tx, _rx) = mpsc::unbounded_channel();
1416        let req = ExecRequest {
1417            cmd: "/definitely/not/a/real/binary".to_string(),
1418            args: Vec::new(),
1419            env: Vec::new(),
1420            cwd: None,
1421            user: None,
1422            tty: false,
1423            rows: 24,
1424            cols: 80,
1425            rlimits: Vec::new(),
1426        };
1427
1428        let err = ExecSession::spawn(9, &req, tx, None, SecurityProfile::Default)
1429            .expect_err("spawn should fail");
1430
1431        // Spawn failures now produce the typed `ExecSpawnFailed` so
1432        // the host can render a useful message + hint. The classifier
1433        // maps ENOENT on the binary path to `NotFound`.
1434        let payload = match &err {
1435            AgentdError::ExecSpawnFailed(p) => p,
1436            other => panic!("expected ExecSpawnFailed, got: {other:?}"),
1437        };
1438        assert_eq!(payload.kind, ExecFailureKind::NotFound);
1439        assert_eq!(payload.errno, Some(libc::ENOENT));
1440        assert_eq!(payload.errno_name.as_deref(), Some("ENOENT"));
1441
1442        // The original intent of the test: probe internals leak into
1443        // the error message. The format is now
1444        // `spawn "<cmd>": <io::Error>` from
1445        // `exec_failed_from_io_error`. Verify that none of the old
1446        // probe-detail keys snuck back into the message.
1447        let message = &payload.message;
1448        assert!(message.contains("spawn"));
1449        assert!(!message.contains("symlink_metadata="));
1450        assert!(!message.contains("metadata="));
1451        assert!(!message.contains("magic="));
1452        assert!(!message.contains("path_probe="));
1453        assert!(!message.contains("cwd_probe="));
1454        assert!(!message.contains("target_probe="));
1455    }
1456}