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 @@ -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: 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 =
!!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
162 changes: 153 additions & 9 deletions sdk/node/src/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,19 @@ type WindowsBuild = { major: number; minor: number } | null;
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 };
// `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;
Expand All @@ -87,6 +90,16 @@ 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.
Expand Down Expand Up @@ -299,14 +312,38 @@ 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');
}
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;
}
Expand Down Expand Up @@ -542,9 +579,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,
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 @@ -48,7 +48,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 @@ -73,7 +73,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 @@ -96,7 +96,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 @@ -137,7 +141,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 @@ -167,7 +171,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 @@ -187,5 +191,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
Loading