bvisor 0.9.0

Sync-first boundary supervisor: platform-agnostic boundary contract (types + fail-closed planner) with real Linux (landlock/seccomp/cgroups) and Wasm (wasmi/WASI) confinement backends. ZERO OS code, ZERO BatPak writes in the Backend trait.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
//! The HOST-SIDE launcher harness (kernel plan §10.8, backend→launcher rewire step
//! 7a). A REUSABLE, SAFE orchestration that produces+seals a [`LinuxLaunchPlanV1`]
//! over a memfd, spawns the single-threaded Linux confinement launcher binary with
//! controlled inherited fds, and collects its transcript + terminal outcome into a
//! structured [`LaunchObservation`]. Step 7b wires this into `backend_impl::execute()`;
//! THIS step only BUILDS it and re-proves G1/G3 confinement THROUGH it.
//!
//! ## Safety posture
//! This module is SAFE Rust (the runtime-shape gate fails the build on any `unsafe`
//! outside the `sys.rs` basement). EXACTLY two basement calls do the raw work:
//!   - `sys::seal_plan_memfd` — seal the encoded plan into a read-only memfd
//!     (`LEDGER:linux-backend-memfd-seal`);
//!   - `sys::spawn_launcher_with_fds` — `Command::spawn` the launcher with a
//!     post-fork `pre_exec` that only `dup2`/`fcntl`s a PRE-BUILT fd map
//!     (`LEDGER:linux-backend-launcher-pre-exec`).
//!
//! The control socketpair + error pipe are created with SAFE std (`UnixStream::pair`,
//! `std::io::pipe`), which return CLOEXEC fds — no `unsafe` here.
//!
//! ## fd directions (from the LAUNCHER's point of view)
//! - PLAN (`BVISOR_LAUNCH_PLAN_FD`): the launcher READS the sealed memfd to EOF.
//! - CONTROL (`BVISOR_CONTROL_FD`): the launcher WRITES its state-machine transcript;
//!   the host keeps the READ end of the socketpair.
//! - ERROR WRITE (`BVISOR_ERROR_FD`): the child WRITES its errno here on failure; a
//!   successful `fexecve` CLOEXEC-closes it (the launcher owns this end + passes it to
//!   the child). It KEEPS `FD_CLOEXEC` so the EOF signal is honest.
//! - ERROR READ (`BVISOR_ERROR_READ_FD`): the launcher READS this to distinguish EOF
//!   (exec success) from errno bytes (a scrub/exec fault). A pipe is two fds; the host
//!   passes BOTH to the launcher.
//! - AUTHORITY (exe / read+write roots / stdio): pre-opened handles placed at their
//!   declared descriptor-table slot fd numbers (slot_index == fd number).
//!
//! ## Launcher binary identity
//! The launcher bin is a `[[bin]]` in this package. The harness locates it via the
//! `BVISOR_LAUNCHER_BIN` env override, else the test/dev compile-time path the caller
//! passes (`env!("CARGO_BIN_EXE_bvisor-linux-launcher")`). The launcher is now
//! content-addressed: `backend_impl::attest_launcher` records the BLAKE3 digest of the
//! bin observed at the resolved path. REMAINING (F2): the digest is computed from the
//! file AT THE PATH and the launcher is then exec'd FROM the path — a TOCTOU window;
//! true fd-pinning (open once, hash THAT fd, `fexecve` the SAME fd) is the follow-on.

use crate::backend::linux::protocol::{LauncherState, LinuxLaunchPlanV1};
use crate::backend::linux::sys::{self, LaunchFd};
use std::io::Read;
use std::os::fd::{OwnedFd, RawFd};
#[cfg(feature = "dangerous-test-hooks")]
use std::path::PathBuf;

/// The env var that overrides the launcher binary path. When set, the harness spawns
/// exactly this path; otherwise it uses the `default_path` the caller supplies (the
/// compile-time `CARGO_BIN_EXE_bvisor-linux-launcher`). Content-addressed identity is
/// step 12.
pub const ENV_LAUNCHER_BIN: &str = "BVISOR_LAUNCHER_BIN";

/// The env var names the launcher reads to learn its inherited fd NUMBERS. Frozen by the
/// launcher (`launcher/linux/imp.rs`); mirrored here so the harness and the launcher
/// agree without a shared constant module.
const ENV_PLAN_FD: &str = "BVISOR_LAUNCH_PLAN_FD";
const ENV_CONTROL_FD: &str = "BVISOR_CONTROL_FD";
const ENV_ERROR_FD: &str = "BVISOR_ERROR_FD";
const ENV_ERROR_READ_FD: &str = "BVISOR_ERROR_READ_FD";

/// `dangerous-test-hooks` ONLY: the env flag the harness forwards to the launcher to
/// force its userns map-write to fail (exercising the fail-closed reap-and-fault branch
/// on a host that DOES support unprivileged userns). The launcher reads it only under the
/// same feature. Public so the S8 teeth test names exactly this key.
#[cfg(feature = "dangerous-test-hooks")]
pub const ENV_FORCE_USERNS_MAP_FAIL: &str = "BVISOR_TEST_FORCE_USERNS_MAP_FAIL";

/// One pre-opened authority handle the launcher inherits at a fixed fd number. The
/// `slot_index` MUST equal the matching [`crate::backend::linux::protocol::DescriptorSlotV1`]
/// slot index in the plan (the launcher reads the fd at exactly the slot number), and the
/// `handle` is the host-owned descriptor placed there. Read-only roots / the exe / stdio
/// all ride a handle here — authority NEVER rides a re-opened path.
pub struct AuthorityFd {
    /// The fixed fd number the launcher will see (== the plan slot index).
    pub slot_index: RawFd,
    /// The host-owned descriptor to place at `slot_index`.
    pub handle: OwnedFd,
}

/// Why the harness could not even run the launcher (a host-side wiring fault, before any
/// launcher verdict). Distinct from a launcher REFUSAL/FAULT, which is carried in the
/// [`LaunchObservation`] the launcher itself produced.
#[derive(Debug)]
#[non_exhaustive]
pub enum HarnessError {
    /// The plan could not be canonically encoded for the memfd.
    Encode(crate::backend::linux::protocol::EncodeError),
    /// An authority slot index does not fit a `u32`/`RawFd`, or two slots collide.
    BadSlot {
        /// The offending slot index.
        slot_index: RawFd,
    },
    /// An OS error sealing the plan, setting up the channels, spawning, or collecting.
    Os(std::io::Error),
}

impl std::fmt::Display for HarnessError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Encode(e) => write!(f, "could not encode the launch plan: {e}"),
            Self::BadSlot { slot_index } => {
                write!(
                    f,
                    "authority slot index {slot_index} is invalid or collides"
                )
            }
            Self::Os(e) => write!(f, "launcher harness OS fault: {e}"),
        }
    }
}

impl std::error::Error for HarnessError {}

impl From<std::io::Error> for HarnessError {
    fn from(e: std::io::Error) -> Self {
        Self::Os(e)
    }
}

/// What the harness collected from one launcher run — the structured observation step 7b
/// maps to an `Outcome` + evidence. The TERMINAL is parsed from the launcher's own
/// transcript (its honest state machine); the harness NEVER grades confinement itself
/// (an independent on-disk oracle does that — see the G1/G3 test).
#[derive(Clone, Debug)]
pub struct LaunchObservation {
    /// The terminal [`LauncherState`] the launcher reached, parsed from the transcript's
    /// last state line. `None` ⇒ the launcher emitted no terminal (it died before
    /// resolving — a harness/launcher fault).
    pub terminal: Option<LauncherState>,
    /// Every state-machine line the launcher emitted, in order (the `# ...` note lines
    /// are dropped — they are free-form mechanism annotations, kept in [`Self::notes`]).
    /// Test-harness only (read solely by the integration tests) — compiled under
    /// `dangerous-test-hooks`, kept off the published API surface.
    #[cfg(feature = "dangerous-test-hooks")]
    pub transcript: Vec<LauncherState>,
    /// The free-form mechanism notes the launcher emitted (`# mechanism=clone3 ...`,
    /// `# confinement=Applied installed=true`, `# refusal=...`), in order. These carry
    /// the launcher's own honest mechanism attestation the report can surface.
    pub notes: Vec<String>,
    /// Whether the launcher recorded `installed=true` — its REAL confinement evidence
    /// (true IFF a landlock action was scheduled AND its ruleset built+applied).
    pub confinement_installed: bool,
    /// The launcher process's exit status (its own exit code, NOT the workload's).
    pub launcher_exit: std::process::ExitStatus,
    /// The WORKLOAD's captured stdout bytes. The launcher's clone3 child inherits the
    /// launcher's fd 1 (the scrub allowlists stdio), and the launcher is stdio-silent on
    /// every path where a workload runs, so the launcher process's own piped stdout
    /// carries ONLY the workload's output — the host reads it here. This is the honest
    /// backing for `CaptureStreams=Enforced` the backend→launcher cutover must restore.
    pub captured_stdout: Vec<u8>,
    /// The WORKLOAD's captured stderr bytes (same mechanism as [`Self::captured_stdout`]
    /// over the launcher's inherited fd 2). The launcher writes NO diagnostics to its own
    /// stderr on any workload-running path, so this carries only the workload's stderr.
    pub captured_stderr: Vec<u8>,
}

impl LaunchObservation {
    /// Whether the workload ran confined to success: the terminal is
    /// [`LauncherState::ExecSucceeded`]. Step 7b maps this to the workload-ran outcome.
    #[cfg(feature = "dangerous-test-hooks")]
    #[must_use]
    pub fn exec_succeeded(&self) -> bool {
        self.terminal == Some(LauncherState::ExecSucceeded)
    }

    /// The canonical run-time `Outcome` the terminal maps to (via the protocol's
    /// `outcome_class`), or `None` if no terminal was reached. Provided so 7b's
    /// `execute()` maps HONESTLY (ExecSucceeded→ran-confined; refused/faulted→matching).
    #[must_use]
    pub fn outcome(&self) -> Option<crate::contract::report::Outcome> {
        self.terminal
            .and_then(crate::backend::linux::protocol::outcome_class)
    }
}

/// Locate the launcher binary: the `BVISOR_LAUNCHER_BIN` override if set, else
/// `default_path` (the caller's compile-time `CARGO_BIN_EXE_bvisor-linux-launcher`).
/// Content-addressed identity is step 12 — the path is trusted as supplied here.
#[cfg(feature = "dangerous-test-hooks")]
#[must_use]
pub fn resolve_launcher_path(default_path: &str) -> PathBuf {
    match std::env::var(ENV_LAUNCHER_BIN) {
        Ok(p) if !p.trim().is_empty() => PathBuf::from(p),
        _ => PathBuf::from(default_path),
    }
}

/// Whether this host permits an UNPRIVILEGED process to create a new user namespace —
/// the S8 userns-rendezvous prerequisite. SAFE host-side probe (reads `/proc/sys` only).
///
/// Returns `false` (⇒ the userns test must SKIP, never silently pass) when EITHER:
///   - `/proc/sys/user/max_user_namespaces` reads `0` (user namespaces are globally
///     disabled, or this namespace's quota is exhausted), OR
///   - the Debian/Ubuntu `kernel.unprivileged_userns_clone` sysctl EXISTS and reads `0`
///     (unprivileged userns creation is explicitly disabled by that knob).
///
/// A MISSING `unprivileged_userns_clone` (most distros) is NOT a denial — the mainline
/// default permits it, so absence ⇒ allowed. This mirrors the landlock ABI-floor SKIP:
/// the test refuses to claim a pass it could not actually exercise.
#[must_use]
pub fn unprivileged_userns_available() -> bool {
    // Global quota: a literal "0" means no userns may be created here.
    if let Ok(text) = std::fs::read_to_string("/proc/sys/user/max_user_namespaces") {
        if text.trim() == "0" {
            return false;
        }
    }
    // Debian/Ubuntu knob: present-and-"0" ⇒ explicitly disabled. Absent ⇒ allowed.
    if let Ok(text) = std::fs::read_to_string("/proc/sys/kernel/unprivileged_userns_clone") {
        if text.trim() == "0" {
            return false;
        }
    }
    true
}

/// The `ENOSYS` capability-absence signature: the kernel — or a container's stripped
/// kernel surface (Docker dropping landlock / user-namespaces / seccomp) — LACKS the
/// syscall the launcher needs to INSTALL confinement, so setup faults with `ENOSYS`
/// ("Function not implemented", errno 38). This is the ONLY signature the
/// confinement-unavailable detectors below admit: a capable kernel that genuinely FAILS
/// to confine for any other reason emits no `ENOSYS` and is therefore never skipped, so a
/// real confinement regression still RED-fails the test that exercises it.
#[cfg(feature = "dangerous-test-hooks")]
fn detail_is_capability_absence(detail: &str) -> bool {
    detail.contains("Function not implemented")
        || detail.contains("os error 38")
        || detail.contains("errno=38")
}

/// Whether a sealed [`crate::BoundaryReportBody`]'s observed facts show the launcher could
/// not install confinement because the kernel/container LACKS the capability (`ENOSYS`),
/// as opposed to a genuine confinement failure on a capable kernel.
///
/// Conservative by construction: it requires BOTH an `ENOSYS` capability-absence signature
/// (in any observed fact's detail) AND honest evidence that confinement did NOT install —
/// a `launcher_terminal` `SetupFaulted` with `confinement_installed=false`, or a
/// `confinement_not_installed` fact. A capable kernel (confinement installs, the run
/// proceeds) never matches, so the test still RUNS and PASSES there; only an environment
/// that physically cannot install confinement trips it. A test that finds this true must
/// SKIP LOUD (never a silent pass) rather than assert a guarantee it could not exercise.
#[cfg(feature = "dangerous-test-hooks")]
#[must_use]
pub fn report_confinement_unavailable(observed: &[crate::contract::report::ObservedFact]) -> bool {
    let capability_absent = observed
        .iter()
        .any(|f| detail_is_capability_absence(&f.detail));
    let confinement_uninstalled = observed.iter().any(|f| {
        f.kind == "launcher_terminal"
            && f.detail.contains("SetupFaulted")
            && f.detail.contains("confinement_installed=false")
    }) || observed
        .iter()
        .any(|f| f.kind == "confinement_not_installed");
    capability_absent && confinement_uninstalled
}

/// The [`LaunchObservation`] analogue of [`report_confinement_unavailable`], for tests
/// that drive [`run_launcher`] directly (they hold the observation, not a sealed report).
/// Same conservative posture: an `ENOSYS` capability-absence note AND a `SetupFaulted`
/// terminal that installed no confinement.
#[cfg(feature = "dangerous-test-hooks")]
#[must_use]
pub fn launch_confinement_unavailable(obs: &LaunchObservation) -> bool {
    let capability_absent = obs.notes.iter().any(|n| detail_is_capability_absence(n));
    let confinement_uninstalled =
        obs.terminal == Some(LauncherState::SetupFaulted) && !obs.confinement_installed;
    capability_absent && confinement_uninstalled
}

/// The raw-transcript analogue, for tests that read the launcher's control transcript as a
/// single string (a hand-rolled spawn, not the harness). Same conservative posture: an
/// `ENOSYS` capability-absence line AND a `SetupFaulted` terminal line.
#[cfg(feature = "dangerous-test-hooks")]
#[must_use]
pub fn transcript_confinement_unavailable(transcript: &str) -> bool {
    let capability_absent = transcript.lines().any(detail_is_capability_absence);
    let setup_faulted = transcript.lines().any(|l| l.trim() == "SetupFaulted");
    capability_absent && setup_faulted
}

/// Run the launcher over `plan` with the pre-opened `authority` handles, collecting the
/// transcript + terminal into a [`LaunchObservation`]. SAFE end-to-end except the two
/// ledgered basement calls (memfd seal + spawn pre_exec).
///
/// The harness:
///   1. seals the encoded plan into a read-only memfd (`sys::seal_plan_memfd`);
///   2. creates a control socketpair (host keeps the read end) + an error pipe;
///   3. assigns the launcher-channel fds at numbers ABOVE every authority slot (so they
///      never collide), builds the `LaunchFd` table matching what the launcher expects;
///   4. spawns the launcher with the explicit `BVISOR_*_FD` env (`sys::spawn_launcher_with_fds`);
///   5. drains the control transcript, then waits the launcher.
///
/// # Errors
/// A [`HarnessError`] for an encode/slot/OS failure BEFORE the launcher produced a
/// verdict. A launcher refusal/fault is NOT an error — it is the [`LaunchObservation`]'s
/// terminal.
pub fn run_launcher(
    launcher_path: &std::path::Path,
    plan: &LinuxLaunchPlanV1,
    authority: Vec<AuthorityFd>,
) -> Result<LaunchObservation, HarnessError> {
    run_launcher_inner(launcher_path, plan, authority, &[])
}

/// `dangerous-test-hooks` ONLY: run the launcher with EXTRA env entries forwarded into
/// its otherwise `env_clear()`ed environment. The S8 fail-closed teeth uses this to set
/// [`ENV_FORCE_USERNS_MAP_FAIL`] for a SINGLE launch WITHOUT mutating the test process's
/// own (process-global, clippy-disallowed) environment — so concurrent tests are
/// unaffected. Production never compiles this.
///
/// # Errors
/// As [`run_launcher`].
#[cfg(feature = "dangerous-test-hooks")]
pub fn run_launcher_with_env(
    launcher_path: &std::path::Path,
    plan: &LinuxLaunchPlanV1,
    authority: Vec<AuthorityFd>,
    extra_env: &[(&str, String)],
) -> Result<LaunchObservation, HarnessError> {
    run_launcher_inner(launcher_path, plan, authority, extra_env)
}

fn run_launcher_inner(
    launcher_path: &std::path::Path,
    plan: &LinuxLaunchPlanV1,
    authority: Vec<AuthorityFd>,
    extra_env: &[(&str, String)],
) -> Result<LaunchObservation, HarnessError> {
    use std::os::unix::net::UnixStream;

    // 1. Seal the encoded plan into a read-only memfd (basement, ledgered).
    let bytes = plan.encode().map_err(HarnessError::Encode)?;
    let plan_fd = sys::seal_plan_memfd(&bytes)?;

    // 2. Control socketpair: the launcher WRITES the transcript on its end; the host
    //    READS the transcript on its end. The error pipe: the child WRITES its errno on
    //    the write end; the launcher READS the read end (it owns BOTH).
    let (control_host, control_launcher) = UnixStream::pair()?;
    let (error_read, error_write) = std::io::pipe()?;

    // 3. Pick launcher-channel fd numbers strictly ABOVE every authority slot, so a
    //    fixed channel fd can never collide with a declared descriptor slot. Authority
    //    slot indices are the fd numbers the launcher reads each handle at.
    let mut max_slot: RawFd = 2; // stdio floor
    for a in &authority {
        if a.slot_index < 0 {
            return Err(HarnessError::BadSlot {
                slot_index: a.slot_index,
            });
        }
        max_slot = max_slot.max(a.slot_index);
    }
    let base = max_slot.checked_add(1).ok_or(HarnessError::BadSlot {
        slot_index: max_slot,
    })?;
    let plan_target = base;
    let control_target = base + 1;
    let error_write_target = base + 2;
    let error_read_target = base + 3;

    // 4. Build the LaunchFd table: each authority handle at its slot fd, plus the four
    //    launcher channels at the channel fds. Only the error-WRITE end keeps CLOEXEC
    //    (so a successful workload fexecve closes it → the launcher's read end sees EOF);
    //    every other inherited fd clears CLOEXEC so the launcher inherits it.
    let mut fds: Vec<LaunchFd> = Vec::with_capacity(authority.len() + 4);
    for a in authority {
        fds.push(LaunchFd {
            src: a.handle,
            target: a.slot_index,
            keep_cloexec: false,
        });
    }
    fds.push(LaunchFd {
        src: plan_fd,
        target: plan_target,
        keep_cloexec: false,
    });
    // The host keeps `control_host`; the launcher gets the OTHER end.
    fds.push(LaunchFd {
        src: OwnedFd::from(control_launcher),
        target: control_target,
        keep_cloexec: false,
    });
    fds.push(LaunchFd {
        src: OwnedFd::from(error_write),
        target: error_write_target,
        keep_cloexec: true,
    });
    fds.push(LaunchFd {
        src: OwnedFd::from(error_read),
        target: error_read_target,
        keep_cloexec: false,
    });

    let mut env: Vec<(&str, String)> = vec![
        (ENV_PLAN_FD, plan_target.to_string()),
        (ENV_CONTROL_FD, control_target.to_string()),
        (ENV_ERROR_FD, error_write_target.to_string()),
        (ENV_ERROR_READ_FD, error_read_target.to_string()),
    ];
    // The launcher spawns with `env_clear()`, so any extra (test-only) env entry must be
    // forwarded EXPLICITLY here. In production `extra_env` is always empty.
    for (name, value) in extra_env {
        env.push((name, value.clone()));
    }

    // 5. Spawn the launcher (basement, ledgered pre_exec). The relocated source fds come
    //    back so the parent can drop them AFTER spawn (the child holds its own copies).
    //    The launcher's own stdout/stderr are piped (set in `spawn_launcher_with_fds`) so
    //    the host captures the WORKLOAD's inherited stdout/stderr here.
    let (mut child, relocated) = sys::spawn_launcher_with_fds(launcher_path, &env, fds)?;
    // Drop the parent's copies of every launcher-side fd so the launcher solely owns its
    // channels (the error read end then reaches EOF when the child's fexecve closes the
    // child write copy). `control_host` is intentionally KEPT (the host reads it).
    drop(relocated);

    // 6. Drain the workload's piped stdout/stderr CONCURRENTLY with the run (on scoped
    //    threads) while THIS thread drains the control transcript, THEN wait the launcher.
    //
    //    Reading the workload streams AFTER `wait` would DEADLOCK a workload that floods a
    //    pipe past its kernel buffer: it blocks writing to the full pipe, never exits, so
    //    `wait` never returns and the post-wait read is never reached. Concurrent draining
    //    keeps all three pipes (control + stdout + stderr) emptying as the workload runs,
    //    so the workload always makes progress regardless of output size. The scoped
    //    threads own the stream handles and their join() yields the captured bytes — no
    //    channel needed. We do NOT cap the capture — every byte is read to EOF.
    let stdout = child.stdout.take();
    let stderr = child.stderr.take();
    let mut transcript_text = String::new();
    let (captured_stdout, captured_stderr) =
        std::thread::scope(|scope| -> Result<(Vec<u8>, Vec<u8>), HarnessError> {
            let out = scope.spawn(move || drain_owned(stdout));
            let err = scope.spawn(move || drain_owned(stderr));
            let mut control = control_host;
            control.read_to_string(&mut transcript_text)?;
            let captured_stdout = out.join().map_err(|_| drain_panicked("stdout"))??;
            let captured_stderr = err.join().map_err(|_| drain_panicked("stderr"))??;
            Ok((captured_stdout, captured_stderr))
        })?;
    let launcher_exit = child.wait()?;

    Ok(parse_observation(
        &transcript_text,
        launcher_exit,
        captured_stdout,
        captured_stderr,
    ))
}

/// Read an owned piped child stream fully to EOF, returning the captured bytes. `None`
/// (the stream handle was not present) yields empty bytes — never a fault. Owns the
/// stream so it can be moved onto a scoped drain thread; generic so the same drain serves
/// both `ChildStdout` and `ChildStderr`.
fn drain_owned<R: Read>(stream: Option<R>) -> Result<Vec<u8>, HarnessError> {
    let mut buf = Vec::new();
    if let Some(mut s) = stream {
        s.read_to_end(&mut buf)?;
    }
    Ok(buf)
}

/// Map a panicked drain thread to an OS fault (a drain thread only panics on a bug, never
/// in normal operation — a pipe read errors via `io::Error`, which propagates as `Os`).
fn drain_panicked(which: &str) -> HarnessError {
    HarnessError::Os(std::io::Error::other(format!(
        "{which} drain thread panicked"
    )))
}

/// Parse the launcher's newline-delimited transcript into a structured observation.
/// State lines are the `LauncherState` `Debug` names the launcher emits; note lines are
/// prefixed `# `. The terminal is the last recognised state line. The captured workload
/// `stdout`/`stderr` (read from the launcher's piped stdio) ride into the observation
/// unchanged — they are the workload's output, NOT part of the control transcript.
fn parse_observation(
    text: &str,
    launcher_exit: std::process::ExitStatus,
    captured_stdout: Vec<u8>,
    captured_stderr: Vec<u8>,
) -> LaunchObservation {
    let mut transcript: Vec<LauncherState> = Vec::new();
    let mut notes: Vec<String> = Vec::new();
    let mut installed = false;
    for line in text.lines() {
        let line = line.trim_end();
        if let Some(note) = line.strip_prefix("# ") {
            if note.contains("installed=true") {
                installed = true;
            }
            notes.push(note.to_string());
        } else if let Some(state) = state_from_name(line) {
            transcript.push(state);
        }
    }
    let terminal = transcript.iter().rev().copied().find(|s| s.is_terminal());
    LaunchObservation {
        terminal,
        #[cfg(feature = "dangerous-test-hooks")]
        transcript,
        notes,
        confinement_installed: installed,
        launcher_exit,
        captured_stdout,
        captured_stderr,
    }
}

/// Map a transcript state name (the launcher's `LauncherState` `Debug` spelling) back to
/// the enum. Unknown lines (free text, blank) return `None` and are ignored.
fn state_from_name(name: &str) -> Option<LauncherState> {
    let state = match name {
        "LauncherStarted" => LauncherState::LauncherStarted,
        "IdentityVerified" => LauncherState::IdentityVerified,
        "PlanVerified" => LauncherState::PlanVerified,
        "HandlesVerified" => LauncherState::HandlesVerified,
        "ChildCreated" => LauncherState::ChildCreated,
        "IdentityPhaseResolved" => LauncherState::IdentityPhaseResolved,
        "VisibilityPhaseResolved" => LauncherState::VisibilityPhaseResolved,
        "AmbientAuthorityPhaseResolved" => LauncherState::AmbientAuthorityPhaseResolved,
        "ConfinementPhaseResolved" => LauncherState::ConfinementPhaseResolved,
        "ReadyToExec" => LauncherState::ReadyToExec,
        "ExecSucceeded" => LauncherState::ExecSucceeded,
        "SetupRefused" => LauncherState::SetupRefused,
        "SetupFaulted" => LauncherState::SetupFaulted,
        _ => return None,
    };
    Some(state)
}