Skip to content

Commit b82cd9c

Browse files
SohamDas2021CopilotCopilothuzaifa-d
authored
[Bubblewrap] Make Bubblewrap the default Linux backend (#368)
* Make Bubblewrap the silent default Linux process-sandbox backen * Addressed PR comments * Update ContainmentType JSDoc to reflect bubblewrap as Linux default * Make proxy tests explicit about containment to fix macOS CI Six proxy tests omitted containment and relied on the previous unconditional ProcessContainer default to satisfy the proxy guard (proxy is only valid with processcontainer). After this PR the default resolves to Seatbelt on macOS, causing those six tests to fail in the arm64 MAC CI job. Make each test explicit by setting containment: "processcontainer" in the JSON — they are testing proxy parsing behavior, not the OS-default resolution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix MicroVM workflow cache permissions Agent-Logs-Url: https://github.com/microsoft/mxc/sessions/81dc5e19-9e89-4a1a-a005-6633f4ffbfe8 Co-authored-by: huzaifa-d <16077119+huzaifa-d@users.noreply.github.com> * Install bubblewrap in Linux SDK integration CI job PR 368 introduces SDK integration tests that exercise the Bubblewrap backend (the new Linux default for `containment: "process"` / omitted). Those tests skip unless `bwrap` is installed on the runner; the Linux integration job previously only installed `lxc`, so the new bwrap tests failed under sudo. Add `bubblewrap` to the apt-get install line alongside `lxc lxc-utils dnsmasq-base iptables` so both backends are present and both test suites can run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Addressed PR comments * More fixes to LXC * Comment clarification --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: huzaifa-d <16077119+huzaifa-d@users.noreply.github.com>
1 parent a2c2538 commit b82cd9c

14 files changed

Lines changed: 522 additions & 87 deletions

File tree

.azure-pipelines/templates/SDK.Integration.Test.Job.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,8 @@ jobs:
8585
- ${{ if eq(target.os, 'linux') }}:
8686
- script: |
8787
sudo apt-get update -qq
88-
sudo apt-get install -y -qq lxc lxc-utils dnsmasq-base iptables
89-
displayName: Install LXC
88+
sudo apt-get install -y -qq lxc lxc-utils dnsmasq-base iptables bubblewrap
89+
displayName: Install LXC and Bubblewrap
9090
9191
- script: sudo MXC_SKIP_LXC_NETWORK_TESTS=1 MXC_DEBUG=${{ parameters.debug }} npm test
9292
workingDirectory: $(integrationDirectory)

.github/workflows/microvm-e2e.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ on:
88
workflow_dispatch:
99

1010
permissions:
11+
actions: write
1112
contents: read
1213

1314
jobs:

sdk/src/sandbox.ts

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,27 @@ function buildWslcContainerConfig(
8585
return config;
8686
}
8787

88+
/**
89+
* Applies iptables-based network enforcement to `config.network`:
90+
* when host filtering is requested, force `enforcementMode = 'firewall'`
91+
* so the Linux runner actually applies iptables rules. Without this,
92+
* `network_enforcement_mode` falls back to the parser default of
93+
* `Capabilities` and the runner silently skips iptables, dropping
94+
* `allowedHosts` / `blockedHosts` on the floor.
95+
*
96+
* Shared between the explicit `'bubblewrap'` / `'lxc'` builders and the
97+
* abstract `'process'` branch on Linux (which resolves to Bubblewrap
98+
* server-side) so the wire-format `network` block is identical regardless
99+
* of which intent the caller used.
100+
*/
101+
function applyIptablesNetworkEnforcement(config: ContainerConfig): void {
102+
if (config.network) {
103+
if (config.network.allowedHosts?.length || config.network.blockedHosts?.length) {
104+
config.network.enforcementMode = 'firewall';
105+
}
106+
}
107+
}
108+
88109
/**
89110
* Builds the Bubblewrap (bwrap) portion of a ContainerConfig.
90111
* Bubblewrap is Linux-only and uses shared cross-backend fields only —
@@ -95,14 +116,7 @@ function buildBubblewrapConfig(
95116
config: ContainerConfig,
96117
): ContainerConfig {
97118
config.containment = 'bubblewrap';
98-
99-
// Network enforcement: use firewall when host filtering is needed
100-
if (config.network) {
101-
if (config.network.allowedHosts?.length || config.network.blockedHosts?.length) {
102-
config.network.enforcementMode = 'firewall';
103-
}
104-
}
105-
119+
applyIptablesNetworkEnforcement(config);
106120
return config;
107121
}
108122

@@ -119,7 +133,7 @@ function buildLinuxProcessConfig(
119133
release: '3.23',
120134
destroyOnExit: true,
121135
};
122-
136+
applyIptablesNetworkEnforcement(config);
123137
return config;
124138
}
125139

@@ -297,10 +311,18 @@ export function createConfigFromPolicy(
297311

298312
// WSLC supports block + allowedHosts via iptables (Bridged networking
299313
// with per-host filtering). macOS sandbox supports it natively via
300-
// per-host Seatbelt rules. Bubblewrap supports it via iptables.
314+
// per-host Seatbelt rules. Bubblewrap and LXC support it via iptables.
315+
// Abstract `'process'` on Linux resolves to Bubblewrap server-side, so
316+
// treat it the same as explicit `'bubblewrap'` here.
301317
// Other backends require allowOutbound for host filtering since it
302318
// maps to AppContainer capabilities.
303-
if (containment !== 'wslc' && containment !== 'seatbelt' && containment !== 'bubblewrap') {
319+
const resolvesToHostFilteringBackend =
320+
containment === 'wslc' ||
321+
containment === 'seatbelt' ||
322+
containment === 'bubblewrap' ||
323+
containment === 'lxc' ||
324+
(containment === 'process' && platform === 'linux');
325+
if (!resolvesToHostFilteringBackend) {
304326
if ((policy.network.allowedHosts?.length || policy.network.blockedHosts?.length) && !policy.network.allowOutbound) {
305327
throw new Error('allowedHosts/blockedHosts require allowOutbound to be true');
306328
}
@@ -328,11 +350,26 @@ export function createConfigFromPolicy(
328350
return buildBubblewrapConfig(config);
329351
}
330352

353+
if (containment === 'lxc') {
354+
diagLog(`createConfigFromPolicy: containment=lxc, id=${containerId}`);
355+
config.containment = 'lxc';
356+
return buildLinuxProcessConfig(config, containerId);
357+
}
358+
331359
if (containment === 'process') {
332360
config.containment = 'process';
333361
if (platform === 'linux') {
334-
diagLog(`createConfigFromPolicy: containment=lxc, id=${containerId}`);
335-
return buildLinuxProcessConfig(config, containerId);
362+
// Abstract `'process'` on Linux is resolved to Bubblewrap by the
363+
// native binary (see `wxc_common::config_parser`). The wire-format
364+
// payload intentionally omits any backend-specific block so the
365+
// config reflects the abstract intent. Callers who explicitly want
366+
// LXC must pass `containment: 'lxc'`.
367+
//
368+
// Network enforcement still needs the same iptables firewall mode
369+
// as explicit `'bubblewrap'` when host filtering is in play.
370+
applyIptablesNetworkEnforcement(config);
371+
diagLog(`createConfigFromPolicy: containment=process (linux, resolves to bubblewrap), id=${containerId}`);
372+
return config;
336373
}
337374
if (platform === 'darwin') {
338375
// The seatbelt backend has no container abstraction

sdk/src/types.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,20 +31,17 @@ export interface LifecycleConfig {
3131
preservePolicy?: boolean;
3232
}
3333

34-
/**
35-
* Abstract containment intent. Names the *kind* of isolation the caller
36-
* wants. The native binary (or the SDK as a fallback) resolves this to a
37-
* concrete {@link ContainmentBackend} at run time based on what the host
38-
* supports.
39-
*
4034
/**
4135
* Abstract containment intent. Names the *kind* of isolation the caller
4236
* wants; the native binary resolves it to a concrete
4337
* {@link ContainmentBackend} per host capability.
4438
*
4539
* Today's intents:
4640
* - "process": OS-native process-level isolation. Resolves to
47-
* `processcontainer` (Windows), `lxc` (Linux), or `seatbelt` (macOS).
41+
* `processcontainer` (Windows), `bubblewrap` (Linux), or `seatbelt`
42+
* (macOS). On Linux, `lxc` remains available as an explicit concrete
43+
* backend but is no longer the default for the abstract `"process"`
44+
* intent.
4845
* - "vm": full hardware-virtualised VM isolation. Resolves to
4946
* `windows_sandbox` on Windows; no concrete VM backend exists on other
5047
* platforms today.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { describe, it } from 'node:test';
5+
import assert from 'node:assert';
6+
import {
7+
sdk,
8+
supportedVersions,
9+
isLinuxRoot,
10+
debugSpawnOptions,
11+
spawnFromConfigAsync,
12+
} from './test-helpers.js';
13+
14+
// Bwrap fingerprint: when invoked with `--unshare-pid`, bubblewrap creates a
15+
// new PID namespace and stays as PID 1 in that namespace, acting as init
16+
// (reaping orphans, forwarding signals). It does NOT exec the child shell
17+
// directly — the script runs as PID 2. So /proc/1/comm always reads "bwrap"
18+
// from inside the sandbox, regardless of how bwrap is invoked or which user
19+
// runs it. This is documented bubblewrap behavior (see bwrap(1)) and the
20+
// most reliable cross-context signal — mount-count heuristics break under
21+
// WSL2 where bind-mount propagation can produce 40+ entries.
22+
const BWRAP_PROBE =
23+
"PID1=$(cat /proc/1/comm 2>/dev/null || echo unknown); " +
24+
"MOUNTS=$(wc -l </proc/self/mountinfo); " +
25+
"echo \"pid1=$PID1 mountinfo_lines=$MOUNTS\"; " +
26+
"[ \"$PID1\" = \"bwrap\" ] || { echo \"FAIL: not under bubblewrap (pid1=$PID1)\"; exit 1; }; " +
27+
"echo 'OK: under bubblewrap (pid1=bwrap)'";
28+
29+
for (const schemaVersion of supportedVersions) {
30+
describe(`Linux Bubblewrap (schema ${schemaVersion})`, {
31+
skip: !isLinuxRoot ? 'Linux Bubblewrap tests require Linux with root privileges (sudo npm test)' : undefined,
32+
}, () => {
33+
it('should default to Bubblewrap when containment is omitted (silent default)', async () => {
34+
// spawnSandboxAsync routes through abstract `containment: 'process'`,
35+
// which on Linux resolves to Bubblewrap in the binary. No --experimental
36+
// flag is required for this path.
37+
const result = await sdk.spawnSandboxAsync(
38+
BWRAP_PROBE,
39+
{ version: schemaVersion.raw },
40+
debugSpawnOptions,
41+
undefined,
42+
`bwrap-default-${schemaVersion}`,
43+
);
44+
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] silent-default Bubblewrap probe failed: ${result.stdout}`);
45+
assert.ok(result.stdout.includes('OK: under bubblewrap'), `[${schemaVersion}] ${result.stdout}`);
46+
});
47+
48+
it('should select Bubblewrap for abstract containment="process"', async () => {
49+
const config = sdk.createConfigFromPolicy(
50+
{ version: schemaVersion.raw },
51+
'process',
52+
`bwrap-process-${schemaVersion}`,
53+
);
54+
config.process!.commandLine = BWRAP_PROBE;
55+
assert.strictEqual(config.containment, 'process', 'wire-format containment should be "process"');
56+
const result = await spawnFromConfigAsync(config, debugSpawnOptions);
57+
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] containment=process Bubblewrap probe failed: ${result.stdout}`);
58+
assert.ok(result.stdout.includes('OK: under bubblewrap'), `[${schemaVersion}] ${result.stdout}`);
59+
});
60+
61+
it('should select Bubblewrap for explicit containment="bubblewrap" with experimental flag', async () => {
62+
// Explicit "bubblewrap" still requires `experimental: true` per the SDK
63+
// gate in helper.ts (`ExperimentalBackends`).
64+
const config = sdk.createConfigFromPolicy(
65+
{ version: schemaVersion.raw },
66+
'bubblewrap',
67+
`bwrap-explicit-${schemaVersion}`,
68+
);
69+
config.process!.commandLine = BWRAP_PROBE;
70+
assert.strictEqual(config.containment, 'bubblewrap', 'wire-format containment should be "bubblewrap"');
71+
const result = await spawnFromConfigAsync(config, { ...debugSpawnOptions, experimental: true });
72+
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] explicit Bubblewrap probe failed: ${result.stdout}`);
73+
assert.ok(result.stdout.includes('OK: under bubblewrap'), `[${schemaVersion}] ${result.stdout}`);
74+
});
75+
});
76+
}

sdk/tests/integration/linux-process-container.test.ts

Lines changed: 49 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import assert from 'node:assert';
66
import fs from 'fs';
77
import os from 'os';
88
import path from 'path';
9+
import type { SandboxPolicy } from '@microsoft/mxc-sdk';
910
import {
1011
sdk,
1112
supportedVersions,
@@ -14,11 +15,26 @@ import {
1415
NETWORK_TEST_URL,
1516
lxcNetworkSkipReason,
1617
debugSpawnOptions,
18+
spawnFromConfigAsync,
1719
} from './test-helpers.js';
1820

21+
// Route through explicit `containment: 'lxc'` so these tests genuinely exercise
22+
// the LXC backend. spawnSandboxAsync internally routes through abstract
23+
// `containment: 'process'`, which on Linux resolves to a different backend
24+
// (Bubblewrap). The LXC backend is covered by an explicit opt-in only.
25+
async function runLxc(
26+
script: string,
27+
policy: SandboxPolicy,
28+
containerId: string,
29+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
30+
const config = sdk.createConfigFromPolicy(policy, 'lxc', containerId);
31+
config.process!.commandLine = script;
32+
return spawnFromConfigAsync(config, debugSpawnOptions);
33+
}
34+
1935
for (const schemaVersion of supportedVersions) {
20-
describe(`Linux Process Container (schema ${schemaVersion})`, {
21-
skip: !isLinuxRoot ? 'Linux Process Container tests require Linux with root privileges (sudo npm test)' : undefined,
36+
describe(`Linux LXC Container (schema ${schemaVersion})`, {
37+
skip: !isLinuxRoot ? 'Linux LXC Container tests require Linux with root privileges (sudo npm test)' : undefined,
2238
}, () => {
2339
let tempDir = '';
2440

@@ -30,48 +46,59 @@ describe(`Linux Process Container (schema ${schemaVersion})`, {
3046
});
3147

3248
it('should execute hello world in LXC container', async () => {
33-
const result = await sdk.spawnSandboxAsync(
49+
const result = await runLxc(
3450
"echo 'Hello from LXC via CLI'",
3551
{ version: schemaVersion.raw },
36-
debugSpawnOptions,
37-
undefined,
3852
`lxc-hello-${schemaVersion}`,
3953
);
4054
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`);
4155
assert.ok(result.stdout.includes('Hello from LXC via CLI'));
4256
});
4357

4458
it('should propagate exit code', async () => {
45-
const result = await sdk.spawnSandboxAsync(
59+
const result = await runLxc(
4660
"echo 'about to exit' && exit 0",
4761
{ version: schemaVersion.raw },
48-
debugSpawnOptions,
49-
undefined,
5062
`lxc-exit-${schemaVersion}`,
5163
);
5264
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`);
5365
assert.ok(result.stdout.includes('about to exit'));
5466
});
5567

5668
it('should report system info', async () => {
57-
const result = await sdk.spawnSandboxAsync(
69+
const result = await runLxc(
5870
"uname -a && echo 'System info test passed'",
5971
{ version: schemaVersion.raw },
60-
debugSpawnOptions,
61-
undefined,
6272
`lxc-sysinfo-${schemaVersion}`,
6373
);
6474
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`);
6575
assert.ok(result.stdout.includes('System info test passed'));
6676
});
6777

78+
it('should select LXC backend (not Bubblewrap) on explicit containment="lxc"', async () => {
79+
// Probe PID 1 to distinguish backends:
80+
// bwrap -> PID 1 comm == 'bwrap' (bwrap re-execs itself as init in the new pid namespace)
81+
// LXC -> PID 1 comm is 'init' / 'systemd' (the container's init)
82+
// We assert NOT bwrap by checking PID 1 is not 'bwrap'.
83+
const probe =
84+
"PID1=$(cat /proc/1/comm); " +
85+
"echo \"pid1=$PID1\"; " +
86+
"[ \"$PID1\" != bwrap ] || { echo 'FAIL: PID 1 is bwrap, looks like Bubblewrap, not LXC'; exit 1; }; " +
87+
"echo 'OK: not under Bubblewrap'";
88+
const result = await runLxc(
89+
probe,
90+
{ version: schemaVersion.raw },
91+
`lxc-probe-${schemaVersion}`,
92+
);
93+
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] LXC backend probe failed: ${result.stdout}`);
94+
assert.ok(result.stdout.includes('OK: not under Bubblewrap'), `[${schemaVersion}] ${result.stdout}`);
95+
});
96+
6897
it('should allow outbound network access', { skip: lxcNetworkSkipReason }, async () => {
6998
const policy = { version: schemaVersion.raw, network: { allowOutbound: true } };
70-
const result = await sdk.spawnSandboxAsync(
99+
const result = await runLxc(
71100
`wget -q -T 10 -O /dev/null '${NETWORK_TEST_URL}' && echo 'Network accessible'`,
72101
policy,
73-
debugSpawnOptions,
74-
undefined,
75102
`lxc-net-${schemaVersion}`,
76103
);
77104
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`);
@@ -83,9 +110,7 @@ describe(`Linux Process Container (schema ${schemaVersion})`, {
83110
fs.writeFileSync(path.join(tempDir, 'test.txt'), 'original');
84111
const policy = { version: schemaVersion.raw, filesystem: { readwritePaths: [tempDir] } };
85112
const script = `cat ${tempDir}/test.txt && echo 'overwritten' > ${tempDir}/test.txt && cat ${tempDir}/test.txt`;
86-
const result = await sdk.spawnSandboxAsync(
87-
script, policy, debugSpawnOptions, undefined, `lxc-rw-${schemaVersion}`,
88-
);
113+
const result = await runLxc(script, policy, `lxc-rw-${schemaVersion}`);
89114
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`);
90115
assert.ok(result.stdout.includes('overwritten'));
91116
});
@@ -94,11 +119,9 @@ describe(`Linux Process Container (schema ${schemaVersion})`, {
94119
tempDir = createTempDir('mxc-lxc-test');
95120
fs.writeFileSync(path.join(tempDir, 'data.txt'), 'readonly content');
96121
const policy = { version: schemaVersion.raw, filesystem: { readonlyPaths: [tempDir] } };
97-
const result = await sdk.spawnSandboxAsync(
122+
const result = await runLxc(
98123
`cat ${tempDir}/data.txt && echo 'Read succeeded'`,
99124
policy,
100-
debugSpawnOptions,
101-
undefined,
102125
`lxc-ro-${schemaVersion}`,
103126
);
104127
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`);
@@ -115,20 +138,16 @@ describe(`Linux Process Container (schema ${schemaVersion})`, {
115138
const script =
116139
`wget -q -T 10 -O ${tempDir}/download.json '${NETWORK_TEST_URL}'` +
117140
` && test -s ${tempDir}/download.json && echo 'Combined test passed'`;
118-
const result = await sdk.spawnSandboxAsync(
119-
script, policy, debugSpawnOptions, undefined, `lxc-combined-${schemaVersion}`,
120-
);
141+
const result = await runLxc(script, policy, `lxc-combined-${schemaVersion}`);
121142
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`);
122143
assert.ok(result.stdout.includes('Combined test passed'));
123144
});
124145

125146
it('should access HTTPS endpoint', { skip: lxcNetworkSkipReason }, async () => {
126147
const policy = { version: schemaVersion.raw, network: { allowOutbound: true } };
127-
const result = await sdk.spawnSandboxAsync(
148+
const result = await runLxc(
128149
`wget -q -T 10 -O /dev/null '${NETWORK_TEST_URL}' && echo 'HTTPS endpoint accessible'`,
129150
policy,
130-
debugSpawnOptions,
131-
undefined,
132151
`lxc-https-${schemaVersion}`,
133152
);
134153
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`);
@@ -137,8 +156,10 @@ describe(`Linux Process Container (schema ${schemaVersion})`, {
137156

138157
it('should run multi-command pipeline', async () => {
139158
const script = "echo 'step 1' && ls / && echo 'step 2' && whoami && echo 'Multi-command passed'";
140-
const result = await sdk.spawnSandboxAsync(
141-
script, { version: schemaVersion.raw }, debugSpawnOptions, undefined, `lxc-pipeline-${schemaVersion}`,
159+
const result = await runLxc(
160+
script,
161+
{ version: schemaVersion.raw },
162+
`lxc-pipeline-${schemaVersion}`,
142163
);
143164
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`);
144165
assert.ok(result.stdout.includes('Multi-command passed'));

0 commit comments

Comments
 (0)