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::os::unix::process::CommandExt;
7use std::process::{Command, Stdio};
8use std::sync::Arc;
9use std::{iter, mem, ptr};
10
11use nix::pty;
12use nix::sys::signal::Signal;
13use tokio::io::AsyncReadExt;
14use tokio::sync::mpsc;
15
16use microsandbox_protocol::exec::{ExecFailed, ExecFailureKind, ExecRequest};
17
18use crate::config::SecurityProfile;
19use crate::error::{AgentdError, AgentdResult};
20use crate::process::{ProcessExitWatcher, ProcessIdentity, ProcessManager};
21use crate::rlimit;
22
23//--------------------------------------------------------------------------------------------------
24// Constants
25//--------------------------------------------------------------------------------------------------
26
27const LINUX_CAPABILITY_VERSION_3: u32 = 0x20080522;
28const CAP_SYS_ADMIN: u32 = 21;
29const CAP_WORD_BITS: u32 = 32;
30const PR_CAPBSET_DROP: libc::c_int = 24;
31const PR_CAP_AMBIENT: libc::c_int = 47;
32const PR_CAP_AMBIENT_CLEAR_ALL: libc::c_int = 4;
33const DEFAULT_USER_SPEC: &str = "0:0";
34
35//--------------------------------------------------------------------------------------------------
36// Functions: classify
37//--------------------------------------------------------------------------------------------------
38
39/// Map an `errno` integer to its standard symbolic name. Returns
40/// `None` for unrecognized values; we only enumerate the ones that
41/// can plausibly come out of fork/exec/setrlimit/setuid paths.
42fn errno_name(e: i32) -> Option<&'static str> {
43    match e {
44        libc::E2BIG => Some("E2BIG"),
45        libc::EACCES => Some("EACCES"),
46        libc::EAGAIN => Some("EAGAIN"),
47        libc::EBUSY => Some("EBUSY"),
48        libc::EFAULT => Some("EFAULT"),
49        libc::EINVAL => Some("EINVAL"),
50        libc::EIO => Some("EIO"),
51        libc::EISDIR => Some("EISDIR"),
52        libc::ELOOP => Some("ELOOP"),
53        libc::EMFILE => Some("EMFILE"),
54        libc::ENAMETOOLONG => Some("ENAMETOOLONG"),
55        libc::ENFILE => Some("ENFILE"),
56        libc::ENOENT => Some("ENOENT"),
57        libc::ENOEXEC => Some("ENOEXEC"),
58        libc::ENOMEM => Some("ENOMEM"),
59        libc::ENOSYS => Some("ENOSYS"),
60        libc::ENOTDIR => Some("ENOTDIR"),
61        libc::ENXIO => Some("ENXIO"),
62        libc::EPERM => Some("EPERM"),
63        libc::ETXTBSY => Some("ETXTBSY"),
64        _ => None,
65    }
66}
67
68/// Classify a fork/exec-time `errno` into one of the
69/// `ExecFailureKind` buckets.
70///
71/// ENOENT is ambiguous in principle (missing binary vs. missing
72/// cwd), but in practice it's overwhelmingly the binary — the cwd
73/// is set in `pre_exec` *before* execvp, and a bad cwd would more
74/// commonly produce ENOTDIR (path component isn't a directory) or
75/// EACCES (no permission to chdir). We classify ENOENT as
76/// `NotFound` and ENOTDIR as `BadCwd`. Edge cases of "bad cwd that
77/// happens to ENOENT" fall through with the message "spawn 'cmd':
78/// No such file or directory" which is still understandable.
79fn classify_spawn_errno(errno: i32) -> ExecFailureKind {
80    match errno {
81        libc::ENOENT => ExecFailureKind::NotFound,
82        libc::ENOTDIR => ExecFailureKind::BadCwd,
83        libc::EACCES | libc::EPERM => ExecFailureKind::PermissionDenied,
84        libc::ENOEXEC => ExecFailureKind::NotExecutable,
85        libc::EISDIR => ExecFailureKind::NotExecutable,
86        libc::ETXTBSY => ExecFailureKind::NotExecutable,
87        libc::E2BIG | libc::ELOOP | libc::ENAMETOOLONG | libc::EFAULT => ExecFailureKind::BadArgs,
88        libc::EMFILE | libc::ENFILE => ExecFailureKind::ResourceLimit,
89        libc::EAGAIN => ExecFailureKind::ResourceLimit,
90        libc::ENOMEM => ExecFailureKind::OutOfMemory,
91        libc::EINVAL => ExecFailureKind::Other,
92        _ => ExecFailureKind::Other,
93    }
94}
95
96/// Build a `ExecFailed` payload from a spawn-time `io::Error`.
97fn exec_failed_from_io_error(err: &std::io::Error, cmd: &str, stage: &str) -> ExecFailed {
98    let errno = err.raw_os_error();
99    let kind = errno
100        .map(classify_spawn_errno)
101        .unwrap_or(ExecFailureKind::Other);
102    let errno_name = errno.and_then(errno_name).map(str::to_string);
103    let message = format!("spawn {cmd:?}: {err}");
104    ExecFailed {
105        kind,
106        errno,
107        errno_name,
108        message,
109        stage: Some(stage.to_string()),
110    }
111}
112
113//--------------------------------------------------------------------------------------------------
114// Types
115//--------------------------------------------------------------------------------------------------
116
117/// An active exec session handle for sending input to a running process.
118///
119/// Output reading is handled by a background task that sends events
120/// via the `mpsc` channel provided at spawn time.
121#[derive(Debug)]
122pub struct ExecSession {
123    /// Stable identity for the spawned process registration.
124    process_identity: ProcessIdentity,
125
126    /// Owns process status and serializes signals with PID reuse.
127    process_manager: Arc<ProcessManager>,
128
129    /// The PTY master fd (only for PTY mode, used for writing and resize).
130    pty_master: Option<OwnedFd>,
131
132    /// The child's stdin (only for pipe mode).
133    stdin: Option<tokio::process::ChildStdin>,
134}
135
136/// Output from a session that the agent loop should forward to the host.
137pub enum SessionOutput {
138    /// Data from stdout (or PTY master).
139    Stdout(Vec<u8>),
140
141    /// Data from stderr (pipe mode only).
142    Stderr(Vec<u8>),
143
144    /// The process has exited with the given code.
145    Exited(i32),
146
147    /// Pre-encoded frame bytes to write directly to the serial output buffer.
148    Raw(RawSessionOutput),
149}
150
151/// Pre-encoded session output plus the accounting metadata known by its producer.
152pub struct RawSessionOutput {
153    /// Encoded protocol frame bytes.
154    pub frame: Vec<u8>,
155
156    /// Activity represented by the frame.
157    pub activity: RawActivity,
158
159    /// Session table entry completed by the frame, if any.
160    pub completion: Option<RawSessionCompletion>,
161}
162
163/// Activity represented by a pre-encoded session frame.
164#[derive(Debug, Clone, Copy, Default)]
165pub struct RawActivity {
166    /// Whether this frame is a meaningful guest-to-host protocol message.
167    pub guest_message: bool,
168
169    /// Filesystem bytes moved by this frame.
170    pub fs_bytes: usize,
171
172    /// TCP bytes moved by this frame.
173    pub tcp_bytes: usize,
174}
175
176/// Session table entry completed by a pre-encoded session frame.
177#[derive(Debug, Clone, Copy)]
178pub enum RawSessionCompletion {
179    /// A filesystem read stream completed.
180    FsRead,
181
182    /// A TCP stream completed.
183    Tcp,
184}
185
186struct ResolvedUser {
187    uid: libc::uid_t,
188    gid: libc::gid_t,
189    initgroups_user: Option<CString>,
190    home_dir: Option<CString>,
191}
192
193struct PasswdEntry {
194    name: String,
195    uid: libc::uid_t,
196    gid: libc::gid_t,
197    home_dir: Option<String>,
198}
199
200struct GroupEntry {
201    gid: libc::gid_t,
202}
203
204struct ExecErrorPipe {
205    read_end: OwnedFd,
206    write_end: OwnedFd,
207}
208
209/// A piped process whose exit status is observed by [`ProcessManager`].
210struct PipedProcess {
211    stdin: Option<tokio::process::ChildStdin>,
212    stdout: Option<tokio::process::ChildStdout>,
213    stderr: Option<tokio::process::ChildStderr>,
214    exit_watcher: ProcessExitWatcher,
215}
216
217#[repr(C)]
218#[derive(Clone, Copy)]
219struct CapUserHeader {
220    version: u32,
221    pid: libc::c_int,
222}
223
224#[repr(C)]
225#[derive(Clone, Copy)]
226struct CapUserData {
227    effective: u32,
228    permitted: u32,
229    inheritable: u32,
230}
231
232//--------------------------------------------------------------------------------------------------
233// Methods
234//--------------------------------------------------------------------------------------------------
235
236impl RawSessionOutput {
237    /// Creates pre-encoded output with activity metadata.
238    pub fn new(
239        frame: Vec<u8>,
240        activity: RawActivity,
241        completion: Option<RawSessionCompletion>,
242    ) -> Self {
243        Self {
244            frame,
245            activity,
246            completion,
247        }
248    }
249}
250
251impl RawActivity {
252    /// A guest-to-host frame with no byte counter.
253    pub fn guest_message() -> Self {
254        Self {
255            guest_message: true,
256            ..Self::default()
257        }
258    }
259
260    /// A guest-to-host filesystem data frame.
261    pub fn fs_bytes(len: usize) -> Self {
262        Self {
263            guest_message: true,
264            fs_bytes: len,
265            tcp_bytes: 0,
266        }
267    }
268
269    /// A guest-to-host TCP data frame.
270    pub fn tcp_bytes(len: usize) -> Self {
271        Self {
272            guest_message: true,
273            fs_bytes: 0,
274            tcp_bytes: len,
275        }
276    }
277}
278
279impl ExecSession {
280    /// Spawns a new exec session.
281    ///
282    /// If `req.tty` is true, uses a PTY. Otherwise, uses piped stdin/stdout/stderr.
283    /// A background task is spawned to read output and send events via `tx`.
284    pub fn spawn(
285        id: u32,
286        req: &ExecRequest,
287        tx: mpsc::UnboundedSender<(u32, SessionOutput)>,
288        default_user: Option<&str>,
289        security_profile: SecurityProfile,
290    ) -> AgentdResult<Self> {
291        let process_manager = ProcessManager::get()?;
292        if req.tty {
293            Self::spawn_pty(
294                id,
295                req,
296                tx,
297                default_user,
298                security_profile,
299                &process_manager,
300            )
301        } else {
302            Self::spawn_pipe(
303                id,
304                req,
305                tx,
306                default_user,
307                security_profile,
308                &process_manager,
309            )
310        }
311    }
312
313    /// Returns the PID of the spawned process (as u32 for the protocol).
314    pub fn pid(&self) -> u32 {
315        self.process_identity.pid() as u32
316    }
317
318    /// Writes data to the process's stdin (or PTY master).
319    pub async fn write_stdin(&self, data: &[u8]) -> AgentdResult<()> {
320        if let Some(ref master) = self.pty_master {
321            blocking_write_fd(master.as_raw_fd(), data).await
322        } else if let Some(ref stdin) = self.stdin {
323            blocking_write_fd(stdin.as_raw_fd(), data).await
324        } else {
325            Ok(())
326        }
327    }
328
329    /// Resizes the PTY (only applicable for TTY sessions).
330    pub fn resize(&self, rows: u16, cols: u16) -> AgentdResult<()> {
331        if let Some(ref master) = self.pty_master {
332            let ws = libc::winsize {
333                ws_row: rows,
334                ws_col: cols,
335                ws_xpixel: 0,
336                ws_ypixel: 0,
337            };
338            let ret = unsafe { libc::ioctl(master.as_raw_fd(), libc::TIOCSWINSZ, &ws) };
339            if ret < 0 {
340                return Err(std::io::Error::last_os_error().into());
341            }
342        }
343        Ok(())
344    }
345
346    /// Sends a signal to the spawned process and everything it started.
347    ///
348    /// The child is made a session leader at spawn (both pipe and PTY modes),
349    /// so signalling the negative pid reaches its whole process group. A bare
350    /// kill(pid) here leaked orphans: killing `sh -c "job &"` took out the
351    /// shell while its backgrounded children survived reparented to init,
352    /// silently accumulating load in the guest.
353    pub fn send_signal(&self, signum: i32) -> AgentdResult<()> {
354        let sig = Signal::try_from(signum)
355            .map_err(|e| AgentdError::ExecSession(format!("invalid signal {signum}: {e}")))?;
356        self.process_manager
357            .signal_process_group(self.process_identity, sig as i32)
358    }
359
360    /// Closes the process's stdin.
361    ///
362    /// For pipe mode, drops the `ChildStdin` handle which closes the fd.
363    /// For PTY mode, this is a no-op (the PTY master stays open for output).
364    pub fn close_stdin(&mut self) {
365        self.stdin.take();
366    }
367}
368
369impl ExecSession {
370    /// Spawns a process with a PTY.
371    fn spawn_pty(
372        id: u32,
373        req: &ExecRequest,
374        tx: mpsc::UnboundedSender<(u32, SessionOutput)>,
375        default_user: Option<&str>,
376        security_profile: SecurityProfile,
377        process_manager: &Arc<ProcessManager>,
378    ) -> AgentdResult<Self> {
379        let pty = pty::openpty(None, None)?;
380        let err_pipe = new_exec_error_pipe()?;
381
382        // Set initial window size.
383        let ws = libc::winsize {
384            ws_row: req.rows,
385            ws_col: req.cols,
386            ws_xpixel: 0,
387            ws_ypixel: 0,
388        };
389        let ret = unsafe { libc::ioctl(pty.master.as_raw_fd(), libc::TIOCSWINSZ, &ws) };
390        if ret < 0 {
391            return Err(std::io::Error::last_os_error().into());
392        }
393
394        let slave_fd = pty.slave.as_raw_fd();
395
396        // Pre-build all strings before fork to avoid allocating in the child.
397        let c_cmd = CString::new(req.cmd.as_str())
398            .map_err(|e| AgentdError::ExecSession(format!("invalid command: {e}")))?;
399        let mut c_args: Vec<CString> = vec![c_cmd.clone()];
400        for arg in &req.args {
401            c_args.push(
402                CString::new(arg.as_str())
403                    .map_err(|e| AgentdError::ExecSession(format!("invalid arg: {e}")))?,
404            );
405        }
406
407        // Build argv pointer array (null-terminated).
408        let argv_ptrs: Vec<*const libc::c_char> = c_args
409            .iter()
410            .map(|s| s.as_ptr())
411            .chain(iter::once(ptr::null()))
412            .collect();
413
414        // Pre-parse environment variables into CStrings.
415        let c_env: Vec<(CString, CString)> = req
416            .env
417            .iter()
418            .filter_map(|var| {
419                let (key, val) = var.split_once('=')?;
420                let k = CString::new(key).ok()?;
421                let v = CString::new(val).ok()?;
422                Some((k, v))
423            })
424            .collect();
425
426        // Pre-build cwd CString.
427        let c_cwd = req
428            .cwd
429            .as_ref()
430            .map(|dir| CString::new(dir.as_str()))
431            .transpose()
432            .map_err(|e| AgentdError::ExecSession(format!("invalid cwd: {e}")))?;
433
434        let resolved_user = resolve_requested_user(req, default_user)?;
435        let default_home = default_home_dir(req, resolved_user.as_ref())?;
436        let home_key = default_home
437            .as_ref()
438            .map(|_| {
439                CString::new("HOME")
440                    .map_err(|e| AgentdError::ExecSession(format!("invalid home env key: {e}")))
441            })
442            .transpose()?;
443
444        // Pre-parse rlimits before fork (no allocations in child).
445        let parsed_rlimits = rlimit::to_libc(&req.rlimits);
446
447        // Prevent the central reaper from observing this child before its PID
448        // and generation are registered.
449        let spawn_guard = process_manager.spawn_guard()?;
450
451        // Fork.
452        let pid = unsafe { libc::fork() };
453        if pid < 0 {
454            let io_err = std::io::Error::last_os_error();
455            return Err(AgentdError::ExecSpawnFailed(exec_failed_from_io_error(
456                &io_err, &req.cmd, "fork",
457            )));
458        }
459
460        #[allow(unreachable_code)]
461        if pid == 0 {
462            // Child process — only async-signal-safe operations from here.
463            drop(pty.master);
464            drop(err_pipe.read_end);
465
466            // Create new session.
467            if unsafe { libc::setsid() } < 0 {
468                unsafe { libc::_exit(1) };
469            }
470
471            // Set controlling terminal.
472            if unsafe { libc::ioctl(slave_fd, libc::TIOCSCTTY, 0) } < 0 {
473                unsafe { libc::_exit(1) };
474            }
475
476            // Dup slave to stdin/stdout/stderr.
477            unsafe {
478                if libc::dup2(slave_fd, 0) < 0 {
479                    libc::_exit(1);
480                }
481                if libc::dup2(slave_fd, 1) < 0 {
482                    libc::_exit(1);
483                }
484                if libc::dup2(slave_fd, 2) < 0 {
485                    libc::_exit(1);
486                }
487                if slave_fd > 2 {
488                    libc::close(slave_fd);
489                }
490            }
491
492            // Set environment variables using pre-built CStrings.
493            for (key, val) in &c_env {
494                unsafe {
495                    libc::setenv(key.as_ptr(), val.as_ptr(), 1);
496                }
497            }
498
499            // Set working directory.
500            if let Some(ref dir) = c_cwd {
501                unsafe {
502                    libc::chdir(dir.as_ptr());
503                }
504            }
505
506            if apply_exec_security_profile(security_profile).is_err() {
507                unsafe { libc::_exit(1) };
508            }
509
510            if let Some(ref user) = resolved_user
511                && apply_resolved_user(user).is_err()
512            {
513                unsafe { libc::_exit(1) };
514            }
515
516            if let (Some(key), Some(home)) = (&home_key, &default_home) {
517                unsafe {
518                    libc::setenv(key.as_ptr(), home.as_ptr(), 1);
519                }
520            }
521
522            // Apply resource limits.
523            for (resource, limit) in &parsed_rlimits {
524                if unsafe { libc::setrlimit(*resource as _, limit) } != 0 {
525                    unsafe { libc::_exit(1) };
526                }
527            }
528
529            // execvp — on success this never returns.
530            unsafe {
531                libc::execvp(argv_ptrs[0], argv_ptrs.as_ptr());
532            }
533
534            // If execvp returns, it failed.
535            write_exec_error_and_exit(err_pipe.write_end.as_raw_fd());
536        }
537
538        // Parent process.
539        drop(pty.slave);
540        drop(err_pipe.write_end);
541        let exit_watcher = spawn_guard.track(pid)?;
542        let process_identity = exit_watcher.identity();
543
544        match read_exec_error(err_pipe.read_end.as_raw_fd()) {
545            Ok(Some(exec_errno)) => {
546                drop(exit_watcher);
547                process_manager.release(process_identity);
548                let io_err = std::io::Error::from_raw_os_error(exec_errno);
549                return Err(AgentdError::ExecSpawnFailed(exec_failed_from_io_error(
550                    &io_err, &req.cmd, "execvp",
551                )));
552            }
553            Ok(None) => {}
554            Err(error) => {
555                let _ =
556                    process_manager.signal_process_group(process_identity, Signal::SIGKILL as i32);
557                process_manager.release(process_identity);
558                return Err(error);
559            }
560        }
561
562        // Dup the master fd for the reader task.
563        let reader_fd = unsafe { libc::dup(pty.master.as_raw_fd()) };
564        if reader_fd < 0 {
565            let _ = process_manager.signal_process_group(process_identity, Signal::SIGKILL as i32);
566            process_manager.release(process_identity);
567            return Err(std::io::Error::last_os_error().into());
568        }
569        let reader_fd = unsafe { OwnedFd::from_raw_fd(reader_fd) };
570
571        // Spawn background reader task.
572        tokio::spawn(pty_reader_task(id, reader_fd, exit_watcher, tx));
573
574        Ok(Self {
575            process_identity,
576            process_manager: Arc::clone(process_manager),
577            pty_master: Some(pty.master),
578            stdin: None,
579        })
580    }
581
582    /// Spawns a process with piped stdio.
583    fn spawn_pipe(
584        id: u32,
585        req: &ExecRequest,
586        tx: mpsc::UnboundedSender<(u32, SessionOutput)>,
587        default_user: Option<&str>,
588        security_profile: SecurityProfile,
589        process_manager: &Arc<ProcessManager>,
590    ) -> AgentdResult<Self> {
591        let mut cmd = Command::new(&req.cmd);
592        cmd.args(&req.args)
593            .stdin(Stdio::piped())
594            .stdout(Stdio::piped())
595            .stderr(Stdio::piped());
596
597        for var in &req.env {
598            if let Some((key, val)) = var.split_once('=') {
599                cmd.env(key, val);
600            }
601        }
602
603        if let Some(ref dir) = req.cwd {
604            cmd.current_dir(dir);
605        }
606
607        let resolved_user = resolve_requested_user(req, default_user)?;
608        if let Some(home) = default_home_dir(req, resolved_user.as_ref())? {
609            cmd.env("HOME", home.to_string_lossy().into_owned());
610        }
611
612        // Apply the security profile and resource limits in the child before exec.
613        let parsed_rlimits = rlimit::to_libc(&req.rlimits);
614        unsafe {
615            cmd.pre_exec(move || {
616                // Become a session (and process-group) leader so signals sent
617                // to the group reach every descendant the command spawns, not
618                // just the direct child. The PTY path does the same for its
619                // controlling terminal; here it exists purely for group kills.
620                if libc::setsid() < 0 {
621                    return Err(std::io::Error::last_os_error());
622                }
623                apply_exec_security_profile(security_profile).map_err(agentd_to_io_error)?;
624                if let Some(ref user) = resolved_user {
625                    apply_resolved_user(user).map_err(agentd_to_io_error)?;
626                }
627                for (resource, limit) in &parsed_rlimits {
628                    if libc::setrlimit(*resource as _, limit) != 0 {
629                        return Err(std::io::Error::last_os_error());
630                    }
631                }
632                Ok(())
633            });
634        }
635
636        let PipedProcess {
637            stdin,
638            stdout,
639            stderr,
640            exit_watcher,
641        } = spawn_piped_process(cmd, process_manager)?;
642        let process_identity = exit_watcher.identity();
643
644        // Spawn background reader task.
645        tokio::spawn(pipe_reader_task(id, stdout, stderr, exit_watcher, tx));
646
647        Ok(Self {
648            process_identity,
649            process_manager: Arc::clone(process_manager),
650            pty_master: None,
651            stdin,
652        })
653    }
654}
655
656//--------------------------------------------------------------------------------------------------
657// Trait Implementations
658//--------------------------------------------------------------------------------------------------
659
660impl Drop for ExecSession {
661    fn drop(&mut self) {
662        // The registration deliberately outlives the direct child so signals
663        // can still reach descendants while their output is being drained.
664        self.process_manager.release(self.process_identity);
665    }
666}
667
668//--------------------------------------------------------------------------------------------------
669// Functions
670//--------------------------------------------------------------------------------------------------
671
672fn spawn_piped_process(
673    mut command: Command,
674    process_manager: &ProcessManager,
675) -> AgentdResult<PipedProcess> {
676    let cmd_label = command.get_program().to_string_lossy().into_owned();
677
678    // Prevent the central reaper from observing this child before its PID and
679    // generation are registered.
680    let spawn_guard = process_manager.spawn_guard()?;
681    let mut child = command.spawn().map_err(|error| {
682        AgentdError::ExecSpawnFailed(exec_failed_from_io_error(
683            &error,
684            &cmd_label,
685            "Command::spawn",
686        ))
687    })?;
688    let pid = child.id() as i32;
689    let exit_watcher = spawn_guard.track(pid)?;
690    let process_identity = exit_watcher.identity();
691
692    let stdio = (|| {
693        let stdin = child
694            .stdin
695            .take()
696            .map(tokio::process::ChildStdin::from_std)
697            .transpose()?;
698        let stdout = child
699            .stdout
700            .take()
701            .map(tokio::process::ChildStdout::from_std)
702            .transpose()?;
703        let stderr = child
704            .stderr
705            .take()
706            .map(tokio::process::ChildStderr::from_std)
707            .transpose()?;
708        Ok::<_, std::io::Error>((stdin, stdout, stderr))
709    })();
710    let (stdin, stdout, stderr) = stdio.map_err(|error| {
711        // The command has already exec'd successfully. If an async stdio
712        // adapter cannot be registered, do not leave an unreported process
713        // group running after the host receives ExecFailed.
714        let _ = process_manager.signal_process_group(process_identity, Signal::SIGKILL as i32);
715        process_manager.release(process_identity);
716        AgentdError::ExecSpawnFailed(exec_failed_from_io_error(
717            &error,
718            &cmd_label,
719            "Command::spawn",
720        ))
721    })?;
722
723    // `std::process::Child` has no asynchronous reaper on drop. Once the PID
724    // is tracked, the process manager owns its exit status during normal
725    // operation; terminal teardown may reap it directly as a fallback.
726    drop(child);
727
728    Ok(PipedProcess {
729        stdin,
730        stdout,
731        stderr,
732        exit_watcher,
733    })
734}
735
736fn new_exec_error_pipe() -> AgentdResult<ExecErrorPipe> {
737    let mut fds = [0; 2];
738    let ret = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) };
739    if ret != 0 {
740        return Err(std::io::Error::last_os_error().into());
741    }
742
743    Ok(ExecErrorPipe {
744        read_end: unsafe { OwnedFd::from_raw_fd(fds[0]) },
745        write_end: unsafe { OwnedFd::from_raw_fd(fds[1]) },
746    })
747}
748
749fn write_exec_error_and_exit(err_fd: RawFd) -> ! {
750    let errno = unsafe { *libc::__errno_location() };
751    let bytes = errno.to_ne_bytes();
752    let _ = unsafe { libc::write(err_fd, bytes.as_ptr() as *const libc::c_void, bytes.len()) };
753    unsafe { libc::_exit(127) }
754}
755
756fn read_exec_error(err_fd: RawFd) -> AgentdResult<Option<i32>> {
757    let mut buf = [0u8; mem::size_of::<i32>()];
758    let n = unsafe { libc::read(err_fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
759    if n < 0 {
760        return Err(std::io::Error::last_os_error().into());
761    }
762    if n == 0 {
763        return Ok(None);
764    }
765    if n as usize != buf.len() {
766        return Err(AgentdError::ExecSession(format!(
767            "short exec error report: expected {} bytes, got {n}",
768            buf.len()
769        )));
770    }
771    Ok(Some(i32::from_ne_bytes(buf)))
772}
773
774fn apply_exec_security_profile(profile: SecurityProfile) -> AgentdResult<()> {
775    match profile {
776        SecurityProfile::Default => Ok(()),
777        SecurityProfile::Restricted => drop_mount_admin_privileges(),
778    }
779}
780
781fn drop_mount_admin_privileges() -> AgentdResult<()> {
782    if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
783        return Err(std::io::Error::last_os_error().into());
784    }
785
786    let ret = unsafe { libc::prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_CLEAR_ALL, 0, 0, 0) };
787    if ret != 0 {
788        let err = std::io::Error::last_os_error();
789        if err.raw_os_error() != Some(libc::EINVAL) {
790            return Err(err.into());
791        }
792    }
793
794    let mut header = CapUserHeader {
795        version: LINUX_CAPABILITY_VERSION_3,
796        pid: 0,
797    };
798    let mut data = [CapUserData {
799        effective: 0,
800        permitted: 0,
801        inheritable: 0,
802    }; 2];
803
804    if unsafe { libc::syscall(libc::SYS_capget, &mut header, data.as_mut_ptr()) } != 0 {
805        return Err(std::io::Error::last_os_error().into());
806    }
807
808    let index = (CAP_SYS_ADMIN / CAP_WORD_BITS) as usize;
809    let mask = 1u32 << (CAP_SYS_ADMIN % CAP_WORD_BITS);
810    let had_sys_admin = data[index].effective & mask != 0
811        || data[index].permitted & mask != 0
812        || data[index].inheritable & mask != 0;
813
814    if had_sys_admin {
815        data[index].effective &= !mask;
816        data[index].permitted &= !mask;
817        data[index].inheritable &= !mask;
818
819        if unsafe { libc::syscall(libc::SYS_capset, &mut header, data.as_ptr()) } != 0 {
820            return Err(std::io::Error::last_os_error().into());
821        }
822    }
823
824    let ret = unsafe { libc::prctl(PR_CAPBSET_DROP, CAP_SYS_ADMIN, 0, 0, 0) };
825    if ret != 0 {
826        let err = std::io::Error::last_os_error();
827        let errno = err.raw_os_error();
828        // Already-unprivileged callers may also lack CAP_SETPCAP for the bounding-set drop.
829        let already_unprivileged = !had_sys_admin && errno == Some(libc::EPERM);
830        if errno != Some(libc::EINVAL) && !already_unprivileged {
831            return Err(err.into());
832        }
833    }
834
835    Ok(())
836}
837
838pub(crate) fn resolve_default_user(default_user: Option<&str>) -> AgentdResult<(u32, u32)> {
839    let Some(spec) = default_user
840        .map(str::trim)
841        .filter(|value| !value.is_empty())
842    else {
843        return Ok((0, 0));
844    };
845
846    let resolved = resolve_user_spec(spec)?;
847    Ok((resolved.uid, resolved.gid))
848}
849
850fn resolve_requested_user(
851    req: &ExecRequest,
852    default_user: Option<&str>,
853) -> AgentdResult<Option<ResolvedUser>> {
854    let default_user = default_user
855        .map(str::trim)
856        .filter(|value| !value.is_empty());
857    let requested = req
858        .user
859        .as_deref()
860        .map(str::trim)
861        .filter(|value| !value.is_empty())
862        .or(default_user);
863
864    requested.map(resolve_user_spec).transpose()
865}
866
867fn resolve_user_spec(spec: &str) -> AgentdResult<ResolvedUser> {
868    let (user_part, group_part) = match spec.split_once(':') {
869        Some((user, group)) => (user.trim(), Some(group.trim())),
870        None => (spec.trim(), None),
871    };
872
873    if user_part.is_empty() {
874        return Err(AgentdError::ExecSession("user spec has empty user".into()));
875    }
876
877    let passwd = if let Ok(uid) = parse_id(user_part) {
878        lookup_passwd_by_uid(uid)?
879    } else {
880        lookup_passwd_by_name(user_part)?
881            .ok_or_else(|| AgentdError::ExecSession(format!("guest user not found: {user_part}")))?
882            .into()
883    };
884
885    let (uid, passwd_entry) = match passwd {
886        ResolvedUserLookup::Known(entry) => (entry.uid, Some(entry)),
887        ResolvedUserLookup::Numeric(uid) => (uid, None),
888    };
889
890    let gid = match group_part {
891        Some("") => {
892            return Err(AgentdError::ExecSession("user spec has empty group".into()));
893        }
894        Some(group) => resolve_group_spec(group)?,
895        None => passwd_entry
896            .as_ref()
897            .map(|entry| entry.gid)
898            .unwrap_or_else(|| unsafe { libc::getgid() }),
899    };
900
901    let initgroups_user = passwd_entry
902        .as_ref()
903        .map(|entry| CString::new(entry.name.as_str()))
904        .transpose()
905        .map_err(|e| AgentdError::ExecSession(format!("invalid guest user name: {e}")))?;
906
907    Ok(ResolvedUser {
908        uid,
909        gid,
910        initgroups_user,
911        home_dir: passwd_entry
912            .as_ref()
913            .and_then(|entry| entry.home_dir.as_deref())
914            .map(CString::new)
915            .transpose()
916            .map_err(|e| AgentdError::ExecSession(format!("invalid guest home directory: {e}")))?,
917    })
918}
919
920enum ResolvedUserLookup {
921    Known(PasswdEntry),
922    Numeric(libc::uid_t),
923}
924
925impl From<PasswdEntry> for ResolvedUserLookup {
926    fn from(value: PasswdEntry) -> Self {
927        Self::Known(value)
928    }
929}
930
931fn resolve_group_spec(spec: &str) -> AgentdResult<libc::gid_t> {
932    if let Ok(gid) = parse_id(spec) {
933        return Ok(gid);
934    }
935
936    lookup_group_by_name(spec)?
937        .map(|entry| entry.gid)
938        .ok_or_else(|| AgentdError::ExecSession(format!("guest group not found: {spec}")))
939}
940
941fn parse_id(value: &str) -> Result<u32, std::num::ParseIntError> {
942    value.parse::<u32>()
943}
944
945fn lookup_passwd_by_name(name: &str) -> AgentdResult<Option<PasswdEntry>> {
946    let name = CString::new(name)
947        .map_err(|e| AgentdError::ExecSession(format!("invalid guest user name: {e}")))?;
948    let mut pwd = MaybeUninit::<libc::passwd>::uninit();
949    let mut result = ptr::null_mut();
950    let mut buf = vec![0u8; lookup_buffer_len()];
951    let rc = unsafe {
952        libc::getpwnam_r(
953            name.as_ptr(),
954            pwd.as_mut_ptr(),
955            buf.as_mut_ptr().cast(),
956            buf.len(),
957            &mut result,
958        )
959    };
960    if rc != 0 {
961        return Err(AgentdError::ExecSession(format!(
962            "failed to resolve guest user {name:?}: {}",
963            std::io::Error::from_raw_os_error(rc)
964        )));
965    }
966    if result.is_null() {
967        return Ok(None);
968    }
969
970    let pwd = unsafe { pwd.assume_init() };
971    let name = unsafe { CStr::from_ptr(pwd.pw_name) }
972        .to_string_lossy()
973        .into_owned();
974    let home_dir = unsafe { CStr::from_ptr(pwd.pw_dir) }
975        .to_string_lossy()
976        .into_owned();
977    Ok(Some(PasswdEntry {
978        name,
979        uid: pwd.pw_uid,
980        gid: pwd.pw_gid,
981        home_dir: (!home_dir.is_empty()).then_some(home_dir),
982    }))
983}
984
985fn lookup_passwd_by_uid(uid: libc::uid_t) -> AgentdResult<ResolvedUserLookup> {
986    let mut pwd = MaybeUninit::<libc::passwd>::uninit();
987    let mut result = ptr::null_mut();
988    let mut buf = vec![0u8; lookup_buffer_len()];
989    let rc = unsafe {
990        libc::getpwuid_r(
991            uid,
992            pwd.as_mut_ptr(),
993            buf.as_mut_ptr().cast(),
994            buf.len(),
995            &mut result,
996        )
997    };
998    if rc != 0 {
999        return Err(AgentdError::ExecSession(format!(
1000            "failed to resolve guest uid {uid}: {}",
1001            std::io::Error::from_raw_os_error(rc)
1002        )));
1003    }
1004    if result.is_null() {
1005        return Ok(ResolvedUserLookup::Numeric(uid));
1006    }
1007
1008    let pwd = unsafe { pwd.assume_init() };
1009    let name = unsafe { CStr::from_ptr(pwd.pw_name) }
1010        .to_string_lossy()
1011        .into_owned();
1012    let home_dir = unsafe { CStr::from_ptr(pwd.pw_dir) }
1013        .to_string_lossy()
1014        .into_owned();
1015    Ok(ResolvedUserLookup::Known(PasswdEntry {
1016        name,
1017        uid: pwd.pw_uid,
1018        gid: pwd.pw_gid,
1019        home_dir: (!home_dir.is_empty()).then_some(home_dir),
1020    }))
1021}
1022
1023fn lookup_group_by_name(name: &str) -> AgentdResult<Option<GroupEntry>> {
1024    let name = CString::new(name)
1025        .map_err(|e| AgentdError::ExecSession(format!("invalid guest group name: {e}")))?;
1026    let mut grp = MaybeUninit::<libc::group>::uninit();
1027    let mut result = ptr::null_mut();
1028    let mut buf = vec![0u8; lookup_buffer_len()];
1029    let rc = unsafe {
1030        libc::getgrnam_r(
1031            name.as_ptr(),
1032            grp.as_mut_ptr(),
1033            buf.as_mut_ptr().cast(),
1034            buf.len(),
1035            &mut result,
1036        )
1037    };
1038    if rc != 0 {
1039        return Err(AgentdError::ExecSession(format!(
1040            "failed to resolve guest group {name:?}: {}",
1041            std::io::Error::from_raw_os_error(rc)
1042        )));
1043    }
1044    if result.is_null() {
1045        return Ok(None);
1046    }
1047
1048    let grp = unsafe { grp.assume_init() };
1049    Ok(Some(GroupEntry { gid: grp.gr_gid }))
1050}
1051
1052fn lookup_buffer_len() -> usize {
1053    let size = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) };
1054    if size > 0 { size as usize } else { 16 * 1024 }
1055}
1056
1057fn apply_resolved_user(user: &ResolvedUser) -> AgentdResult<()> {
1058    if let Some(ref name) = user.initgroups_user {
1059        if unsafe { libc::initgroups(name.as_ptr(), user.gid) } != 0 {
1060            return Err(std::io::Error::last_os_error().into());
1061        }
1062    } else if unsafe { libc::setgroups(0, ptr::null()) } != 0 {
1063        return Err(std::io::Error::last_os_error().into());
1064    }
1065
1066    if unsafe { libc::setgid(user.gid) } != 0 {
1067        return Err(std::io::Error::last_os_error().into());
1068    }
1069    if unsafe { libc::setuid(user.uid) } != 0 {
1070        return Err(std::io::Error::last_os_error().into());
1071    }
1072
1073    Ok(())
1074}
1075
1076fn default_home_dir(
1077    req: &ExecRequest,
1078    user: Option<&ResolvedUser>,
1079) -> AgentdResult<Option<CString>> {
1080    if env_contains_key(&req.env, "HOME") {
1081        return Ok(None);
1082    }
1083
1084    if let Some(user) = user {
1085        return Ok(user.home_dir.clone());
1086    }
1087
1088    Ok(resolve_user_spec(DEFAULT_USER_SPEC)?.home_dir)
1089}
1090
1091fn env_contains_key(env: &[String], key: &str) -> bool {
1092    env.iter().any(|entry| {
1093        entry
1094            .split_once('=')
1095            .map(|(entry_key, _)| entry_key == key)
1096            .unwrap_or(false)
1097    })
1098}
1099
1100fn agentd_to_io_error(err: AgentdError) -> std::io::Error {
1101    std::io::Error::other(err.to_string())
1102}
1103
1104/// Writes data to a raw fd using a blocking task, handling short writes.
1105async fn blocking_write_fd(fd: RawFd, data: &[u8]) -> AgentdResult<()> {
1106    let data = data.to_vec();
1107    tokio::task::spawn_blocking(move || {
1108        let mut written = 0;
1109        while written < data.len() {
1110            let ptr = unsafe { data.as_ptr().add(written) as *const libc::c_void };
1111            let ret = unsafe { libc::write(fd, ptr, data.len() - written) };
1112            if ret < 0 {
1113                let err = std::io::Error::last_os_error();
1114                let code = err.raw_os_error();
1115                if code == Some(libc::EAGAIN) || code == Some(libc::EWOULDBLOCK) {
1116                    wait_fd_writable(fd)?;
1117                    continue;
1118                }
1119                if code == Some(libc::EINTR) {
1120                    continue;
1121                }
1122                return Err(AgentdError::Io(err));
1123            }
1124            if ret == 0 {
1125                wait_fd_writable(fd)?;
1126                continue;
1127            }
1128            written += ret as usize;
1129        }
1130        Ok(())
1131    })
1132    .await
1133    .map_err(|e| AgentdError::ExecSession(format!("stdin write join error: {e}")))?
1134}
1135
1136fn wait_fd_writable(fd: RawFd) -> AgentdResult<()> {
1137    let mut pollfd = libc::pollfd {
1138        fd,
1139        events: libc::POLLOUT,
1140        revents: 0,
1141    };
1142
1143    loop {
1144        let ret = unsafe { libc::poll(&mut pollfd, 1, -1) };
1145        if ret < 0 {
1146            let err = std::io::Error::last_os_error();
1147            if err.raw_os_error() == Some(libc::EINTR) {
1148                continue;
1149            }
1150            return Err(AgentdError::Io(err));
1151        }
1152        if ret == 0 {
1153            continue;
1154        }
1155        // Any positive return means the fd is actionable: POLLOUT lets the
1156        // next write make progress, and POLLHUP/POLLERR/POLLNVAL will cause
1157        // the next write to fail with a real errno (typically EPIPE) which
1158        // is more meaningful than poll's revents.
1159        return Ok(());
1160    }
1161}
1162
1163/// Background task that reads from a PTY master fd and sends output events.
1164async fn pty_reader_task(
1165    id: u32,
1166    master_fd: OwnedFd,
1167    exit_watcher: ProcessExitWatcher,
1168    tx: mpsc::UnboundedSender<(u32, SessionOutput)>,
1169) {
1170    let tx_output = tx.clone();
1171    let read_result = tokio::task::spawn_blocking(move || {
1172        // PTY masters are safer with a dedicated blocking read loop than with
1173        // edge-driven readiness. Fast writers followed by process exit can
1174        // strand the tail behind a missed wakeup/HUP transition.
1175        let raw = master_fd.as_raw_fd();
1176        let flags = unsafe { libc::fcntl(raw, libc::F_GETFL) };
1177        if flags >= 0 {
1178            unsafe { libc::fcntl(raw, libc::F_SETFL, flags & !libc::O_NONBLOCK) };
1179        }
1180
1181        loop {
1182            let mut buf = [0u8; 4096];
1183            let n = unsafe { libc::read(raw, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
1184
1185            if n > 0 {
1186                if tx_output
1187                    .send((id, SessionOutput::Stdout(buf[..n as usize].to_vec())))
1188                    .is_err()
1189                {
1190                    break;
1191                }
1192                continue;
1193            }
1194
1195            if n == 0 {
1196                break;
1197            }
1198
1199            let err = std::io::Error::last_os_error();
1200            match err.raw_os_error() {
1201                Some(libc::EINTR) => continue,
1202                Some(libc::EIO) => break,
1203                _ => break,
1204            }
1205        }
1206    })
1207    .await;
1208
1209    let _ = read_result;
1210
1211    let code = exit_watcher.await;
1212    let _ = tx.send((id, SessionOutput::Exited(code)));
1213}
1214
1215/// Background task that reads from piped stdout/stderr and sends output events.
1216async fn pipe_reader_task(
1217    id: u32,
1218    stdout: Option<tokio::process::ChildStdout>,
1219    stderr: Option<tokio::process::ChildStderr>,
1220    exit_watcher: ProcessExitWatcher,
1221    tx: mpsc::UnboundedSender<(u32, SessionOutput)>,
1222) {
1223    let mut stdout = stdout;
1224    let mut stderr = stderr;
1225    let mut stdout_eof = stdout.is_none();
1226    let mut stderr_eof = stderr.is_none();
1227
1228    while !stdout_eof || !stderr_eof {
1229        let mut stdout_buf = [0u8; 4096];
1230        let mut stderr_buf = [0u8; 4096];
1231
1232        tokio::select! {
1233            result = async {
1234                match stdout.as_mut() {
1235                    Some(out) => out.read(&mut stdout_buf).await,
1236                    None => std::future::pending().await,
1237                }
1238            }, if !stdout_eof => {
1239                match result {
1240                    Ok(0) | Err(_) => {
1241                        stdout = None;
1242                        stdout_eof = true;
1243                    }
1244                    Ok(n) => {
1245                        let _ = tx.send((id, SessionOutput::Stdout(stdout_buf[..n].to_vec())));
1246                    }
1247                }
1248            }
1249            result = async {
1250                match stderr.as_mut() {
1251                    Some(err) => err.read(&mut stderr_buf).await,
1252                    None => std::future::pending().await,
1253                }
1254            }, if !stderr_eof => {
1255                match result {
1256                    Ok(0) | Err(_) => {
1257                        stderr = None;
1258                        stderr_eof = true;
1259                    }
1260                    Ok(n) => {
1261                        let _ = tx.send((id, SessionOutput::Stderr(stderr_buf[..n].to_vec())));
1262                    }
1263                }
1264            }
1265        }
1266    }
1267
1268    let code = exit_watcher.await;
1269
1270    let _ = tx.send((id, SessionOutput::Exited(code)));
1271}
1272
1273//--------------------------------------------------------------------------------------------------
1274// Tests
1275//--------------------------------------------------------------------------------------------------
1276
1277#[cfg(test)]
1278mod tests {
1279    use std::collections::HashMap;
1280    use std::io::Read;
1281    use std::process::{Command as StdCommand, Stdio as StdStdio};
1282    use std::time::Duration;
1283
1284    use tokio::time;
1285
1286    use microsandbox_protocol::exec::ExecRequest;
1287
1288    use super::*;
1289
1290    const REAP_HELPER_ENV: &str = "MSB_AGENTD_SESSION_REAP_HELPER";
1291    const REAP_HELPER_SENTINEL: &str = "session-reap-helper-passed";
1292    const REAP_TEST_NAME: &str = "session::tests::test_spawn_reaps_adopted_descendant";
1293    const CONCURRENT_HELPER_ENV: &str = "MSB_AGENTD_CONCURRENT_SPAWN_HELPER";
1294    const CONCURRENT_HELPER_SENTINEL: &str = "concurrent-spawn-helper-passed";
1295    const CONCURRENT_TEST_NAME: &str = "session::tests::test_concurrent_spawn_exit_codes";
1296    const RUNTIME_HELPER_ENV: &str = "MSB_AGENTD_RUNTIME_REPLACEMENT_HELPER";
1297    const RUNTIME_HELPER_SENTINEL: &str = "runtime-replacement-helper-passed";
1298    const RUNTIME_TEST_NAME: &str = "session::tests::test_spawn_survives_runtime_replacement";
1299    const PIPE_OWNER_HELPER_ENV: &str = "MSB_AGENTD_PIPE_OWNER_HELPER";
1300    const PIPE_OWNER_HELPER_SENTINEL: &str = "pipe-owner-helper-passed";
1301    const PIPE_OWNER_TEST_NAME: &str =
1302        "session::tests::test_piped_process_exit_outlives_spawning_runtime";
1303
1304    #[test]
1305    fn test_spawn_reaps_adopted_descendant() {
1306        if std::env::var_os(REAP_HELPER_ENV).is_some() {
1307            let runtime = tokio::runtime::Builder::new_current_thread()
1308                .enable_all()
1309                .build()
1310                .expect("session reap test runtime");
1311            runtime.block_on(run_adopted_descendant_scenario());
1312            println!("{REAP_HELPER_SENTINEL}");
1313            return;
1314        }
1315
1316        let mut helper = StdCommand::new(std::env::current_exe().expect("current test binary"))
1317            .args(["--exact", REAP_TEST_NAME, "--nocapture"])
1318            .env(REAP_HELPER_ENV, "1")
1319            .stdout(StdStdio::piped())
1320            .spawn()
1321            .expect("spawn isolated session reap test");
1322        let mut output = String::new();
1323        helper
1324            .stdout
1325            .take()
1326            .expect("helper stdout")
1327            .read_to_string(&mut output)
1328            .expect("read helper stdout");
1329
1330        match helper.wait() {
1331            Ok(status) => assert!(status.success(), "helper failed: {status}\n{output}"),
1332            Err(error) if error.raw_os_error() == Some(libc::ECHILD) => {}
1333            Err(error) => panic!("wait for helper: {error}"),
1334        }
1335        assert!(
1336            output.contains(REAP_HELPER_SENTINEL),
1337            "helper did not complete the session reap scenario:\n{output}"
1338        );
1339    }
1340
1341    async fn run_adopted_descendant_scenario() {
1342        let ret = unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1) };
1343        assert_eq!(
1344            ret,
1345            0,
1346            "set child subreaper: {}",
1347            std::io::Error::last_os_error()
1348        );
1349
1350        let (tx, mut rx) = mpsc::unbounded_channel();
1351        let req = ExecRequest {
1352            cmd: "/bin/sh".to_string(),
1353            args: vec!["-c".to_string(), "sleep 30 & echo $!".to_string()],
1354            env: Vec::new(),
1355            cwd: None,
1356            user: None,
1357            tty: false,
1358            rows: 24,
1359            cols: 80,
1360            rlimits: Vec::new(),
1361        };
1362
1363        let session = ExecSession::spawn(17, &req, tx, None, SecurityProfile::Default)
1364            .expect("spawn background descendant session");
1365        let leader_pid = session.pid() as i32;
1366        let mut stdout = Vec::new();
1367        time::timeout(Duration::from_secs(10), async {
1368            while !stdout.contains(&b'\n') {
1369                let (id, output) = rx.recv().await.expect("session output");
1370                assert_eq!(id, 17);
1371                match output {
1372                    SessionOutput::Stdout(data) => stdout.extend_from_slice(&data),
1373                    SessionOutput::Exited(code) => panic!("session exited early with {code}"),
1374                    SessionOutput::Stderr(_) | SessionOutput::Raw(_) => {}
1375                }
1376            }
1377        })
1378        .await
1379        .expect("wait for background descendant session");
1380
1381        let descendant_pid: i32 = String::from_utf8(stdout)
1382            .expect("descendant PID is UTF-8")
1383            .trim()
1384            .parse()
1385            .expect("parse descendant PID");
1386        let expected_parent = std::process::id().to_string();
1387        let status_path = format!("/proc/{descendant_pid}/status");
1388        time::timeout(Duration::from_secs(5), async {
1389            loop {
1390                if let Ok(status) = std::fs::read_to_string(&status_path)
1391                    && status
1392                        .lines()
1393                        .find_map(|line| line.strip_prefix("PPid:"))
1394                        .is_some_and(|ppid| ppid.trim() == expected_parent)
1395                {
1396                    break;
1397                }
1398                time::sleep(Duration::from_millis(10)).await;
1399            }
1400        })
1401        .await
1402        .expect("descendant should be adopted by the helper subreaper");
1403
1404        let leader_path = format!("/proc/{leader_pid}");
1405        time::timeout(Duration::from_secs(5), async {
1406            while std::path::Path::new(&leader_path).exists() {
1407                time::sleep(Duration::from_millis(10)).await;
1408            }
1409        })
1410        .await
1411        .expect("direct child should be reaped before signalling its descendants");
1412
1413        session
1414            .send_signal(libc::SIGTERM)
1415            .expect("signal descendants through completed process registration");
1416        let exit = time::timeout(Duration::from_secs(5), async {
1417            loop {
1418                let (id, output) = rx.recv().await.expect("session output after signal");
1419                assert_eq!(id, 17);
1420                if let SessionOutput::Exited(code) = output {
1421                    break code;
1422                }
1423            }
1424        })
1425        .await
1426        .expect("session should finish after its descendant is signalled");
1427        assert_eq!(exit, 0);
1428
1429        let proc_path = format!("/proc/{descendant_pid}");
1430        time::timeout(Duration::from_secs(5), async {
1431            while std::path::Path::new(&proc_path).exists() {
1432                time::sleep(Duration::from_millis(10)).await;
1433            }
1434        })
1435        .await
1436        .expect("descendant should be reaped");
1437
1438        let ret = unsafe { libc::waitpid(descendant_pid, ptr::null_mut(), libc::WNOHANG) };
1439        assert_eq!(ret, -1, "descendant {descendant_pid} was not reaped");
1440        assert_eq!(
1441            std::io::Error::last_os_error().raw_os_error(),
1442            Some(libc::ECHILD)
1443        );
1444    }
1445
1446    #[test]
1447    fn test_concurrent_spawn_exit_codes() {
1448        if std::env::var_os(CONCURRENT_HELPER_ENV).is_some() {
1449            let runtime = tokio::runtime::Builder::new_current_thread()
1450                .enable_all()
1451                .build()
1452                .expect("concurrent spawn test runtime");
1453            runtime.block_on(run_concurrent_spawn_scenario());
1454            println!("{CONCURRENT_HELPER_SENTINEL}");
1455            return;
1456        }
1457
1458        let mut helper = StdCommand::new(std::env::current_exe().expect("current test binary"))
1459            .args(["--exact", CONCURRENT_TEST_NAME, "--nocapture"])
1460            .env(CONCURRENT_HELPER_ENV, "1")
1461            .stdout(StdStdio::piped())
1462            .spawn()
1463            .expect("spawn isolated concurrent session test");
1464        let mut output = String::new();
1465        helper
1466            .stdout
1467            .take()
1468            .expect("helper stdout")
1469            .read_to_string(&mut output)
1470            .expect("read helper stdout");
1471
1472        match helper.wait() {
1473            Ok(status) => assert!(status.success(), "helper failed: {status}\n{output}"),
1474            Err(error) if error.raw_os_error() == Some(libc::ECHILD) => {}
1475            Err(error) => panic!("wait for helper: {error}"),
1476        }
1477        assert!(
1478            output.contains(CONCURRENT_HELPER_SENTINEL),
1479            "helper did not complete the concurrent spawn scenario:\n{output}"
1480        );
1481    }
1482
1483    async fn run_concurrent_spawn_scenario() {
1484        const PROCESS_COUNT: u32 = 12;
1485
1486        let runtime_handle = tokio::runtime::Handle::current();
1487        let (tx, mut rx) = mpsc::unbounded_channel();
1488        let mut spawn_threads = Vec::new();
1489        for offset in 0..PROCESS_COUNT {
1490            let handle = runtime_handle.clone();
1491            let tx = tx.clone();
1492            spawn_threads.push(std::thread::spawn(move || {
1493                let _runtime = handle.enter();
1494                let code = 20 + offset as i32;
1495                let req = ExecRequest {
1496                    cmd: "/bin/sh".to_string(),
1497                    args: vec!["-c".to_string(), format!("exit {code}")],
1498                    env: Vec::new(),
1499                    cwd: None,
1500                    user: None,
1501                    tty: offset % 2 == 1,
1502                    rows: 24,
1503                    cols: 80,
1504                    rlimits: Vec::new(),
1505                };
1506                ExecSession::spawn(100 + offset, &req, tx, None, SecurityProfile::Default)
1507            }));
1508        }
1509        drop(tx);
1510
1511        let mut sessions = Vec::new();
1512        for thread in spawn_threads {
1513            sessions.push(
1514                thread
1515                    .join()
1516                    .expect("concurrent spawn thread")
1517                    .expect("concurrent process spawn"),
1518            );
1519        }
1520
1521        let mut exits = HashMap::new();
1522        time::timeout(Duration::from_secs(15), async {
1523            while exits.len() < PROCESS_COUNT as usize {
1524                let (id, output) = rx.recv().await.expect("session output");
1525                if let SessionOutput::Exited(code) = output {
1526                    exits.insert(id, code);
1527                }
1528            }
1529        })
1530        .await
1531        .expect("wait for concurrent exits");
1532
1533        for offset in 0..PROCESS_COUNT {
1534            assert_eq!(exits.get(&(100 + offset)), Some(&(20 + offset as i32)));
1535        }
1536        drop(sessions);
1537    }
1538
1539    #[test]
1540    fn test_spawn_survives_runtime_replacement() {
1541        if std::env::var_os(RUNTIME_HELPER_ENV).is_some() {
1542            run_runtime_replacement_scenario();
1543            println!("{RUNTIME_HELPER_SENTINEL}");
1544            return;
1545        }
1546
1547        let mut helper = StdCommand::new(std::env::current_exe().expect("current test binary"))
1548            .args(["--exact", RUNTIME_TEST_NAME, "--nocapture"])
1549            .env(RUNTIME_HELPER_ENV, "1")
1550            .stdout(StdStdio::piped())
1551            .spawn()
1552            .expect("spawn isolated runtime replacement test");
1553        let mut output = String::new();
1554        helper
1555            .stdout
1556            .take()
1557            .expect("helper stdout")
1558            .read_to_string(&mut output)
1559            .expect("read helper stdout");
1560
1561        match helper.wait() {
1562            Ok(status) => assert!(status.success(), "helper failed: {status}\n{output}"),
1563            Err(error) if error.raw_os_error() == Some(libc::ECHILD) => {}
1564            Err(error) => panic!("wait for helper: {error}"),
1565        }
1566        assert!(
1567            output.contains(RUNTIME_HELPER_SENTINEL),
1568            "helper did not complete the runtime replacement scenario:\n{output}"
1569        );
1570    }
1571
1572    fn run_runtime_replacement_scenario() {
1573        for (id, code) in [(201, 51), (202, 52)] {
1574            let runtime = tokio::runtime::Builder::new_current_thread()
1575                .enable_all()
1576                .build()
1577                .expect("replacement test runtime");
1578            runtime.block_on(run_single_pipe_spawn(id, code));
1579        }
1580    }
1581
1582    async fn run_single_pipe_spawn(id: u32, code: i32) {
1583        let (tx, mut rx) = mpsc::unbounded_channel();
1584        let req = ExecRequest {
1585            cmd: "/bin/sh".to_string(),
1586            args: vec!["-c".to_string(), format!("exit {code}")],
1587            env: Vec::new(),
1588            cwd: None,
1589            user: None,
1590            tty: false,
1591            rows: 24,
1592            cols: 80,
1593            rlimits: Vec::new(),
1594        };
1595        let _session = ExecSession::spawn(id, &req, tx, None, SecurityProfile::Default)
1596            .expect("spawn session on replacement runtime");
1597
1598        let actual = time::timeout(Duration::from_secs(5), async {
1599            loop {
1600                let (actual_id, output) = rx.recv().await.expect("session output");
1601                assert_eq!(actual_id, id);
1602                if let SessionOutput::Exited(actual) = output {
1603                    break actual;
1604                }
1605            }
1606        })
1607        .await
1608        .expect("wait for exit on replacement runtime");
1609        assert_eq!(actual, code);
1610    }
1611
1612    #[test]
1613    fn test_piped_process_exit_outlives_spawning_runtime() {
1614        if std::env::var_os(PIPE_OWNER_HELPER_ENV).is_some() {
1615            run_piped_process_exit_scenario();
1616            println!("{PIPE_OWNER_HELPER_SENTINEL}");
1617            return;
1618        }
1619
1620        let mut helper = StdCommand::new(std::env::current_exe().expect("current test binary"))
1621            .args(["--exact", PIPE_OWNER_TEST_NAME, "--nocapture"])
1622            .env(PIPE_OWNER_HELPER_ENV, "1")
1623            .stdout(StdStdio::piped())
1624            .spawn()
1625            .expect("spawn isolated pipe owner test");
1626        let mut output = String::new();
1627        helper
1628            .stdout
1629            .take()
1630            .expect("helper stdout")
1631            .read_to_string(&mut output)
1632            .expect("read helper stdout");
1633
1634        match helper.wait() {
1635            Ok(status) => assert!(status.success(), "helper failed: {status}\n{output}"),
1636            Err(error) if error.raw_os_error() == Some(libc::ECHILD) => {}
1637            Err(error) => panic!("wait for helper: {error}"),
1638        }
1639        assert!(
1640            output.contains(PIPE_OWNER_HELPER_SENTINEL),
1641            "helper did not complete the pipe owner scenario:\n{output}"
1642        );
1643    }
1644
1645    fn run_piped_process_exit_scenario() {
1646        let process_manager = ProcessManager::get().expect("get process manager");
1647        let spawning_runtime = tokio::runtime::Builder::new_current_thread()
1648            .enable_all()
1649            .build()
1650            .expect("spawning runtime");
1651        let exit_watcher = {
1652            let _runtime_guard = spawning_runtime.enter();
1653            let mut command = Command::new("/bin/sh");
1654            command
1655                .args(["-c", "exit 63"])
1656                .stdin(Stdio::piped())
1657                .stdout(Stdio::piped())
1658                .stderr(Stdio::piped());
1659            let process =
1660                spawn_piped_process(command, &process_manager).expect("spawn piped process");
1661            let PipedProcess { exit_watcher, .. } = process;
1662            exit_watcher
1663        };
1664        drop(spawning_runtime);
1665
1666        let waiting_runtime = tokio::runtime::Builder::new_current_thread()
1667            .enable_all()
1668            .build()
1669            .expect("waiting runtime");
1670        let code = waiting_runtime.block_on(async {
1671            time::timeout(Duration::from_secs(5), exit_watcher)
1672                .await
1673                .expect("wait for piped process exit")
1674        });
1675        assert_eq!(code, 63);
1676    }
1677
1678    #[tokio::test]
1679    async fn test_pty_reader_drains_ready_fd() {
1680        let (tx, mut rx) = mpsc::unbounded_channel();
1681        let req = ExecRequest {
1682            cmd: "/bin/sh".to_string(),
1683            args: vec![
1684                "-c".to_string(),
1685                "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"
1686                    .to_string(),
1687            ],
1688            env: vec!["PATH=/usr/local/bin:/usr/bin:/bin".to_string()],
1689            cwd: None,
1690            user: None,
1691            tty: true,
1692            rows: 24,
1693            cols: 80,
1694            rlimits: Vec::new(),
1695        };
1696
1697        let session = ExecSession::spawn(7, &req, tx, None, SecurityProfile::Default)
1698            .expect("spawn pty session");
1699        let mut stdout = Vec::new();
1700        let mut exit = None;
1701
1702        let recv_result = time::timeout(Duration::from_secs(15), async {
1703            while let Some((id, output)) = rx.recv().await {
1704                assert_eq!(id, 7);
1705                match output {
1706                    SessionOutput::Stdout(data) => stdout.extend_from_slice(&data),
1707                    SessionOutput::Exited(code) => {
1708                        exit = Some(code);
1709                        break;
1710                    }
1711                    SessionOutput::Stderr(_) | SessionOutput::Raw(_) => {}
1712                }
1713            }
1714        })
1715        .await;
1716
1717        if recv_result.is_err() {
1718            let _ = session.send_signal(libc::SIGKILL);
1719            panic!("timed out waiting for PTY output");
1720        }
1721
1722        assert_eq!(exit, Some(0));
1723
1724        let second = stdout
1725            .windows(b"SECOND".len())
1726            .position(|window| window == b"SECOND");
1727        let end = stdout
1728            .windows(b"<END>".len())
1729            .position(|window| window == b"<END>");
1730
1731        assert!(
1732            matches!((second, end), (Some(second), Some(end)) if second < end),
1733            "expected immediate PTY write to arrive before later output; got {:?}",
1734            String::from_utf8_lossy(&stdout),
1735        );
1736    }
1737
1738    #[test]
1739    fn test_resolve_user_spec_for_current_uid_gid() {
1740        let uid = unsafe { libc::getuid() };
1741        let gid = unsafe { libc::getgid() };
1742        let resolved = resolve_user_spec(&format!("{uid}:{gid}")).expect("resolve numeric user");
1743        assert_eq!(resolved.uid, uid);
1744        assert_eq!(resolved.gid, gid);
1745    }
1746
1747    #[test]
1748    fn test_request_user_overrides_config_default() {
1749        let req = ExecRequest {
1750            cmd: "/bin/true".to_string(),
1751            args: Vec::new(),
1752            env: Vec::new(),
1753            cwd: None,
1754            user: Some("1:1".to_string()),
1755            tty: false,
1756            rows: 24,
1757            cols: 80,
1758            rlimits: Vec::new(),
1759        };
1760
1761        let resolved = resolve_requested_user(&req, Some("0:0")).expect("resolve requested user");
1762        assert_eq!(resolved.unwrap().uid, 1);
1763    }
1764
1765    #[test]
1766    fn test_config_default_user_used_when_request_has_none() {
1767        let req = ExecRequest {
1768            cmd: "/bin/true".to_string(),
1769            args: Vec::new(),
1770            env: Vec::new(),
1771            cwd: None,
1772            user: None,
1773            tty: false,
1774            rows: 24,
1775            cols: 80,
1776            rlimits: Vec::new(),
1777        };
1778
1779        let uid = unsafe { libc::getuid() };
1780        let gid = unsafe { libc::getgid() };
1781        let resolved = resolve_requested_user(&req, Some(&format!("{uid}:{gid}")))
1782            .expect("resolve with config default");
1783        let resolved = resolved.expect("should resolve to a user");
1784        assert_eq!(resolved.uid, uid);
1785        assert_eq!(resolved.gid, gid);
1786    }
1787
1788    #[test]
1789    fn test_request_without_user_does_not_apply_user_switch() {
1790        let req = ExecRequest {
1791            cmd: "/bin/true".to_string(),
1792            args: Vec::new(),
1793            env: Vec::new(),
1794            cwd: None,
1795            user: None,
1796            tty: false,
1797            rows: 24,
1798            cols: 80,
1799            rlimits: Vec::new(),
1800        };
1801
1802        let resolved = resolve_requested_user(&req, None).expect("resolve absent user");
1803        assert!(resolved.is_none());
1804    }
1805
1806    #[test]
1807    fn test_default_user_absent_resolves_to_root() {
1808        let resolved = resolve_default_user(None).expect("resolve absent default user");
1809        assert_eq!(resolved, (0, 0));
1810    }
1811
1812    #[test]
1813    fn test_default_home_dir_uses_resolved_user_home() {
1814        let req = ExecRequest {
1815            cmd: "/bin/true".to_string(),
1816            args: Vec::new(),
1817            env: Vec::new(),
1818            cwd: None,
1819            user: None,
1820            tty: false,
1821            rows: 24,
1822            cols: 80,
1823            rlimits: Vec::new(),
1824        };
1825        let user = ResolvedUser {
1826            uid: 1000,
1827            gid: 1000,
1828            initgroups_user: None,
1829            home_dir: Some(CString::new("/home/tester").unwrap()),
1830        };
1831
1832        assert_eq!(
1833            default_home_dir(&req, Some(&user))
1834                .expect("resolve default home")
1835                .as_deref()
1836                .map(CStr::to_string_lossy),
1837            Some("/home/tester".into()),
1838        );
1839    }
1840
1841    #[test]
1842    fn test_default_home_dir_uses_root_when_user_absent() {
1843        let req = ExecRequest {
1844            cmd: "/bin/true".to_string(),
1845            args: Vec::new(),
1846            env: Vec::new(),
1847            cwd: None,
1848            user: None,
1849            tty: false,
1850            rows: 24,
1851            cols: 80,
1852            rlimits: Vec::new(),
1853        };
1854        let root = resolve_user_spec(DEFAULT_USER_SPEC).expect("resolve implicit root");
1855
1856        assert_eq!(
1857            default_home_dir(&req, None)
1858                .expect("resolve default home")
1859                .as_deref()
1860                .map(CStr::to_string_lossy),
1861            root.home_dir.as_deref().map(CStr::to_string_lossy),
1862        );
1863    }
1864
1865    #[test]
1866    fn test_default_home_dir_respects_explicit_home_env() {
1867        let req = ExecRequest {
1868            cmd: "/bin/true".to_string(),
1869            args: Vec::new(),
1870            env: vec!["HOME=/tmp/custom".to_string()],
1871            cwd: None,
1872            user: None,
1873            tty: false,
1874            rows: 24,
1875            cols: 80,
1876            rlimits: Vec::new(),
1877        };
1878        let user = ResolvedUser {
1879            uid: 1000,
1880            gid: 1000,
1881            initgroups_user: None,
1882            home_dir: Some(CString::new("/home/tester").unwrap()),
1883        };
1884
1885        assert!(
1886            default_home_dir(&req, Some(&user))
1887                .expect("resolve default home")
1888                .is_none()
1889        );
1890    }
1891
1892    #[tokio::test]
1893    async fn test_spawn_pipe_error_does_not_include_probe_details() {
1894        let (tx, _rx) = mpsc::unbounded_channel();
1895        let req = ExecRequest {
1896            cmd: "/definitely/not/a/real/binary".to_string(),
1897            args: Vec::new(),
1898            env: Vec::new(),
1899            cwd: None,
1900            user: None,
1901            tty: false,
1902            rows: 24,
1903            cols: 80,
1904            rlimits: Vec::new(),
1905        };
1906
1907        // Use the process-wide manager because other tests may have already
1908        // started its reaper thread. A private manager cannot guard this spawn
1909        // from the global `waitpid(-1, ...)` owner.
1910        let process_manager = ProcessManager::get().expect("get process manager");
1911        let err = ExecSession::spawn_pipe(
1912            9,
1913            &req,
1914            tx,
1915            None,
1916            SecurityProfile::Default,
1917            &process_manager,
1918        )
1919        .expect_err("spawn should fail");
1920
1921        // Spawn failures now produce the typed `ExecSpawnFailed` so
1922        // the host can render a useful message + hint. The classifier
1923        // maps ENOENT on the binary path to `NotFound`.
1924        let payload = match &err {
1925            AgentdError::ExecSpawnFailed(p) => p,
1926            other => panic!("expected ExecSpawnFailed, got: {other:?}"),
1927        };
1928        assert_eq!(payload.kind, ExecFailureKind::NotFound);
1929        assert_eq!(payload.errno, Some(libc::ENOENT));
1930        assert_eq!(payload.errno_name.as_deref(), Some("ENOENT"));
1931
1932        // The original intent of the test: probe internals leak into
1933        // the error message. The format is now
1934        // `spawn "<cmd>": <io::Error>` from
1935        // `exec_failed_from_io_error`. Verify that none of the old
1936        // probe-detail keys snuck back into the message.
1937        let message = &payload.message;
1938        assert!(message.contains("spawn"));
1939        assert!(!message.contains("symlink_metadata="));
1940        assert!(!message.contains("metadata="));
1941        assert!(!message.contains("magic="));
1942        assert!(!message.contains("path_probe="));
1943        assert!(!message.contains("cwd_probe="));
1944        assert!(!message.contains("target_probe="));
1945    }
1946}