diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4f9651c58..db706bbcf 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -22,6 +22,7 @@ build.bat # Release build for current architecture build.bat --debug # Debug build build.bat --all # Release build for both x64 and ARM64 build.bat --with-microvm # Include NanVix micro-VM binaries +build.bat --with-wslc # Include the WSLc backend (adds `--features wslc`; builds wxc-wslc-daemon.exe and stages wslcsdk.dll). Not on by default — a plain build.bat produces a wxc-exec.exe that rejects `"containment": "wslc"` with `WSLC backend not compiled`. ``` ### Full build (Linux) @@ -145,8 +146,8 @@ tests\scripts\run_test_configs.ps1 # All test configs via wxc_test_dr tests\scripts\run_basicprocess_test.ps1 # Single process container test tests\scripts\run_isolation_session_tests.ps1 # IsolationSession one-shot E2E (requires host with the OS-side IsoSessionOps service) tests\scripts\run_isolation_session_state_aware_tests.ps1 # IsolationSession state-aware lifecycle E2E (multi-invocation provision/start/exec/stop/deprovision, same host requirements) -tests\scripts\run_wslc_all_tests.ps1 # All WSLc one-shot config tests (Windows, requires a WSL2 host + wslcsdk.dll; skips if absent) -tests\scripts\run_wslc_state_aware_tests.ps1 # WSLc state-aware lifecycle E2E (multi-invocation provision/start/exec/stop/deprovision + warm-reuse + idle-teardown; requires a WSL2 host + staged wxc-wslc-daemon.exe; skips if absent) +tests\scripts\run_wslc_all_tests.ps1 # SINGLE ENTRY POINT for all WSLc E2E: runs the one-shot configs, the five per-scenario scripts, then delegates to run_wslc_state_aware_tests.ps1 (passing -SkipSetup) and folds its exit code into the summary. Windows, requires a WSL2 host + wslcsdk.dll; skips if absent. Pre-pulls images via scripts\setup-wslc.ps1 unless -SkipSetup. +tests\scripts\run_wslc_state_aware_tests.ps1 # WSLc state-aware lifecycle E2E (multi-invocation provision/start/exec/stop/deprovision + warm-reuse + idle-teardown; requires a WSL2 host + staged wxc-wslc-daemon.exe next to wxc-exec.exe; skips if absent). Already run by run_wslc_all_tests.ps1 — invoke directly only to iterate on the lifecycle suite alone. tests\scripts\run_windows_sandbox_one_shot_tests.ps1 # Windows Sandbox one-shot E2E (fresh disposable VM per test; requires the Windows Sandbox optional feature) tests\scripts\run_windows_sandbox_state_aware_tests.ps1 # Windows Sandbox state-aware lifecycle E2E (provision/start/exec*/stop/deprovision; requires the Windows Sandbox optional feature; skips if absent) tests\scripts\run_lxc_all_tests.sh # All LXC tests (Linux) @@ -185,7 +186,7 @@ The Rust workspace (`src/`) implements multiple sandboxing backends behind the ` | MicroVM (NanVix) | `wxc-exec.exe` | Windows | `backends/nanvix/runner/src/lib.rs` — feature-gated behind `microvm` | | Hyperlight | `wxc-exec.exe` | Windows | `backends/hyperlight/common/src/lib.rs` — Hyperlight + Unikraft micro-VM backend | | IsolationSession | `wxc-exec.exe` | Windows | `backends/isolation_session/common/src/` — feature-gated behind `isolation_session`, experimental, uses the in-proc `Windows.AI.IsolationSession.Preview` `IsoSessionOps` API. Supports both one-shot (single-invocation lifecycle, via `ScriptRunner`) and state-aware (multi-invocation provision/start/exec/stop/deprovision, via `StatefulSandboxBackend`) modes. Rejects all filesystem policy (`readwritePaths`/`readonlyPaths`/`deniedPaths`) at every phase with `policy_validation` — the backend has no host-folder-sharing primitive. Likewise rejects any supplied `ui` policy at every phase on both surfaces (as `policy_validation` on the state-aware surface; one-shot discards the typed variant and surfaces `backend_error` with the reason in the message): the isolation session isolates the *host's* UI from contained code but does not deny it UI capabilities (window creation, GDI and the session's own clipboard all work inside it), so no `ui` posture is truthful here — there is no value combination that could be accepted instead, which is why there is no acknowledgment-style gate as there is for `network`. The check is presence-based via `ContainerPolicy::ui_specified` (twin of `network_specified`) because `UiPolicy`'s defaults are full lockdown, making an explicit lockdown `ui` indistinguishable by value from an absent one. An omitted `ui` is accepted and applies no restriction — the schema's default-deny reading does not hold on this backend. One-shot additionally rejects `lifecycle.destroyOnExit=false` and `lifecycle.preservePolicy=true` — the in-proc API has no session-lifetime knob, and the default `destroyOnExit=true` matches actual behavior so it is accepted; the state-aware parser already rejects the whole `lifecycle` section. The full per-phase honor matrix for both surfaces is in `docs/isolation-session/state-aware-rust.md`. The container's network is unrestricted (outbound open; a process inside can listen on a localhost-reachable port) and MXC has no primitive to filter or deny it, so provision (and one-shot) accept ONLY the canonical unrestricted-network acknowledgment — `network.defaultPolicy=allow` + `network.allowLocalNetwork=true`, no host rules, no proxy, default enforcement — and refuse anything else (including an absent policy, which defaults to the unenforceable deny) with `policy_validation`; post-provision phases reject any supplied network policy (fixed at provision, tracked via `ExecutionRequest.network_specified`) and inherit an absent one. State-aware provision accepts an optional `appId` (a packaged app must pass its Package Family Name in the `PFN:` format, e.g. `PFN:Contoso.App_8wekyb3d8bbwe`; an unpackaged app may pass any string), carried verbatim inside the returned `sandboxId`; the one-shot surface takes no backend configuration at all (a stray `experimental.isolation_session` payload is accepted and ignored). Streams stdout/stderr, forwards stdin, and switches to ConPTY mode when wxc-exec's stdout is a TTY for `spawnSandbox` parity. | -| WSLc | `wxc-exec.exe` | Windows | `backends/wslc/common/src/` — feature-gated behind `wslc`, experimental, uses the WSLc SDK (`wslcsdk.dll`, loaded at runtime) to run Linux containers in a WSL2 VM. Supports both one-shot (`WSLContainerRunner`, via `ScriptRunner` + streaming `SandboxBackend`) and state-aware (`state_aware.rs` `WslcStateAwareRunner`, via `StatefulSandboxBackend`) modes. Because the WSLc SDK has **no cross-process re-attach**, state-aware keeps the session (VM) + container warm across separate `wxc-exec` phase processes behind a persistent per-user daemon (`wxc-wslc-daemon.exe`, `backends/wslc/daemon/`) that owns the live `WslcSession`/`WslcContainer` handles; phase processes are thin named-pipe clients (`daemon_client.rs`). The daemon runs all SDK calls on one apartment-affine worker thread (so exec is currently serialized across sandboxes — see `docs/wsl/wslc-state-aware.md`). Honors `readwritePaths`/`readonlyPaths` at provision (→ container volumes) + `network.defaultPolicy` (`Block`→`None`, `Allow`→`Bridged`; networking is all-or-nothing — no per-host filtering, since the container lacks `CAP_NET_ADMIN`); rejects `deniedPaths` nested under a mount and rejects proxy/host-filtering at provision. exec honors `network.proxy` **url-form only** (injected as `HTTP_PROXY`/`HTTPS_PROXY`); start/stop/deprovision reject all policy. Rejects, on **both** surfaces and every phase, the fields it cannot honor: any supplied `ui` (presence-based via `ContainerPolicy::ui_specified`, since the backend has no mechanism to enforce UI restrictions on a container — an omitted `ui` is accepted and applies no restriction, so the schema's default-deny reading does not hold here) and `network.enforcementMode` other than `capabilities` (no `CAP_NET_ADMIN` for in-container rules). Provision/one-shot additionally reject `network.allowLocalNetwork=true` (all-or-nothing networking; the one-shot message points at `experimental.wslc.portMappings`, which the state-aware surface does not have), and one-shot rejects `lifecycle.preservePolicy=true` and `lifecycle.destroyOnExit=false` (the container is session-scoped and the session dies with the one-shot process, so `false` cannot be honored; only the default `true` matches actual behavior). Every rejection aborts before any container is created. ID prefix `wslc` (`wslc:<32-hex>`). Idle-timeout is env-overridable via `MXC_WSLC_DAEMON_IDLE_TIMEOUT_SECS`/`MXC_WSLC_DAEMON_IDLE_POLL_SECS`. See `docs/wsl/wslc-state-aware.md`. | +| WSLc | `wxc-exec.exe` | Windows | `backends/wslc/common/src/` — feature-gated behind `wslc`, configured through the **top-level `wslc` section** (promoted off `experimental`; a config that still nests it under `experimental.wslc` is rejected with a migration message), uses the WSLc SDK (`wslcsdk.dll`, loaded at runtime) to run Linux containers in a WSL2 VM. Supports both one-shot (`WSLContainerRunner`, via `ScriptRunner` + streaming `SandboxBackend`) and state-aware (`state_aware.rs` `WslcStateAwareRunner`, via `StatefulSandboxBackend`) modes. Because the WSLc SDK has **no cross-process re-attach**, state-aware keeps the session (VM) + container warm across separate `wxc-exec` phase processes behind a persistent per-user daemon (`wxc-wslc-daemon.exe`, `backends/wslc/daemon/`) that owns the live `WslcSession`/`WslcContainer` handles; phase processes are thin named-pipe clients (`daemon_client.rs`). The daemon runs all SDK calls on one apartment-affine worker thread (so exec is currently serialized across sandboxes — see `docs/wsl/wslc-state-aware.md`). Honors `readwritePaths`/`readonlyPaths` at provision (→ container volumes) + `network.defaultPolicy` (`Block`→`None`, `Allow`→`Bridged`; networking is all-or-nothing — no per-host filtering, since the container lacks `CAP_NET_ADMIN`); rejects `deniedPaths` nested under a mount and rejects proxy/host-filtering at provision. exec honors `network.proxy` **url-form only** (injected as `HTTP_PROXY`/`HTTPS_PROXY`); start/stop/deprovision reject all policy. Rejects, on **both** surfaces and every phase, the fields it cannot honor: any supplied `ui` (presence-based via `ContainerPolicy::ui_specified`, since the backend has no mechanism to enforce UI restrictions on a container — an omitted `ui` is accepted and applies no restriction, so the schema's default-deny reading does not hold here) and `network.enforcementMode` other than `capabilities` (no `CAP_NET_ADMIN` for in-container rules). Provision/one-shot additionally reject `network.allowLocalNetwork=true` (all-or-nothing networking; the one-shot message points at `wslc.portMappings`, which the state-aware surface does not have), and one-shot rejects `lifecycle.preservePolicy=true` and `lifecycle.destroyOnExit=false` (the container is session-scoped and the session dies with the one-shot process, so `false` cannot be honored; only the default `true` matches actual behavior). Every rejection aborts before any container is created. ID prefix `wslc` (`wslc:<32-hex>`). Idle-timeout is env-overridable via `MXC_WSLC_DAEMON_IDLE_TIMEOUT_SECS`/`MXC_WSLC_DAEMON_IDLE_POLL_SECS`. See `docs/wsl/wslc-state-aware.md`. | | LXC | `lxc-exec` | Linux | `core/lxc/src/main.rs` + `backends/lxc/common/` | | Seatbelt | `mxc-exec-mac` | macOS | `core/mxc_darwin/src/main.rs` + `backends/seatbelt/common/` — uses macOS App Sandbox (Seatbelt) profiles for process containment. Requires schema `0.7.0-alpha`+. Supports `network.proxy` via the same cooperative env-var model as Bubblewrap (injects `HTTP_PROXY`/`HTTPS_PROXY` into the sandbox, reusing `wxc_common::unix_proxy_coordinator`; `builtinTestServer` spawns the shared `unix-test-proxy`). Also declares the schema-0.8 directional `NetworkPolicySupport` capability flags (`EGRESS_DEFAULT \| INGRESS_DEFAULT \| HOST_LOOPBACK \| RUNTIME_PROXY`, no `EGRESS_RULES`/`PROXY_PEER_IDENTITY`): `network.egress.default`/`network.ingress.default`/`runtimeConfig.networkProxy` map onto the same profile rules as the legacy `defaultPolicy`/`allowLocalNetwork`/`network.proxy` fields (`profile_builder.rs` consults `network_egress`/`network_ingress` when populated, falling back to the legacy fields otherwise — see `docs/sandbox-policy/0.8.0/networking/networking.md`). `network.ingress.hostLoopback` is enforced on the container-to-host direction: `deny` emits `(deny network-outbound (remote ip "localhost:*"))` after the broad outbound rule (Seatbelt takes the *last* matching rule, and SBPL's `localhost` covers the host's non-loopback addresses too, closing the LAN-IP path), while `allow` under a deny egress default emits the matching allow, with the same breadth in reverse — it opens every address bound to this host, not just `127.0.0.1`. Neither case reaches another machine: SBPL's `localhost` means *this machine*, not *this network*, and there is no narrower primitive (a literal `127.0.0.1` is a profile syntax error); the port-scoped proxy allow is always emitted last so it survives. The inbound half is not expressible, so `validate()` still rejects a `hostLoopback` that diverges from `network.ingress.default`. An omitted `hostLoopback` is `deny`, not an inherit of `default` ([#1032](https://github.com/microsoft/mxc/issues/1032)). Every Seatbelt-specific network invariant (proxy vs. `enforcementMode`, remote proxy under deny, proxy combined with an allow default, `allowedHosts` under deny, `blockedHosts`, and the `hostLoopback`/`ingress.default` match) lives in `backends/seatbelt/common/src/seatbelt_policy.rs` and is enforced from the backend's own `validate`, which both the run-to-completion and streaming paths call — `wxc_common::config_parser` holds no Seatbelt rules. See `docs/seatbelt/seatbelt-backend.md`. | | Bubblewrap | `lxc-exec` | Linux | `backends/bubblewrap/common/src/bwrap_runner.rs` — unprivileged sandboxing via Linux user namespaces and `bwrap`. Experimental — requires `--experimental`. Uses shared filesystem/network policy fields; per-host network filtering via `NetworkIptablesManager` from `backends/lxc/common`. For schema 0.8+, proxy mode uses a private network namespace with rootless `slirp4netns` routing and a default-DROP egress chain that permits only loopback and the translated proxy endpoint; `network.enforcementMode: "firewall"` with host lists takes the same private-namespace path and filters by IP/CIDR instead. Both also install a default-deny `MXC_INGRESS` chain on `INPUT` (accepting `-i lo` and `ESTABLISHED,RELATED`), whose posture comes from the directional `network.ingress` section at 0.8+ (`ingress.default`) or from `network.allowLocalNetwork` on the legacy shape; `ingress.default: "allow"` and `ingress.hostLoopback: "allow"` are both refused, as slirp offers no route in. `ingress.hostLoopback` is bidirectional, so its deny also drops egress to slirp's gateway `10.0.2.2` (the host's own loopback), lowered ahead of every caller rule so a broad allow cannot reopen it; proxy mode needs no such rule since it opens only the proxy endpoint. Rules are installed from a supervisor that holds the namespaces, via `nsenter` + `iptables-restore`/`ip6tables-restore` split into byte-budgeted numbered payloads (one restore is one bounded netlink transaction, so a large host list would otherwise exceed it and install nothing); both built-in hooks ride in the final transaction, so a hook is never live over a half-built chain. Both modes require `slirp4netns`, util-linux `unshare`/`nsenter`, `iptables`/`ip6tables`, `iptables-restore`/`ip6tables-restore` on PATH, and fail validation if any is unavailable. The `nf_conntrack` module must also be loaded for the ingress chain's connection-state match, but it is *not* probed at validation time: unprivileged bwrap cannot `modprobe`, and a missing module instead fails the `iptables-restore` transaction at launch, which rolls back and aborts the supervisor before the workload runs (fail-closed, not silently unenforced). `iptables`/`ip6tables` must also resolve to the `nf_tables` backend, unless `/run/xtables.lock` is writable by the calling user: the legacy backend opens that lock unconditionally, and the rules are installed by an unprivileged same-uid supervisor that cannot open a root-owned one — `validate` refuses such a host rather than letting the supervisor die at the first rule. The host proxy endpoint is rewritten to slirp's gateway `10.0.2.2`, so `127.0.0.1`/`0.0.0.0`/`::` are translated while `::1` is rejected (an IPv6-loopback listener cannot accept the gateway's IPv4 connection). Schema 0.6/0.7 and absent-version requests retain the legacy shared-network proxy behavior. See `docs/bwrap-support/bubblewrap-backend.md`. | @@ -296,9 +297,9 @@ The workspace is organized into six top-level directories under `src/`: onto the calling process's stdio and allocates a pty on IsolationSession; no other entry point allocates one. Streaming supports Seatbelt (macOS), Bubblewrap (Linux), Windows ProcessContainer (AppContainer + BaseContainer), - and WSLC (Windows, experimental — needs the crate's `wslc` feature plus - `SandboxRequest::set_experimental(true)`; no stdin and `id() == 0`, since the - WSLC SDK exposes neither); other backends return + and WSLC (Windows — needs the crate's `wslc` feature; no experimental opt-in, + and no stdin with `id() == 0`, since the WSLC SDK exposes neither); other + backends return `ErrorCode::UnsupportedContainment`. - The lower-level execution surface lives in `wxc_common::sandbox_process`: the `SandboxBackend` trait (`validate` + `spawn(request, logger, StdioMode) -> Box` + a `diagnose_exit` hook) and the generic `Runner` adapter that bridges any `SandboxBackend` to the run-to-completion `ScriptRunner` (via `spawn(StdioMode::Inherit)` then `wait()`). `SandboxProcess::output_metadata()` carries backend-produced structured outputs after terminal teardown without writing to process-global stdio. `StdioMode::Pipes` hands the caller live stdin/stdout/stderr (what the `mxc-sdk` streaming path uses); `StdioMode::Inherit` lets the child inherit the host's stdio (what the executor binaries use, preserving the TTY under a pty). `SandboxBackend` is implemented for Seatbelt, Bubblewrap, Windows ProcessContainer, and WSLC (on `wslc_common::WSLContainerRunner` itself, which shares one container lifecycle — `start_container` — between its streaming `SandboxBackend` and run-to-completion `ScriptRunner` impls, differing only in where the WSLC SDK's output callbacks write). - `mxc_ffi` (`ffi/mxc_ffi`, `crate-type = ["cdylib", "staticlib", "lib"]`) is a flat, panic-safe **C ABI over `mxc-sdk`** for language bindings. `mxc_run(policyJson, command, out)` runs a sandbox to completion, filling a `#[repr(C)] MxcRunResult` (status + exit_code + timed_out, owned stdout/stderr/output-metadata C strings, and an `MxcErrorDetail` carrying the failure message plus the failing API call and its platform status); every entry point is `catch_unwind`-wrapped so a panic becomes a status code, never an unwind. Its `build.rs` runs **csbindgen** to generate the C# P/Invoke (`sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeMethods.g.cs`), gated behind the crate's **`dotnetsdk`** feature (off by default, so the whole-workspace backend build matrix doesn't compile csbindgen). The generated file is **not committed** (gitignored); the C# csproj regenerates it at build time and `scripts/check-dotnet-bindings-codegen.js` runs the codegen in CI and asserts the expected entry points are produced. The C ABI is **not a stable external contract** (native + binding are co-versioned and generated together; see the crate docs). It exposes three surfaces: **run-to-completion** (`mxc_run`), **streaming** (`mxc_spawn` → opaque `MxcSandbox` handle; `mxc_stream_read`/`write`/`flush`, `mxc_sandbox_take_stdin`/`stdout`/`stderr`, `mxc_sandbox_id`/`try_wait`/`wait`/`kill`/`output_metadata_json`/`free`, in `src/streaming.rs`), and the **state-aware lifecycle** (`mxc_state_aware` for the envelope phases, `mxc_state_aware_exec` returning a live streaming handle, and `mxc_state_aware_exec_attached` relaying onto this process's stdio and returning an outcome, in `src/state_aware.rs`). All four `.rs` files are csbindgen inputs in `build.rs` (the shared `MxcErrorDetail` lives in `src/error_detail.rs`); the `MXC_STATUS_*` space already reserves the state-aware phase codes. diff --git a/README.md b/README.md index 22d913db7..562ee5682 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ MXC ships a native container wrapper plus a TypeScript SDK — see the [SDK READ | macOS ARM64 / x64 (schema `0.7.0-alpha`+) | `seatbelt` | — | — | -The stable one-shot backends (`processcontainer`, `bubblewrap`, `lxc`, and `seatbelt`) do not require experimental mode; Linux hosts also need the matching runtime installed: bwrap (Bubblewrap) for the default backend, or the lxc toolset for the lxc backend. **Experimental backends** (`windows_sandbox`, `wslc`, `microvm`, `isolation_session`, `hyperlight`) require `{ experimental: true }` in `SandboxSpawnOptions` or the `--experimental` CLI flag. +The stable one-shot backends (`processcontainer`, `bubblewrap`, `lxc`, `wslc`, and `seatbelt`) do not require experimental mode; Linux hosts also need the matching runtime installed: bwrap (Bubblewrap) for the default backend, or the lxc toolset for the lxc backend. `wslc` additionally needs a build with the `wslc` Cargo feature (`build.bat --with-wslc`) and a WSL2 host. **Experimental backends** (`windows_sandbox`, `microvm`, `isolation_session`, `hyperlight`) require `{ experimental: true }` in `SandboxSpawnOptions` or the `--experimental` CLI flag. For which filesystem, network, and UI-restriction policy aspects the Windows `processcontainer` backend can enforce on each Windows 11 release (23H2 / 24H2 / 25H2 / 25H2+), see [Windows OS-version policy support](./docs/process-container/os-version-support.md). diff --git a/docs/linux-wsl-roadmap-june-2026.md b/docs/linux-wsl-roadmap-june-2026.md index 0e572a7b1..829436758 100644 --- a/docs/linux-wsl-roadmap-june-2026.md +++ b/docs/linux-wsl-roadmap-june-2026.md @@ -306,13 +306,11 @@ File:line citations reference paths under `src/backends//...` and `src/ > > ```json > { -> "experimental": { -> "wslc": { -> "image": "python:3.12", -> "portMappings": [ -> { "windowsPort": 3000, "containerPort": 3000, "protocol": "tcp" } -> ] -> } +> "wslc": { +> "image": "python:3.12", +> "portMappings": [ +> { "windowsPort": 3000, "containerPort": 3000, "protocol": "tcp" } +> ] > } > } > ``` @@ -552,5 +550,5 @@ Item **LXC Network #24** (nftables backend) is gated on a real user signal — s ## Notes - **Issue tracking**: [open issues](https://github.com/microsoft/mxc/issues?q=is%3Aissue+is%3Aopen). None of the above are filed yet. -- **Promotion path**: Bubblewrap and WSLC are both still under `experimental` in the schema; see `docs/versioning.md` for the migration mechanics required for each promotion. +- **Promotion path**: WSLC has been promoted to the stable surface — its settings live in the top-level `wslc` block and no longer require `--experimental`. Bubblewrap is still under `experimental` in the schema; see `docs/versioning.md` for the migration mechanics required for its promotion. - **Labels**: re-use `Container-WSLC` and `Area-Executor-LXC`; propose adding `Container-Bubblewrap` (Bwrap #35). diff --git a/docs/schema.md b/docs/schema.md index 248af03b3..894c8a3b5 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -190,6 +190,18 @@ cannot mix both formats in one request. "release": "3.19" }, + "wslc": { // WSL Container settings (Windows only) + "image": "alpine:latest", // Container image name + "imageTarPath": "C:\\images\\alpine.tar", // Import image from local tar file + "cpuCount": 4, // CPU count for WSLC session + "memoryMb": 2048, // Memory in MB for WSLC session + "gpu": false, // GPU passthrough + "storagePath": "C:\\wslc-storage", // Image store path + "portMappings": [ // Host<->container port forwarding. TCP only -- the WSLC SDK runtime returns E_NOTIMPL for UDP, so the parser hard-rejects "udp" entries with a clear message. + { "windowsPort": 8080, "containerPort": 80, "protocol": "tcp" } + ] + }, + "seatbelt": { // macOS Seatbelt settings (macOS only) "profileOverride": null, // Optional raw TinyScheme profile (escape hatch) "guiAccess": false, // Allow GUI Mach services / IOKit / pty for window-drawing apps @@ -200,17 +212,6 @@ cannot mix both formats in one request. }, "experimental": { // Experimental features (requires --experimental) - "wslc": { // WSL Container settings - "image": "alpine:latest", // Container image name - "imageTarPath": "C:\\images\\alpine.tar", // Import image from local tar file - "cpuCount": 4, // CPU count for WSLC session - "memoryMb": 2048, // Memory in MB for WSLC session - "gpu": false, // GPU passthrough - "storagePath": "C:\\wslc-storage", // Image store path - "portMappings": [ // Host<->container port forwarding. TCP only -- the WSLC SDK runtime returns E_NOTIMPL for UDP, so the parser hard-rejects "udp" entries with a clear message. - { "windowsPort": 8080, "containerPort": 80, "protocol": "tcp" } - ] - }, "telemetry": { // Telemetry (Windows only) "enabled": true // Emit TraceLogging ETW events via pure Rust tracelogging crate } diff --git a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md index c424efde4..cd30328e4 100644 --- a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md +++ b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md @@ -497,7 +497,7 @@ to learn the cross-cutting flags. Phase-specific fields on `SandboxSpawnOptions` (`ptyOptions`, `usePty`) are honored by `execInSandbox` / `execInSandboxAsync` and silently ignored on the other phases. State-awareness is not itself experimental — `experimental: true` must be set when the targeted backend is itself experimental, just -as it is today for one-shot calls against `microvm` and `wslc`. IsolationSession is +as it is today for one-shot calls against `microvm`. IsolationSession is experimental at the time of writing; that status is independent of the state-aware API surface (§13). @@ -706,8 +706,9 @@ Configs (§6.1), not on this wire-shape type. Raw-JSON callers writing `ExperimentalStateAwareConfigs` directly are validated by the Rust parser and `validate_` hooks at runtime (§10.1). -For one-shot calls (phase absent), `experimental.` directly holds the backend's -one-shot config object (e.g., `experimental.wslc?: WslcConfig`), as documented in +For one-shot calls (phase absent), an experimental backend's one-shot config object sits +directly under `experimental.`; a backend on the stable surface uses its +own top-level key instead (e.g. `wslc?: WslcConfig`). Both are documented in `docs/schema.md`. The TypeScript types make this distinction structural: `OneShotRequest.experimental` and `StateAwareRequest.experimental` have different shapes. diff --git a/docs/wsl/wsl-container-getting-started.md b/docs/wsl/wsl-container-getting-started.md index 304ba0696..131636d95 100644 --- a/docs/wsl/wsl-container-getting-started.md +++ b/docs/wsl/wsl-container-getting-started.md @@ -3,9 +3,10 @@ This guide walks you through setting up the WSL Container (WSLC) backend for MXC, which lets you run Linux containers on Windows using the WSLC SDK. -> **Note:** WSLC is an **experimental** feature. It requires the `--experimental` -> CLI flag, `{ experimental: true }` in TypeScript SDK spawn options, or -> `SandboxRequest::set_experimental(true)` in the Rust SDK. +> **Note:** WSLC is a stable backend on the top-level `wslc` config block — it no +> longer needs `--experimental`, `{ experimental: true }`, or +> `SandboxRequest::set_experimental(true)`. It still requires a build with the +> `wslc` Cargo feature and a host that meets the prerequisites below. ## Prerequisites @@ -92,7 +93,7 @@ cost once per image, not once per run. > **Storage path consistency:** the cache lives under the WSLC > `storage_path` (default `%TEMP%\mxc-wslc-sessions`). If your runtime -> configs override `experimental.wslc.storagePath`, pass the same +> configs override `wslc.storagePath`, pass the same > value here with `-StoragePath` (or `--storage-path` on > `wxc-exec.exe`), otherwise the runner will not find what you just > pulled. @@ -108,7 +109,7 @@ Run the included hello world example config from the repo root: ```powershell cd -.\src\target\x86_64-pc-windows-msvc\release\wxc-exec.exe --experimental --debug examples\wslc_hello_world.json +.\src\target\x86_64-pc-windows-msvc\release\wxc-exec.exe --debug examples\wslc_hello_world.json ``` Expected output: @@ -128,7 +129,7 @@ Once setup is done, the day-to-day flow is two distinct commands: # (any number of times) execute against the cached image .\src\target\x86_64-pc-windows-msvc\release\wxc-exec.exe ` - --experimental my-config.json + my-config.json ``` This separation keeps `wxc-exec.exe` hermetic and fast at run time — @@ -146,21 +147,21 @@ fields before spawning: import { createConfigFromPolicy, spawnSandboxFromConfig } from '@microsoft/mxc-sdk'; const policy = { - version: '0.6.0-alpha', + version: '0.9.0-alpha', network: { allowOutbound: true }, }; const config = createConfigFromPolicy(policy, 'wslc'); config.process!.commandLine = 'python3 -c "print(\'Hello from WSLC\')"'; -config.experimental!.wslc!.image = 'python:3.12-alpine'; -config.experimental!.wslc!.cpuCount = 2; -config.experimental!.wslc!.memoryMb = 1024; +config.wslc!.image = 'python:3.12-alpine'; +config.wslc!.cpuCount = 2; +config.wslc!.memoryMb = 1024; // PTY mode (interactive terminal): -const ptyProcess = spawnSandboxFromConfig(config, { experimental: true }); +const ptyProcess = spawnSandboxFromConfig(config); // Non-PTY mode (reliable exit codes, separate stdout/stderr): -const child = spawnSandboxFromConfig(config, { experimental: true, usePty: false }); +const child = spawnSandboxFromConfig(config, { usePty: false }); child.stdout?.on('data', (data) => console.log(data.toString())); child.on('close', (code) => console.log('Exit code:', code)); ``` @@ -168,9 +169,8 @@ child.on('close', (code) => console.log('Exit code:', code)); ### Rust SDK The Rust SDK (`mxc-sdk`) runs WSLC **in-process** — it does not spawn -`wxc-exec.exe`. Build the crate with its `wslc` feature, select the backend with -`build_request_with_containment`, and opt into experimental features on the -request (the library-side equivalent of `--experimental`): +`wxc-exec.exe`. Build the crate with its `wslc` feature and select the backend with +`build_request_with_containment` — no experimental opt-in is needed: ```toml # Cargo.toml @@ -199,9 +199,7 @@ let wslc = WslcSection { }; let mut request = build_request_with_containment(&policy, &Containment::Wslc(wslc), None)?; -request - .set_script("python3 -c \"print('Hello from WSLC')\"") - .set_experimental(true); +request.set_script("python3 -c \"print('Hello from WSLC')\""); // Run to completion, capturing output… let output = run(request.clone())?; @@ -212,7 +210,7 @@ let mut sandbox = spawn_sandbox(request)?; let stdout = sandbox.take_stdout().expect("stdout"); ``` -`WslcSection` mirrors the `experimental.wslc` block below; +`WslcSection` mirrors the top-level `wslc` block below; `WslcSection::default()` matches the SDK default (`alpine:latest`). Settings go through the same parser the executor uses, so a rejected value (e.g. a port mapping with a zero or duplicated host port) fails at @@ -234,7 +232,7 @@ Notes and limits: ### JSON config -WSLC-specific settings go under `experimental.wslc` in the JSON config: +WSLC-specific settings go under the top-level `wslc` block in the JSON config: | Field | Type | Default | Description | |---|---|---|---| @@ -258,7 +256,7 @@ WSLC-specific settings go under `experimental.wslc` in the JSON config: ``` ```json -"experimental": { "wslc": { "image": "alpine:latest" } } +"wslc": { "image": "alpine:latest" } ``` **2. Pre-pulled from a custom registry (no auth):** @@ -268,7 +266,7 @@ WSLC-specific settings go under `experimental.wslc` in the JSON config: ``` ```json -"experimental": { "wslc": { "image": "ghcr.io/linuxserver/baseimage-alpine:3.21" } } +"wslc": { "image": "ghcr.io/linuxserver/baseimage-alpine:3.21" } ``` Tested registries: DockerHub, `mcr.microsoft.com`, `ghcr.io`, `quay.io`. @@ -276,11 +274,9 @@ Tested registries: DockerHub, `mcr.microsoft.com`, `ghcr.io`, `quay.io`. **3. Import from a local tar file (no pre-pull needed):** ```json -"experimental": { - "wslc": { - "image": "my-image:latest", - "imageTarPath": "C:\\path\\to\\image.tar" - } +"wslc": { + "image": "my-image:latest", + "imageTarPath": "C:\\path\\to\\image.tar" } ``` @@ -333,14 +329,14 @@ address the container can reach: ```json { - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containment": "wslc", "process": { "commandLine": "curl -fsSL https://example.com && echo OK" }, "network": { "defaultPolicy": "allow", "proxy": { "url": "http://proxy.example:8080" } }, - "experimental": { "wslc": { "image": "alpine:latest" } } + "wslc": { "image": "alpine:latest" } } ``` @@ -387,7 +383,7 @@ rather than refused merely for being present. inbound connections) is **rejected at config-parse time** for WSLC. A WSLC container runs in the NAT'd WSL2 VM and MXC does not honor a blanket inbound-listen grant — only explicit host→container forwards via -`experimental.wslc` `portMappings` have any inbound effect, so accepting the +`wslc.portMappings` have any inbound effect, so accepting the flag would silently promise reachability the backend never delivers. Expose specific ports with `portMappings` instead. (`allowLocalNetwork: false`, the default, is a no-op and is accepted.) @@ -447,9 +443,8 @@ explicit `provision` / `deprovision` phases rather than by per-run flags. | `Failed to load wslcsdk.dll` | DLL not in same directory as `wxc-exec.exe` | Copy `wslcsdk.dll` next to the binary | | `WSLC runtime unavailable` | WSL runtime package is missing, older than 2.9.9, or the Virtual Machine Platform optional component is disabled | Update WSL with `wsl --update --pre-release`, verify the installed version with `wsl --version`, and enable the Virtual Machine Platform optional component if required. The WSLC SDK DLL is a separate dependency and does not replace the WSL runtime package. | | `WSLC runtime unavailable. Missing components: SdkNeedsUpdate` | The opposite direction: your installed WSL is **newer** than the WSLc SDK this MXC build ships (pinned by `WSLC_SDK_VERSION` in `src/backends/wslc/common/build.rs`) | Update MXC to a build with a newer pinned SDK. Do **not** update WSL — it is already ahead, and updating it further will not clear this. | -| `WSLC image '' not found locally` | Image was not pre-pulled, and no `imageTarPath` is set | Run `.\scripts\setup-wslc.ps1 -Image ` (or `wxc-exec.exe --setup-wslc --image `); match the `-StoragePath` to your config's `experimental.wslc.storagePath` if set | -| `WSLC is an experimental feature` | Missing `--experimental` flag | Add `--experimental` to CLI or `{ experimental: true }` in SDK | -| `experimental mode` error in SDK | `SandboxSpawnOptions.experimental` not set | Pass `{ experimental: true }` to spawn functions | +| `WSLC image '' not found locally` | Image was not pre-pulled, and no `imageTarPath` is set | Run `.\scripts\setup-wslc.ps1 -Image ` (or `wxc-exec.exe --setup-wslc --image `); match the `-StoragePath` to your config's `wslc.storagePath` if set | +| `'experimental.wslc' has moved to the stable section` | Config still nests the block under `experimental.wslc` (pre-promotion shape) | Move the block to the top-level `wslc` key | | Container exits with code -1 | Process failed or timed out | Check stderr output with `--debug` flag | ## Example Configs diff --git a/docs/wsl/wsl-container-support-plan.md b/docs/wsl/wsl-container-support-plan.md index a4834854a..135b6e19c 100644 --- a/docs/wsl/wsl-container-support-plan.md +++ b/docs/wsl/wsl-container-support-plan.md @@ -5,8 +5,9 @@ This document was written before the experimental features infrastructure and the WSLC SDK self-host release. Key changes: -- **WSLC is experimental:** `"containment": "wslc"` requires the `--experimental` - CLI flag (same as the sandbox backend). +- **WSLC is promoted to the stable surface:** `"containment": "wslc"` no longer + requires the `--experimental` CLI flag; its settings live in the top-level + `wslc` config block. A build with the `wslc` Cargo feature is still required. - **Config format updated:** The JSON section is `"wslc"` (not `"container"`), command is `"process": { "commandLine": ... }` (not `"script"`), and timeout is under `"process": { "timeout": ... }`. @@ -25,24 +26,24 @@ MXC (Microsoft eXecution Container) runs untrusted code in sandboxed environment ## Proposed Solution -Add a **WSL Container runner** directly into the existing `wxc-exec.exe` Rust binary, using the **WSLC SDK** as the container runtime interface. When the JSON config specifies `"containment": "wslc"` and the `--experimental` flag is passed, the binary routes to a new `WSLContainerRunner` instead of `AppContainerScriptRunner`. The runner calls WSLC SDK C APIs (via Rust FFI bindings) to manage sessions, containers, and process I/O — eliminating the need to build a custom containerd gRPC client or OCI spec builder. This leverages the existing `ScriptRunner` trait and keeps everything in a single binary. +Add a **WSL Container runner** directly into the existing `wxc-exec.exe` Rust binary, using the **WSLC SDK** as the container runtime interface. When the JSON config specifies `"containment": "wslc"`, the binary routes to a new `WSLContainerRunner` instead of `AppContainerScriptRunner`. The runner calls WSLC SDK C APIs (via Rust FFI bindings) to manage sessions, containers, and process I/O — eliminating the need to build a custom containerd gRPC client or OCI spec builder. This leverages the existing `ScriptRunner` trait and keeps everything in a single binary. ## How It Works ``` Path A — CLI (direct): - User: wxc-exec.exe --experimental --debug config.json + User: wxc-exec.exe --debug config.json └── Clap parses args → loads JSON config → dispatches to WSLContainerRunner Path B — SDK (programmatic): - App calls: spawnSandbox("python3 my_app.py", policy, { experimental: true }) + App calls: spawnSandbox("python3 my_app.py", policy) ├── Builds JSON config with containment = "wslc" - └── Spawns wxc-exec.exe --experimental with the config + └── Spawns wxc-exec.exe with the config Both paths converge here: wxc-exec.exe (Rust — single binary, multiple backends) ├── Parses JSON config → sees containment = "wslc" - ├── Checks --experimental flag → creates WSLContainerRunner + ├── Creates WSLContainerRunner ├── Calls WSLC SDK via Rust FFI bindings: │ WslcGetMissingComponents() → preflight check │ WslcInitSessionSettings() → init session settings (with storagePath) @@ -85,20 +86,14 @@ Both paths converge here: └── I/O via SDK callbacks ``` -All backends implement the `ScriptRunner` trait. `main.rs` uses `Box` with a `match` on `request.containment`. Experimental backends (Sandbox, WSLC) require the `--experimental` flag: +All backends implement the `ScriptRunner` trait. `main.rs` uses `Box` with a `match` on `request.containment`. WSLC is a stable backend and needs no `--experimental` gate; the remaining experimental backends (Sandbox, MicroVM, IsolationSession, Hyperlight) still do: ```rust // main.rs — current dispatch let mut runner: Box = match request.containment { ContainmentBackend::AppContainer => Box::new(AppContainerScriptRunner::new()), // ... other stable backends ... - ContainmentBackend::Wslc => { - if !request.experimental_enabled { - eprintln!("Error: WSLC is an experimental feature. Use --experimental flag."); - process::exit(1); - } - Box::new(WslContainerRunner::new(&request.container_config)) - } + ContainmentBackend::Wslc => Box::new(WslContainerRunner::new(&request.container_config)), }; ``` @@ -149,7 +144,7 @@ portMappings. `ContainerConfig` struct and `container_config` field on } ``` -Run with: `wxc-exec.exe config.json --experimental --debug` +Run with: `wxc-exec.exe config.json --debug` --- @@ -342,7 +337,7 @@ Setup script (`scripts/setup-wslc.ps1`): - TODO: Run a smoke test after the pull (tracked separately) **Current workaround:** Users write JSON configs with `"containment": "wslc"` -and run with `wxc-exec.exe --experimental --debug config.json`. +and run with `wxc-exec.exe --debug config.json`. --- @@ -398,10 +393,8 @@ from the config: "containment": "wslc", "process": { "commandLine": "echo hello" }, "network": { "defaultPolicy": "block" }, - "experimental": { - "wslc": { - "image": "alpine:latest" - } + "wslc": { + "image": "alpine:latest" } } ``` @@ -422,10 +415,8 @@ future WSLC SDK release. "containment": "wslc", "process": { "commandLine": "cat /etc/os-release" }, "network": { "defaultPolicy": "allow" }, - "experimental": { - "wslc": { - "image": "mcr.microsoft.com/cbl-mariner/base/core:2.0" - } + "wslc": { + "image": "mcr.microsoft.com/cbl-mariner/base/core:2.0" } } ``` @@ -441,7 +432,7 @@ local Docker daemon is needed — the WSLC SDK handles the pull internally. > **Note on storage path:** the setup script and the runner must share > the same `storage_path`. The runner default is > `%TEMP%\mxc-wslc-sessions`; if your config sets -> `experimental.wslc.storagePath`, pass the same path to the setup +> `wslc.storagePath`, pass the same path to the setup > script with `-StoragePath`. ### 3. Import from a local tar file @@ -456,11 +447,9 @@ format is auto-detected. "containment": "wslc", "process": { "commandLine": "echo 'Hello from tar!'" }, "network": { "defaultPolicy": "block" }, - "experimental": { - "wslc": { - "image": "my-image:latest", - "imageTarPath": "C:\\workspace\\alpine.tar" - } + "wslc": { + "image": "my-image:latest", + "imageTarPath": "C:\\workspace\\alpine.tar" } } ``` @@ -550,11 +539,11 @@ msiexec /i wsl.2.9.9.0.x64.msi # Verify WSLC is available wslc container run hello-world -# Run a Linux command via MXC (requires --experimental) -wxc-exec.exe --experimental --debug wslc-config.json +# Run a Linux command via MXC (needs a build with the `wslc` Cargo feature) +wxc-exec.exe --debug wslc-config.json # Or programmatically via SDK -spawnSandbox("python3 app.py", policy, { experimental: true }) +spawnSandbox("python3 app.py", policy) # Existing Windows AppContainer usage is unchanged wxc-exec.exe --debug windows-app.json @@ -584,7 +573,7 @@ Config file (`app-policy.json`): } ``` -Run with: `wxc-exec.exe --experimental --debug app-policy.json` +Run with: `wxc-exec.exe --debug app-policy.json` This mounts `C:\Projects\my-app` as `/mnt/c/Projects/my-app` (read-write) inside the Linux container, gives it network access (except to `internal.corp.net`), runs `app.py` with Python 3.12, and kills the container after 60 seconds if it hasn't exited. diff --git a/docs/wsl/wslc-state-aware.md b/docs/wsl/wslc-state-aware.md index 4c560e1b7..8dc795c9a 100644 --- a/docs/wsl/wslc-state-aware.md +++ b/docs/wsl/wslc-state-aware.md @@ -10,8 +10,9 @@ It complements: - [`wsl-container-support-plan.md`](wsl-container-support-plan.md) — the original one-shot backend design. - [`../state-aware-lifecycle/mxc-state-aware-sandbox-api.md`](../state-aware-lifecycle/mxc-state-aware-sandbox-api.md) — the cross-backend state-aware wire format, the Rust `StatefulSandboxBackend` trait, and the dispatcher contract. -The WSLc state-aware surface is **experimental** — it requires `--experimental` and a build with -the `wslc` feature (`build.bat --with-wslc`). +The WSLc state-aware surface no longer requires `--experimental` — WSLc is configured +through the top-level `wslc` section. It still requires a build with the `wslc` +feature (`build.bat --with-wslc`). ## Why a daemon @@ -43,7 +44,7 @@ against different sandboxes are serialized — correct, just not concurrent. See | Component | Location | Role | |-----------|----------|------| -| State-aware backend | `src/backends/wslc/common/src/state_aware.rs` (`WslcStateAwareRunner`) | Translates the public `experimental.wslc.*` wire schema + cross-cutting policy into daemon protocol frames; implements `StatefulSandboxBackend` (`ID_PREFIX`/`BACKEND_KEY` = `wslc`). | +| State-aware backend | `src/backends/wslc/common/src/state_aware.rs` (`WslcStateAwareRunner`) | Translates the public top-level `wslc.*` wire schema + cross-cutting policy into daemon protocol frames; implements `StatefulSandboxBackend` (`ID_PREFIX`/`BACKEND_KEY` = `wslc`). | | Policy honor matrix | `src/backends/wslc/common/src/policy.rs` | Per-phase validation of which policy fields are honored vs rejected. | | Daemon client | `src/backends/wslc/common/src/daemon_client.rs` | Discovers / spawns the daemon, connects the control pipe, sends `DaemonRequest` frames, reads responses; typed `DaemonError`. | | Daemon | `src/backends/wslc/daemon/` (`wxc-wslc-daemon.exe`) | Long-lived host process holding `WslcSession` / `WslcContainer`; worker thread drives the SDK; idle-timeout watchdog tears the session down when unused. | diff --git a/schemas/dev/mxc-config.schema.0.9.0-alpha.json b/schemas/dev/mxc-config.schema.0.9.0-alpha.json index 84b796cc2..9b5d762a1 100644 --- a/schemas/dev/mxc-config.schema.0.9.0-alpha.json +++ b/schemas/dev/mxc-config.schema.0.9.0-alpha.json @@ -887,10 +887,6 @@ "windows_sandbox": { "$ref": "#/definitions/OneShotWindowsSandbox", "description": "Optional one-shot Windows Sandbox compatibility settings." - }, - "wslc": { - "$ref": "#/definitions/OneShotWslc", - "description": "Optional one-shot WSLC backend settings." } }, "type": "object" @@ -987,6 +983,10 @@ "version": { "$ref": "#/definitions/Version", "description": "The exact contract version marker." + }, + "wslc": { + "$ref": "#/definitions/Wslc", + "description": "Optional Windows WSL container configuration." } }, "required": [ @@ -1018,52 +1018,6 @@ }, "type": "object" }, - "OneShotWslc": { - "additionalProperties": false, - "description": "One-shot WSLC backend settings.", - "properties": { - "cpuCount": { - "description": "Requested virtual CPU count.", - "maximum": 4294967295, - "minimum": 0.0, - "type": "integer" - }, - "gpu": { - "description": "Whether GPU passthrough is enabled.", - "type": "boolean" - }, - "image": { - "description": "Container image reference.", - "type": "string" - }, - "imageTarPath": { - "description": "Path to a local image tarball to import.", - "type": "string" - }, - "memoryMb": { - "description": "Requested memory limit in megabytes.", - "maximum": 18446744073709551615, - "minimum": 0.0, - "type": "integer" - }, - "portMappings": { - "description": "Optional host-to-container TCP port mappings.", - "items": { - "$ref": "#/definitions/PortMapping" - }, - "type": "array" - }, - "storagePath": { - "description": "Optional storage path override.", - "type": "string" - }, - "targetOs": { - "description": "Target operating system inside the container.", - "type": "string" - } - }, - "type": "object" - }, "PortMapping": { "additionalProperties": false, "description": "A host-to-container WSLC port mapping.", @@ -1346,7 +1300,7 @@ }, "StateAwareWslc": { "additionalProperties": false, - "description": "State-aware WSLC experimental settings.", + "description": "State-aware WSLC settings, carried by the top-level `wslc` section.", "properties": { "provision": { "$ref": "#/definitions/WslcProvision", @@ -1568,6 +1522,52 @@ ], "type": "object" }, + "Wslc": { + "additionalProperties": false, + "description": "Windows WSL container backend settings.", + "properties": { + "cpuCount": { + "description": "Requested virtual CPU count.", + "maximum": 4294967295, + "minimum": 0.0, + "type": "integer" + }, + "gpu": { + "description": "Whether GPU passthrough is enabled.", + "type": "boolean" + }, + "image": { + "description": "Container image reference.", + "type": "string" + }, + "imageTarPath": { + "description": "Path to a local image tarball to import.", + "type": "string" + }, + "memoryMb": { + "description": "Requested memory limit in megabytes.", + "maximum": 18446744073709551615, + "minimum": 0.0, + "type": "integer" + }, + "portMappings": { + "description": "Optional host-to-container TCP port mappings.", + "items": { + "$ref": "#/definitions/PortMapping" + }, + "type": "array" + }, + "storagePath": { + "description": "Optional storage path override.", + "type": "string" + }, + "targetOs": { + "description": "Target operating system inside the container.", + "type": "string" + } + }, + "type": "object" + }, "WslcContainment": { "enum": [ "wslc" @@ -1596,10 +1596,6 @@ "telemetry": { "$ref": "#/definitions/Telemetry", "description": "Optional telemetry override." - }, - "wslc": { - "$ref": "#/definitions/StateAwareWslc", - "description": "Optional WSLC backend settings." } }, "type": "object" @@ -1638,6 +1634,10 @@ "version": { "$ref": "#/definitions/Version", "description": "Exact development contract version." + }, + "wslc": { + "$ref": "#/definitions/StateAwareWslc", + "description": "Optional WSLC backend settings fixed at provision time." } }, "required": [ diff --git a/schemas/dev/mxc-config.schema.0.9.0-dev.json b/schemas/dev/mxc-config.schema.0.9.0-dev.json index a3a3fa3a4..ddfbcb969 100644 --- a/schemas/dev/mxc-config.schema.0.9.0-dev.json +++ b/schemas/dev/mxc-config.schema.0.9.0-dev.json @@ -254,7 +254,7 @@ "type": "null" } ], - "description": "WSL container backend config." + "description": "WSL container backend config (pre-promotion alias)." } }, "type": "object" @@ -710,7 +710,8 @@ "type": "string" }, "PortMapping": { - "description": "A single host → container port forward. Reachable only under the permissive `experimental` surface, so unknown fields are tolerated (forward-compat).", + "additionalProperties": false, + "description": "A single host → container port forward.", "properties": { "containerPort": { "description": "Container port.", @@ -1067,6 +1068,7 @@ "type": "object" }, "Wslc": { + "additionalProperties": false, "description": "WSL container backend config.", "properties": { "cpuCount": { @@ -1127,7 +1129,7 @@ "type": "null" } ], - "description": "State-aware provision-phase configuration (`experimental.wslc.provision`). Carries the container-creation knobs for the state-aware lifecycle; the flat sibling fields above remain the one-shot surface. Absent on one-shot configs and non-provision phases." + "description": "State-aware provision-phase configuration (`wslc.provision`). Carries the container-creation knobs for the state-aware lifecycle; the flat sibling fields above remain the one-shot surface. Absent on one-shot configs and non-provision phases." }, "storagePath": { "description": "Storage path override.", @@ -1147,7 +1149,8 @@ "type": "object" }, "WslcProvisionPhase": { - "description": "Per-phase WSLc **provision** configuration (state-aware lifecycle), nested under `experimental.wslc.provision`. Carries only what the amortized daemon session honors: the container image (or a local tarball to import).\n\nFilesystem mounts and network mode derive from the top-level `policy` section (readwrite / readonly paths, network), not from here. The one-shot-only sizing knobs (`cpuCount` / `memoryMb` / `gpu` / `storagePath` / `portMappings`) are deliberately absent: the daemon shares a single session across sandboxes and does not apply per-sandbox sizing. start / exec / stop / deprovision carry no backend-specific config (the exec command flows through the top-level `process` section), so they have no phase struct.", + "additionalProperties": false, + "description": "Per-phase WSLc **provision** configuration (state-aware lifecycle), nested under `wslc.provision`. Carries only what the amortized daemon session honors: the container image (or a local tarball to import).\n\nFilesystem mounts and network mode derive from the top-level `policy` section (readwrite / readonly paths, network), not from here. The one-shot-only sizing knobs (`cpuCount` / `memoryMb` / `gpu` / `storagePath` / `portMappings`) are deliberately absent: the daemon shares a single session across sandboxes and does not apply per-sandbox sizing. start / exec / stop / deprovision carry no backend-specific config (the exec command flows through the top-level `process` section), so they have no phase struct.", "properties": { "image": { "description": "Container image reference (e.g. `alpine:latest`). Defaults to `alpine:latest` when omitted.", @@ -1348,6 +1351,17 @@ "string", "null" ] + }, + "wslc": { + "anyOf": [ + { + "$ref": "#/definitions/Wslc" + }, + { + "type": "null" + } + ], + "description": "WSL container backend settings (Windows). Used when containment is `wslc`." } }, "type": "object" diff --git a/schemas/schema-version.json b/schemas/schema-version.json index 7d95f4695..8bedab486 100644 --- a/schemas/schema-version.json +++ b/schemas/schema-version.json @@ -3,7 +3,7 @@ "min": "0.6.0-alpha", "maxSupported": "0.9.0-alpha", "stateAware": "0.6.0-alpha", - "stateAwareWslc": "0.8.0-alpha", + "stateAwareWslc": "0.9.0-alpha", "stableLatest": "0.8.0-alpha", "devSchemaFile": "0.9.0-dev" } diff --git a/scripts/check-dotnet-api-parity.js b/scripts/check-dotnet-api-parity.js index 68201386f..8f05913a0 100644 --- a/scripts/check-dotnet-api-parity.js +++ b/scripts/check-dotnet-api-parity.js @@ -443,35 +443,6 @@ if (!tierMatch) { compare("isolation-tier wire names", managedTiers, rustTiers); } -const rustStateAware = read( - "src", - "core", - "mxc_engine", - "src", - "state_aware.rs" -).split("#[cfg(test)]")[0]; -const stateAwareMatch = /matches!\(\s*backend,\s*([\s\S]*?)\)\s*&&/.exec( - rustStateAware -); -if (!stateAwareMatch) { - errors.push("state_aware.rs: could not find experimental backend registry"); -} else { - const rustBackends = [ - ...stateAwareMatch[1].matchAll(/ContainmentBackend::(\w+)/g), - ].map((match) => match[1]); - const managedStateAware = read( - "sdk", - "dotnet", - "Microsoft.Mxc.Sdk", - "StateAwareTypes.cs" - ); - compare( - "state-aware containment enum", - enumVariants(managedStateAware, "StateAwareContainment", "csharp"), - rustBackends - ); -} - const rustDispatch = read( "src", "core", @@ -479,13 +450,33 @@ const rustDispatch = read( "src", "state_aware_dispatch.rs" ); +// The managed `StateAwareContainment` enum must cover every backend the +// state-aware dispatcher can reach, which is the sandbox-id prefix registry in +// `state_aware_dispatch.rs` — NOT the `require_experimental_optin` list in +// `state_aware.rs`, which is only the experimental subset and shrinks as +// backends are promoted to the stable surface. +const rustPrefixBody = namedBody(rustDispatch, "fn", "backend_from_prefix"); +const rustBackends = [ + ...rustPrefixBody.matchAll(/=>\s*Ok\(ContainmentBackend::(\w+)\)/g), +].map((match) => match[1]); +const managedStateAware = read( + "sdk", + "dotnet", + "Microsoft.Mxc.Sdk", + "StateAwareTypes.cs" +); +compare( + "state-aware containment enum", + enumVariants(managedStateAware, "StateAwareContainment", "csharp"), + rustBackends +); + const managedLifecycle = read( "sdk", "dotnet", "Microsoft.Mxc.Sdk", "MxcLifecycle.cs" ); -const rustPrefixBody = namedBody(rustDispatch, "fn", "backend_from_prefix"); const managedPrefixBody = namedBody(managedLifecycle, "StateAwareContainment", "ContainmentForId"); const rustPrefixes = [ ...rustPrefixBody.matchAll(/"([^"]+)"\s*=>\s*Ok\(ContainmentBackend::(\w+)\)/g), diff --git a/scripts/setup-wslc.ps1 b/scripts/setup-wslc.ps1 index e37e79046..62a0b5582 100644 --- a/scripts/setup-wslc.ps1 +++ b/scripts/setup-wslc.ps1 @@ -15,7 +15,7 @@ and become visible to subsequent runtime executions. The storage path you pass here MUST match the value used at run time - (the `experimental.wslc.storagePath` field of the config, or the + (the `wslc.storagePath` field of the config, or the runner's default of `%TEMP%\mxc-wslc-sessions` when omitted). .PARAMETER Image @@ -32,7 +32,7 @@ .PARAMETER StoragePath WSLC storage path to populate. When omitted, the runner default (`%TEMP%\mxc-wslc-sessions`) is used. Set this if your runtime configs - override `experimental.wslc.storagePath`. + override `wslc.storagePath`. .PARAMETER DebugLogs Enable verbose logging from wxc-exec (passes `--debug`). diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcLifecycleTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcLifecycleTests.cs index e79f5dd71..f257a501b 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcLifecycleTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcLifecycleTests.cs @@ -419,13 +419,15 @@ public void BuildProvisionEnvelope_WslcUsesV08AndNestsImageOptions() using var doc = JsonDocument.Parse(json); var root = doc.RootElement; - Assert.Equal("0.8.0-alpha", root.GetProperty("version").GetString()); + Assert.Equal("0.9.0-alpha", root.GetProperty("version").GetString()); Assert.Equal("wslc", root.GetProperty("containment").GetString()); Assert.Equal( "allow", root.GetProperty("network").GetProperty("defaultPolicy").GetString()); - var provision = root.GetProperty("experimental") - .GetProperty("wslc") + Assert.False( + root.TryGetProperty("experimental", out _), + "a promoted backend must not emit an `experimental` section"); + var provision = root.GetProperty("wslc") .GetProperty("provision"); Assert.Equal("alpine:latest", provision.GetProperty("image").GetString()); Assert.Equal( @@ -479,7 +481,7 @@ public void BuildExecEnvelope_CarriesProcessOptionsAndWslcProxy() using var doc = JsonDocument.Parse(json); var root = doc.RootElement; - Assert.Equal("0.8.0-alpha", root.GetProperty("version").GetString()); + Assert.Equal("0.9.0-alpha", root.GetProperty("version").GetString()); var process = root.GetProperty("process"); Assert.Equal("/work", process.GetProperty("cwd").GetString()); Assert.Equal("A=1", process.GetProperty("env")[0].GetString()); @@ -531,7 +533,7 @@ public void IdPhases_InferBackendVersionAndHonorOverrides() new SandboxId("iso:abc"), new StateAwarePhaseOptions { Version = "0.9.0-alpha" }); - Assert.Equal("0.8.0-alpha", wslcStart["version"]!.GetValue()); + Assert.Equal("0.9.0-alpha", wslcStart["version"]!.GetValue()); Assert.Equal("0.6.0-alpha", wsbStop["version"]!.GetValue()); Assert.Equal("0.9.0-alpha", overridden["version"]!.GetValue()); } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs index 91864d802..e53e6e084 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs @@ -115,7 +115,7 @@ public void FullRequestSerialization_MatchesCrossLanguageGoldens() "echo network"); var wslc = new SandboxRequest( - new SandboxPolicy { Version = "0.8.0-alpha" }, + new SandboxPolicy { Version = "0.9.0-alpha" }, "printf parity") { Containment = new WslcContainment diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs index f58f94f6d..e04351420 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs @@ -493,6 +493,24 @@ private static void SetOptionalBackendConfig( } } + /// + /// Where a backend's per-phase section lives on the wire. Mirrors the Rust + /// StatefulSandboxBackend::SECTION_ROOT const and the TypeScript + /// BACKEND_SECTION_ROOT: a backend that is still experimental nests + /// its phases under experimental.<backend>.<phase>, while a + /// promoted backend carries a closed top-level + /// <backend>.<phase> section. + /// + private static bool IsExperimentalSection(string backend) => backend switch + { + IsolationSessionContainment => true, + WindowsSandboxContainment => true, + WslcContainment => false, + _ => throw new MxcException( + ErrorCode.UnsupportedContainment, + $"no wire section root declared for state-aware backend '{backend}'"), + }; + private static void SetBackendConfig( JsonObject envelope, string backend, @@ -500,15 +518,25 @@ private static void SetBackendConfig( string key, JsonNode? value) { - if (envelope["experimental"] is not JsonObject experimental) + JsonObject root; + if (IsExperimentalSection(backend)) + { + if (envelope["experimental"] is not JsonObject experimental) + { + experimental = new JsonObject(); + envelope["experimental"] = experimental; + } + root = experimental; + } + else { - experimental = new JsonObject(); - envelope["experimental"] = experimental; + root = envelope; } - if (experimental[backend] is not JsonObject backendConfig) + + if (root[backend] is not JsonObject backendConfig) { backendConfig = new JsonObject(); - experimental[backend] = backendConfig; + root[backend] = backendConfig; } if (backendConfig[phase] is not JsonObject phaseConfig) { diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/SchemaVersions.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/SchemaVersions.cs index d5cad536c..4b4c3f32f 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/SchemaVersions.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/SchemaVersions.cs @@ -24,5 +24,5 @@ public static class SchemaVersions public const string StateAware = "0.6.0-alpha"; /// Default state-aware version for WSLC. - public const string WslcStateAware = "0.8.0-alpha"; + public const string WslcStateAware = "0.9.0-alpha"; } diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index 7849735f6..0cf18c712 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -244,7 +244,7 @@ require schema `0.8.0-alpha` or later. #### WSL Container options -`WslcContainment` selects the experimental WSLC backend and carries its image, +`WslcContainment` selects the WSLC backend and carries its image, resource, storage, GPU, and host-to-container TCP port settings: ```csharp @@ -256,7 +256,6 @@ var request = new SandboxRequest( }, "python3 -c 'print(42)'") { - Experimental = true, Containment = new WslcContainment { Image = "python:3.12", @@ -274,9 +273,8 @@ var request = new SandboxRequest( ``` The image must already be cached unless `ImageTarPath` is supplied. The image -store wins over the tar when both identify an already-cached image. WSLC is -experimental, so `Experimental` is required; the native unit must also be built -with WSLC support or execution returns `BackendUnavailable`. +store wins over the tar when both identify an already-cached image. The native +unit must be built with WSLC support or execution returns `BackendUnavailable`. ### Network proxy @@ -541,7 +539,7 @@ Exposes **run-to-completion** (`Run` / `RunAsync`), **streaming** (`MxcLifecycle`) over the backends the public Rust SDK supports (Windows ProcessContainer, Linux Bubblewrap, macOS Seatbelt for run/stream; the state-aware lifecycle supports IsolationSession, Windows Sandbox, and WSLC on -Windows; all three are experimental). +Windows; IsolationSession and Windows Sandbox are experimental). `SchemaVersions` exposes the minimum and maximum accepted schema versions, the latest stable schema, and the backend-specific state-aware defaults. These @@ -630,7 +628,7 @@ var wslc = new WslcProvisionOptions ``` IsolationSession and Windows Sandbox default to schema `0.6.0-alpha`; WSLC -defaults to `0.8.0-alpha`. Set `Version` on provision or phase options to +defaults to `0.9.0-alpha`. Set `Version` on provision or phase options to override the inferred version. State-aware exec options expose working directory, `KEY=VALUE` environment entries, and timeout. WSLC also accepts a proxy-only per-exec override: diff --git a/sdk/node/README.md b/sdk/node/README.md index c2c046766..9b7e00c67 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -64,7 +64,7 @@ child.on('close', (code) => console.log('exit:', code)); Pick `0.8.0-alpha` for new code on any supported platform. -> **Stable schemas document only the non-experimental surface.** Experimental backends (`windows_sandbox`, `wslc`, `microvm`, `hyperlight`, `isolation_session`), the `experimental.*` block, and state-aware lifecycle live in `0.9.0-dev`. The parser still accepts them when paired with `--experimental` regardless of which schema your config validates against — schema choice affects editor validation, not runtime behavior. +> **Stable schemas document only the non-experimental surface.** Experimental backends (`windows_sandbox`, `microvm`, `hyperlight`, `isolation_session`), the `experimental.*` block, and state-aware lifecycle live in `0.9.0-dev`. The parser still accepts them when paired with `--experimental` regardless of which schema your config validates against — schema choice affects editor validation, not runtime behavior. > **Network host allow/block lists are not implemented on Windows.** `network.allowedHosts` / `network.blockedHosts` have no enforcement on this platform — use `network.defaultPolicy` (`allow` / `block`) or `network.proxy` to constrain network access. @@ -138,15 +138,15 @@ for all three connectivity modes and backend-specific support. | Platform | Default backend | Other backends | Minimum build | | --- | --- | --- | --- | -| Windows 11 24H2+ (verified on 25H2) | `processcontainer` | `windows_sandbox`, `wslc`, `microvm`, `isolation_session` | `processcontainer`: 26100 (24H2)
`isolation_session`: 26340.9212 ([Insider Preview](https://learn.microsoft.com/en-us/windows-insider/release-notes/experimental/preview-build-26340-9212)) | +| Windows 11 24H2+ (verified on 25H2) | `processcontainer` | `wslc`, `windows_sandbox`, `microvm`, `isolation_session` | `processcontainer`: 26100 (24H2)
`isolation_session`: 26340.9212 ([Insider Preview](https://learn.microsoft.com/en-us/windows-insider/release-notes/experimental/preview-build-26340-9212)) | | Linux x64 / ARM64 | `bubblewrap` | `lxc` | — | | macOS ARM64 (schema `0.7.0-alpha`+) | `seatbelt` | — | — | -The default `processcontainer`, `bubblewrap`, `lxc`, and `seatbelt` backends work out of the box. **Experimental backends** (`windows_sandbox`, `wslc`, `microvm`, `isolation_session`, `hyperlight`) require `{ experimental: true }` in `SandboxSpawnOptions` when you spawn — see [Choosing a Backend](#choosing-a-backend). +The stable `processcontainer`, `bubblewrap`, `lxc`, `seatbelt`, and `wslc` backends need no experimental opt-in; `wslc` is stable but not a default, so select it explicitly and note it additionally requires a WSL2-capable Windows host. **Experimental backends** (`windows_sandbox`, `microvm`, `isolation_session`, `hyperlight`) require `{ experimental: true }` in `SandboxSpawnOptions` when you spawn — see [Choosing a Backend](#choosing-a-backend). > **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. On Linux, `unavailableReasons` provides a diagnostic for each unavailable LXC or Bubblewrap backend even when the other backend keeps the platform supported. +`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 Windows, `availableMethods` includes `'wslc'` only when the probe reports the WSLc runtime is present (`isolation_session` behaves the same way), so check `availableMethods` before selecting either backend. 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. @@ -266,7 +266,7 @@ console.log(result.stdout); | `seatbelt` | `process` | macOS | ✅ (schema `0.7.0-alpha`+) | [`docs/seatbelt/seatbelt-backend.md`](https://github.com/microsoft/mxc/blob/main/docs/seatbelt/seatbelt-backend.md) | | `windows_sandbox` | `vm` | Windows | Experimental | [`docs/windows-sandbox/windows-sandbox.md`](https://github.com/microsoft/mxc/blob/main/docs/windows-sandbox/windows-sandbox.md) | | `microvm` | `microvm` | Windows | Experimental | [`docs/nanvix-microvm/nanvix.md`](https://github.com/microsoft/mxc/blob/main/docs/nanvix-microvm/nanvix.md) — MicroVM via NanVix on Windows Hypervisor Platform | -| `wslc` | (concrete only) | Windows | Experimental | [`docs/wsl/wsl-container-getting-started.md`](https://github.com/microsoft/mxc/blob/main/docs/wsl/wsl-container-getting-started.md) | +| `wslc` | (concrete only) | Windows | Stable | [`docs/wsl/wsl-container-getting-started.md`](https://github.com/microsoft/mxc/blob/main/docs/wsl/wsl-container-getting-started.md) | | `isolation_session` | (concrete only) | Windows | Experimental | [`docs/isolation-session/oneshot.md`](https://github.com/microsoft/mxc/blob/main/docs/isolation-session/oneshot.md) | Experimental backends require `{ experimental: true }` in `SandboxSpawnOptions`: @@ -282,7 +282,7 @@ Backend-specific tuning lives on the returned `ContainerConfig`. The full set of - Stable backends: [`schemas/stable/`](https://github.com/microsoft/mxc/tree/main/schemas/stable/) - Experimental backends: [`schemas/dev/`](https://github.com/microsoft/mxc/tree/main/schemas/dev/) -Open the schema file matching your `policy.version` (e.g. `mxc-config.schema.0.6.0-alpha.json`) and look up `processContainer`, `lxc`, `experimental.wslc`, `experimental.windows_sandbox`, etc. +Open the schema file matching your `policy.version` (e.g. `mxc-config.schema.0.6.0-alpha.json`) and look up `processContainer`, `lxc`, `experimental.windows_sandbox`, etc. `wslc` is newer than every published stable schema — it first appears in `schemas/dev/mxc-config.schema.0.9.0-alpha.json`, so WSLc configs must declare `version: '0.9.0-alpha'`. For Windows ProcessContainer configs, `processContainer.learningMode: true` enables deny-and-record learning mode: failed accesses are logged but remain @@ -299,7 +299,7 @@ capability names are reserved and must not be added directly to For long-lived sandboxes where you provision once, exec many times, and tear down at the end (e.g. agentic loops), use the state-aware lifecycle. -> **Backend support:** the state-aware lifecycle is currently implemented for `isolation_session`, `windows_sandbox`, and `wslc` (all Windows-only; all still experimental, so every call must pass `{ experimental: true }`). The one-shot spawn APIs (`spawnSandbox` / `spawnSandboxFromConfig`) are the supported path for every other backend. +> **Backend support:** the state-aware lifecycle is currently implemented for `isolation_session`, `windows_sandbox`, and `wslc` (all Windows-only; `isolation_session` and `windows_sandbox` are still experimental, so calls for those must pass `{ experimental: true }`). The one-shot spawn APIs (`spawnSandbox` / `spawnSandboxFromConfig`) are the supported path for every other backend. ```typescript import { @@ -329,7 +329,7 @@ await deprovisionSandbox(sandboxId, undefined, opts); `windows_sandbox` follows the same shape (substitute the containment string and provide `filesystem.readwritePaths` / `readonlyPaths` at provision if needed). See [`docs/windows-sandbox/windows-sandbox.md`](https://github.com/microsoft/mxc/blob/main/docs/windows-sandbox/windows-sandbox.md) for the per-phase config matrix. -`wslc` follows the same shape and needs no provision config at all (it defaults to an `alpine:latest` container with no network). Provide `filesystem.readwritePaths` / `readonlyPaths` (mounted for the sandbox's lifetime), `network.defaultPolicy: 'allow'` (a bridged container; the default `'block'` gives no network), and/or a backend-specific `image` / `imageTarPath` at provision; inject a cooperative `network.proxy: { url }` per-exec. WSLc state-aware requests default to schema `0.8.0-alpha`. See [`docs/wsl/wslc-state-aware.md`](https://github.com/microsoft/mxc/blob/main/docs/wsl/wslc-state-aware.md) for the per-phase config matrix. +`wslc` follows the same shape and needs no provision config at all (it defaults to an `alpine:latest` container with no network). Provide `filesystem.readwritePaths` / `readonlyPaths` (mounted for the sandbox's lifetime), `network.defaultPolicy: 'allow'` (a bridged container; the default `'block'` gives no network), and/or a backend-specific `image` / `imageTarPath` at provision; inject a cooperative `network.proxy: { url }` per-exec. WSLc state-aware requests default to schema `0.9.0-alpha`. See [`docs/wsl/wslc-state-aware.md`](https://github.com/microsoft/mxc/blob/main/docs/wsl/wslc-state-aware.md) for the per-phase config matrix. **Handling failures.** Every lifecycle call rejects with a typed `MxcError`. Branch on `code` first: @@ -437,7 +437,7 @@ Setting `cwd` (or the `workingDirectory` argument) does **not** add that path to | `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=` so `//wxc-exec.exe` (or `lxc-exec`) exists, or pass `options.executablePath` explicitly. | | `Invalid containment value ''` | `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). | -| `'' containment requires experimental mode` | A `windows_sandbox` / `wslc` / `microvm` / `isolation_session` / `hyperlight` backend was selected without the flag. | Pass `{ experimental: true }` in `SandboxSpawnOptions`. | +| `'' containment requires experimental mode` | A `windows_sandbox` / `microvm` / `isolation_session` / `hyperlight` backend was selected without the flag. | Pass `{ experimental: true }` in `SandboxSpawnOptions`. | | `process.commandLine starts with an unquoted Windows path containing a space` | `wxc-exec` rejects unquoted paths with spaces at parse time. | Quote the executable: `'"C:\\Program Files\\…\\foo.exe" args'`. | | `Experimental_CreateProcessInSandbox failed: WIN32_ERROR(...)` | Native sandbox API returned an OS-level error, e.g. `448` = device feature not supported (Windows build / WIP feature not enabled). Note `120` (call not implemented / BaseContainer disabled) is now handled automatically — the default `process` backend falls back to AppContainer+DACL, so it no longer surfaces here. | Check the Windows build / WIP requirements for the backend you selected. | | Process exits `-1` / `4294967295` with no stdout | Native binary terminated abnormally. | Re-run with `options.debug: true` (or `options.logDir: ''`) to capture diagnostic logs. | diff --git a/sdk/node/src/generated/v0_9_0_alpha/wire.ts b/sdk/node/src/generated/v0_9_0_alpha/wire.ts index 842f81921..c173664a9 100644 --- a/sdk/node/src/generated/v0_9_0_alpha/wire.ts +++ b/sdk/node/src/generated/v0_9_0_alpha/wire.ts @@ -415,10 +415,6 @@ export interface OneShotExperimental { * Optional one-shot Windows Sandbox compatibility settings. */ windows_sandbox?: OneShotWindowsSandbox; - /** - * Optional one-shot WSLC backend settings. - */ - wslc?: OneShotWslc; } /** @@ -497,6 +493,10 @@ export type OneShotRequest = { * The exact contract version marker. */ version: Version; + /** + * Optional Windows WSL container configuration. + */ + wslc?: Wslc; } & ({ processContainer?: never } | { appContainer?: never }) & ({ seatbelt?: never } | { macos_sandbox?: never }); /** @@ -517,44 +517,6 @@ export interface OneShotWindowsSandbox { idleTimeoutMs?: number; } -/** - * One-shot WSLC backend settings. - */ -export interface OneShotWslc { - /** - * Requested virtual CPU count. - */ - cpuCount?: number; - /** - * Whether GPU passthrough is enabled. - */ - gpu?: boolean; - /** - * Container image reference. - */ - image?: string; - /** - * Path to a local image tarball to import. - */ - imageTarPath?: string; - /** - * Requested memory limit in megabytes. - */ - memoryMb?: number; - /** - * Optional host-to-container TCP port mappings. - */ - portMappings?: PortMapping[]; - /** - * Optional storage path override. - */ - storagePath?: string; - /** - * Target operating system inside the container. - */ - targetOs?: string; -} - /** * A host-to-container WSLC port mapping. */ @@ -760,7 +722,7 @@ export interface StateAwareIsolationSession { } /** - * State-aware WSLC experimental settings. + * State-aware WSLC settings, carried by the top-level `wslc` section. */ export interface StateAwareWslc { /** @@ -907,6 +869,44 @@ export interface WindowsSandboxProvisionRequest { version: Version; } +/** + * Windows WSL container backend settings. + */ +export interface Wslc { + /** + * Requested virtual CPU count. + */ + cpuCount?: number; + /** + * Whether GPU passthrough is enabled. + */ + gpu?: boolean; + /** + * Container image reference. + */ + image?: string; + /** + * Path to a local image tarball to import. + */ + imageTarPath?: string; + /** + * Requested memory limit in megabytes. + */ + memoryMb?: number; + /** + * Optional host-to-container TCP port mappings. + */ + portMappings?: PortMapping[]; + /** + * Optional storage path override. + */ + storagePath?: string; + /** + * Target operating system inside the container. + */ + targetOs?: string; +} + export type WslcContainment = "wslc"; /** @@ -931,10 +931,6 @@ export interface WslcProvisionExperimental { * Optional telemetry override. */ telemetry?: Telemetry; - /** - * Optional WSLC backend settings. - */ - wslc?: StateAwareWslc; } /** @@ -973,6 +969,10 @@ export interface WslcProvisionRequest { * Exact development contract version. */ version: Version; + /** + * Optional WSLC backend settings fixed at provision time. + */ + wslc?: StateAwareWslc; } /** diff --git a/sdk/node/src/generated/wire.ts b/sdk/node/src/generated/wire.ts index 811d621cc..f5f4292b4 100644 --- a/sdk/node/src/generated/wire.ts +++ b/sdk/node/src/generated/wire.ts @@ -96,7 +96,7 @@ export interface Experimental { */ windows_sandbox?: WindowsSandbox | null; /** - * WSL container backend config. + * WSL container backend config (pre-promotion alias). */ wslc?: Wslc | null; [k: string]: unknown; @@ -331,7 +331,7 @@ export interface NetworkRule { export type Phase = "provision" | "start" | "exec" | "stop" | "deprovision"; /** - * A single host → container port forward. Reachable only under the permissive `experimental` surface, so unknown fields are tolerated (forward-compat). + * A single host → container port forward. */ export interface PortMapping { /** @@ -346,7 +346,6 @@ export interface PortMapping { * Host (Windows) port. */ windowsPort: number; - [k: string]: unknown; } /** @@ -567,7 +566,7 @@ export interface Wslc { */ portMappings?: PortMapping[] | null; /** - * State-aware provision-phase configuration (`experimental.wslc.provision`). Carries the container-creation knobs for the state-aware lifecycle; the flat sibling fields above remain the one-shot surface. Absent on one-shot configs and non-provision phases. + * State-aware provision-phase configuration (`wslc.provision`). Carries the container-creation knobs for the state-aware lifecycle; the flat sibling fields above remain the one-shot surface. Absent on one-shot configs and non-provision phases. */ provision?: WslcProvisionPhase | null; /** @@ -578,11 +577,10 @@ export interface Wslc { * OS inside the WSL container. */ targetOs?: string | null; - [k: string]: unknown; } /** - * Per-phase WSLc **provision** configuration (state-aware lifecycle), nested under `experimental.wslc.provision`. Carries only what the amortized daemon session honors: the container image (or a local tarball to import). + * Per-phase WSLc **provision** configuration (state-aware lifecycle), nested under `wslc.provision`. Carries only what the amortized daemon session honors: the container image (or a local tarball to import). * * Filesystem mounts and network mode derive from the top-level `policy` section (readwrite / readonly paths, network), not from here. The one-shot-only sizing knobs (`cpuCount` / `memoryMb` / `gpu` / `storagePath` / `portMappings`) are deliberately absent: the daemon shares a single session across sandboxes and does not apply per-sandbox sizing. start / exec / stop / deprovision carry no backend-specific config (the exec command flows through the top-level `process` section), so they have no phase struct. */ @@ -595,7 +593,6 @@ export interface WslcProvisionPhase { * Path to a local image tarball to import instead of pulling. */ imageTarPath?: string | null; - [k: string]: unknown; } /** @@ -678,5 +675,9 @@ export interface MXCConfiguration { * MXC config schema version (semver), e.g. `"0.9.0-alpha"`. */ version?: string | null; + /** + * WSL container backend settings (Windows). Used when containment is `wslc`. + */ + wslc?: Wslc | null; } diff --git a/sdk/node/src/platform.ts b/sdk/node/src/platform.ts index c99f2bf86..de4539793 100644 --- a/sdk/node/src/platform.ts +++ b/sdk/node/src/platform.ts @@ -150,9 +150,10 @@ function isUiCapabilitySupport(value: unknown): value is UiCapabilitySupport { /** * Run the probe binary and merge its results into `support`: the isolation - * tier, any warnings, portable UI capabilities, and the `isolation_session` - * and `hyperlight` methods when the probe reports them available. On any - * failure (binary missing, timeout, malformed JSON), the function + * tier, any warnings, portable UI capabilities, and — when the probe reports + * the corresponding runtime available — the `isolation_session`, `wslc`, and + * `hyperlight` methods. + * On any failure (binary missing, timeout, malformed JSON), the function * silently leaves those fields unset, so callers see the same contract as * pre-probe SDKs. */ @@ -178,6 +179,9 @@ function populateIsolationFromProbe(support: PlatformSupport): void { if (facts.isolationSessionAvailable === true) { support.availableMethods.push('isolation_session'); } + if (facts.wslcAvailable === true) { + support.availableMethods.push('wslc'); + } if (facts.hyperlightAvailable === true) { support.availableMethods.push('hyperlight'); } diff --git a/sdk/node/src/sandbox.ts b/sdk/node/src/sandbox.ts index 0094845a5..db18cc295 100644 --- a/sdk/node/src/sandbox.ts +++ b/sdk/node/src/sandbox.ts @@ -107,7 +107,7 @@ function selectDirectionalNetwork(policy: SandboxPolicy): boolean { /** * Builds the WSLC (WSL Container) portion of a ContainerConfig. * WSLC runs Linux containers on Windows via the WSL Container SDK. - * Config goes under `experimental.wslc` since WSLC is experimental. + * Config goes in the top-level `wslc` section. */ function buildWslcContainerConfig( config: ContainerConfig, @@ -117,10 +117,8 @@ function buildWslcContainerConfig( config.containment = 'wslc'; config.containerId = containerId; - config.experimental = { - wslc: { - image: 'alpine:latest', - }, + config.wslc = { + image: 'alpine:latest', }; // WSLC uses its own networking mode (None/Bridged) derived from @@ -315,12 +313,16 @@ export function createConfigFromPolicy( deniedPaths: [...(policy.filesystem?.deniedPaths ?? [])], }; - // UI mapping (cross-platform) - config.ui = { - disable: !(policy.ui?.allowWindows ?? false), - clipboard: policy.ui?.clipboard ?? "none", - injection: policy.ui?.allowInputInjection ?? false, - }; + // Emitted only when supplied: backends that cannot honour UI restrictions + // reject a present `ui` on presence alone, and an absent one already + // defaults to the same full lockdown natively. + if (policy.ui) { + config.ui = { + disable: !(policy.ui.allowWindows ?? false), + clipboard: policy.ui.clipboard ?? "none", + injection: policy.ui.allowInputInjection ?? false, + }; + } if (directionalNetwork) { if (policy.network?.egress !== undefined || policy.network?.ingress !== undefined) { diff --git a/sdk/node/src/state-aware-helper.ts b/sdk/node/src/state-aware-helper.ts index 5174ce424..9f0808faa 100644 --- a/sdk/node/src/state-aware-helper.ts +++ b/sdk/node/src/state-aware-helper.ts @@ -10,16 +10,15 @@ import { Phase, StateAwareContainmentBackend } from './state-aware-types.js'; export const STATE_AWARE_VERSION = '0.6.0-alpha'; -// WSLc's state-aware surface shipped at a later schema version than the -// `STATE_AWARE_VERSION` default above (the shared default for IsolationSession -// and Windows Sandbox). WSLc is intentionally NOT gate-locked to it: the -// backends were promoted independently, so WSLc carries its own later default. +// WSLc envelopes carry a top-level `wslc` section, which the closed stable +// surface first defines in 0.9. Stamping them with the `STATE_AWARE_VERSION` +// above would claim a schema in which that section does not exist. // See `DEFAULT_STATE_AWARE_VERSION`. -export const WSLC_STATE_AWARE_VERSION = '0.8.0-alpha'; +export const WSLC_STATE_AWARE_VERSION = '0.9.0-alpha'; // Wire-format cross-cutting fields that live at the envelope's top level. // Anything else on a per-(backend, phase) Config is backend-specific and is -// nested under `experimental..`. +// nested under the backend's section root — see {@link BACKEND_SECTION_ROOT}. export const CROSS_CUTTING_FIELDS = ['filesystem', 'network', 'ui', 'process'] as const; // Per-backend wire-format prefix. Each value mirrors the corresponding @@ -61,6 +60,18 @@ export const PREFIX_TO_BACKEND: Record = O ), ); +// Where a backend's per-phase section lives on the wire. Mirrors the Rust +// `StatefulSandboxBackend::SECTION_ROOT` const: a backend that is still +// experimental nests its phases under `experimental..`, while +// a promoted backend carries a closed top-level `.` section. +// Typed exhaustively so adding a backend without declaring its root is a +// compile error. +export const BACKEND_SECTION_ROOT: Record = { + isolation_session: 'experimental', + windows_sandbox: 'experimental', + wslc: 'stable', +}; + /** * Resolves the wire-format backend key for a sandbox id by reading its * leading prefix segment. Throws an `MxcError` with `code: 'malformed_id'` @@ -92,12 +103,13 @@ export interface BuildEnvelopeArgs { * Constructs the wire-format JSON-shaped envelope for a state-aware request * from a per-(backend, phase) Config. Lifts cross-cutting fields * (filesystem, network, ui, process) to envelope top-level; nests any - * remaining backend-specific fields under `experimental..`. + * remaining backend-specific fields under the backend's section root — either + * `experimental..` or the promoted `.`. */ export function buildStateAwareEnvelope(args: BuildEnvelopeArgs): Record { const { phase, backendKey, containment, sandboxId, correlationVector, config } = args; // Copy of config; fields are removed as they are lifted into the envelope. - // Anything left becomes experimental... + // Anything left becomes the backend's per-phase section. const backendSpecific: Record = { ...(config ?? {}) }; const defaultVersion = DEFAULT_STATE_AWARE_VERSION[backendKey] ?? STATE_AWARE_VERSION; const version = (typeof backendSpecific.version === 'string' && backendSpecific.version) || defaultVersion; @@ -125,7 +137,12 @@ export function buildStateAwareEnvelope(args: BuildEnvelopeArgs): Record 0) { - envelope.experimental = { [backendKey]: { [phase]: backendSpecific } }; + const section = { [phase]: backendSpecific }; + if (BACKEND_SECTION_ROOT[backendKey] === 'stable') { + envelope[backendKey] = section; + } else { + envelope.experimental = { [backendKey]: section }; + } } return envelope; diff --git a/sdk/node/src/state-aware-types.ts b/sdk/node/src/state-aware-types.ts index 2334ec82b..20c3b0ac3 100644 --- a/sdk/node/src/state-aware-types.ts +++ b/sdk/node/src/state-aware-types.ts @@ -154,7 +154,7 @@ export interface WindowsSandboxDeprovisionConfig { // may be injected per-exec. export interface WslcProvisionConfig { - /** Schema version (semver). When omitted, the SDK fills in `0.8.0-alpha`. */ + /** Schema version (semver). When omitted, the SDK fills in `0.9.0-alpha`. */ version?: string; /** * Filesystem policy applied at provision and frozen for the life of the @@ -178,12 +178,12 @@ export interface WslcProvisionConfig { /** * Container image reference (e.g. `alpine:latest`). Defaults to * `alpine:latest` when omitted. Nested under - * `experimental.wslc.provision.image` on the wire. + * `wslc.provision.image` on the wire. */ image?: string; /** * Path to a local image tarball to import instead of pulling. Nested under - * `experimental.wslc.provision.imageTarPath` on the wire. + * `wslc.provision.imageTarPath` on the wire. */ imageTarPath?: string; } diff --git a/sdk/node/src/types.ts b/sdk/node/src/types.ts index 0d87a78ed..c8e93e11d 100644 --- a/sdk/node/src/types.ts +++ b/sdk/node/src/types.ts @@ -107,7 +107,7 @@ export type ContainmentBackend = * Containment values (abstract intent or concrete backend) that require * the `--experimental` flag. */ -export const ExperimentalBackends: readonly (ContainmentType | ContainmentBackend)[] = ['microvm', 'windows_sandbox', 'hyperlight', 'wslc', 'isolation_session']; +export const ExperimentalBackends: readonly (ContainmentType | ContainmentBackend)[] = ['microvm', 'windows_sandbox', 'hyperlight', 'isolation_session']; /** * Clipboard access policy levels @@ -392,13 +392,13 @@ export interface ContainerConfig { runtimeConfig?: RuntimeConfig; /** Experimental features (only applied when --experimental flag is set) */ experimental?: { - /** WSLC SDK configuration for Linux containers from Windows */ - wslc?: WslcConfig; /** Telemetry configuration for experimental TraceLogging ETW support */ telemetry?: TelemetryConfig; }; /** macOS Seatbelt sandbox configuration (macOS only) */ seatbelt?: SeatbeltConfig; + /** WSLC SDK configuration for Linux containers from Windows (Windows only) */ + wslc?: WslcConfig; /** Cross-platform UI configuration */ ui?: UiConfig; } diff --git a/sdk/node/tests/integration/wslc-e2e.test.ts b/sdk/node/tests/integration/wslc-e2e.test.ts index 8fea36525..f47f571c4 100644 --- a/sdk/node/tests/integration/wslc-e2e.test.ts +++ b/sdk/node/tests/integration/wslc-e2e.test.ts @@ -67,9 +67,9 @@ describe('WSLC SDK E2E — createConfigFromPolicy → customize → spawn', { "cat /proc/meminfo | grep MemTotal", "echo 'All fields work'", ].join(' && '); - config.experimental!.wslc!.image = 'python:3.12-alpine'; - config.experimental!.wslc!.cpuCount = 2; - config.experimental!.wslc!.memoryMb = 1024; + config.wslc!.image = 'python:3.12-alpine'; + config.wslc!.cpuCount = 2; + config.wslc!.memoryMb = 1024; // Intentionally omit `storagePath` so this test reuses the default // image store where `python:3.12-alpine` has already been pre-pulled // (the docs require operators to pre-pull). Setting storagePath to a @@ -136,8 +136,8 @@ srv.handle_request() `; const scriptB64 = Buffer.from(pythonScript, 'utf8').toString('base64'); config.process!.commandLine = `python3 -c "import base64; exec(base64.b64decode('${scriptB64}'))"`; - config.experimental!.wslc!.image = 'python:3.12-alpine'; - config.experimental!.wslc!.portMappings = [ + config.wslc!.image = 'python:3.12-alpine'; + config.wslc!.portMappings = [ { windowsPort: HOST_PORT, containerPort: CONTAINER_PORT, protocol: 'tcp' }, ]; @@ -215,8 +215,8 @@ srv.handle_request() }; const config = sdk.createConfigFromPolicy(policy, 'wslc'); config.process!.commandLine = 'echo unreachable'; - config.experimental!.wslc!.image = 'python:3.12-alpine'; - config.experimental!.wslc!.portMappings = [ + config.wslc!.image = 'python:3.12-alpine'; + config.wslc!.portMappings = [ { windowsPort: 39000, containerPort: 9000, protocol: 'udp' as unknown as 'tcp' }, ]; diff --git a/sdk/node/tests/unit/conformance-helpers.ts b/sdk/node/tests/unit/conformance-helpers.ts index 720bdf2b0..3280bf782 100644 --- a/sdk/node/tests/unit/conformance-helpers.ts +++ b/sdk/node/tests/unit/conformance-helpers.ts @@ -14,8 +14,8 @@ export type AssertTrue = T; export type StripIndex = { [K in keyof T as string extends K ? never : K]: T[K] }; /** - * Recursively drop index signatures (open objects nest: e.g. `experimental.wslc` - * and the `IsolationSession*` objects), so structural assignment is not tripped + * Recursively drop index signatures (open objects nest: e.g. the `experimental` + * block and the `IsolationSession*` objects), so structural assignment is not tripped * by an emitted `[k: string]: unknown` at any depth. Modifiers (`?`) are * preserved because the mapped type is homomorphic over `keyof T`. */ diff --git a/sdk/node/tests/unit/platform.test.ts b/sdk/node/tests/unit/platform.test.ts index aa7f6421a..19064036f 100644 --- a/sdk/node/tests/unit/platform.test.ts +++ b/sdk/node/tests/unit/platform.test.ts @@ -445,6 +445,79 @@ describe('isolation_session availability gate', () => { }); }); +describe('wslc availability gate', () => { + beforeEach(() => { + _resetPlatformSupportCache(); + }); + + afterEach(() => { + _setProbeRunner(null); + _resetPlatformSupportCache(); + }); + + it('includes wslc when the probe reports it available', { skip: !isWindows }, () => { + _setProbeRunner(() => + JSON.stringify({ tier: 'base-container', probes: { wslcAvailable: true } }), + ); + const support = getPlatformSupport(); + assert.ok( + support.availableMethods.includes('wslc'), + `expected wslc present, got: ${support.availableMethods.join(',')}`, + ); + }); + + it('omits wslc when the probe reports it unavailable', { skip: !isWindows }, () => { + _setProbeRunner(() => + JSON.stringify({ tier: 'base-container', probes: { wslcAvailable: false } }), + ); + const support = getPlatformSupport(); + assert.ok( + !support.availableMethods.includes('wslc'), + `expected wslc absent, got: ${support.availableMethods.join(',')}`, + ); + }); + + it('omits wslc when the probes block omits the field', { skip: !isWindows }, () => { + _setProbeRunner(() => JSON.stringify({ tier: 'base-container', probes: {} })); + const support = getPlatformSupport(); + assert.ok(!support.availableMethods.includes('wslc')); + }); + + it('omits wslc for a truthy non-boolean probe value', { skip: !isWindows }, () => { + for (const value of ['true', 1, {}, []]) { + _resetPlatformSupportCache(); + _setProbeRunner(() => + JSON.stringify({ tier: 'base-container', probes: { wslcAvailable: value } }), + ); + assert.ok( + !getPlatformSupport().availableMethods.includes('wslc'), + `${JSON.stringify(value)} is truthy but not true, so it must not enable wslc`, + ); + } + }); + + it('omits wslc when the probe fails', { skip: !isWindows }, () => { + _setProbeRunner(() => { + throw new Error('probe failed'); + }); + const support = getPlatformSupport(); + assert.ok(support.isSupported, 'Windows support is independent of the probe'); + assert.ok(!support.availableMethods.includes('wslc')); + }); + + it('gates wslc and isolation_session independently', { skip: !isWindows }, () => { + _setProbeRunner(() => + JSON.stringify({ + tier: 'base-container', + probes: { isolationSessionAvailable: false, wslcAvailable: true }, + }), + ); + const support = getPlatformSupport(); + assert.ok(support.availableMethods.includes('wslc')); + assert.ok(!support.availableMethods.includes('isolation_session')); + }); +}); + describe('hyperlight availability gate', () => { beforeEach(() => { _resetPlatformSupportCache(); diff --git a/sdk/node/tests/unit/sandbox.test.ts b/sdk/node/tests/unit/sandbox.test.ts index 526cda2c0..892a92295 100644 --- a/sdk/node/tests/unit/sandbox.test.ts +++ b/sdk/node/tests/unit/sandbox.test.ts @@ -3,7 +3,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; -import { buildSandboxPayload, createConfigFromPolicy, spawnSandbox, spawnSandboxFromConfig } from '../../src/sandbox.js'; +import { buildSandboxPayload, createConfigFromPolicy, spawnSandbox } from '../../src/sandbox.js'; import { resolveExecutableAndArgs } from '../../src/helper.js'; import { _resetPlatformSupportCache, @@ -453,10 +453,10 @@ describe('buildSandboxPayload', () => { assert.strictEqual(payload.process!.commandLine, 'echo hello'); }); - it('should populate experimental.wslc with default image', () => { + it('should populate the top-level wslc section with default image', () => { const payload = buildSandboxPayload('echo hello', { version: '0.6.0-alpha' }, undefined, undefined, 'wslc'); - assert.ok(payload.experimental?.wslc); - assert.strictEqual(payload.experimental!.wslc!.image, 'alpine:latest'); + assert.ok(payload.wslc); + assert.strictEqual(payload.wslc!.image, 'alpine:latest'); }); it('should not set processContainer or lxc config', () => { @@ -481,15 +481,17 @@ describe('createConfigFromPolicy', () => { assert.deepStrictEqual(config.filesystem!.readwritePaths, []); assert.deepStrictEqual(config.filesystem!.readonlyPaths, []); assert.deepStrictEqual(config.filesystem!.deniedPaths, []); - assert.strictEqual(config.ui!.disable, true); - assert.strictEqual(config.ui!.clipboard, 'none'); - assert.strictEqual(config.ui!.injection, false); assert.strictEqual(config.process!.timeout, 0); assert.strictEqual(config.process!.commandLine, ''); assert.strictEqual(config.lifecycle!.destroyOnExit, true); assert.strictEqual(config.lifecycle!.preservePolicy, false); }); + it('should omit ui when the policy does not set it', () => { + const config = createConfigFromPolicy(defaultPolicy); + assert.strictEqual(config.ui, undefined); + }); + it('should pass filesystem paths through', () => { const config = createConfigFromPolicy({ version: '0.6.0-alpha', @@ -1515,11 +1517,11 @@ describe('createConfigFromPolicy', () => { }); describe('WSLC', () => { - it('should set containment to wslc and populate experimental.wslc', () => { + it('should set containment to wslc and populate the top-level wslc section', () => { const config = createConfigFromPolicy({ version: '0.6.0-alpha' }, 'wslc'); assert.strictEqual(config.containment, 'wslc'); - assert.ok(config.experimental?.wslc); - assert.strictEqual(config.experimental!.wslc!.image, 'alpine:latest'); + assert.ok(config.wslc); + assert.strictEqual(config.wslc!.image, 'alpine:latest'); }); it('should forward schema 0.8 ProcessContainer peer policy for native rejection', () => { @@ -1580,6 +1582,19 @@ describe('createConfigFromPolicy', () => { assert.strictEqual(config.lxc, undefined); }); + it('should omit ui for wslc when the policy does not set it', () => { + const config = createConfigFromPolicy({ version: '0.6.0-alpha' }, 'wslc'); + assert.strictEqual(config.ui, undefined); + }); + + it('should emit ui for wslc when the policy sets it', () => { + const config = createConfigFromPolicy({ + version: '0.6.0-alpha', + ui: { allowWindows: true }, + }, 'wslc'); + assert.strictEqual(config.ui!.disable, false); + }); + it('should map filesystem paths correctly', () => { const config = createConfigFromPolicy({ version: '0.6.0-alpha', @@ -1606,24 +1621,6 @@ describe('createConfigFromPolicy', () => { const config = createConfigFromPolicy({ version: '0.6.0-alpha' }, 'wslc', 'my-container'); assert.strictEqual(config.containerId, 'my-container'); }); - - it('should throw from spawnSandbox when experimental backend is used via config', () => { - const config = createConfigFromPolicy({ version: '0.6.0-alpha' }, 'wslc'); - config.process!.commandLine = 'echo hello'; - assert.throws( - () => spawnSandboxFromConfig(config), - { message: /experimental mode/ }, - ); - }); - - it('should throw from spawnSandboxFromConfig when experimental is not set', () => { - const config = createConfigFromPolicy({ version: '0.6.0-alpha' }, 'wslc'); - config.process!.commandLine = 'echo hello'; - assert.throws( - () => spawnSandboxFromConfig(config), - { message: /experimental mode/ }, - ); - }); }); describe('Bubblewrap', () => { @@ -1787,13 +1784,21 @@ describe('resolveExecutableAndArgs (containment validation)', { skip: platformSk } }); - it('should still require experimental mode for experimental backends like wslc', () => { + it('should still require experimental mode for experimental backends like windows_sandbox', () => { assert.throws( - () => resolveExecutableAndArgs(makeConfig('wslc'), { executablePath: fakeExe }), + () => resolveExecutableAndArgs(makeConfig('windows_sandbox'), { executablePath: fakeExe }), { message: /experimental mode/ }, ); }); + it('should NOT require experimental mode for explicit wslc containment', () => { + const resolved = resolveExecutableAndArgs(makeConfig('wslc'), { + executablePath: fakeExe, + skipPlatformCheck: true, + }); + assert.ok(!resolved.args.includes('--experimental')); + }); + it('should NOT require experimental mode for explicit lxc containment', function (this: { skip: (reason?: string) => void }) { if (process.platform !== 'linux') { this.skip('lxc is Linux-only'); diff --git a/sdk/node/tests/unit/state-aware.test.ts b/sdk/node/tests/unit/state-aware.test.ts index f74727167..257107323 100644 --- a/sdk/node/tests/unit/state-aware.test.ts +++ b/sdk/node/tests/unit/state-aware.test.ts @@ -553,14 +553,14 @@ describe('windows_sandbox state-aware lifecycle', () => { }); describe('wslc state-aware lifecycle', () => { - it('defaults the version to 0.8.0-alpha (not the isolation_session default)', () => { + it('defaults the version to 0.9.0-alpha (not the isolation_session default)', () => { const env = buildStateAwareEnvelope({ phase: 'provision', backendKey: 'wslc', containment: 'wslc', config: { image: 'alpine:latest' }, }); - assert.strictEqual(env.version, '0.8.0-alpha'); + assert.strictEqual(env.version, '0.9.0-alpha'); }); it('still honors a caller-supplied version over the wslc default', () => { @@ -573,7 +573,7 @@ describe('wslc state-aware lifecycle', () => { assert.strictEqual(env.version, '0.8.1-alpha'); }); - it('lifts filesystem + network and nests image under experimental.wslc.provision', () => { + it('lifts filesystem + network and nests image under the top-level wslc.provision', () => { const env = buildStateAwareEnvelope({ phase: 'provision', backendKey: 'wslc', @@ -589,12 +589,13 @@ describe('wslc state-aware lifecycle', () => { assert.deepStrictEqual(env.filesystem, { readwritePaths: ['C:\\ws\\rw'] }); assert.deepStrictEqual(env.network, { defaultPolicy: 'allow' }); const wire = JSON.parse(JSON.stringify(env)); - assert.deepStrictEqual(wire.experimental, { - wslc: { provision: { image: 'alpine:latest', imageTarPath: 'C:\\images\\alpine.tar' } }, + assert.strictEqual(wire.experimental, undefined); + assert.deepStrictEqual(wire.wslc, { + provision: { image: 'alpine:latest', imageTarPath: 'C:\\images\\alpine.tar' }, }); }); - it('omits the experimental block when provision carries no backend-specific field', () => { + it('omits the wslc block when provision carries no backend-specific field', () => { const env = buildStateAwareEnvelope({ phase: 'provision', backendKey: 'wslc', @@ -602,10 +603,11 @@ describe('wslc state-aware lifecycle', () => { config: { network: { defaultPolicy: 'block' } }, }); assert.strictEqual(env.experimental, undefined); + assert.strictEqual(env.wslc, undefined); assert.deepStrictEqual(env.network, { defaultPolicy: 'block' }); }); - it('lifts exec process + cooperative proxy network to top-level with no experimental block', () => { + it('lifts exec process + cooperative proxy network to top-level with no wslc block', () => { const env = buildStateAwareEnvelope({ phase: 'exec', backendKey: 'wslc', @@ -618,6 +620,7 @@ describe('wslc state-aware lifecycle', () => { assert.deepStrictEqual(env.process, { commandLine: 'echo hi' }); assert.deepStrictEqual(env.network, { proxy: { url: 'http://127.0.0.1:8888' } }); assert.strictEqual(env.experimental, undefined); + assert.strictEqual(env.wslc, undefined); }); describe('round-trip via the typed API', { skip: platformSkip }, () => { @@ -634,7 +637,7 @@ describe('wslc state-aware lifecycle', () => { assert.strictEqual(result.sandboxId, 'wslc:0123abcd'); assert.strictEqual(fake.captured.envelope?.phase, 'provision'); assert.strictEqual(fake.captured.envelope?.containment, 'wslc'); - assert.strictEqual(fake.captured.envelope?.version, '0.8.0-alpha'); + assert.strictEqual(fake.captured.envelope?.version, '0.9.0-alpha'); }); it('startSandbox infers wslc from the wslc: prefix', async () => { diff --git a/sdk/node/tests/unit/wire-conformance-state-aware.test.ts b/sdk/node/tests/unit/wire-conformance-state-aware.test.ts index 0ea7488c1..4010476b5 100644 --- a/sdk/node/tests/unit/wire-conformance-state-aware.test.ts +++ b/sdk/node/tests/unit/wire-conformance-state-aware.test.ts @@ -83,7 +83,7 @@ type _Phase = AssertTrue>; // // `filesystem` is a lifted top-level wire field (like `network`): WSLc provision // surfaces it publicly but it maps to the envelope's top-level `filesystem`, not -// under `experimental.wslc.provision`. Listing it here keeps the backend-key set +// under `wslc.provision`. Listing it here keeps the backend-key set // limited to genuinely per-phase wire fields. type LiftedPhaseKey = 'version' | 'process' | 'network' | 'filesystem'; diff --git a/src/backends/appcontainer/common/src/probe.rs b/src/backends/appcontainer/common/src/probe.rs index cbdea3365..25f7d6419 100644 --- a/src/backends/appcontainer/common/src/probe.rs +++ b/src/backends/appcontainer/common/src/probe.rs @@ -70,6 +70,11 @@ pub struct ProbeFacts { /// the isolation-session backend; `wxc-exec --probe` overrides it when /// that backend is compiled in. pub isolation_session_available: bool, + /// Whether the host can actually run WSLc (WSL2 present and the WSLc + /// runtime loadable). Always `false` here — `appcontainer_common` has no + /// dependency on the WSLc backend; `wxc-exec --probe` overrides it when + /// that backend is compiled in. + pub wslc_available: bool, /// Whether Hyperlight (WHP micro-VM) is available on this host. Always /// `false` here — overridden by `wxc-exec --probe` when the hyperlight /// feature is compiled in and WHP is loadable. @@ -136,6 +141,7 @@ pub fn run_probe(policy: &ContainerPolicy) -> ProbeOutput { base_container_supports_deny_paths: crate::base_container_runner::BaseContainerRunner::base_container_supports_deny_paths(), isolation_session_available: false, + wslc_available: false, hyperlight_available: false, ui_capabilities: crate::job_object::supported_ui_restrictions().into(), }; @@ -213,6 +219,7 @@ mod tests { bfs_compiled_in: false, base_container_supports_deny_paths: false, isolation_session_available: true, + wslc_available: true, hyperlight_available: false, ui_capabilities: all_ui_capabilities(), }, @@ -227,6 +234,7 @@ mod tests { assert_eq!(v["probes"]["bfscfgPresent"], false); assert_eq!(v["probes"]["bfsCompiledIn"], false); assert_eq!(v["probes"]["isolationSessionAvailable"], true); + assert_eq!(v["probes"]["wslcAvailable"], true); assert_eq!(v["probes"]["uiCapabilities"]["canBlockClipboardRead"], true); assert_eq!( v["probes"]["uiCapabilities"]["canBlockInputInjection"], @@ -251,6 +259,7 @@ mod tests { bfs_compiled_in: false, base_container_supports_deny_paths: false, isolation_session_available: false, + wslc_available: false, hyperlight_available: false, ui_capabilities: UiCapabilitySupport { can_block_input_injection: false, @@ -343,4 +352,15 @@ mod tests { "isolationSessionAvailable must always be present, got: {v}" ); } + + #[test] + fn probe_always_emits_wslc_available() { + let out = run_probe(&ContainerPolicy::default()); + let v = serde_json::to_value(&out).expect("to_value"); + let probes = v["probes"].as_object().expect("probes object"); + assert!( + probes.contains_key("wslcAvailable"), + "wslcAvailable must always be present, got: {v}" + ); + } } diff --git a/src/backends/wslc/common/src/daemon_protocol.rs b/src/backends/wslc/common/src/daemon_protocol.rs index def14663d..851b2597f 100644 --- a/src/backends/wslc/common/src/daemon_protocol.rs +++ b/src/backends/wslc/common/src/daemon_protocol.rs @@ -20,7 +20,7 @@ //! //! # Layering //! These are the daemon's **own internal** config structs, deliberately -//! separate from the public `experimental.wslc.*` wire schema. The state-aware +//! separate from the public `wslc.*` wire schema. The state-aware //! backend (a later PR) is the translator between the public wire model and //! this protocol; keeping them decoupled lets the daemon + IPC ship and be //! fully tested without touching the wire schema or its CI gates. diff --git a/src/backends/wslc/common/src/policy.rs b/src/backends/wslc/common/src/policy.rs index ecf4b8e47..0fc756bd7 100644 --- a/src/backends/wslc/common/src/policy.rs +++ b/src/backends/wslc/common/src/policy.rs @@ -170,8 +170,8 @@ pub(crate) fn reject_unsupported_enforcement_mode( } /// Reject inbound local networking at provision. Separate from the one-shot -/// message, which points at `experimental.wslc.portMappings` — a primitive the -/// state-aware surface does not have. +/// message, which points at `wslc.portMappings` — a primitive the state-aware +/// surface does not have. fn reject_provision_allow_local_network(request: &ExecutionRequest) -> Result<(), MxcError> { if request.policy.allow_local_network { return Err(MxcError::policy_validation( diff --git a/src/backends/wslc/common/src/state_aware.rs b/src/backends/wslc/common/src/state_aware.rs index 94c8e1cb6..6c44c2c20 100644 --- a/src/backends/wslc/common/src/state_aware.rs +++ b/src/backends/wslc/common/src/state_aware.rs @@ -6,7 +6,7 @@ //! Each lifecycle phase (`provision` / `start` / `exec` / `stop` / //! `deprovision`) runs as a separate short-lived `wxc-exec` process. Because the //! WSLc SDK has no cross-process re-attach, this backend does **not** touch the -//! SDK directly: it translates the public `experimental.wslc.*` wire model plus +//! SDK directly: it translates the public `wslc.*` wire model plus //! the cross-cutting `policy` section into [`daemon_protocol`] frames and drives //! the long-lived `wxc-wslc-daemon` (which owns the live session/container //! handles) over an owner-only named pipe via [`DaemonClient`]. @@ -22,6 +22,7 @@ use wxc_common::state_aware_backend::{ null_pipe_handle, DeprovisionResult, ExecHandle, ExecOutcome, ExecStdio, ProvisionResult, StartResult, StatefulSandboxBackend, StopResult, }; +use wxc_common::state_aware_request::SectionRoot; use wxc_common::validator::{validate_state_aware_network_policy_support, NetworkPolicySupport}; use wxc_common::wire::WslcProvisionPhase; @@ -35,7 +36,7 @@ use crate::policy::{ exec_proxy_url, validate_exec_policy, validate_post_provision_policy, validate_provision_policy, }; -/// Default image when a provision request omits `experimental.wslc.provision.image`. +/// Default image when a provision request omits `wslc.provision.image`. const DEFAULT_IMAGE: &str = "alpine:latest"; /// State-aware WSLc backend. Zero-sized: every phase opens a fresh @@ -52,6 +53,7 @@ impl WslcStateAwareRunner { impl StatefulSandboxBackend for WslcStateAwareRunner { const ID_PREFIX: &'static str = "wslc"; const BACKEND_KEY: &'static str = "wslc"; + const SECTION_ROOT: SectionRoot = SectionRoot::Stable; type ProvisionConfig = WslcProvisionPhase; type StartConfig = (); diff --git a/src/backends/wslc/common/src/wsl_container_runner.rs b/src/backends/wslc/common/src/wsl_container_runner.rs index 5f23561c9..758bedcf2 100644 --- a/src/backends/wslc/common/src/wsl_container_runner.rs +++ b/src/backends/wslc/common/src/wsl_container_runner.rs @@ -675,7 +675,7 @@ impl ScriptRunner for WSLContainerRunner { if request.policy.allow_local_network { return Err(WslcError::Rejected( "WSLc: network.allowLocalNetwork=true is not supported. Expose specific \ - ports with experimental.wslc portMappings instead." + ports with wslc.portMappings instead." .to_string(), ) .into_response()); @@ -932,7 +932,7 @@ impl WSLContainerRunner { // setup script `scripts\setup-wslc.ps1` (or `wxc-exec.exe // --setup-wslc --image `) pre-pulls images into the same // WSLC storage_path the runner uses. When the config overrides - // `experimental.wslc.storagePath`, include it in the suggested + // `wslc.storagePath`, include it in the suggested // commands so the operator's first copy-paste lands the image in // the cache the next run will actually read. let (storage_arg_wxc, storage_arg_ps) = match &self.config.storage_path { @@ -2441,7 +2441,7 @@ mod tests { } /// The two surfaces refuse `allowLocalNetwork` with deliberately different - /// remedies: one-shot has `experimental.wslc.portMappings` to point at, + /// remedies: one-shot has `wslc.portMappings` to point at, /// state-aware has no port-mapping primitive at all. Unifying the messages /// would send state-aware users after a dead end. #[test] diff --git a/src/core/mxc-sdk/Cargo.toml b/src/core/mxc-sdk/Cargo.toml index b3a4bd747..9fbbc8b37 100644 --- a/src/core/mxc-sdk/Cargo.toml +++ b/src/core/mxc-sdk/Cargo.toml @@ -11,8 +11,8 @@ path = "src/lib.rs" [features] default = [] -# WSL Container backend (Windows only, experimental). Forwards to the engine, -# which links the WSLC SDK loader; see `mxc_engine`'s `wslc` feature. +# WSL Container backend (Windows only). Forwards to the engine, which links +# the WSLC SDK loader; see `mxc_engine`'s `wslc` feature. wslc = ["mxc_engine/wslc"] # IsolationSession backend (Windows only, experimental). isolation_session = ["mxc_engine/isolation_session"] diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index e5679fc4b..5b85f0675 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -322,10 +322,11 @@ state-aware sandbox lifecycle from a wire-format request JSON string: Both take the same request JSON and differ only in where the workload's stdio goes. -Every state-aware backend is experimental, so `experimental` is the in-process -equivalent of the executor's `--experimental` flag: without it the request is -refused with `ErrorCode::BackendUnavailable` before any work happens. It is an -API parameter, not a field in the request JSON. +`experimental` is the in-process equivalent of the executor's `--experimental` +flag: an **experimental** state-aware backend (Windows Sandbox, IsolationSession) +is refused with `ErrorCode::BackendUnavailable` before any work happens unless it +is set. Stable state-aware backends (WSLc) do not need it. It is an API +parameter, not a field in the request JSON. The example needs this crate's `isolation_session` feature and a host running the OS-side service. @@ -398,16 +399,15 @@ Backends with no variant at all — Windows Sandbox, MicroVM, Hyperlight, LXC cannot be named from this crate; use the executor binaries. Windows Sandbox is still reachable here through the state-aware lifecycle. -### WSLC (experimental) +### WSLC WSLC runs a Linux container on a Windows host through the WSLC SDK. It is -opt-in on two axes: build this crate with its **`wslc` feature**, and call -[`SandboxRequest::set_experimental(true)`] on the request (the library-side -equivalent of the executor's `--experimental`). Its settings — image, vCPUs, -memory, GPU, storage path, port forwards — are carried by the [`WslcSection`] -inside [`Containment::Wslc`], mirroring the SDK's `experimental.wslc` block, and -go through the same parser the executor uses — so a rejected value (e.g. a port -mapping with a zero or duplicated host port) fails at build time, not at spawn. +opt-in on one axis only: build this crate with its **`wslc` feature**. Its +settings — image, vCPUs, memory, GPU, storage path, port forwards — are carried +by the [`WslcSection`] inside [`Containment::Wslc`], mirroring the SDK's +top-level `wslc` block, and go through the same parser the executor uses — so a +rejected value (e.g. a port mapping with a zero or duplicated host port) fails +at build time, not at spawn. ```rust,no_run use mxc_sdk::{ @@ -420,7 +420,7 @@ use mxc_sdk::{ # }; let wslc = WslcSection { image: "python:3.12".to_string(), ..Default::default() }; let mut request = build_request_with_containment(&policy, &Containment::Wslc(wslc), None)?; -request.set_script("python3 -c 'print(42)'").set_experimental(true); +request.set_script("python3 -c 'print(42)'"); let output = run(request)?; # Ok::<(), mxc_sdk::Error>(()) ``` diff --git a/src/core/mxc-sdk/src/lib.rs b/src/core/mxc-sdk/src/lib.rs index 2de423421..e64eaa180 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -50,10 +50,9 @@ //! | Explicit ProcessContainer configuration | Windows | [`Containment::ProcessContainer`] | //! | WSLC (WSL Container) | Windows | [`Containment::Wslc`] | //! -//! WSLC is **experimental**: build with the crate's `wslc` feature, and call -//! [`SandboxRequest::set_experimental(true)`](SandboxRequest::set_experimental) -//! on the request. Its container has no stdin (the WSLC SDK exposes no -//! process-input API), so [`Sandbox::take_stdin`] returns `None` for it. +//! WSLC is opt-in at **build** time only: compile this crate with its `wslc` +//! feature. Its container has no stdin (the WSLC SDK exposes no process-input +//! API), so [`Sandbox::take_stdin`] returns `None` for it. //! //! Backends with no [`Containment`] variant return an [`Error`] with //! [`ErrorCode::UnsupportedContainment`]; drive the standalone executor @@ -94,7 +93,7 @@ //! // Run a command inside a WSL container (Windows, --features wslc). //! let wslc = WslcSection { image: "python:3.12".to_string(), ..Default::default() }; //! let mut request = build_request_with_containment(&policy, &Containment::Wslc(wslc), None)?; -//! request.set_script("python3 -c 'print(42)'").set_experimental(true); +//! request.set_script("python3 -c 'print(42)'"); //! let output = run(request)?; //! # Ok::<(), mxc_sdk::Error>(()) //! ``` @@ -206,8 +205,8 @@ pub fn run(request: SandboxRequest) -> Result { /// failures) come back as an [`Error`] with the matching [`ErrorCode`]. /// /// `experimental` is the in-process equivalent of the executor's -/// `--experimental` flag. The experimental backends — WindowsSandbox, -/// IsolationSession and WSLc — are refused with +/// `--experimental` flag. The experimental backends — WindowsSandbox and +/// IsolationSession — are refused with /// [`ErrorCode::BackendUnavailable`] unless it is set, before any work is done. /// It is an API parameter rather than a field in the request JSON so that a /// config cannot grant itself experimental access. diff --git a/src/core/mxc_config_contract/src/dev/experimental.rs b/src/core/mxc_config_contract/src/dev/experimental.rs index 656019f53..f0fb9209a 100644 --- a/src/core/mxc_config_contract/src/dev/experimental.rs +++ b/src/core/mxc_config_contract/src/dev/experimental.rs @@ -2,7 +2,6 @@ // Licensed under the MIT License. use super::primitives::OptionalField; -use std::num::NonZeroU16; /// Placeholder feature used to exercise experimental configuration plumbing. #[derive(Debug, serde::Deserialize)] @@ -40,60 +39,6 @@ pub struct OneShotWindowsSandbox { pub daemon_pipe_name: OptionalField, } -string_enum! { - /// Transport protocol for a WSLC port mapping. - #[derive(Debug)] - pub enum TransportProtocol { - /// TCP transport. - Tcp => ["tcp"], - } -} - -/// A host-to-container WSLC port mapping. -#[derive(Debug, serde::Deserialize)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct PortMapping { - /// Non-zero TCP port on the Windows host. - pub windows_port: NonZeroU16, - /// Non-zero TCP port inside the container. - pub container_port: NonZeroU16, - /// Optional transport protocol. Only TCP is currently supported. - #[serde(default)] - pub protocol: OptionalField, -} - -/// One-shot WSLC backend settings. -#[derive(Debug, serde::Deserialize)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct OneShotWslc { - /// Target operating system inside the container. - #[serde(default)] - pub target_os: OptionalField, - /// Container image reference. - #[serde(default)] - pub image: OptionalField, - /// Path to a local image tarball to import. - #[serde(default)] - pub image_tar_path: OptionalField, - /// Requested virtual CPU count. - #[serde(default)] - pub cpu_count: OptionalField, - /// Requested memory limit in megabytes. - #[serde(default)] - pub memory_mb: OptionalField, - /// Whether GPU passthrough is enabled. - #[serde(default)] - pub gpu: OptionalField, - /// Optional storage path override. - #[serde(default)] - pub storage_path: OptionalField, - /// Optional host-to-container TCP port mappings. - #[serde(default)] - pub port_mappings: OptionalField>, -} - /// Experimental settings. #[derive(Debug, serde::Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] @@ -105,9 +50,6 @@ pub struct OneShotExperimental { /// Optional one-shot Windows Sandbox compatibility settings. #[serde(rename = "windows_sandbox", default)] pub windows_sandbox: OptionalField, - /// Optional one-shot WSLC backend settings. - #[serde(default)] - pub wslc: OptionalField, /// Optional telemetry override. #[serde(default)] pub telemetry: OptionalField, diff --git a/src/core/mxc_config_contract/src/dev/mod.rs b/src/core/mxc_config_contract/src/dev/mod.rs index cacf3e9c1..83ed8c923 100644 --- a/src/core/mxc_config_contract/src/dev/mod.rs +++ b/src/core/mxc_config_contract/src/dev/mod.rs @@ -230,10 +230,7 @@ mod stable; /// The development `0.9.0-alpha` state-aware configuration contract. mod state_aware; -pub use experimental::{ - OneShotExperimental, OneShotWindowsSandbox, OneShotWslc, PortMapping, Telemetry, TestFeature, - TransportProtocol, -}; +pub use experimental::{OneShotExperimental, OneShotWindowsSandbox, Telemetry, TestFeature}; pub use network::{ DefaultNetworkPolicy, Network, NetworkAction, NetworkEgress, NetworkEnforcementMode, NetworkIngress, NetworkPeer, NetworkPort, NetworkProtocol, NetworkProxy, NetworkRule, @@ -245,8 +242,9 @@ pub use request::{parse_request, Request, RequestParseError}; pub use schema::development_schema; pub use stable::{ CaptureDenials, CaptureDenialsMode, Fallback, Filesystem, LaunchMethod, Lifecycle, Lxc, - Process, ProcessContainer, ProcessContainerCapability, ProcessContainerNetwork, - ProcessContainerUi, ProcessContainerUiIsolation, RuntimeConfig, Seatbelt, Ui, UiClipboard, + PortMapping, Process, ProcessContainer, ProcessContainerCapability, ProcessContainerNetwork, + ProcessContainerUi, ProcessContainerUiIsolation, RuntimeConfig, Seatbelt, TransportProtocol, + Ui, UiClipboard, Wslc, }; pub use state_aware::{probe_containment, Containment, ContainmentProbeError}; pub use state_aware::{probe_phase, Phase, PhaseProbeError}; diff --git a/src/core/mxc_config_contract/src/dev/one_shot.rs b/src/core/mxc_config_contract/src/dev/one_shot.rs index 0d683bc23..ba8478e20 100644 --- a/src/core/mxc_config_contract/src/dev/one_shot.rs +++ b/src/core/mxc_config_contract/src/dev/one_shot.rs @@ -6,6 +6,7 @@ use super::network::Network; use super::primitives::OptionalField; use super::stable::{ Fallback, Filesystem, Lifecycle, Lxc, Process, ProcessContainer, RuntimeConfig, Seatbelt, Ui, + Wslc, }; use crate::dev::Version; @@ -88,6 +89,9 @@ pub struct Request { /// Optional macOS Seatbelt configuration. #[serde(alias = "macos_sandbox", default)] pub seatbelt: OptionalField, + /// Optional Windows WSL container configuration. + #[serde(default)] + pub wslc: OptionalField, /// Optional runtime configuration settings. #[serde(default)] pub runtime_config: OptionalField, diff --git a/src/core/mxc_config_contract/src/dev/stable.rs b/src/core/mxc_config_contract/src/dev/stable.rs index 8f03e7db3..7ddc68ace 100644 --- a/src/core/mxc_config_contract/src/dev/stable.rs +++ b/src/core/mxc_config_contract/src/dev/stable.rs @@ -3,6 +3,7 @@ use super::primitives::{NonEmptyString, OptionalField}; use serde::{de, Deserialize, Deserializer}; +use std::num::NonZeroU16; /// Container lifecycle settings. #[derive(Debug, serde::Deserialize)] @@ -352,3 +353,57 @@ pub struct Seatbelt { #[serde(default)] pub extra_mach_lookups: OptionalField>, } + +string_enum! { + /// Transport protocol for a WSLC port mapping. + #[derive(Debug)] + pub enum TransportProtocol { + /// TCP transport. + Tcp => ["tcp"], + } +} + +/// A host-to-container WSLC port mapping. +#[derive(Debug, serde::Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PortMapping { + /// Non-zero TCP port on the Windows host. + pub windows_port: NonZeroU16, + /// Non-zero TCP port inside the container. + pub container_port: NonZeroU16, + /// Optional transport protocol. Only TCP is currently supported. + #[serde(default)] + pub protocol: OptionalField, +} + +/// Windows WSL container backend settings. +#[derive(Debug, serde::Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Wslc { + /// Target operating system inside the container. + #[serde(default)] + pub target_os: OptionalField, + /// Container image reference. + #[serde(default)] + pub image: OptionalField, + /// Path to a local image tarball to import. + #[serde(default)] + pub image_tar_path: OptionalField, + /// Requested virtual CPU count. + #[serde(default)] + pub cpu_count: OptionalField, + /// Requested memory limit in megabytes. + #[serde(default)] + pub memory_mb: OptionalField, + /// Whether GPU passthrough is enabled. + #[serde(default)] + pub gpu: OptionalField, + /// Optional storage path override. + #[serde(default)] + pub storage_path: OptionalField, + /// Optional host-to-container TCP port mappings. + #[serde(default)] + pub port_mappings: OptionalField>, +} diff --git a/src/core/mxc_config_contract/src/dev/state_aware/provision/wslc.rs b/src/core/mxc_config_contract/src/dev/state_aware/provision/wslc.rs index e53730962..7c42e5c3a 100644 --- a/src/core/mxc_config_contract/src/dev/state_aware/provision/wslc.rs +++ b/src/core/mxc_config_contract/src/dev/state_aware/provision/wslc.rs @@ -24,7 +24,7 @@ pub struct WslcProvision { pub image_tar_path: OptionalField, } -/// State-aware WSLC experimental settings. +/// State-aware WSLC settings, carried by the top-level `wslc` section. #[derive(Debug, Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -39,9 +39,6 @@ pub struct StateAwareWslc { #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct WslcProvisionExperimental { - /// Optional WSLC backend settings. - #[serde(default)] - pub wslc: OptionalField, /// Optional telemetry override. #[serde(default)] pub telemetry: OptionalField, @@ -70,6 +67,9 @@ pub struct WslcProvisionRequest { /// Optional network policy fixed at provision time. #[serde(default)] pub network: OptionalField, + /// Optional WSLC backend settings fixed at provision time. + #[serde(default)] + pub wslc: OptionalField, /// Optional closed experimental settings. #[serde(default)] pub experimental: OptionalField, diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha.rs b/src/core/mxc_config_contract/tests/v0_9_0_alpha.rs index 5a947cbab..92d7249b0 100644 --- a/src/core/mxc_config_contract/tests/v0_9_0_alpha.rs +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha.rs @@ -23,3 +23,5 @@ mod request; mod seatbelt; #[path = "v0_9_0_alpha/state_aware.rs"] mod state_aware; +#[path = "v0_9_0_alpha/wslc.rs"] +mod wslc; diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/experimental.rs b/src/core/mxc_config_contract/tests/v0_9_0_alpha/experimental.rs index 1d8719bd3..1c1ed1ea7 100644 --- a/src/core/mxc_config_contract/tests/v0_9_0_alpha/experimental.rs +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/experimental.rs @@ -7,5 +7,3 @@ mod root; mod test_and_telemetry; #[path = "experimental/windows_sandbox.rs"] mod windows_sandbox; -#[path = "experimental/wslc.rs"] -mod wslc; diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/experimental/root.rs b/src/core/mxc_config_contract/tests/v0_9_0_alpha/experimental/root.rs index 612c788d4..6fecf804e 100644 --- a/src/core/mxc_config_contract/tests/v0_9_0_alpha/experimental/root.rs +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/experimental/root.rs @@ -72,9 +72,12 @@ fn rejects_moved_experimental_seatbelt_sections() { } #[test] -fn rejects_state_aware_experimental_sections() { +fn rejects_moved_experimental_wslc_section() { + // `wslc` was promoted to the top-level stable surface. The closed + // experimental block must no longer accept it under any shape. for field in [ - r#""isolation_session": {"provision": {}}"#, + r#""wslc": {}"#, + r#""wslc": {"image": "alpine:latest"}"#, r#""wslc": {"provision": {}}"#, ] { let json = format!( @@ -88,3 +91,14 @@ fn rejects_state_aware_experimental_sections() { assert_invalid(&json); } } + +#[test] +fn rejects_state_aware_experimental_sections() { + let json = r#"{ + "version": "0.9.0-alpha", + "process": {"commandLine": "echo"}, + "experimental": {"isolation_session": {"provision": {}}} + }"#; + + assert_invalid(json); +} diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/fixtures/one_shot/invalid/port_out_of_range.json b/src/core/mxc_config_contract/tests/v0_9_0_alpha/fixtures/one_shot/invalid/port_out_of_range.json index 931c40d5c..ab868f54c 100644 --- a/src/core/mxc_config_contract/tests/v0_9_0_alpha/fixtures/one_shot/invalid/port_out_of_range.json +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/fixtures/one_shot/invalid/port_out_of_range.json @@ -4,14 +4,12 @@ "process": { "commandLine": "echo invalid port" }, - "experimental": { - "wslc": { - "portMappings": [ - { - "windowsPort": 65536, - "containerPort": 80 - } - ] - } + "wslc": { + "portMappings": [ + { + "windowsPort": 65536, + "containerPort": 80 + } + ] } } diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/optional_fields.rs b/src/core/mxc_config_contract/tests/v0_9_0_alpha/optional_fields.rs index ca482c495..cc6a6fc25 100644 --- a/src/core/mxc_config_contract/tests/v0_9_0_alpha/optional_fields.rs +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/optional_fields.rs @@ -351,55 +351,51 @@ fn rejects_null_optional_fields() { version_and_process.as_str(), r#""experimental": {"windows_sandbox": {"daemonPipeName": null}}"#, ), + ("wslc", version_and_process.as_str(), r#""wslc": null"#), ( - "experimental.wslc", + "wslc.targetOs", version_and_process.as_str(), - r#""experimental": {"wslc": null}"#, + r#""wslc": {"targetOs": null}"#, ), ( - "experimental.wslc.targetOs", + "wslc.image", version_and_process.as_str(), - r#""experimental": {"wslc": {"targetOs": null}}"#, + r#""wslc": {"image": null}"#, ), ( - "experimental.wslc.image", + "wslc.imageTarPath", version_and_process.as_str(), - r#""experimental": {"wslc": {"image": null}}"#, + r#""wslc": {"imageTarPath": null}"#, ), ( - "experimental.wslc.imageTarPath", + "wslc.cpuCount", version_and_process.as_str(), - r#""experimental": {"wslc": {"imageTarPath": null}}"#, + r#""wslc": {"cpuCount": null}"#, ), ( - "experimental.wslc.cpuCount", + "wslc.memoryMb", version_and_process.as_str(), - r#""experimental": {"wslc": {"cpuCount": null}}"#, + r#""wslc": {"memoryMb": null}"#, ), ( - "experimental.wslc.memoryMb", + "wslc.gpu", version_and_process.as_str(), - r#""experimental": {"wslc": {"memoryMb": null}}"#, + r#""wslc": {"gpu": null}"#, ), ( - "experimental.wslc.gpu", + "wslc.storagePath", version_and_process.as_str(), - r#""experimental": {"wslc": {"gpu": null}}"#, + r#""wslc": {"storagePath": null}"#, ), ( - "experimental.wslc.storagePath", + "wslc.portMappings", version_and_process.as_str(), - r#""experimental": {"wslc": {"storagePath": null}}"#, + r#""wslc": {"portMappings": null}"#, ), ( - "experimental.wslc.portMappings", + "wslc.portMappings[].protocol", version_and_process.as_str(), - r#""experimental": {"wslc": {"portMappings": null}}"#, - ), - ( - "experimental.wslc.portMappings[].protocol", - version_and_process.as_str(), - r#""experimental": {"wslc": {"portMappings": [{"windowsPort": 8080, "containerPort": 80, "protocol": null}]}}"#, + r#""wslc": {"portMappings": [{"windowsPort": 8080, "containerPort": 80, "protocol": null}]}"#, ), ], "null optional field", diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/provision/wslc.rs b/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/provision/wslc.rs index 788366f6e..13608340f 100644 --- a/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/provision/wslc.rs +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/provision/wslc.rs @@ -63,15 +63,15 @@ fn accepts_provision_request_with_optional_fields() { "network": { "defaultPolicy": "block" }, + "wslc": { + "provision": { + "image": "alpine:latest", + "imageTarPath": "C:\\images\\alpine.tar" + } + }, "experimental": { "telemetry": { "enabled": true - }, - "wslc": { - "provision": { - "image": "alpine:latest", - "imageTarPath": "C:\\images\\alpine.tar" - } } } }"#, @@ -85,8 +85,8 @@ fn accepts_empty_optional_objects() { r#""network": {}"#, r#""experimental": {}"#, r#""experimental": {"telemetry": {}}"#, - r#""experimental": {"wslc": {}}"#, - r#""experimental": {"wslc": {"provision": {}}}"#, + r#""wslc": {}"#, + r#""wslc": {"provision": {}}"#, ] { assert_valid(&request_with_additional_fields(field)); } @@ -110,7 +110,7 @@ fn accepts_provision_string_values() { ("imageTarPath", r#""C:\\images\\alpine.tar""#), ] { assert_valid(&request_with_additional_fields(&format!( - r#""experimental": {{"wslc": {{"provision": {{"{field}": {value}}}}}}}"# + r#""wslc": {{"provision": {{"{field}": {value}}}}}"# ))); } } @@ -214,10 +214,10 @@ fn rejects_null_optional_fields() { r#""experimental": null"#, r#""experimental": {"telemetry": null}"#, r#""experimental": {"telemetry": {"enabled": null}}"#, - r#""experimental": {"wslc": null}"#, - r#""experimental": {"wslc": {"provision": null}}"#, - r#""experimental": {"wslc": {"provision": {"image": null}}}"#, - r#""experimental": {"wslc": {"provision": {"imageTarPath": null}}}"#, + r#""wslc": null"#, + r#""wslc": {"provision": null}"#, + r#""wslc": {"provision": {"image": null}}"#, + r#""wslc": {"provision": {"imageTarPath": null}}"#, ] { assert_invalid(&request_with_additional_fields(field)); } @@ -231,8 +231,8 @@ fn rejects_unknown_fields_at_each_object_level() { r#""network": {"unknownField": true}"#, r#""experimental": {"unknownField": true}"#, r#""experimental": {"telemetry": {"unknownField": true}}"#, - r#""experimental": {"wslc": {"unknownField": true}}"#, - r#""experimental": {"wslc": {"provision": {"unknownField": true}}}"#, + r#""wslc": {"unknownField": true}"#, + r#""wslc": {"provision": {"unknownField": true}}"#, ] { assert_invalid(&request_with_additional_fields(field)); } @@ -242,7 +242,7 @@ fn rejects_unknown_fields_at_each_object_level() { fn rejects_other_wslc_phase_keys() { for phase in ["start", "exec", "stop", "deprovision"] { assert_invalid(&request_with_additional_fields(&format!( - r#""experimental": {{"wslc": {{"{phase}": {{}}}}}}"# + r#""wslc": {{"{phase}": {{}}}}"# ))); } } @@ -304,10 +304,10 @@ fn rejects_duplicate_nested_fields() { r#""filesystem": {"readwritePaths": [], "readwritePaths": []}"#, r#""network": {"defaultPolicy": "block", "defaultPolicy": "allow"}"#, r#""experimental": {"telemetry": {}, "telemetry": {}}"#, - r#""experimental": {"wslc": {}, "wslc": {}}"#, - r#""experimental": {"wslc": {"provision": {}, "provision": {}}}"#, - r#""experimental": {"wslc": {"provision": {"image": "a", "image": "b"}}}"#, - r#""experimental": {"wslc": {"provision": {"imageTarPath": "a", "imageTarPath": "b"}}}"#, + r#""wslc": {}, "wslc": {}"#, + r#""wslc": {"provision": {}, "provision": {}}"#, + r#""wslc": {"provision": {"image": "a", "image": "b"}}"#, + r#""wslc": {"provision": {"imageTarPath": "a", "imageTarPath": "b"}}"#, ] { assert_invalid(&request_with_additional_fields(field)); } @@ -363,8 +363,8 @@ fn rejects_invalid_optional_object_types() { format!(r#""network": {value}"#), format!(r#""experimental": {value}"#), format!(r#""experimental": {{"telemetry": {value}}}"#), - format!(r#""experimental": {{"wslc": {value}}}"#), - format!(r#""experimental": {{"wslc": {{"provision": {value}}}}}"#), + format!(r#""wslc": {value}"#), + format!(r#""wslc": {{"provision": {value}}}"#), ] { assert_invalid(&request_with_additional_fields(&field)); } @@ -409,7 +409,7 @@ fn rejects_non_string_provision_fields() { for field in ["image", "imageTarPath"] { for value in ["123", "true", "false", "[]", "{}"] { assert_invalid(&request_with_additional_fields(&format!( - r#""experimental": {{"wslc": {{"provision": {{"{field}": {value}}}}}}}"# + r#""wslc": {{"provision": {{"{field}": {value}}}}}"# ))); } } diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/experimental/wslc.rs b/src/core/mxc_config_contract/tests/v0_9_0_alpha/wslc.rs similarity index 75% rename from src/core/mxc_config_contract/tests/v0_9_0_alpha/experimental/wslc.rs rename to src/core/mxc_config_contract/tests/v0_9_0_alpha/wslc.rs index 8514bc0d9..05f9a3c6a 100644 --- a/src/core/mxc_config_contract/tests/v0_9_0_alpha/experimental/wslc.rs +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/wslc.rs @@ -7,9 +7,7 @@ fn wslc_request(fields: &str) -> String { format!( r#"{{ "version": "0.9.0-alpha", - "experimental": {{ - "wslc": {{{fields}}} - }}, + "wslc": {{{fields}}}, "process": {{"commandLine": "echo"}} }}"# ) @@ -89,48 +87,44 @@ fn rejects_duplicate_wslc_fields() { assert_invalid_cases( [ + ("wslc", version_and_process, r#""wslc": {}, "wslc": {}"#), ( - "experimental.wslc", + "wslc.targetOs", version_and_process, - r#""experimental": {"wslc": {}, "wslc": {}}"#, + r#""wslc": {"targetOs": "linux", "targetOs": "other"}"#, ), ( - "experimental.wslc.targetOs", + "wslc.image", version_and_process, - r#""experimental": {"wslc": {"targetOs": "linux", "targetOs": "other"}}"#, + r#""wslc": {"image": "first", "image": "second"}"#, ), ( - "experimental.wslc.image", + "wslc.imageTarPath", version_and_process, - r#""experimental": {"wslc": {"image": "first", "image": "second"}}"#, + r#""wslc": {"imageTarPath": "first", "imageTarPath": "second"}"#, ), ( - "experimental.wslc.imageTarPath", + "wslc.cpuCount", version_and_process, - r#""experimental": {"wslc": {"imageTarPath": "first", "imageTarPath": "second"}}"#, + r#""wslc": {"cpuCount": 1, "cpuCount": 2}"#, ), ( - "experimental.wslc.cpuCount", + "wslc.memoryMb", version_and_process, - r#""experimental": {"wslc": {"cpuCount": 1, "cpuCount": 2}}"#, + r#""wslc": {"memoryMb": 1024, "memoryMb": 2048}"#, ), ( - "experimental.wslc.memoryMb", + "wslc.gpu", version_and_process, - r#""experimental": {"wslc": {"memoryMb": 1024, "memoryMb": 2048}}"#, + r#""wslc": {"gpu": true, "gpu": false}"#, ), ( - "experimental.wslc.gpu", + "wslc.storagePath", version_and_process, - r#""experimental": {"wslc": {"gpu": true, "gpu": false}}"#, - ), - ( - "experimental.wslc.storagePath", - version_and_process, - r#""experimental": {"wslc": {"storagePath": "first", "storagePath": "second"}}"#, + r#""wslc": {"storagePath": "first", "storagePath": "second"}"#, ), ], - "duplicate experimental field", + "duplicate nested field", ); } @@ -239,26 +233,26 @@ fn rejects_duplicate_wslc_port_mapping_fields() { assert_invalid_cases( [ ( - "experimental.wslc.portMappings", + "wslc.portMappings", version_and_process, - r#""experimental": {"wslc": {"portMappings": [], "portMappings": []}}"#, + r#""wslc": {"portMappings": [], "portMappings": []}"#, ), ( - "experimental.wslc.portMappings[].windowsPort", + "wslc.portMappings[].windowsPort", version_and_process, - r#""experimental": {"wslc": {"portMappings": [{"windowsPort": 8080, "windowsPort": 8081, "containerPort": 80}]}}"#, + r#""wslc": {"portMappings": [{"windowsPort": 8080, "windowsPort": 8081, "containerPort": 80}]}"#, ), ( - "experimental.wslc.portMappings[].containerPort", + "wslc.portMappings[].containerPort", version_and_process, - r#""experimental": {"wslc": {"portMappings": [{"windowsPort": 8080, "containerPort": 80, "containerPort": 81}]}}"#, + r#""wslc": {"portMappings": [{"windowsPort": 8080, "containerPort": 80, "containerPort": 81}]}"#, ), ( - "experimental.wslc.portMappings[].protocol", + "wslc.portMappings[].protocol", version_and_process, - r#""experimental": {"wslc": {"portMappings": [{"windowsPort": 8080, "containerPort": 80, "protocol": "tcp", "protocol": "tcp"}]}}"#, + r#""wslc": {"portMappings": [{"windowsPort": 8080, "containerPort": 80, "protocol": "tcp", "protocol": "tcp"}]}"#, ), ], - "duplicate experimental field", + "duplicate nested field", ); } diff --git a/src/core/mxc_config_contract/tests/version_boundaries.rs b/src/core/mxc_config_contract/tests/version_boundaries.rs index f03abd058..1f33c1bcc 100644 --- a/src/core/mxc_config_contract/tests/version_boundaries.rs +++ b/src/core/mxc_config_contract/tests/version_boundaries.rs @@ -19,3 +19,5 @@ mod process_container; mod seatbelt; #[path = "version_boundaries/state_aware.rs"] mod state_aware; +#[path = "version_boundaries/wslc.rs"] +mod wslc; diff --git a/src/core/mxc_config_contract/tests/version_boundaries/experimental.rs b/src/core/mxc_config_contract/tests/version_boundaries/experimental.rs index 51a7fc038..e5417a166 100644 --- a/src/core/mxc_config_contract/tests/version_boundaries/experimental.rs +++ b/src/core/mxc_config_contract/tests/version_boundaries/experimental.rs @@ -35,22 +35,3 @@ fn experimental_windows_sandbox_is_introduced_in_v09() { }"#, ); } - -#[test] -fn experimental_wslc_is_introduced_in_v09() { - assert_v09_introduces( - r#""experimental": { - "wslc": { - "targetOs": "linux", - "image": "ubuntu", - "cpuCount": 2, - "memoryMb": 4096, - "gpu": false, - "storagePath": "C:\\mxc", - "portMappings": [ - {"windowsPort": 8080, "containerPort": 80, "protocol": "tcp"} - ] - } - }"#, - ); -} diff --git a/src/core/mxc_config_contract/tests/version_boundaries/state_aware.rs b/src/core/mxc_config_contract/tests/version_boundaries/state_aware.rs index f6844b01a..5a01073e9 100644 --- a/src/core/mxc_config_contract/tests/version_boundaries/state_aware.rs +++ b/src/core/mxc_config_contract/tests/version_boundaries/state_aware.rs @@ -100,13 +100,13 @@ const CASES: &[StateAwareCase] = &[ "allowedHosts": ["packages.example"], "allowLocalNetwork": false }, + "wslc": { + "provision": { + "image": "ubuntu:24.04", + "imageTarPath": "C:\\images\\ubuntu.tar" + } + }, "experimental": { - "wslc": { - "provision": { - "image": "ubuntu:24.04", - "imageTarPath": "C:\\images\\ubuntu.tar" - } - }, "telemetry": {"enabled": true} } }"#, diff --git a/src/core/mxc_config_contract/tests/version_boundaries/wslc.rs b/src/core/mxc_config_contract/tests/version_boundaries/wslc.rs new file mode 100644 index 000000000..1abac3e39 --- /dev/null +++ b/src/core/mxc_config_contract/tests/version_boundaries/wslc.rs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::common::assert_v09_introduces; + +#[test] +fn wslc_section_is_introduced_in_v09() { + assert_v09_introduces( + r#""wslc": { + "targetOs": "linux", + "image": "ubuntu", + "cpuCount": 2, + "memoryMb": 4096, + "gpu": false, + "storagePath": "C:\\mxc", + "portMappings": [ + {"windowsPort": 8080, "containerPort": 80, "protocol": "tcp"} + ] + }"#, + ); +} + +#[test] +fn wslc_containment_value_is_introduced_in_v09() { + assert_v09_introduces(r#""containment": "wslc", "wslc": {}"#); +} diff --git a/src/core/mxc_engine/src/dispatch.rs b/src/core/mxc_engine/src/dispatch.rs index d99b4ea2d..49f3cd261 100644 --- a/src/core/mxc_engine/src/dispatch.rs +++ b/src/core/mxc_engine/src/dispatch.rs @@ -222,9 +222,7 @@ fn spawn_process_container( )) } -/// Spawn the WSL Container backend. Experimental, so it refuses to run unless -/// the request opted in (`SandboxRequest::set_experimental(true)`) — the -/// library-side equivalent of the executor's `--experimental` flag. +/// Spawn the WSL Container backend. #[cfg(all(target_os = "windows", feature = "wslc"))] fn spawn_wslc( request: &ExecutionRequest, @@ -232,13 +230,7 @@ fn spawn_wslc( ) -> Result, MxcError> { use wxc_common::sandbox_process::{SandboxBackend, StdioMode}; - if !request.experimental_enabled { - return Err(MxcError::malformed_request( - "WSLC is an experimental backend; enable experimental features on the \ - request (SandboxRequest::set_experimental(true)) to use it", - )); - } - let config = request.experimental.wslc.clone().unwrap_or_default(); + let config = request.wslc.clone().unwrap_or_default(); let mut runner = wslc_common::WSLContainerRunner::new(&config); runner .spawn(request, logger, StdioMode::Pipes) @@ -411,20 +403,4 @@ mod tests { assert_eq!(err.code, MxcErrorCode::UnsupportedContainment); assert!(err.message.contains("Windows"), "got: {}", err.message); } - - #[cfg(all(target_os = "windows", feature = "wslc"))] - #[test] - fn streaming_rejects_wslc_without_experimental() { - // The experimental gate is fail-closed: selecting WSLC without opting - // in must be rejected before any container is created. - let mut request = build_request(&minimal_policy(), None).expect("build_request"); - request.inner.containment = ContainmentBackend::Wslc; - let mut logger = Logger::new(Mode::Buffer); - let err = match spawn_runner(&request.inner, &mut logger) { - Ok(_) => panic!("WSLC must be rejected without experimental features"), - Err(e) => e, - }; - assert_eq!(err.code, MxcErrorCode::MalformedRequest); - assert!(err.message.contains("experimental"), "got: {}", err.message); - } } diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index 493bab680..65ed77f27 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -44,6 +44,8 @@ mod state_aware; pub use error::{Error, ErrorCode}; #[cfg(all(target_os = "windows", feature = "isolation_session"))] pub use platform::isolation_session_available; +#[cfg(target_os = "windows")] +pub use platform::wslc_available; pub use platform::{platform_support, PlatformSupport}; pub use policy::{ available_tools_policy, build_request, build_request_with_containment, temporary_files_policy, diff --git a/src/core/mxc_engine/src/platform.rs b/src/core/mxc_engine/src/platform.rs index 7eeeb81d5..f8b57a090 100644 --- a/src/core/mxc_engine/src/platform.rs +++ b/src/core/mxc_engine/src/platform.rs @@ -120,8 +120,13 @@ pub fn platform_support() -> PlatformSupport { /// Whether this host can run the WSL Container backend, probing the WSLC /// runtime the same way the runner's preflight does. Always `false` when the /// backend isn't compiled in, so the caller needs no `cfg` of its own. +/// +/// Exposed from the engine so `wxc` reaches the WSLc backend through +/// `mxc_engine` rather than depending on `wslc_common` directly. It is the +/// same probe [`platform_support`] consults, so the CLI `--probe` surface and +/// the Rust SDK surface can never disagree about this host. #[cfg(target_os = "windows")] -fn wslc_available() -> bool { +pub fn wslc_available() -> bool { #[cfg(feature = "wslc")] { wslc_common::is_available() diff --git a/src/core/mxc_engine/src/policy.rs b/src/core/mxc_engine/src/policy.rs index 77f99f60c..27731c880 100644 --- a/src/core/mxc_engine/src/policy.rs +++ b/src/core/mxc_engine/src/policy.rs @@ -476,8 +476,7 @@ pub enum Containment { /// SDK, configured by the carried [`WslcSection`] /// (`WslcSection::default()` matches the SDK's defaults). /// - /// **Experimental** — the request must have experimental features enabled - /// ([`SandboxRequest::set_experimental`]) or the spawn is rejected. + /// Requires the crate's `wslc` build feature. Wslc(WslcSection), /// IsolationSession backend: a Windows isolated user session. /// @@ -542,7 +541,7 @@ impl Default for WslcSection { } impl WslcSection { - /// The wire-format `experimental.wslc` object. Optional fields are omitted + /// The wire-format top-level `wslc` object. Optional fields are omitted /// rather than sent as `null` so the parser applies its own defaults. fn wire(&self) -> serde_json::Value { use serde_json::json; @@ -721,7 +720,7 @@ pub fn build_request( /// use mxc_engine::policy::{build_request_with_containment, Containment, SandboxPolicy, WslcSection}; /// /// let policy = SandboxPolicy { -/// version: "0.7.0-alpha".to_string(), +/// version: "0.9.0-alpha".to_string(), /// filesystem: None, /// network: None, /// ui: None, @@ -729,7 +728,7 @@ pub fn build_request( /// }; /// let wslc = WslcSection { image: "python:3.12".to_string(), ..Default::default() }; /// let mut request = build_request_with_containment(&policy, &Containment::Wslc(wslc), None)?; -/// request.set_script("python3 -c 'print(1)'").set_experimental(true); +/// request.set_script("python3 -c 'print(1)'"); /// # Ok::<(), mxc_engine::Error>(()) /// ``` pub fn build_request_with_containment( @@ -971,13 +970,11 @@ fn apply_host_process_backend( /// Apply the WSL Container backend fields — the Rust port of the SDK's /// `buildWslcContainerConfig`. WSLC derives its networking mode (`None` / -/// `Bridged`) from `network.defaultPolicy`, so no enforcement mode is set here; -/// its settings live under `experimental.wslc` because the backend is -/// experimental. +/// `Bridged`) from `network.defaultPolicy`, so no enforcement mode is set here. fn apply_wslc_backend(config: &mut serde_json::Value, wslc: &WslcSection) { use serde_json::json; config["containment"] = json!("wslc"); - config["experimental"] = json!({ "wslc": wslc.wire() }); + config["wslc"] = wslc.wire(); } /// Promote network enforcement to `firewall` when host rules are present and @@ -1531,7 +1528,7 @@ mod tests { #[test] fn wslc_containment_maps_config_to_the_request() { // Mirrors `createConfigFromPolicy(policy, 'wslc')` plus a tweaked - // `experimental.wslc` block: the wire config goes through the shared + // top-level `wslc` block: the wire config goes through the shared // parser, so the mapped request carries the WSLC settings verbatim. let wslc = WslcSection { image: "python:3.12".to_string(), @@ -1547,12 +1544,7 @@ mod tests { .expect("build_request_with_containment"); assert_eq!(request.inner.containment, ContainmentBackend::Wslc); - let config = request - .inner - .experimental - .wslc - .as_ref() - .expect("wslc config"); + let config = request.inner.wslc.as_ref().expect("wslc config"); assert_eq!(config.image, "python:3.12"); assert_eq!(config.cpu_count, Some(2)); assert_eq!(config.memory_mb, Some(2048)); @@ -1574,34 +1566,13 @@ mod tests { None, ) .expect("build_request_with_containment"); - let config = request - .inner - .experimental - .wslc - .as_ref() - .expect("wslc config"); + let config = request.inner.wslc.as_ref().expect("wslc config"); assert_eq!(config.image, "alpine:latest"); assert_eq!(config.target_os, "linux"); assert!(config.port_mappings.is_empty()); assert!(!config.gpu); } - #[test] - fn wslc_is_not_experimental_enabled_by_default() { - // Selecting the backend must not silently flip the experimental gate: - // the caller opts in explicitly, exactly like the SDK's - // `SandboxSpawnOptions.experimental`. - let mut request = build_request_with_containment( - &minimal_policy(), - &Containment::Wslc(WslcSection::default()), - None, - ) - .expect("build_request_with_containment"); - assert!(!request.inner.experimental_enabled); - request.set_experimental(true); - assert!(request.inner.experimental_enabled); - } - #[test] fn wslc_rejects_an_invalid_port_mapping() { // Validation is the shared parser's, so a bad mapping is rejected at @@ -1657,7 +1628,7 @@ mod tests { fn isolation_session_names_the_backend_and_carries_no_section() { // The one-shot surface takes no backend configuration at all, so the // wire config must name the backend and add nothing else — unlike - // WSLc, which also writes an `experimental.wslc` block. + // WSLc, which also writes a top-level `wslc` block. let policy = policy_with_network(isolation_session_network()); let config = super::build_wire_config(&policy, &Containment::IsolationSession, None) .expect("build_wire_config"); @@ -1694,7 +1665,7 @@ mod tests { #[test] fn isolation_session_is_not_experimental_enabled_by_default() { // Selecting an experimental backend must not silently satisfy the - // experimental gate. Mirrors `wslc_is_not_experimental_enabled_by_default`. + // experimental gate. let policy = policy_with_network(isolation_session_network()); let mut request = build_request_with_containment(&policy, &Containment::IsolationSession, None) diff --git a/src/core/mxc_engine/src/run.rs b/src/core/mxc_engine/src/run.rs index 3da7f1e5e..453bd85ee 100644 --- a/src/core/mxc_engine/src/run.rs +++ b/src/core/mxc_engine/src/run.rs @@ -175,18 +175,8 @@ fn resolve_runner_inner_windows( ContainmentBackend::Wslc => { #[cfg(feature = "wslc")] { - if !request.experimental_enabled { - return Err(MxcError::malformed_request( - "WSLC is an experimental feature. Use --experimental flag.", - )); - } - let _ = writeln!(logger, "Using WSLContainer runner (--experimental)"); - let wslc_config = request - .experimental - .wslc - .as_ref() - .cloned() - .unwrap_or_default(); + let _ = writeln!(logger, "Using WSLContainer runner"); + let wslc_config = request.wslc.as_ref().cloned().unwrap_or_default(); Ok(ResolvedRunner::without_guard(Box::new( wslc_common::wsl_container_runner::WSLContainerRunner::new(&wslc_config), ))) diff --git a/src/core/mxc_engine/src/state_aware.rs b/src/core/mxc_engine/src/state_aware.rs index ac3099510..3afaa6d9a 100644 --- a/src/core/mxc_engine/src/state_aware.rs +++ b/src/core/mxc_engine/src/state_aware.rs @@ -61,7 +61,6 @@ fn require_experimental_optin( backend, wxc_common::models::ContainmentBackend::WindowsSandbox | wxc_common::models::ContainmentBackend::IsolationSession - | wxc_common::models::ContainmentBackend::Wslc ) && !parsed.request.experimental_enabled { return Err(MxcError::backend_unavailable(format!( @@ -323,9 +322,9 @@ fn exec_state_aware_attached_with( /// [`exec_state_aware_attached`] to attach the workload to this process's stdio, /// or [`exec_state_aware_json`] to drive the pipes yourself. /// -/// `experimental` opts in to the experimental backends (WindowsSandbox, -/// IsolationSession, WSLc); without it they are refused with -/// `backend_unavailable` before any work is done. +/// `experimental` opts in to the experimental backends (WindowsSandbox and +/// IsolationSession); without it they are refused with `backend_unavailable` +/// before any work is done. pub fn run_state_aware_json( request_json: &str, dry_run: bool, @@ -389,6 +388,7 @@ mod tests { sandbox_id: None, correlation_vector: None, experimental_raw: None, + stable_raw: None, source_text: None, }; @@ -401,21 +401,22 @@ mod tests { #[test] fn exec_experimental_backend_requires_optin() { // The streaming exec entry point applies the same opt-in gate as the - // envelope dispatcher: a `wslc:` exec without the opt-in must be - // refused before reaching the backend. + // envelope dispatcher: an experimental-backend exec without the opt-in + // must be refused before reaching the backend. let parsed = ParsedStateAwareRequest { request: ExecutionRequest::default(), phase: Phase::Exec, - containment: Some(ContainmentBackend::Wslc), - sandbox_id: Some("wslc:00000000000000000000000000000000".to_string()), + containment: Some(ContainmentBackend::WindowsSandbox), + sandbox_id: Some("wsb:0123abcd".to_string()), correlation_vector: None, experimental_raw: None, + stable_raw: None, source_text: None, }; let error = match exec_state_aware(parsed) { Ok(_) => { - panic!("expected the experimental gate to reject a wslc exec without the opt-in") + panic!("expected the experimental gate to reject an exec without the opt-in") } Err(e) => e, }; diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index 53d8406f1..d31e7d0d9 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -99,7 +99,7 @@ struct Cli { /// Optional WSLC storage path. When omitted the runner default is used /// (`%TEMP%\mxc-wslc-sessions`). Pass the same value here that your - /// runtime configs set in `experimental.wslc.storagePath`, otherwise + /// runtime configs set in `wslc.storagePath`, otherwise /// the runner will not find the pulled image. Requires `--setup-wslc`. #[arg(long = "storage-path", requires = "setup_wslc")] storage_path: Option, @@ -783,6 +783,15 @@ fn main() { output.probes.isolation_session_available = mxc_engine::isolation_session_available(); output }; + // `appcontainer_common` cannot see the WSLc backend either, so it + // reports `wslcAvailable` as `false`; the SDK gates every `wslc` + // request on that value. + #[cfg(target_os = "windows")] + let output = { + let mut output = output; + output.probes.wslc_available = mxc_engine::wslc_available(); + output + }; // WHP is delay-loaded; check before pyhl::install warms a VM. #[cfg(all(target_os = "windows", feature = "hyperlight", target_arch = "x86_64"))] let output = { @@ -1431,7 +1440,7 @@ mod tests { let mut logger = test_logger(); logger.enable_file_sink(&log_path).unwrap(); let error = MxcError::malformed_request( - "Invalid configuration at `experimental.wslc.start.portMappings[0].windowsPort`", + "Invalid configuration at `wslc.start.portMappings[0].windowsPort`", ); log_state_aware_dispatch_error(&mut logger, &error); @@ -1443,7 +1452,7 @@ mod tests { drop(logger); let log = std::fs::read_to_string(log_path).unwrap(); assert_eq!( - log.matches("experimental.wslc.start.portMappings[0].windowsPort") + log.matches("wslc.start.portMappings[0].windowsPort") .count(), 1 ); @@ -1739,6 +1748,7 @@ mod tests { sandbox_id: Some("iso:wxc-1234".into()), correlation_vector: None, experimental_raw: None, + stable_raw: None, source_text: None, }; diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs index cfd71c8e2..1c36f5163 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs @@ -229,8 +229,8 @@ fn convert_wslc_port_mapping(value: contract::PortMapping) -> wire::PortMapping } } -fn convert_wslc(value: contract::OneShotWslc) -> wire::Wslc { - let contract::OneShotWslc { +fn convert_wslc(value: contract::Wslc) -> wire::Wslc { + let contract::Wslc { target_os, image, image_tar_path, @@ -262,13 +262,12 @@ fn convert_experimental(value: contract::OneShotExperimental) -> wire::Experimen let contract::OneShotExperimental { test, windows_sandbox, - wslc, telemetry, } = value; wire::Experimental { test: test.into_option().map(convert_test), windows_sandbox: windows_sandbox.into_option().map(convert_windows_sandbox), - wslc: wslc.into_option().map(convert_wslc), + wslc: None, isolation_session: None, seatbelt: None, telemetry: telemetry.into_option().map(convert_telemetry), @@ -291,6 +290,7 @@ pub(super) fn into_wire(request: contract::OneShotRequest) -> wire::MxcConfig { process_container, ui, seatbelt, + wslc, runtime_config, experimental, } = request; @@ -315,6 +315,7 @@ pub(super) fn into_wire(request: contract::OneShotRequest) -> wire::MxcConfig { runtime_config: runtime_config.into_option().map(convert_runtime_config), ui: ui.into_option().map(convert_ui), seatbelt: seatbelt.into_option().map(convert_seatbelt), + wslc: wslc.into_option().map(convert_wslc), experimental: experimental.into_option().map(convert_experimental), } } diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs index 873ea82df..16c17132a 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs @@ -40,27 +40,26 @@ const WSLC_REQUEST_JSON: &str = r#"{ "process": { "commandLine": "echo hello" }, - "experimental": { - "wslc": { - "targetOs": "linux", - "image": "alpine:latest", - "imageTarPath": "C:\\images\\alpine.tar", - "cpuCount": 4, - "memoryMb": 4294967296, - "gpu": true, - "storagePath": "C:\\wslc", - "portMappings": [ - { - "windowsPort": 8080, - "containerPort": 80 - }, - { - "windowsPort": 8443, - "containerPort": 443, - "protocol": "tcp" - } - ] - } + "experimental": {}, + "wslc": { + "targetOs": "linux", + "image": "alpine:latest", + "imageTarPath": "C:\\images\\alpine.tar", + "cpuCount": 4, + "memoryMb": 4294967296, + "gpu": true, + "storagePath": "C:\\wslc", + "portMappings": [ + { + "windowsPort": 8080, + "containerPort": 80 + }, + { + "windowsPort": 8443, + "containerPort": 443, + "protocol": "tcp" + } + ] } }"#; @@ -122,8 +121,7 @@ fn wslc_maps_expected_wire_fields() { Some(super::wire::Containment::Wslc) )); - let experimental = wire.experimental.expect("experimental should be populated"); - let wslc = experimental.wslc.expect("wslc should be populated"); + let wslc = wire.wslc.expect("wslc should be populated"); assert_eq!(wslc.target_os.as_deref(), Some("linux")); assert_eq!(wslc.image.as_deref(), Some("alpine:latest")); @@ -153,10 +151,12 @@ fn wslc_maps_expected_wire_fields() { Some(super::wire::TransportProtocol::Tcp) )); + let experimental = wire.experimental.expect("experimental should be populated"); assert!(experimental.test.is_none()); assert!(experimental.windows_sandbox.is_none()); assert!(experimental.isolation_session.is_none()); assert!(experimental.seatbelt.is_none()); + assert!(experimental.wslc.is_none()); assert!(experimental.telemetry.is_none()); } diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs index 8ad394b64..0c6b2e4bc 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs @@ -119,11 +119,11 @@ fn convert_state_aware_wslc(value: contract::StateAwareWslc) -> wire::Wslc { fn convert_wslc_provision_experimental( value: contract::WslcProvisionExperimental, ) -> wire::Experimental { - let contract::WslcProvisionExperimental { wslc, telemetry } = value; + let contract::WslcProvisionExperimental { telemetry } = value; wire::Experimental { test: None, windows_sandbox: None, - wslc: wslc.into_option().map(convert_state_aware_wslc), + wslc: None, isolation_session: None, seatbelt: None, telemetry: telemetry.into_option().map(convert_telemetry), @@ -225,6 +225,7 @@ fn isolation_session_provision_into_wire( lxc: None, process_container: None, seatbelt: None, + wslc: None, ui: None, } } @@ -262,6 +263,7 @@ fn windows_sandbox_provision_into_wire( lxc: None, process_container: None, seatbelt: None, + wslc: None, ui: None, } } @@ -275,6 +277,7 @@ fn wslc_provision_into_wire(request: contract::WslcProvisionRequest) -> wire::Mx containment: contract::WslcContainment, filesystem, network, + wslc, experimental, } = request; wire::MxcConfig { @@ -298,6 +301,7 @@ fn wslc_provision_into_wire(request: contract::WslcProvisionRequest) -> wire::Mx lxc: None, process_container: None, seatbelt: None, + wslc: wslc.into_option().map(convert_state_aware_wslc), ui: None, } } @@ -331,6 +335,7 @@ pub(super) fn start_into_wire(request: contract::StartRequest) -> wire::MxcConfi lxc: None, process_container: None, seatbelt: None, + wslc: None, ui: None, } } @@ -366,6 +371,7 @@ pub(super) fn exec_into_wire(request: contract::ExecRequest) -> wire::MxcConfig lxc: None, process_container: None, seatbelt: None, + wslc: None, ui: None, } } @@ -399,6 +405,7 @@ pub(super) fn stop_into_wire(request: contract::StopRequest) -> wire::MxcConfig lxc: None, process_container: None, seatbelt: None, + wslc: None, ui: None, } } @@ -434,6 +441,7 @@ pub(super) fn deprovision_into_wire(request: contract::DeprovisionRequest) -> wi lxc: None, process_container: None, seatbelt: None, + wslc: None, ui: None, } } diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/provision.rs b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/provision.rs index f3e04c195..3b5e3299b 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/provision.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/provision.rs @@ -86,13 +86,13 @@ const WSLC_ALL_FIELDS_REQUEST_JSON: &str = r#"{ "url": "http://example.com/proxy" } }, + "wslc": { + "provision": { + "image": "someImage", + "imageTarPath": "someImageTarPath" + } + }, "experimental": { - "wslc": { - "provision": { - "image": "someImage", - "imageTarPath": "someImageTarPath" - } - }, "telemetry": { "enabled": false } @@ -416,7 +416,7 @@ fn wslc_request_maps_expected_wire_fields() { let experimental = wire.experimental.expect("experimental should be populated"); - let wslc = experimental.wslc.expect("wslc should be populated"); + let wslc = wire.wslc.expect("wslc should be populated"); let provision = wslc.provision.expect("provision should be populated"); assert_eq!(provision.image.as_deref(), Some("someImage")); assert_eq!( @@ -441,6 +441,7 @@ fn wslc_request_maps_expected_wire_fields() { assert!(experimental.isolation_session.is_none()); assert!(experimental.windows_sandbox.is_none()); assert!(experimental.seatbelt.is_none()); + assert!(experimental.wslc.is_none()); assert!(wire.sandbox_id.is_none()); assert!(wire.correlation_vector.is_none()); @@ -549,16 +550,12 @@ fn empty_wslc_sections_map_to_present_empty_wire_sections() { .expect("telemetry should be populated"); assert!(telemetry.enabled.is_none()); - let wire = adapt(&wslc_request_with_fields(r#""experimental": {"wslc": {}}"#)); - let experimental = wire.experimental.expect("experimental should be populated"); - let wslc = experimental.wslc.expect("wslc should be populated"); + let wire = adapt(&wslc_request_with_fields(r#""wslc": {}"#)); + let wslc = wire.wslc.expect("wslc should be populated"); assert!(wslc.provision.is_none()); - let wire = adapt(&wslc_request_with_fields( - r#""experimental": {"wslc": {"provision": {}}}"#, - )); - let experimental = wire.experimental.expect("experimental should be populated"); - let wslc = experimental.wslc.expect("wslc should be populated"); + let wire = adapt(&wslc_request_with_fields(r#""wslc": {"provision": {}}"#)); + let wslc = wire.wslc.expect("wslc should be populated"); let provision = wslc.provision.expect("provision should be populated"); assert!(provision.image.is_none()); assert!(provision.image_tar_path.is_none()); @@ -580,11 +577,10 @@ fn empty_isolation_session_app_id_maps_expected_wire_field() { #[test] fn empty_wslc_provision_strings_map_expected_wire_fields() { let wire = adapt(&wslc_request_with_fields( - r#""experimental": {"wslc": {"provision": {"image": "", "imageTarPath": ""}}}"#, + r#""wslc": {"provision": {"image": "", "imageTarPath": ""}}"#, )); let provision = wire - .experimental - .and_then(|experimental| experimental.wslc) + .wslc .and_then(|wslc| wslc.provision) .expect("provision should be populated"); assert_eq!(provision.image.as_deref(), Some("")); @@ -680,8 +676,8 @@ fn empty_wslc_sections_match_current_wire_deserialization() { r#""network": {}"#, r#""experimental": {}"#, r#""experimental": {"telemetry": {}}"#, - r#""experimental": {"wslc": {}}"#, - r#""experimental": {"wslc": {"provision": {}}}"#, + r#""wslc": {}"#, + r#""wslc": {"provision": {}}"#, ] { assert_matches_current_wire_deserialization(&wslc_request_with_fields(fields)); } @@ -694,8 +690,7 @@ fn empty_backend_strings_match_current_wire_deserialization() { ); assert_matches_current_wire_deserialization(&isolation_session); - let wslc = wslc_request_with_fields( - r#""experimental": {"wslc": {"provision": {"image": "", "imageTarPath": ""}}}"#, - ); + let wslc = + wslc_request_with_fields(r#""wslc": {"provision": {"image": "", "imageTarPath": ""}}"#); assert_matches_current_wire_deserialization(&wslc); } diff --git a/src/core/wxc_common/src/config_contract_adapters/v0_6.rs b/src/core/wxc_common/src/config_contract_adapters/v0_6.rs index 1d3c5cdbf..8d90dca83 100644 --- a/src/core/wxc_common/src/config_contract_adapters/v0_6.rs +++ b/src/core/wxc_common/src/config_contract_adapters/v0_6.rs @@ -242,6 +242,7 @@ pub(crate) fn into_wire(request: contract::Request) -> wire::MxcConfig { runtime_config: None, ui: ui.into_option().map(convert_ui), seatbelt: None, + wslc: None, experimental: None, } } diff --git a/src/core/wxc_common/src/config_contract_adapters/v0_7.rs b/src/core/wxc_common/src/config_contract_adapters/v0_7.rs index 787c90b1d..78cffc477 100644 --- a/src/core/wxc_common/src/config_contract_adapters/v0_7.rs +++ b/src/core/wxc_common/src/config_contract_adapters/v0_7.rs @@ -272,6 +272,7 @@ pub(crate) fn into_wire(request: contract::Request) -> wire::MxcConfig { runtime_config: None, ui: ui.into_option().map(convert_ui), seatbelt: seatbelt.into_option().map(convert_seatbelt), + wslc: None, experimental: None, } } diff --git a/src/core/wxc_common/src/config_contract_adapters/v0_8.rs b/src/core/wxc_common/src/config_contract_adapters/v0_8.rs index 1f2f344d6..21522e8b3 100644 --- a/src/core/wxc_common/src/config_contract_adapters/v0_8.rs +++ b/src/core/wxc_common/src/config_contract_adapters/v0_8.rs @@ -403,6 +403,7 @@ pub(crate) fn into_wire(request: contract::Request) -> wire::MxcConfig { network: network.into_option().map(convert_network), ui: ui.into_option().map(convert_ui), seatbelt: seatbelt.into_option().map(convert_seatbelt), + wslc: None, runtime_config: runtime_config.into_option().map(convert_runtime_config), experimental: None, } diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 128cebb54..56fbb0f06 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -336,7 +336,7 @@ const CURRENT_SCHEMA_VERSION: &str = "0.9.0-alpha"; /// experimental backend sections that don't match the selected /// `containment`. Add a new entry when promoting a backend to a top-level /// section or graduating one from experimental. -const KNOWN_EXPERIMENTAL_BACKENDS: &[&str] = &["windows_sandbox", "wslc", "isolation_session"]; +const KNOWN_EXPERIMENTAL_BACKENDS: &[&str] = &["windows_sandbox", "isolation_session"]; /// Validate that the schema version (semver) is supported by this binary. /// Compares major.minor only — patch and pre-release labels are ignored. @@ -508,13 +508,13 @@ fn present_backend_sections(cfg: &wire::MxcConfig) -> Vec<&'static str> { if cfg.seatbelt.is_some() { push(ContainmentBackend::Seatbelt); } + if cfg.wslc.is_some() { + push(ContainmentBackend::Wslc); + } if let Some(experimental) = cfg.experimental.as_ref() { if experimental.windows_sandbox.is_some() { push(ContainmentBackend::WindowsSandbox); } - if experimental.wslc.is_some() { - push(ContainmentBackend::Wslc); - } if experimental.isolation_session.is_some() { push(ContainmentBackend::IsolationSession); } @@ -568,18 +568,23 @@ fn validate_experimental_backend_keys( return Ok(()); }; - let matching_key = containment - .and_then(|c| c.section_path()) - .and_then(|path| path.strip_prefix("experimental.")); - let present: Vec<&'static str> = KNOWN_EXPERIMENTAL_BACKENDS .iter() .copied() .filter(|key| map.contains_key(*key)) .collect(); - let rejected: Vec<&'static str> = match matching_key { - Some(allowed) => present.into_iter().filter(|k| *k != allowed).collect(), + let rejected: Vec<&'static str> = match containment { + Some(c) => match c + .section_path() + .and_then(|p| p.strip_prefix("experimental.")) + { + Some(allowed) => present.into_iter().filter(|k| *k != allowed).collect(), + + // An unrejected key is dropped when the experimental section is + // cleared. + None => present, + }, None if present.len() > 1 => present, None => return Ok(()), }; @@ -1081,7 +1086,7 @@ fn convert_wire_config( } // WSLc cannot honor a blanket inbound-listen grant. The runner only - // wires explicit host->container port forwards (experimental.wslc + // wires explicit host->container port forwards (wslc // portMappings) into the WSL2 VM's NAT; it never consults // allowLocalNetwork. Reject `true` and point at portMappings. // (`false` is the default and a no-op.) @@ -1089,7 +1094,7 @@ fn convert_wire_config( let msg = "WSLc: network.allowLocalNetwork=true is not supported. A WSLc \ container runs in the NAT'd WSL2 VM and MXC does not honor a \ blanket inbound-listen grant; expose specific ports with \ - experimental.wslc.portMappings instead."; + wslc.portMappings instead."; logger.log_line(msg); return Err(WxcError::ConfigParse(msg.to_string())); } @@ -1239,70 +1244,12 @@ fn convert_wire_config( } config }); - let wslc = if let Some(cc) = raw_exp.wslc { - let mut config = WslcConfig::default(); - if let Some(os) = cc.target_os { - config.target_os = os; - } - if let Some(img) = cc.image { - config.image = img; - } - config.image_tar_path = cc.image_tar_path; - config.cpu_count = cc.cpu_count; - config.memory_mb = cc.memory_mb; - if let Some(gpu) = cc.gpu { - config.gpu = gpu; - } - config.storage_path = cc.storage_path; - if let Some(mappings) = cc.port_mappings { - let mut converted = Vec::with_capacity(mappings.len()); - for (idx, m) in mappings.into_iter().enumerate() { - if m.windows_port == 0 { - let msg = format!( - "experimental.wslc.portMappings[{idx}]: 'windowsPort' must be > 0" - ); - return Err(WxcError::ConfigParse(msg)); - } - if m.container_port == 0 { - let msg = format!( - "experimental.wslc.portMappings[{idx}]: 'containerPort' must be > 0" - ); - return Err(WxcError::ConfigParse(msg)); - } - // Only TCP is representable in the wire model - // (TransportProtocol is tcp-only); a `udp` value is rejected - // at deserialize. The WSLC SDK runtime returns E_NOTIMPL for - // UDP, so only TCP is currently supported. - let protocol = "tcp".to_string(); - converted.push(PortMapping { - windows_port: m.windows_port, - container_port: m.container_port, - protocol, - }); - } - // Reject duplicate (windowsPort, protocol) entries. Same host - // port on TCP+UDP would in principle be legal, but UDP is - // rejected at deserialize (the wire model is tcp-only); the - // second protocol dimension is retained in the dedupe key in - // case UDP support is enabled later. - let mut seen: std::collections::HashSet<(u16, &str)> = - std::collections::HashSet::new(); - for pm in &converted { - if !seen.insert((pm.windows_port, pm.protocol.as_str())) { - let msg = format!( - "experimental.wslc.portMappings: duplicate windowsPort {} \ - for protocol '{}'", - pm.windows_port, pm.protocol - ); - return Err(WxcError::ConfigParse(msg)); - } - } - config.port_mappings = converted; - } - Some(config) - } else { - None - }; + if raw_exp.wslc.is_some() { + let msg = "'experimental.wslc' has moved to the stable section; \ + use top-level 'wslc' instead." + .to_string(); + return Err(WxcError::ConfigParse(msg)); + } if raw_exp.seatbelt.is_some() { let msg = "'experimental.seatbelt' has moved to the stable section; \ use top-level 'seatbelt' instead." @@ -1315,13 +1262,83 @@ fn convert_wire_config( ExperimentalConfig { test, windows_sandbox, - wslc, telemetry, } } else { ExperimentalConfig::default() }; + // Top-level `wslc` config. Configs using `experimental.wslc` are rejected + // above. + let wslc = if let Some(cc) = cfg.wslc { + // The state-aware path clears `wslc` before delegating here, so a + // surviving `provision` is genuinely a one-shot request. + if cc.provision.is_some() { + return Err(WxcError::ConfigParse( + "One-shot requests do not accept 'wslc.provision'; it configures the \ + state-aware lifecycle. Remove it, or add a 'phase' field to make this \ + a state-aware request." + .to_string(), + )); + } + let mut config = WslcConfig::default(); + if let Some(os) = cc.target_os { + config.target_os = os; + } + if let Some(img) = cc.image { + config.image = img; + } + config.image_tar_path = cc.image_tar_path; + config.cpu_count = cc.cpu_count; + config.memory_mb = cc.memory_mb; + if let Some(gpu) = cc.gpu { + config.gpu = gpu; + } + config.storage_path = cc.storage_path; + if let Some(mappings) = cc.port_mappings { + let mut converted = Vec::with_capacity(mappings.len()); + for (idx, m) in mappings.into_iter().enumerate() { + if m.windows_port == 0 { + let msg = format!("wslc.portMappings[{idx}]: 'windowsPort' must be > 0"); + return Err(WxcError::ConfigParse(msg)); + } + if m.container_port == 0 { + let msg = format!("wslc.portMappings[{idx}]: 'containerPort' must be > 0"); + return Err(WxcError::ConfigParse(msg)); + } + // Only TCP is representable in the wire model + // (TransportProtocol is tcp-only); a `udp` value is rejected + // at deserialize. The WSLC SDK runtime returns E_NOTIMPL for + // UDP, so only TCP is currently supported. + let protocol = "tcp".to_string(); + converted.push(PortMapping { + windows_port: m.windows_port, + container_port: m.container_port, + protocol, + }); + } + // Reject duplicate (windowsPort, protocol) entries. Same host + // port on TCP+UDP would in principle be legal, but UDP is + // rejected at deserialize (the wire model is tcp-only); the + // second protocol dimension is retained in the dedupe key in + // case UDP support is enabled later. + let mut seen: std::collections::HashSet<(u16, &str)> = std::collections::HashSet::new(); + for pm in &converted { + if !seen.insert((pm.windows_port, pm.protocol.as_str())) { + let msg = format!( + "wslc.portMappings: duplicate windowsPort {} for protocol '{}'", + pm.windows_port, pm.protocol + ); + return Err(WxcError::ConfigParse(msg)); + } + } + config.port_mappings = converted; + } + Some(config) + } else { + None + }; + // Top-level `seatbelt` config. Configs using `experimental.seatbelt` are // rejected above. let seatbelt = cfg.seatbelt.map(make_seatbelt_config); @@ -1353,6 +1370,7 @@ fn convert_wire_config( policy, lxc_config, seatbelt, + wslc, experimental_enabled: false, testing_features_enabled: false, experimental, @@ -1430,11 +1448,15 @@ fn convert_wire_state_aware( // discarded (the same silent-policy-drop class as the moved-to-stable // sections). if let Some(serde_json::Value::Object(exp)) = experimental_raw.as_ref() { - for key in ["seatbelt", "macos_sandbox"] { + for (key, section) in [ + ("seatbelt", "seatbelt"), + ("macos_sandbox", "seatbelt"), + ("wslc", "wslc"), + ] { if exp.contains_key(key) { let msg = format!( "'experimental.{key}' has moved to the stable section; \ - use top-level 'seatbelt' instead." + use top-level '{section}' instead." ); return Err(WxcError::ConfigParse(msg)); } @@ -1443,6 +1465,53 @@ fn convert_wire_state_aware( validate_experimental_backend_keys(containment.as_ref(), experimental_raw.as_ref())?; + // Raw top-level object, retained so the dispatcher can read the per-phase + // config of backends promoted to the stable surface, which live at + // `.` instead of under `experimental`. The typed + // deserialize above already proved `json` is a JSON object. + let stable_raw = serde_json::from_str::(json).ok(); + + // The state-aware lifecycle reads WSLc's provision config from + // `wslc.provision`, so only the one-shot-only siblings below are rejected; + // the daemon-backed lifecycle does not honor them. + if let Some(wslc) = cfg.wslc.as_ref() { + if phase != Phase::Provision { + return Err(WxcError::ConfigParse(format!( + "State-aware '{phase}' requests do not accept a 'wslc' section; \ + WSLc backend configuration is fixed at provision time." + ))); + } + if containment != Some(ContainmentBackend::Wslc) { + return Err(WxcError::ConfigParse( + "The 'wslc' section requires 'containment': \"wslc\". Remove the section, \ + or set the matching containment." + .to_string(), + )); + } + let mut one_shot_only: Vec<&'static str> = Vec::new(); + for (present, name) in [ + (wslc.target_os.is_some(), "targetOs"), + (wslc.image.is_some(), "image"), + (wslc.image_tar_path.is_some(), "imageTarPath"), + (wslc.cpu_count.is_some(), "cpuCount"), + (wslc.memory_mb.is_some(), "memoryMb"), + (wslc.gpu.is_some(), "gpu"), + (wslc.storage_path.is_some(), "storagePath"), + (wslc.port_mappings.is_some(), "portMappings"), + ] { + if present { + one_shot_only.push(name); + } + } + if !one_shot_only.is_empty() { + return Err(WxcError::ConfigParse(format!( + "State-aware lifecycle requests do not accept one-shot 'wslc' field(s): {}. \ + Use 'wslc.provision' for state-aware container configuration.", + one_shot_only.join(", ") + ))); + } + } + let sandbox_id = cfg.sandbox_id.clone(); let correlation_vector = cfg.correlation_vector.clone(); let network_supplied = cfg.network.is_some(); @@ -1482,6 +1551,9 @@ fn convert_wire_state_aware( cfg.correlation_vector = None; cfg.experimental = None; cfg.seatbelt = None; + // Validated above; the shared one-shot converter has no state-aware + // meaning for it (`wslc.provision` is read by the dispatcher instead). + cfg.wslc = None; cfg.process_container = None; cfg.lxc = None; cfg.lifecycle = None; @@ -1540,9 +1612,10 @@ fn convert_wire_state_aware( sandbox_id, correlation_vector, experimental_raw, + stable_raw, // Retain the decoded request text so the dispatcher can deserialize each - // `experimental..` sub-slice positionally and report - // typed errors with whole-file line/column (parity with base config). + // per-phase config sub-slice positionally and report typed errors with + // whole-file line/column (parity with base config). source_text: Some(json.to_owned().into_boxed_str()), }) } @@ -4747,19 +4820,20 @@ mod tests { } #[test] - fn experimental_port_mapping_unknown_field_accepted() { - // The experimental surface is intentionally permissive (forward-compat): - // an unknown field on a nested experimental struct must be tolerated and - // the known fields preserved. - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80, "futureField": "ignored"}]}}}"#; + fn wslc_port_mapping_unknown_field_rejected() { + // `wslc` is a stable section, so — unlike the permissive `experimental` + // subtree it was promoted out of — its nested structs are closed and an + // unknown field is a hard parse error rather than silently tolerated. + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80, "futureField": "ignored"}]}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); - let req = load_request(&encoded, &mut logger, true).unwrap(); - let wslc = req.experimental.wslc.expect("wslc config present"); - assert_eq!(wslc.port_mappings.len(), 1); - assert_eq!(wslc.port_mappings[0].windows_port, 8080); - assert_eq!(wslc.port_mappings[0].container_port, 80); + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("futureField"), + "unknown wslc port-mapping field should be rejected, got: {msg}" + ); } #[test] @@ -4970,6 +5044,126 @@ mod tests { ); } + #[test] + fn state_aware_rejects_experimental_wslc() { + // `experimental.wslc` moved to the stable section; the state-aware path + // must reject it with the migration message rather than silently + // discarding the provision config. + let json = r#"{ + "phase": "provision", + "containment": "wslc", + "experimental": {"wslc": {"provision": {"image": "alpine:latest"}}} + }"#; + let err = match load_mxc(json) { + Err(ParseError::StateAware(e)) => e.to_string(), + other => panic!("expected StateAware rejection, got: {other:?}"), + }; + assert!( + err.contains("has moved to the stable section") && err.contains("wslc"), + "got: {err}" + ); + } + + #[test] + fn state_aware_accepts_top_level_wslc_provision() { + let json = r#"{ + "phase": "provision", + "containment": "wslc", + "wslc": {"provision": {"image": "alpine:latest"}} + }"#; + match load_mxc(json) { + Ok(MxcRequest::StateAware(parsed)) => { + assert_eq!(parsed.phase, Phase::Provision); + let provision = parsed + .deserialize_config::( + crate::state_aware_request::SectionRoot::Stable, + "wslc", + "provision", + ) + .expect("provision config should deserialize") + .expect("provision config should be present"); + assert_eq!(provision.image.as_deref(), Some("alpine:latest")); + } + other => panic!("expected a state-aware request, got: {other:?}"), + } + } + + #[test] + fn state_aware_rejects_one_shot_wslc_fields() { + // The daemon-backed lifecycle does not honor the one-shot sizing knobs; + // they must be refused rather than silently dropped. + let json = r#"{ + "phase": "provision", + "containment": "wslc", + "wslc": {"cpuCount": 4, "memoryMb": 2048} + }"#; + let err = match load_mxc(json) { + Err(ParseError::StateAware(e)) => e.to_string(), + other => panic!("expected StateAware rejection, got: {other:?}"), + }; + assert!( + err.contains("cpuCount") && err.contains("memoryMb"), + "got: {err}" + ); + } + + #[test] + fn state_aware_rejects_wslc_section_on_non_provision_phase() { + let json = r#"{ + "phase": "start", + "sandboxId": "wslc:0123456789abcdef0123456789abcdef", + "wslc": {"provision": {"image": "alpine:latest"}} + }"#; + let err = match load_mxc(json) { + Err(ParseError::StateAware(e)) => e.to_string(), + other => panic!("expected StateAware rejection, got: {other:?}"), + }; + assert!(err.contains("fixed at provision time"), "got: {err}"); + } + + #[test] + fn state_aware_rejects_wslc_section_under_foreign_containment() { + let json = r#"{ + "phase": "provision", + "containment": "isolation_session", + "wslc": {"provision": {"image": "alpine:latest"}} + }"#; + let err = match load_mxc(json) { + Err(ParseError::StateAware(e)) => e.to_string(), + other => panic!("expected StateAware rejection, got: {other:?}"), + }; + assert!(err.contains("requires 'containment'"), "got: {err}"); + } + + #[test] + fn state_aware_rejects_foreign_experimental_block_under_wslc() { + let json = r#"{ + "phase": "provision", + "containment": "wslc", + "wslc": {"provision": {"image": "alpine:latest"}}, + "experimental": {"windows_sandbox": {"provision": {}}} + }"#; + let err = match load_mxc(json) { + Err(ParseError::StateAware(e)) => e.to_string(), + other => panic!("expected StateAware rejection, got: {other:?}"), + }; + assert!(err.contains("experimental.windows_sandbox"), "got: {err}"); + } + + #[test] + fn state_aware_rejects_foreign_experimental_block_under_stable_backend() { + let json = r#"{ + "phase": "provision", + "containment": "lxc", + "experimental": {"windows_sandbox": {"provision": {}}} + }"#; + let err = match load_mxc(json) { + Err(ParseError::StateAware(e)) => e.to_string(), + other => panic!("expected StateAware rejection, got: {other:?}"), + }; + assert!(err.contains("experimental.windows_sandbox"), "got: {err}"); + } + #[test] fn state_aware_rejects_experimental_macos_sandbox_alias() { let json = r#"{ @@ -5725,24 +5919,38 @@ mod tests { #[test] fn wslc_section_parsed() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12"}}}"#; + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "wslc": {"image": "python:3.12"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); let req = load_request(&encoded, &mut logger, true).unwrap(); - let wslc = req.experimental.wslc.unwrap(); + let wslc = req.wslc.unwrap(); assert_eq!(wslc.image, "python:3.12"); assert!(wslc.image_tar_path.is_none()); } + #[test] + fn one_shot_rejects_wslc_provision() { + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "wslc": {"provision": {"image": "alpine:latest"}}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("wslc.provision"), + "one-shot 'wslc.provision' should be rejected, got: {msg}" + ); + } + #[test] fn wslc_image_tar_path_parsed() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "my-image:latest", "imageTarPath": "C:\\images\\alpine.tar"}}}"#; + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "wslc": {"image": "my-image:latest", "imageTarPath": "C:\\images\\alpine.tar"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); let req = load_request(&encoded, &mut logger, true).unwrap(); - let wslc = req.experimental.wslc.unwrap(); + let wslc = req.wslc.unwrap(); assert_eq!(wslc.image, "my-image:latest"); assert_eq!( wslc.image_tar_path.as_deref(), @@ -5752,12 +5960,12 @@ mod tests { #[test] fn wslc_port_mapping_basic_tcp_parsed() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80, "protocol": "tcp"}]}}}"#; + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80, "protocol": "tcp"}]}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); let req = load_request(&encoded, &mut logger, true).unwrap(); - let wslc = req.experimental.wslc.unwrap(); + let wslc = req.wslc.unwrap(); assert_eq!(wslc.port_mappings.len(), 1); assert_eq!(wslc.port_mappings[0].windows_port, 8080); assert_eq!(wslc.port_mappings[0].container_port, 80); @@ -5766,12 +5974,12 @@ mod tests { #[test] fn wslc_port_mappings_default_protocol_is_tcp() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80}]}}}"#; + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80}]}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); let req = load_request(&encoded, &mut logger, true).unwrap(); - let wslc = req.experimental.wslc.unwrap(); + let wslc = req.wslc.unwrap(); assert_eq!(wslc.port_mappings[0].protocol, "tcp"); } @@ -5780,7 +5988,7 @@ mod tests { // Strict enums are case-sensitive: "TCP" is not the lowercase wire // value "tcp", so it is rejected at deserialize as an unknown variant. // Only lowercase "tcp" is accepted (see wslc_port_mapping_basic_tcp_parsed). - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80, "protocol": "TCP"}]}}}"#; + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80, "protocol": "TCP"}]}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -5797,7 +6005,7 @@ mod tests { // The wire model's TransportProtocol is tcp-only (the WSLC SDK runtime // returns E_NOTIMPL for UDP), so "udp" is rejected at // deserialize as an unknown enum variant. - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 5353, "containerPort": 53, "protocol": "udp"}]}}}"#; + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 5353, "containerPort": 53, "protocol": "udp"}]}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -5811,7 +6019,7 @@ mod tests { #[test] fn wslc_port_mapping_missing_windows_port_rejected() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"containerPort": 80}]}}}"#; + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "wslc": {"image": "python:3.12", "portMappings": [{"containerPort": 80}]}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -5825,7 +6033,7 @@ mod tests { #[test] fn wslc_port_mapping_missing_container_port_rejected() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080}]}}}"#; + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080}]}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -5839,7 +6047,7 @@ mod tests { #[test] fn wslc_port_mapping_zero_windows_port_rejected() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 0, "containerPort": 80}]}}}"#; + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 0, "containerPort": 80}]}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -5853,7 +6061,7 @@ mod tests { #[test] fn wslc_port_mapping_zero_container_port_rejected() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 0}]}}}"#; + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 0}]}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -5869,7 +6077,7 @@ mod tests { fn wslc_port_mapping_unsupported_protocol_rejected() { // An unknown protocol like "sctp" is rejected at deserialize: the // tcp-only TransportProtocol enum has no matching variant. - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80, "protocol": "sctp"}]}}}"#; + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80, "protocol": "sctp"}]}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -5883,7 +6091,7 @@ mod tests { #[test] fn wslc_port_mapping_duplicate_host_port_same_protocol_rejected() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80}, {"windowsPort": 8080, "containerPort": 81}]}}}"#; + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80}, {"windowsPort": 8080, "containerPort": 81}]}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -5897,12 +6105,12 @@ mod tests { #[test] fn wslc_port_mapping_empty_list_default() { - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12"}}}"#; + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "wslc": {"image": "python:3.12"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); let req = load_request(&encoded, &mut logger, true).unwrap(); - let wslc = req.experimental.wslc.unwrap(); + let wslc = req.wslc.unwrap(); assert!(wslc.port_mappings.is_empty()); } @@ -6024,7 +6232,7 @@ mod tests { // `present_backend_sections` reads to detect a configured backend. // Pairing it with another backend section must still be refused, or // removing the domain slot would have silently dropped the check. - let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "isolation_session", "experimental": {"isolation_session": {}, "wslc": {"image": "alpine:latest"}}}"#; + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "isolation_session", "experimental": {"isolation_session": {}}, "wslc": {"image": "alpine:latest"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -6032,7 +6240,7 @@ mod tests { .expect_err("two backend sections must be refused"); let msg = format!("{err}"); assert!( - msg.contains("experimental.wslc") || msg.contains("isolation_session"), + msg.contains("wslc") || msg.contains("isolation_session"), "expected the conflicting section to be named, got: {msg}" ); } @@ -6127,6 +6335,23 @@ mod tests { ); } + #[test] + fn experimental_wslc_errors_with_migration_message() { + // After promotion, configs using experimental.wslc must error rather + // than silently ignoring the block. + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "alpine:latest"}}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + let msg = format!("{:?}", err); + assert!( + msg.contains("has moved to the stable section") && msg.contains("wslc"), + "expected migration error, got: {}", + msg + ); + } + // Legacy wire-name aliases. The parser accepts the pre-0.6 wire vocabulary // (`appcontainer`, `macos_sandbox`, and the `appContainer` / // `experimental.macos_sandbox` sub-block keys) regardless of the declared @@ -6316,7 +6541,7 @@ mod tests { "containment": "isolation_session", "experimental": { "isolation_session": {}, - "wslc": {"image": "alpine:latest"} + "windows_sandbox": {"idleTimeoutMs": 1000} } }"#; let encoded = base64_encode(json.as_bytes()); @@ -6329,7 +6554,7 @@ mod tests { "error did not mention multi-backend rejection: {msg}" ); assert!( - msg.contains("experimental.wslc"), + msg.contains("experimental.windows_sandbox"), "error did not name the foreign section: {msg}" ); } diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs index 02465f660..e918e650f 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -75,7 +75,7 @@ impl ContainmentBackend { ContainmentBackend::ProcessContainer => Some("processContainer"), ContainmentBackend::Lxc => Some("lxc"), ContainmentBackend::WindowsSandbox => Some("experimental.windows_sandbox"), - ContainmentBackend::Wslc => Some("experimental.wslc"), + ContainmentBackend::Wslc => Some("wslc"), ContainmentBackend::Seatbelt => Some("seatbelt"), ContainmentBackend::IsolationSession => Some("experimental.isolation_session"), ContainmentBackend::Bubblewrap @@ -748,15 +748,12 @@ pub struct ContainerPolicy { /// present), captured at parse time. The twin of `network_specified`, and /// necessary for the same reason: `UiPolicy::default()` is full lockdown, /// so an absent `ui` and an explicitly-supplied lockdown `ui` are - /// indistinguishable from the other fields here. Parse-derived, never on - /// the wire. + /// indistinguishable by value. Parse-derived, never on the wire. /// - /// Consumed only by IsolationSession today, which has no UI-restriction - /// primitive and refuses a supplied UI policy rather than accepting and - /// dropping it. The other backends that do not enforce `policy.ui` — LXC - /// and Bubblewrap on Linux, Seatbelt on macOS, Windows Sandbox — still - /// accept and ignore it, so this flag being set does not mean a UI policy - /// was honored anywhere; it means only that the caller supplied one. + /// Consumed by the backends that have no UI-restriction primitive + /// (IsolationSession and WSLc today) to refuse a supplied UI policy rather + /// than accept and drop it. Being set does not mean a UI policy was honored + /// anywhere — only that the caller supplied one. #[serde(skip)] pub ui_specified: bool, /// BaseProcessContainer-specific UI config (Windows only, from processContainer.ui). @@ -926,8 +923,6 @@ pub struct ExperimentalConfig { /// Windows Sandbox backend (experimental). #[serde(rename = "windows_sandbox")] pub windows_sandbox: Option, - /// WSL Container (WSLC SDK) backend (experimental). - pub wslc: Option, /// Telemetry configuration (experimental). pub telemetry: Option, } @@ -963,6 +958,8 @@ pub struct ExecutionRequest { pub lxc_config: LxcConfig, /// Seatbelt (macOS) backend configuration (used when containment == Seatbelt). pub seatbelt: Option, + /// WSL Container (WSLC SDK) backend configuration (used when containment == Wslc). + pub wslc: Option, /// Whether the --experimental flag was passed. pub experimental_enabled: bool, /// Whether the --allow-testing-features flag was passed. Gates testing-only, diff --git a/src/core/wxc_common/src/state_aware_backend.rs b/src/core/wxc_common/src/state_aware_backend.rs index 4c226fe75..8b927ffd3 100644 --- a/src/core/wxc_common/src/state_aware_backend.rs +++ b/src/core/wxc_common/src/state_aware_backend.rs @@ -24,6 +24,7 @@ use serde::{de::DeserializeOwned, Serialize}; use crate::id::mint_random_token; use crate::models::ExecutionRequest; use crate::mxc_error::MxcError; +use crate::state_aware_request::SectionRoot; /// Platform pipe-handle wrapper used by `ExecHandle`. On Windows this is a /// kernel `HANDLE`; on Unix-like targets it is a raw file descriptor. @@ -225,11 +226,17 @@ pub trait StatefulSandboxBackend { /// Wire-format `containment` value for this backend, matching the SDK's /// `StateAwareContainmentBackend` member name (e.g. `"isolation_session"`). - /// Used by the dispatcher to navigate - /// `experimental..` in the request envelope and to + /// Used by the dispatcher to navigate the per-phase config section in the + /// request envelope (see [`SECTION_ROOT`](Self::SECTION_ROOT)) and to /// resolve provision-phase requests to the right backend implementation. const BACKEND_KEY: &'static str; + /// Which envelope section holds this backend's per-phase config. Defaults + /// to the permissive `experimental.` block; a backend promoted + /// to the stable surface overrides this to [`SectionRoot::Stable`] so its + /// config is read from the top-level `` section instead. + const SECTION_ROOT: SectionRoot = SectionRoot::Experimental; + type ProvisionConfig: DeserializeOwned; type StartConfig: DeserializeOwned; type ExecConfig: DeserializeOwned; diff --git a/src/core/wxc_common/src/state_aware_dispatch.rs b/src/core/wxc_common/src/state_aware_dispatch.rs index d27044696..cfa77dec9 100644 --- a/src/core/wxc_common/src/state_aware_dispatch.rs +++ b/src/core/wxc_common/src/state_aware_dispatch.rs @@ -89,7 +89,8 @@ pub fn dispatch_state_aware_exec( } let request = parsed.request.clone(); let sandbox_id = parsed.sandbox_id_required()?.to_string(); - let config = parsed.deserialize_config::(B::BACKEND_KEY, "exec")?; + let config = + parsed.deserialize_config::(B::SECTION_ROOT, B::BACKEND_KEY, "exec")?; validate_exec_common(&request)?; backend.validate_exec(&sandbox_id, &request, config.as_ref())?; // The caller drives the returned streams itself, so the backend must @@ -108,8 +109,11 @@ pub fn dispatch_state_aware( let phase = parsed.phase; match phase { Phase::Provision => { - let config = - parsed.deserialize_config::(B::BACKEND_KEY, "provision")?; + let config = parsed.deserialize_config::( + B::SECTION_ROOT, + B::BACKEND_KEY, + "provision", + )?; backend.validate_provision(&request, config.as_ref())?; if dry_run { return Ok(DispatchOutcome::Envelope(empty_result_envelope())); @@ -119,7 +123,11 @@ pub fn dispatch_state_aware( } Phase::Start => { let sandbox_id = parsed.sandbox_id_required()?.to_string(); - let config = parsed.deserialize_config::(B::BACKEND_KEY, "start")?; + let config = parsed.deserialize_config::( + B::SECTION_ROOT, + B::BACKEND_KEY, + "start", + )?; backend.validate_start(&sandbox_id, &request, config.as_ref())?; if dry_run { return Ok(DispatchOutcome::Envelope(empty_result_envelope())); @@ -129,7 +137,11 @@ pub fn dispatch_state_aware( } Phase::Exec => { let sandbox_id = parsed.sandbox_id_required()?.to_string(); - let config = parsed.deserialize_config::(B::BACKEND_KEY, "exec")?; + let config = parsed.deserialize_config::( + B::SECTION_ROOT, + B::BACKEND_KEY, + "exec", + )?; // Everything needed for exec is now owned (`request` clone, owned // `sandbox_id`, owned `config`); drop the parsed request so its // retained decoded source text and raw `experimental` tree are not @@ -146,7 +158,11 @@ pub fn dispatch_state_aware( } Phase::Stop => { let sandbox_id = parsed.sandbox_id_required()?.to_string(); - let config = parsed.deserialize_config::(B::BACKEND_KEY, "stop")?; + let config = parsed.deserialize_config::( + B::SECTION_ROOT, + B::BACKEND_KEY, + "stop", + )?; backend.validate_stop(&sandbox_id, &request, config.as_ref())?; if dry_run { return Ok(DispatchOutcome::Envelope(empty_result_envelope())); @@ -156,8 +172,11 @@ pub fn dispatch_state_aware( } Phase::Deprovision => { let sandbox_id = parsed.sandbox_id_required()?.to_string(); - let config = - parsed.deserialize_config::(B::BACKEND_KEY, "deprovision")?; + let config = parsed.deserialize_config::( + B::SECTION_ROOT, + B::BACKEND_KEY, + "deprovision", + )?; backend.validate_deprovision(&sandbox_id, &request, config.as_ref())?; if dry_run { return Ok(DispatchOutcome::Envelope(empty_result_envelope())); @@ -816,6 +835,7 @@ mod tests { sandbox_id: sandbox_id.map(String::from), correlation_vector: None, experimental_raw: exp, + stable_raw: None, source_text: None, } } @@ -1064,6 +1084,7 @@ mod tests { sandbox_id: None, correlation_vector: None, experimental_raw: None, + stable_raw: None, source_text: None, }; let err = run_state_aware(p, false).unwrap_err(); @@ -1079,6 +1100,7 @@ mod tests { sandbox_id: None, correlation_vector: None, experimental_raw: None, + stable_raw: None, source_text: None, }; let err = run_state_aware(p, false).unwrap_err(); @@ -1094,6 +1116,7 @@ mod tests { sandbox_id: Some("iso:wxc-abcd1234".into()), correlation_vector: None, experimental_raw: None, + stable_raw: None, source_text: None, }; assert_eq!( @@ -1111,6 +1134,7 @@ mod tests { sandbox_id: Some("wsb:deadbeef".into()), correlation_vector: None, experimental_raw: None, + stable_raw: None, source_text: None, }; assert_eq!( @@ -1128,6 +1152,7 @@ mod tests { sandbox_id: Some("wslc:deadbeef".into()), correlation_vector: None, experimental_raw: None, + stable_raw: None, source_text: None, }; assert_eq!(resolve_backend(&p).unwrap(), ContainmentBackend::Wslc); @@ -1142,6 +1167,7 @@ mod tests { sandbox_id: Some("unknownxyz:abc".into()), correlation_vector: None, experimental_raw: None, + stable_raw: None, source_text: None, }; let err = resolve_backend(&p).unwrap_err(); @@ -1157,6 +1183,7 @@ mod tests { sandbox_id: Some("no-colon".into()), correlation_vector: None, experimental_raw: None, + stable_raw: None, source_text: None, }; let err = resolve_backend(&p).unwrap_err(); diff --git a/src/core/wxc_common/src/state_aware_request.rs b/src/core/wxc_common/src/state_aware_request.rs index f1cb9d3fa..85b0f6b0e 100644 --- a/src/core/wxc_common/src/state_aware_request.rs +++ b/src/core/wxc_common/src/state_aware_request.rs @@ -11,10 +11,11 @@ //! `ParsedStateAwareRequest` bundles the inner `ExecutionRequest` (populated by //! the same parser path one-shot uses for cross-cutting fields) with the //! state-aware-only fields: `phase`, optional `containment`, optional -//! `sandbox_id`, and the raw JSON `experimental` block. The dispatcher -//! resolves the backend, deserialises the per-backend per-phase config from -//! `experimental_raw` via `deserialize_config`, and asserts -//! `sandbox_id_required` for non-provision phases. +//! `sandbox_id`, and the raw JSON per-backend config blocks. The dispatcher +//! resolves the backend, deserialises the per-backend per-phase config via +//! `deserialize_config` — from `experimental_raw` for experimental backends +//! and from `stable_raw` for backends promoted to the stable surface — and +//! asserts `sandbox_id_required` for non-provision phases. use std::collections::HashMap; @@ -67,6 +68,29 @@ impl From for Phase { } } +/// Where a backend's per-phase config section lives in the request envelope. +/// +/// Experimental backends nest their section under the permissive `experimental` +/// block; a backend promoted to the stable surface owns a top-level section of +/// the same name. Both shapes then nest one object per lifecycle phase. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SectionRoot { + /// `experimental..`. + Experimental, + /// `.` — promoted to the stable, top-level surface. + Stable, +} + +impl SectionRoot { + /// JSON path of this root's per-phase config, for error prefixes. + fn phase_path(self, backend_key: &str, phase_name: &str) -> String { + match self { + Self::Experimental => format!("experimental.{backend_key}.{phase_name}"), + Self::Stable => format!("{backend_key}.{phase_name}"), + } + } +} + /// Parsed state-aware request. Pairs the inner `ExecutionRequest` with the /// state-aware-only wire fields the dispatcher consumes. #[derive(Debug, Clone)] @@ -94,6 +118,10 @@ pub struct ParsedStateAwareRequest { /// `{ : { : , ... }, ... }`. /// `deserialize_config` navigates the two layers. pub experimental_raw: Option, + /// Raw top-level JSON object of the request (un-narrowed), used to reach the + /// per-phase config of backends promoted to the stable surface, which live + /// at `.` rather than under `experimental`. + pub stable_raw: Option, /// Full DECODED request text, retained so `deserialize_config` can /// deserialize the `experimental..` sub-slice positionally /// and report typed errors with whole-file (line, column) coordinates — @@ -109,26 +137,33 @@ impl ParsedStateAwareRequest { /// /// `backend_key` is the wire-format backend name (the `containment` /// string, e.g. "isolation_session") — typically pulled from a trait const - /// at the dispatcher. + /// at the dispatcher. `root` selects which envelope section holds it: + /// `experimental.` for experimental backends, or the top-level + /// `` for backends promoted to the stable surface. pub fn deserialize_config( &self, + root: SectionRoot, backend_key: &str, phase_name: &str, ) -> Result, MxcError> { // Presence gate over the parsed value tree. Cheap, and preserves the // established "backend key or phase key absent => None" behavior // regardless of whether the positional path below is available. - let Some(exp) = self.experimental_raw.as_ref() else { + let section = match root { + SectionRoot::Experimental => self.experimental_raw.as_ref(), + SectionRoot::Stable => self.stable_raw.as_ref(), + }; + let Some(section) = section else { return Ok(None); }; - let Some(backend_obj) = exp.get(backend_key) else { + let Some(backend_obj) = section.get(backend_key) else { return Ok(None); }; let Some(phase_value) = backend_obj.get(phase_name) else { return Ok(None); }; - let prefix = format!("experimental.{backend_key}.{phase_name}"); + let prefix = root.phase_path(backend_key, phase_name); // Preferred path: deserialize the phase config directly from its // sub-slice of the retained source text so typed errors carry @@ -137,7 +172,7 @@ impl ParsedStateAwareRequest { // would-be typed error is never turned into a navigation panic. if let Some(source_text) = self.source_text.as_deref() { if let Some((fragment, fragment_offset)) = - locate_phase_fragment(source_text, backend_key, phase_name) + locate_phase_fragment(source_text, root, backend_key, phase_name) { return match config_deserialize::from_str::(fragment) { Ok(config) => Ok(Some(config)), @@ -174,9 +209,10 @@ impl ParsedStateAwareRequest { } } -/// Navigate the retained request text to the `experimental..` -/// sub-slice, returning the fragment (borrowed from `source_text`) and its byte -/// offset within `source_text`. +/// Navigate the retained request text to the per-phase config sub-slice — +/// `experimental..` or the promoted top-level +/// `.` — returning the fragment (borrowed from `source_text`) +/// and its byte offset within `source_text`. /// /// Each layer is re-parsed as a map of owned-key → borrowed [`RawValue`], so the /// returned fragment is a genuine sub-slice of `source_text` whose byte offset is @@ -194,12 +230,18 @@ impl ParsedStateAwareRequest { /// returned fragment still points into `source_text`. fn locate_phase_fragment<'a>( source_text: &'a str, + root: SectionRoot, backend_key: &str, phase_name: &str, ) -> Option<(&'a str, usize)> { let top: HashMap = serde_json::from_str(source_text).ok()?; - let experimental = top.get("experimental")?; - let backends: HashMap = serde_json::from_str(experimental.get()).ok()?; + let backends: HashMap = match root { + SectionRoot::Experimental => { + let experimental = top.get("experimental")?; + serde_json::from_str(experimental.get()).ok()? + } + SectionRoot::Stable => top, + }; let backend = backends.get(backend_key)?; let phases: HashMap = serde_json::from_str(backend.get()).ok()?; let fragment = phases.get(phase_name)?.get(); @@ -250,6 +292,7 @@ mod tests { sandbox_id: None, correlation_vector: None, experimental_raw: exp, + stable_raw: None, source_text: None, } } @@ -268,6 +311,7 @@ mod tests { sandbox_id: None, correlation_vector: None, experimental_raw, + stable_raw: None, source_text: Some(source_text.to_owned().into_boxed_str()), } } @@ -276,7 +320,11 @@ mod tests { fn deserialize_config_returns_none_when_no_experimental_block() { let p = parsed_with_experimental(None, Phase::Start); let r = p - .deserialize_config::("isolation_session", "start") + .deserialize_config::( + SectionRoot::Experimental, + "isolation_session", + "start", + ) .unwrap(); assert!(r.is_none()); } @@ -285,7 +333,11 @@ mod tests { fn deserialize_config_returns_none_when_backend_key_absent() { let p = parsed_with_experimental(Some(json!({})), Phase::Start); let r = p - .deserialize_config::("isolation_session", "start") + .deserialize_config::( + SectionRoot::Experimental, + "isolation_session", + "start", + ) .unwrap(); assert!(r.is_none()); } @@ -295,7 +347,11 @@ mod tests { let exp = json!({"isolation_session": {}}); let p = parsed_with_experimental(Some(exp), Phase::Start); let r = p - .deserialize_config::("isolation_session", "start") + .deserialize_config::( + SectionRoot::Experimental, + "isolation_session", + "start", + ) .unwrap(); assert!(r.is_none()); } @@ -309,7 +365,11 @@ mod tests { }); let p = parsed_with_experimental(Some(exp), Phase::Start); let r = p - .deserialize_config::("isolation_session", "start") + .deserialize_config::( + SectionRoot::Experimental, + "isolation_session", + "start", + ) .unwrap() .expect("config should be present"); assert_eq!( @@ -328,7 +388,11 @@ mod tests { }); let p = parsed_with_experimental(Some(exp), Phase::Start); let err = p - .deserialize_config::("isolation_session", "start") + .deserialize_config::( + SectionRoot::Experimental, + "isolation_session", + "start", + ) .unwrap_err(); assert_eq!(err.code, MxcErrorCode::MalformedRequest); assert!( @@ -359,7 +423,11 @@ mod tests { let parsed = parsed_with_source(source_text, Phase::Start); let err = parsed - .deserialize_config::("isolation_session", "start") + .deserialize_config::( + SectionRoot::Experimental, + "isolation_session", + "start", + ) .unwrap_err(); assert_eq!(err.code, MxcErrorCode::MalformedRequest); @@ -395,7 +463,11 @@ mod tests { let parsed = parsed_with_source(source_text, Phase::Start); let err = parsed - .deserialize_config::("isolation_session", "start") + .deserialize_config::( + SectionRoot::Experimental, + "isolation_session", + "start", + ) .unwrap_err(); assert!( @@ -426,7 +498,11 @@ mod tests { let parsed = parsed_with_experimental(Some(exp), Phase::Start); let err = parsed - .deserialize_config::("isolation_session", "start") + .deserialize_config::( + SectionRoot::Experimental, + "isolation_session", + "start", + ) .unwrap_err(); assert_eq!(err.code, MxcErrorCode::MalformedRequest); @@ -459,14 +535,14 @@ mod tests { let parsed = parsed_with_experimental(Some(exp), Phase::Start); let error = parsed - .deserialize_config::("wslc", "start") + .deserialize_config::(SectionRoot::Experimental, "wslc", "start") .unwrap_err(); assert_eq!(error.code, MxcErrorCode::MalformedRequest); assert!( error .message - .contains("experimental.wslc.start.portMappings[0].windowsPort"), + .contains("wslc.start.portMappings[0].windowsPort"), "expected complete array element path, got: {}", error.message ); @@ -495,7 +571,11 @@ mod tests { let parsed = parsed_with_source(source_text, Phase::Start); let err = parsed - .deserialize_config::("isolation_session", "start") + .deserialize_config::( + SectionRoot::Experimental, + "isolation_session", + "start", + ) .unwrap_err(); assert_eq!(err.code, MxcErrorCode::MalformedRequest); @@ -532,7 +612,11 @@ mod tests { let parsed = parsed_with_source(source_text, Phase::Start); let err = parsed - .deserialize_config::("isolation_session", "start") + .deserialize_config::( + SectionRoot::Experimental, + "isolation_session", + "start", + ) .unwrap_err(); // `42` sits on whole-file line 4; serde reports the column at the end of @@ -555,7 +639,11 @@ mod tests { let parsed = parsed_with_source(source_text, Phase::Start); let err = parsed - .deserialize_config::("isolation_session", "start") + .deserialize_config::( + SectionRoot::Experimental, + "isolation_session", + "start", + ) .unwrap_err(); assert!( @@ -581,6 +669,7 @@ mod tests { experimental_raw: Some(json!({ "isolation_session": { "start": { "wrong_field": 42 } } })), + stable_raw: None, source_text: Some( r#"{"experimental":{"isolation_session":5}}"# .to_owned() @@ -589,7 +678,11 @@ mod tests { }; let err = parsed - .deserialize_config::("isolation_session", "start") + .deserialize_config::( + SectionRoot::Experimental, + "isolation_session", + "start", + ) .unwrap_err(); assert_eq!(err.code, MxcErrorCode::MalformedRequest); diff --git a/src/core/wxc_common/src/wire.rs b/src/core/wxc_common/src/wire.rs index eb7cc1890..e8685b2be 100644 --- a/src/core/wxc_common/src/wire.rs +++ b/src/core/wxc_common/src/wire.rs @@ -116,6 +116,10 @@ pub struct MxcConfig { #[serde(alias = "macos_sandbox")] pub seatbelt: Option, + /// WSL container backend settings (Windows). Used when containment is + /// `wslc`. + pub wslc: Option, + /// Experimental features. Only honored when `--experimental` is passed. pub experimental: Option, } @@ -582,7 +586,7 @@ pub struct Experimental { pub test: Option, /// Windows Sandbox backend config. pub windows_sandbox: Option, - /// WSL container backend config. + /// WSL container backend config (pre-promotion alias). pub wslc: Option, /// IsolationSession backend config (Windows). pub isolation_session: Option, @@ -628,7 +632,7 @@ pub struct WindowsSandbox { /// WSL container backend config. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Wslc { /// OS inside the WSL container. pub target_os: Option, @@ -648,16 +652,16 @@ pub struct Wslc { /// parser rejects `udp` because the WSLC SDK runtime returns `E_NOTIMPL` /// for UDP port mappings. pub port_mappings: Option>, - /// State-aware provision-phase configuration - /// (`experimental.wslc.provision`). Carries the container-creation knobs - /// for the state-aware lifecycle; the flat sibling fields above remain the - /// one-shot surface. Absent on one-shot configs and non-provision phases. + /// State-aware provision-phase configuration (`wslc.provision`). Carries + /// the container-creation knobs for the state-aware lifecycle; the flat + /// sibling fields above remain the one-shot surface. Absent on one-shot + /// configs and non-provision phases. pub provision: Option, } /// Per-phase WSLc **provision** configuration (state-aware lifecycle), nested -/// under `experimental.wslc.provision`. Carries only what the amortized daemon -/// session honors: the container image (or a local tarball to import). +/// under `wslc.provision`. Carries only what the amortized daemon session +/// honors: the container image (or a local tarball to import). /// /// Filesystem mounts and network mode derive from the top-level `policy` /// section (readwrite / readonly paths, network), not from here. The @@ -668,7 +672,7 @@ pub struct Wslc { /// the top-level `process` section), so they have no phase struct. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct WslcProvisionPhase { /// Container image reference (e.g. `alpine:latest`). Defaults to /// `alpine:latest` when omitted. @@ -677,11 +681,10 @@ pub struct WslcProvisionPhase { pub image_tar_path: Option, } -/// A single host → container port forward. Reachable only under the permissive -/// `experimental` surface, so unknown fields are tolerated (forward-compat). +/// A single host → container port forward. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct PortMapping { /// Host (Windows) port. #[cfg_attr(feature = "schema-gen", schemars(range(min = 1, max = 65535)))] diff --git a/src/ffi/mxc_ffi/src/state_aware.rs b/src/ffi/mxc_ffi/src/state_aware.rs index ad32ea5b6..1cf9c6b82 100644 --- a/src/ffi/mxc_ffi/src/state_aware.rs +++ b/src/ffi/mxc_ffi/src/state_aware.rs @@ -111,7 +111,7 @@ impl MxcStateAwareResult { /// same reason. /// /// `experimental` is non-zero to opt in to the experimental backends -/// (WindowsSandbox, IsolationSession, WSLc); with zero they are refused with +/// (WindowsSandbox and IsolationSession); with zero they are refused with /// `backend_unavailable` before any work is done. /// /// # Safety diff --git a/tests/configs/wslc_custom_registry.json b/tests/configs/wslc_custom_registry.json index ab954e78a..09582d5af 100644 --- a/tests/configs/wslc_custom_registry.json +++ b/tests/configs/wslc_custom_registry.json @@ -1,5 +1,5 @@ { - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-custom-registry", "containment": "wslc", "process": { @@ -8,9 +8,7 @@ "network": { "defaultPolicy": "allow" }, - "experimental": { - "wslc": { - "image": "mcr.microsoft.com/cbl-mariner/base/core:2.0" - } + "wslc": { + "image": "mcr.microsoft.com/cbl-mariner/base/core:2.0" } } diff --git a/tests/configs/wslc_custom_registry_ghcr.json b/tests/configs/wslc_custom_registry_ghcr.json index 667abf95a..4d8efadc4 100644 --- a/tests/configs/wslc_custom_registry_ghcr.json +++ b/tests/configs/wslc_custom_registry_ghcr.json @@ -1,5 +1,5 @@ { - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-ghcr-registry", "containment": "wslc", "process": { @@ -8,9 +8,7 @@ "network": { "defaultPolicy": "allow" }, - "experimental": { - "wslc": { - "image": "ghcr.io/linuxserver/baseimage-alpine:3.21" - } + "wslc": { + "image": "ghcr.io/linuxserver/baseimage-alpine:3.21" } } diff --git a/tests/configs/wslc_custom_registry_quay.json b/tests/configs/wslc_custom_registry_quay.json index 6b560042a..25bec20dc 100644 --- a/tests/configs/wslc_custom_registry_quay.json +++ b/tests/configs/wslc_custom_registry_quay.json @@ -1,5 +1,5 @@ { - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-quay-registry", "containment": "wslc", "process": { @@ -8,9 +8,7 @@ "network": { "defaultPolicy": "allow" }, - "experimental": { - "wslc": { - "image": "quay.io/fedora/fedora-minimal:latest" - } + "wslc": { + "image": "quay.io/fedora/fedora-minimal:latest" } } diff --git a/tests/configs/wslc_denied_dotdot_alias.json b/tests/configs/wslc_denied_dotdot_alias.json index 219752645..3144ec025 100644 --- a/tests/configs/wslc_denied_dotdot_alias.json +++ b/tests/configs/wslc_denied_dotdot_alias.json @@ -1,20 +1,22 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-denied-dotdot-alias", "containment": "wslc", "process": { "commandLine": "echo SHOULD_NOT_RUN" }, "filesystem": { - "readwritePaths": ["C:\\ddttest\\real"], - "deniedPaths": ["C:\\ddttest\\link\\ghost\\sub\\..\\secret"] + "readwritePaths": [ + "C:\\ddttest\\real" + ], + "deniedPaths": [ + "C:\\ddttest\\link\\ghost\\sub\\..\\secret" + ] }, "network": { "defaultPolicy": "allow" }, - "experimental": { - "wslc": { - "image": "alpine:latest" - } + "wslc": { + "image": "alpine:latest" } } diff --git a/tests/configs/wslc_denied_masking.json b/tests/configs/wslc_denied_masking.json index 6dd917e79..acc8e1c3a 100644 --- a/tests/configs/wslc_denied_masking.json +++ b/tests/configs/wslc_denied_masking.json @@ -1,20 +1,23 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-denied-masking", "containment": "wslc", "process": { "commandLine": "V=/mnt/c/wslcmask; if cat \"$V/visible/control.txt\" 2>/dev/null | grep -q VISIBLE_SECRET; then echo VISIBLE_OK; else echo VISIBLE_MISSING; fi; if cat \"$V/secret_file.txt\" 2>/dev/null | grep -q FILE_SECRET; then echo FILE_LEAK; else echo FILE_MASKED_OK; fi; if ls \"$V/secret_dir\" 2>/dev/null | grep -q .; then echo DIR_LEAK; else echo DIR_MASKED_OK; fi" }, "filesystem": { - "readwritePaths": ["C:\\wslcmask\\visible"], - "deniedPaths": ["C:\\wslcmask\\secret_file.txt", "C:\\wslcmask\\secret_dir"] + "readwritePaths": [ + "C:\\wslcmask\\visible" + ], + "deniedPaths": [ + "C:\\wslcmask\\secret_file.txt", + "C:\\wslcmask\\secret_dir" + ] }, "network": { "defaultPolicy": "allow" }, - "experimental": { - "wslc": { - "image": "alpine:latest" - } + "wslc": { + "image": "alpine:latest" } } diff --git a/tests/configs/wslc_destroy_on_exit_false_rejected.json b/tests/configs/wslc_destroy_on_exit_false_rejected.json index f0367e82b..3b8aa3c6e 100644 --- a/tests/configs/wslc_destroy_on_exit_false_rejected.json +++ b/tests/configs/wslc_destroy_on_exit_false_rejected.json @@ -1,6 +1,6 @@ { "_comment": "Rejection fixture: the one-shot WSLc surface refuses lifecycle.destroyOnExit=false. The container is scoped to a session this process owns, and terminating that session reaps the container regardless of the AutoRemove flag, so `false` cannot be honored. Expects a rejection before any container is created -- the payload must never run.", - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-destroy-on-exit-false-rejected", "containment": "wslc", "process": { @@ -12,9 +12,7 @@ "network": { "defaultPolicy": "block" }, - "experimental": { - "wslc": { - "image": "alpine:latest" - } + "wslc": { + "image": "alpine:latest" } } diff --git a/tests/configs/wslc_destroy_on_exit_true.json b/tests/configs/wslc_destroy_on_exit_true.json index 61b30b3f4..148337e77 100644 --- a/tests/configs/wslc_destroy_on_exit_true.json +++ b/tests/configs/wslc_destroy_on_exit_true.json @@ -1,6 +1,6 @@ { "_comment": "Smoke test: asserts the config parses and the payload runs. WSLC's session teardown reaps session-scoped containers regardless of the AutoRemove flag, so `true` has no externally observable effect -- it is accepted because that teardown is exactly what it asks for. See wslc_destroy_on_exit_false_rejected.json for the refused counterpart.", - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-destroy-on-exit-true", "containment": "wslc", "process": { @@ -12,9 +12,7 @@ "network": { "defaultPolicy": "block" }, - "experimental": { - "wslc": { - "image": "alpine:latest" - } + "wslc": { + "image": "alpine:latest" } } diff --git a/tests/configs/wslc_env_vars.json b/tests/configs/wslc_env_vars.json index efcf22685..d69f26059 100644 --- a/tests/configs/wslc_env_vars.json +++ b/tests/configs/wslc_env_vars.json @@ -1,17 +1,18 @@ { - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-env-vars", "containment": "wslc", "process": { "commandLine": "echo MY_VAR=$MY_VAR && echo GREETING=$GREETING", - "env": ["MY_VAR=hello_from_host", "GREETING=world"] + "env": [ + "MY_VAR=hello_from_host", + "GREETING=world" + ] }, "network": { "defaultPolicy": "block" }, - "experimental": { - "wslc": { - "image": "alpine:latest" - } + "wslc": { + "image": "alpine:latest" } } diff --git a/tests/configs/wslc_exit_code.json b/tests/configs/wslc_exit_code.json index 7dc290e64..24443c3c4 100644 --- a/tests/configs/wslc_exit_code.json +++ b/tests/configs/wslc_exit_code.json @@ -1,5 +1,5 @@ { - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-exit-code", "containment": "wslc", "process": { @@ -8,9 +8,7 @@ "network": { "defaultPolicy": "block" }, - "experimental": { - "wslc": { - "image": "alpine:latest" - } + "wslc": { + "image": "alpine:latest" } } diff --git a/tests/configs/wslc_filesystem.json b/tests/configs/wslc_filesystem.json index d655fba6a..080c35aee 100644 --- a/tests/configs/wslc_filesystem.json +++ b/tests/configs/wslc_filesystem.json @@ -1,5 +1,5 @@ { - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-filesystem-test", "containment": "wslc", "process": { @@ -7,16 +7,16 @@ "cwd": "C:\\wslcfs" }, "filesystem": { - "readwritePaths": ["C:\\wslcfs"] + "readwritePaths": [ + "C:\\wslcfs" + ] }, "network": { "defaultPolicy": "allow" }, - "experimental": { - "wslc": { - "image": "alpine:latest", - "cpuCount": 2, - "memoryMb": 1024 - } + "wslc": { + "image": "alpine:latest", + "cpuCount": 2, + "memoryMb": 1024 } } diff --git a/tests/configs/wslc_filesystem_object.json b/tests/configs/wslc_filesystem_object.json index 2e5bd2a0a..812fee0a1 100644 --- a/tests/configs/wslc_filesystem_object.json +++ b/tests/configs/wslc_filesystem_object.json @@ -1,20 +1,22 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-object-validation", "containment": "wslc", "process": { "commandLine": "if cat /mnt/c/objtest/data/secret.txt 2>/dev/null; then echo OBJECT_LEAK; else echo OBJECT_MASKED_OK; fi" }, "filesystem": { - "readwritePaths": ["C:\\objtest\\data"], - "deniedPaths": ["C:\\objtest\\data_link"] + "readwritePaths": [ + "C:\\objtest\\data" + ], + "deniedPaths": [ + "C:\\objtest\\data_link" + ] }, "network": { "defaultPolicy": "allow" }, - "experimental": { - "wslc": { - "image": "alpine:latest" - } + "wslc": { + "image": "alpine:latest" } } diff --git a/tests/configs/wslc_large_output.json b/tests/configs/wslc_large_output.json index aa57ee4c8..9369b7f92 100644 --- a/tests/configs/wslc_large_output.json +++ b/tests/configs/wslc_large_output.json @@ -1,5 +1,5 @@ { - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-large-output", "containment": "wslc", "process": { @@ -8,9 +8,7 @@ "network": { "defaultPolicy": "block" }, - "experimental": { - "wslc": { - "image": "alpine:latest" - } + "wslc": { + "image": "alpine:latest" } } diff --git a/tests/configs/wslc_most_specific_denied_parent.json b/tests/configs/wslc_most_specific_denied_parent.json index 54f203006..ec37e4cbb 100644 --- a/tests/configs/wslc_most_specific_denied_parent.json +++ b/tests/configs/wslc_most_specific_denied_parent.json @@ -1,20 +1,22 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-most-specific-denied-parent", "containment": "wslc", "process": { "commandLine": "D=/mnt/c/wslcmsp/data; if cat \"$D/secret_child/keep.txt\" 2>/dev/null | grep -q CHILD_KEPT; then echo CHILD_OK; else echo CHILD_MISSING; fi; if echo w > \"$D/secret_child/written.txt\" 2>/dev/null && grep -q w \"$D/secret_child/written.txt\" 2>/dev/null; then echo CHILD_WRITE_OK; else echo CHILD_WRITE_FAIL; fi; if cat \"$D/sibling.txt\" 2>/dev/null | grep -q PARENT_SECRET; then echo PARENT_LEAK; else echo PARENT_MASKED_OK; fi" }, "filesystem": { - "readwritePaths": ["C:\\wslcmsp\\data\\secret_child"], - "deniedPaths": ["C:\\wslcmsp\\data"] + "readwritePaths": [ + "C:\\wslcmsp\\data\\secret_child" + ], + "deniedPaths": [ + "C:\\wslcmsp\\data" + ] }, "network": { "defaultPolicy": "allow" }, - "experimental": { - "wslc": { - "image": "alpine:latest" - } + "wslc": { + "image": "alpine:latest" } } diff --git a/tests/configs/wslc_network_isolated.json b/tests/configs/wslc_network_isolated.json index 5dc12daef..a7dc179c1 100644 --- a/tests/configs/wslc_network_isolated.json +++ b/tests/configs/wslc_network_isolated.json @@ -1,5 +1,5 @@ { - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-network-isolated", "containment": "wslc", "process": { @@ -8,9 +8,7 @@ "network": { "defaultPolicy": "block" }, - "experimental": { - "wslc": { - "image": "alpine:latest" - } + "wslc": { + "image": "alpine:latest" } } diff --git a/tests/configs/wslc_network_proxy.json b/tests/configs/wslc_network_proxy.json index a96793909..6a9d5c44a 100644 --- a/tests/configs/wslc_network_proxy.json +++ b/tests/configs/wslc_network_proxy.json @@ -1,18 +1,25 @@ { - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-network-proxy", "containment": "wslc", "process": { "commandLine": "set -e; ( while true; do printf 'HTTP/1.0 200 OK\\r\\nContent-Length: 9\\r\\nConnection: close\\r\\n\\r\\nPROXY_HIT' | nc -l -p 8888 -w 2 >/dev/null 2>&1; done ) & sleep 1; echo \"PROXY_ENV HTTP_PROXY=[$HTTP_PROXY] http_proxy=[$http_proxy] FTP_PROXY=[$FTP_PROXY] ftp_proxy=[$ftp_proxy] NO_PROXY=[$NO_PROXY] no_proxy=[$no_proxy]\"; BODY=$(wget -q -O - http://example.com/ 2>/dev/null || true); echo \"CLIENT_GOT=[$BODY]\"; case \"$BODY\" in *PROXY_HIT*) echo WSLC_PROXY_FUNCTIONAL_OK ;; *) echo WSLC_PROXY_FAIL ;; esac", - "env": ["HTTP_PROXY=http://attacker.invalid:1", "http_proxy=http://attacker.invalid:1", "FTP_PROXY=http://attacker.invalid:1", "ftp_proxy=http://attacker.invalid:1", "NO_PROXY=*", "no_proxy=attacker.invalid"] + "env": [ + "HTTP_PROXY=http://attacker.invalid:1", + "http_proxy=http://attacker.invalid:1", + "FTP_PROXY=http://attacker.invalid:1", + "ftp_proxy=http://attacker.invalid:1", + "NO_PROXY=*", + "no_proxy=attacker.invalid" + ] }, "network": { "defaultPolicy": "allow", - "proxy": { "url": "http://127.0.0.1:8888" } - }, - "experimental": { - "wslc": { - "image": "alpine:latest" + "proxy": { + "url": "http://127.0.0.1:8888" } + }, + "wslc": { + "image": "alpine:latest" } } diff --git a/tests/configs/wslc_port_mapping_multiple.json b/tests/configs/wslc_port_mapping_multiple.json index 6feec8de2..92af27360 100644 --- a/tests/configs/wslc_port_mapping_multiple.json +++ b/tests/configs/wslc_port_mapping_multiple.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-port-mapping-multiple", "containment": "wslc", "process": { @@ -8,13 +8,19 @@ "network": { "defaultPolicy": "allow" }, - "experimental": { - "wslc": { - "image": "python:3.12-alpine", - "portMappings": [ - { "windowsPort": 18080, "containerPort": 8080, "protocol": "tcp" }, - { "windowsPort": 19090, "containerPort": 9090, "protocol": "tcp" } - ] - } + "wslc": { + "image": "python:3.12-alpine", + "portMappings": [ + { + "windowsPort": 18080, + "containerPort": 8080, + "protocol": "tcp" + }, + { + "windowsPort": 19090, + "containerPort": 9090, + "protocol": "tcp" + } + ] } } diff --git a/tests/configs/wslc_port_mapping_tcp.json b/tests/configs/wslc_port_mapping_tcp.json index 99f72f178..2700c8dce 100644 --- a/tests/configs/wslc_port_mapping_tcp.json +++ b/tests/configs/wslc_port_mapping_tcp.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-port-mapping-tcp", "containment": "wslc", "process": { @@ -8,12 +8,14 @@ "network": { "defaultPolicy": "allow" }, - "experimental": { - "wslc": { - "image": "python:3.12-alpine", - "portMappings": [ - { "windowsPort": 18080, "containerPort": 8080, "protocol": "tcp" } - ] - } + "wslc": { + "image": "python:3.12-alpine", + "portMappings": [ + { + "windowsPort": 18080, + "containerPort": 8080, + "protocol": "tcp" + } + ] } } diff --git a/tests/configs/wslc_python_hello.json b/tests/configs/wslc_python_hello.json index f1967fa09..830607cde 100644 --- a/tests/configs/wslc_python_hello.json +++ b/tests/configs/wslc_python_hello.json @@ -1,5 +1,5 @@ { - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-python-hello", "containment": "wslc", "process": { @@ -8,9 +8,7 @@ "network": { "defaultPolicy": "block" }, - "experimental": { - "wslc": { - "image": "python:3.12-alpine" - } + "wslc": { + "image": "python:3.12-alpine" } } diff --git a/tests/configs/wslc_python_stdlib.json b/tests/configs/wslc_python_stdlib.json index 60decc244..d73f69266 100644 --- a/tests/configs/wslc_python_stdlib.json +++ b/tests/configs/wslc_python_stdlib.json @@ -1,5 +1,5 @@ { - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-python-stdlib", "containment": "wslc", "process": { @@ -8,9 +8,7 @@ "network": { "defaultPolicy": "block" }, - "experimental": { - "wslc": { - "image": "python:3.12-alpine" - } + "wslc": { + "image": "python:3.12-alpine" } } diff --git a/tests/configs/wslc_readonly_mount.json b/tests/configs/wslc_readonly_mount.json index 5ad6d429e..f5a4f1069 100644 --- a/tests/configs/wslc_readonly_mount.json +++ b/tests/configs/wslc_readonly_mount.json @@ -1,19 +1,19 @@ { - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-readonly-mount", "containment": "wslc", "process": { "commandLine": "cat /mnt/c/wslcro/test.txt && echo 'Read succeeded' && echo 'write test' > /mnt/c/wslcro/readonly_write_test.txt && echo 'Write succeeded (unexpected)' || echo 'Write blocked (expected for readonly)'" }, "filesystem": { - "readonlyPaths": ["C:\\wslcro"] + "readonlyPaths": [ + "C:\\wslcro" + ] }, "network": { "defaultPolicy": "block" }, - "experimental": { - "wslc": { - "image": "alpine:latest" - } + "wslc": { + "image": "alpine:latest" } } diff --git a/tests/configs/wslc_state_aware_deprovision.json b/tests/configs/wslc_state_aware_deprovision.json index 71bbacf7e..37b4121a2 100644 --- a/tests/configs/wslc_state_aware_deprovision.json +++ b/tests/configs/wslc_state_aware_deprovision.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "deprovision", "sandboxId": "{{SANDBOX_ID}}" } diff --git a/tests/configs/wslc_state_aware_exec_basic.json b/tests/configs/wslc_state_aware_exec_basic.json index 04c763922..e9f229356 100644 --- a/tests/configs/wslc_state_aware_exec_basic.json +++ b/tests/configs/wslc_state_aware_exec_basic.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/wslc_state_aware_exec_drip.json b/tests/configs/wslc_state_aware_exec_drip.json index 7a6830130..2dc24f295 100644 --- a/tests/configs/wslc_state_aware_exec_drip.json +++ b/tests/configs/wslc_state_aware_exec_drip.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/wslc_state_aware_exec_env.json b/tests/configs/wslc_state_aware_exec_env.json index 0a6589cac..9eb75ab21 100644 --- a/tests/configs/wslc_state_aware_exec_env.json +++ b/tests/configs/wslc_state_aware_exec_env.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/wslc_state_aware_exec_exit_0.json b/tests/configs/wslc_state_aware_exec_exit_0.json index bae4b1e06..fd333e7c2 100644 --- a/tests/configs/wslc_state_aware_exec_exit_0.json +++ b/tests/configs/wslc_state_aware_exec_exit_0.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/wslc_state_aware_exec_exit_1.json b/tests/configs/wslc_state_aware_exec_exit_1.json index cdaad72ed..c5704e71b 100644 --- a/tests/configs/wslc_state_aware_exec_exit_1.json +++ b/tests/configs/wslc_state_aware_exec_exit_1.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/wslc_state_aware_exec_exit_7.json b/tests/configs/wslc_state_aware_exec_exit_7.json index b083119a4..b2cbd6083 100644 --- a/tests/configs/wslc_state_aware_exec_exit_7.json +++ b/tests/configs/wslc_state_aware_exec_exit_7.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/wslc_state_aware_exec_proxy.json b/tests/configs/wslc_state_aware_exec_proxy.json index 5704bb54d..48c91e023 100644 --- a/tests/configs/wslc_state_aware_exec_proxy.json +++ b/tests/configs/wslc_state_aware_exec_proxy.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "network": { diff --git a/tests/configs/wslc_state_aware_exec_read_marker.json b/tests/configs/wslc_state_aware_exec_read_marker.json index 9480c0151..e4bc7a7d1 100644 --- a/tests/configs/wslc_state_aware_exec_read_marker.json +++ b/tests/configs/wslc_state_aware_exec_read_marker.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/wslc_state_aware_exec_rejected_filesystem.json b/tests/configs/wslc_state_aware_exec_rejected_filesystem.json index b0746c9f6..03d6c75ec 100644 --- a/tests/configs/wslc_state_aware_exec_rejected_filesystem.json +++ b/tests/configs/wslc_state_aware_exec_rejected_filesystem.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "filesystem": { diff --git a/tests/configs/wslc_state_aware_exec_write_marker.json b/tests/configs/wslc_state_aware_exec_write_marker.json index c5304c158..102dd6377 100644 --- a/tests/configs/wslc_state_aware_exec_write_marker.json +++ b/tests/configs/wslc_state_aware_exec_write_marker.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "exec", "sandboxId": "{{SANDBOX_ID}}", "process": { diff --git a/tests/configs/wslc_state_aware_provision.json b/tests/configs/wslc_state_aware_provision.json index 475e3dda9..d3f6f7c24 100644 --- a/tests/configs/wslc_state_aware_provision.json +++ b/tests/configs/wslc_state_aware_provision.json @@ -1,15 +1,13 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "provision", "containment": "wslc", "network": { "defaultPolicy": "block" }, - "experimental": { - "wslc": { - "provision": { - "image": "alpine:latest" - } + "wslc": { + "provision": { + "image": "alpine:latest" } } } diff --git a/tests/configs/wslc_state_aware_provision_bridged.json b/tests/configs/wslc_state_aware_provision_bridged.json index 153d42f5f..4e646d37f 100644 --- a/tests/configs/wslc_state_aware_provision_bridged.json +++ b/tests/configs/wslc_state_aware_provision_bridged.json @@ -1,15 +1,13 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "provision", "containment": "wslc", "network": { "defaultPolicy": "allow" }, - "experimental": { - "wslc": { - "provision": { - "image": "alpine:latest" - } + "wslc": { + "provision": { + "image": "alpine:latest" } } } diff --git a/tests/configs/wslc_state_aware_provision_rejected_denied.json b/tests/configs/wslc_state_aware_provision_rejected_denied.json index 6bf202467..a14ab0fec 100644 --- a/tests/configs/wslc_state_aware_provision_rejected_denied.json +++ b/tests/configs/wslc_state_aware_provision_rejected_denied.json @@ -1,19 +1,21 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "provision", "containment": "wslc", "filesystem": { - "readwritePaths": ["C:\\mxc_wslc_sa_test\\rw"], - "deniedPaths": ["C:\\mxc_wslc_sa_test\\rw\\secret"] + "readwritePaths": [ + "C:\\mxc_wslc_sa_test\\rw" + ], + "deniedPaths": [ + "C:\\mxc_wslc_sa_test\\rw\\secret" + ] }, "network": { "defaultPolicy": "block" }, - "experimental": { - "wslc": { - "provision": { - "image": "alpine:latest" - } + "wslc": { + "provision": { + "image": "alpine:latest" } } } diff --git a/tests/configs/wslc_state_aware_provision_rejected_hosts.json b/tests/configs/wslc_state_aware_provision_rejected_hosts.json index 6528a29e3..9ea7e330e 100644 --- a/tests/configs/wslc_state_aware_provision_rejected_hosts.json +++ b/tests/configs/wslc_state_aware_provision_rejected_hosts.json @@ -1,16 +1,16 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "provision", "containment": "wslc", "network": { "defaultPolicy": "allow", - "allowedHosts": ["example.com"] + "allowedHosts": [ + "example.com" + ] }, - "experimental": { - "wslc": { - "provision": { - "image": "alpine:latest" - } + "wslc": { + "provision": { + "image": "alpine:latest" } } } diff --git a/tests/configs/wslc_state_aware_provision_rejected_proxy.json b/tests/configs/wslc_state_aware_provision_rejected_proxy.json index f1b1a37ab..10b8d3e10 100644 --- a/tests/configs/wslc_state_aware_provision_rejected_proxy.json +++ b/tests/configs/wslc_state_aware_provision_rejected_proxy.json @@ -1,16 +1,16 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "provision", "containment": "wslc", "network": { "defaultPolicy": "allow", - "proxy": { "url": "http://127.0.0.1:8888" } + "proxy": { + "url": "http://127.0.0.1:8888" + } }, - "experimental": { - "wslc": { - "provision": { - "image": "alpine:latest" - } + "wslc": { + "provision": { + "image": "alpine:latest" } } } diff --git a/tests/configs/wslc_state_aware_provision_with_filesystem.json b/tests/configs/wslc_state_aware_provision_with_filesystem.json index df96e05c3..ff82ececf 100644 --- a/tests/configs/wslc_state_aware_provision_with_filesystem.json +++ b/tests/configs/wslc_state_aware_provision_with_filesystem.json @@ -1,19 +1,21 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "provision", "containment": "wslc", "filesystem": { - "readwritePaths": ["C:\\mxc_wslc_sa_test\\rw"], - "readonlyPaths": ["C:\\mxc_wslc_sa_test\\ro"] + "readwritePaths": [ + "C:\\mxc_wslc_sa_test\\rw" + ], + "readonlyPaths": [ + "C:\\mxc_wslc_sa_test\\ro" + ] }, "network": { "defaultPolicy": "block" }, - "experimental": { - "wslc": { - "provision": { - "image": "alpine:latest" - } + "wslc": { + "provision": { + "image": "alpine:latest" } } } diff --git a/tests/configs/wslc_state_aware_start.json b/tests/configs/wslc_state_aware_start.json index af95e58b4..47247f302 100644 --- a/tests/configs/wslc_state_aware_start.json +++ b/tests/configs/wslc_state_aware_start.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "start", "sandboxId": "{{SANDBOX_ID}}" } diff --git a/tests/configs/wslc_state_aware_stop.json b/tests/configs/wslc_state_aware_stop.json index 88703e15b..abef58870 100644 --- a/tests/configs/wslc_state_aware_stop.json +++ b/tests/configs/wslc_state_aware_stop.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "stop", "sandboxId": "{{SANDBOX_ID}}" } diff --git a/tests/configs/wslc_stderr.json b/tests/configs/wslc_stderr.json index 636679074..33f61c57f 100644 --- a/tests/configs/wslc_stderr.json +++ b/tests/configs/wslc_stderr.json @@ -1,5 +1,5 @@ { - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-stderr", "containment": "wslc", "process": { @@ -8,9 +8,7 @@ "network": { "defaultPolicy": "block" }, - "experimental": { - "wslc": { - "image": "alpine:latest" - } + "wslc": { + "image": "alpine:latest" } } diff --git a/tests/configs/wslc_tar_import_docker_save.json b/tests/configs/wslc_tar_import_docker_save.json index 263ea46a6..c7359f5eb 100644 --- a/tests/configs/wslc_tar_import_docker_save.json +++ b/tests/configs/wslc_tar_import_docker_save.json @@ -1,5 +1,5 @@ { - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-tar-import-docker-save", "containment": "wslc", "process": { @@ -8,10 +8,8 @@ "network": { "defaultPolicy": "block" }, - "experimental": { - "wslc": { - "image": "alpine:latest", - "imageTarPath": "C:\\workspace\\alpine-docker-save.tar" - } + "wslc": { + "image": "alpine:latest", + "imageTarPath": "C:\\workspace\\alpine-docker-save.tar" } } diff --git a/tests/configs/wslc_tar_import_rootfs.json b/tests/configs/wslc_tar_import_rootfs.json index 4fcbaf28e..ba8ae7e8b 100644 --- a/tests/configs/wslc_tar_import_rootfs.json +++ b/tests/configs/wslc_tar_import_rootfs.json @@ -1,5 +1,5 @@ { - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-tar-import", "containment": "wslc", "process": { @@ -8,10 +8,8 @@ "network": { "defaultPolicy": "block" }, - "experimental": { - "wslc": { - "image": "alpine-export:latest", - "imageTarPath": "C:\\workspace\\alpine.tar" - } + "wslc": { + "image": "alpine-export:latest", + "imageTarPath": "C:\\workspace\\alpine.tar" } } diff --git a/tests/configs/wslc_timeout.json b/tests/configs/wslc_timeout.json index d49ddb489..29ceb9748 100644 --- a/tests/configs/wslc_timeout.json +++ b/tests/configs/wslc_timeout.json @@ -1,5 +1,5 @@ { - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-timeout-test", "containment": "wslc", "process": { @@ -9,9 +9,7 @@ "network": { "defaultPolicy": "block" }, - "experimental": { - "wslc": { - "image": "alpine:latest" - } + "wslc": { + "image": "alpine:latest" } } diff --git a/tests/examples/wslc_hello_world.json b/tests/examples/wslc_hello_world.json index d79fc4efb..f5e4545eb 100644 --- a/tests/examples/wslc_hello_world.json +++ b/tests/examples/wslc_hello_world.json @@ -1,5 +1,5 @@ { - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-hello-world", "containment": "wslc", "process": { @@ -8,9 +8,7 @@ "network": { "defaultPolicy": "block" }, - "experimental": { - "wslc": { - "image": "alpine:latest" - } + "wslc": { + "image": "alpine:latest" } } diff --git a/tests/policy/request-wslc.json b/tests/policy/request-wslc.json index 35ace35ec..ebe174c34 100644 --- a/tests/policy/request-wslc.json +++ b/tests/policy/request-wslc.json @@ -1,6 +1,6 @@ { "policy": { - "version": "0.8.0-alpha" + "version": "0.9.0-alpha" }, "command": "printf parity", "containment": { diff --git a/tests/policy/state-aware-wslc-exec.json b/tests/policy/state-aware-wslc-exec.json index 8b46aa0ba..64375605c 100644 --- a/tests/policy/state-aware-wslc-exec.json +++ b/tests/policy/state-aware-wslc-exec.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "exec", "sandboxId": "wslc:0123456789abcdef0123456789abcdef", "process": { diff --git a/tests/policy/state-aware-wslc-provision.json b/tests/policy/state-aware-wslc-provision.json index 6cef06dc3..45dd17dbb 100644 --- a/tests/policy/state-aware-wslc-provision.json +++ b/tests/policy/state-aware-wslc-provision.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "provision", "containment": "wslc", "filesystem": { @@ -10,12 +10,10 @@ "network": { "defaultPolicy": "allow" }, - "experimental": { - "wslc": { - "provision": { - "image": "alpine:3.20", - "imageTarPath": "C:\\images\\alpine.tar" - } + "wslc": { + "provision": { + "image": "alpine:3.20", + "imageTarPath": "C:\\images\\alpine.tar" } } } diff --git a/tests/scripts/run_wslc_all_tests.ps1 b/tests/scripts/run_wslc_all_tests.ps1 index 586ec6200..ca0134306 100644 --- a/tests/scripts/run_wslc_all_tests.ps1 +++ b/tests/scripts/run_wslc_all_tests.ps1 @@ -116,7 +116,7 @@ function Run-WslcTest { # Skip if the config references a tar file that doesn't exist locally $configJson = Get-Content $configPath -Raw | ConvertFrom-Json - $tarPath = $configJson.experimental.wslc.imageTarPath + $tarPath = $configJson.wslc.imageTarPath if ($tarPath -and -not (Test-Path $tarPath)) { Write-Host " $ConfigFile ... " -NoNewline Write-Host "SKIP (tar not found: $tarPath)" -ForegroundColor Yellow @@ -127,7 +127,7 @@ function Run-WslcTest { $prevPref = $ErrorActionPreference $ErrorActionPreference = "Continue" - $wxcArgs = @("--experimental") + $wxcArgs = @() if ($Debug) { $wxcArgs += "--debug" } diff --git a/tests/scripts/run_wslc_denied_masking_test.ps1 b/tests/scripts/run_wslc_denied_masking_test.ps1 index 8ff54ef74..929b1d15e 100644 --- a/tests/scripts/run_wslc_denied_masking_test.ps1 +++ b/tests/scripts/run_wslc_denied_masking_test.ps1 @@ -68,7 +68,7 @@ try { Set-Content (Join-Path $SecretDir "inner.txt") "DIR_SECRET" Write-Host "Running WSLC denied-path masking test (denied sibling file + dir left unmounted)..." - $wxcArgs = @("--experimental") + $wxcArgs = @() if ($Debug) { $wxcArgs += "--debug" } $wxcArgs += $ConfigPath diff --git a/tests/scripts/run_wslc_dotdot_alias_test.ps1 b/tests/scripts/run_wslc_dotdot_alias_test.ps1 index de3aa3a0e..114713f23 100644 --- a/tests/scripts/run_wslc_dotdot_alias_test.ps1 +++ b/tests/scripts/run_wslc_dotdot_alias_test.ps1 @@ -71,7 +71,7 @@ try { } Write-Host "Running WSLC denied `..`-through-junction test (expect pre-flight rejection)..." - $wxcArgs = @("--experimental") + $wxcArgs = @() if ($Debug) { $wxcArgs += "--debug" } $wxcArgs += $ConfigPath diff --git a/tests/scripts/run_wslc_most_specific_test.ps1 b/tests/scripts/run_wslc_most_specific_test.ps1 index 9fec04434..596488ed0 100644 --- a/tests/scripts/run_wslc_most_specific_test.ps1 +++ b/tests/scripts/run_wslc_most_specific_test.ps1 @@ -62,7 +62,7 @@ try { Set-Content (Join-Path $DataDir "sibling.txt") "PARENT_SECRET" Write-Host "Running WSLC most-specific test (denied parent, rw child)..." - $wxcArgs = @("--experimental") + $wxcArgs = @() if ($Debug) { $wxcArgs += "--debug" } $wxcArgs += $ConfigPath diff --git a/tests/scripts/run_wslc_object_test.ps1 b/tests/scripts/run_wslc_object_test.ps1 index b6b909acd..006b10fc2 100644 --- a/tests/scripts/run_wslc_object_test.ps1 +++ b/tests/scripts/run_wslc_object_test.ps1 @@ -67,7 +67,7 @@ try { } Write-Host "Running WSLC object-validation test (RW + denied junction alias, expect masked)..." - $wxcArgs = @("--experimental") + $wxcArgs = @() if ($Debug) { $wxcArgs += "--debug" } $wxcArgs += $ConfigPath diff --git a/tests/scripts/run_wslc_proxy_test.ps1 b/tests/scripts/run_wslc_proxy_test.ps1 index 9da6300c0..9d1b7f2f0 100644 --- a/tests/scripts/run_wslc_proxy_test.ps1 +++ b/tests/scripts/run_wslc_proxy_test.ps1 @@ -57,7 +57,7 @@ if (-not $WxcExec -or -not (Test-Path $WxcExec)) { Write-Host "Running WSLC cooperative proxy functional test..." Write-Host "Binary: $WxcExec" -ForegroundColor Gray -$wxcArgs = @("--experimental") +$wxcArgs = @() if ($Debug) { $wxcArgs += "--debug" } $wxcArgs += $ConfigPath diff --git a/tests/scripts/run_wslc_state_aware_tests.ps1 b/tests/scripts/run_wslc_state_aware_tests.ps1 index 2f2faa034..653be877b 100644 --- a/tests/scripts/run_wslc_state_aware_tests.ps1 +++ b/tests/scripts/run_wslc_state_aware_tests.ps1 @@ -159,7 +159,7 @@ function Invoke-StateAware { $b64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($json)) - $argList = @('--experimental') + $argList = @() if ($Debug) { $argList += '--debug' } $argList += @('--config-base64', $b64) @@ -228,7 +228,7 @@ function Invoke-StateAwareStreaming { } $b64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($json)) - $argList = @('--experimental') + $argList = @() if ($Debug) { $argList += '--debug' } $argList += @('--config-base64', $b64)