From 555ff294848c865e19d5272cf4b314d68b23f875 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 5 Aug 2026 15:14:59 -0300 Subject: [PATCH 1/6] fix(engine): probe real containment in platform_support() platform_support() reported hosts as supported without testing what the sandbox actually needs, so callers discovered the gap at spawn time instead of at detection time. On Linux it ran `bwrap --version`, which only prints a banner and never creates a namespace. Hosts with unprivileged user namespaces disabled, or with AppArmor denying bwrap, passed detection and then failed at every spawn. It now runs a trivial sandbox with the same namespace set a real run unshares, and surfaces bwrap's own diagnostic as the reason. On Windows it returned is_supported: true unconditionally, with no check against the documented 26100 (24H2) product floor. It now gates on the OS build, failing open when the build cannot be read so a detection failure never declares a supported host unsupported. The SDK's getPlatformSupport() had both defects and gets the same fixes, including a unit test that previously pinned the missing Windows gate as intended behaviour. Below the floor the SDK still reports Windows Sandbox and IsolationSession in availableMethods, since they have their own lower floors, but they no longer set isSupported: that flag guards the default processcontainer spawn. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89d7c4bf-cda2-4835-95a3-311c5c494b1b Signed-off-by: Carlos Alexandro Becker --- docs/process-container/os-version-support.md | 8 +- sdk/node/README.md | 2 +- sdk/node/src/platform.ts | 149 ++++++++- sdk/node/tests/unit/platform.test.ts | 57 +++- src/core/mxc-sdk/README.md | 5 +- src/core/mxc_engine/src/platform.rs | 329 +++++++++++++++++-- 6 files changed, 503 insertions(+), 47 deletions(-) diff --git a/docs/process-container/os-version-support.md b/docs/process-container/os-version-support.md index 7df7abd30..fafa750b6 100644 --- a/docs/process-container/os-version-support.md +++ b/docs/process-container/os-version-support.md @@ -20,9 +20,11 @@ For the enforcement mechanisms themselves see the > **Product floor:** the [README](../../README.md#platforms) and > [SDK README](../../sdk/node/README.md) state that `processcontainer`'s **minimum -> supported build is 26100 (24H2)**. The Rust code build-gates individual -> capabilities down to 23H2 (build 22631); the **23H2** column below therefore -> describes *what the code can enforce if run there* — it is below the +> supported build is 26100 (24H2)**. This floor is enforced at detection time: +> both `platform_support()` (Rust) and `getPlatformSupport()` (TypeScript SDK) +> report a host below build 26100 as unsupported. The Rust code build-gates +> individual capabilities down to 23H2 (build 22631); the **23H2** column below +> therefore describes *what the code can enforce if run there* — it is below the > officially supported floor and is not a support commitment. ## Enforcement tiers diff --git a/sdk/node/README.md b/sdk/node/README.md index 1bb1dc382..d51ae9a25 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -334,7 +334,7 @@ Setting `cwd` (or the `workingDirectory` argument) does **not** add that path to | Error | Cause | Fix | | --- | --- | --- | -| `MXC is not supported on this platform` | `getPlatformSupport()` returned `isSupported: false`. On Linux: neither LXC nor Bubblewrap on PATH. On macOS: schema version < `0.6.0-alpha`. | Install LXC/Bubblewrap, or switch to schema `0.6.0-alpha` (or `0.7.0-alpha` if you need state-aware lifecycle). | +| `MXC is not supported on this platform` | `getPlatformSupport()` returned `isSupported: false`. On Linux: LXC is not installed and Bubblewrap cannot sandbox — `bwrap` is missing, or it is installed but cannot create a user namespace. On Windows: the host build is below 26100 (24H2) and no other containment backend is available. On macOS: schema version < `0.6.0-alpha`. | Install LXC/Bubblewrap; on a hardened kernel, enable unprivileged user namespaces (`kernel.unprivileged_userns_clone=1`) or allow `bwrap` in AppArmor. On macOS, switch to schema `0.6.0-alpha` (or `0.7.0-alpha` if you need state-aware lifecycle). | | `wxc-exec.exe not found` / `lxc-exec not found` | The SDK couldn't locate the native binary. | Set `MXC_BIN_DIR=` so `//wxc-exec.exe` (or `lxc-exec`) exists, or pass `options.executablePath` explicitly. | | `Invalid containment value ''` | `containment` field doesn't match the parser's accepted values. | Use one of the abstract intents (`process`, `vm`, `microvm`) or a concrete backend listed in [Choosing a Backend](#choosing-a-backend). | | `'' containment requires experimental mode` | A `windows_sandbox` / `wslc` / `microvm` / `isolation_session` / `hyperlight` backend was selected without the flag. | Pass `{ experimental: true }` in `SandboxSpawnOptions`. | diff --git a/sdk/node/src/platform.ts b/sdk/node/src/platform.ts index 4a2e60d0b..d585f5e77 100644 --- a/sdk/node/src/platform.ts +++ b/sdk/node/src/platform.ts @@ -63,20 +63,24 @@ type WindowsBuild = { major: number; minor: number } | null; * Default implementation that reads `CurrentBuild` / `UBR` from the * registry. Replaceable via {@link _setWindowsBuildQuery} in tests so we * can exercise the IsolationSession version gate deterministically. + * + * `UBR` is only needed for the IsolationSession minor-build gate, so an + * unreadable `UBR` degrades to `minor: 0` rather than discarding + * `CurrentBuild` — otherwise a missing revision value would silently bypass + * the `processcontainer` build floor. */ function defaultWindowsBuildQuery(): WindowsBuild { const registryPath = 'HKLM\\Software\\Microsoft\\Windows NT\\CurrentVersion'; const currentBuild = queryWindowsRegistry(registryPath, 'CurrentBuild'); - const ubrValue = queryWindowsRegistry(registryPath, 'UBR'); - if (!currentBuild || !ubrValue) { + if (!currentBuild) { return null; } const major = parseInt(currentBuild, 10); - const minor = Number(ubrValue); - if (isNaN(major) || isNaN(minor)) { + if (isNaN(major)) { return null; } - return { major, minor }; + const minor = Number(queryWindowsRegistry(registryPath, 'UBR')); + return { major, minor: isNaN(minor) ? 0 : minor }; } let windowsBuildQuery: () => WindowsBuild = defaultWindowsBuildQuery; @@ -86,6 +90,13 @@ export function _setWindowsBuildQuery(fn: (() => WindowsBuild) | null): void { windowsBuildQuery = fn ?? defaultWindowsBuildQuery; } +/** + * Minimum Windows build the `processcontainer` backend supports — 26100 + * (Windows 11 24H2). This is the product floor documented in the README and in + * `docs/process-container/os-version-support.md`. + */ +const MIN_PROCESSCONTAINER_BUILD = 26100; + /** * Check whether the host supports the IsolationSession backend. * Requires Windows Insider Preview build 26300.8553 or later. @@ -273,12 +284,13 @@ function computeSupport(): PlatformSupport { // are installed; callers pick via the containment field. const methods: ContainmentBackend[] = []; if (isLxcAvailable()) methods.push('lxc'); - if (isBubblewrapAvailable()) methods.push('bubblewrap'); + const bubblewrap = probeBubblewrap(); + if (bubblewrap.ok) methods.push('bubblewrap'); if (methods.length > 0) { support.isSupported = true; support.availableMethods = methods; } else { - support.reason = 'Neither LXC nor Bubblewrap is available on this system'; + support.reason = `No Linux containment backend is usable: LXC is not installed, and bubblewrap ${bubblewrap.detail}`; } return support; } @@ -288,14 +300,37 @@ function computeSupport(): PlatformSupport { return support; } - support.isSupported = true; - support.availableMethods = ['processcontainer']; + // The host build is the real gate on Windows: below the product floor + // `processcontainer` fails at spawn rather than at detection. An unreadable + // registry leaves the build unknown, which is treated as modern so a + // detection failure never declares a supported host unsupported. + const build = windowsBuildQuery(); + const methods: ContainmentBackend[] = []; + if (!build || build.major >= MIN_PROCESSCONTAINER_BUILD) { + methods.push('processcontainer'); + } + // Windows Sandbox and IsolationSession have their own floors, so a host + // below the processcontainer floor may still have them. They are reported, + // but they are experimental-only backends and callers reach them by opting + // in explicitly, so they cannot carry `isSupported` — that flag is what + // guards the default `processcontainer` spawn. if (isWindowsSandboxAvailable()) { - support.availableMethods.push('windows_sandbox'); + methods.push('windows_sandbox'); } if (isIsoSessionSupported()) { - support.availableMethods.push('isolation_session'); + methods.push('isolation_session'); } + support.availableMethods = methods; + + if (!methods.includes('processcontainer')) { + const alternatives = methods.length > 0 ? ` (experimental backends available: ${methods.join(', ')})` : ''; + support.reason = + `Windows build ${build?.major} is below ${MIN_PROCESSCONTAINER_BUILD}, ` + + `the minimum supported build (Windows 11 24H2)${alternatives}`; + return support; + } + + support.isSupported = true; populateIsolationFromProbe(support); return support; } @@ -313,15 +348,95 @@ function isLxcAvailable(): boolean { } /** - * Check if Bubblewrap (bwrap) is available on the system + * Arguments for a minimal end-to-end containment probe. + * + * `bwrap --version` only prints a banner — it never creates a namespace — so + * it passes on hosts where unprivileged user namespaces are disabled + * (`kernel.unprivileged_userns_clone=0`) or where AppArmor denies `bwrap` + * (Ubuntu 23.10+), both of which then fail at every spawn. + * + * The shape mirrors a real run: the same namespaces the Bubblewrap backend + * unshares, plus `--proc` / `--dev`, and `--clearenv` so the payload is + * resolved through `execvp`'s built-in `/bin:/usr/bin` default rather than the + * caller's `PATH`. Binds use `--ro-bind-try` on the few directories a shell + * needs — binding `/` instead would make the probe fail on any host with an + * awkward submount, since `bwrap` treats a failed submount remount as fatal. + * + * Kept in step with the engine's `BWRAP_PROBE_ARGS` + * (`src/core/mxc_engine/src/platform.rs`), which is pinned against the + * production argument builder by a unit test. */ -function isBubblewrapAvailable(): boolean { +const BWRAP_PROBE_ARGS = [ + '--unshare-user', + '--unshare-pid', + '--unshare-ipc', + '--unshare-uts', + '--unshare-net', + '--ro-bind-try', + '/bin', + '/bin', + '--ro-bind-try', + '/usr/bin', + '/usr/bin', + '--ro-bind-try', + '/lib', + '/lib', + '--ro-bind-try', + '/lib64', + '/lib64', + '--ro-bind-try', + '/usr/lib', + '/usr/lib', + '--ro-bind-try', + '/usr/lib64', + '/usr/lib64', + '--proc', + '/proc', + '--dev', + '/dev', + '--clearenv', + '--', + 'sh', + '-c', + 'exit 0', +]; + +/** Outcome of {@link probeBubblewrap}; `detail` is empty when `ok`. */ +type BubblewrapProbe = { ok: boolean; detail: string }; + +/** + * Check whether Bubblewrap can actually create a sandbox on this system, + * reporting bwrap's own diagnostic when it can't. + */ +function probeBubblewrap(): BubblewrapProbe { try { - execSync('bwrap --version', { encoding: 'utf-8', stdio: 'pipe' }); - return true; - } catch { - return false; + execFileSync('bwrap', BWRAP_PROBE_ARGS, { + stdio: ['ignore', 'ignore', 'pipe'], + timeout: 5000, + }); + return { ok: true, detail: '' }; + } catch (error) { + return { ok: false, detail: bwrapFailureDetail(error) }; + } +} + +/** Reduce a failed bwrap run to a single length-capped line for a `reason`. */ +function bwrapFailureDetail(error: unknown): string { + const MAX_LEN = 200; + const { code, stderr } = (error ?? {}) as { code?: string; stderr?: Buffer | string }; + if (code === 'ENOENT') { + return 'not installed'; + } + const line = (stderr?.toString() ?? '') + .split('\n') + .map((l) => l.trim()) + .find((l) => l.length > 0); + if (!line) { + return 'failed with no diagnostic output'; } + // Spread so the cap counts code points and never splits a surrogate pair. + const chars = [...line]; + return chars.length > MAX_LEN ? `${chars.slice(0, MAX_LEN).join('')}…` : line; } /** diff --git a/sdk/node/tests/unit/platform.test.ts b/sdk/node/tests/unit/platform.test.ts index bcb36c9b4..a6a6d5e07 100644 --- a/sdk/node/tests/unit/platform.test.ts +++ b/sdk/node/tests/unit/platform.test.ts @@ -371,12 +371,61 @@ describe('isolation_session availability gate', () => { assert.ok(!support.availableMethods.includes('isolation_session')); }); - it('always reports processcontainer as the default on Windows (no build gate)', { skip: !isWindows }, () => { - // Even on a hypothetical sub-24H2 build the SDK now reports support; - // the runtime gate has moved into the native binary. - _setWindowsBuildQuery(() => ({ major: 22000, minor: 0 })); + it('reports processcontainer as the default on a build at or above the floor', { skip: !isWindows }, () => { + _setWindowsBuildQuery(() => ({ major: 26100, minor: 0 })); const support = getPlatformSupport(); assert.ok(support.isSupported); assert.strictEqual(support.availableMethods[0], 'processcontainer'); }); }); + +// `processcontainer`'s product floor is Windows 11 24H2 (build 26100). Below +// it the SDK must report the host as unsupported rather than let callers +// discover the gap at spawn time. +describe('processcontainer build gate', () => { + beforeEach(() => { + _resetPlatformSupportCache(); + }); + + afterEach(() => { + _setWindowsBuildQuery(null); + _resetPlatformSupportCache(); + }); + + it('reports unsupported below build 26100', { skip: !isWindows }, () => { + // 22000 = Windows 11 21H2, 22631 = 23H2, 19045 = Windows 10 22H2. + for (const major of [19045, 22000, 22631, 26099]) { + _resetPlatformSupportCache(); + _setWindowsBuildQuery(() => ({ major, minor: 0 })); + const support = getPlatformSupport(); + assert.ok(!support.isSupported, `build ${major} should be unsupported`); + assert.ok( + !support.availableMethods.includes('processcontainer'), + `build ${major} must not offer processcontainer`, + ); + assert.match(support.reason ?? '', new RegExp(`${major}`)); + // Windows Sandbox has its own, lower floor, so it may still be listed — + // but it is experimental-only and must not make the host supported. + assert.ok(!support.availableMethods.includes('isolation_session')); + } + }); + + it('reports supported at or above build 26100', { skip: !isWindows }, () => { + for (const major of [26100, 26200, 26600]) { + _resetPlatformSupportCache(); + _setWindowsBuildQuery(() => ({ major, minor: 0 })); + const support = getPlatformSupport(); + assert.ok(support.isSupported, `build ${major} should be supported`); + assert.ok(support.availableMethods.includes('processcontainer')); + } + }); + + it('reports supported when the build cannot be read', { skip: !isWindows }, () => { + // A registry read failure must not disable sandboxing on a host that is + // in fact supported. + _setWindowsBuildQuery(() => null); + const support = getPlatformSupport(); + assert.ok(support.isSupported); + assert.ok(support.availableMethods.includes('processcontainer')); + }); +}); diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index 1ed26f1eb..7f2d609c5 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -49,7 +49,10 @@ available to feed a policy: [`available_tools_policy`] (PATH + tool/SDK env dirs), [`user_profile_policy`], and [`temporary_files_policy`]. [`platform_support`] is the Rust port of `getPlatformSupport` — reports host -support and the available containment backends. +support and the available containment backends. Each platform probes the +dependency that actually fails at spawn time: `/usr/bin/sandbox-exec` on macOS, +a real namespace-creating `bwrap` run on Linux, and the host OS build against +the 26100 (24H2) floor on Windows. ## Live stdio + kill (streaming) diff --git a/src/core/mxc_engine/src/platform.rs b/src/core/mxc_engine/src/platform.rs index 8cbe38b2b..a3a643e62 100644 --- a/src/core/mxc_engine/src/platform.rs +++ b/src/core/mxc_engine/src/platform.rs @@ -25,13 +25,32 @@ pub struct PlatformSupport { pub available_methods: Vec, } +/// Minimum Windows build the `processcontainer` backend supports — 26100 +/// (Windows 11 24H2). This is the product floor documented in the README and +/// in `docs/process-container/os-version-support.md`. +#[cfg(any(target_os = "windows", test))] +const MIN_WINDOWS_BUILD: u32 = 26100; + /// Detect MXC support on the current host. /// /// Mirrors the SDK's `getPlatformSupport`, restricted to the backends the /// `mxc-sdk` library can actually run. On Windows the isolation tier and UI /// capabilities come from the in-process fallback probe rather than a /// `wxc-exec --probe` subprocess. +/// +/// Each arm probes the dependency that actually fails at spawn time: the +/// Seatbelt binary on macOS, a real namespace-creating `bwrap` run on Linux, +/// and the host's OS build on Windows. +/// +/// Memoized for the process lifetime, matching the SDK's `getPlatformSupport` +/// — host capability is not expected to change at runtime, and the Linux arm +/// forks `bwrap`. pub fn platform_support() -> PlatformSupport { + static CACHED: std::sync::OnceLock = std::sync::OnceLock::new(); + CACHED.get_or_init(detect_platform_support).clone() +} + +fn detect_platform_support() -> PlatformSupport { #[cfg(target_os = "macos")] { if std::path::Path::new("/usr/bin/sandbox-exec").exists() { @@ -52,27 +71,22 @@ pub fn platform_support() -> PlatformSupport { #[cfg(target_os = "linux")] { - if command_succeeds("bwrap", &["--version"]) { - PlatformSupport { + match probe_bubblewrap() { + Ok(()) => PlatformSupport { is_supported: true, available_methods: vec!["bubblewrap".to_string()], ..Default::default() - } - } else { - PlatformSupport { - reason: Some("Bubblewrap is not available on this system".to_string()), + }, + Err(reason) => PlatformSupport { + reason: Some(reason), ..Default::default() - } + }, } } #[cfg(target_os = "windows")] { - PlatformSupport { - is_supported: true, - available_methods: vec!["processcontainer".to_string()], - ..Default::default() - } + windows_platform_support(appcontainer_common::job_object::os_build_number()) } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] @@ -84,17 +98,290 @@ pub fn platform_support() -> PlatformSupport { } } -/// Returns true when `program args...` exits successfully — used to probe for -/// the presence of `bwrap` on Linux. +/// Windows support decision for a given OS build number. +/// +/// Split out from [`platform_support`] as a pure function so the build gate is +/// unit-testable on every host. `os_build_number` reports [`u32::MAX`] when +/// `RtlGetVersion` fails, which lands here as "modern" — a detection failure +/// must not silently declare a supported host unsupported. +#[cfg(any(target_os = "windows", test))] +fn windows_platform_support(build: u32) -> PlatformSupport { + if build >= MIN_WINDOWS_BUILD { + PlatformSupport { + is_supported: true, + available_methods: vec!["processcontainer".to_string()], + ..Default::default() + } + } else { + PlatformSupport { + reason: Some(format!( + "Windows build {build} is below {MIN_WINDOWS_BUILD}, the minimum \ + supported build (Windows 11 24H2)" + )), + ..Default::default() + } + } +} + +/// Arguments for a minimal end-to-end containment probe. +/// +/// `bwrap --version` only prints a banner — it never creates a namespace — so +/// it passes on hosts where unprivileged user namespaces are disabled +/// (`kernel.unprivileged_userns_clone=0`) or where AppArmor denies `bwrap` +/// (Ubuntu 23.10+), both of which then fail at every spawn. +/// +/// The shape mirrors a real run: the same namespaces +/// `bwrap_command::build_args` unshares (pinned by +/// `probe_unshares_every_production_namespace`), plus `--proc` / `--dev`, and +/// `--clearenv` so the payload is resolved through `execvp`'s built-in +/// `/bin:/usr/bin` default rather than the caller's `PATH`. Binds use +/// `--ro-bind-try` on the few directories a shell needs — binding `/` instead +/// would make the probe fail on any host with an awkward submount, since +/// `bwrap` treats a failed submount remount as fatal. +#[cfg(any(target_os = "linux", test))] +const BWRAP_PROBE_ARGS: &[&str] = &[ + "--unshare-user", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + "--unshare-net", + "--ro-bind-try", + "/bin", + "/bin", + "--ro-bind-try", + "/usr/bin", + "/usr/bin", + "--ro-bind-try", + "/lib", + "/lib", + "--ro-bind-try", + "/lib64", + "/lib64", + "--ro-bind-try", + "/usr/lib", + "/usr/lib", + "--ro-bind-try", + "/usr/lib64", + "/usr/lib64", + "--proc", + "/proc", + "--dev", + "/dev", + "--clearenv", + "--", + "sh", + "-c", + "exit 0", +]; + +/// How long the probe may take before it is treated as a failure. The probe +/// mounts and forks, so unlike the `--version` call it replaced it can block +/// on a wedged filesystem. #[cfg(target_os = "linux")] -fn command_succeeds(program: &str, args: &[&str]) -> bool { +const BWRAP_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +/// Run [`BWRAP_PROBE_ARGS`], reporting why the host cannot sandbox on failure. +#[cfg(target_os = "linux")] +fn probe_bubblewrap() -> Result<(), String> { + use std::io::ErrorKind; use std::process::{Command, Stdio}; - Command::new(program) - .args(args) + + let child = Command::new("bwrap") + .args(BWRAP_PROBE_ARGS) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) + .stderr(Stdio::piped()) + .spawn(); + + let mut child = match child { + Ok(child) => child, + Err(e) if e.kind() == ErrorKind::NotFound => { + return Err("Bubblewrap is not available on this system".to_string()) + } + Err(e) => return Err(format!("Bubblewrap could not be executed: {e}")), + }; + + let output = match wait_with_deadline(&mut child, BWRAP_PROBE_TIMEOUT) { + Some(Ok(output)) => output, + Some(Err(e)) => return Err(format!("Bubblewrap could not be executed: {e}")), + None => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "Bubblewrap did not finish a trivial sandbox within {}s on this host", + BWRAP_PROBE_TIMEOUT.as_secs() + )); + } + }; + + if output.status.success() { + return Ok(()); + } + Err(format!( + "Bubblewrap is installed but cannot create a sandbox on this host: {}", + bwrap_failure_detail(&output.stderr) + )) +} + +/// Collect `child`'s output, or return `None` if it outlives `timeout`. +#[cfg(target_os = "linux")] +fn wait_with_deadline( + child: &mut std::process::Child, + timeout: std::time::Duration, +) -> Option> { + use std::time::Instant; + + let deadline = Instant::now() + timeout; + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(std::time::Duration::from_millis(25)); + } + Ok(None) => return None, + Err(e) => return Some(Err(e)), + } + } + // The child has exited, so `wait_with_output` drains the already-closed + // stderr pipe and returns immediately. + Some(child.stderr.take().map_or_else( + || Err(std::io::Error::other("bwrap stderr pipe was not captured")), + |mut stderr| { + use std::io::Read; + let mut buf = Vec::new(); + stderr.read_to_end(&mut buf)?; + Ok(std::process::Output { + status: child.wait()?, + stdout: Vec::new(), + stderr: buf, + }) + }, + )) +} + +/// Reduce `bwrap`'s stderr to a single length-capped line for a `reason`. +#[cfg(any(target_os = "linux", test))] +fn bwrap_failure_detail(stderr: &[u8]) -> String { + const MAX_LEN: usize = 200; + + let text = String::from_utf8_lossy(stderr); + let Some(line) = text.lines().map(str::trim).find(|l| !l.is_empty()) else { + return "no diagnostic output".to_string(); + }; + match line.char_indices().nth(MAX_LEN) { + Some((end, _)) => format!("{}…", &line[..end]), + None => line.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `is_supported`, `reason`, and `available_methods` must agree on every + /// host: a supported host names its backends and gives no reason, an + /// unsupported one does the opposite. + #[test] + fn support_fields_are_consistent() { + let support = platform_support(); + assert_eq!(support.is_supported, support.reason.is_none()); + assert_eq!(support.is_supported, !support.available_methods.is_empty()); + } + + #[test] + fn windows_build_at_or_above_floor_is_supported() { + for build in [MIN_WINDOWS_BUILD, 26200, u32::MAX] { + let support = windows_platform_support(build); + assert!(support.is_supported, "build {build}"); + assert_eq!(support.available_methods, ["processcontainer"]); + assert!(support.reason.is_none()); + } + } + + #[test] + fn windows_build_below_floor_is_unsupported() { + // 19045 = Windows 10 22H2, 22631 = Windows 11 23H2. + for build in [0, 19045, 22631, MIN_WINDOWS_BUILD - 1] { + let support = windows_platform_support(build); + assert!(!support.is_supported, "build {build}"); + assert!(support.available_methods.is_empty()); + let reason = support.reason.expect("unsupported build needs a reason"); + assert!(reason.contains(&build.to_string()), "reason: {reason}"); + } + } + + /// The probe is only a precondition worth trusting if it unshares + /// everything a real spawn does — a namespace type the host disables + /// (`user.max_uts_namespaces=0` and friends) must fail here, not at spawn. + #[cfg(target_os = "linux")] + #[test] + fn probe_unshares_every_production_namespace() { + use wxc_common::models::ExecutionRequest; + + let request = ExecutionRequest { + script_code: "exit 0".to_string(), + ..Default::default() + }; + let production = bwrap_common::bwrap_command::build_args(&request, None); + let unshares: Vec<&String> = production + .iter() + .filter(|a| a.starts_with("--unshare-")) + .collect(); + + assert!( + !unshares.is_empty(), + "production emits no --unshare-* flags" + ); + for flag in unshares { + assert!( + BWRAP_PROBE_ARGS.contains(&flag.as_str()), + "probe is missing {flag}, which every production spawn uses" + ); + } + } + + /// `--clearenv` is what keeps the probe's verdict independent of the + /// caller's `PATH`: `execvp` then falls back to its built-in + /// `/bin:/usr/bin`, both of which the probe binds. + #[test] + fn probe_clears_the_environment_and_binds_the_paths_it_execs_from() { + assert!(BWRAP_PROBE_ARGS.contains(&"--clearenv")); + for dir in ["/bin", "/usr/bin"] { + assert!(BWRAP_PROBE_ARGS.contains(&dir), "probe does not bind {dir}"); + } + } + + /// Binding `/` makes the probe fail on any host with an awkward submount, + /// because `bwrap` treats a failed submount remount as fatal. + #[test] + fn probe_never_binds_the_host_root() { + let root_bind = BWRAP_PROBE_ARGS + .windows(3) + .any(|w| w[0].starts_with("--ro-bind") && w[1] == "/" && w[2] == "/"); + assert!(!root_bind, "probe must not bind-mount host /"); + } + + #[test] + fn bwrap_failure_detail_reports_first_nonempty_line() { + let stderr = b"\n \nbwrap: No permissions to creating new namespace\nsecond line\n"; + assert_eq!( + bwrap_failure_detail(stderr), + "bwrap: No permissions to creating new namespace" + ); + } + + #[test] + fn bwrap_failure_detail_handles_silence() { + assert_eq!(bwrap_failure_detail(b""), "no diagnostic output"); + assert_eq!(bwrap_failure_detail(b"\n \n"), "no diagnostic output"); + } + + /// Truncation must land on a character boundary, not inside a multi-byte + /// sequence — `bwrap` echoes back paths that may not be ASCII. + #[test] + fn bwrap_failure_detail_truncates_on_char_boundary() { + let detail = bwrap_failure_detail("é".repeat(300).as_bytes()); + assert!(detail.ends_with('…')); + assert_eq!(detail.chars().count(), 201); + } } From 63c16afa680787da97cfdbb49b3338c2b7348421 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 5 Aug 2026 15:46:04 -0300 Subject: [PATCH 2/6] fix(sdk): honor experimental backends in the shared platform gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows build floor added in the previous commit ties `isSupported` to `processcontainer`, which is correct for the default spawn path but was also vetoing every state-aware phase: `resolveBinaryAndCommonArgs` checked `isSupported` unconditionally, so a sub-26100 host could list `windows_sandbox` in `availableMethods` and still be refused when calling its state-aware API. Thread the selected containment through the shared helper and apply the same experimental bypass `resolveExecutableAndArgs` already had — which the shared check was silently undoing on the one-shot path too. Also bound `probe_bwrap` with the deadline the engine probe already used, so platform detection as a whole is bounded rather than just its second half, and drop the engine's now-duplicate copy of the helper. Two doc corrections: IsolationSession pins build 26300, so it can never appear below the processcontainer floor (only Windows Sandbox can), and the SDK troubleshooting row described macOS support as schema-version dependent when it is really `/usr/bin/sandbox-exec` presence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89d7c4bf-cda2-4835-95a3-311c5c494b1b Signed-off-by: Carlos Alexandro Becker --- sdk/node/README.md | 2 +- sdk/node/src/helper.ts | 16 +++- sdk/node/src/platform.ts | 10 +-- sdk/node/src/state-aware-helper.ts | 10 ++- sdk/node/src/state-aware.ts | 16 ++-- .../bubblewrap/common/src/bwrap_version.rs | 89 ++++++++++++++++--- src/core/mxc_engine/src/platform.rs | 50 ++--------- 7 files changed, 121 insertions(+), 72 deletions(-) diff --git a/sdk/node/README.md b/sdk/node/README.md index 6f04e2a2e..069949093 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -340,7 +340,7 @@ Setting `cwd` (or the `workingDirectory` argument) does **not** add that path to | Error | Cause | Fix | | --- | --- | --- | -| `MXC is not supported on this platform` | `getPlatformSupport()` returned `isSupported: false`. On Linux: LXC is not installed and Bubblewrap cannot sandbox — `bwrap` is missing, or it is installed but cannot create a user namespace. On Windows: the host build is below 26100 (24H2) and no other containment backend is available. On macOS: schema version < `0.6.0-alpha`. | Install LXC/Bubblewrap; on a hardened kernel, enable unprivileged user namespaces (`kernel.unprivileged_userns_clone=1`) or allow `bwrap` in AppArmor. On macOS, switch to schema `0.6.0-alpha` (or `0.7.0-alpha` if you need state-aware lifecycle). | +| `MXC is not supported on this platform` | `getPlatformSupport()` returned `isSupported: false`. On Linux: LXC is not installed and Bubblewrap cannot sandbox — `bwrap` is missing, too old, or installed but unable to create a user namespace. On Windows: the host build is below 26100 (24H2), so `processcontainer` is unavailable. Experimental backends such as `windows_sandbox` may still be listed in `availableMethods`, but they do not make the host supported. On macOS: `/usr/bin/sandbox-exec` is missing. | Install LXC/Bubblewrap; on a hardened kernel, enable unprivileged user namespaces (`kernel.unprivileged_userns_clone=1`) or allow `bwrap` in AppArmor. On Windows, upgrade to Windows 11 24H2 (build 26100) or newer, or select an experimental backend explicitly with `{ experimental: true }`. On macOS, repair the OS install. | | `wxc-exec.exe not found` / `lxc-exec not found` | The SDK couldn't locate the native binary. | Set `MXC_BIN_DIR=` so `//wxc-exec.exe` (or `lxc-exec`) exists, or pass `options.executablePath` explicitly. | | `Invalid containment value ''` | `containment` field doesn't match the parser's accepted values. | Use one of the abstract intents (`process`, `vm`, `microvm`) or a concrete backend listed in [Choosing a Backend](#choosing-a-backend). | | `'' containment requires experimental mode` | A `windows_sandbox` / `wslc` / `microvm` / `isolation_session` / `hyperlight` backend was selected without the flag. | Pass `{ experimental: true }` in `SandboxSpawnOptions`. | diff --git a/sdk/node/src/helper.ts b/sdk/node/src/helper.ts index d0d2fbabe..d61f31ada 100644 --- a/sdk/node/src/helper.ts +++ b/sdk/node/src/helper.ts @@ -147,9 +147,17 @@ export function applyLinuxNetworkPolicy(config: ContainerConfig): void { export function resolveBinaryAndCommonArgs( envelopeJson: string, options: SandboxSpawnOptions, + containment?: string, ): { executablePath: string; args: string[] } { const platformSupport = getPlatformSupport(); - if (!platformSupport.isSupported && !options.skipPlatformCheck) { + // `isSupported` tracks the default, non-experimental backend, so it must not + // veto an experimental backend the caller asked for by name: those have their + // own host requirements (Windows Sandbox, for instance, has a lower build + // floor than `processcontainer`). Mirrors the bypass in + // `resolveExecutableAndArgs`, which would otherwise be undone here. + const isExperimental = + !!containment && (ExperimentalBackends as readonly string[]).includes(containment); + if (!platformSupport.isSupported && !isExperimental && !options.skipPlatformCheck) { throw new Error(`MXC is not supported on this platform: ${platformSupport.reason}`); } @@ -284,7 +292,11 @@ export function resolveExecutableAndArgs( ); } - const resolved = resolveBinaryAndCommonArgs(JSON.stringify(config), options); + const resolved = resolveBinaryAndCommonArgs( + JSON.stringify(config), + options, + effectiveContainment, + ); if (usesBuiltinTestServer) { resolved.args.push('--allow-testing-features'); } diff --git a/sdk/node/src/platform.ts b/sdk/node/src/platform.ts index 16cdc10c9..d196c3552 100644 --- a/sdk/node/src/platform.ts +++ b/sdk/node/src/platform.ts @@ -321,11 +321,11 @@ function computeSupport(): PlatformSupport { if (!build || build.major >= MIN_PROCESSCONTAINER_BUILD) { methods.push('processcontainer'); } - // Windows Sandbox and IsolationSession have their own floors, so a host - // below the processcontainer floor may still have them. They are reported, - // but they are experimental-only backends reached by explicit opt-in, so - // they cannot carry `isSupported` — that flag is what guards the default - // `processcontainer` spawn. + // Windows Sandbox has its own, lower floor, so a host below the + // processcontainer floor may still have it. Both it and IsolationSession are + // reported when present, but they are experimental-only backends reached by + // explicit opt-in, so they cannot carry `isSupported` — that flag is what + // guards the default `processcontainer` spawn. if (isWindowsSandboxAvailable()) { methods.push('windows_sandbox'); } diff --git a/sdk/node/src/state-aware-helper.ts b/sdk/node/src/state-aware-helper.ts index e7b09f807..5f37d5ded 100644 --- a/sdk/node/src/state-aware-helper.ts +++ b/sdk/node/src/state-aware-helper.ts @@ -183,6 +183,7 @@ export interface CollectedOutput { export function spawnAndCollect( envelope: Record, options: SandboxSpawnOptions, + containment?: string, ): Promise { return new Promise((resolve, reject) => { const signal = options.signal; @@ -194,7 +195,11 @@ export function spawnAndCollect( let executablePath: string; let args: string[]; try { - ({ executablePath, args } = resolveBinaryAndCommonArgs(JSON.stringify(envelope), options)); + ({ executablePath, args } = resolveBinaryAndCommonArgs( + JSON.stringify(envelope), + options, + containment, + )); } catch (err) { reject(err); return; @@ -264,7 +269,8 @@ export function spawnAndCollect( export async function nonExecCall( envelope: Record, options: SandboxSpawnOptions, + containment?: string, ): Promise { - const { stdout } = await spawnAndCollect(envelope, options); + const { stdout } = await spawnAndCollect(envelope, options, containment); return parseNonExecResponse(stdout); } diff --git a/sdk/node/src/state-aware.ts b/sdk/node/src/state-aware.ts index 59c663ad0..52177e946 100644 --- a/sdk/node/src/state-aware.ts +++ b/sdk/node/src/state-aware.ts @@ -48,7 +48,7 @@ export async function provisionSandbox( sandboxId: string; metadata?: ProvisionMetadataFor; correlationVector?: string; - }>(envelope, options); + }>(envelope, options, containment); return { sandboxId: result.sandboxId as SandboxId, metadata: result.metadata, @@ -73,7 +73,7 @@ export async function startSandbox( correlationVector: options.correlationVector, config: config as Record | undefined, }); - return nonExecCall>(envelope, options); + return nonExecCall>(envelope, options, backendKey); } /** @@ -96,7 +96,11 @@ export function execInSandbox( correlationVector: options.correlationVector, config: config as unknown as Record, }); - const { executablePath, args } = resolveBinaryAndCommonArgs(JSON.stringify(envelope), options); + const { executablePath, args } = resolveBinaryAndCommonArgs( + JSON.stringify(envelope), + options, + backendKey, + ); diagLog(`state-aware: spawning exec via PTY`); const ptyProcess = pty.spawn(executablePath, args, { name: 'xterm-color', @@ -137,7 +141,7 @@ export async function execInSandboxAsync correlationVector: options.correlationVector, config: config as unknown as Record, }); - const { stdout, stderr, exitCode } = await spawnAndCollect(envelope, options); + const { stdout, stderr, exitCode } = await spawnAndCollect(envelope, options, backendKey); if (exitCode !== 0) { const errorEnvelope = tryParseErrorEnvelope(stdout); @@ -167,7 +171,7 @@ export async function stopSandbox( correlationVector: options.correlationVector, config: config as Record | undefined, }); - return nonExecCall>(envelope, options); + return nonExecCall>(envelope, options, backendKey); } /** @@ -187,5 +191,5 @@ export async function deprovisionSandbox correlationVector: options.correlationVector, config: config as Record | undefined, }); - return nonExecCall>(envelope, options); + return nonExecCall>(envelope, options, backendKey); } diff --git a/src/backends/bubblewrap/common/src/bwrap_version.rs b/src/backends/bubblewrap/common/src/bwrap_version.rs index 17a33a2f7..484f3744d 100644 --- a/src/backends/bubblewrap/common/src/bwrap_version.rs +++ b/src/backends/bubblewrap/common/src/bwrap_version.rs @@ -13,7 +13,17 @@ //! [`probe_bwrap`] shells out. use std::fmt; -use std::process::{Command, Stdio}; +use std::io; +use std::process::{Child, Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +/// How long a `bwrap` probe may run before it is treated as a failure. +/// +/// Platform detection is synchronous, so without a bound a `bwrap` that hangs +/// — a wrapper script on PATH, a binary on a stalled network mount — blocks +/// the caller indefinitely. Mirrors `BWRAP_VERSION_TIMEOUT_MS` in the +/// TypeScript SDK (`sdk/node/src/platform.ts`). +pub const PROBE_TIMEOUT: Duration = Duration::from_secs(5); /// The minimum `bwrap` version the Bubblewrap backend supports. /// @@ -135,20 +145,39 @@ impl std::error::Error for BwrapUnavailable {} /// Runs `bwrap --version` and validates the reported version against /// [`MIN_BWRAP_VERSION`]. Returns the detected version on success. pub fn probe_bwrap() -> Result { - let output = Command::new("bwrap") + let spawn_failure = |err: io::Error| match err.kind() { + // `ENOENT` covers both an absent binary and a present-but-unusable + // one (missing ELF interpreter / shebang target), so confirm the + // binary is really absent before blaming the package manager. + io::ErrorKind::NotFound if !bwrap_exists_on_path() => BwrapUnavailable::NotFound, + _ => BwrapUnavailable::ProbeFailed { + status: None, + detail: err.to_string(), + }, + }; + + let mut child = Command::new("bwrap") .arg("--version") .stdin(Stdio::null()) - .output() - .map_err(|err| match err.kind() { - // `ENOENT` covers both an absent binary and a present-but-unusable - // one (missing ELF interpreter / shebang target), so confirm the - // binary is really absent before blaming the package manager. - std::io::ErrorKind::NotFound if !bwrap_exists_on_path() => BwrapUnavailable::NotFound, - _ => BwrapUnavailable::ProbeFailed { + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(spawn_failure)?; + + let output = match wait_with_deadline(&mut child, PROBE_TIMEOUT) { + Some(result) => result.map_err(spawn_failure)?, + None => { + let _ = child.kill(); + let _ = child.wait(); + return Err(BwrapUnavailable::ProbeFailed { status: None, - detail: err.to_string(), - }, - })?; + detail: format!( + "did not respond within {}s and was killed", + PROBE_TIMEOUT.as_secs() + ), + }); + } + }; if !output.status.success() { return Err(BwrapUnavailable::ProbeFailed { @@ -162,6 +191,42 @@ pub fn probe_bwrap() -> Result { check_version_output(&stdout) } +/// Collect `child`'s output, or return `None` if it outlives `timeout`. +/// +/// `Child::wait_with_output` has no deadline, so this polls `try_wait` and +/// only drains the pipes once the process has exited — at which point the +/// reads return immediately and cannot block on a full pipe. +pub fn wait_with_deadline(child: &mut Child, timeout: Duration) -> Option> { + use std::io::Read; + + let deadline = Instant::now() + timeout; + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(25)), + Ok(None) => return None, + Err(e) => return Some(Err(e)), + } + } + + let read_pipe = |pipe: Option<&mut dyn Read>| -> io::Result> { + let mut buf = Vec::new(); + if let Some(pipe) = pipe { + pipe.read_to_end(&mut buf)?; + } + Ok(buf) + }; + Some((|| { + let stdout = read_pipe(child.stdout.as_mut().map(|p| p as &mut dyn Read))?; + let stderr = read_pipe(child.stderr.as_mut().map(|p| p as &mut dyn Read))?; + Ok(Output { + status: child.wait()?, + stdout, + stderr, + }) + })()) +} + /// Validate a raw `bwrap --version` output string against /// [`MIN_BWRAP_VERSION`]. Split out from [`probe_bwrap`] so the decision logic /// is testable without a `bwrap` binary on the host. diff --git a/src/core/mxc_engine/src/platform.rs b/src/core/mxc_engine/src/platform.rs index d5459ad72..96350e817 100644 --- a/src/core/mxc_engine/src/platform.rs +++ b/src/core/mxc_engine/src/platform.rs @@ -199,15 +199,13 @@ const BWRAP_PROBE_ARGS: &[&str] = &[ "exit 0", ]; -/// How long the probe may take before it is treated as a failure. The probe -/// mounts and forks, so unlike the `--version` call it replaced it can block -/// on a wedged filesystem. -#[cfg(target_os = "linux")] -const BWRAP_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); - /// Run [`BWRAP_PROBE_ARGS`], reporting why the host cannot sandbox on failure. +/// +/// Bounded by the same deadline as the version probe: this one mounts and +/// forks, so it can block on a wedged filesystem. #[cfg(target_os = "linux")] fn probe_bubblewrap() -> Result<(), String> { + use bwrap_common::bwrap_version::{wait_with_deadline, PROBE_TIMEOUT}; use std::io::ErrorKind; use std::process::{Command, Stdio}; @@ -226,7 +224,7 @@ fn probe_bubblewrap() -> Result<(), String> { Err(e) => return Err(format!("Bubblewrap could not be executed: {e}")), }; - let output = match wait_with_deadline(&mut child, BWRAP_PROBE_TIMEOUT) { + let output = match wait_with_deadline(&mut child, PROBE_TIMEOUT) { Some(Ok(output)) => output, Some(Err(e)) => return Err(format!("Bubblewrap could not be executed: {e}")), None => { @@ -234,7 +232,7 @@ fn probe_bubblewrap() -> Result<(), String> { let _ = child.wait(); return Err(format!( "Bubblewrap did not finish a trivial sandbox within {}s on this host", - BWRAP_PROBE_TIMEOUT.as_secs() + PROBE_TIMEOUT.as_secs() )); } }; @@ -248,42 +246,6 @@ fn probe_bubblewrap() -> Result<(), String> { )) } -/// Collect `child`'s output, or return `None` if it outlives `timeout`. -#[cfg(target_os = "linux")] -fn wait_with_deadline( - child: &mut std::process::Child, - timeout: std::time::Duration, -) -> Option> { - use std::time::Instant; - - let deadline = Instant::now() + timeout; - loop { - match child.try_wait() { - Ok(Some(_)) => break, - Ok(None) if Instant::now() < deadline => { - std::thread::sleep(std::time::Duration::from_millis(25)); - } - Ok(None) => return None, - Err(e) => return Some(Err(e)), - } - } - // The child has exited, so `wait_with_output` drains the already-closed - // stderr pipe and returns immediately. - Some(child.stderr.take().map_or_else( - || Err(std::io::Error::other("bwrap stderr pipe was not captured")), - |mut stderr| { - use std::io::Read; - let mut buf = Vec::new(); - stderr.read_to_end(&mut buf)?; - Ok(std::process::Output { - status: child.wait()?, - stdout: Vec::new(), - stderr: buf, - }) - }, - )) -} - /// Reduce `bwrap`'s stderr to a single length-capped line for a `reason`. #[cfg(any(target_os = "linux", test))] fn bwrap_failure_detail(stderr: &[u8]) -> String { From a63524e515ae9fe0f4487018a42d61e13b29a85b Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 5 Aug 2026 16:14:31 -0300 Subject: [PATCH 3/6] style(sdk): restore CRLF line endings in platform.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file is stored with CRLF upstream, and my earlier edits rewrote it as LF — Python's text mode does universal-newline translation on read and writes back `\n`. That turned a +153/-9 change into a +901/-757 whole-file rewrite, burying the actual diff. No content change: `git diff --ignore-cr-at-eol` against the merge base is identical before and after. Note `core.autocrlf=input` strips CRLF on commit, so this had to be staged with the conversion disabled to keep the blob matching upstream. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89d7c4bf-cda2-4835-95a3-311c5c494b1b Signed-off-by: Carlos Alexandro Becker --- sdk/node/src/platform.ts | 1802 +++++++++++++++++++------------------- 1 file changed, 901 insertions(+), 901 deletions(-) diff --git a/sdk/node/src/platform.ts b/sdk/node/src/platform.ts index d196c3552..384cac819 100644 --- a/sdk/node/src/platform.ts +++ b/sdk/node/src/platform.ts @@ -1,901 +1,901 @@ -import * as os from 'os'; -import * as fs from 'fs'; -import * as path from 'path'; -import { execSync, execFileSync } from 'child_process'; -import { fileURLToPath } from 'node:url'; -import { ContainmentBackend, IsolationTier, PlatformSupport, UiCapabilitySupport } from './types.js'; -import { diagLog } from './diagnostic.js'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -/** - * Resolves the SDK package root directory. - * Uses require.resolve to find the package.json (works when the SDK is installed - * in node_modules, even if the consuming code is bundled by esbuild/webpack). - * Falls back to __dirname for local development (monorepo layout). - */ -function getSdkPackageRoot(): string { - try { - return path.dirname(require.resolve('@microsoft/mxc-sdk/package.json')); - } catch { - // Fallback: __dirname is dist/, so parent is package root - return path.join(__dirname, '..'); - } -} - -/** - * Query Windows Registry for a value - * @param key - Registry key path (e.g., "HKLM\\Software\\...") - * @param valueName - Name of the value to query - * @returns The registry value as a string, or null if not found - */ -function queryWindowsRegistry(key: string, valueName: string): string | null { - try { - const command = `reg query "${key}" /v "${valueName}"`; - const output = execSync(command, { encoding: 'utf-8', stdio: 'pipe' }); - - // Parse output - format is: - // HKEY_LOCAL_MACHINE\... - // ValueName REG_SZ Value - const lines = output.split('\n'); - for (const line of lines) { - if (line.includes(valueName)) { - // Extract value after REG_SZ or REG_DWORD - const match = line.match(/REG_\w+\s+(.+)/); - if (match) { - return match[1].trim(); - } - } - } - return null; - } catch { - return null; - } -} - -/** - * Result of querying the host's Windows build number, or `null` when the - * registry values are missing or unparseable. - */ -type WindowsBuild = { major: number; minor: number } | null; - -/** - * Default implementation that reads `CurrentBuild` / `UBR` from the - * registry. Replaceable via {@link _setWindowsBuildQuery} in tests so we - * can exercise the IsolationSession version gate deterministically. - */ -function defaultWindowsBuildQuery(): WindowsBuild { - const registryPath = 'HKLM\\Software\\Microsoft\\Windows NT\\CurrentVersion'; - const currentBuild = queryWindowsRegistry(registryPath, 'CurrentBuild'); - if (!currentBuild) { - return null; - } - const major = parseInt(currentBuild, 10); - if (isNaN(major)) { - return null; - } - // `UBR` is only needed for the IsolationSession minor-build gate, so an - // unreadable revision degrades to 0 rather than discarding `CurrentBuild` — - // otherwise a missing value would silently bypass the processcontainer - // build floor. - const minor = Number(queryWindowsRegistry(registryPath, 'UBR')); - return { major, minor: isNaN(minor) ? 0 : minor }; -} - -let windowsBuildQuery: () => WindowsBuild = defaultWindowsBuildQuery; - -/** @internal Test-only: override the Windows build lookup. */ -export function _setWindowsBuildQuery(fn: (() => WindowsBuild) | null): void { - windowsBuildQuery = fn ?? defaultWindowsBuildQuery; -} - -/** - * Minimum Windows build the `processcontainer` backend supports — 26100 - * (Windows 11 24H2). This is the product floor documented in the README and in - * `docs/process-container/os-version-support.md`. - * - * Mirrors `MIN_WINDOWS_BUILD` in `src/core/mxc_engine/src/platform.rs` — keep - * both in sync. - */ -const MIN_PROCESSCONTAINER_BUILD = 26100; - -/** - * Check whether the host supports the IsolationSession backend. - * Requires Windows Insider Preview build 26300.8553 or later. - * - * No internal cache — `getPlatformSupport` memoizes the full result, and - * registry reads are cheap relative to the rest of the probe. - */ -function isIsoSessionSupported(): boolean { - const build = windowsBuildQuery(); - if (!build) { - return false; - } - - // Pin to the Windows Insider Preview build that introduced IsolationSession - // (26300.8553+). Other major builds are not yet supported. - return build.major === 26300 && build.minor >= 8553; -} - -let windowsSandboxAvailableCache: boolean | undefined; - -/** - * Check if Windows Sandbox feature is enabled via DISM. - * @returns true if the Containers-DisposableClientVM feature is enabled - */ -function isWindowsSandboxAvailable(): boolean { - if (windowsSandboxAvailableCache !== undefined) { - return windowsSandboxAvailableCache; - } - - try { - const output = execSync( - 'dism /online /get-featureinfo /featurename:Containers-DisposableClientVM', - { encoding: 'utf-8', stdio: 'pipe', timeout: 10000 }, - ); - windowsSandboxAvailableCache = /State\s*:\s*Enabled/i.test(output); - } catch { - // `dism /online` typically requires elevation, so a non-elevated session - // throws here and we can't distinguish "disabled" from "no permission". - // Fall back to checking for the sandbox executable — Windows installs it - // under System32 only when the Containers-DisposableClientVM feature is - // enabled, and the path is readable without admin. - const sandboxExe = path.join( - process.env.SystemRoot || 'C:\\Windows', - 'System32', - 'WindowsSandbox.exe', - ); - windowsSandboxAvailableCache = fs.existsSync(sandboxExe); - } - - return windowsSandboxAvailableCache; -} - -/** - * Get platform support information. - * - * On Windows, this also invokes `wxc-exec --probe` to populate - * `isolationTier`, the `isolationWarnings` array (if any), and portable UI - * capability facts. Linux and macOS currently do not expose native probe data, - * so `uiCapabilities` is omitted on those platforms. The result is cached for - * the lifetime of the SDK module — the underlying machine state is not - * expected to change at runtime. - * - * @returns Platform support details including available sandboxing methods - */ -export function getPlatformSupport(): PlatformSupport { - if (cachedSupport !== null) { - return cachedSupport; - } - const support = computeSupport(); - cachedSupport = support; - return support; -} - -let cachedSupport: PlatformSupport | null = null; - -/** @internal Test-only: clear the cached PlatformSupport. */ -export function _resetPlatformSupportCache(): void { - cachedSupport = null; -} - -/** - * Probe runner injection seam. Spawns `wxc-exec --probe` and returns - * its stdout. Replaceable in unit tests via {@link _setProbeRunner}. - */ -type ProbeRunner = () => string; - -let probeRunner: ProbeRunner = defaultProbeRunner; - -/** @internal Test-only: override the probe runner. */ -export function _setProbeRunner(runner: ProbeRunner | null): void { - probeRunner = runner ?? defaultProbeRunner; -} - -function defaultProbeRunner(): string { - const wxcPath = findWxcExecutable(); - if (!wxcPath) { - throw new Error('wxc-exec not found'); - } - return execFileSync(wxcPath, ['--probe'], { - timeout: 5000, - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'pipe'], - }); -} - -function isValidTier(s: unknown): s is IsolationTier { - return s === 'base-container' || s === 'appcontainer-bfs' || s === 'appcontainer-dacl'; -} - -const UI_CAPABILITY_FIELDS: readonly (keyof UiCapabilitySupport)[] = [ - 'canBlockClipboardRead', - 'canBlockClipboardWrite', - 'canBlockInputInjection', - 'canBlockInputMethodChanges', - 'canBlockExternalUiObjects', - 'canBlockGlobalUiNamespace', - 'canBlockDesktopSwitching', - 'canBlockLogoffOrShutdown', - 'canBlockSystemParameterChanges', - 'canBlockDisplaySettingsChanges', -]; - -function isUiCapabilitySupport(value: unknown): value is UiCapabilitySupport { - if (!value || typeof value !== 'object') { - return false; - } - const capabilities = value as Record; - return UI_CAPABILITY_FIELDS.every((field) => typeof capabilities[field] === 'boolean'); -} - -/** - * Run the probe binary and merge its results into `support`. On any - * failure (binary missing, timeout, malformed JSON, unknown tier), the - * function silently leaves `support.isolationTier` and - * `support.isolationWarnings` unset — callers see the same contract as - * pre-Phase-5 SDKs. - */ -function populateIsolationFromProbe(support: PlatformSupport): void { - try { - const stdout = probeRunner(); - const probe = JSON.parse(stdout); - if (probe && typeof probe === 'object') { - if (isValidTier(probe.tier)) { - support.isolationTier = probe.tier; - } - if (Array.isArray(probe.warnings) && probe.warnings.length > 0) { - const warnings = probe.warnings.filter((w: unknown): w is string => typeof w === 'string'); - if (warnings.length > 0) { - support.isolationWarnings = warnings; - } - } - const facts = probe.probes; - if (facts && typeof facts === 'object') { - if (isUiCapabilitySupport(facts.uiCapabilities)) { - support.uiCapabilities = facts.uiCapabilities; - } - } - } - } catch { - // Graceful degradation: leave isolation fields unset. - } -} - -function computeSupport(): PlatformSupport { - const platform = os.platform(); - const support: PlatformSupport = { isSupported: false, reason: '', availableMethods: [] }; - - // Non-Windows platforms do not currently have native probes, so fields that - // depend on probe data (including uiCapabilities) stay omitted. - if (platform === 'darwin') { - // seatbelt is the only containment backend on macOS. - // /usr/bin/sandbox-exec ships with every release of macOS so the check - // is effectively just confirming we're on a supported OS. - if (isSeatbeltAvailable()) { - support.isSupported = true; - support.availableMethods = ['seatbelt']; - } else { - support.reason = '/usr/bin/sandbox-exec not found; macOS install is incomplete'; - } - return support; - } - - if (platform === 'linux') { - // LXC and Bubblewrap are both supported on Linux. Report whichever - // are installed; callers pick via the containment field. - const methods: ContainmentBackend[] = []; - if (isLxcAvailable()) methods.push('lxc'); - const bubblewrap = _probeBubblewrap(); - if (bubblewrap.available) { - methods.push('bubblewrap'); - } else { - // Always surface why bwrap is unavailable. When LXC is present the - // platform is still supported, so `reason` — documented as why the - // platform is *not* supported — must stay unset, and the detail would - // otherwise be dropped with no way to diagnose the missing backend. - diagLog(`getPlatformSupport: bubblewrap unavailable — ${bubblewrap.reason}`); - if (methods.length === 0) { - support.reason = `Neither LXC nor Bubblewrap is available on this system (${bubblewrap.reason})`; - } - } - if (methods.length > 0) { - support.isSupported = true; - support.availableMethods = methods; - } - return support; - } - - if (platform !== 'win32') { - support.reason = 'MXC is not supported on this platform'; - return support; - } - - // The host build is the real gate on Windows: below the product floor - // `processcontainer` fails at spawn rather than at detection. An unreadable - // registry leaves the build unknown, which is treated as modern so a - // detection failure never declares a supported host unsupported. - const build = windowsBuildQuery(); - const methods: ContainmentBackend[] = []; - if (!build || build.major >= MIN_PROCESSCONTAINER_BUILD) { - methods.push('processcontainer'); - } - // Windows Sandbox has its own, lower floor, so a host below the - // processcontainer floor may still have it. Both it and IsolationSession are - // reported when present, but they are experimental-only backends reached by - // explicit opt-in, so they cannot carry `isSupported` — that flag is what - // guards the default `processcontainer` spawn. - if (isWindowsSandboxAvailable()) { - methods.push('windows_sandbox'); - } - if (isIsoSessionSupported()) { - methods.push('isolation_session'); - } - support.availableMethods = methods; - - if (!methods.includes('processcontainer')) { - const alternatives = - methods.length > 0 ? ` (experimental backends available: ${methods.join(', ')})` : ''; - support.reason = - `Windows build ${build?.major} is below ${MIN_PROCESSCONTAINER_BUILD}, ` + - `the minimum supported build (Windows 11 24H2)${alternatives}`; - return support; - } - - support.isSupported = true; - populateIsolationFromProbe(support); - return support; -} - -/** - * Check if LXC is available on the system - */ -function isLxcAvailable(): boolean { - try { - execSync('lxc-ls --version', { encoding: 'utf-8', stdio: 'pipe' }); - return true; - } catch { - return false; - } -} - -/** - * Minimum `bwrap` version the Bubblewrap backend supports, as - * `[major, minor, patch]`. - * - * This is the oldest release that has **every** flag the Rust argument builder - * emits. `--ro-bind-try` (deny-by-default baseline mounts) landed in bwrap - * 0.3.1 and `--clearenv` (minimal sandbox environment) in 0.5.0, so - * `--clearenv` sets the floor. - * - * Mirrors `MIN_BWRAP_VERSION` in - * `src/backends/bubblewrap/common/src/bwrap_version.rs` — keep both in sync. - */ -const MIN_BWRAP_VERSION: readonly [number, number, number] = [0, 5, 0]; - -/** Outcome of the Bubblewrap probe: available, or unavailable with a reason. */ -type BubblewrapProbe = { available: true } | { available: false; reason: string }; - -/** - * Raw result of running `bwrap --version`, normalized across the ways the call - * can fail. Mirrors the cases the Rust `probe_bwrap` distinguishes. - */ -type BwrapVersionResult = - | { kind: 'output'; stdout: string } - | { kind: 'notFound' } - | { kind: 'failed'; status: number | null; detail: string }; - -/** - * Whether a `bwrap` candidate exists anywhere on `PATH`. - * - * Linux reports `ENOENT` both for a genuinely absent binary and for one that - * exists but cannot be executed (a missing ELF interpreter or script shebang - * target), so the spawn error alone cannot tell `notFound` from `failed`. A - * candidate on `PATH` means the package is installed and the failure is a - * broken install. - */ -function bwrapExistsOnPath(): boolean { - const pathVar = process.env.PATH; - if (!pathVar) return false; - return pathVar - .split(path.delimiter) - .some((dir) => dir !== '' && fs.existsSync(path.join(dir, 'bwrap'))); -} - -/** - * How long to wait for `bwrap --version` before giving up. - * - * `getPlatformSupport()` is synchronous, so without a bound a `bwrap` that - * hangs — a wrapper script on PATH, a binary on a stalled network mount — - * would block the caller indefinitely. Printing a version string is - * near-instant, so this is generous. - */ -const BWRAP_VERSION_TIMEOUT_MS = 5000; - -/** - * Default runner for `bwrap --version`. Uses `execFileSync` rather than a - * shell so a missing binary surfaces as `ENOENT` instead of the shell's - * indistinguishable exit code 127 — that separation is what lets us report - * "not installed" and "installed but broken" differently. - * - * Replaceable in unit tests via {@link _setBwrapVersionRunner}. - */ -function defaultBwrapVersionRunner(): BwrapVersionResult { - try { - return { - kind: 'output', - stdout: execFileSync('bwrap', ['--version'], { - encoding: 'utf-8', - stdio: 'pipe', - timeout: BWRAP_VERSION_TIMEOUT_MS, - }), - }; - } catch (err) { - const e = err as NodeJS.ErrnoException & { - status?: number | null; - stderr?: Buffer | string; - killed?: boolean; - }; - // `ENOENT` covers both an absent binary and a present-but-unusable one - // (missing ELF interpreter / shebang target), so confirm the binary is - // really absent before blaming the package manager. - if (e.code === 'ENOENT' && !bwrapExistsOnPath()) { - return { kind: 'notFound' }; - } - // Timed out: the child was killed, so there is no meaningful exit status. - if (e.code === 'ETIMEDOUT' || e.killed) { - return { - kind: 'failed', - status: null, - detail: `timed out after ${BWRAP_VERSION_TIMEOUT_MS}ms`, - }; - } - return { - kind: 'failed', - status: e.status ?? null, - detail: e.stderr?.toString().trim() || e.message, - }; - } -} - -let bwrapVersionRunner: () => BwrapVersionResult = defaultBwrapVersionRunner; - -/** @internal Test-only: override the `bwrap --version` runner. */ -export function _setBwrapVersionRunner(fn: (() => BwrapVersionResult) | null): void { - bwrapVersionRunner = fn ?? defaultBwrapVersionRunner; -} - -/** - * Parse the version out of a `bwrap --version` line such as - * `"bubblewrap 0.11.2"`. - * - * Anchored on the `bubblewrap` package name, which is what makes unrecognized - * output fail closed: without it any numeric token in arbitrary output (say - * `"some other tool 999"`) would be read as a version and clear the - * minimum-version gate. - * - * Lenient about what *surrounds* each number so distro-patched version strings - * (`0.4.1-1`, a bare `0.6`) still resolve: the version token is split on `.` - * and each of the (up to three) components contributes its leading digits. - * Debian's `+really` marker is honored rather than ignored — see below. - * - * Strict about components that are *present but not numeric*: only a component - * that is genuinely absent defaults to `0`, so `"0.6.invalid"` is rejected - * rather than silently read as `0.6.0`. - * - * @internal Exported for unit tests. - * @returns `[major, minor, patch]`, or `null` when the version cannot be determined. - */ -export function _parseBwrapVersion(output: string): [number, number, number] | null { - // bwrap prints its PACKAGE_STRING, "bubblewrap "; that leading name - // has been stable since 0.1.0. - const tokens = output.trim().split(/\s+/); - if (tokens[0]?.toLowerCase() !== 'bubblewrap' || !tokens[1]) return null; - // Debian's `+really` marker means the package ships the version that FOLLOWS - // it, so `0.5.0+really0.4.1` is really 0.4.1 — which predates `--clearenv` - // and must not clear the gate. - const marker = tokens[1].lastIndexOf('+really'); - const token = marker === -1 ? tokens[1] : tokens[1].slice(marker + '+really'.length); - const components: number[] = []; - // Every component must be numeric, including ones past the patch: they are - // not significant, but `0.5.0.invalid` is an unrecognized banner rather than - // 0.5.0. Validating (rather than rejecting on count) keeps a distro - // four-part build such as `0.6.0.1` working. - for (const part of token.split('.')) { - const digits = /^\d+/.exec(part); - // Present but non-numeric: fail closed rather than guessing 0. - if (!digits) return null; - const value = parseInt(digits[0], 10); - // Mirror the Rust parser's `u32`: a larger value is not something bwrap - // could print, and accepting it would let this gate admit a banner the - // backend's gate rejects. - if (value > 0xffffffff) return null; - components.push(value); - } - // Only a genuinely absent component defaults to 0, so "0.6" is 0.6.0. - return [components[0], components[1] ?? 0, components[2] ?? 0]; -} - -/** Compare two `[major, minor, patch]` tuples lexicographically. */ -function compareVersions( - a: readonly [number, number, number], - b: readonly [number, number, number], -): number { - for (let i = 0; i < 3; i++) { - if (a[i] !== b[i]) return a[i] - b[i]; - } - return 0; -} - -/** - * Check whether Bubblewrap (bwrap) is installed *and* new enough. - * - * Presence on PATH is not sufficient: a `bwrap` older than - * {@link MIN_BWRAP_VERSION} would reject flags the backend always emits and - * fail at spawn time with an opaque "unknown option" error. Unparsable output - * fails closed — without a version we cannot assert the required flags exist. - * - * Mirrors `probe_bwrap` in - * `src/backends/bubblewrap/common/src/bwrap_version.rs`, including the - * distinction between a missing binary and a present-but-broken one. - * - * @internal Exported for unit tests. - */ -export function _probeBubblewrap(): BubblewrapProbe { - const minVersion = MIN_BWRAP_VERSION.join('.'); - const result = bwrapVersionRunner(); - - if (result.kind === 'notFound') { - return { - available: false, - reason: `Bubblewrap (bwrap) is not installed or not on PATH; version ${minVersion} or newer is required`, - }; - } - if (result.kind === 'failed') { - // Present but broken: do not send the user to their package manager for a - // package they already have. - // Covers both a spawn failure and termination by a signal, neither of - // which yields an exit code. - const where = - result.status === null ? 'failed without an exit status' : `exited with status ${result.status}`; - const detail = result.detail ? `: ${result.detail}` : ''; - return { - available: false, - reason: `Bubblewrap (bwrap) is present but \`bwrap --version\` ${where}${detail}; version ${minVersion} or newer is required`, - }; - } - - const version = _parseBwrapVersion(result.stdout); - if (!version) { - return { - available: false, - reason: `could not determine the Bubblewrap (bwrap) version from ${JSON.stringify(result.stdout.trim())}; version ${minVersion} or newer is required`, - }; - } - if (compareVersions(version, MIN_BWRAP_VERSION) < 0) { - return { - available: false, - reason: `Bubblewrap (bwrap) ${version.join('.')} is too old; version ${minVersion} or newer is required`, - }; - } - // A new enough `bwrap` still cannot sandbox if the host forbids it, and - // `--version` never creates a namespace, so ask it to build a real one. - const sandbox = bwrapSandboxRunner(); - if (!sandbox.ok) { - return { - available: false, - reason: `Bubblewrap (bwrap) ${version.join('.')} is installed but cannot create a sandbox on this host: ${sandbox.detail}`, - }; - } - return { available: true }; -} - -/** - * Arguments for a minimal end-to-end containment probe. - * - * `bwrap --version` only prints a banner — it never creates a namespace — so - * it passes on hosts where unprivileged user namespaces are disabled - * (`kernel.unprivileged_userns_clone=0`) or where AppArmor denies `bwrap` - * (Ubuntu 23.10+), both of which then fail at every spawn. - * - * The shape mirrors a real run: the same namespaces the Bubblewrap backend - * unshares, plus `--proc` / `--dev`, and `--clearenv` so the payload is - * resolved through `execvp`'s built-in `/bin:/usr/bin` default rather than the - * caller's `PATH`. Binds use `--ro-bind-try` on the few directories a shell - * needs — binding `/` instead would make the probe fail on any host with an - * awkward submount, since `bwrap` treats a failed submount remount as fatal. - * - * Kept in step with the engine's `BWRAP_PROBE_ARGS` - * (`src/core/mxc_engine/src/platform.rs`), which is pinned against the - * production argument builder by a unit test. - */ -const BWRAP_PROBE_ARGS = [ - '--unshare-user', - '--unshare-pid', - '--unshare-ipc', - '--unshare-uts', - '--unshare-net', - '--ro-bind-try', - '/bin', - '/bin', - '--ro-bind-try', - '/usr/bin', - '/usr/bin', - '--ro-bind-try', - '/lib', - '/lib', - '--ro-bind-try', - '/lib64', - '/lib64', - '--ro-bind-try', - '/usr/lib', - '/usr/lib', - '--ro-bind-try', - '/usr/lib64', - '/usr/lib64', - '--proc', - '/proc', - '--dev', - '/dev', - '--clearenv', - '--', - 'sh', - '-c', - 'exit 0', -]; - -/** Outcome of the sandbox probe; `detail` is empty when `ok`. */ -export type BubblewrapSandboxProbe = { ok: boolean; detail: string }; - -/** - * Run {@link BWRAP_PROBE_ARGS}, reporting bwrap's own diagnostic on failure. - * - * Replaceable in unit tests via {@link _setBwrapSandboxRunner}, so the - * version-gate tests can drive `_probeBubblewrap` on a host without `bwrap`. - */ -function defaultBwrapSandboxRunner(): BubblewrapSandboxProbe { - try { - execFileSync('bwrap', BWRAP_PROBE_ARGS, { - stdio: ['ignore', 'ignore', 'pipe'], - timeout: BWRAP_VERSION_TIMEOUT_MS, - }); - return { ok: true, detail: '' }; - } catch (error) { - return { ok: false, detail: bwrapFailureDetail(error) }; - } -} - -let bwrapSandboxRunner: () => BubblewrapSandboxProbe = defaultBwrapSandboxRunner; - -/** @internal Test-only: override the Bubblewrap sandbox probe. */ -export function _setBwrapSandboxRunner(fn: (() => BubblewrapSandboxProbe) | null): void { - bwrapSandboxRunner = fn ?? defaultBwrapSandboxRunner; -} - -/** Reduce a failed bwrap run to a single length-capped line for a `reason`. */ -function bwrapFailureDetail(error: unknown): string { - const MAX_LEN = 200; - const { stderr } = (error ?? {}) as { stderr?: Buffer | string }; - const line = (stderr?.toString() ?? '') - .split('\n') - .map((l) => l.trim()) - .find((l) => l.length > 0); - if (!line) { - return 'it failed with no diagnostic output'; - } - // Spread so the cap counts code points and never splits a surrogate pair. - const chars = [...line]; - return chars.length > MAX_LEN ? `${chars.slice(0, MAX_LEN).join('')}…` : line; -} - -/** - * Check if the macOS sandbox is available. `/usr/bin/sandbox-exec` is part - * of the macOS base install and present on every shipping version of macOS, - * so this is effectively a sanity check for a corrupted install. - */ -function isSeatbeltAvailable(): boolean { - try { - return fs.existsSync('/usr/bin/sandbox-exec'); - } catch { - return false; - } -} - -/** - * Get the simplified architecture name used for SDK bin directory layout. - * @returns 'arm64' or 'x64' - */ -function getSdkArch(): string { - return os.arch() === 'arm64' ? 'arm64' : 'x64'; -} - -/** - * Get the Rust target triple for the current machine architecture. - * @returns The Rust target triple string - */ -function getRustTargetTriple(): string { - const arch = os.arch(); - const platform = os.platform(); - if (platform === 'linux') { - return arch === 'arm64' ? 'aarch64-unknown-linux-gnu' : 'x86_64-unknown-linux-gnu'; - } - // Windows - return arch === 'arm64' ? 'aarch64-pc-windows-msvc' : 'x86_64-pc-windows-msvc'; -} - -/** - * Get the Rust target triple for the current Linux machine architecture. - */ -function getLinuxRustTargetTriple(): string { - const arch = os.arch(); - switch (arch) { - case 'arm64': - return 'aarch64-unknown-linux-gnu'; - case 'x64': - default: - return 'x86_64-unknown-linux-gnu'; - } -} - -/** - * Get the Rust target triple for the current macOS machine architecture. - */ -function getDarwinRustTargetTriple(): string { - const arch = os.arch(); - return arch === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin'; -} - -/** - * Find the wxc-exec executable - * Searches in common locations relative to the SDK package, - * selecting the build matching the current machine architecture. - * @returns Path to wxc-exec.exe if found, null otherwise - */ -export function findWxcExecutable(): string | null { - // Allow override for bundled deployments (debugging/testing) - if (process.env.MXC_BIN_DIR) { - const overridePath = path.join(process.env.MXC_BIN_DIR, getSdkArch(), 'wxc-exec.exe'); - if (verifyWxcExecutable(overridePath)) { - return overridePath; - } - } - - const pkgRoot = getSdkPackageRoot(); - const targetTriple = getRustTargetTriple(); - const targetDir = path.join(pkgRoot, '..', '..', 'src', 'target'); - - const possiblePaths = [ - // Bundled in the SDK package (e.g. when installed via npm) - path.join(pkgRoot, 'bin', getSdkArch(), 'wxc-exec.exe'), - // Architecture-specific release build output (monorepo dev) - path.join(targetDir, targetTriple, 'release', 'wxc-exec.exe'), - // Architecture-specific debug build output (monorepo dev) - path.join(targetDir, targetTriple, 'debug', 'wxc-exec.exe'), - // Fallback: default Cargo release build output (no explicit --target) - path.join(targetDir, 'release', 'wxc-exec.exe'), - // Fallback: default Cargo debug build output (no explicit --target) - path.join(targetDir, 'debug', 'wxc-exec.exe'), - ]; - - for (const wxcPath of possiblePaths) { - if (verifyWxcExecutable(wxcPath)) { - return wxcPath; - } - } - - return null; -} - -/** - * Verify that an executable exists at the given path - * @param execPath - Path to verify - * @returns true if the executable exists and is a file, false otherwise - */ -function verifyExecutable(execPath: string): boolean { - try { - // Paths inside Electron's app.asar exist to fs but can't be executed - if (execPath.includes('.asar')) { - return false; - } - if (!fs.existsSync(execPath) || !fs.statSync(execPath).isFile()) { - return false; - } - // On non-Windows platforms, also verify execute permission - if (process.platform !== 'win32') { - fs.accessSync(execPath, fs.constants.X_OK); - } - return true; - } catch { - return false; - } -} - -/** - * Verify that a wxc-exec executable exists at the given path - * @param wxcPath - Path to verify - * @returns true if the executable exists and is a file, false otherwise - */ -function verifyWxcExecutable(wxcPath: string): boolean { - return verifyExecutable(wxcPath); -} - -/** - * Find the lxc-exec executable on Linux - * Searches in common locations relative to the SDK package. - * @returns Path to lxc-exec if found, null otherwise - */ -export function findLxcExecutable(): string | null { - // Allow override for bundled deployments (debugging/testing) - if (process.env.MXC_BIN_DIR) { - const overridePath = path.join(process.env.MXC_BIN_DIR, getSdkArch(), 'lxc-exec'); - if (verifyExecutable(overridePath)) { - return overridePath; - } - } - - const pkgRoot = getSdkPackageRoot(); - const targetTriple = getLinuxRustTargetTriple(); - const targetDir = path.join(pkgRoot, '..', '..', 'src', 'target'); - - const possiblePaths = [ - // Bundled in the SDK package - path.join(pkgRoot, 'bin', getSdkArch(), 'lxc-exec'), - // Architecture-specific release build - path.join(targetDir, targetTriple, 'release', 'lxc-exec'), - // Architecture-specific debug build - path.join(targetDir, targetTriple, 'debug', 'lxc-exec'), - // Default Cargo release build - path.join(targetDir, 'release', 'lxc-exec'), - // Default Cargo debug build - path.join(targetDir, 'debug', 'lxc-exec'), - ]; - - for (const lxcPath of possiblePaths) { - if (verifyExecutable(lxcPath)) { - return lxcPath; - } - } - - return null; -} - -/** - * Find the mxc-exec-mac executable on macOS. - * Searches in the SDK bin directory (npm install path) and Cargo build - * output directories (monorepo dev path). - * @returns Path to mxc-exec-mac if found, null otherwise - */ -export function findSeatbeltExecutable(): string | null { - // Allow override for bundled deployments (debugging/testing) - if (process.env.MXC_BIN_DIR) { - const overridePath = path.join(process.env.MXC_BIN_DIR, getSdkArch(), 'mxc-exec-mac'); - if (verifyExecutable(overridePath)) { - return overridePath; - } - } - - const targetTriple = getDarwinRustTargetTriple(); - const targetDir = path.join(__dirname, '..', '..', '..', 'src', 'target'); - - const possiblePaths = [ - // Bundled in the SDK package - path.join(__dirname, '..', 'bin', getSdkArch(), 'mxc-exec-mac'), - // Architecture-specific release build - path.join(targetDir, targetTriple, 'release', 'mxc-exec-mac'), - // Architecture-specific debug build - path.join(targetDir, targetTriple, 'debug', 'mxc-exec-mac'), - // Default Cargo release build - path.join(targetDir, 'release', 'mxc-exec-mac'), - // Default Cargo debug build - path.join(targetDir, 'debug', 'mxc-exec-mac'), - ]; - - for (const darwinPath of possiblePaths) { - if (verifyExecutable(darwinPath)) { - return darwinPath; - } - } - - return null; -} +import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; +import { execSync, execFileSync } from 'child_process'; +import { fileURLToPath } from 'node:url'; +import { ContainmentBackend, IsolationTier, PlatformSupport, UiCapabilitySupport } from './types.js'; +import { diagLog } from './diagnostic.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Resolves the SDK package root directory. + * Uses require.resolve to find the package.json (works when the SDK is installed + * in node_modules, even if the consuming code is bundled by esbuild/webpack). + * Falls back to __dirname for local development (monorepo layout). + */ +function getSdkPackageRoot(): string { + try { + return path.dirname(require.resolve('@microsoft/mxc-sdk/package.json')); + } catch { + // Fallback: __dirname is dist/, so parent is package root + return path.join(__dirname, '..'); + } +} + +/** + * Query Windows Registry for a value + * @param key - Registry key path (e.g., "HKLM\\Software\\...") + * @param valueName - Name of the value to query + * @returns The registry value as a string, or null if not found + */ +function queryWindowsRegistry(key: string, valueName: string): string | null { + try { + const command = `reg query "${key}" /v "${valueName}"`; + const output = execSync(command, { encoding: 'utf-8', stdio: 'pipe' }); + + // Parse output - format is: + // HKEY_LOCAL_MACHINE\... + // ValueName REG_SZ Value + const lines = output.split('\n'); + for (const line of lines) { + if (line.includes(valueName)) { + // Extract value after REG_SZ or REG_DWORD + const match = line.match(/REG_\w+\s+(.+)/); + if (match) { + return match[1].trim(); + } + } + } + return null; + } catch { + return null; + } +} + +/** + * Result of querying the host's Windows build number, or `null` when the + * registry values are missing or unparseable. + */ +type WindowsBuild = { major: number; minor: number } | null; + +/** + * Default implementation that reads `CurrentBuild` / `UBR` from the + * registry. Replaceable via {@link _setWindowsBuildQuery} in tests so we + * can exercise the IsolationSession version gate deterministically. + */ +function defaultWindowsBuildQuery(): WindowsBuild { + const registryPath = 'HKLM\\Software\\Microsoft\\Windows NT\\CurrentVersion'; + const currentBuild = queryWindowsRegistry(registryPath, 'CurrentBuild'); + if (!currentBuild) { + return null; + } + const major = parseInt(currentBuild, 10); + if (isNaN(major)) { + return null; + } + // `UBR` is only needed for the IsolationSession minor-build gate, so an + // unreadable revision degrades to 0 rather than discarding `CurrentBuild` — + // otherwise a missing value would silently bypass the processcontainer + // build floor. + const minor = Number(queryWindowsRegistry(registryPath, 'UBR')); + return { major, minor: isNaN(minor) ? 0 : minor }; +} + +let windowsBuildQuery: () => WindowsBuild = defaultWindowsBuildQuery; + +/** @internal Test-only: override the Windows build lookup. */ +export function _setWindowsBuildQuery(fn: (() => WindowsBuild) | null): void { + windowsBuildQuery = fn ?? defaultWindowsBuildQuery; +} + +/** + * Minimum Windows build the `processcontainer` backend supports — 26100 + * (Windows 11 24H2). This is the product floor documented in the README and in + * `docs/process-container/os-version-support.md`. + * + * Mirrors `MIN_WINDOWS_BUILD` in `src/core/mxc_engine/src/platform.rs` — keep + * both in sync. + */ +const MIN_PROCESSCONTAINER_BUILD = 26100; + +/** + * Check whether the host supports the IsolationSession backend. + * Requires Windows Insider Preview build 26300.8553 or later. + * + * No internal cache — `getPlatformSupport` memoizes the full result, and + * registry reads are cheap relative to the rest of the probe. + */ +function isIsoSessionSupported(): boolean { + const build = windowsBuildQuery(); + if (!build) { + return false; + } + + // Pin to the Windows Insider Preview build that introduced IsolationSession + // (26300.8553+). Other major builds are not yet supported. + return build.major === 26300 && build.minor >= 8553; +} + +let windowsSandboxAvailableCache: boolean | undefined; + +/** + * Check if Windows Sandbox feature is enabled via DISM. + * @returns true if the Containers-DisposableClientVM feature is enabled + */ +function isWindowsSandboxAvailable(): boolean { + if (windowsSandboxAvailableCache !== undefined) { + return windowsSandboxAvailableCache; + } + + try { + const output = execSync( + 'dism /online /get-featureinfo /featurename:Containers-DisposableClientVM', + { encoding: 'utf-8', stdio: 'pipe', timeout: 10000 }, + ); + windowsSandboxAvailableCache = /State\s*:\s*Enabled/i.test(output); + } catch { + // `dism /online` typically requires elevation, so a non-elevated session + // throws here and we can't distinguish "disabled" from "no permission". + // Fall back to checking for the sandbox executable — Windows installs it + // under System32 only when the Containers-DisposableClientVM feature is + // enabled, and the path is readable without admin. + const sandboxExe = path.join( + process.env.SystemRoot || 'C:\\Windows', + 'System32', + 'WindowsSandbox.exe', + ); + windowsSandboxAvailableCache = fs.existsSync(sandboxExe); + } + + return windowsSandboxAvailableCache; +} + +/** + * Get platform support information. + * + * On Windows, this also invokes `wxc-exec --probe` to populate + * `isolationTier`, the `isolationWarnings` array (if any), and portable UI + * capability facts. Linux and macOS currently do not expose native probe data, + * so `uiCapabilities` is omitted on those platforms. The result is cached for + * the lifetime of the SDK module — the underlying machine state is not + * expected to change at runtime. + * + * @returns Platform support details including available sandboxing methods + */ +export function getPlatformSupport(): PlatformSupport { + if (cachedSupport !== null) { + return cachedSupport; + } + const support = computeSupport(); + cachedSupport = support; + return support; +} + +let cachedSupport: PlatformSupport | null = null; + +/** @internal Test-only: clear the cached PlatformSupport. */ +export function _resetPlatformSupportCache(): void { + cachedSupport = null; +} + +/** + * Probe runner injection seam. Spawns `wxc-exec --probe` and returns + * its stdout. Replaceable in unit tests via {@link _setProbeRunner}. + */ +type ProbeRunner = () => string; + +let probeRunner: ProbeRunner = defaultProbeRunner; + +/** @internal Test-only: override the probe runner. */ +export function _setProbeRunner(runner: ProbeRunner | null): void { + probeRunner = runner ?? defaultProbeRunner; +} + +function defaultProbeRunner(): string { + const wxcPath = findWxcExecutable(); + if (!wxcPath) { + throw new Error('wxc-exec not found'); + } + return execFileSync(wxcPath, ['--probe'], { + timeout: 5000, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function isValidTier(s: unknown): s is IsolationTier { + return s === 'base-container' || s === 'appcontainer-bfs' || s === 'appcontainer-dacl'; +} + +const UI_CAPABILITY_FIELDS: readonly (keyof UiCapabilitySupport)[] = [ + 'canBlockClipboardRead', + 'canBlockClipboardWrite', + 'canBlockInputInjection', + 'canBlockInputMethodChanges', + 'canBlockExternalUiObjects', + 'canBlockGlobalUiNamespace', + 'canBlockDesktopSwitching', + 'canBlockLogoffOrShutdown', + 'canBlockSystemParameterChanges', + 'canBlockDisplaySettingsChanges', +]; + +function isUiCapabilitySupport(value: unknown): value is UiCapabilitySupport { + if (!value || typeof value !== 'object') { + return false; + } + const capabilities = value as Record; + return UI_CAPABILITY_FIELDS.every((field) => typeof capabilities[field] === 'boolean'); +} + +/** + * Run the probe binary and merge its results into `support`. On any + * failure (binary missing, timeout, malformed JSON, unknown tier), the + * function silently leaves `support.isolationTier` and + * `support.isolationWarnings` unset — callers see the same contract as + * pre-Phase-5 SDKs. + */ +function populateIsolationFromProbe(support: PlatformSupport): void { + try { + const stdout = probeRunner(); + const probe = JSON.parse(stdout); + if (probe && typeof probe === 'object') { + if (isValidTier(probe.tier)) { + support.isolationTier = probe.tier; + } + if (Array.isArray(probe.warnings) && probe.warnings.length > 0) { + const warnings = probe.warnings.filter((w: unknown): w is string => typeof w === 'string'); + if (warnings.length > 0) { + support.isolationWarnings = warnings; + } + } + const facts = probe.probes; + if (facts && typeof facts === 'object') { + if (isUiCapabilitySupport(facts.uiCapabilities)) { + support.uiCapabilities = facts.uiCapabilities; + } + } + } + } catch { + // Graceful degradation: leave isolation fields unset. + } +} + +function computeSupport(): PlatformSupport { + const platform = os.platform(); + const support: PlatformSupport = { isSupported: false, reason: '', availableMethods: [] }; + + // Non-Windows platforms do not currently have native probes, so fields that + // depend on probe data (including uiCapabilities) stay omitted. + if (platform === 'darwin') { + // seatbelt is the only containment backend on macOS. + // /usr/bin/sandbox-exec ships with every release of macOS so the check + // is effectively just confirming we're on a supported OS. + if (isSeatbeltAvailable()) { + support.isSupported = true; + support.availableMethods = ['seatbelt']; + } else { + support.reason = '/usr/bin/sandbox-exec not found; macOS install is incomplete'; + } + return support; + } + + if (platform === 'linux') { + // LXC and Bubblewrap are both supported on Linux. Report whichever + // are installed; callers pick via the containment field. + const methods: ContainmentBackend[] = []; + if (isLxcAvailable()) methods.push('lxc'); + const bubblewrap = _probeBubblewrap(); + if (bubblewrap.available) { + methods.push('bubblewrap'); + } else { + // Always surface why bwrap is unavailable. When LXC is present the + // platform is still supported, so `reason` — documented as why the + // platform is *not* supported — must stay unset, and the detail would + // otherwise be dropped with no way to diagnose the missing backend. + diagLog(`getPlatformSupport: bubblewrap unavailable — ${bubblewrap.reason}`); + if (methods.length === 0) { + support.reason = `Neither LXC nor Bubblewrap is available on this system (${bubblewrap.reason})`; + } + } + if (methods.length > 0) { + support.isSupported = true; + support.availableMethods = methods; + } + return support; + } + + if (platform !== 'win32') { + support.reason = 'MXC is not supported on this platform'; + return support; + } + + // The host build is the real gate on Windows: below the product floor + // `processcontainer` fails at spawn rather than at detection. An unreadable + // registry leaves the build unknown, which is treated as modern so a + // detection failure never declares a supported host unsupported. + const build = windowsBuildQuery(); + const methods: ContainmentBackend[] = []; + if (!build || build.major >= MIN_PROCESSCONTAINER_BUILD) { + methods.push('processcontainer'); + } + // Windows Sandbox has its own, lower floor, so a host below the + // processcontainer floor may still have it. Both it and IsolationSession are + // reported when present, but they are experimental-only backends reached by + // explicit opt-in, so they cannot carry `isSupported` — that flag is what + // guards the default `processcontainer` spawn. + if (isWindowsSandboxAvailable()) { + methods.push('windows_sandbox'); + } + if (isIsoSessionSupported()) { + methods.push('isolation_session'); + } + support.availableMethods = methods; + + if (!methods.includes('processcontainer')) { + const alternatives = + methods.length > 0 ? ` (experimental backends available: ${methods.join(', ')})` : ''; + support.reason = + `Windows build ${build?.major} is below ${MIN_PROCESSCONTAINER_BUILD}, ` + + `the minimum supported build (Windows 11 24H2)${alternatives}`; + return support; + } + + support.isSupported = true; + populateIsolationFromProbe(support); + return support; +} + +/** + * Check if LXC is available on the system + */ +function isLxcAvailable(): boolean { + try { + execSync('lxc-ls --version', { encoding: 'utf-8', stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +/** + * Minimum `bwrap` version the Bubblewrap backend supports, as + * `[major, minor, patch]`. + * + * This is the oldest release that has **every** flag the Rust argument builder + * emits. `--ro-bind-try` (deny-by-default baseline mounts) landed in bwrap + * 0.3.1 and `--clearenv` (minimal sandbox environment) in 0.5.0, so + * `--clearenv` sets the floor. + * + * Mirrors `MIN_BWRAP_VERSION` in + * `src/backends/bubblewrap/common/src/bwrap_version.rs` — keep both in sync. + */ +const MIN_BWRAP_VERSION: readonly [number, number, number] = [0, 5, 0]; + +/** Outcome of the Bubblewrap probe: available, or unavailable with a reason. */ +type BubblewrapProbe = { available: true } | { available: false; reason: string }; + +/** + * Raw result of running `bwrap --version`, normalized across the ways the call + * can fail. Mirrors the cases the Rust `probe_bwrap` distinguishes. + */ +type BwrapVersionResult = + | { kind: 'output'; stdout: string } + | { kind: 'notFound' } + | { kind: 'failed'; status: number | null; detail: string }; + +/** + * Whether a `bwrap` candidate exists anywhere on `PATH`. + * + * Linux reports `ENOENT` both for a genuinely absent binary and for one that + * exists but cannot be executed (a missing ELF interpreter or script shebang + * target), so the spawn error alone cannot tell `notFound` from `failed`. A + * candidate on `PATH` means the package is installed and the failure is a + * broken install. + */ +function bwrapExistsOnPath(): boolean { + const pathVar = process.env.PATH; + if (!pathVar) return false; + return pathVar + .split(path.delimiter) + .some((dir) => dir !== '' && fs.existsSync(path.join(dir, 'bwrap'))); +} + +/** + * How long to wait for `bwrap --version` before giving up. + * + * `getPlatformSupport()` is synchronous, so without a bound a `bwrap` that + * hangs — a wrapper script on PATH, a binary on a stalled network mount — + * would block the caller indefinitely. Printing a version string is + * near-instant, so this is generous. + */ +const BWRAP_VERSION_TIMEOUT_MS = 5000; + +/** + * Default runner for `bwrap --version`. Uses `execFileSync` rather than a + * shell so a missing binary surfaces as `ENOENT` instead of the shell's + * indistinguishable exit code 127 — that separation is what lets us report + * "not installed" and "installed but broken" differently. + * + * Replaceable in unit tests via {@link _setBwrapVersionRunner}. + */ +function defaultBwrapVersionRunner(): BwrapVersionResult { + try { + return { + kind: 'output', + stdout: execFileSync('bwrap', ['--version'], { + encoding: 'utf-8', + stdio: 'pipe', + timeout: BWRAP_VERSION_TIMEOUT_MS, + }), + }; + } catch (err) { + const e = err as NodeJS.ErrnoException & { + status?: number | null; + stderr?: Buffer | string; + killed?: boolean; + }; + // `ENOENT` covers both an absent binary and a present-but-unusable one + // (missing ELF interpreter / shebang target), so confirm the binary is + // really absent before blaming the package manager. + if (e.code === 'ENOENT' && !bwrapExistsOnPath()) { + return { kind: 'notFound' }; + } + // Timed out: the child was killed, so there is no meaningful exit status. + if (e.code === 'ETIMEDOUT' || e.killed) { + return { + kind: 'failed', + status: null, + detail: `timed out after ${BWRAP_VERSION_TIMEOUT_MS}ms`, + }; + } + return { + kind: 'failed', + status: e.status ?? null, + detail: e.stderr?.toString().trim() || e.message, + }; + } +} + +let bwrapVersionRunner: () => BwrapVersionResult = defaultBwrapVersionRunner; + +/** @internal Test-only: override the `bwrap --version` runner. */ +export function _setBwrapVersionRunner(fn: (() => BwrapVersionResult) | null): void { + bwrapVersionRunner = fn ?? defaultBwrapVersionRunner; +} + +/** + * Parse the version out of a `bwrap --version` line such as + * `"bubblewrap 0.11.2"`. + * + * Anchored on the `bubblewrap` package name, which is what makes unrecognized + * output fail closed: without it any numeric token in arbitrary output (say + * `"some other tool 999"`) would be read as a version and clear the + * minimum-version gate. + * + * Lenient about what *surrounds* each number so distro-patched version strings + * (`0.4.1-1`, a bare `0.6`) still resolve: the version token is split on `.` + * and each of the (up to three) components contributes its leading digits. + * Debian's `+really` marker is honored rather than ignored — see below. + * + * Strict about components that are *present but not numeric*: only a component + * that is genuinely absent defaults to `0`, so `"0.6.invalid"` is rejected + * rather than silently read as `0.6.0`. + * + * @internal Exported for unit tests. + * @returns `[major, minor, patch]`, or `null` when the version cannot be determined. + */ +export function _parseBwrapVersion(output: string): [number, number, number] | null { + // bwrap prints its PACKAGE_STRING, "bubblewrap "; that leading name + // has been stable since 0.1.0. + const tokens = output.trim().split(/\s+/); + if (tokens[0]?.toLowerCase() !== 'bubblewrap' || !tokens[1]) return null; + // Debian's `+really` marker means the package ships the version that FOLLOWS + // it, so `0.5.0+really0.4.1` is really 0.4.1 — which predates `--clearenv` + // and must not clear the gate. + const marker = tokens[1].lastIndexOf('+really'); + const token = marker === -1 ? tokens[1] : tokens[1].slice(marker + '+really'.length); + const components: number[] = []; + // Every component must be numeric, including ones past the patch: they are + // not significant, but `0.5.0.invalid` is an unrecognized banner rather than + // 0.5.0. Validating (rather than rejecting on count) keeps a distro + // four-part build such as `0.6.0.1` working. + for (const part of token.split('.')) { + const digits = /^\d+/.exec(part); + // Present but non-numeric: fail closed rather than guessing 0. + if (!digits) return null; + const value = parseInt(digits[0], 10); + // Mirror the Rust parser's `u32`: a larger value is not something bwrap + // could print, and accepting it would let this gate admit a banner the + // backend's gate rejects. + if (value > 0xffffffff) return null; + components.push(value); + } + // Only a genuinely absent component defaults to 0, so "0.6" is 0.6.0. + return [components[0], components[1] ?? 0, components[2] ?? 0]; +} + +/** Compare two `[major, minor, patch]` tuples lexicographically. */ +function compareVersions( + a: readonly [number, number, number], + b: readonly [number, number, number], +): number { + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) return a[i] - b[i]; + } + return 0; +} + +/** + * Check whether Bubblewrap (bwrap) is installed *and* new enough. + * + * Presence on PATH is not sufficient: a `bwrap` older than + * {@link MIN_BWRAP_VERSION} would reject flags the backend always emits and + * fail at spawn time with an opaque "unknown option" error. Unparsable output + * fails closed — without a version we cannot assert the required flags exist. + * + * Mirrors `probe_bwrap` in + * `src/backends/bubblewrap/common/src/bwrap_version.rs`, including the + * distinction between a missing binary and a present-but-broken one. + * + * @internal Exported for unit tests. + */ +export function _probeBubblewrap(): BubblewrapProbe { + const minVersion = MIN_BWRAP_VERSION.join('.'); + const result = bwrapVersionRunner(); + + if (result.kind === 'notFound') { + return { + available: false, + reason: `Bubblewrap (bwrap) is not installed or not on PATH; version ${minVersion} or newer is required`, + }; + } + if (result.kind === 'failed') { + // Present but broken: do not send the user to their package manager for a + // package they already have. + // Covers both a spawn failure and termination by a signal, neither of + // which yields an exit code. + const where = + result.status === null ? 'failed without an exit status' : `exited with status ${result.status}`; + const detail = result.detail ? `: ${result.detail}` : ''; + return { + available: false, + reason: `Bubblewrap (bwrap) is present but \`bwrap --version\` ${where}${detail}; version ${minVersion} or newer is required`, + }; + } + + const version = _parseBwrapVersion(result.stdout); + if (!version) { + return { + available: false, + reason: `could not determine the Bubblewrap (bwrap) version from ${JSON.stringify(result.stdout.trim())}; version ${minVersion} or newer is required`, + }; + } + if (compareVersions(version, MIN_BWRAP_VERSION) < 0) { + return { + available: false, + reason: `Bubblewrap (bwrap) ${version.join('.')} is too old; version ${minVersion} or newer is required`, + }; + } + // A new enough `bwrap` still cannot sandbox if the host forbids it, and + // `--version` never creates a namespace, so ask it to build a real one. + const sandbox = bwrapSandboxRunner(); + if (!sandbox.ok) { + return { + available: false, + reason: `Bubblewrap (bwrap) ${version.join('.')} is installed but cannot create a sandbox on this host: ${sandbox.detail}`, + }; + } + return { available: true }; +} + +/** + * Arguments for a minimal end-to-end containment probe. + * + * `bwrap --version` only prints a banner — it never creates a namespace — so + * it passes on hosts where unprivileged user namespaces are disabled + * (`kernel.unprivileged_userns_clone=0`) or where AppArmor denies `bwrap` + * (Ubuntu 23.10+), both of which then fail at every spawn. + * + * The shape mirrors a real run: the same namespaces the Bubblewrap backend + * unshares, plus `--proc` / `--dev`, and `--clearenv` so the payload is + * resolved through `execvp`'s built-in `/bin:/usr/bin` default rather than the + * caller's `PATH`. Binds use `--ro-bind-try` on the few directories a shell + * needs — binding `/` instead would make the probe fail on any host with an + * awkward submount, since `bwrap` treats a failed submount remount as fatal. + * + * Kept in step with the engine's `BWRAP_PROBE_ARGS` + * (`src/core/mxc_engine/src/platform.rs`), which is pinned against the + * production argument builder by a unit test. + */ +const BWRAP_PROBE_ARGS = [ + '--unshare-user', + '--unshare-pid', + '--unshare-ipc', + '--unshare-uts', + '--unshare-net', + '--ro-bind-try', + '/bin', + '/bin', + '--ro-bind-try', + '/usr/bin', + '/usr/bin', + '--ro-bind-try', + '/lib', + '/lib', + '--ro-bind-try', + '/lib64', + '/lib64', + '--ro-bind-try', + '/usr/lib', + '/usr/lib', + '--ro-bind-try', + '/usr/lib64', + '/usr/lib64', + '--proc', + '/proc', + '--dev', + '/dev', + '--clearenv', + '--', + 'sh', + '-c', + 'exit 0', +]; + +/** Outcome of the sandbox probe; `detail` is empty when `ok`. */ +export type BubblewrapSandboxProbe = { ok: boolean; detail: string }; + +/** + * Run {@link BWRAP_PROBE_ARGS}, reporting bwrap's own diagnostic on failure. + * + * Replaceable in unit tests via {@link _setBwrapSandboxRunner}, so the + * version-gate tests can drive `_probeBubblewrap` on a host without `bwrap`. + */ +function defaultBwrapSandboxRunner(): BubblewrapSandboxProbe { + try { + execFileSync('bwrap', BWRAP_PROBE_ARGS, { + stdio: ['ignore', 'ignore', 'pipe'], + timeout: BWRAP_VERSION_TIMEOUT_MS, + }); + return { ok: true, detail: '' }; + } catch (error) { + return { ok: false, detail: bwrapFailureDetail(error) }; + } +} + +let bwrapSandboxRunner: () => BubblewrapSandboxProbe = defaultBwrapSandboxRunner; + +/** @internal Test-only: override the Bubblewrap sandbox probe. */ +export function _setBwrapSandboxRunner(fn: (() => BubblewrapSandboxProbe) | null): void { + bwrapSandboxRunner = fn ?? defaultBwrapSandboxRunner; +} + +/** Reduce a failed bwrap run to a single length-capped line for a `reason`. */ +function bwrapFailureDetail(error: unknown): string { + const MAX_LEN = 200; + const { stderr } = (error ?? {}) as { stderr?: Buffer | string }; + const line = (stderr?.toString() ?? '') + .split('\n') + .map((l) => l.trim()) + .find((l) => l.length > 0); + if (!line) { + return 'it failed with no diagnostic output'; + } + // Spread so the cap counts code points and never splits a surrogate pair. + const chars = [...line]; + return chars.length > MAX_LEN ? `${chars.slice(0, MAX_LEN).join('')}…` : line; +} + +/** + * Check if the macOS sandbox is available. `/usr/bin/sandbox-exec` is part + * of the macOS base install and present on every shipping version of macOS, + * so this is effectively a sanity check for a corrupted install. + */ +function isSeatbeltAvailable(): boolean { + try { + return fs.existsSync('/usr/bin/sandbox-exec'); + } catch { + return false; + } +} + +/** + * Get the simplified architecture name used for SDK bin directory layout. + * @returns 'arm64' or 'x64' + */ +function getSdkArch(): string { + return os.arch() === 'arm64' ? 'arm64' : 'x64'; +} + +/** + * Get the Rust target triple for the current machine architecture. + * @returns The Rust target triple string + */ +function getRustTargetTriple(): string { + const arch = os.arch(); + const platform = os.platform(); + if (platform === 'linux') { + return arch === 'arm64' ? 'aarch64-unknown-linux-gnu' : 'x86_64-unknown-linux-gnu'; + } + // Windows + return arch === 'arm64' ? 'aarch64-pc-windows-msvc' : 'x86_64-pc-windows-msvc'; +} + +/** + * Get the Rust target triple for the current Linux machine architecture. + */ +function getLinuxRustTargetTriple(): string { + const arch = os.arch(); + switch (arch) { + case 'arm64': + return 'aarch64-unknown-linux-gnu'; + case 'x64': + default: + return 'x86_64-unknown-linux-gnu'; + } +} + +/** + * Get the Rust target triple for the current macOS machine architecture. + */ +function getDarwinRustTargetTriple(): string { + const arch = os.arch(); + return arch === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin'; +} + +/** + * Find the wxc-exec executable + * Searches in common locations relative to the SDK package, + * selecting the build matching the current machine architecture. + * @returns Path to wxc-exec.exe if found, null otherwise + */ +export function findWxcExecutable(): string | null { + // Allow override for bundled deployments (debugging/testing) + if (process.env.MXC_BIN_DIR) { + const overridePath = path.join(process.env.MXC_BIN_DIR, getSdkArch(), 'wxc-exec.exe'); + if (verifyWxcExecutable(overridePath)) { + return overridePath; + } + } + + const pkgRoot = getSdkPackageRoot(); + const targetTriple = getRustTargetTriple(); + const targetDir = path.join(pkgRoot, '..', '..', 'src', 'target'); + + const possiblePaths = [ + // Bundled in the SDK package (e.g. when installed via npm) + path.join(pkgRoot, 'bin', getSdkArch(), 'wxc-exec.exe'), + // Architecture-specific release build output (monorepo dev) + path.join(targetDir, targetTriple, 'release', 'wxc-exec.exe'), + // Architecture-specific debug build output (monorepo dev) + path.join(targetDir, targetTriple, 'debug', 'wxc-exec.exe'), + // Fallback: default Cargo release build output (no explicit --target) + path.join(targetDir, 'release', 'wxc-exec.exe'), + // Fallback: default Cargo debug build output (no explicit --target) + path.join(targetDir, 'debug', 'wxc-exec.exe'), + ]; + + for (const wxcPath of possiblePaths) { + if (verifyWxcExecutable(wxcPath)) { + return wxcPath; + } + } + + return null; +} + +/** + * Verify that an executable exists at the given path + * @param execPath - Path to verify + * @returns true if the executable exists and is a file, false otherwise + */ +function verifyExecutable(execPath: string): boolean { + try { + // Paths inside Electron's app.asar exist to fs but can't be executed + if (execPath.includes('.asar')) { + return false; + } + if (!fs.existsSync(execPath) || !fs.statSync(execPath).isFile()) { + return false; + } + // On non-Windows platforms, also verify execute permission + if (process.platform !== 'win32') { + fs.accessSync(execPath, fs.constants.X_OK); + } + return true; + } catch { + return false; + } +} + +/** + * Verify that a wxc-exec executable exists at the given path + * @param wxcPath - Path to verify + * @returns true if the executable exists and is a file, false otherwise + */ +function verifyWxcExecutable(wxcPath: string): boolean { + return verifyExecutable(wxcPath); +} + +/** + * Find the lxc-exec executable on Linux + * Searches in common locations relative to the SDK package. + * @returns Path to lxc-exec if found, null otherwise + */ +export function findLxcExecutable(): string | null { + // Allow override for bundled deployments (debugging/testing) + if (process.env.MXC_BIN_DIR) { + const overridePath = path.join(process.env.MXC_BIN_DIR, getSdkArch(), 'lxc-exec'); + if (verifyExecutable(overridePath)) { + return overridePath; + } + } + + const pkgRoot = getSdkPackageRoot(); + const targetTriple = getLinuxRustTargetTriple(); + const targetDir = path.join(pkgRoot, '..', '..', 'src', 'target'); + + const possiblePaths = [ + // Bundled in the SDK package + path.join(pkgRoot, 'bin', getSdkArch(), 'lxc-exec'), + // Architecture-specific release build + path.join(targetDir, targetTriple, 'release', 'lxc-exec'), + // Architecture-specific debug build + path.join(targetDir, targetTriple, 'debug', 'lxc-exec'), + // Default Cargo release build + path.join(targetDir, 'release', 'lxc-exec'), + // Default Cargo debug build + path.join(targetDir, 'debug', 'lxc-exec'), + ]; + + for (const lxcPath of possiblePaths) { + if (verifyExecutable(lxcPath)) { + return lxcPath; + } + } + + return null; +} + +/** + * Find the mxc-exec-mac executable on macOS. + * Searches in the SDK bin directory (npm install path) and Cargo build + * output directories (monorepo dev path). + * @returns Path to mxc-exec-mac if found, null otherwise + */ +export function findSeatbeltExecutable(): string | null { + // Allow override for bundled deployments (debugging/testing) + if (process.env.MXC_BIN_DIR) { + const overridePath = path.join(process.env.MXC_BIN_DIR, getSdkArch(), 'mxc-exec-mac'); + if (verifyExecutable(overridePath)) { + return overridePath; + } + } + + const targetTriple = getDarwinRustTargetTriple(); + const targetDir = path.join(__dirname, '..', '..', '..', 'src', 'target'); + + const possiblePaths = [ + // Bundled in the SDK package + path.join(__dirname, '..', 'bin', getSdkArch(), 'mxc-exec-mac'), + // Architecture-specific release build + path.join(targetDir, targetTriple, 'release', 'mxc-exec-mac'), + // Architecture-specific debug build + path.join(targetDir, targetTriple, 'debug', 'mxc-exec-mac'), + // Default Cargo release build + path.join(targetDir, 'release', 'mxc-exec-mac'), + // Default Cargo debug build + path.join(targetDir, 'debug', 'mxc-exec-mac'), + ]; + + for (const darwinPath of possiblePaths) { + if (verifyExecutable(darwinPath)) { + return darwinPath; + } + } + + return null; +} From 82d3197644dc9a7cbf09f44b49cd19a40a6b7236 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 5 Aug 2026 16:23:13 -0300 Subject: [PATCH 4/6] fix(bwrap): collect probe output via files so the deadline actually holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wait deadline bounded `try_wait`, but not the drain that follows it. A pipe only reaches EOF once every write end is closed, so a `bwrap` wrapper that backgrounds a process inheriting stdout keeps `read_to_end` blocked long after the direct child exits — the probe still hung, just at a different line. Collect through unlinked temporary files instead. A file read always terminates, and a descendant that keeps writing after we return is harmless. This also folds the spawn into the helper, so a caller cannot reintroduce the bug by wiring up pipes itself. The regression test asserts the call returns promptly for `sleep 10 & echo 'bubblewrap 0.11.0'`; against the pipe implementation it blocks the full 10s and fails. `tempfile` moves from dev-dependencies to dependencies. It was already in the workspace and already used by this crate's tests, so Cargo.lock is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89d7c4bf-cda2-4835-95a3-311c5c494b1b Signed-off-by: Carlos Alexandro Becker --- src/backends/bubblewrap/common/Cargo.toml | 7 +- .../bubblewrap/common/src/bwrap_version.rs | 137 ++++++++++++------ src/core/mxc_engine/src/platform.rs | 35 ++--- 3 files changed, 109 insertions(+), 70 deletions(-) diff --git a/src/backends/bubblewrap/common/Cargo.toml b/src/backends/bubblewrap/common/Cargo.toml index e337d810e..e7b72ace4 100644 --- a/src/backends/bubblewrap/common/Cargo.toml +++ b/src/backends/bubblewrap/common/Cargo.toml @@ -10,7 +10,8 @@ lxc_common = { workspace = true } nix = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -thiserror = { workspace = true } - -[dev-dependencies] +# Used by `bwrap_version::run_with_deadline` to collect probe output without +# pipes, which cannot be drained under a deadline once a descendant inherits +# the write end. tempfile = { workspace = true } +thiserror = { workspace = true } diff --git a/src/backends/bubblewrap/common/src/bwrap_version.rs b/src/backends/bubblewrap/common/src/bwrap_version.rs index 484f3744d..bcda26981 100644 --- a/src/backends/bubblewrap/common/src/bwrap_version.rs +++ b/src/backends/bubblewrap/common/src/bwrap_version.rs @@ -13,8 +13,9 @@ //! [`probe_bwrap`] shells out. use std::fmt; -use std::io; -use std::process::{Child, Command, Output, Stdio}; +use std::fs::File; +use std::io::{self, Read, Seek}; +use std::process::{Command, Output, Stdio}; use std::time::{Duration, Instant}; /// How long a `bwrap` probe may run before it is treated as a failure. @@ -156,26 +157,18 @@ pub fn probe_bwrap() -> Result { }, }; - let mut child = Command::new("bwrap") - .arg("--version") - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(spawn_failure)?; - - let output = match wait_with_deadline(&mut child, PROBE_TIMEOUT) { - Some(result) => result.map_err(spawn_failure)?, + let mut command = Command::new("bwrap"); + command.arg("--version"); + let output = match run_with_deadline(&mut command, PROBE_TIMEOUT).map_err(spawn_failure)? { + Some(output) => output, None => { - let _ = child.kill(); - let _ = child.wait(); return Err(BwrapUnavailable::ProbeFailed { status: None, detail: format!( "did not respond within {}s and was killed", PROBE_TIMEOUT.as_secs() ), - }); + }) } }; @@ -191,40 +184,52 @@ pub fn probe_bwrap() -> Result { check_version_output(&stdout) } -/// Collect `child`'s output, or return `None` if it outlives `timeout`. +/// Run `command` to completion, or kill it and return `None` if it outlives +/// `timeout`. /// -/// `Child::wait_with_output` has no deadline, so this polls `try_wait` and -/// only drains the pipes once the process has exited — at which point the -/// reads return immediately and cannot block on a full pipe. -pub fn wait_with_deadline(child: &mut Child, timeout: Duration) -> Option> { - use std::io::Read; +/// Output is collected through temporary files rather than pipes on purpose. +/// A pipe only reaches EOF once *every* write end is closed, so a `bwrap` +/// wrapper that backgrounds a process inheriting stdout would keep a +/// `read_to_end` blocked long after the direct child exited — reintroducing +/// the exact hang this deadline exists to prevent. Reading a file always +/// terminates, and a descendant that keeps writing to it after we return is +/// harmless because the file is already unlinked. +pub fn run_with_deadline(command: &mut Command, timeout: Duration) -> io::Result> { + const POLL_INTERVAL: Duration = Duration::from_millis(25); + + let stdout = tempfile::tempfile()?; + let stderr = tempfile::tempfile()?; + let mut child = command + .stdin(Stdio::null()) + .stdout(stdout.try_clone()?) + .stderr(stderr.try_clone()?) + .spawn()?; let deadline = Instant::now() + timeout; - loop { - match child.try_wait() { - Ok(Some(_)) => break, - Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(25)), - Ok(None) => return None, - Err(e) => return Some(Err(e)), - } - } - - let read_pipe = |pipe: Option<&mut dyn Read>| -> io::Result> { - let mut buf = Vec::new(); - if let Some(pipe) = pipe { - pipe.read_to_end(&mut buf)?; + let status = loop { + match child.try_wait()? { + Some(status) => break status, + None if Instant::now() < deadline => std::thread::sleep(POLL_INTERVAL), + None => { + let _ = child.kill(); + let _ = child.wait(); + return Ok(None); + } } - Ok(buf) }; - Some((|| { - let stdout = read_pipe(child.stdout.as_mut().map(|p| p as &mut dyn Read))?; - let stderr = read_pipe(child.stderr.as_mut().map(|p| p as &mut dyn Read))?; - Ok(Output { - status: child.wait()?, - stdout, - stderr, - }) - })()) + + Ok(Some(Output { + status, + stdout: read_from_start(stdout)?, + stderr: read_from_start(stderr)?, + })) +} + +fn read_from_start(mut file: File) -> io::Result> { + file.rewind()?; + let mut buf = Vec::new(); + file.read_to_end(&mut buf)?; + Ok(buf) } /// Validate a raw `bwrap --version` output string against @@ -523,4 +528,48 @@ mod tests { "OS error should survive: {message}" ); } + + /// A `bwrap` wrapper that backgrounds a process keeps the inherited output + /// handle open after the direct child exits. Draining a *pipe* in that + /// situation blocks until the descendant exits, which is how a probe with a + /// wait deadline can still hang; collecting into a file cannot. + #[test] + #[cfg(unix)] + fn deadline_holds_when_a_descendant_inherits_the_output_handles() { + let mut command = Command::new("sh"); + command.args(["-c", "sleep 10 & echo 'bubblewrap 0.11.0'"]); + + let started = Instant::now(); + let output = run_with_deadline(&mut command, Duration::from_secs(5)) + .expect("probe should not error") + .expect("the direct child exits immediately"); + + assert!( + started.elapsed() < Duration::from_secs(5), + "returned only after {:?}; the descendant blocked the drain", + started.elapsed() + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "bubblewrap 0.11.0" + ); + } + + #[test] + #[cfg(unix)] + fn deadline_kills_a_command_that_outlives_it() { + let mut command = Command::new("sh"); + command.args(["-c", "sleep 10"]); + + let started = Instant::now(); + let outcome = run_with_deadline(&mut command, Duration::from_millis(200)) + .expect("probe should not error"); + + assert!(outcome.is_none(), "expected the deadline to fire"); + assert!( + started.elapsed() < Duration::from_secs(5), + "took {:?} to give up", + started.elapsed() + ); + } } diff --git a/src/core/mxc_engine/src/platform.rs b/src/core/mxc_engine/src/platform.rs index 96350e817..18c94e1be 100644 --- a/src/core/mxc_engine/src/platform.rs +++ b/src/core/mxc_engine/src/platform.rs @@ -205,36 +205,25 @@ const BWRAP_PROBE_ARGS: &[&str] = &[ /// forks, so it can block on a wedged filesystem. #[cfg(target_os = "linux")] fn probe_bubblewrap() -> Result<(), String> { - use bwrap_common::bwrap_version::{wait_with_deadline, PROBE_TIMEOUT}; + use bwrap_common::bwrap_version::{run_with_deadline, PROBE_TIMEOUT}; use std::io::ErrorKind; - use std::process::{Command, Stdio}; + use std::process::Command; - let child = Command::new("bwrap") - .args(BWRAP_PROBE_ARGS) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn(); + let mut command = Command::new("bwrap"); + command.args(BWRAP_PROBE_ARGS); - let mut child = match child { - Ok(child) => child, - Err(e) if e.kind() == ErrorKind::NotFound => { - return Err("Bubblewrap is not available on this system".to_string()) - } - Err(e) => return Err(format!("Bubblewrap could not be executed: {e}")), - }; - - let output = match wait_with_deadline(&mut child, PROBE_TIMEOUT) { - Some(Ok(output)) => output, - Some(Err(e)) => return Err(format!("Bubblewrap could not be executed: {e}")), - None => { - let _ = child.kill(); - let _ = child.wait(); + let output = match run_with_deadline(&mut command, PROBE_TIMEOUT) { + Ok(Some(output)) => output, + Ok(None) => { return Err(format!( "Bubblewrap did not finish a trivial sandbox within {}s on this host", PROBE_TIMEOUT.as_secs() - )); + )) } + Err(e) if e.kind() == ErrorKind::NotFound => { + return Err("Bubblewrap is not available on this system".to_string()) + } + Err(e) => return Err(format!("Bubblewrap could not be executed: {e}")), }; if output.status.success() { From c002170e477365d9b25234e6d340a7f803d125e6 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 5 Aug 2026 16:46:47 -0300 Subject: [PATCH 5/6] fix(bwrap): cap the probe output read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `read_to_end` on the collected output let the allocation follow the file, so an unusually verbose `bwrap` — or a wrapper backgrounding a writer that keeps growing the file after the direct child exits — could be read without bound. Retain a 64 KiB snapshot instead; `--version` prints one line and a failure prints a short diagnostic, so anything past that is a runaway writer rather than something worth parsing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89d7c4bf-cda2-4835-95a3-311c5c494b1b Signed-off-by: Carlos Alexandro Becker --- .../bubblewrap/common/src/bwrap_version.rs | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/src/backends/bubblewrap/common/src/bwrap_version.rs b/src/backends/bubblewrap/common/src/bwrap_version.rs index bcda26981..713b217ee 100644 --- a/src/backends/bubblewrap/common/src/bwrap_version.rs +++ b/src/backends/bubblewrap/common/src/bwrap_version.rs @@ -206,29 +206,38 @@ pub fn run_with_deadline(command: &mut Command, timeout: Duration) -> io::Result .spawn()?; let deadline = Instant::now() + timeout; - let status = loop { + let outcome = loop { match child.try_wait()? { - Some(status) => break status, + Some(status) => break Some(status), None if Instant::now() < deadline => std::thread::sleep(POLL_INTERVAL), None => { let _ = child.kill(); let _ = child.wait(); - return Ok(None); + break None; } } }; + let Some(status) = outcome else { + return Ok(None); + }; Ok(Some(Output { status, - stdout: read_from_start(stdout)?, - stderr: read_from_start(stderr)?, + stdout: read_capped(stdout)?, + stderr: read_capped(stderr)?, })) } -fn read_from_start(mut file: File) -> io::Result> { +/// Cap on retained probe output. `bwrap --version` prints one line and a +/// failure prints a short diagnostic, so anything past this is a runaway +/// writer rather than something worth parsing — read a bounded snapshot +/// instead of letting the allocation follow the file. +const MAX_PROBE_OUTPUT: u64 = 64 * 1024; + +fn read_capped(mut file: File) -> io::Result> { file.rewind()?; let mut buf = Vec::new(); - file.read_to_end(&mut buf)?; + file.take(MAX_PROBE_OUTPUT).read_to_end(&mut buf)?; Ok(buf) } @@ -555,6 +564,25 @@ mod tests { ); } + /// A verbose command must not translate into an allocation that follows + /// the output. Deliberately bounded so the test cannot fill the disk. + #[test] + #[cfg(unix)] + fn output_beyond_the_cap_is_truncated() { + let mut command = Command::new("sh"); + command.args(["-c", "yes diagnostic | head -c 200000"]); + + let output = run_with_deadline(&mut command, Duration::from_secs(5)) + .expect("probe should not error") + .expect("the command exits on its own"); + + assert_eq!( + output.stdout.len() as u64, + MAX_PROBE_OUTPUT, + "expected the read to stop at the cap" + ); + } + #[test] #[cfg(unix)] fn deadline_kills_a_command_that_outlives_it() { From a00b42872ddd497fc059cbbec5bcbc5588a83a45 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 5 Aug 2026 17:01:09 -0300 Subject: [PATCH 6/6] fix(bwrap): reap the timed-out probe without blocking The deadline sent a kill and then waited on it. A process wedged in uninterruptible I/O -- a stalled mount, which is one of the cases this deadline exists for -- leaves the signal pending, so that wait would never return and `platform_support()` was still unbounded on the path that matters most. Signal the child and hand it to a detached thread to reap, so the zombie is still collected whenever the kernel lets go without the caller waiting for it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89d7c4bf-cda2-4835-95a3-311c5c494b1b Signed-off-by: Carlos Alexandro Becker --- .../bubblewrap/common/src/bwrap_version.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/backends/bubblewrap/common/src/bwrap_version.rs b/src/backends/bubblewrap/common/src/bwrap_version.rs index 713b217ee..fd9933c9d 100644 --- a/src/backends/bubblewrap/common/src/bwrap_version.rs +++ b/src/backends/bubblewrap/common/src/bwrap_version.rs @@ -194,6 +194,10 @@ pub fn probe_bwrap() -> Result { /// the exact hang this deadline exists to prevent. Reading a file always /// terminates, and a descendant that keeps writing to it after we return is /// harmless because the file is already unlinked. +/// +/// The timeout path never blocks: the child is signalled and reaped +/// asynchronously, so the deadline holds even against a process the kernel +/// will not interrupt. pub fn run_with_deadline(command: &mut Command, timeout: Duration) -> io::Result> { const POLL_INTERVAL: Duration = Duration::from_millis(25); @@ -212,7 +216,15 @@ pub fn run_with_deadline(command: &mut Command, timeout: Duration) -> io::Result None if Instant::now() < deadline => std::thread::sleep(POLL_INTERVAL), None => { let _ = child.kill(); - let _ = child.wait(); + // Reap on a detached thread rather than blocking here. A + // process wedged in uninterruptible I/O — the stalled-mount + // case this deadline exists for — leaves the signal pending, + // and a `wait()` on it would never return. The thread still + // collects the zombie once the kernel lets go, but the caller + // gets its deadline back either way. + std::thread::spawn(move || { + let _ = child.wait(); + }); break None; } }