[Bubblewrap] Gate platform detection on a minimum bwrap version - #714
Conversation
Platform detection only checked that `bwrap --version` exited successfully, so a host with an old bubblewrap was reported as supported and then failed at spawn time with an opaque "unknown option" error. Derive the minimum supported version from the flags the argument builder actually emits. `--ro-bind-try` (deny-by-default baseline mounts) first shipped in bwrap 0.3.1 and `--clearenv` (minimal sandbox environment) in 0.5.0, so 0.5.0 is the floor. Add `bwrap_common::bwrap_version` with `MIN_BWRAP_VERSION`, a lenient version parser that tolerates distro-patched strings, and `probe_bwrap()` returning a `BwrapUnavailable` whose `Display` is the shared user-facing reason. Probing fails closed when the version cannot be parsed, since the required flags cannot be asserted without it. Both `mxc_engine::platform_support` and `BubblewrapScriptRunner::validate` now call the single probe instead of their own presence-only checks, so they cannot disagree. The e2e `has_bwrap()` helper reuses it as well, so characterization tests skip cleanly on an old-bwrap host rather than red-failing. The TypeScript SDK mirrors the gate, and its Linux `reason` now distinguishes missing from too-old. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a4718095-4782-4577-b0a4-bee42c846389 Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds minimum-version validation for Bubblewrap across Rust and Node.js platform detection.
Changes:
- Introduces a shared Rust Bubblewrap version probe requiring 0.5.0+.
- Mirrors version gating in the Node.js SDK and E2E checks.
- Updates tests, dependencies, and documentation.
Show a summary per file
| File | Description |
|---|---|
src/backends/bubblewrap/common/src/bwrap_version.rs |
Adds version parsing and availability errors. |
src/backends/bubblewrap/common/src/bwrap_runner.rs |
Validates Bubblewrap using the shared probe. |
src/backends/bubblewrap/common/src/lib.rs |
Exposes the version module. |
src/core/mxc_engine/src/platform.rs |
Applies version-aware platform detection. |
src/testing/wxc_e2e_tests/src/lib.rs |
Reuses the probe for test skipping. |
src/testing/wxc_e2e_tests/Cargo.toml |
Adds the Linux-only internal dependency. |
src/Cargo.lock |
Records the dependency update. |
sdk/node/src/platform.ts |
Adds SDK-side parsing and version gating. |
sdk/node/tests/unit/platform.test.ts |
Adds parser tests. |
docs/bwrap-support/bubblewrap-backend.md |
Documents the 0.5.0 minimum. |
Review details
Comments suppressed due to low confidence (2)
src/backends/bubblewrap/common/src/bwrap_version.rs:170
- A present but nonnumeric component is treated as zero, so output such as
bubblewrap 0.6.invalidparses as 0.6.0 and is accepted. This contradicts the fail-closed contract for unrecognized output; only genuinely absent components should default to zero.
let mut components = token.split('.').map(leading_number);
let major = components.next().flatten()?;
let minor = components.next().flatten().unwrap_or(0);
let patch = components.next().flatten().unwrap_or(0);
Some(BwrapVersion::new(major, minor, patch))
sdk/node/src/platform.ts:400
- The added SDK tests exercise only
_parseBwrapVersion; they never execute this minimum-version comparison or verify thatgetPlatformSupport()omits Bubblewrap below 0.5.0 and accepts it at the floor. Add an injectable Bubblewrap command runner and cover the below/exact/unparseable probe outcomes so the duplicated SDK gate cannot drift from the Rust gate unnoticed.
if (compareVersions(version, MIN_BWRAP_VERSION) < 0) {
return {
available: false,
reason: `Bubblewrap (bwrap) ${version.join('.')} is too old; version ${minVersion} or newer is required`,
};
- Files reviewed: 9/10 changed files
- Comments generated: 2
- Review effort level: Medium
Three points from review:
Fail closed on a present but non-numeric version component. Both parsers
defaulted every unreadable component to 0, so `bubblewrap 0.6.invalid` was
accepted as 0.6.0 — contradicting the documented fail-closed contract. Only a
genuinely *absent* component now defaults to 0; a component that is present but
has no leading digits rejects the whole parse.
Distinguish a missing `bwrap` from a broken one. A non-zero exit from
`bwrap --version` was mapped to `NotFound`, whose message tells the user to
install a package they already have, and the exit status and stderr were
discarded. Add `BwrapUnavailable::ProbeFailed { status, detail }`, reserve
`NotFound` for a spawn failure that is genuinely `ErrorKind::NotFound`, and
surface the real cause in the message.
Cover the SDK gate, not just its parser. The SDK tests only exercised
`_parseBwrapVersion`, so the duplicated minimum-version comparison could drift
from the Rust gate unnoticed. Add a `_setBwrapVersionRunner` test hook, matching
the existing `_setProbeRunner` / `_setWindowsBuildQuery` convention, and cover
below-floor, exactly-at-floor, above-floor, unparsable, missing, and
present-but-broken outcomes plus the `getPlatformSupport()` result on Linux.
The SDK probe now shells out via `execFileSync` rather than `execSync`, so a
missing binary surfaces as `ENOENT` instead of the shell's indistinguishable
exit code 127 — that separation is what makes the missing/broken distinction
possible on the TypeScript side.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a4718095-4782-4577-b0a4-bee42c846389
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (2)
sdk/node/src/platform.ts:398
- This parser likewise accepts any numeric token, so output such as
some other tool 1is treated as Bubblewrap 1.0.0 and the SDK marks the backend available. That contradicts the stated fail-closed behavior and can recreate the late spawn failure this gate is intended to prevent; bind the parsed token to the stablebubblewrappackage name.
export function _parseBwrapVersion(output: string): [number, number, number] | null {
const token = output.split(/\s+/).find((t) => /^\d/.test(t));
if (!token) return null;
const parts = token.split('.');
src/backends/bubblewrap/common/src/bwrap_version.rs:206
- The parser accepts the first numeric token even when the output is not a Bubblewrap version. For example,
some other tool 1becomes1.0.0, socheck_version_outputreports this unrecognized output as supported instead of failing closed. Require the version token to immediately follow the stablebubblewrappackage name.
let token = output
.split_whitespace()
.find(|token| token.starts_with(|c: char| c.is_ascii_digit()))?;
- Files reviewed: 9/10 changed files
- Comments generated: 0 new
- Review effort level: Medium
No behavior change; the probe's results and messages are identical. Drop `BwrapVersion::major`/`minor`/`patch` and the `Default` derive. The accessors had no callers anywhere in the repo — they were added reflexively to pair with the private fields, but `Display` and the derived `Ord` already cover every real use. A default `0.0.0` is meaningless for a type that exists to be compared against a floor. Make `check_version_output` and `parse_version` private. Neither had a caller outside the module, and the `#[cfg(test)]` child module reaches them through `use super::*` regardless. Replace the `next_component` helper with `map_or(Some(0), leading_number)` at both call sites, which keeps the absent-vs-unreadable distinction without a function and its lifetime parameter, and make `leading_number` allocation-free by slicing at the first non-digit instead of collecting a `String`. On the SDK side, rewrite `_parseBwrapVersion`'s index loop as a `for...of` over `slice(0, 3)` with `?? 0` for absent components, so the two parsing rules read directly, and drop a redundant `typeof e.stderr === 'string'` ternary — `String.prototype.toString()` returns the string itself, so the optional-chained call already handles both the `string` and `Buffer` cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a4718095-4782-4577-b0a4-bee42c846389 Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (6)
src/backends/bubblewrap/common/src/bwrap_version.rs:191
- This does not actually fail closed on unrecognized successful output: it accepts the first numeric token anywhere, so
check_version_output("some other tool 999")reports version 999.0.0 and marks the backend usable. Since the preceding comment says Bubblewrap'sPACKAGE_STRINGname has been stable since 0.1.0, require abubblewraptoken followed by the version before parsing it.
let token = output
.split_whitespace()
.find(|token| token.starts_with(|c: char| c.is_ascii_digit()))?;
src/backends/bubblewrap/common/src/bwrap_version.rs:144
execvealso returnsENOENT/ErrorKind::NotFoundwhen thebwrapfile exists but its shebang or ELF interpreter/dynamic loader is missing. This therefore still maps a broken present installation toNotFoundand tells the user to install a package, contrary to the newProbeFailedcontract; resolve/check the PATH candidate before deciding thatNotFoundmeans the executable itself is absent.
.map_err(|err| match err.kind() {
// Only a genuinely missing binary is "not installed"; anything else
// (permissions, loader errors) is a broken install, not a missing one.
std::io::ErrorKind::NotFound => BwrapUnavailable::NotFound,
_ => BwrapUnavailable::ProbeFailed {
sdk/node/src/platform.ts:448
- A synchronous child killed by a signal also has a null exit status, so this reason says it “could not be executed” even though it started and was terminated. Use neutral no-exit-status wording or include the error's
signalfield so this diagnostic remains accurate.
const where =
result.status === null ? 'could not be executed' : `exited with status ${result.status}`;
const detail = result.detail ? `: ${result.detail}` : '';
sdk/node/src/platform.ts:396
- The SDK parser likewise accepts any successful output containing a numeric token; for example,
something else 999is treated as a supported Bubblewrap 999.0.0 instead of taking the documented fail-closed path. Validate the stablebubblewrap <version>package-string shape rather than searching arbitrary output for a number.
This issue also appears on line 446 of the same file.
export function _parseBwrapVersion(output: string): [number, number, number] | null {
const token = output.split(/\s+/).find((t) => /^\d/.test(t));
if (!token) return null;
sdk/node/src/platform.ts:361
ENOENTfromexecFileSyncis not limited to a missingbwrappath: Unix also reports it when an existing script/ELF executable names a missing interpreter or dynamic loader. Such a broken present install is still classified asnotFound, producing the misleading install-package remediation; first determine whether abwrapPATH candidate exists before choosing this outcome.
} catch (err) {
const e = err as NodeJS.ErrnoException & { status?: number | null; stderr?: Buffer | string };
if (e.code === 'ENOENT') {
return { kind: 'notFound' };
src/backends/bubblewrap/common/src/bwrap_version.rs:103
- For a process terminated by a signal,
ExitStatus::code()is alsoNone, even thoughbwrapwas successfully executed. This branch then reports “could not be executed,” obscuring the actual probe failure; use wording that covers both spawn failures and signal termination, or preserve the terminating signal separately.
This issue also appears on line 189 of the same file.
match status {
Some(code) => write!(f, "exited with status {code}")?,
None => write!(f, "could not be executed")?,
}
- Files reviewed: 9/10 changed files
- Comments generated: 0 new
- Review effort level: Medium
The parser searched for the first whitespace-separated token starting with a
digit, so any numeric token in unrelated output was read as a version. That
defeated the fail-closed contract the probe documents:
`check_version_output("some other tool 999")` returned 999.0.0 and cleared the
minimum-version gate rather than rejecting the output.
Anchor on the `bubblewrap` package name instead. bwrap prints its autotools /
meson `PACKAGE_STRING` — "bubblewrap <version>" — and that leading name has been
stable since 0.1.0, so requiring it costs no real leniency while making
unrecognized output actually fail closed. The distro-patched forms (`0.4.1-1`,
`0.11.0+really0.10.0`, a bare `0.6`) are unaffected. Regression tests added on
both sides.
Also correct the no-exit-status wording. `ExitStatus::code()` is `None` both for
a spawn failure and for a process killed by a signal, so "could not be executed"
was wrong in the second case — it did run. Say "failed without an exit status",
which is accurate for both.
Found via suppressed low-confidence review comments on microsoft#714.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a4718095-4782-4577-b0a4-bee42c846389
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (4)
src/backends/bubblewrap/common/src/bwrap_version.rs:208
- The
+reallydistro form is interpreted backwards. In Debian-style versions,0.11.0+really0.10.0means the package actually contains upstream 0.10.0, but this parser keeps 0.11.0. Consequently a package such as0.5.0+really0.4.1is accepted even though its effective bwrap version predates--clearenv, defeating the minimum-version gate. Parse the version after+reallywhen that marker is present (and add a below-floor regression case).
let mut components = tokens.next()?.split('.');
let major = leading_number(components.next()?)?;
let minor = components.next().map_or(Some(0), leading_number)?;
let patch = components.next().map_or(Some(0), leading_number)?;
Some(BwrapVersion::new(major, minor, patch))
sdk/node/src/platform.ts:410
- This mirrors the Rust parser's incorrect handling of Debian
+reallyversions: the portion after+reallyis the effective upstream version, not a suffix to ignore. For example,0.5.0+really0.4.1currently becomes 0.5.0 and is accepted even though the installed code is 0.4.1 and lacks the required flag. Parse the post-+reallyversion and cover a case that crosses the 0.5.0 floor.
for (const part of tokens[1].split('.').slice(0, 3)) {
const digits = /^\d+/.exec(part);
// Present but non-numeric: fail closed rather than guessing 0.
if (!digits) return null;
components.push(parseInt(digits[0], 10));
}
sdk/node/src/platform.ts:361
ENOENTcan also mean that an existingbwrapcould not be executed because its ELF loader or script interpreter is missing; it is not exclusive to an absent PATH entry. Such a broken binary is still reported as “not installed” here, so the SDK does not actually preserve the missing-vs-broken distinction promised by this probe. Resolve/check the PATH candidate before classifying this asnotFound; an existing candidate should producefailed.
This issue also appears on line 405 of the same file.
if (e.code === 'ENOENT') {
return { kind: 'notFound' };
src/backends/bubblewrap/common/src/bwrap_version.rs:145
ErrorKind::NotFounddoes not prove that thebwrappath is absent: on Linux,execvealso returnsENOENTwhen an existing executable's ELF loader or script interpreter is missing. That broken installation is therefore still classified asNotFoundand gets the misleading package-install remediation, contrary to the newProbeFailedcontract. Check whether a PATH candidate exists before choosingNotFound, and mapENOENTfor an existing candidate toProbeFailed.
This issue also appears on line 204 of the same file.
std::io::ErrorKind::NotFound => BwrapUnavailable::NotFound,
- Files reviewed: 9/10 changed files
- Comments generated: 0 new
- Review effort level: Medium
…bsent Two ways the version gate could still be defeated or mislead. `X+reallyY` ships upstream Y, not X. Debian uses the marker when a maintainer must ship an older upstream without decreasing the package version, so the effective version is the one AFTER the marker. Both parsers read the leading version, so `bubblewrap 0.5.0+really0.4.1` was accepted as 0.5.0 even though the installed bwrap is 0.4.1 and has no `--clearenv` — precisely the case this gate exists to catch. The previous tests asserted the wrong direction (`0.11.0+really0.10.0` as 0.11.0); they now assert 0.10.0, and a below-floor regression covers the smuggling case. `ErrorKind::NotFound` does not prove the binary is absent. Linux returns `ENOENT` both for a missing file and for an executable whose ELF interpreter or script shebang target is missing, so a broken-but-installed bwrap was reported as `NotFound` and told the user to install a package they already have — contrary to the `ProbeFailed` contract. Check `PATH` for a `bwrap` candidate before choosing `NotFound`; an existing candidate now maps to `ProbeFailed`. Uses only `std::env::split_paths` / `path.delimiter`, so separator handling stays platform-correct. Found via suppressed low-confidence review comments on microsoft#714. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a4718095-4782-4577-b0a4-bee42c846389 Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (2)
src/backends/bubblewrap/common/src/bwrap_version.rs:217
- The parser never verifies that the component iterator is exhausted, so
bubblewrap 0.5.0.invalidis accepted as 0.5.0. That lets an unrecognized banner clear a gate that is intended to fail closed; reject any components beyond the supportedmajor.minor.patchshape.
let mut components = token.split('.');
let major = leading_number(components.next()?)?;
let minor = components.next().map_or(Some(0), leading_number)?;
let patch = components.next().map_or(Some(0), leading_number)?;
Some(BwrapVersion::new(major, minor, patch))
sdk/node/src/platform.ts:437
- Slicing to three components silently drops any remaining components, so
bubblewrap 0.5.0.invalidis accepted as 0.5.0 instead of following the fail-closed unrecognized-version path. Reject tokens with more than three components before parsing them.
for (const part of token.split('.').slice(0, 3)) {
- Files reviewed: 9/10 changed files
- Comments generated: 0 new
- Review effort level: Medium
Components after the patch were dropped unchecked, so `bubblewrap 0.5.0.invalid` parsed as 0.5.0 and cleared the minimum-version gate instead of taking the fail-closed unrecognized-version path. The rule "a component that is present but non-numeric rejects the parse" was only applied to the first three components; applying it to all of them is what actually makes the shape fail closed. Validating the extra components, rather than rejecting on component count, keeps a plausible distro four-part build such as `0.6.0.1` working — it parses as 0.6.0, since components past the patch are not significant. Found via a suppressed low-confidence review comment on microsoft#714. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a4718095-4782-4577-b0a4-bee42c846389 Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (1)
sdk/node/src/platform.ts:439
- The TypeScript parser accepts overflowing components that the Rust
u32parser rejects. For example, a sufficiently long major component becomesInfinity,_parseBwrapVersionreturns it, andcompareVersionsadmits the malformed banner as newer than 0.5.0, violating the intended fail-closed behavior and making the SDK disagree with the backend. Reject non-safe or out-of-u32values before storing them.
const digits = /^\d+/.exec(part);
// Present but non-numeric: fail closed rather than guessing 0.
if (!digits) return null;
components.push(parseInt(digits[0], 10));
- Files reviewed: 9/10 changed files
- Comments generated: 0 new
- Review effort level: Balanced
The two mirrored gates disagreed on overflow. Rust parses each component with `parse::<u32>()`, so an out-of-range component fails closed, but the SDK used `parseInt`, which yields a huge float — `compareVersions` then admitted a malformed banner as newer than 0.5.0. A host the backend rejects would have been reported as supported by the SDK, which is exactly the drift the duplicated-gate tests exist to catch. Bound the SDK parser at `u32::MAX` to mirror Rust, and lock the shared contract in on both sides with matching tests (over-range rejected, `4294967295` accepted). Found via a suppressed low-confidence review comment on microsoft#714. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a4718095-4782-4577-b0a4-bee42c846389 Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Addresses two review comments on microsoft#714. The bwrap unavailability reason was dropped whenever LXC was present: the `else if (methods.length === 0)` branch only ran when nothing else was available, so on an LXC host a missing or too-old bwrap disappeared silently with no way to diagnose it. The reason now always goes to the diagnostic channel via `diagLog`. It deliberately does not go into `PlatformSupport.reason` — that field is documented as why the platform is *not* supported, and with LXC present the platform is supported, so populating it would contradict an exported contract. `bwrap --version` had no timeout. `getPlatformSupport()` is synchronous, so a `bwrap` that hangs — a wrapper script on PATH, a binary on a stalled network mount — would block the caller indefinitely. Bound it at 5s (printing a version string is near-instant) and report a timeout as a distinct failure detail rather than as a bogus exit status. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a4718095-4782-4577-b0a4-bee42c846389 Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (4)
sdk/node/src/platform.ts:366
- This check does not mirror executable lookup for two ENOENT cases:
existsSyncfollows symlinks (so a danglingbwrapsymlink is classified as absent rather than broken), and an empty PATH component—which means the current directory on Linux—is skipped. UselstatSyncand treat an empty component as.so the missing/broken distinction is accurate.
function bwrapExistsOnPath(): boolean {
const pathVar = process.env.PATH;
if (!pathVar) return false;
return pathVar
.split(path.delimiter)
.some((dir) => dir !== '' && fs.existsSync(path.join(dir, 'bwrap')));
src/backends/bubblewrap/common/src/bwrap_version.rs:237
- The ENOENT disambiguation still misclassifies a broken symlink as an absent package.
Path::exists()follows symlinks, so a PATH entry such as/usr/bin/bwrap -> /missing/bwrapreturns false and becomesNotFoundeven though this is exactly a present-but-broken installation. Check the directory entry withsymlink_metadatainstead.
fn bwrap_exists_on_path() -> bool {
std::env::var_os("PATH")
.is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join("bwrap").exists()))
src/backends/bubblewrap/common/src/bwrap_version.rs:141
- This probe has no timeout and captures both pipes without a size bound. A hung or output-flooding
bwrapwrapper can therefore blockplatform_support()and every Bubblewrap validation indefinitely (or exhaust memory), whereas the SDK probe added here explicitly bounds the same synchronous operation to five seconds. Use a bounded child wait and cap/drain the version output before parsing it.
This issue also appears on line 235 of the same file.
let output = Command::new("bwrap")
.arg("--version")
.stdin(Stdio::null())
.output()
sdk/node/src/platform.ts:285
diagLogcannot surface this reason on Linux:diagnostic.tsshort-circuits all non-Windows calls. When LXC is installed but bwrap is too old/broken, the platform remains supported, and an explicitly selected Bubblewrap request is then rejected byhelper.tswith only the generic “not available” message. The detected version/probe failure is therefore still lost on the exact platform this branch handles; propagate a per-backend unavailability reason to that validation path instead.
This issue also appears on line 361 of the same file.
// Always surface why bwrap is unavailable. When LXC is present the
// platform is still supported, so `reason` — documented as why the
// platform is *not* supported — must stay unset, and the detail would
// otherwise be dropped with no way to diagnose the missing backend.
diagLog(`getPlatformSupport: bubblewrap unavailable — ${bubblewrap.reason}`);
- Files reviewed: 9/10 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Resolve the src/core/mxc_engine/src/platform.rs conflict by keeping both sides' intent: this branch's `wslc_available()` helper (which reports the opt-in WSLC backend in `available_methods`) alongside main's removal of `command_succeeds`, which microsoft#714 made dead when the Linux probe switched to `bwrap_version::probe_bwrap()` for the minimum-version gate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1cfac031-7051-4559-8c4e-9f76fa8605c2 Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
📖 Description
Bubblewrap platform detection only checked that
bwrap --versionexitedsuccessfully. Presence on PATH is not the same as usability: a host with an old
bubblewrapwas reported as supported, and the failure only surfaced laterat spawn time as an opaque
bwrap: unknown optionerror.This PR gates detection on a minimum version, derived from the flags
bwrap_command::build_args_classifiedactually emits (verified against thebubblewrap git history):
bwraprelease--bind,--ro-bind,--dev,--proc,--tmpfs,--symlink,--chdir,--setenv,--unshare-*--ro-bind-try(deny-by-default baseline mounts)--clearenv(minimal sandbox environment)--clearenvsets the floor, so bwrap 0.5.0 is the minimum. The docspreviously claimed
0.3.0+, which was both wrong for--ro-bind-try(0.3.1)and predated the
--clearenvrequirement.Changes
src/backends/bubblewrap/common/src/bwrap_version.rs:MIN_BWRAP_VERSIONwith a table documenting why each component is thefloor, so a future flag adoption has an obvious place to bump.
BwrapVersion(orderedmajor.minor.patch) and a parser that is:bubblewrappackage name, so unrecognized outputactually fails closed — a stray number in unrelated output (
some other tool 999) can't be read as a version and clear the gate;(
0.4.1-1, a bare0.6) resolve;0.6.invalidis rejected rather than silently read as0.6.0;+reallymarker, which names the version thepackage actually ships —
0.5.0+really0.4.1is 0.4.1, and is rejectedrather than admitted as 0.5.0;
0.5.0.invalidand anout-of-
u32component are rejected instead of being read as 0.5.0 /admitted as very new, while a numeric
0.6.0.1distro build still works.probe_bwrap()returningResult<BwrapVersion, BwrapUnavailable>, whereBwrapUnavailablerenders the user-facing reason viaDisplay— onemessage source for every caller. Its variants separate the four distinct
outcomes:
NotFound— spawn failed withio::ErrorKind::NotFound; the binary isgenuinely absent, so "install the package" is the right remediation.
ProbeFailed { status, detail }— the binary exists butbwrap --versionfailed (permissions, dynamic-loader problems, non-zero exit). Keeps the
exit status and stderr instead of discarding them, and does not tell
the user to install a package they already have.
UnrecognizedVersion— ran, but printed something unparsable. Failsclosed: without a version we cannot assert the required flags exist.
TooOld— names the detected version and the required floor.mxc_engine::platform_supportandBubblewrapScriptRunner::validateboth nowcall the single probe, replacing their two independent presence-only checks,
so the platform report and the runner can no longer disagree.
wxc_e2e_tests::has_bwrap()reuses the same probe, so the Bubblewrapcharacterization tests skip cleanly on a too-old host instead of red-failing
on an error the backend now rejects up front.
sdk/node/src/platform.tsmirrors the gate (MIN_BWRAP_VERSION,_parseBwrapVersion,_probeBubblewrap), including the missing/brokendistinction. The probe uses
execFileSyncrather thanexecSyncbecauserunning through a shell collapses a missing binary into an indistinguishable
exit code 127, whereas
execFileSyncsurfacesENOENTdirectly. The Linuxreasonnow says why rather than reporting a bare "not available".docs/bwrap-support/bubblewrap-backend.mdprerequisites corrected to 0.5.0.src/Cargo.lockchanges by exactly one line —wxc_e2e_testsgains theinternal workspace crate
bwrap_common(Linux-only target dependency). Nonew external dependency is introduced, and
dependency-feed-checkpasses.🔗 References
No existing issue — found while reviewing Bubblewrap host detection.
Minimum-version claims were verified directly against
containers/bubblewrap release tags
(
--clearenvadded by8f72ceb, first released inv0.5.0;--bind-tryfamily added by
d3515d8, first released inv0.3.1).mainhas since been merged in. That merge included a refactor ofbwrap_command.rs(proxy env keys moved into the sharedwxc_common::proxy_env), so the emitted flag set was re-checked afterwards —it is unchanged, and
--clearenvremains the binding constraint.🔍 Validation
Automated coverage added:
bwrap_version.rs— standard / distro-patched /short version parsing, rejection of non-version output, rejection of
present-but-non-numeric components, semantic ordering, acceptance at exactly
MIN_BWRAP_VERSION, rejection of0.4.1, fail-closed on unparsable output(including a stray number in unrelated output), rejection of trailing junk
and of components that overflow
u32,+reallyresolving to the shippedversion and not smuggling a below-floor bwrap past the gate, every error
message naming the required version, and the probe-failure message preserving
exit status and stderr (with and without a status).
sdk/node/tests/unit/platform.test.ts— the parsercases plus the minimum-version comparison itself, driven through a new
_setBwrapVersionRunnerhook (matching the existing_setProbeRunner/_setWindowsBuildQueryconvention): below-floor, exactly-at-floor,above-floor, unparsable, missing, and present-but-broken outcomes, and the
resulting
getPlatformSupport()methods on Linux. This keeps the duplicatedSDK gate from drifting from the Rust gate unnoticed.
Commands run locally (macOS host):
The 3 SDK failures are pre-existing and unrelated — Windows-only
availableToolspolicy assertions (pwsh.exe/PSReadLine/ system root)that cannot pass on a macOS host. Confirmed identical on a stashed baseline
before any change, and the
SDK Unit Tests (windows)CI lane passes.✅ Checklist
Cargo.lock, thedependency-feed-checkcheck passes (see docs/pull-requests.md)📋 Issue Type