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
9 changes: 8 additions & 1 deletion docs/bwrap-support/bubblewrap-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@ requiring root privileges or a container runtime.
environment is built with `--clearenv` (bwrap 0.5.0+), so **bwrap 0.5.0 or
newer** is required. Platform detection probes `bwrap --version` and reports
the backend as unavailable — with the detected version — when the host is
below that floor.
below that floor. The probe has a 5-second deadline and retains at most 64 KB
from each output stream. On timeout, its process group is terminated with
`SIGKILL` so wrappers and descendants cannot keep the probe alive. Successful
Rust-executor advisory results are cached for the process lifetime; Rust
failures are not cached, and execution validation probes again before launch
so a changed PATH target cannot reuse an advisory result. The Node SDK caches
the complete `getPlatformSupport()` result, including failures, for the module
lifetime; restart the Node process after remediating the host.
- User namespaces must be enabled:
```bash
# Check: should print "1"
Expand Down
4 changes: 2 additions & 2 deletions sdk/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ The default `processcontainer`, `bubblewrap`, `lxc`, and `seatbelt` backends wor

> **Hyperlight** is an opt-in build flavor (Linux x64 and Windows x64) gated by the `--with-hyperlight` cargo feature. Default shipped binaries do not include it; build from source with `build.bat --with-hyperlight` (Windows) or the equivalent cargo invocation on Linux.

`getPlatformSupport()` reports backend availability and, when the native probe can determine it, `uiCapabilities`: a platform-neutral view of which UI restrictions the host can enforce. This is currently populated only by the Windows native probe, where it is derived from `JOB_OBJECT_UILIMIT_*` support; Linux and macOS omit the field until their probes expose equivalent data.
`getPlatformSupport()` reports backend availability and, when the native probe can determine it, `uiCapabilities`: a platform-neutral view of which UI restrictions the host can enforce. This is currently populated only by the Windows native probe, where it is derived from `JOB_OBJECT_UILIMIT_*` support; Linux and macOS omit the field until their probes expose equivalent data. On Linux, `unavailableReasons` provides a diagnostic for each unavailable LXC or Bubblewrap backend even when the other backend keeps the platform supported.

**Node.js:** ≥ 18.

Expand Down 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, neither LXC nor a usable Bubblewrap 0.5.0+ installation is available. On macOS, the Seatbelt platform probe could not find `/usr/bin/sandbox-exec`. | Inspect `support.reason`. On Linux, also inspect `support.unavailableReasons` and install LXC or Bubblewrap 0.5.0+. On macOS, verify that `/usr/bin/sandbox-exec` exists; its absence indicates an incomplete or unsupported macOS installation. |
| `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
93 changes: 93 additions & 0 deletions sdk/node/src/bwrap-probe-anchor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { spawn } from 'node:child_process';

// Strip NODE_OPTIONS so a host's loader/inspector/require flags cannot
// interfere with the helper's minimal, trusted `bwrap --version` probe.
const spawnEnv = { ...process.env };
delete spawnEnv.NODE_OPTIONS;

const helperPath = process.argv[2];
const timeoutMs = Number(process.argv[3]);
const outputLimit = process.argv[4];
let resultWritten = false;
let resultFlushed = false;
let shuttingDown = false;
let helperClosed = false;
let output = '';
const hold = setInterval(() => {}, 0x3fffffff);

function terminateHelper(): void {
if (shuttingDown) return;
shuttingDown = true;
const pid = helperProcess.pid;
if (pid && !helperClosed) {
try {
process.kill(pid, 'SIGKILL');
} catch {
// The helper may already have exited.
}
}
}

function terminateOwnedGroup(): void {
try {
process.kill(-process.pid, 'SIGKILL');
} catch {
process.exit(1);
}
}

function finishIfReady(): void {
if (!helperClosed || !resultFlushed) return;
clearTimeout(watchdog);
clearInterval(hold);
// The anchor is still the unreaped group leader. The helper has already
// been reaped, so terminating the owned group cannot target a recycled ID.
terminateOwnedGroup();
}

const watchdog = setTimeout(() => {
terminateHelper();
setTimeout(terminateOwnedGroup, 500).unref();
}, timeoutMs + 1000);
watchdog.unref();

process.on('SIGTERM', () => {
terminateHelper();
});

function emitFailure(detail: string): void {
if (resultWritten) return;
resultWritten = true;
process.stdout.write(`${JSON.stringify({ kind: 'spawnError', detail })}\n`, () => {
resultFlushed = true;
finishIfReady();
});
}

const helperProcess = spawn(
process.execPath,
[helperPath, String(timeoutMs), outputLimit],
{ detached: false, stdio: ['ignore', 'pipe', 'ignore'], env: spawnEnv },
);
helperProcess.stdout.setEncoding('utf8');
helperProcess.stdout.on('data', (chunk: string) => {
output += chunk;
const newline = output.indexOf('\n');
if (!resultWritten && newline !== -1) {
resultWritten = true;
process.stdout.write(output.slice(0, newline + 1), () => {
resultFlushed = true;
finishIfReady();
});
terminateHelper();
}
});
helperProcess.on('error', (error) => emitFailure(error.message));
helperProcess.on('close', () => {
helperClosed = true;
emitFailure('probe helper exited without a result');
finishIfReady();
});
113 changes: 113 additions & 0 deletions sdk/node/src/bwrap-probe-helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { spawn, ChildProcessByStdio } from 'node:child_process';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { Readable } from 'node:stream';

type HelperResult =
| { kind: 'completed'; status: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string }
| { kind: 'notFound' }
| { kind: 'timeout' }
| { kind: 'overflow' }
| { kind: 'spawnError'; detail: string };

const timeoutMs = Number(process.argv[2]);
const outputLimit = Number(process.argv[3]);
let child: ChildProcessByStdio<null, Readable, Readable> | undefined;
let finished = false;
let classifyingSpawnError = false;
let timer: NodeJS.Timeout | undefined;
let stdoutLength = 0;
let stderrLength = 0;
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
const hold = setInterval(() => {}, 0x3fffffff);

setTimeout(() => {
if (child) {
try {
child.kill('SIGKILL');
} catch {
// The child may already have exited.
}
}
process.exit(1);
}, timeoutMs + 1000).unref();

function capture(chunks: Buffer[], chunk: Buffer, currentLength: number): number {
const remaining = Math.max(0, outputLimit - currentLength);
if (remaining > 0) chunks.push(chunk.subarray(0, remaining));
return currentLength + chunk.length;
}

function emit(result: HelperResult): void {
if (finished) return;
finished = true;
if (timer) clearTimeout(timer);
process.stdout.write(`${JSON.stringify(result)}\n`);
}

async function handleSpawnError(error: NodeJS.ErrnoException): Promise<void> {
if (error.code !== 'ENOENT') {
emit({ kind: 'spawnError', detail: error.message });
return;
}
for (const entry of (process.env.PATH ?? '').split(path.delimiter)) {
const candidate = path.join(entry, 'bwrap');
try {
if ((await fs.stat(candidate)).isFile()) {
emit({
kind: 'spawnError',
detail: `${candidate} was found but could not be executed; check for a missing interpreter or loader`,
});
return;
}
} catch (statError) {
const error = statError as NodeJS.ErrnoException;
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') {
emit({ kind: 'spawnError', detail: `failed to inspect ${candidate}: ${error.message}` });
return;
}
}
}
emit({ kind: 'notFound' });
}

try {
child = spawn('bwrap', ['--version'], {
detached: false,
stdio: ['ignore', 'pipe', 'pipe'],
});
} catch (error) {
void handleSpawnError(error as NodeJS.ErrnoException);
}

if (child) {
child.stdout.on('data', (chunk: Buffer) => {
stdoutLength = capture(stdoutChunks, chunk, stdoutLength);
if (stdoutLength > outputLimit) emit({ kind: 'overflow' });
});
child.stderr.on('data', (chunk: Buffer) => {
stderrLength = capture(stderrChunks, chunk, stderrLength);
if (stderrLength > outputLimit) emit({ kind: 'overflow' });
});
child.on('error', (error: NodeJS.ErrnoException) => {
classifyingSpawnError = true;
void handleSpawnError(error);
});
child.on('close', (status, signal) => {
if (classifyingSpawnError) return;
emit({
kind: 'completed',
status,
signal,
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
stderr: Buffer.concat(stderrChunks).toString('utf8'),
});
});
timer = setTimeout(() => emit({ kind: 'timeout' }), timeoutMs);
}

void hold;
135 changes: 135 additions & 0 deletions sdk/node/src/bwrap-probe-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { spawn, ChildProcessByStdio } from 'node:child_process';
import { Readable } from 'node:stream';
import { workerData } from 'node:worker_threads';

// Strip NODE_OPTIONS so a host's loader/inspector/require flags cannot
// interfere with the anchor's minimal, trusted probe supervision.
const spawnEnv = { ...process.env };
delete spawnEnv.NODE_OPTIONS;

interface ProbeWorkerData {
shared: SharedArrayBuffer;
anchorPath: string;
helperPath: string;
probeTimeoutMs: number;
publishTimeoutMs: number;
outputLimit: number;
}

const data = workerData as ProbeWorkerData;
const header = new Int32Array(data.shared, 0, 3);
const payload = new Uint8Array(data.shared, 12);
let anchor: ChildProcessByStdio<null, Readable, Readable> | undefined;
let output = '';
let finished = false;
let anchorExited = false;
let anchorClosed = false;
let completionRequested = false;
let pendingResult: unknown;
let timeout: NodeJS.Timeout | undefined;

function stopAnchor(): void {
const pid = anchor?.pid;
if (!pid || anchorExited) return;
try {
process.kill(pid, 'SIGTERM');
} catch {
// The process may have exited between the ownership check and the signal.
}
}

function publish(result: unknown): void {
if (finished) return;
finished = true;
if (timeout) clearTimeout(timeout);
let encoded = Buffer.from(JSON.stringify(result));
if (encoded.length > payload.length) {
encoded = Buffer.from(JSON.stringify({
kind: 'spawnError',
detail: 'probe helper result exceeded its bound',
}));
}
payload.set(encoded);
Atomics.store(header, 1, encoded.length);
if (Atomics.compareExchange(header, 0, 0, 1) === 0) {
Atomics.notify(header, 0);
}
}

function completeAfterCleanup(result: unknown, stop = false): void {
if (finished || completionRequested) return;
completionRequested = true;
pendingResult = result;
if (stop) stopAnchor();
if (anchorClosed) publish(pendingResult);
}

if (Atomics.load(header, 0) === 0) {
try {
const spawnedAnchor = spawn(
process.execPath,
[
data.anchorPath,
data.helperPath,
String(data.probeTimeoutMs),
String(data.outputLimit),
],
{ detached: true, stdio: ['ignore', 'pipe', 'pipe'], env: spawnEnv },
);
anchor = spawnedAnchor;
const anchorPid = spawnedAnchor.pid;
if (anchorPid === undefined) {
completeAfterCleanup({
kind: 'spawnError',
detail: 'probe anchor did not receive a process id',
});
} else {
Atomics.store(header, 2, anchorPid);
}
if (Atomics.load(header, 0) !== 0) {
stopAnchor();
}
spawnedAnchor.stdout.setEncoding('utf8');
spawnedAnchor.stdout.on('data', (chunk: string) => {
Comment on lines +95 to +96
output += chunk;
const newline = output.indexOf('\n');
if (newline !== -1) {
try {
completeAfterCleanup(JSON.parse(output.slice(0, newline)));
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
completeAfterCleanup(
{ kind: 'spawnError', detail: `invalid probe helper result: ${detail}` },
true,
);
}
}
});
spawnedAnchor.on('error', (error) => {
completeAfterCleanup({ kind: 'spawnError', detail: error.message }, true);
});
spawnedAnchor.on('exit', () => {
anchorExited = true;
});
spawnedAnchor.on('close', () => {
anchorClosed = true;
publish(
completionRequested
? pendingResult
: { kind: 'spawnError', detail: 'probe helper exited without a result' },
);
});
// Ask the anchor to stop inside the caller's budget. Publication waits for
// close so the result cannot escape before group teardown and reaping.
timeout = setTimeout(
() => completeAfterCleanup({ kind: 'timeout' }, true),
data.publishTimeoutMs,
);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
publish({ kind: 'spawnError', detail });
}
}
3 changes: 3 additions & 0 deletions sdk/node/src/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,11 @@ export function resolveExecutableAndArgs(
effectiveContainment as ContainmentBackend
);
if (!isIntent && !isExperimental && !isAvailable) {
const unavailableReason =
platformSupport.unavailableReasons?.[effectiveContainment as ContainmentBackend];
throw new Error(
`Containment backend '${rawContainment}' is not available on this platform. ` +
(unavailableReason ? `${unavailableReason} ` : '') +
`Available methods: ${platformSupport.availableMethods.join(', ')}`
);
}
Expand Down
2 changes: 2 additions & 0 deletions sdk/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
* `processContainer.learningMode: true` to enable deny-and-record learning
* mode. Learning-mode capability names are reserved and must not be supplied
* directly in `processContainer.capabilities`.
* On Linux, `getPlatformSupport()` reports failures for individual backends
* through `PlatformSupport.unavailableReasons`, including when none is usable.
*
* @example
* ```typescript
Expand Down
Loading
Loading