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