Skip to main content

lean_ctx/core/
protected_execution.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Supervised execution channel for protected Codex sessions.
3//!
4//! macOS refuses a nested `sandbox_apply` as soon as the *outer* profile is
5//! anything but `(allow default)`: one `(deny mach-task-read)` — or a single
6//! unrelated file-read denial — in the whole-MCP profile makes every inner
7//! `sandbox-exec` fail with `Operation not permitted`. This was measured on the
8//! running system, not inferred. A protected MCP server therefore cannot wrap
9//! its own command children in the launcher's store profile, and the previous
10//! per-child `sandbox-exec` prefix could only ever fail closed — which is a
11//! broken shell, not a security result.
12//!
13//! The launcher's wrapper process already runs *outside* the MCP sandbox and is
14//! the MCP server's parent. This module turns it into a narrowly scoped
15//! supervisor. One anonymous `UnixStream` pair is inherited by the MCP child at
16//! a fixed descriptor, sealed `FD_CLOEXEC` before any thread, TCC probe or tool
17//! can observe it, and never reaches a command child. The supervisor is the only
18//! process that starts command children, and it applies the complete
19//! launcher-owned profile once, at spawn, from an unsandboxed parent.
20//!
21//! What this file is responsible for:
22//! * the combined profile is launcher-owned — a request can never select,
23//!   weaken or drop it; a `Program` request may only *narrow* it;
24//! * a missing, invalid or closed channel fails closed — there is no
25//!   unsandboxed fallback shell;
26//! * frames, live-output snapshots, wall time and concurrency are bounded;
27//! * diagnostics carry no command text, environment value or file content.
28//!
29//! Not in scope, deliberately: no named listener, no TCP service, no daemon
30//! delegation, no durable key. The channel is a process capability that dies
31//! with the wrapper.
32
33use std::collections::BTreeMap;
34use std::io::{self, Read, Write};
35use std::os::unix::net::UnixStream;
36use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
37use std::sync::{Arc, Mutex, OnceLock, mpsc};
38use std::time::{Duration, Instant};
39
40/// Argument the launcher's wrapper passes to the sandboxed MCP child.
41pub(crate) const EXEC_FD_FLAG: &str = "--protected-exec-fd";
42/// Fixed descriptor the wrapper dups the MCP end onto. `3` stays reserved for
43/// the selected-GitLab credential channel, which is read and closed earlier.
44pub(crate) const CHILD_EXEC_FD: i32 = 4;
45
46/// The shared shell executor the supervisor runs a brokered `ctx_shell`
47/// command with: `(command, cwd, extra_env, timeout_ms, cancel, idle_keyed,
48/// live)` → `(text, exit_code)`. It lives in the server layer above the
49/// engine (`server::execute`), which registers it; the CLI does so at start-up.
50pub type ShellRunner = fn(
51    &str,
52    &str,
53    &std::collections::HashMap<String, String>,
54    Option<u64>,
55    Option<&AtomicBool>,
56    bool,
57    Option<&Mutex<String>>,
58) -> (String, i32);
59
60static SHELL_RUNNER: OnceLock<ShellRunner> = OnceLock::new();
61
62/// Register the shell executor of brokered commands. The first registration
63/// wins. Without one, a brokered shell request fails; nothing runs.
64pub fn register_shell_runner(runner: ShellRunner) {
65    let _ = SHELL_RUNNER.set(runner);
66}
67
68const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec";
69/// Every profile the launcher pins starts here; a narrowing request may not.
70const LAUNCH_PROFILE_HEADER: &str = "(version 1)\n(allow default)\n";
71
72/// Frame ceiling for both directions. Large enough for one capped shell capture
73/// plus its framing, small enough that a malformed length is rejected outright.
74const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
75/// Concurrent command children one session may hold open. Foreground plus the
76/// background jobs `ctx_shell` actually keeps alive; beyond this a request is
77/// refused rather than queued behind an unbounded backlog.
78const MAX_INFLIGHT: usize = 8;
79/// Absolute supervisor-side lifetime for a single request, independent of the
80/// executor's own idle/wall budget.
81const MAX_REQUEST_WALL: Duration = Duration::from_hours(4);
82/// Cadence at which the supervisor mirrors a running child's captured output.
83const LIVE_INTERVAL: Duration = Duration::from_millis(250);
84/// Client poll granularity; also how fast a cancellation reaches the wire.
85const CLIENT_TICK: Duration = Duration::from_millis(50);
86const CHANNEL_WRITE_TIMEOUT: Duration = Duration::from_secs(2);
87/// Grace period for in-flight children after the MCP server disconnects.
88const TEARDOWN_GRACE: Duration = Duration::from_secs(5);
89
90const PROTOCOL_VERSION: u8 = 1;
91
92// ---------------------------------------------------------------------------
93// Wire protocol
94// ---------------------------------------------------------------------------
95
96#[derive(serde::Serialize, serde::Deserialize)]
97#[serde(deny_unknown_fields)]
98struct Request {
99    version: u8,
100    id: u64,
101    body: Body,
102}
103
104#[derive(serde::Serialize, serde::Deserialize)]
105#[serde(deny_unknown_fields, rename_all = "snake_case")]
106enum Body {
107    /// A model-directed shell command. The profile is never part of the
108    /// request: the supervisor uses the one the launcher pinned.
109    Shell {
110        command: String,
111        cwd: String,
112        env: BTreeMap<String, String>,
113        timeout_ms: Option<u64>,
114        idle_keyed: bool,
115    },
116    /// `ctx_execute`'s own, narrower read/write/network profile. The supervisor
117    /// composes it with the launch denials, which are appended last.
118    Program {
119        constrained: bool,
120        read_paths: Vec<String>,
121        program: String,
122        args: Vec<String>,
123        env: Vec<(String, String)>,
124        timeout_secs: u64,
125        cwd: Option<String>,
126    },
127    /// Cooperative cancellation for an already accepted request.
128    Cancel,
129}
130
131#[derive(serde::Serialize, serde::Deserialize)]
132#[serde(deny_unknown_fields)]
133struct Envelope {
134    version: u8,
135    id: u64,
136    event: Event,
137}
138
139#[derive(serde::Serialize, serde::Deserialize, Debug)]
140#[serde(deny_unknown_fields, rename_all = "snake_case")]
141pub enum Event {
142    /// Progress snapshot of the captured output so far; advisory only.
143    Live {
144        text: String,
145    },
146    ShellExit {
147        text: String,
148        code: i32,
149    },
150    ProgramExit {
151        stdout: String,
152        stderr: String,
153        code: i32,
154    },
155    /// The request never produced a child, or the channel refused it. Carries a
156    /// fixed reason, never the request's own content.
157    Failed {
158        message: String,
159    },
160}
161
162fn write_frame(stream: &mut UnixStream, payload: &[u8]) -> io::Result<()> {
163    if payload.len() > MAX_FRAME_BYTES {
164        return Err(io::Error::other("frame exceeds the channel limit"));
165    }
166    stream.write_all(&(payload.len() as u32).to_be_bytes())?;
167    stream.write_all(payload)?;
168    stream.flush()
169}
170
171/// `Ok(None)` is a clean end of stream. A truncated frame is indistinguishable
172/// from a peer that died mid-write, and both mean the same thing here: the
173/// channel is gone and every caller must fail closed.
174fn read_frame(stream: &mut UnixStream) -> io::Result<Option<Vec<u8>>> {
175    let mut header = [0_u8; 4];
176    match stream.read_exact(&mut header) {
177        Ok(()) => {}
178        Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
179        Err(error) => return Err(error),
180    }
181    let len = u32::from_be_bytes(header) as usize;
182    if len > MAX_FRAME_BYTES {
183        return Err(io::Error::other("frame exceeds the channel limit"));
184    }
185    let mut body = vec![0_u8; len];
186    match stream.read_exact(&mut body) {
187        Ok(()) => Ok(Some(body)),
188        Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => Ok(None),
189        Err(error) => Err(error),
190    }
191}
192
193// ---------------------------------------------------------------------------
194// Launcher-owned profile
195// ---------------------------------------------------------------------------
196
197/// The immutable boundary the supervisor applies. Built once, in the wrapper,
198/// from the rules the launcher pinned into the environment — never from a
199/// request, an `extra_env` entry or a writable file.
200#[derive(Clone)]
201pub struct LaunchProfile {
202    /// Complete profile for a shell child.
203    shell: String,
204    /// The denial lines alone, for composing onto a narrower caller profile.
205    denials: String,
206}
207
208impl LaunchProfile {
209    /// Fails closed: a wrapper that cannot reproduce the pinned boundary
210    /// supervises nothing.
211    pub(crate) fn from_pinned(control_profile: &str) -> Result<Self, String> {
212        let prefix = crate::core::protected_store::protected_child_prefix()?
213            .ok_or("supervised execution requires a protected session")?;
214        let mut shell = match prefix.as_slice() {
215            [program, flag, profile] if program == SANDBOX_EXEC && flag == "-p" => profile.clone(),
216            _ => return Err("unexpected protected launch rules".into()),
217        };
218        if !shell.starts_with(LAUNCH_PROFILE_HEADER) {
219            return Err("protected launch rules have an unexpected header".into());
220        }
221        // A supervisor child does not inherit the MCP's control-file sandbox.
222        // Apply those rules explicitly, after every store/build exception.
223        let controls = control_profile
224            .strip_prefix(LAUNCH_PROFILE_HEADER)
225            .ok_or("protected control profile has an unexpected header")?;
226        for line in controls.lines().filter(|line| !line.is_empty()) {
227            if !line.starts_with("(deny ")
228                && line != "(allow network-outbound (literal \"/private/var/run/mDNSResponder\"))"
229            {
230                return Err("protected control profile contains an unsupported grant".into());
231            }
232        }
233        shell.push_str(controls);
234        if shell.len() > 128 * 1024 {
235            return Err("combined protected launch profile exceeds the limit".into());
236        }
237        let mut denials = String::new();
238        for line in shell
239            .strip_prefix(LAUNCH_PROFILE_HEADER)
240            .ok_or("protected launch profile has an unexpected header")?
241            .lines()
242            .filter(|line| !line.is_empty())
243        {
244            if line.starts_with("(deny ") && line.ends_with(')') {
245                denials.push_str(line);
246                denials.push('\n');
247            } else if !line.starts_with("(allow ") || !line.ends_with(')') {
248                return Err("protected launch profile contains an unsupported rule".into());
249            }
250        }
251        if denials.is_empty() {
252            return Err("protected launch rules are not a pure denial set".into());
253        }
254        Ok(Self { shell, denials })
255    }
256
257    pub(crate) fn shell_profile(&self) -> &str {
258        &self.shell
259    }
260
261    /// Build the restrictive program profile from typed paths. Requests never
262    /// contain executable Seatbelt rules. Launch denials are appended last so
263    /// a requested read root cannot re-open a control file or the store.
264    fn compose(&self, read_paths: &[String], program: &str) -> Result<String, String> {
265        if read_paths.len() > 64
266            || read_paths
267                .iter()
268                .map(String::as_str)
269                .chain(std::iter::once(program))
270                .any(|path| {
271                    !std::path::Path::new(path).is_absolute() || path.chars().any(char::is_control)
272                })
273        {
274            return Err("supervised program paths must be bounded and absolute".into());
275        }
276        let paths: Vec<&std::path::Path> = read_paths.iter().map(std::path::Path::new).collect();
277        let requested = crate::core::sandbox_seatbelt::seatbelt_profile(&paths, program);
278        if requested.len() + self.denials.len() > MAX_FRAME_BYTES {
279            return Err("supervised program profile exceeds the channel limit".into());
280        }
281        let mut composed = String::with_capacity(requested.len() + self.denials.len() + 1);
282        composed.push_str(&requested);
283        if !composed.ends_with('\n') {
284            composed.push('\n');
285        }
286        composed.push_str(&self.denials);
287        Ok(composed)
288    }
289
290    #[cfg(any(test, all(feature = "test-support", target_os = "macos")))]
291    fn synthetic(denials: &str) -> Self {
292        Self {
293            shell: format!("{LAUNCH_PROFILE_HEADER}{denials}"),
294            denials: denials.to_string(),
295        }
296    }
297}
298
299// ---------------------------------------------------------------------------
300// Launch mode
301// ---------------------------------------------------------------------------
302
303thread_local! {
304    /// Set only on a supervisor worker thread: the fixed profile this thread's
305    /// child must run under. Its presence is what makes the shared executor take
306    /// the broker-private path instead of dispatching back to the client.
307    static BROKER_PROFILE: std::cell::RefCell<Option<String>> = const {
308        std::cell::RefCell::new(None)
309    };
310}
311
312fn broker_profile() -> Option<String> {
313    BROKER_PROFILE.with(|slot| slot.borrow().clone())
314}
315
316fn with_broker_profile<T>(profile: String, run: impl FnOnce() -> T) -> T {
317    BROKER_PROFILE.with(|slot| *slot.borrow_mut() = Some(profile));
318    let result = run();
319    BROKER_PROFILE.with(|slot| *slot.borrow_mut() = None);
320    result
321}
322
323#[cfg(all(any(test, feature = "test-support"), target_os = "macos"))]
324#[allow(clippy::unwrap_used)]
325pub fn with_test_supervisor<T>(run: impl FnOnce() -> T) -> T {
326    let prefix = crate::core::protected_store::protected_child_prefix()
327        .unwrap()
328        .unwrap();
329    with_broker_profile(prefix[2].clone(), run)
330}
331
332pub enum Mode {
333    /// Community session: the shared executor runs the child directly.
334    Direct,
335    /// Supervisor worker thread: apply this fixed profile at spawn. Legal only
336    /// outside the MCP sandbox, which is exactly where the supervisor runs.
337    Sandboxed(String),
338    /// Protected MCP server: hand the request to the supervisor.
339    Brokered(Arc<Client>),
340}
341
342/// How the calling thread must start a command child.
343///
344/// Fails closed in one case that matters: a process carrying the protected
345/// launch authority with no usable channel runs nothing at all. It never
346/// degrades to an unsupervised child.
347pub fn launch_mode() -> Result<Mode, String> {
348    if let Some(profile) = broker_profile() {
349        return Ok(Mode::Sandboxed(profile));
350    }
351    if let Some(client) = client() {
352        if client.is_closed() {
353            return Err(
354                "the protected execution channel closed; restart `lean-ctx codex-protected`".into(),
355            );
356        }
357        return Ok(Mode::Brokered(client));
358    }
359    if crate::core::protected_store::protected_child_prefix()?.is_some() {
360        return Err(
361            "protected session without a supervised execution channel; restart \
362             `lean-ctx codex-protected`"
363                .into(),
364        );
365    }
366    Ok(Mode::Direct)
367}
368
369/// Program and leading arguments for a broker-private child.
370pub fn sandbox_prefix(profile: &str) -> Vec<String> {
371    vec![
372        SANDBOX_EXEC.to_string(),
373        "-p".to_string(),
374        profile.to_string(),
375    ]
376}
377
378// ---------------------------------------------------------------------------
379// Client — the protected MCP server's end
380// ---------------------------------------------------------------------------
381
382static CLIENT: OnceLock<Arc<Client>> = OnceLock::new();
383
384pub struct Client {
385    writer: Mutex<UnixStream>,
386    inflight: Mutex<std::collections::HashMap<u64, mpsc::SyncSender<Event>>>,
387    next_id: AtomicU64,
388    closed: Arc<AtomicBool>,
389}
390
391pub(crate) fn client() -> Option<Arc<Client>> {
392    CLIENT.get().cloned()
393}
394
395/// Adopt the descriptor the wrapper inherited to this process. Runs from the
396/// early CLI dispatch, before logging, telemetry, threads or tools exist.
397///
398/// Validation mirrors the credential channel: an anonymous, connected stream
399/// socket pair and nothing else, sealed `FD_CLOEXEC` so no command child can
400/// ever inherit the capability.
401pub(crate) fn install_client(fd: i32) -> Result<(), String> {
402    use std::os::fd::FromRawFd;
403
404    if fd <= 2 {
405        return Err("invalid protected execution descriptor".into());
406    }
407    let mut kind: libc::c_int = 0;
408    let mut len = std::mem::size_of_val(&kind) as libc::socklen_t;
409    // SAFETY: kind/len are initialized writable values of the sizes supplied.
410    if unsafe {
411        libc::getsockopt(
412            fd,
413            libc::SOL_SOCKET,
414            libc::SO_TYPE,
415            (&raw mut kind).cast(),
416            &raw mut len,
417        )
418    } != 0
419        || kind != libc::SOCK_STREAM
420    {
421        return Err("supervised execution requires an anonymous stream socket".into());
422    }
423    // SAFETY: the early CLI receiver owns the inherited descriptor exactly once,
424    // before other code can use it; getsockopt verified it is open.
425    let stream = unsafe { UnixStream::from_raw_fd(fd) };
426    if !stream
427        .local_addr()
428        .is_ok_and(|address| address.is_unnamed())
429        || !stream.peer_addr().is_ok_and(|address| address.is_unnamed())
430    {
431        return Err("supervised execution requires an anonymous connected socket pair".into());
432    }
433    // SAFETY: stream owns the open descriptor and F_SETFD takes integer flags.
434    if unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
435        return Err("cannot seal the protected execution descriptor".into());
436    }
437    adopt(stream)
438}
439
440fn adopt(stream: UnixStream) -> Result<(), String> {
441    stream
442        .set_write_timeout(Some(CHANNEL_WRITE_TIMEOUT))
443        .map_err(|_| "cannot bound execution channel writes")?;
444    let reader = stream
445        .try_clone()
446        .map_err(|_| "cannot bind the protected execution channel")?;
447    let closed = Arc::new(AtomicBool::new(false));
448    let client = Arc::new(Client {
449        writer: Mutex::new(stream),
450        inflight: Mutex::new(std::collections::HashMap::new()),
451        next_id: AtomicU64::new(1),
452        closed: Arc::clone(&closed),
453    });
454    let pump = Arc::clone(&client);
455    std::thread::Builder::new()
456        .name("leanctx-exec-client".into())
457        .spawn(move || pump.pump(reader))
458        .map_err(|_| "cannot start the protected execution reader")?;
459    CLIENT
460        .set(client)
461        .map_err(|_| "the protected execution channel is already bound".to_string())
462}
463
464impl Client {
465    fn is_closed(&self) -> bool {
466        self.closed.load(Ordering::Acquire)
467    }
468
469    /// Demultiplex supervisor events onto the waiting requests. Any protocol
470    /// error ends the channel: every waiter then observes a disconnect and
471    /// fails closed rather than silently losing a result.
472    fn pump(&self, mut reader: UnixStream) {
473        while let Ok(Some(frame)) = read_frame(&mut reader) {
474            match serde_json::from_slice::<Envelope>(&frame) {
475                Ok(envelope) if envelope.version == PROTOCOL_VERSION => {
476                    let sender = self
477                        .inflight
478                        .lock()
479                        .ok()
480                        .and_then(|table| table.get(&envelope.id).cloned());
481                    if let Some(sender) = sender {
482                        let advisory = matches!(envelope.event, Event::Live { .. });
483                        if matches!(
484                            sender.try_send(envelope.event),
485                            Err(mpsc::TrySendError::Full(_))
486                        ) && !advisory
487                        {
488                            break;
489                        }
490                    }
491                }
492                _ => break,
493            }
494        }
495        self.closed.store(true, Ordering::Release);
496        let _ = reader.shutdown(std::net::Shutdown::Both);
497        if let Ok(mut table) = self.inflight.lock() {
498            table.clear();
499        }
500    }
501
502    fn begin(&self) -> Result<(u64, mpsc::Receiver<Event>), String> {
503        if self.is_closed() {
504            return Err("the protected execution channel closed".into());
505        }
506        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
507        let (sender, receiver) = mpsc::sync_channel(4);
508        let mut table = self
509            .inflight
510            .lock()
511            .map_err(|_| "the protected execution channel is unusable")?;
512        if self.is_closed() {
513            return Err("the protected execution channel closed".into());
514        }
515        if table.len() >= MAX_INFLIGHT {
516            return Err("too many concurrent supervised commands".into());
517        }
518        table.insert(id, sender);
519        Ok((id, receiver))
520    }
521
522    fn finish(&self, id: u64) {
523        if let Ok(mut table) = self.inflight.lock() {
524            table.remove(&id);
525        }
526    }
527
528    fn send(&self, id: u64, body: Body) -> Result<(), String> {
529        let frame = serde_json::to_vec(&Request {
530            version: PROTOCOL_VERSION,
531            id,
532            body,
533        })
534        .map_err(|_| "cannot encode a supervised request")?;
535        let mut writer = self
536            .writer
537            .lock()
538            .map_err(|_| "the protected execution channel is unusable")?;
539        write_frame(&mut writer, &frame).map_err(|_| {
540            self.closed.store(true, Ordering::Release);
541            let _ = writer.shutdown(std::net::Shutdown::Both);
542            "the protected execution channel closed".to_string()
543        })
544    }
545
546    /// Run a shell command under the launcher's boundary and return exactly what
547    /// the shared executor produced in the supervisor: its text and its real
548    /// exit code, including the timeout and truncation notices.
549    pub fn shell(
550        &self,
551        command: &str,
552        cwd: &str,
553        env: &std::collections::HashMap<String, String>,
554        timeout_ms: Option<u64>,
555        cancel: Option<&AtomicBool>,
556        idle_keyed: bool,
557        live: Option<&Mutex<String>>,
558    ) -> (String, i32) {
559        let body = Body::Shell {
560            command: command.to_string(),
561            cwd: cwd.to_string(),
562            env: env
563                .iter()
564                .map(|(key, value)| (key.clone(), value.clone()))
565                .collect(),
566            timeout_ms,
567            idle_keyed,
568        };
569        match self.request(body, cancel, live) {
570            Ok(Event::ShellExit { text, code }) => (text, code),
571            Ok(Event::Failed { message }) => (format!("ERROR: {message}"), 1),
572            Ok(_) => ("ERROR: unexpected supervised execution response".into(), 1),
573            Err(error) => (format!("ERROR: {error}"), 1),
574        }
575    }
576
577    /// Run `ctx_execute`'s interpreter under its own narrower profile composed
578    /// with the launch denials. The caller cannot request a weaker boundary.
579    pub(crate) fn program(
580        &self,
581        read_paths: &[&std::path::Path],
582        program: &str,
583        args: &[&str],
584        env: &[(String, String)],
585        timeout_secs: u64,
586        cwd: Option<&std::path::Path>,
587    ) -> Result<(String, String, i32), String> {
588        self.program_with_boundary(read_paths, program, args, env, timeout_secs, cwd, true)
589    }
590
591    /// Level-zero snippets still require the immutable session boundary.
592    pub fn session_program(
593        &self,
594        program: &str,
595        args: &[&str],
596        env: &[(String, String)],
597        timeout_secs: u64,
598        cwd: Option<&std::path::Path>,
599    ) -> Result<(String, String, i32), String> {
600        self.program_with_boundary(&[], program, args, env, timeout_secs, cwd, false)
601    }
602
603    #[allow(clippy::too_many_arguments)]
604    fn program_with_boundary(
605        &self,
606        read_paths: &[&std::path::Path],
607        program: &str,
608        args: &[&str],
609        env: &[(String, String)],
610        timeout_secs: u64,
611        cwd: Option<&std::path::Path>,
612        constrained: bool,
613    ) -> Result<(String, String, i32), String> {
614        let body = Body::Program {
615            constrained,
616            read_paths: read_paths
617                .iter()
618                .map(|path| {
619                    path.to_str()
620                        .map(str::to_owned)
621                        .ok_or("program read path is not UTF-8".to_string())
622                })
623                .collect::<Result<_, _>>()?,
624            program: program.to_string(),
625            args: args.iter().map(|arg| (*arg).to_string()).collect(),
626            env: env.to_vec(),
627            timeout_secs,
628            cwd: cwd
629                .map(|dir| {
630                    dir.to_str()
631                        .map(str::to_string)
632                        .ok_or_else(|| "working directory is not valid UTF-8".to_string())
633                })
634                .transpose()?,
635        };
636        match self.request(body, None, None)? {
637            Event::ProgramExit {
638                stdout,
639                stderr,
640                code,
641            } => Ok((stdout, stderr, code)),
642            Event::Failed { message } => Err(message),
643            _ => Err("unexpected supervised execution response".into()),
644        }
645    }
646
647    fn request(
648        &self,
649        body: Body,
650        cancel: Option<&AtomicBool>,
651        live: Option<&Mutex<String>>,
652    ) -> Result<Event, String> {
653        let (id, events) = self.begin()?;
654        let outcome = self.wait(id, body, cancel, live, &events);
655        self.finish(id);
656        outcome
657    }
658
659    fn wait(
660        &self,
661        id: u64,
662        body: Body,
663        cancel: Option<&AtomicBool>,
664        live: Option<&Mutex<String>>,
665        events: &mpsc::Receiver<Event>,
666    ) -> Result<Event, String> {
667        self.send(id, body)?;
668        let mut cancelled = false;
669        let deadline = Instant::now() + MAX_REQUEST_WALL + TEARDOWN_GRACE;
670        loop {
671            if Instant::now() >= deadline {
672                let _ = self.send(id, Body::Cancel);
673                return Err("supervised command exceeded its absolute lifetime".into());
674            }
675            match events.recv_timeout(CLIENT_TICK) {
676                Ok(Event::Live { text }) => {
677                    if let Some(live) = live
678                        && let Ok(mut guard) = live.try_lock()
679                    {
680                        *guard = text;
681                    }
682                }
683                Ok(event) => return Ok(event),
684                Err(mpsc::RecvTimeoutError::Timeout) => {
685                    if self.is_closed() {
686                        return Err("the protected execution channel closed".into());
687                    }
688                    if !cancelled
689                        && cancel.is_some_and(|signal| signal.load(Ordering::Acquire))
690                        && self.send(id, Body::Cancel).is_ok()
691                    {
692                        cancelled = true;
693                    }
694                }
695                Err(mpsc::RecvTimeoutError::Disconnected) => {
696                    return Err("the protected execution channel closed".into());
697                }
698            }
699        }
700    }
701}
702
703// ---------------------------------------------------------------------------
704// Supervisor — the launcher wrapper's end
705// ---------------------------------------------------------------------------
706
707struct Supervisor {
708    writer: Mutex<UnixStream>,
709    profile: LaunchProfile,
710    running: Mutex<std::collections::HashMap<u64, Arc<AtomicBool>>>,
711}
712
713/// Serve supervised execution for one protected MCP server until it
714/// disconnects. Returns when the channel ends; in-flight children are then
715/// cancelled and their process groups terminated.
716pub fn serve(stream: UnixStream, profile: LaunchProfile) -> Result<(), String> {
717    stream
718        .set_write_timeout(Some(CHANNEL_WRITE_TIMEOUT))
719        .map_err(|_| "cannot bound supervisor channel writes")?;
720    let mut reader = stream
721        .try_clone()
722        .map_err(|_| "cannot bind the supervised execution channel")?;
723    let supervisor = Arc::new(Supervisor {
724        writer: Mutex::new(stream),
725        profile,
726        running: Mutex::new(std::collections::HashMap::new()),
727    });
728    let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
729    // EOF, a truncated frame or malformed length closes the capability.
730    while let Ok(Some(frame)) = read_frame(&mut reader) {
731        let Ok(request): Result<Request, _> = serde_json::from_slice(&frame) else {
732            supervisor.fail(0, "malformed supervised request");
733            break;
734        };
735        if request.version != PROTOCOL_VERSION {
736            supervisor.fail(request.id, "unsupported supervised protocol version");
737            continue;
738        }
739        if matches!(request.body, Body::Cancel) {
740            supervisor.cancel(request.id);
741            continue;
742        }
743        workers.retain(|worker| !worker.is_finished());
744        if workers.len() >= MAX_INFLIGHT {
745            supervisor.fail(request.id, "too many concurrent supervised commands");
746            continue;
747        }
748        let worker = Arc::clone(&supervisor);
749        let id = request.id;
750        let body = request.body;
751        let cancel = Arc::new(AtomicBool::new(false));
752        {
753            let mut running = supervisor
754                .running
755                .lock()
756                .map_err(|_| "supervisor state unavailable")?;
757            if running.contains_key(&id) {
758                break;
759            }
760            running.insert(id, Arc::clone(&cancel));
761        }
762        if let Ok(handle) = std::thread::Builder::new()
763            .name("leanctx-exec-worker".into())
764            .spawn(move || worker.run(id, body, &cancel))
765        {
766            workers.push(handle);
767        } else {
768            if let Ok(mut running) = supervisor.running.lock() {
769                running.remove(&id);
770            }
771            supervisor.fail(id, "cannot start a supervised command");
772        }
773    }
774    let _ = reader.shutdown(std::net::Shutdown::Both);
775    supervisor.shutdown();
776    let deadline = Instant::now() + TEARDOWN_GRACE;
777    for worker in workers {
778        while !worker.is_finished() && Instant::now() < deadline {
779            std::thread::sleep(CLIENT_TICK);
780        }
781        if !worker.is_finished() {
782            break;
783        }
784        let _ = worker.join();
785    }
786    Ok(())
787}
788
789impl Supervisor {
790    fn emit(&self, id: u64, event: Event) {
791        let Ok(mut frame) = serde_json::to_vec(&Envelope {
792            version: PROTOCOL_VERSION,
793            id,
794            event,
795        }) else {
796            return;
797        };
798        if frame.len() > MAX_FRAME_BYTES {
799            frame = serde_json::to_vec(&Envelope {
800                version: PROTOCOL_VERSION,
801                id,
802                event: Event::Failed {
803                    message: "supervised output exceeds the channel limit".into(),
804                },
805            })
806            .expect("fixed failure envelope");
807        }
808        if let Ok(mut writer) = self.writer.lock() {
809            if write_frame(&mut writer, &frame).is_err() {
810                let _ = writer.shutdown(std::net::Shutdown::Both);
811                self.shutdown();
812            }
813        }
814    }
815
816    fn fail(&self, id: u64, message: &str) {
817        self.emit(
818            id,
819            Event::Failed {
820                message: message.to_string(),
821            },
822        );
823    }
824
825    fn cancel(&self, id: u64) {
826        if let Ok(table) = self.running.lock()
827            && let Some(signal) = table.get(&id)
828        {
829            signal.store(true, Ordering::Release);
830        }
831    }
832
833    /// The MCP server is gone: cancel everything still running. The shared
834    /// executor turns a set cancel flag into a full process-group kill.
835    fn shutdown(&self) {
836        if let Ok(table) = self.running.lock() {
837            for signal in table.values() {
838                signal.store(true, Ordering::Release);
839            }
840        }
841    }
842
843    fn run(self: Arc<Self>, id: u64, body: Body, cancel: &Arc<AtomicBool>) {
844        // Absolute supervisor-side lifetime, independent of the executor's own
845        // idle or wall budget.
846        let watchdog = {
847            let cancel = Arc::clone(cancel);
848            let (done, finished) = mpsc::sync_channel(1);
849            let handle = std::thread::Builder::new()
850                .name("leanctx-exec-watchdog".into())
851                .spawn(move || {
852                    if matches!(
853                        finished.recv_timeout(MAX_REQUEST_WALL),
854                        Err(mpsc::RecvTimeoutError::Timeout)
855                    ) {
856                        cancel.store(true, Ordering::Release);
857                    }
858                })
859                .ok();
860            (done, handle)
861        };
862
863        let event = if watchdog.1.is_none() {
864            Event::Failed {
865                message: "cannot bound supervised command lifetime".into(),
866            }
867        } else {
868            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match body {
869                Body::Shell {
870                    command,
871                    cwd,
872                    env,
873                    timeout_ms,
874                    idle_keyed,
875                } => self.run_shell(id, &command, &cwd, env, timeout_ms, idle_keyed, cancel),
876                Body::Program {
877                    constrained,
878                    read_paths,
879                    program,
880                    args,
881                    env,
882                    timeout_secs,
883                    cwd,
884                } => self.run_program(
885                    constrained,
886                    &read_paths,
887                    &program,
888                    &args,
889                    &env,
890                    timeout_secs,
891                    cwd,
892                    cancel,
893                ),
894                Body::Cancel => Event::Failed {
895                    message: "cancellation is not a command".into(),
896                },
897            }))
898            .unwrap_or_else(|_| {
899                cancel.store(true, Ordering::Release);
900                Event::Failed {
901                    message: "supervised command ended without a result".into(),
902                }
903            })
904        };
905
906        let _ = watchdog.0.send(());
907        if let Some(handle) = watchdog.1 {
908            let _ = handle.join();
909        }
910        if let Ok(mut table) = self.running.lock() {
911            table.remove(&id);
912        }
913        self.emit(id, event);
914    }
915
916    #[allow(clippy::too_many_arguments)]
917    fn run_shell(
918        self: &Arc<Self>,
919        id: u64,
920        command: &str,
921        cwd: &str,
922        env: BTreeMap<String, String>,
923        timeout_ms: Option<u64>,
924        idle_keyed: bool,
925        cancel: &Arc<AtomicBool>,
926    ) -> Event {
927        // Fail closed: without the registered executor nothing is started.
928        let Some(runner) = SHELL_RUNNER.get().copied() else {
929            return Event::Failed {
930                message: "no shell executor is registered with the protected supervisor".into(),
931            };
932        };
933        let extra: std::collections::HashMap<String, String> = env.into_iter().collect();
934        let live = Arc::new(Mutex::new(String::new()));
935        let (done, finished) = mpsc::sync_channel(1);
936        let mirror = {
937            let supervisor = Arc::clone(self);
938            let live = Arc::clone(&live);
939            std::thread::Builder::new()
940                .name("leanctx-exec-live".into())
941                .spawn(move || supervisor.mirror(id, &live, &finished))
942                .ok()
943        };
944        // The one place the fixed profile enters: pinned by the launcher, taken
945        // from this supervisor, never from the request.
946        let profile = self.profile.shell.clone();
947        let (text, code) = with_broker_profile(profile, || {
948            runner(
949                command,
950                cwd,
951                &extra,
952                timeout_ms.map(|ms| ms.min(MAX_REQUEST_WALL.as_millis() as u64)),
953                Some(cancel.as_ref()),
954                idle_keyed,
955                Some(live.as_ref()),
956            )
957        });
958        let _ = done.send(());
959        if let Some(handle) = mirror {
960            let _ = handle.join();
961        }
962        Event::ShellExit { text, code }
963    }
964
965    #[allow(clippy::too_many_arguments)]
966    fn run_program(
967        &self,
968        constrained: bool,
969        read_paths: &[String],
970        program: &str,
971        args: &[String],
972        env: &[(String, String)],
973        timeout_secs: u64,
974        cwd: Option<String>,
975        cancel: &Arc<AtomicBool>,
976    ) -> Event {
977        let mut composed = match self.profile.compose(read_paths, program) {
978            Ok(composed) => composed,
979            Err(message) => return Event::Failed { message },
980        };
981        if !constrained {
982            // This matches the shell boundary, never an unprotected child.
983            composed.clone_from(&self.profile.shell);
984        }
985        let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
986        let directory = cwd.map(std::path::PathBuf::from);
987        match crate::core::sandbox_seatbelt::execute_with_profile(
988            &composed,
989            program,
990            &borrowed,
991            env,
992            timeout_secs.min(MAX_REQUEST_WALL.as_secs()),
993            directory.as_deref(),
994            Some(cancel.as_ref()),
995        ) {
996            Ok((stdout, stderr, code)) => Event::ProgramExit {
997                stdout,
998                stderr,
999                code,
1000            },
1001            Err(message) => Event::Failed { message },
1002        }
1003    }
1004
1005    /// Mirror the captured-so-far output while the child runs, so a detached
1006    /// background job's `status` poll still shows progress across the channel.
1007    /// Best effort and bounded: snapshots are capped at the same ceiling the
1008    /// executor applies to the final capture.
1009    fn mirror(&self, id: u64, live: &Mutex<String>, done: &mpsc::Receiver<()>) {
1010        let cap = crate::core::limits::max_shell_bytes().min(2 * 1024 * 1024);
1011        let mut last = String::new();
1012        while matches!(
1013            done.recv_timeout(LIVE_INTERVAL),
1014            Err(mpsc::RecvTimeoutError::Timeout)
1015        ) {
1016            let Ok(guard) = live.lock() else { return };
1017            let snapshot = guard.clone();
1018            drop(guard);
1019            if snapshot == last {
1020                continue;
1021            }
1022            last.clone_from(&snapshot);
1023            let bounded = if snapshot.len() > cap {
1024                let mut start = snapshot.len() - cap;
1025                while start < snapshot.len() && !snapshot.is_char_boundary(start) {
1026                    start += 1;
1027                }
1028                snapshot[start..].to_string()
1029            } else {
1030                snapshot
1031            };
1032            self.emit(id, Event::Live { text: bounded });
1033        }
1034    }
1035}
1036
1037/// Test support: supervisor and client in one process, over a real socket
1038/// pair and real child processes — the topology the wrapper runs, minus the
1039/// sandbox. Brokered shell commands run with the registered [`ShellRunner`]
1040/// (the server layer's tests register theirs); without one they fail closed.
1041#[cfg(all(any(test, feature = "test-support"), target_os = "macos"))]
1042#[allow(clippy::unwrap_used)]
1043pub fn supervised_for_test<T>(denials: &str, exercise: impl FnOnce(&Client) -> T) -> T {
1044    let (supervisor_end, client_end) = UnixStream::pair().expect("socket pair");
1045    let profile = LaunchProfile::synthetic(denials);
1046    let server = std::thread::spawn(move || serve(supervisor_end, profile));
1047    let reader = client_end.try_clone().expect("clone");
1048    let closed = Arc::new(AtomicBool::new(false));
1049    let client = Arc::new(Client {
1050        writer: Mutex::new(client_end),
1051        inflight: Mutex::new(std::collections::HashMap::new()),
1052        next_id: AtomicU64::new(1),
1053        closed: Arc::clone(&closed),
1054    });
1055    let pump = Arc::clone(&client);
1056    let reader_thread = std::thread::spawn(move || pump.pump(reader));
1057    let result = exercise(&client);
1058    // Close both cloned socket handles, as process exit does in production.
1059    client
1060        .writer
1061        .lock()
1062        .unwrap()
1063        .shutdown(std::net::Shutdown::Both)
1064        .unwrap();
1065    drop(client);
1066    let _ = server.join();
1067    let _ = reader_thread.join();
1068    result
1069}
1070
1071// ---------------------------------------------------------------------------
1072// Tests
1073// ---------------------------------------------------------------------------
1074
1075#[cfg(test)]
1076mod tests {
1077    use super::*;
1078
1079    fn pair() -> (UnixStream, UnixStream) {
1080        UnixStream::pair().expect("socket pair")
1081    }
1082
1083    #[test]
1084    fn frames_round_trip_and_reject_oversize_and_truncation() {
1085        let (mut read, mut write) = pair();
1086        write_frame(&mut write, b"payload").unwrap();
1087        drop(write);
1088        assert_eq!(read_frame(&mut read).unwrap().unwrap(), b"payload");
1089        assert!(read_frame(&mut read).unwrap().is_none());
1090
1091        let (mut read, mut write) = pair();
1092        // A length header that promises more than the channel allows must be
1093        // refused before any allocation of that size.
1094        write
1095            .write_all(&((MAX_FRAME_BYTES as u32) + 1).to_be_bytes())
1096            .unwrap();
1097        drop(write);
1098        assert!(read_frame(&mut read).is_err());
1099
1100        let (mut read, mut write) = pair();
1101        write.write_all(&64_u32.to_be_bytes()).unwrap();
1102        write.write_all(b"short").unwrap();
1103        drop(write);
1104        assert!(read_frame(&mut read).unwrap().is_none());
1105
1106        let (_read, mut write) = pair();
1107        assert!(write_frame(&mut write, &vec![0_u8; MAX_FRAME_BYTES + 1]).is_err());
1108    }
1109
1110    #[test]
1111    fn a_program_profile_may_only_narrow() {
1112        let launch = LaunchProfile::synthetic("(deny file-read* (subpath \"/store\"))\n");
1113        let composed = launch
1114            .compose(&["/store".into()], "/bin/cat")
1115            .expect("composed");
1116        assert!(composed.starts_with("(version 1)\n(deny default)"));
1117        assert!(
1118            composed.find("(deny file-read*") > composed.find("(allow file-read*"),
1119            "the launch denials must come last: {composed}"
1120        );
1121        assert!(composed.ends_with(&launch.denials));
1122        assert!(launch.compose(&["relative".into()], "/bin/cat").is_err());
1123        assert!(launch.compose(&["/path\nrule".into()], "/bin/cat").is_err());
1124        assert!(
1125            launch
1126                .compose(&vec!["/store".into(); 65], "/bin/cat")
1127                .is_err()
1128        );
1129        assert!(launch.compose(&[], "cat").is_err());
1130        let quoted = launch
1131            .compose(&["/a\") (allow default) (\"b".into()], "/bin/cat")
1132            .unwrap();
1133        assert!(quoted.contains("/a\\\") (allow default) (\\\"b"));
1134        let legacy = serde_json::json!({"version":1,"id":1,"body":{"program":{
1135            "profile":"(version 1)\n(allow default)","read_paths":[],
1136            "program":"/bin/cat","args":[],"env":[],"timeout_secs":10,"cwd":null
1137        }}});
1138        assert!(serde_json::from_value::<Request>(legacy).is_err());
1139    }
1140
1141    #[cfg(target_os = "macos")]
1142    #[test]
1143    fn combined_shell_retains_controls_and_refuses_new_grants() {
1144        let _lock = crate::core::data_dir::test_env_lock();
1145        let isolation = crate::core::data_dir::isolated_data_dir();
1146        let _session =
1147            crate::core::protected_store::pin_synthetic_session(isolation.path()).unwrap();
1148        let control = "(deny file-read* (literal \"/synthetic-control\"))\n";
1149        let combined =
1150            LaunchProfile::from_pinned(&format!("{LAUNCH_PROFILE_HEADER}{control}")).unwrap();
1151        assert!(combined.shell.ends_with(control));
1152        assert!(
1153            combined
1154                .compose(&["/".into()], "/bin/cat")
1155                .unwrap()
1156                .ends_with(control)
1157        );
1158        assert!(
1159            LaunchProfile::from_pinned(&format!("{LAUNCH_PROFILE_HEADER}(allow file-read*)\n"))
1160                .is_err()
1161        );
1162        assert!(
1163            LaunchProfile::from_pinned(&format!("{LAUNCH_PROFILE_HEADER}  {control}")).is_err()
1164        );
1165    }
1166
1167    #[test]
1168    fn a_community_session_runs_children_directly() {
1169        let _lock = crate::core::data_dir::test_env_lock();
1170        crate::core::protected_store::unpin_synthetic_session();
1171        assert!(matches!(launch_mode().unwrap(), Mode::Direct));
1172    }
1173
1174    #[test]
1175    #[cfg(target_os = "macos")]
1176    fn a_protected_session_without_a_channel_runs_nothing() {
1177        let _lock = crate::core::data_dir::test_env_lock();
1178        let isolation = crate::core::data_dir::isolated_data_dir();
1179        let _session = crate::core::protected_store::pin_synthetic_session(isolation.path())
1180            .expect("pinned rules");
1181        // No channel was installed in this process: the mode must be an error,
1182        // never a direct, unsupervised child.
1183        match launch_mode() {
1184            Err(message) => assert!(
1185                message.contains("supervised execution channel"),
1186                "{message}"
1187            ),
1188            Ok(_) => panic!("a protected session must not fall back to an unsupervised child"),
1189        }
1190    }
1191
1192    #[test]
1193    fn a_supervisor_thread_takes_the_broker_private_path() {
1194        let _lock = crate::core::data_dir::test_env_lock();
1195        crate::core::protected_store::unpin_synthetic_session();
1196        let profile = format!("{LAUNCH_PROFILE_HEADER}(deny file-read* (subpath \"/store\"))\n");
1197        with_broker_profile(profile.clone(), || match launch_mode().unwrap() {
1198            Mode::Sandboxed(fixed) => {
1199                assert_eq!(fixed, profile);
1200                assert_eq!(
1201                    sandbox_prefix(&fixed),
1202                    vec![SANDBOX_EXEC.to_string(), "-p".to_string(), profile.clone()]
1203                );
1204            }
1205            _ => panic!("a supervisor worker must apply the fixed profile itself"),
1206        });
1207        // The thread-local is scoped: it must not leak to the next request.
1208        assert!(broker_profile().is_none());
1209        with_broker_profile(profile, || {
1210            assert!(
1211                crate::core::sandbox_seatbelt::execute_sandboxed(
1212                    "/usr/bin/true",
1213                    &[],
1214                    &[],
1215                    &[],
1216                    1,
1217                    None
1218                )
1219                .is_err()
1220            );
1221        });
1222    }
1223
1224    #[test]
1225    fn standard_streams_and_closed_descriptors_are_refused() {
1226        for fd in [-1, 0, 1, 2, i32::MAX] {
1227            assert!(install_client(fd).is_err());
1228        }
1229    }
1230
1231    /// Program requests need no shell executor; the shell-runner tests live
1232    /// with the executor in the server layer.
1233    #[cfg(target_os = "macos")]
1234    fn supervised<T>(denials: &str, exercise: impl FnOnce(&Client) -> T) -> T {
1235        supervised_for_test(denials, exercise)
1236    }
1237
1238    #[cfg(target_os = "macos")]
1239    #[test]
1240    fn a_request_cannot_select_a_weaker_boundary() {
1241        let _lock = crate::core::data_dir::test_env_lock();
1242        crate::core::protected_store::unpin_synthetic_session();
1243        let temp = tempfile::tempdir().expect("temp");
1244        let root = temp.path().canonicalize().unwrap();
1245        let secret = root.join("knowledge.json");
1246        std::fs::write(&secret, "synthetic-provider-fact").unwrap();
1247        let denials = format!("(deny file-read* (subpath \"{}\"))\n", root.display());
1248        supervised(&denials, |client| {
1249            let (_, stderr, code) = client
1250                .program(&[&root], "/usr/bin/true", &[], &[], 10, None)
1251                .expect("ordinary program starts");
1252            assert_eq!(
1253                code, 0,
1254                "the profile must permit normal execution: {stderr}"
1255            );
1256            // Even an overly broad read root cannot re-open protected data.
1257            let (_, _, code) = client
1258                .program(
1259                    &[&root],
1260                    "/bin/cat",
1261                    &[secret.to_str().unwrap()],
1262                    &[],
1263                    10,
1264                    None,
1265                )
1266                .expect("the composed profile must compile and run");
1267            assert_ne!(code, 0, "the launch denials must survive a read root");
1268        });
1269    }
1270
1271    #[test]
1272    fn a_closed_channel_fails_closed_instead_of_running_a_child() {
1273        let (supervisor_end, client_end) = pair();
1274        let reader = client_end.try_clone().expect("clone");
1275        let client = Arc::new(Client {
1276            writer: Mutex::new(client_end),
1277            inflight: Mutex::new(std::collections::HashMap::new()),
1278            next_id: AtomicU64::new(1),
1279            closed: Arc::new(AtomicBool::new(false)),
1280        });
1281        let pump = Arc::clone(&client);
1282        let reader_thread = std::thread::spawn(move || pump.pump(reader));
1283        drop(supervisor_end);
1284        let (text, code) = client.shell(
1285            "echo unreachable",
1286            "/",
1287            &std::collections::HashMap::new(),
1288            Some(5_000),
1289            None,
1290            false,
1291            None,
1292        );
1293        assert_eq!(code, 1);
1294        assert!(text.contains("channel closed"), "{text}");
1295        let _ = reader_thread.join();
1296    }
1297
1298    #[test]
1299    fn a_malformed_frame_ends_the_channel_rather_than_running_it() {
1300        let (supervisor_end, mut client_end) = pair();
1301        let profile = LaunchProfile::synthetic("");
1302        let server = std::thread::spawn(move || serve(supervisor_end, profile));
1303        let payload = b"{\"not\":\"a request\"}";
1304        write_frame(&mut client_end, payload).unwrap();
1305        // The supervisor answers with a fixed refusal and stops serving.
1306        let frame = read_frame(&mut client_end).unwrap().expect("refusal");
1307        let envelope: Envelope = serde_json::from_slice(&frame).unwrap();
1308        assert!(matches!(envelope.event, Event::Failed { .. }));
1309        assert!(read_frame(&mut client_end).unwrap().is_none());
1310        drop(client_end);
1311        server.join().unwrap().unwrap();
1312    }
1313
1314    #[cfg(target_os = "macos")]
1315    #[test]
1316    fn program_output_larger_than_a_pipe_completes_and_is_bounded() {
1317        let _lock = crate::core::data_dir::test_env_lock();
1318        crate::core::protected_store::unpin_synthetic_session();
1319        let temp = tempfile::tempdir().unwrap();
1320        let root = temp.path().canonicalize().unwrap();
1321        let file = root.join("output.txt");
1322        let content = "q".repeat(512 * 1024);
1323        std::fs::write(&file, &content).unwrap();
1324        supervised("", |client| {
1325            let (stdout, stderr, code) = client
1326                .program(
1327                    &[&root],
1328                    "/bin/cat",
1329                    &[file.to_str().unwrap()],
1330                    &[],
1331                    10,
1332                    None,
1333                )
1334                .unwrap();
1335            assert_eq!(code, 0, "{stderr}");
1336            assert_eq!(stdout, content);
1337            std::fs::write(&file, "q".repeat(2 * 1024 * 1024)).unwrap();
1338            let failure = client
1339                .program(
1340                    &[&root],
1341                    "/bin/cat",
1342                    &[file.to_str().unwrap()],
1343                    &[],
1344                    10,
1345                    None,
1346                )
1347                .unwrap_err();
1348            assert!(failure.contains("capture bound"), "{failure}");
1349            let (stdout, stderr, code) = client
1350                .program(
1351                    &[],
1352                    "/bin/sh",
1353                    &["-c", "printf partial; while :; do :; done"],
1354                    &[],
1355                    1,
1356                    None,
1357                )
1358                .unwrap();
1359            assert_ne!(code, 0);
1360            assert!(stdout.contains("partial"), "{stdout}");
1361            assert!(stderr.contains("timed out"), "{stderr}");
1362        });
1363    }
1364
1365    #[test]
1366    fn oversized_final_output_returns_a_failure_frame() {
1367        let (supervisor_end, mut client_end) = pair();
1368        let supervisor = Supervisor {
1369            writer: Mutex::new(supervisor_end),
1370            profile: LaunchProfile::synthetic(""),
1371            running: Mutex::new(std::collections::HashMap::new()),
1372        };
1373        supervisor.emit(
1374            17,
1375            Event::ShellExit {
1376                text: "x".repeat(MAX_FRAME_BYTES),
1377                code: 0,
1378            },
1379        );
1380        let frame = read_frame(&mut client_end).unwrap().unwrap();
1381        let event: Envelope = serde_json::from_slice(&frame).unwrap();
1382        assert_eq!(event.id, 17);
1383        assert!(matches!(event.event, Event::Failed { .. }));
1384    }
1385}