Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/process-container/os-version-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion sdk/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<dir>` so `<dir>/<arch>/wxc-exec.exe` (or `lxc-exec`) exists, or pass `options.executablePath` explicitly. |
| `Invalid containment value '<x>'` | `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). |
| `'<x>' containment requires experimental mode` | A `windows_sandbox` / `wslc` / `microvm` / `isolation_session` / `hyperlight` backend was selected without the flag. | Pass `{ experimental: true }` in `SandboxSpawnOptions`. |
Expand Down
16 changes: 14 additions & 2 deletions sdk/node/src/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =

@bbonaby Branden Bonaby (bbonaby) Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking, tests): This parameter threading repairs the case where the shared platform check rejects an explicitly selected experimental backend on a below-26100 host. Current tests validate platform output and state-aware envelopes separately, but do not execute a state-aware phase through this resolution path.

Could we add a build-22631 test using a fake executor that calls provisionSandbox or startSandbox for an experimental backend and verifies resolution does not throw the default platform-support error?

!!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}`);
}

Expand Down Expand Up @@ -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');
}
Expand Down
212 changes: 209 additions & 3 deletions sdk/node/src/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 = [

@bbonaby Branden Bonaby (bbonaby) Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): The Rust vector is pinned against production arguments, but this TypeScript copy is protected only by a β€œkeep in step” comment. A future namespace or mount change can silently make Node detection weaker than actual execution.

'--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,
Comment thread
caarlos0 marked this conversation as resolved.
});
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,
Expand Down
10 changes: 8 additions & 2 deletions sdk/node/src/state-aware-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export interface CollectedOutput {
export function spawnAndCollect(
envelope: Record<string, unknown>,
options: SandboxSpawnOptions,
containment?: string,
): Promise<CollectedOutput> {
return new Promise((resolve, reject) => {
const signal = options.signal;
Expand All @@ -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;
Expand Down Expand Up @@ -264,7 +269,8 @@ export function spawnAndCollect(
export async function nonExecCall<T>(
envelope: Record<string, unknown>,
options: SandboxSpawnOptions,
containment?: string,
): Promise<T> {
const { stdout } = await spawnAndCollect(envelope, options);
const { stdout } = await spawnAndCollect(envelope, options, containment);
return parseNonExecResponse<T>(stdout);
}
16 changes: 10 additions & 6 deletions sdk/node/src/state-aware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export async function provisionSandbox<C extends StateAwareContainmentBackend>(
sandboxId: string;
metadata?: ProvisionMetadataFor<C>;
correlationVector?: string;
}>(envelope, options);
}>(envelope, options, containment);
return {
sandboxId: result.sandboxId as SandboxId<C>,
metadata: result.metadata,
Expand All @@ -96,7 +96,7 @@ export async function startSandbox<C extends StateAwareContainmentBackend>(
correlationVector: options.correlationVector,
config: config as Record<string, unknown> | undefined,
});
return nonExecCall<StartResult<C>>(envelope, options);
return nonExecCall<StartResult<C>>(envelope, options, backendKey);
}

/**
Expand All @@ -119,7 +119,11 @@ export function execInSandbox<C extends StateAwareContainmentBackend>(
correlationVector: options.correlationVector,
config: config as unknown as Record<string, unknown>,
});
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',
Expand Down Expand Up @@ -160,7 +164,7 @@ export async function execInSandboxAsync<C extends StateAwareContainmentBackend>
correlationVector: options.correlationVector,
config: config as unknown as Record<string, unknown>,
});
const { stdout, stderr, exitCode } = await spawnAndCollect(envelope, options);
const { stdout, stderr, exitCode } = await spawnAndCollect(envelope, options, backendKey);

if (exitCode !== 0) {
const errorEnvelope = tryParseErrorEnvelope(stdout);
Expand Down Expand Up @@ -189,7 +193,7 @@ export async function stopSandbox<C extends StateAwareContainmentBackend>(
correlationVector: options.correlationVector,
config: config as Record<string, unknown> | undefined,
});
return nonExecCall<StopResult<C>>(envelope, options);
return nonExecCall<StopResult<C>>(envelope, options, backendKey);
}

/**
Expand All @@ -209,5 +213,5 @@ export async function deprovisionSandbox<C extends StateAwareContainmentBackend>
correlationVector: options.correlationVector,
config: config as Record<string, unknown> | undefined,
});
return nonExecCall<DeprovisionResult<C>>(envelope, options);
return nonExecCall<DeprovisionResult<C>>(envelope, options, backendKey);
}
Loading