diff --git a/docs/process-container/os-version-support.md b/docs/process-container/os-version-support.md index b98d486b8..f1f95525c 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 47ca684e3..b03434ad3 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -368,7 +368,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, 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 b704e212d..47b111c0e 100644 --- a/sdk/node/src/platform.ts +++ b/sdk/node/src/platform.ts @@ -24,6 +24,77 @@ function getSdkPackageRoot(): string { } } +/** + * 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 value is missing or unparseable. + */ +type WindowsBuild = { major: number } | null; + +/** + * Default implementation that reads `CurrentBuild` from the registry. + * Replaceable via {@link _setWindowsBuildQuery} in tests so we can exercise + * the `processcontainer` build floor 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; + } + return { major }; +} + +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; + let windowsSandboxAvailableCache: boolean | undefined; /** @@ -222,12 +293,40 @@ 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 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()) { - support.availableMethods.push('windows_sandbox'); + methods.push('windows_sandbox'); } + support.availableMethods = methods; + // Runs before the verdict below so `isolation_session`, which only the probe + // can report, is counted among the alternatives on a below-floor host. populateIsolationFromProbe(support); + + if (!support.availableMethods.includes('processcontainer')) { + const alternatives = + support.availableMethods.length > 0 + ? ` (experimental backends available: ${support.availableMethods.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; return support; } @@ -462,9 +561,116 @@ export function _probeBubblewrap(): BubblewrapProbe { 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, diff --git a/sdk/node/src/state-aware-helper.ts b/sdk/node/src/state-aware-helper.ts index 0fc072981..659736b5a 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 3c1cfa51a..916b9110b 100644 --- a/sdk/node/src/state-aware.ts +++ b/sdk/node/src/state-aware.ts @@ -71,7 +71,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, @@ -96,7 +96,7 @@ export async function startSandbox( correlationVector: options.correlationVector, config: config as Record | undefined, }); - return nonExecCall>(envelope, options); + return nonExecCall>(envelope, options, backendKey); } /** @@ -119,7 +119,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', @@ -160,7 +164,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); @@ -189,7 +193,7 @@ export async function stopSandbox( correlationVector: options.correlationVector, config: config as Record | undefined, }); - return nonExecCall>(envelope, options); + return nonExecCall>(envelope, options, backendKey); } /** @@ -209,5 +213,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/sdk/node/tests/unit/platform.test.ts b/sdk/node/tests/unit/platform.test.ts index 194641342..879b2981d 100644 --- a/sdk/node/tests/unit/platform.test.ts +++ b/sdk/node/tests/unit/platform.test.ts @@ -9,9 +9,11 @@ import { getPlatformSupport, _resetPlatformSupportCache, _setProbeRunner, + _setWindowsBuildQuery, _parseBwrapVersion, _probeBubblewrap, _setBwrapVersionRunner, + _setBwrapSandboxRunner, findWxcExecutable, } from '../../src/platform.js'; @@ -333,10 +335,14 @@ describe('findWxcExecutable failure modes', () => { describe('isolation_session availability gate', () => { beforeEach(() => { _resetPlatformSupportCache(); + // Pin the host build above the `processcontainer` floor so these assertions + // exercise the probe alone, not whatever build the CI runner happens to be. + _setWindowsBuildQuery(() => ({ major: 26100 })); }); afterEach(() => { _setProbeRunner(null); + _setWindowsBuildQuery(null); _resetPlatformSupportCache(); }); @@ -378,13 +384,79 @@ 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 }, () => { - // The runtime gate lives in the native binary; the SDK reports Windows - // support regardless of isolation-session availability. - _setProbeRunner(() => JSON.stringify({ probes: { isolationSessionAvailable: false } })); +}); + +// The 26100 (Windows 11 24H2) product floor. Reporting a below-floor host as +// supported only moves the failure to spawn time, where the reason is far less +// actionable. Mirrors `windows_platform_support` in +// `src/core/mxc_engine/src/platform.rs`. +describe('processcontainer build gate', () => { + beforeEach(() => { + _resetPlatformSupportCache(); + }); + + afterEach(() => { + _setWindowsBuildQuery(null); + _setProbeRunner(null); + _resetPlatformSupportCache(); + }); + + it('reports unsupported below build 26100', { skip: !isWindows }, () => { + // 19045 = Windows 10 22H2, 22000 = Windows 11 21H2, 22631 = 23H2. + for (const major of [19045, 22000, 22631, 26099]) { + _resetPlatformSupportCache(); + _setWindowsBuildQuery(() => ({ major })); + 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}`)); + } + }); + + it('reports supported at or above build 26100', { skip: !isWindows }, () => { + for (const major of [26100, 26200, 26600]) { + _resetPlatformSupportCache(); + _setWindowsBuildQuery(() => ({ major })); + 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.strictEqual(support.availableMethods[0], 'processcontainer'); + assert.ok(support.availableMethods.includes('processcontainer')); + }); + + it( + 'reports processcontainer on a supported build regardless of the probe', + { skip: !isWindows }, + () => { + // Isolation-session availability is orthogonal to the build gate. + _setWindowsBuildQuery(() => ({ major: 26100 })); + _setProbeRunner(() => JSON.stringify({ probes: { isolationSessionAvailable: false } })); + const support = getPlatformSupport(); + assert.ok(support.isSupported); + assert.ok(support.availableMethods.includes('processcontainer')); + }, + ); + + // A below-floor host can still run the experimental backends, and the reason + // must say so rather than reading as a flat "unsupported host". + it('lists probe-reported backends as alternatives below the floor', { skip: !isWindows }, () => { + _setWindowsBuildQuery(() => ({ major: 22631 })); + _setProbeRunner(() => JSON.stringify({ probes: { isolationSessionAvailable: true } })); + const support = getPlatformSupport(); + assert.ok(!support.isSupported); + assert.ok(support.availableMethods.includes('isolation_session')); + assert.match(support.reason ?? '', /experimental backends available: .*isolation_session/); }); }); @@ -452,8 +524,16 @@ describe('bwrap version parsing', () => { // runner. Without these the SDK gate could drift from the Rust gate in // `src/backends/bubblewrap/common/src/bwrap_version.rs` unnoticed. describe('bwrap minimum-version gate', () => { + beforeEach(() => { + // A new enough `bwrap` still has to prove it can build a sandbox, which no + // host running these tests necessarily can. Stub that half so these cases + // exercise the version comparison alone. + _setBwrapSandboxRunner(() => ({ ok: true, detail: '' })); + }); + afterEach(() => { _setBwrapVersionRunner(null); + _setBwrapSandboxRunner(null); _resetPlatformSupportCache(); }); @@ -556,6 +636,21 @@ describe('bwrap minimum-version gate', () => { assert.match(probe.reason, /failed without an exit status/); }); + it('rejects a new enough bwrap that cannot create a sandbox', () => { + // The case `bwrap --version` cannot see: unprivileged user namespaces + // disabled, or AppArmor denying bwrap. Without this the host passes + // detection and then fails at every spawn. + withVersion('bubblewrap 0.11.2\n'); + _setBwrapSandboxRunner(() => ({ + ok: false, + detail: 'bwrap: No permissions to creating new namespace', + })); + const probe = _probeBubblewrap(); + assert.strictEqual(probe.available, false); + assert.match(probe.reason, /cannot create a sandbox/); + assert.match(probe.reason, /No permissions to creating new namespace/); + }); + it('omits bubblewrap from getPlatformSupport below the floor', { skip: os.platform() !== 'linux' }, () => { withVersion('bubblewrap 0.4.1\n'); _resetPlatformSupportCache(); 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 17a33a2f7..fd9933c9d 100644 --- a/src/backends/bubblewrap/common/src/bwrap_version.rs +++ b/src/backends/bubblewrap/common/src/bwrap_version.rs @@ -13,7 +13,18 @@ //! [`probe_bwrap`] shells out. use std::fmt; -use std::process::{Command, 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. +/// +/// 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 +146,31 @@ 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") - .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 { + 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 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 => { + 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 +184,75 @@ pub fn probe_bwrap() -> Result { check_version_output(&stdout) } +/// Run `command` to completion, or kill it and return `None` if it outlives +/// `timeout`. +/// +/// 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. +/// +/// 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); + + 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; + let outcome = loop { + match child.try_wait()? { + Some(status) => break Some(status), + None if Instant::now() < deadline => std::thread::sleep(POLL_INTERVAL), + None => { + let _ = child.kill(); + // 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; + } + } + }; + + let Some(status) = outcome else { + return Ok(None); + }; + Ok(Some(Output { + status, + stdout: read_capped(stdout)?, + stderr: read_capped(stderr)?, + })) +} + +/// 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.take(MAX_PROBE_OUTPUT).read_to_end(&mut buf)?; + Ok(buf) +} + /// 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. @@ -458,4 +549,67 @@ 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" + ); + } + + /// 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() { + 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-sdk/README.md b/src/core/mxc-sdk/README.md index 3e7bfa116..677fcf6ce 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -62,6 +62,9 @@ questions: MXC is supported on this host and the backends **this SDK can actually launch** (the subset in [Supported backends](#supported-backends)). Use it to decide whether `run` / `spawn_sandbox` will work before building a request. + 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. - [`available_backends`] — a broader **host-capability** probe. Reports every containment backend the *host* can run, including ones only the executor binaries (`wxc-exec` etc.) can currently drive — Windows Sandbox, diff --git a/src/core/mxc_engine/src/platform.rs b/src/core/mxc_engine/src/platform.rs index 6fa5ff3e9..9cd81a725 100644 --- a/src/core/mxc_engine/src/platform.rs +++ b/src/core/mxc_engine/src/platform.rs @@ -25,6 +25,12 @@ 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 @@ -35,7 +41,20 @@ pub struct PlatformSupport { /// host-capability set (backends the host can run but the SDK cannot launch, /// e.g. `lxc`, `windows_sandbox`, `isolation_session`) is reported separately by /// [`available_backends`](crate::available_backends). +/// +/// 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() { @@ -56,19 +75,24 @@ pub fn platform_support() -> PlatformSupport { #[cfg(target_os = "linux")] { - // Presence alone is not enough: `bwrap` must also be new enough for - // every flag the argument builder emits (see - // `bwrap_common::bwrap_version::MIN_BWRAP_VERSION`). `lxc` is a - // host-capability backend the SDK can't launch, so it is reported by + // Two independent things can be wrong, so both are checked. `bwrap` + // must be new enough for every flag the argument builder emits (see + // `bwrap_common::bwrap_version::MIN_BWRAP_VERSION`), and — since + // `--version` never creates a namespace — it must also actually be + // able to build a sandbox on this host. `lxc` is a host-capability + // backend the SDK can't launch, so it is reported by // `available_backends()` rather than here. - match bwrap_common::bwrap_version::probe_bwrap() { - Ok(_) => PlatformSupport { + let unavailable = bwrap_common::bwrap_version::probe_bwrap() + .map_err(|err| err.to_string()) + .and_then(|_| probe_bubblewrap()); + match unavailable { + Ok(()) => PlatformSupport { is_supported: true, available_methods: vec!["bubblewrap".to_string()], ..Default::default() }, - Err(err) => PlatformSupport { - reason: Some(err.to_string()), + Err(reason) => PlatformSupport { + reason: Some(reason), ..Default::default() }, } @@ -76,33 +100,164 @@ pub fn platform_support() -> PlatformSupport { #[cfg(target_os = "windows")] { - let mut available_methods = vec!["processcontainer".to_string()]; // `windows_sandbox` and `isolation_session` are host-capability backends // the SDK can't launch, so they are reported by `available_backends()` // rather than here. - // - // WSLC is an additional, opt-in backend rather than a fallback: report - // it only when the host can actually run it (WSL2 + the WSLC runtime), - // which is the same preflight the runner performs. - if wslc_available() { - available_methods.push("wslc".to_string()); + windows_platform_support( + appcontainer_common::job_object::os_build_number(), + wslc_available(), + ) + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + PlatformSupport { + reason: Some("MXC is not supported on this platform".to_string()), + ..Default::default() } + } +} + +/// 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. +/// +/// WSLC is an additional, opt-in backend rather than a fallback, so it is +/// reported when the host can run it but does not carry `is_supported`: that +/// flag guards the default `processcontainer` spawn. +#[cfg(any(target_os = "windows", test))] +fn windows_platform_support(build: u32, wslc: bool) -> PlatformSupport { + let mut available_methods = Vec::new(); + if build >= MIN_WINDOWS_BUILD { + available_methods.push("processcontainer".to_string()); + } + if wslc { + available_methods.push("wslc".to_string()); + } + + if build >= MIN_WINDOWS_BUILD { PlatformSupport { is_supported: true, available_methods, ..Default::default() } - } - - #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] - { + } else { PlatformSupport { - reason: Some("MXC is not supported on this platform".to_string()), + reason: Some(format!( + "Windows build {build} is below {MIN_WINDOWS_BUILD}, the minimum \ + supported build (Windows 11 24H2)" + )), + available_methods, ..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", +]; + +/// 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::{run_with_deadline, PROBE_TIMEOUT}; + use std::io::ErrorKind; + use std::process::Command; + + let mut command = Command::new("bwrap"); + command.args(BWRAP_PROBE_ARGS); + + 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() { + return Ok(()); + } + Err(format!( + "Bubblewrap is installed but cannot create a sandbox on this host: {}", + bwrap_failure_detail(&output.stderr) + )) +} + +/// 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(), + } +} + /// Whether this host can run the WSL Container backend, probing the WSLC /// runtime the same way the runner's preflight does. Always `false` when the /// backend isn't compiled in, so the caller needs no `cfg` of its own. @@ -137,7 +292,7 @@ pub fn isolation_session_available() -> bool { #[cfg(test)] mod tests { - use super::platform_support; + use super::*; use wxc_common::wire::Containment; fn wire_name(containment: &Containment) -> String { @@ -192,4 +347,127 @@ mod tests { ); } } + + /// `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()); + if support.is_supported { + assert!(!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, false); + 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, false); + 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}"); + } + } + + /// WSLC has its own runtime requirements, so it is reported wherever it is + /// present — but it is opt-in, and must not make a below-floor host look + /// ready for the default `processcontainer` spawn. + #[test] + fn wslc_is_reported_but_does_not_carry_support() { + let below = windows_platform_support(22631, true); + assert!(!below.is_supported); + assert_eq!(below.available_methods, ["wslc"]); + + let above = windows_platform_support(MIN_WINDOWS_BUILD, true); + assert!(above.is_supported); + assert_eq!(above.available_methods, ["processcontainer", "wslc"]); + } + + /// 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); + } }