From 6d1766461d932d3a34e43cc4f17d330acc92c0e3 Mon Sep 17 00:00:00 2001 From: Soham Das Date: Thu, 27 Aug 2026 13:04:39 -0700 Subject: [PATCH 1/6] Reject WSLc policy fields the backend cannot honor WSLc accepted four policy fields, carried them into the runner, and then never read them. A caller asking for a UI lockdown, a firewall enforcement mode, inbound local-network access, or policy preservation got a container that silently did not have the posture they asked for. Close each hole with an explicit policy_validation rejection. Every rejection aborts before anything is created. All three entry surfaces validate first: ScriptRunner::run ahead of execute, SandboxBackend::spawn ahead of start_container, and the state-aware dispatcher ahead of each phase body -- and connect_daemon() lives inside provision(), so a refused provision never even spawns the daemon. ui rejected on every phase, both surfaces network.allowLocalNetwork rejected at state-aware provision (one-shot already rejected it) network.enforcementMode firewall/both rejected; capabilities accepted lifecycle.preservePolicy rejected on one-shot (state-aware already rejects the whole lifecycle section at parse) ui is rejected by presence, not value. UiPolicy::default() is full lockdown, so an explicitly supplied lockdown ui is indistinguishable by value from an absent one -- a value-based check would let the single most restrictive request a caller can write through unenforced. This uses the parse-derived ContainerPolicy::ui_specified flag, mirroring IsolationSession. enforcementMode and preservePolicy are rejected by value instead, because their defaults honestly describe WSLc's behavior: an all-or-nothing container network with nothing per-host to enforce, and auto-remove teardown. Refusing those for mere presence would be dishonest. destroyOnExit stays honored -- it selects WSLC_CONTAINER_FLAG_AUTO_REMOVE -- so only preservePolicy is refused. A blanket lifecycle rejection would have broken the wslc_destroy_on_exit_{true,false} configs; a test pins both values still passing. The two allowLocalNetwork messages differ deliberately. One-shot points callers at experimental.wslc portMappings, but WslcProvisionPhase has no portMappings field at all, so repeating that advice on the state-aware surface would be a lie. Rejection ordering is filesystem -> ui -> network, documented in the policy.rs module header and pinned by precedence tests. No wire, schema, or parser-gating changes: this is a domain-model behavior change only, so it lands while WSLc is still experimental and the nightly WSLc suite exercises the new rejections before the surface moves. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c23ddc86-3848-452e-8355-e11d2ffa9b7f --- docs/wsl/wsl-container-getting-started.md | 33 ++ docs/wsl/wslc-state-aware.md | 16 + src/backends/wslc/common/src/policy.rs | 290 +++++++++++++++++- src/backends/wslc/common/src/sandbox.rs | 35 +++ src/backends/wslc/common/src/state_aware.rs | 81 +++++ .../wslc/common/src/wsl_container_runner.rs | 216 +++++++++++++ src/core/wxc_common/src/models.rs | 13 +- 7 files changed, 669 insertions(+), 15 deletions(-) diff --git a/docs/wsl/wsl-container-getting-started.md b/docs/wsl/wsl-container-getting-started.md index ddddee33a..ba5848fea 100644 --- a/docs/wsl/wsl-container-getting-started.md +++ b/docs/wsl/wsl-container-getting-started.md @@ -372,6 +372,15 @@ filtering at the proxy layer, or remove the host lists. The bare `defaultPolicy` forms with **no** host lists remain supported: `"block"` is a full network cutoff and `"allow"` is full outbound (NAT). +### `enforcementMode` must be `capabilities` + +For the same reason, `network.enforcementMode: "firewall"` (or `"both"`) is +**rejected**: both ask for per-rule firewall enforcement inside a container that +has no `CAP_NET_ADMIN` to apply it with. The default `"capabilities"` is +accepted — it is an honest description of WSLC's all-or-nothing network, so an +explicitly supplied `"capabilities"` is accepted rather than refused merely for +being present. + ### Inbound: `allowLocalNetwork` is not supported `network.allowLocalNetwork: true` (a blanket grant to bind/listen and accept @@ -401,6 +410,30 @@ Paths in `filesystem.readwritePaths` and `filesystem.readonlyPaths` are mounted into the container. Host path `C:\workspace` becomes `/mnt/c/workspace` inside the container. +### `ui` is not supported + +A `ui` section is **rejected**. The section maps to Windows job-object UI limits +(`JOB_OBJECT_UILIMIT_*`); a WSLC container runs Linux inside the WSL2 VM, so +those limits have no analogue and nothing in the backend could apply them. + +The check is on **presence, not value**. `ui`'s defaults are full lockdown, so +an explicitly supplied lockdown `ui` is indistinguishable *by value* from an +absent one — a value-based check would let the single most restrictive request +you can write through unenforced. Omit the section entirely. + +### `lifecycle`: `destroyOnExit` is honored, `preservePolicy` is not + +`lifecycle.destroyOnExit` **is** honored: it selects the SDK's +`WSLC_CONTAINER_FLAG_AUTO_REMOVE`, so both `true` (the default) and `false` +behave as documented. + +`lifecycle.preservePolicy: true` is **rejected** — WSLC has no +policy-persistence primitive, so there is nothing for the flag to select. + +Note the state-aware surface differs: it rejects the whole `lifecycle` section +at parse time, because a multi-invocation sandbox's lifetime is driven by the +explicit `provision` / `deprovision` phases rather than by per-run flags. + ## Troubleshooting | Error | Cause | Fix | diff --git a/docs/wsl/wslc-state-aware.md b/docs/wsl/wslc-state-aware.md index b1010a837..c427b28d9 100644 --- a/docs/wsl/wslc-state-aware.md +++ b/docs/wsl/wslc-state-aware.md @@ -85,7 +85,23 @@ rules do not apply — see the empirical finding in the plan history). Proxy is | `deniedPaths` | rejected if overlapping/nested under a mount (a standalone denied path is accepted); no Deny primitive | rejected | rejected | | `network.defaultPolicy` | honored: `Block` → `None`, `Allow` → `Bridged` | rejected (any explicit network-mode field) | rejected (any explicit network-mode field) | | `network` host filtering (`allowedHosts` / `blockedHosts`) | rejected | rejected | rejected | +| `network.allowLocalNetwork` | rejected if `true` — WSLc networking is all-or-nothing, and the state-aware surface has no port-mapping escape hatch | rejected (any explicit network-mode field) | rejected (any explicit network-mode field) | +| `network.enforcementMode` | rejected unless `capabilities` — `firewall` / `both` ask for per-rule enforcement the container cannot perform | rejected (any explicit network-mode field) | rejected (any explicit network-mode field) | | `network.proxy` | rejected | rejected | honored — **`url` form only** (`localhost` / `builtinTestServer` forms → `policy_validation`); injected as `HTTP_PROXY` / `HTTPS_PROXY` env vars | +| `ui` | rejected | rejected | rejected | +| `process.timeout` | n/a | n/a | honored → `ExecConfig.timeout_ms` | +| `lifecycle` | rejected (whole section, at parse) | rejected | rejected | + +`ui` is rejected by **presence, not value**, on every phase. A WSLc container runs Linux, so the +section's Windows job-object UI limits (`JOB_OBJECT_UILIMIT_*`) have no analogue inside it and no +phase could honor it. Presence is the only workable test because `UiPolicy`'s defaults are full +lockdown — an explicitly supplied lockdown `ui` is indistinguishable *by value* from an absent one, +so a value-based check would let the single most restrictive request a caller can write through +unenforced. The parse-derived `ContainerPolicy::ui_specified` flag is what closes that gap. + +Every rejection above **aborts the phase before anything is created**: the dispatcher runs each +`validate_*` hook ahead of the phase body, and `connect_daemon()` lives inside `provision()`, so a +refused provision never even spawns the daemon — let alone a VM or container. Filesystem policy is fixed at `provision` and immutable afterwards. diff --git a/src/backends/wslc/common/src/policy.rs b/src/backends/wslc/common/src/policy.rs index f807693bc..499e327fb 100644 --- a/src/backends/wslc/common/src/policy.rs +++ b/src/backends/wslc/common/src/policy.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Policy validation for the state-aware WSLc backend. +//! Policy validation for the WSLc backend. //! //! WSLc honours a richer policy surface than IsolationSession, and each field //! is bound to the phase where the daemon can actually apply it. Anything the @@ -12,7 +12,10 @@ //! |-----------------------------|------------------------------|----------------------------|----------------------------| //! | `readwrite` / `readonly` | honoured (volume mounts) | rejected (immutable) | rejected (immutable) | //! | `denied_paths` | rejected if overlapping [^1] | rejected | rejected | +//! | `ui` | rejected (no UI primitive) | rejected | rejected | //! | `allowed` / `blocked` hosts | rejected (no host filtering) | rejected | rejected | +//! | `allow_local_network` | rejected if `true` | rejected | rejected | +//! | `network_enforcement_mode` | rejected if not `capabilities` | rejected | rejected | //! | `default_network_policy` | honoured (None / Bridged) | rejected if non-default | rejected if non-default | //! | `network.proxy` | rejected (applies at exec) | rejected | honoured (cooperative env) | //! @@ -20,8 +23,24 @@ //! host paths are simply never mounted); only a denied path nested under a //! mounted `rw`/`ro` parent is rejected, because WSLc has no overlay primitive //! to mask a subtree of a mounted volume. - -use wxc_common::models::ExecutionRequest; +//! +//! # Rejection ordering +//! +//! Checks run filesystem → ui → network so a request that trips several gets a +//! stable, most-structural-first message rather than one that depends on field +//! order. The precedence is asserted by tests, not just documented. +//! +//! # Shared with the one-shot surface +//! +//! [`reject_ui_policy`] and [`reject_unsupported_enforcement_mode`] describe the +//! backend itself, not a phase, so `WSLContainerRunner::validate_runner` (which +//! serves both the run-to-completion `ScriptRunner` and the streaming +//! `SandboxBackend`) calls them too, retagging the message as a +//! [`WslcError::Rejected`](crate::error::WslcError::Rejected). Every one of +//! those call sites runs *before* any container is created, so a rejection +//! always aborts rather than leaving a live container behind. + +use wxc_common::models::{ExecutionRequest, NetworkEnforcementMode}; use wxc_common::mxc_error::MxcError; use crate::policy_mapping::validate_denied_path_overlap; @@ -42,11 +61,28 @@ const ERR_PROXY_URL_FORM: &str = "WSLc: network.proxy requires the 'url' form (a routable proxy URL); the localhost and \ builtinTestServer forms are not supported because a WSLc container runs in its own network \ namespace"; +const ERR_UI_POLICY: &str = + "WSLc: the ui section is not supported. A WSLc container runs Linux, while `ui` maps to \ + Windows job-object UI restrictions (JOB_OBJECT_UILIMIT_*) that have no analogue inside it, \ + so no ui posture is truthful here. Omitting the ui section is accepted but applies no \ + restriction — it is not the lockdown the schema's default implies. Use a backend that \ + enforces UI policy if you need one"; +const ERR_ALLOW_LOCAL_NETWORK_STATE_AWARE: &str = + "WSLc: network.allowLocalNetwork=true is not supported by the state-aware WSLc backend. The \ + container's network is all-or-nothing (defaultPolicy 'block' → isolated, 'allow' → bridged \ + NAT), and the state-aware provision phase has no port-mapping primitive to expose an \ + inbound port"; +const ERR_ENFORCEMENT_MODE: &str = + "WSLc: network.enforcementMode 'firewall' and 'both' are not supported. A WSLc container has \ + no CAP_NET_ADMIN for in-container firewall rules, and VM-level enforcement is not available \ + without breaking other security guarantees (e.g. MDE). Remove the field or set it to \ + 'capabilities' — WSLc's network is all-or-nothing at the container level"; /// Validate the request for the provision phase. `rw` / `ro` paths become /// volume mounts and `default_network_policy` selects the container network -/// mode; both are honoured here. Overlapping denied paths, host filtering, and -/// a provision-phase proxy are rejected. +/// mode; both are honoured here. Overlapping denied paths, a UI policy, host +/// filtering, inbound local networking, a non-default enforcement mode, and a +/// provision-phase proxy are rejected. pub(crate) fn validate_provision_policy(request: &ExecutionRequest) -> Result<(), MxcError> { validate_denied_path_overlap( &request.policy.readwrite_paths, @@ -54,7 +90,10 @@ pub(crate) fn validate_provision_policy(request: &ExecutionRequest) -> Result<() &request.policy.denied_paths, ) .map_err(MxcError::policy_validation)?; + reject_ui_policy(request)?; reject_host_filtering(request)?; + reject_provision_allow_local_network(request)?; + reject_unsupported_enforcement_mode(request)?; if request.policy.network_proxy.is_enabled() { return Err(MxcError::policy_validation(ERR_PROXY_AT_PROVISION)); } @@ -63,9 +102,10 @@ pub(crate) fn validate_provision_policy(request: &ExecutionRequest) -> Result<() /// Validate the request for start / stop / deprovision. These phases carry no /// applicable policy: filesystem and network mode are fixed at provision and -/// the proxy is an exec-time concern. +/// the proxy is an exec-time concern. A UI policy is never supported. pub(crate) fn validate_post_provision_policy(request: &ExecutionRequest) -> Result<(), MxcError> { reject_filesystem_policy(request)?; + reject_ui_policy(request)?; reject_host_filtering(request)?; reject_post_provision_network_mode(request)?; if request.policy.network_proxy.is_enabled() { @@ -75,10 +115,12 @@ pub(crate) fn validate_post_provision_policy(request: &ExecutionRequest) -> Resu } /// Validate the request for the exec phase. Filesystem and network mode are -/// fixed at provision (rejected here); the cooperative proxy is honoured and -/// must be in `url` form so a routable value reaches the container. +/// fixed at provision (rejected here), a UI policy is never supported, and the +/// cooperative proxy is honoured and must be in `url` form so a routable value +/// reaches the container. pub(crate) fn validate_exec_policy(request: &ExecutionRequest) -> Result<(), MxcError> { reject_filesystem_policy(request)?; + reject_ui_policy(request)?; reject_host_filtering(request)?; reject_post_provision_network_mode(request)?; if request.policy.network_proxy.is_enabled() && exec_proxy_url(request).is_none() { @@ -119,6 +161,61 @@ fn reject_host_filtering(request: &ExecutionRequest) -> Result<(), MxcError> { Ok(()) } +/// Reject any supplied UI policy. Presence-based, not value-based: the domain +/// `UiPolicy::default()` is full lockdown, so an explicitly-supplied lockdown +/// `ui` is indistinguishable from an absent one by value — the same blind spot +/// `network_specified` closes for the network policy. +/// +/// Shared by every WSLc phase and by the one-shot / streaming +/// `validate_runner`: the reason is the container's OS, not the lifecycle +/// phase, so there is no phase on either surface where a `ui` section could be +/// honoured. Runs after the filesystem check so a filesystem rejection keeps +/// precedence, and before the network checks. +pub(crate) fn reject_ui_policy(request: &ExecutionRequest) -> Result<(), MxcError> { + if request.policy.ui_specified { + return Err(MxcError::policy_validation(ERR_UI_POLICY)); + } + Ok(()) +} + +/// Reject an enforcement mode WSLc cannot implement. +/// +/// Value-based, unlike [`reject_ui_policy`]: the default `capabilities` is an +/// honest description of what WSLc does (the container's network is +/// all-or-nothing, with nothing per-host to enforce), so an explicit +/// `capabilities` is accepted. `firewall` and `both` ask for per-rule +/// enforcement the container cannot perform — it has no `CAP_NET_ADMIN` — so +/// accepting either would assert a guarantee that does not exist. +/// +/// Shared by every WSLc phase and by the one-shot / streaming +/// `validate_runner`, for the same reason as the UI check: it is a property of +/// the backend, not of a phase. +pub(crate) fn reject_unsupported_enforcement_mode( + request: &ExecutionRequest, +) -> Result<(), MxcError> { + match request.policy.network_enforcement_mode { + NetworkEnforcementMode::Capabilities => Ok(()), + NetworkEnforcementMode::Firewall | NetworkEnforcementMode::Both => { + Err(MxcError::policy_validation(ERR_ENFORCEMENT_MODE)) + } + } +} + +/// Reject inbound local networking at provision. The one-shot surface refuses +/// the same value but points at `experimental.wslc.portMappings`; the +/// state-aware provision phase has no such field, so its message must not offer +/// that escape hatch. Post-provision phases reject it by presence via +/// [`reject_post_provision_network_mode`] instead, since the posture is fixed +/// once the container exists. +fn reject_provision_allow_local_network(request: &ExecutionRequest) -> Result<(), MxcError> { + if request.policy.allow_local_network { + return Err(MxcError::policy_validation( + ERR_ALLOW_LOCAL_NETWORK_STATE_AWARE, + )); + } + Ok(()) +} + /// Reject any network *mode* field supplied after provision. The network /// posture (`defaultPolicy` / `enforcementMode` / `allowLocalNetwork` / host /// lists) is bound to the provision phase; presence — not value — is checked so @@ -138,7 +235,7 @@ fn reject_post_provision_network_mode(request: &ExecutionRequest) -> Result<(), #[cfg(test)] mod tests { use super::*; - use wxc_common::models::{ContainerPolicy, NetworkPolicy, ProxyAddress, ProxyConfig}; + use wxc_common::models::{ContainerPolicy, NetworkPolicy, ProxyAddress, ProxyConfig, UiPolicy}; use wxc_common::mxc_error::MxcErrorCode; fn request_with_policy(policy: ContainerPolicy) -> ExecutionRequest { @@ -330,4 +427,179 @@ mod tests { validate_exec_policy(&ExecutionRequest::default()).unwrap(); assert!(exec_proxy_url(&ExecutionRequest::default()).is_none()); } + + // ---- ui (rejected on every phase) ---- + // + // A WSLc container runs Linux; `ui` maps to Windows job-object UI limits + // that have no analogue inside it. There is no phase where it could be + // honoured, so every validator refuses it. + + #[test] + fn every_phase_rejects_supplied_ui() { + let req = request_with_policy(ContainerPolicy { + ui_specified: true, + ..Default::default() + }); + for (phase, result) in [ + ("provision", validate_provision_policy(&req)), + ("post_provision", validate_post_provision_policy(&req)), + ("exec", validate_exec_policy(&req)), + ] { + let err = result.expect_err(&format!("{phase} must reject a supplied ui")); + assert_policy_validation(err, "ui section is not supported"); + } + } + + /// Presence, not value. `UiPolicy::default()` is full lockdown, so an + /// explicitly-supplied lockdown `ui` is byte-identical to an absent one by + /// value — only `ui_specified` can tell them apart. Were this check + /// value-based, the most restrictive request a caller can write would be + /// the one that slipped through unenforced. + #[test] + fn provision_rejects_lockdown_equivalent_ui() { + let req = request_with_policy(ContainerPolicy { + ui: UiPolicy::default(), + ui_specified: true, + ..Default::default() + }); + assert_policy_validation( + validate_provision_policy(&req).unwrap_err(), + "ui section is not supported", + ); + } + + #[test] + fn absent_ui_is_accepted_on_every_phase() { + let req = ExecutionRequest::default(); + assert!(!req.policy.ui_specified); + validate_provision_policy(&req).unwrap(); + validate_post_provision_policy(&req).unwrap(); + validate_exec_policy(&req).unwrap(); + } + + // ---- allowLocalNetwork ---- + + #[test] + fn provision_rejects_allow_local_network() { + let req = request_with_policy(ContainerPolicy { + allow_local_network: true, + ..Default::default() + }); + assert_policy_validation( + validate_provision_policy(&req).unwrap_err(), + "allowLocalNetwork", + ); + } + + /// Post-provision needs no dedicated `allowLocalNetwork` check: supplying + /// the field sets `network_mode_specified`, which the immutability check + /// already refuses. Pinned so the two rejections can't both be removed as + /// "redundant". + #[test] + fn post_provision_rejects_allow_local_network_as_a_mode_change() { + let req = request_with_policy(ContainerPolicy { + allow_local_network: true, + network_mode_specified: true, + ..Default::default() + }); + assert_policy_validation( + validate_post_provision_policy(&req).unwrap_err(), + "network mode", + ); + assert_policy_validation(validate_exec_policy(&req).unwrap_err(), "network mode"); + } + + // ---- enforcementMode ---- + + #[test] + fn provision_rejects_firewall_and_both_enforcement_modes() { + for mode in [ + NetworkEnforcementMode::Firewall, + NetworkEnforcementMode::Both, + ] { + let req = request_with_policy(ContainerPolicy { + network_enforcement_mode: mode.clone(), + ..Default::default() + }); + assert_policy_validation( + validate_provision_policy(&req).expect_err(&format!("{mode:?} must be rejected")), + "enforcementMode", + ); + } + } + + /// Value-based, unlike `ui`: `capabilities` is an honest description of + /// what WSLc does (an all-or-nothing container network with nothing + /// per-host to enforce), so an explicit `capabilities` is accepted rather + /// than refused for merely being present. + #[test] + fn provision_accepts_explicit_capabilities_enforcement_mode() { + let req = request_with_policy(ContainerPolicy { + network_enforcement_mode: NetworkEnforcementMode::Capabilities, + ..Default::default() + }); + validate_provision_policy(&req).unwrap(); + } + + // ---- rejection ordering ---- + // + // A request that trips several checks must get a stable, most-structural- + // first message: filesystem -> ui -> network. Documented in the module + // header; pinned here so a reordering of the validator bodies is caught. + + #[test] + fn filesystem_error_takes_precedence_over_ui() { + let req = request_with_policy(ContainerPolicy { + readwrite_paths: vec!["C:\\src".to_string()], + ui_specified: true, + ..Default::default() + }); + assert_policy_validation( + validate_post_provision_policy(&req).unwrap_err(), + "provision phase", + ); + assert_policy_validation(validate_exec_policy(&req).unwrap_err(), "provision phase"); + } + + #[test] + fn ui_error_takes_precedence_over_network() { + let req = request_with_policy(ContainerPolicy { + ui_specified: true, + allowed_hosts: vec!["example.com".to_string()], + allow_local_network: true, + network_enforcement_mode: NetworkEnforcementMode::Firewall, + network_proxy: url_proxy(), + ..Default::default() + }); + for (phase, result) in [ + ("provision", validate_provision_policy(&req)), + ("post_provision", validate_post_provision_policy(&req)), + ("exec", validate_exec_policy(&req)), + ] { + let err = result.expect_err(&format!("{phase} must reject")); + assert_policy_validation(err, "ui section is not supported"); + } + } + + #[test] + fn every_new_rejection_maps_to_policy_validation() { + let cases = [ + ContainerPolicy { + ui_specified: true, + ..Default::default() + }, + ContainerPolicy { + allow_local_network: true, + ..Default::default() + }, + ContainerPolicy { + network_enforcement_mode: NetworkEnforcementMode::Firewall, + ..Default::default() + }, + ]; + for policy in cases { + let err = validate_provision_policy(&request_with_policy(policy)).unwrap_err(); + assert_eq!(err.code, MxcErrorCode::PolicyValidation); + } + } } diff --git a/src/backends/wslc/common/src/sandbox.rs b/src/backends/wslc/common/src/sandbox.rs index bc53af2d5..1dad13969 100644 --- a/src/backends/wslc/common/src/sandbox.rs +++ b/src/backends/wslc/common/src/sandbox.rs @@ -394,6 +394,41 @@ impl Drop for WslcSandboxProcess { mod tests { use super::*; + /// The rejections must abort the request, not tear a container down after + /// building one. `SandboxBackend::spawn` is the streaming entry point the + /// Rust SDK uses; it must refuse before `start_container` touches the WSLC + /// SDK. Asserting on a host with no WSLC SDK at all is what proves the + /// ordering: were the guard to run late, we would see an SDK-load failure + /// instead of the policy message. + #[test] + fn spawn_rejects_policy_before_touching_the_sdk() { + let request = ExecutionRequest { + containment: wxc_common::models::ContainmentBackend::Wslc, + script_code: "echo hi".to_string(), + policy: wxc_common::models::ContainerPolicy { + ui_specified: true, + ..Default::default() + }, + ..Default::default() + }; + let mut logger = Logger::new(Mode::Buffer); + let mut runner = WSLContainerRunner::new(&wxc_common::models::WslcConfig::default()); + + let err = runner + .spawn(&request, &mut logger, StdioMode::Pipes) + .err() + .expect("a rejected policy must not produce a live container"); + assert!( + err.error_message.contains("ui section is not supported"), + "the refusal must come from the policy gate, not from bring-up: {}", + err.error_message + ); + assert_eq!( + err.failure_phase, + wxc_common::models::FailurePhase::Rejected + ); + } + /// The exact contradiction the streaming contract forbids: a timed-out run /// is killed, so the container *has* an exit code to report — and reporting /// it would turn `wait`'s `Err(TimedOut)` into a later `Ok(Some(137))`. diff --git a/src/backends/wslc/common/src/state_aware.rs b/src/backends/wslc/common/src/state_aware.rs index 69c20cb14..53db49248 100644 --- a/src/backends/wslc/common/src/state_aware.rs +++ b/src/backends/wslc/common/src/state_aware.rs @@ -544,6 +544,87 @@ mod tests { assert!(runner.validate_deprovision(id, &request, None).is_err()); } + /// Every one of the five hooks must refuse a supplied `ui`. A WSLc + /// container runs Linux, so there is no phase where the section could be + /// honoured — and because the dispatcher calls these hooks *before* the + /// phase body, the refusal happens before the backend contacts the daemon. + /// + /// Enumerating all five (rather than testing the two shared validators) + /// is what catches a hook that forgets to call its validator at all. + #[test] + fn every_validate_hook_rejects_supplied_ui() { + let runner = WslcStateAwareRunner::new(); + let request = ExecutionRequest { + policy: ContainerPolicy { + ui_specified: true, + ..Default::default() + }, + ..Default::default() + }; + let id = "wslc:0123456789abcdef0123456789abcdef"; + + let results = [ + ("provision", runner.validate_provision(&request, None)), + ("start", runner.validate_start(id, &request, None)), + ("exec", runner.validate_exec(id, &request, None)), + ("stop", runner.validate_stop(id, &request, None)), + ( + "deprovision", + runner.validate_deprovision(id, &request, None), + ), + ]; + for (phase, result) in results { + let err = result.expect_err(&format!("{phase} must reject a supplied ui")); + assert_eq!( + err.code, + wxc_common::mxc_error::MxcErrorCode::PolicyValidation + ); + assert!( + err.message.contains("ui section is not supported"), + "{phase}: {}", + err.message + ); + } + } + + /// `allowLocalNetwork` and a `firewall` enforcement mode are refused at + /// provision, the only phase where the network posture is settable — so + /// neither can be silently dropped into the daemon's `ProvisionConfig`, + /// which carries only the binary [`NetworkMode`]. + #[test] + fn validate_provision_rejects_unimplementable_network_posture() { + let runner = WslcStateAwareRunner::new(); + for (policy, needle) in [ + ( + ContainerPolicy { + allow_local_network: true, + ..Default::default() + }, + "allowLocalNetwork", + ), + ( + ContainerPolicy { + network_enforcement_mode: wxc_common::models::NetworkEnforcementMode::Firewall, + ..Default::default() + }, + "enforcementMode", + ), + ] { + let request = ExecutionRequest { + policy, + ..Default::default() + }; + let err = runner + .validate_provision(&request, None) + .expect_err(&format!("provision must reject {needle}")); + assert_eq!( + err.code, + wxc_common::mxc_error::MxcErrorCode::PolicyValidation + ); + assert!(err.message.contains(needle), "got: {}", err.message); + } + } + #[test] fn map_network_maps_block_to_none() { let req = ExecutionRequest { diff --git a/src/backends/wslc/common/src/wsl_container_runner.rs b/src/backends/wslc/common/src/wsl_container_runner.rs index 9e5b484ca..d07cfce96 100644 --- a/src/backends/wslc/common/src/wsl_container_runner.rs +++ b/src/backends/wslc/common/src/wsl_container_runner.rs @@ -25,6 +25,7 @@ use std::sync::{Arc, Condvar, Mutex}; use wxc_common::logger::{Logger, Mode}; use wxc_common::models::{ExecutionRequest, NetworkPolicy, ScriptResponse, WslcConfig}; +use wxc_common::mxc_error::MxcError; use wxc_common::sandbox_process::StdioMode; use wxc_common::script_runner::ScriptRunner; use wxc_common::string_util::{to_wide, CoTaskMemPWSTR}; @@ -32,6 +33,7 @@ use wxc_common::validator::{validate_network_policy_support, NetworkPolicySuppor use crate::container_steps::sdk_error; use crate::error::WslcError; +use crate::policy; use crate::policy_mapping; use crate::stream_buffer::{stream_pair, StreamReader, StreamWriter}; use crate::wslc_bindings::*; @@ -655,7 +657,20 @@ impl ScriptRunner for WSLContainerRunner { /// Mirrors the config parser so requests reaching the engine directly /// (an already-built `ExecutionRequest`, bypassing the parser) fail here /// instead of late in `execute` on the broken in-container iptables path. + /// + /// This is the single validation hook for **both** one-shot surfaces: + /// `ScriptRunner::run` calls it before `execute`, and + /// `SandboxBackend::spawn` calls it (via `SandboxBackend::validate`) before + /// `start_container`. Every rejection here therefore aborts the request + /// with no container, session, or WSL VM created. + /// + /// Checks run lifecycle → ui → network, so a request that trips several + /// gets a stable, most-structural-first message. fn validate_runner(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { + reject_unsupported_lifecycle(request)?; + // Not phase-specific — a WSLc container runs Linux, so `ui` has no + // meaning on any surface. Shared with the state-aware phases. + policy::reject_ui_policy(request).map_err(as_rejection)?; if request.policy.needs_host_filtering() { return Err(WslcError::Rejected( "WSLc: per-host egress filtering (allowedHosts with \ @@ -676,6 +691,7 @@ impl ScriptRunner for WSLContainerRunner { ) .into_response()); } + policy::reject_unsupported_enforcement_mode(request).map_err(as_rejection)?; // The shared validator returns an untagged response; retag it so its // rejections reach SDK callers as `policy_validation` like the checks above. validate_network_policy_support(request, NetworkPolicySupport::LEGACY) @@ -688,6 +704,45 @@ impl ScriptRunner for WSLContainerRunner { } } +/// Retag a shared [`policy`] rejection as a WSLc one, so it reaches SDK callers +/// as `policy_validation` (`FailurePhase::Rejected`) with the same message the +/// state-aware surface emits. +fn as_rejection(err: MxcError) -> ScriptResponse { + WslcError::Rejected(err.message).into_response() +} + +/// Refuses the `lifecycle` settings the one-shot surface cannot honour. +/// +/// Value-based rather than presence-based (unlike `ui`), because the defaults +/// genuinely match the behaviour: +/// +/// * `destroyOnExit` **is** honoured — it selects +/// `WSLC_CONTAINER_FLAG_AUTO_REMOVE` on the container settings, so both +/// values are accepted. +/// * `preservePolicy: true` asks for filesystem and network policy to outlive +/// the run. WSLc installs no persistent host-side enforcement: `rw`/`ro` +/// paths become container volume mounts and the network posture is a +/// container networking mode, both of which are properties of the container +/// object itself and cannot be retained independently of it. There is +/// nothing to preserve, so the request is refused rather than silently +/// dropped. +/// +/// The state-aware surface needs no counterpart: the parser rejects the whole +/// one-shot `lifecycle` section on state-aware requests. +fn reject_unsupported_lifecycle(request: &ExecutionRequest) -> Result<(), ScriptResponse> { + if request.lifecycle.preserve_policy { + return Err(WslcError::Rejected( + "WSLc: lifecycle.preservePolicy=true is not supported. WSLc installs no persistent \ + host-side filesystem or network enforcement — mounts and the container networking \ + mode belong to the container itself — so there is no policy to preserve past the \ + run." + .to_string(), + ) + .into_response()); + } + Ok(()) +} + impl WSLContainerRunner { /// Initialize COM and load the WSLC SDK at runtime. /// @@ -2431,6 +2486,167 @@ mod tests { } } + // -- Accept-but-ignore closures -------------------------------------- + // + // Each of these fields used to be parsed, carried into the runner, and + // then never read — so a caller got a container that silently did not + // have the posture they asked for. They are now refused, and refused + // from `validate_runner`, which both `ScriptRunner::run` and + // `SandboxBackend::spawn` call *before* any container exists. + + /// A WSLc container runs Linux; `ui` maps to Windows job-object UI limits + /// (`JOB_OBJECT_UILIMIT_*`) with no analogue inside it. + /// + /// Presence-based: `UiPolicy::default()` is full lockdown, so an + /// explicitly-supplied lockdown `ui` is indistinguishable by value from an + /// absent one. A value-based check would let the single most restrictive + /// request a caller can write through unenforced. + #[test] + fn validate_runner_rejects_supplied_ui() { + let request = ExecutionRequest { + containment: wxc_common::models::ContainmentBackend::Wslc, + policy: wxc_common::models::ContainerPolicy { + ui: wxc_common::models::UiPolicy::default(), + ui_specified: true, + ..Default::default() + }, + ..Default::default() + }; + let runner = WSLContainerRunner::new(&WslcConfig::default()); + let err = runner.validate_runner(&request).unwrap_err(); + assert!( + err.error_message.contains("ui section is not supported"), + "got: {}", + err.error_message + ); + assert_eq!( + err.failure_phase, + wxc_common::models::FailurePhase::Rejected + ); + } + + #[test] + fn validate_runner_accepts_absent_ui() { + let request = ExecutionRequest { + containment: wxc_common::models::ContainmentBackend::Wslc, + ..Default::default() + }; + assert!(!request.policy.ui_specified); + let runner = WSLContainerRunner::new(&WslcConfig::default()); + assert!(runner.validate_runner(&request).is_ok()); + } + + /// `destroyOnExit` is genuinely honoured (it selects + /// `WSLC_CONTAINER_FLAG_AUTO_REMOVE`), so both values stay accepted; + /// `preservePolicy` is not, so only it is refused. Checking both together + /// is the point — a blanket `lifecycle` rejection would break the + /// `wslc_destroy_on_exit_*` configs. + #[test] + fn validate_runner_rejects_preserve_policy_but_accepts_both_destroy_on_exit_values() { + let runner = WSLContainerRunner::new(&WslcConfig::default()); + + for destroy_on_exit in [true, false] { + let request = ExecutionRequest { + containment: wxc_common::models::ContainmentBackend::Wslc, + lifecycle: wxc_common::models::LifecycleConfig { + destroy_on_exit, + preserve_policy: false, + }, + ..Default::default() + }; + assert!( + runner.validate_runner(&request).is_ok(), + "destroyOnExit={destroy_on_exit} is honoured and must stay accepted" + ); + } + + let request = ExecutionRequest { + containment: wxc_common::models::ContainmentBackend::Wslc, + lifecycle: wxc_common::models::LifecycleConfig { + destroy_on_exit: true, + preserve_policy: true, + }, + ..Default::default() + }; + let err = runner.validate_runner(&request).unwrap_err(); + assert!( + err.error_message.contains("preservePolicy"), + "got: {}", + err.error_message + ); + assert_eq!( + err.failure_phase, + wxc_common::models::FailurePhase::Rejected + ); + } + + /// `firewall` / `both` ask for per-rule enforcement the container cannot + /// perform (no `CAP_NET_ADMIN`). Value-based, unlike `ui`: the default + /// `capabilities` honestly describes WSLc's all-or-nothing network, so an + /// explicit `capabilities` is accepted rather than refused for being + /// present. + #[test] + fn validate_runner_rejects_unimplementable_enforcement_modes() { + let runner = WSLContainerRunner::new(&WslcConfig::default()); + + for mode in [ + wxc_common::models::NetworkEnforcementMode::Firewall, + wxc_common::models::NetworkEnforcementMode::Both, + ] { + let request = ExecutionRequest { + containment: wxc_common::models::ContainmentBackend::Wslc, + policy: wxc_common::models::ContainerPolicy { + network_enforcement_mode: mode.clone(), + ..Default::default() + }, + ..Default::default() + }; + let err = runner + .validate_runner(&request) + .expect_err(&format!("{mode:?} must be rejected")); + assert!( + err.error_message.contains("enforcementMode"), + "got: {}", + err.error_message + ); + } + + let request = ExecutionRequest { + containment: wxc_common::models::ContainmentBackend::Wslc, + policy: wxc_common::models::ContainerPolicy { + network_enforcement_mode: wxc_common::models::NetworkEnforcementMode::Capabilities, + ..Default::default() + }, + ..Default::default() + }; + assert!(runner.validate_runner(&request).is_ok()); + } + + /// The rejections must abort the request, not tear a container down after + /// building one. Both one-shot entry points route through + /// `validate_runner`, and both call it before any container exists — + /// `ScriptRunner::run` ahead of `execute`, and `SandboxBackend::spawn` + /// ahead of `start_container` (asserted in `sandbox.rs`). + #[test] + fn validate_runner_rejects_before_any_container_work() { + let request = ExecutionRequest { + containment: wxc_common::models::ContainmentBackend::Wslc, + script_code: "echo hi".to_string(), + policy: wxc_common::models::ContainerPolicy { + ui_specified: true, + ..Default::default() + }, + ..Default::default() + }; + let runner = WSLContainerRunner::new(&WslcConfig::default()); + let err = runner.validate_runner(&request).unwrap_err(); + assert_eq!( + err.failure_phase, + wxc_common::models::FailurePhase::Rejected, + "a policy refusal is a rejection, not a runtime failure" + ); + } + // -- Host-stdio forwarding (`StdioMode::Inherit`) -------------------- /// The regression this whole indirection exists for: the SDK's callback diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs index 02465f660..57d8e0a75 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -751,12 +751,13 @@ pub struct ContainerPolicy { /// indistinguishable from the other fields here. 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 IsolationSession and WSLc today, neither of which has a + /// UI-restriction primitive: both refuse 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. #[serde(skip)] pub ui_specified: bool, /// BaseProcessContainer-specific UI config (Windows only, from processContainer.ui). From fa97faa92330133e20c54b0f1d8bc65efc6baa31 Mon Sep 17 00:00:00 2001 From: Soham Das Date: Thu, 27 Aug 2026 14:23:34 -0700 Subject: [PATCH 2/6] Addressed PR comments --- .github/copilot-instructions.md | 2 +- docs/schema.md | 12 +- docs/wsl/wsl-container-getting-started.md | 14 +- src/backends/wslc/common/src/policy.rs | 10 +- src/backends/wslc/common/src/sandbox.rs | 19 ++- src/backends/wslc/common/src/state_aware.rs | 42 +++++ .../wslc/common/src/wsl_container_runner.rs | 148 +++++++++++++----- tests/configs/wslc_destroy_on_exit_false.json | 20 --- .../wslc_destroy_on_exit_false_rejected.json | 20 +++ tests/configs/wslc_destroy_on_exit_true.json | 2 +- tests/scripts/run_wslc_all_tests.ps1 | 16 +- 11 files changed, 225 insertions(+), 80 deletions(-) delete mode 100644 tests/configs/wslc_destroy_on_exit_false.json create mode 100644 tests/configs/wslc_destroy_on_exit_false_rejected.json diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 66da9285c..58ac3e146 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -186,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. 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`, 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 a WSLc container runs Linux while `ui` maps to Windows `JOB_OBJECT_UILIMIT_*` — 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`. | | 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`). Because Seatbelt has no independent host-loopback posture, `validate()` rejects `network.ingress.hostLoopback` values that diverge from `network.ingress.default`; for the legacy shape, `config_parser.rs` separately rejects `network.proxy` combined with `defaultPolicy='allow'` (a proxy adds no enforcement when outbound is already unrestricted). 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`. | diff --git a/docs/schema.md b/docs/schema.md index 9bddcefe6..1f4bdffc0 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -304,11 +304,15 @@ backend (via job-object UI restrictions plus the Win32k mitigation — see [`process-container/UIPolicy_Schema.md`](process-container/UIPolicy_Schema.md)) and by the macOS Seatbelt backend (via the generated sandbox profile). Other backends do not implement UI restrictions; each backend's documentation states -whether it applies, rejects, or ignores the section. **IsolationSession refuses -any supplied `ui` at every phase on both surfaces** — no `ui` posture is truthful +whether it applies, rejects, or ignores the section. **IsolationSession and WSLc +refuse any supplied `ui` at every phase on both surfaces**, and each accepts an +omitted one without applying any UI restriction — so the section's default-deny +reading does not hold on either. The reasons differ: no `ui` posture is truthful for a session-isolated sandbox (see -[`isolation-session/state-aware-rust.md`](isolation-session/state-aware-rust.md)) — -and accepts an omitted one without applying any UI restriction. The Windows +[`isolation-session/state-aware-rust.md`](isolation-session/state-aware-rust.md)), +while a WSLc container runs Linux and has no analogue of the Windows job-object +limits `ui` maps to (see [`wsl/wslc-state-aware.md`](wsl/wslc-state-aware.md)). +The Windows `processContainer.ui` sub-block carries additional ProcessContainer-only fields (`isolation`, `desktopSystemControl`, `systemSettings`, `ime`) and is valid only when `containment` is `processcontainer`. diff --git a/docs/wsl/wsl-container-getting-started.md b/docs/wsl/wsl-container-getting-started.md index ba5848fea..b93eb6343 100644 --- a/docs/wsl/wsl-container-getting-started.md +++ b/docs/wsl/wsl-container-getting-started.md @@ -421,11 +421,17 @@ an explicitly supplied lockdown `ui` is indistinguishable *by value* from an absent one — a value-based check would let the single most restrictive request you can write through unenforced. Omit the section entirely. -### `lifecycle`: `destroyOnExit` is honored, `preservePolicy` is not +### `lifecycle`: only `destroyOnExit: true` is supported -`lifecycle.destroyOnExit` **is** honored: it selects the SDK's -`WSLC_CONTAINER_FLAG_AUTO_REMOVE`, so both `true` (the default) and `false` -behave as documented. +`lifecycle.destroyOnExit: true` (the default) is honored: it selects the SDK's +`WSLC_CONTAINER_FLAG_AUTO_REMOVE`, and teardown stops and deletes the container. + +`lifecycle.destroyOnExit: false` is **rejected**. It asks for the container to +outlive the run, which a one-shot invocation cannot deliver: the container is +scoped to a session this process owns, terminating that session at the end of +the run reaps the container regardless of the AutoRemove flag, and the WSLC SDK +has no cross-process re-attach. Use the state-aware lifecycle if you need a +container to persist — its daemon holds the session open across phase processes. `lifecycle.preservePolicy: true` is **rejected** — WSLC has no policy-persistence primitive, so there is nothing for the flag to select. diff --git a/src/backends/wslc/common/src/policy.rs b/src/backends/wslc/common/src/policy.rs index 499e327fb..277d5b28f 100644 --- a/src/backends/wslc/common/src/policy.rs +++ b/src/backends/wslc/common/src/policy.rs @@ -187,9 +187,13 @@ pub(crate) fn reject_ui_policy(request: &ExecutionRequest) -> Result<(), MxcErro /// enforcement the container cannot perform — it has no `CAP_NET_ADMIN` — so /// accepting either would assert a guarantee that does not exist. /// -/// Shared by every WSLc phase and by the one-shot / streaming -/// `validate_runner`, for the same reason as the UI check: it is a property of -/// the backend, not of a phase. +/// Called only by [`validate_provision_policy`] and the one-shot / streaming +/// `WSLContainerRunner::validate_runner` — the two places a network posture is +/// settable. Post-provision and exec deliberately do not call it: they reject +/// the whole network mode by presence via [`reject_post_provision_network_mode`] +/// (the parser sets `network_mode_specified` for `enforcementMode` too), which +/// is broader — routing them here would wrongly accept `capabilities` after +/// provision. pub(crate) fn reject_unsupported_enforcement_mode( request: &ExecutionRequest, ) -> Result<(), MxcError> { diff --git a/src/backends/wslc/common/src/sandbox.rs b/src/backends/wslc/common/src/sandbox.rs index 1dad13969..3e5d3f021 100644 --- a/src/backends/wslc/common/src/sandbox.rs +++ b/src/backends/wslc/common/src/sandbox.rs @@ -393,13 +393,15 @@ impl Drop for WslcSandboxProcess { #[cfg(test)] mod tests { use super::*; + use crate::wsl_container_runner::START_CONTAINER_BANNER; - /// The rejections must abort the request, not tear a container down after - /// building one. `SandboxBackend::spawn` is the streaming entry point the - /// Rust SDK uses; it must refuse before `start_container` touches the WSLC - /// SDK. Asserting on a host with no WSLC SDK at all is what proves the - /// ordering: were the guard to run late, we would see an SDK-load failure - /// instead of the policy message. + /// Rejections must abort, not tear down a container after building one. + /// + /// The message alone cannot prove that: on a host that *has* `wslcsdk.dll`, + /// a guard moved after `start_container` would leak a container and still + /// return this exact message. The banner is what makes the check + /// host-independent — `start_container` writes it as its first statement, + /// so an untouched buffer proves it was never entered. #[test] fn spawn_rejects_policy_before_touching_the_sdk() { let request = ExecutionRequest { @@ -427,6 +429,11 @@ mod tests { err.failure_phase, wxc_common::models::FailurePhase::Rejected ); + assert!( + !logger.get_buffer().contains(START_CONTAINER_BANNER), + "guard must run before `start_container`; logger: {}", + logger.get_buffer() + ); } /// The exact contradiction the streaming contract forbids: a timed-out run diff --git a/src/backends/wslc/common/src/state_aware.rs b/src/backends/wslc/common/src/state_aware.rs index 53db49248..2e2c88a01 100644 --- a/src/backends/wslc/common/src/state_aware.rs +++ b/src/backends/wslc/common/src/state_aware.rs @@ -625,6 +625,48 @@ mod tests { } } + /// Guards against over-rejection, which the refusal tests cannot see. Each + /// value is the near-miss of a rejected one — `capabilities` vs `firewall`, + /// `allowLocalNetwork: false` vs `true`, absent `ui` vs supplied — so a gate + /// that flipped between value- and presence-based would fail here only. + #[test] + fn validate_provision_accepts_the_postures_wslc_can_honour() { + let runner = WslcStateAwareRunner::new(); + for (label, policy) in [ + ( + "explicit capabilities enforcement mode", + ContainerPolicy { + network_enforcement_mode: + wxc_common::models::NetworkEnforcementMode::Capabilities, + ..Default::default() + }, + ), + ( + "explicit allowLocalNetwork=false", + ContainerPolicy { + allow_local_network: false, + ..Default::default() + }, + ), + ( + "absent ui", + ContainerPolicy { + ui_specified: false, + ..Default::default() + }, + ), + ] { + let request = ExecutionRequest { + policy, + ..Default::default() + }; + assert!( + runner.validate_provision(&request, None).is_ok(), + "{label} is honoured by WSLc and must not be rejected" + ); + } + } + #[test] fn map_network_maps_block_to_none() { let req = ExecutionRequest { diff --git a/src/backends/wslc/common/src/wsl_container_runner.rs b/src/backends/wslc/common/src/wsl_container_runner.rs index d07cfce96..f8e75e27e 100644 --- a/src/backends/wslc/common/src/wsl_container_runner.rs +++ b/src/backends/wslc/common/src/wsl_container_runner.rs @@ -711,14 +711,24 @@ fn as_rejection(err: MxcError) -> ScriptResponse { WslcError::Rejected(err.message).into_response() } +/// The first line [`WSLContainerRunner::start_container`] writes, before the +/// filesystem gate and any SDK call. Shared with the tests, which assert its +/// absence to prove a rejection aborted before any container work. +pub(crate) const START_CONTAINER_BANNER: &str = "[WSLC] Starting WSL Container runner"; + /// Refuses the `lifecycle` settings the one-shot surface cannot honour. /// /// Value-based rather than presence-based (unlike `ui`), because the defaults /// genuinely match the behaviour: /// -/// * `destroyOnExit` **is** honoured — it selects -/// `WSLC_CONTAINER_FLAG_AUTO_REMOVE` on the container settings, so both -/// values are accepted. +/// * `destroyOnExit: true` (the default) is honoured — it selects +/// `WSLC_CONTAINER_FLAG_AUTO_REMOVE` and [`StartedContainer::destroy`] stops +/// and deletes the container. +/// * `destroyOnExit: false` is refused. [`StartedContainer`] owns the +/// [`WslcSessionGuard`], whose `Drop` terminates the session — and with it +/// the session-scoped container — regardless of the flag, and the WSLC SDK +/// has no cross-process re-attach. The outcome is identical to `true`, so +/// accepting `false` would promise a container that is already gone. /// * `preservePolicy: true` asks for filesystem and network policy to outlive /// the run. WSLc installs no persistent host-side enforcement: `rw`/`ro` /// paths become container volume mounts and the network posture is a @@ -730,6 +740,17 @@ fn as_rejection(err: MxcError) -> ScriptResponse { /// The state-aware surface needs no counterpart: the parser rejects the whole /// one-shot `lifecycle` section on state-aware requests. fn reject_unsupported_lifecycle(request: &ExecutionRequest) -> Result<(), ScriptResponse> { + if !request.lifecycle.destroy_on_exit { + return Err(WslcError::Rejected( + "WSLc: lifecycle.destroyOnExit=false is not supported by the one-shot WSLc surface. \ + The container is scoped to a session this process owns, and terminating that \ + session at the end of the run removes the container regardless of the AutoRemove \ + flag. Omit the field (or set it to true), or use the state-aware lifecycle, whose \ + daemon holds the session open across phases." + .to_string(), + ) + .into_response()); + } if request.lifecycle.preserve_policy { return Err(WslcError::Rejected( "WSLc: lifecycle.preservePolicy=true is not supported. WSLc installs no persistent \ @@ -1370,7 +1391,7 @@ impl WSLContainerRunner { logger: &mut Logger, output: OutputMode, ) -> Result { - let _ = writeln!(logger, "[WSLC] Starting WSL Container runner"); + let _ = writeln!(logger, "{START_CONTAINER_BANNER}"); // WSLc provision-time filesystem-policy gate (D6 normalization → D3 // delegation → denied-path overlap), shared verbatim with the @@ -2448,6 +2469,53 @@ mod tests { assert!(err.error_message.contains("allowLocalNetwork")); } + /// The two surfaces refuse `allowLocalNetwork` with deliberately different + /// remedies: one-shot has `experimental.wslc.portMappings` to point at, + /// state-aware has no port-mapping primitive at all. Unifying the messages + /// — the obvious tidy-up — would send state-aware users after a dead end. + /// Both must classify as `policy_validation`, which is the phase, not the + /// message, that `mxc_engine::dispatch::map_spawn_error` reads. + #[test] + fn both_surfaces_reject_allow_local_network_with_surface_specific_remedies() { + let request = ExecutionRequest { + containment: wxc_common::models::ContainmentBackend::Wslc, + policy: wxc_common::models::ContainerPolicy { + allow_local_network: true, + ..Default::default() + }, + ..Default::default() + }; + + let one_shot = WSLContainerRunner::new(&WslcConfig::default()) + .validate_runner(&request) + .unwrap_err(); + assert_eq!( + one_shot.failure_phase, + wxc_common::models::FailurePhase::Rejected + ); + assert!( + one_shot.error_message.contains("portMappings"), + "one-shot has a port-mapping primitive and must name it; got: {}", + one_shot.error_message + ); + + let state_aware = crate::policy::validate_provision_policy(&request).unwrap_err(); + assert_eq!( + state_aware.code, + wxc_common::mxc_error::MxcErrorCode::PolicyValidation + ); + assert!( + !state_aware.message.contains("portMappings"), + "state-aware has no port-mapping primitive to point at; got: {}", + state_aware.message + ); + assert!( + state_aware.message.contains("allowLocalNetwork"), + "got: {}", + state_aware.message + ); + } + #[test] fn validate_runner_tags_shared_validator_rejections() { // The shared network validator builds untagged responses; WSLc retags them @@ -2536,48 +2604,58 @@ mod tests { assert!(runner.validate_runner(&request).is_ok()); } - /// `destroyOnExit` is genuinely honoured (it selects - /// `WSLC_CONTAINER_FLAG_AUTO_REMOVE`), so both values stay accepted; - /// `preservePolicy` is not, so only it is refused. Checking both together - /// is the point — a blanket `lifecycle` rejection would break the - /// `wslc_destroy_on_exit_*` configs. + /// `destroyOnExit: true` matches what one-shot does; `false` and + /// `preservePolicy: true` do not. The accepted value is the near-miss a + /// blanket `lifecycle` rejection would swallow. #[test] - fn validate_runner_rejects_preserve_policy_but_accepts_both_destroy_on_exit_values() { + fn validate_runner_rejects_the_lifecycle_settings_one_shot_cannot_honour() { let runner = WSLContainerRunner::new(&WslcConfig::default()); - for destroy_on_exit in [true, false] { - let request = ExecutionRequest { - containment: wxc_common::models::ContainmentBackend::Wslc, - lifecycle: wxc_common::models::LifecycleConfig { - destroy_on_exit, - preserve_policy: false, - }, - ..Default::default() - }; - assert!( - runner.validate_runner(&request).is_ok(), - "destroyOnExit={destroy_on_exit} is honoured and must stay accepted" - ); - } - - let request = ExecutionRequest { + let accepted = ExecutionRequest { containment: wxc_common::models::ContainmentBackend::Wslc, lifecycle: wxc_common::models::LifecycleConfig { destroy_on_exit: true, - preserve_policy: true, + preserve_policy: false, }, ..Default::default() }; - let err = runner.validate_runner(&request).unwrap_err(); assert!( - err.error_message.contains("preservePolicy"), - "got: {}", - err.error_message - ); - assert_eq!( - err.failure_phase, - wxc_common::models::FailurePhase::Rejected + runner.validate_runner(&accepted).is_ok(), + "destroyOnExit=true is what one-shot does and must stay accepted" ); + + for (lifecycle, needle) in [ + ( + wxc_common::models::LifecycleConfig { + destroy_on_exit: false, + preserve_policy: false, + }, + "destroyOnExit=false", + ), + ( + wxc_common::models::LifecycleConfig { + destroy_on_exit: true, + preserve_policy: true, + }, + "preservePolicy", + ), + ] { + let request = ExecutionRequest { + containment: wxc_common::models::ContainmentBackend::Wslc, + lifecycle, + ..Default::default() + }; + let err = runner.validate_runner(&request).unwrap_err(); + assert!( + err.error_message.contains(needle), + "expected {needle}; got: {}", + err.error_message + ); + assert_eq!( + err.failure_phase, + wxc_common::models::FailurePhase::Rejected + ); + } } /// `firewall` / `both` ask for per-rule enforcement the container cannot diff --git a/tests/configs/wslc_destroy_on_exit_false.json b/tests/configs/wslc_destroy_on_exit_false.json deleted file mode 100644 index a3e9ce0e8..000000000 --- a/tests/configs/wslc_destroy_on_exit_false.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "_comment": "Smoke test: asserts config parses and payload runs. The destroyOnExit semantics are NOT externally verified -- WSLC's session teardown reaps session-scoped containers regardless of the AutoRemove flag, so `wslc list --all` shows the container as absent in both true/false cases. True verification requires a runner-side log assertion (follow-up).", - "version": "0.6.0-alpha", - "containerId": "wslc-destroy-on-exit-false", - "containment": "wslc", - "process": { - "commandLine": "echo 'PASS: container ran (destroyOnExit=false)'" - }, - "lifecycle": { - "destroyOnExit": false - }, - "network": { - "defaultPolicy": "block" - }, - "experimental": { - "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 new file mode 100644 index 000000000..f0367e82b --- /dev/null +++ b/tests/configs/wslc_destroy_on_exit_false_rejected.json @@ -0,0 +1,20 @@ +{ + "_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", + "containerId": "wslc-destroy-on-exit-false-rejected", + "containment": "wslc", + "process": { + "commandLine": "echo 'FAIL: this payload must never run'" + }, + "lifecycle": { + "destroyOnExit": false + }, + "network": { + "defaultPolicy": "block" + }, + "experimental": { + "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 c087dc9b3..61b30b3f4 100644 --- a/tests/configs/wslc_destroy_on_exit_true.json +++ b/tests/configs/wslc_destroy_on_exit_true.json @@ -1,5 +1,5 @@ { - "_comment": "Smoke test: asserts config parses and payload runs. The destroyOnExit semantics are NOT externally verified -- WSLC's session teardown reaps session-scoped containers regardless of the AutoRemove flag, so `wslc list --all` shows the container as absent in both true/false cases. True verification requires a runner-side log assertion (follow-up).", + "_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", "containerId": "wslc-destroy-on-exit-true", "containment": "wslc", diff --git a/tests/scripts/run_wslc_all_tests.ps1 b/tests/scripts/run_wslc_all_tests.ps1 index 4e4958f50..586ec6200 100644 --- a/tests/scripts/run_wslc_all_tests.ps1 +++ b/tests/scripts/run_wslc_all_tests.ps1 @@ -345,14 +345,18 @@ Write-Host "`n--- Timeout Tests ---" -ForegroundColor Cyan $null = $results.Add((Run-WslcTest "wslc_timeout.json" -ExpectedExit -1 -OutputContains "Starting long task")) Write-Host "`n--- Lifecycle Tests ---" -ForegroundColor Cyan -# Smoke tests only: assert config parses and payload runs. WSLC's session -# teardown reaps session-scoped containers regardless of AutoRemove, so -# destroyOnExit has no externally observable effect via `wslc list`. -# True semantic verification requires a runner-side log assertion (TODO). +# `destroyOnExit: true` is a smoke test only: WSLC's session teardown reaps +# session-scoped containers regardless of AutoRemove, so `true` has no +# externally observable effect -- it is accepted because that teardown is +# exactly what it asks for. $null = $results.Add((Run-WslcTest "wslc_destroy_on_exit_true.json" ` -OutputContains "PASS: container ran (destroyOnExit=true)")) -$null = $results.Add((Run-WslcTest "wslc_destroy_on_exit_false.json" ` - -OutputContains "PASS: container ran (destroyOnExit=false)")) +# `false` asks for a container that outlives the run, which one-shot cannot +# deliver. It is refused before any container is created, so the payload must +# never run. +$null = $results.Add((Run-WslcTest "wslc_destroy_on_exit_false_rejected.json" ` + -ExpectedExit -1 ` + -OutputContains "destroyOnExit=false")) Write-Host "`n--- State-Aware Lifecycle Tests ---" -ForegroundColor Cyan # Delegate the multi-invocation provision/start/exec/stop/deprovision lifecycle From 33ef9af67fbe025e9cd6985b60078b188358de55 Mon Sep 17 00:00:00 2001 From: Soham Das Date: Fri, 28 Aug 2026 10:43:42 -0700 Subject: [PATCH 3/6] Promote WSLc from experimental to the stable config surface Closes #1049 WSLc is now configured through the top-level `wslc` section instead of `experimental.wslc`, and selecting `"containment": "wslc"` no longer requires `--experimental` / `{ experimental: true }` / `SandboxRequest::set_experimental(true)`. This mirrors the Seatbelt promotion precedent. Wire + schema - Move `wslc` from `wire::Experimental` to the `MxcConfig` root, and from `experimental.rs` to `stable.rs` in the 0.9.0-alpha closed contract. - Keep `experimental.wslc` as a rejection alias so a pre-promotion config fails with an actionable migration message rather than being silently ignored. - Regenerate the two dev schemas and the two TypeScript wire oracles. Parser + dispatch - Generalize the state-aware dispatcher with `SectionRoot` (`Experimental` | `Stable`); `StatefulSandboxBackend::SECTION_ROOT` defaults to `Experimental` and only `WslcStateAwareRunner` overrides it. `ParsedStateAwareRequest` gains `stable_raw` for stable-rooted backends. - Drop the experimental gate for WSLc on both the one-shot and state-aware surfaces. TypeScript SDK - Move `wslc` out of `ContainerConfig.experimental` and off `ExperimentalBackends`; add `BACKEND_SECTION_ROOT`, the TS twin of the Rust `SectionRoot`, so envelopes are built at the right root. - Add a `wslcAvailable` probe fact end to end (Rust probe -> engine -> `wxc-exec --probe` -> SDK). Without it, removing the experimental gate unmasked the fact that `wslc` was never in `availableMethods`, which would have made the backend unreachable through the SDK. Rust SDK, corpus, harnesses and docs - Drop the `set_experimental(true)` requirement from `mxc-sdk`. - Hoist `experimental.wslc` to top-level `wslc` in 31 test configs. - Remove `--experimental` from the WSLc test harnesses. - Update the WSLc docs, schema reference, SDK READMEs, setup script and `.github/copilot-instructions.md`. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9f02fa89-c89e-4a5d-b80d-5130252d3654 --- .github/copilot-instructions.md | 13 +- docs/schema.md | 23 +- .../mxc-state-aware-sandbox-api.md | 2 +- docs/wsl/wsl-container-getting-started.md | 57 ++- docs/wsl/wsl-container-support-plan.md | 7 +- docs/wsl/wslc-state-aware.md | 7 +- .../dev/mxc-config.schema.0.9.0-alpha.json | 108 +++--- schemas/dev/mxc-config.schema.0.9.0-dev.json | 22 +- scripts/setup-wslc.ps1 | 4 +- sdk/node/README.md | 14 +- sdk/node/src/generated/v0_9_0_alpha/wire.ts | 92 ++--- sdk/node/src/generated/wire.ts | 15 +- sdk/node/src/platform.ts | 6 +- sdk/node/src/sandbox.ts | 8 +- sdk/node/src/state-aware-helper.ts | 26 +- sdk/node/src/state-aware-types.ts | 4 +- sdk/node/src/types.ts | 6 +- sdk/node/tests/integration/wslc-e2e.test.ts | 14 +- sdk/node/tests/unit/conformance-helpers.ts | 4 +- sdk/node/tests/unit/sandbox.test.ts | 42 +- sdk/node/tests/unit/state-aware.test.ts | 13 +- .../unit/wire-conformance-state-aware.test.ts | 2 +- src/backends/appcontainer/common/src/probe.rs | 24 ++ .../wslc/common/src/daemon_protocol.rs | 2 +- src/backends/wslc/common/src/policy.rs | 2 +- src/backends/wslc/common/src/state_aware.rs | 8 +- .../wslc/common/src/wsl_container_runner.rs | 6 +- src/core/mxc-sdk/Cargo.toml | 4 +- src/core/mxc-sdk/README.md | 18 +- src/core/mxc-sdk/src/lib.rs | 13 +- .../src/dev/experimental.rs | 58 --- src/core/mxc_config_contract/src/dev/mod.rs | 10 +- .../mxc_config_contract/src/dev/one_shot.rs | 4 + .../mxc_config_contract/src/dev/stable.rs | 55 +++ .../src/dev/state_aware/provision/wslc.rs | 6 +- .../mxc_config_contract/tests/v0_9_0_alpha.rs | 2 + .../tests/v0_9_0_alpha/experimental.rs | 2 - .../tests/v0_9_0_alpha/experimental/root.rs | 18 +- .../one_shot/invalid/port_out_of_range.json | 16 +- .../tests/v0_9_0_alpha/optional_fields.rs | 42 +- .../state_aware/provision/wslc.rs | 46 +-- .../v0_9_0_alpha/{experimental => }/wslc.rs | 58 ++- .../tests/version_boundaries.rs | 2 + .../tests/version_boundaries/experimental.rs | 19 - .../tests/version_boundaries/state_aware.rs | 12 +- .../tests/version_boundaries/wslc.rs | 26 ++ src/core/mxc_engine/src/dispatch.rs | 28 +- src/core/mxc_engine/src/lib.rs | 2 + src/core/mxc_engine/src/platform.rs | 7 +- src/core/mxc_engine/src/policy.rs | 35 +- src/core/mxc_engine/src/run.rs | 14 +- src/core/mxc_engine/src/state_aware.rs | 13 +- src/core/wxc/src/main.rs | 17 +- .../config_contract_adapters/dev/one_shot.rs | 9 +- .../dev/one_shot_tests/experimental.rs | 46 +-- .../dev/state_aware.rs | 12 +- .../dev/state_aware_tests/provision.rs | 41 +- .../src/config_contract_adapters/v0_6.rs | 1 + .../src/config_contract_adapters/v0_7.rs | 1 + .../src/config_contract_adapters/v0_8.rs | 1 + src/core/wxc_common/src/config_parser.rs | 363 ++++++++++++------ src/core/wxc_common/src/models.rs | 6 +- .../wxc_common/src/state_aware_backend.rs | 11 +- .../wxc_common/src/state_aware_dispatch.rs | 43 ++- .../wxc_common/src/state_aware_request.rs | 149 +++++-- src/core/wxc_common/src/wire.rs | 29 +- tests/configs/wslc_custom_registry.json | 6 +- tests/configs/wslc_custom_registry_ghcr.json | 6 +- tests/configs/wslc_custom_registry_quay.json | 6 +- tests/configs/wslc_denied_dotdot_alias.json | 14 +- tests/configs/wslc_denied_masking.json | 15 +- .../wslc_destroy_on_exit_false_rejected.json | 6 +- tests/configs/wslc_destroy_on_exit_true.json | 6 +- tests/configs/wslc_env_vars.json | 11 +- tests/configs/wslc_exit_code.json | 6 +- tests/configs/wslc_filesystem.json | 14 +- tests/configs/wslc_filesystem_object.json | 14 +- tests/configs/wslc_large_output.json | 6 +- .../wslc_most_specific_denied_parent.json | 14 +- tests/configs/wslc_network_isolated.json | 6 +- tests/configs/wslc_network_proxy.json | 19 +- tests/configs/wslc_port_mapping_multiple.json | 22 +- tests/configs/wslc_port_mapping_tcp.json | 16 +- tests/configs/wslc_python_hello.json | 6 +- tests/configs/wslc_python_stdlib.json | 6 +- tests/configs/wslc_readonly_mount.json | 10 +- tests/configs/wslc_state_aware_provision.json | 8 +- .../wslc_state_aware_provision_bridged.json | 8 +- ...state_aware_provision_rejected_denied.json | 16 +- ..._state_aware_provision_rejected_hosts.json | 12 +- ..._state_aware_provision_rejected_proxy.json | 12 +- ...state_aware_provision_with_filesystem.json | 16 +- tests/configs/wslc_stderr.json | 6 +- .../configs/wslc_tar_import_docker_save.json | 8 +- tests/configs/wslc_tar_import_rootfs.json | 8 +- tests/configs/wslc_timeout.json | 6 +- tests/examples/wslc_hello_world.json | 6 +- tests/scripts/run_wslc_all_tests.ps1 | 4 +- .../scripts/run_wslc_denied_masking_test.ps1 | 2 +- tests/scripts/run_wslc_dotdot_alias_test.ps1 | 2 +- tests/scripts/run_wslc_most_specific_test.ps1 | 2 +- tests/scripts/run_wslc_object_test.ps1 | 2 +- tests/scripts/run_wslc_proxy_test.ps1 | 2 +- tests/scripts/run_wslc_state_aware_tests.ps1 | 4 +- 104 files changed, 1219 insertions(+), 884 deletions(-) rename src/core/mxc_config_contract/tests/v0_9_0_alpha/{experimental => }/wslc.rs (75%) create mode 100644 src/core/mxc_config_contract/tests/version_boundaries/wslc.rs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 58ac3e146..ce4b06a34 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) @@ -186,7 +187,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 a WSLc container runs Linux while `ui` maps to Windows `JOB_OBJECT_UILIMIT_*` — 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 a WSLc container runs Linux while `ui` maps to Windows `JOB_OBJECT_UILIMIT_*` — 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`). Because Seatbelt has no independent host-loopback posture, `validate()` rejects `network.ingress.hostLoopback` values that diverge from `network.ingress.default`; for the legacy shape, `config_parser.rs` separately rejects `network.proxy` combined with `defaultPolicy='allow'` (a proxy adds no enforcement when outbound is already unrestricted). 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/docs/schema.md b/docs/schema.md index 1f4bdffc0..11e0fca3f 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -188,18 +188,19 @@ 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" } + ] + }, + "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" } - ] - }, "seatbelt": { // macOS sandbox settings (macOS only) "profileOverride": null, // Optional raw TinyScheme profile (escape hatch) "guiAccess": false, // Allow GUI Mach services / IOKit / pty for window-drawing apps 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 17a6413c5..3aa8f564f 100644 --- a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md +++ b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md @@ -707,7 +707,7 @@ Configs (§6.1), not on this wire-shape type. Raw-JSON callers writing `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 +one-shot config object (e.g., the top-level `wslc?: WslcConfig`), as 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 b93eb6343..e61e9b6ac 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 — @@ -152,15 +153,15 @@ const policy = { 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" } ``` @@ -340,7 +336,7 @@ address the container can reach: "defaultPolicy": "allow", "proxy": { "url": "http://proxy.example:8080" } }, - "experimental": { "wslc": { "image": "alpine:latest" } } + "wslc": { "image": "alpine:latest" } } ``` @@ -387,7 +383,7 @@ 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.) @@ -448,9 +444,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..856f2a94d 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": ... }`. @@ -441,7 +442,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 diff --git a/docs/wsl/wslc-state-aware.md b/docs/wsl/wslc-state-aware.md index c427b28d9..965a6a1b9 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..4bcb48756 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.", @@ -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..a0fe332ab 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). Promoted to the top-level `wslc` section; still parsed here so the parser can reject it with a migration message instead of silently ignoring it." } }, "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/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/node/README.md b/sdk/node/README.md index 223a56a0f..f18f56147 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,11 +138,11 @@ 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 default `processcontainer`, `bubblewrap`, `lxc`, `seatbelt`, and `wslc` backends work out of the box. **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. @@ -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`, `wslc`, `experimental.windows_sandbox`, etc. 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 }` — `wslc` is promoted and needs no opt-in). The one-shot spawn APIs (`spawnSandbox` / `spawnSandboxFromConfig`) are the supported path for every other backend. ```typescript import { @@ -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 Bubblewrap on PATH. On macOS: schema version < `0.6.0-alpha`. | Install LXC/Bubblewrap, or switch to schema `0.6.0-alpha` (or `0.7.0-alpha` if you need state-aware lifecycle). | | `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..3dcf71810 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. */ @@ -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..23e99c2d2 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). Promoted to the top-level `wslc` section; still parsed here so the parser can reject it with a migration message instead of silently ignoring it. */ 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 b704e212d..b96e1aed8 100644 --- a/sdk/node/src/platform.ts +++ b/sdk/node/src/platform.ts @@ -139,7 +139,8 @@ 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 — when the probe reports - * the isolation-session service available — the `isolation_session` method. + * the corresponding runtime available — the `isolation_session` and `wslc` + * 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. @@ -166,6 +167,9 @@ function populateIsolationFromProbe(support: PlatformSupport): void { if (facts.isolationSessionAvailable === true) { support.availableMethods.push('isolation_session'); } + if (facts.wslcAvailable === true) { + support.availableMethods.push('wslc'); + } } } } catch { diff --git a/sdk/node/src/sandbox.ts b/sdk/node/src/sandbox.ts index 0094845a5..5854a075e 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 diff --git a/sdk/node/src/state-aware-helper.ts b/sdk/node/src/state-aware-helper.ts index 5174ce424..ec4f798fa 100644 --- a/sdk/node/src/state-aware-helper.ts +++ b/sdk/node/src/state-aware-helper.ts @@ -19,7 +19,7 @@ export const WSLC_STATE_AWARE_VERSION = '0.8.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 +61,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 +104,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 +138,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..21d7d50cd 100644 --- a/sdk/node/src/state-aware-types.ts +++ b/sdk/node/src/state-aware-types.ts @@ -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 a76f6c959..5c47fee5f 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/sandbox.test.ts b/sdk/node/tests/unit/sandbox.test.ts index e0c8940f0..fd0645860 100644 --- a/sdk/node/tests/unit/sandbox.test.ts +++ b/sdk/node/tests/unit/sandbox.test.ts @@ -448,10 +448,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', () => { @@ -1034,11 +1034,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', () => { @@ -1126,21 +1126,15 @@ describe('createConfigFromPolicy', () => { assert.strictEqual(config.containerId, 'my-container'); }); - it('should throw from spawnSandbox when experimental backend is used via config', () => { + it('should no longer require experimental mode for a wslc config', () => { const config = createConfigFromPolicy({ version: '0.6.0-alpha' }, 'wslc'); config.process!.commandLine = 'echo hello'; + // WSLc is promoted, so the experimental gate no longer fires. A host + // without the WSLc runtime still rejects the request, but on + // availability — never on `--experimental`. 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/ }, + (e: Error) => !/experimental mode/.test(e.message), ); }); }); @@ -1281,13 +1275,23 @@ 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', () => { + // WSLc is promoted, so the experimental gate no longer fires for it. On a + // host without the WSLc runtime the availability check still rejects the + // request — that is a different error, and the point of this test. + assert.throws( + () => resolveExecutableAndArgs(makeConfig('wslc'), { executablePath: fakeExe }), + (e: Error) => !/experimental mode/.test(e.message), + ); + }); + 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..668ed0780 100644 --- a/sdk/node/tests/unit/state-aware.test.ts +++ b/sdk/node/tests/unit/state-aware.test.ts @@ -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 }, () => { 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 022e92f8f..1d8d184e8 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, /// Platform-agnostic UI restrictions this host can enforce. pub ui_capabilities: UiCapabilitySupport, } @@ -132,6 +137,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, ui_capabilities: crate::job_object::supported_ui_restrictions().into(), }; match fallback_detector::detect(policy, /* prefer_base_container */ true) { @@ -208,6 +214,7 @@ mod tests { bfs_compiled_in: false, base_container_supports_deny_paths: false, isolation_session_available: true, + wslc_available: true, ui_capabilities: all_ui_capabilities(), }, error: None, @@ -221,6 +228,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"], @@ -245,6 +253,7 @@ mod tests { bfs_compiled_in: false, base_container_supports_deny_paths: false, isolation_session_available: false, + wslc_available: false, ui_capabilities: UiCapabilitySupport { can_block_input_injection: false, can_block_input_method_changes: false, @@ -336,4 +345,19 @@ mod tests { "isolationSessionAvailable must always be present, got: {v}" ); } + + #[test] + fn probe_always_emits_wslc_available() { + // Twin of the isolation-session gate above: WSLc is a promoted + // (non-experimental) backend, so the SDK's availability check is the + // only thing standing between a caller and a `wslc` containment + // request. The field must always serialize, even when false. + 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 277d5b28f..edfc9c52e 100644 --- a/src/backends/wslc/common/src/policy.rs +++ b/src/backends/wslc/common/src/policy.rs @@ -206,7 +206,7 @@ pub(crate) fn reject_unsupported_enforcement_mode( } /// Reject inbound local networking at provision. The one-shot surface refuses -/// the same value but points at `experimental.wslc.portMappings`; the +/// the same value but points at `wslc.portMappings`; the /// state-aware provision phase has no such field, so its message must not offer /// that escape hatch. Post-provision phases reject it by presence via /// [`reject_post_provision_network_mode`] instead, since the posture is fixed diff --git a/src/backends/wslc/common/src/state_aware.rs b/src/backends/wslc/common/src/state_aware.rs index 2e2c88a01..1317c7ad2 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, ExecConsumer, ExecHandle, ExecOutcome, 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,9 @@ impl WslcStateAwareRunner { impl StatefulSandboxBackend for WslcStateAwareRunner { const ID_PREFIX: &'static str = "wslc"; const BACKEND_KEY: &'static str = "wslc"; + // WSLc is promoted to the stable surface: its per-phase config lives at the + // top-level `wslc` section, not under `experimental`. + 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 f8e75e27e..820d860db 100644 --- a/src/backends/wslc/common/src/wsl_container_runner.rs +++ b/src/backends/wslc/common/src/wsl_container_runner.rs @@ -686,7 +686,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()); @@ -961,7 +961,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 { @@ -2470,7 +2470,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 /// — the obvious tidy-up — would send state-aware users after a dead end. /// Both must classify as `policy_validation`, which is the phase, not the diff --git a/src/core/mxc-sdk/Cargo.toml b/src/core/mxc-sdk/Cargo.toml index dfe49f4b2..266e92a9a 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 4a09928f6..0f6d8882b 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -397,16 +397,16 @@ 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**. The +backend is promoted, so it needs no `set_experimental(true)`. 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::{ @@ -419,7 +419,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 6a9aeafd3..660236fb0 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -50,9 +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 +//! WSLC is opt-in at **build** time only: compile this crate with its `wslc` +//! feature. It carries no `--experimental` gate — the section is part of the +//! stable config surface. 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 @@ -94,7 +94,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>(()) //! ``` @@ -205,9 +205,10 @@ 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. +/// WSLc is promoted and needs no opt-in. /// It is an API parameter rather than a field in the request JSON so that a /// config cannot grant itself experimental access. pub fn run_state_aware_json( 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..5c2d10bdc 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 @@ -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 259224b1f..10effbb61 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 6fa5ff3e9..03cd59574 100644 --- a/src/core/mxc_engine/src/platform.rs +++ b/src/core/mxc_engine/src/platform.rs @@ -106,8 +106,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..04b82ab47 100644 --- a/src/core/mxc_engine/src/policy.rs +++ b/src/core/mxc_engine/src/policy.rs @@ -542,7 +542,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; @@ -971,13 +971,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 +1529,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 +1545,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,12 +1567,7 @@ 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()); @@ -1587,10 +1575,9 @@ mod tests { } #[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`. + fn wslc_does_not_require_the_experimental_gate() { + // WSLc is promoted to the stable surface: selecting it must neither + // require nor silently flip the experimental gate. let mut request = build_request_with_containment( &minimal_policy(), &Containment::Wslc(WslcSection::default()), @@ -1657,7 +1644,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 +1681,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 aa958ebe5..a531f08a8 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..8b0b4a044 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!( @@ -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 4a08e6dcb..088c5484d 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, @@ -782,6 +782,16 @@ fn main() { output.probes.isolation_session_available = mxc_engine::isolation_session_available(); output }; + // Same story for WSLc: `appcontainer_common` cannot see that backend + // either. WSLc is promoted (non-experimental), so the SDK's + // availability check is the only gate on a `wslc` request — an + // un-overridden `false` here would make the backend unreachable. + #[cfg(target_os = "windows")] + let output = { + let mut output = output; + output.probes.wslc_available = mxc_engine::wslc_available(); + output + }; match appcontainer_common::probe::to_json_pretty(&output) { Ok(s) => println!("{s}"), Err(e) => { @@ -1413,7 +1423,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); @@ -1425,7 +1435,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 ); @@ -1721,6 +1731,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 6295a90ef..220d5f699 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); } @@ -1074,7 +1074,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.) @@ -1082,7 +1082,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())); } @@ -1279,70 +1279,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." @@ -1355,13 +1297,73 @@ 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 { + 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); @@ -1393,6 +1395,7 @@ fn convert_wire_config( policy, lxc_config, seatbelt, + wslc, experimental_enabled: false, testing_features_enabled: false, experimental, @@ -1470,11 +1473,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)); } @@ -1483,6 +1490,49 @@ 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(); + + // `wslc` is promoted to the stable surface but, unlike the other promoted + // sections, is not one-shot-only: the state-aware lifecycle reads its + // provision config from `wslc.provision`. So it is not a stray section — + // instead accept `provision` and reject the one-shot-only siblings, which + // the daemon-backed lifecycle does not honor, rather than silently + // dropping 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." + ))); + } + 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(); @@ -1522,6 +1572,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; @@ -1569,9 +1622,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()), }) } @@ -4866,19 +4920,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] @@ -5089,6 +5144,83 @@ 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_experimental_macos_sandbox_alias() { let json = r#"{ @@ -5844,24 +5976,24 @@ 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 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(), @@ -5871,12 +6003,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); @@ -5885,12 +6017,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"); } @@ -5899,7 +6031,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(); @@ -5916,7 +6048,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(); @@ -5930,7 +6062,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(); @@ -5944,7 +6076,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(); @@ -5958,7 +6090,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(); @@ -5972,7 +6104,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(); @@ -5988,7 +6120,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(); @@ -6002,7 +6134,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(); @@ -6016,12 +6148,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()); } @@ -6143,7 +6275,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(); @@ -6151,7 +6283,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}" ); } @@ -6246,6 +6378,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 @@ -6435,7 +6584,7 @@ mod tests { "containment": "isolation_session", "experimental": { "isolation_session": {}, - "wslc": {"image": "alpine:latest"} + "windows_sandbox": {"idleTimeoutMs": 1000} } }"#; let encoded = base64_encode(json.as_bytes()); @@ -6448,7 +6597,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 57d8e0a75..f9eefe7e4 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 @@ -927,8 +927,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, } @@ -964,6 +962,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 03c4db5b8..24c49174b 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. @@ -242,11 +243,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 0d66b1519..ff4feea26 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())); @@ -817,6 +836,7 @@ mod tests { sandbox_id: sandbox_id.map(String::from), correlation_vector: None, experimental_raw: exp, + stable_raw: None, source_text: None, } } @@ -1065,6 +1085,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(); @@ -1080,6 +1101,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(); @@ -1095,6 +1117,7 @@ mod tests { sandbox_id: Some("iso:wxc-abcd1234".into()), correlation_vector: None, experimental_raw: None, + stable_raw: None, source_text: None, }; assert_eq!( @@ -1112,6 +1135,7 @@ mod tests { sandbox_id: Some("wsb:deadbeef".into()), correlation_vector: None, experimental_raw: None, + stable_raw: None, source_text: None, }; assert_eq!( @@ -1129,6 +1153,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); @@ -1143,6 +1168,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(); @@ -1158,6 +1184,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..05d500de2 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,9 @@ 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). Promoted to the + /// top-level `wslc` section; still parsed here so the parser can reject it + /// with a migration message instead of silently ignoring it. pub wslc: Option, /// IsolationSession backend config (Windows). pub isolation_session: Option, @@ -628,7 +634,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 +654,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 +674,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 +683,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/tests/configs/wslc_custom_registry.json b/tests/configs/wslc_custom_registry.json index ab954e78a..2bd0d7b9c 100644 --- a/tests/configs/wslc_custom_registry.json +++ b/tests/configs/wslc_custom_registry.json @@ -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..8206e38f0 100644 --- a/tests/configs/wslc_custom_registry_ghcr.json +++ b/tests/configs/wslc_custom_registry_ghcr.json @@ -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..ce85df6b9 100644 --- a/tests/configs/wslc_custom_registry_quay.json +++ b/tests/configs/wslc_custom_registry_quay.json @@ -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..70ebb7e0b 100644 --- a/tests/configs/wslc_denied_dotdot_alias.json +++ b/tests/configs/wslc_denied_dotdot_alias.json @@ -6,15 +6,17 @@ "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..689462bfc 100644 --- a/tests/configs/wslc_denied_masking.json +++ b/tests/configs/wslc_denied_masking.json @@ -6,15 +6,18 @@ "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..05f2ec153 100644 --- a/tests/configs/wslc_destroy_on_exit_false_rejected.json +++ b/tests/configs/wslc_destroy_on_exit_false_rejected.json @@ -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..e2ce49457 100644 --- a/tests/configs/wslc_destroy_on_exit_true.json +++ b/tests/configs/wslc_destroy_on_exit_true.json @@ -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..741150c03 100644 --- a/tests/configs/wslc_env_vars.json +++ b/tests/configs/wslc_env_vars.json @@ -4,14 +4,15 @@ "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..beb59200b 100644 --- a/tests/configs/wslc_exit_code.json +++ b/tests/configs/wslc_exit_code.json @@ -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..02573933e 100644 --- a/tests/configs/wslc_filesystem.json +++ b/tests/configs/wslc_filesystem.json @@ -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..8fcd23d1d 100644 --- a/tests/configs/wslc_filesystem_object.json +++ b/tests/configs/wslc_filesystem_object.json @@ -6,15 +6,17 @@ "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..39c9afede 100644 --- a/tests/configs/wslc_large_output.json +++ b/tests/configs/wslc_large_output.json @@ -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..3e6bf0399 100644 --- a/tests/configs/wslc_most_specific_denied_parent.json +++ b/tests/configs/wslc_most_specific_denied_parent.json @@ -6,15 +6,17 @@ "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..33a6cbc6d 100644 --- a/tests/configs/wslc_network_isolated.json +++ b/tests/configs/wslc_network_isolated.json @@ -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..22eef7721 100644 --- a/tests/configs/wslc_network_proxy.json +++ b/tests/configs/wslc_network_proxy.json @@ -4,15 +4,22 @@ "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..c49e88c99 100644 --- a/tests/configs/wslc_port_mapping_multiple.json +++ b/tests/configs/wslc_port_mapping_multiple.json @@ -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..2424c72ee 100644 --- a/tests/configs/wslc_port_mapping_tcp.json +++ b/tests/configs/wslc_port_mapping_tcp.json @@ -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..d1cf24803 100644 --- a/tests/configs/wslc_python_hello.json +++ b/tests/configs/wslc_python_hello.json @@ -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..db958ef40 100644 --- a/tests/configs/wslc_python_stdlib.json +++ b/tests/configs/wslc_python_stdlib.json @@ -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..d8b98a936 100644 --- a/tests/configs/wslc_readonly_mount.json +++ b/tests/configs/wslc_readonly_mount.json @@ -6,14 +6,14 @@ "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_provision.json b/tests/configs/wslc_state_aware_provision.json index 475e3dda9..07fddf6e8 100644 --- a/tests/configs/wslc_state_aware_provision.json +++ b/tests/configs/wslc_state_aware_provision.json @@ -5,11 +5,9 @@ "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..c258ae4b9 100644 --- a/tests/configs/wslc_state_aware_provision_bridged.json +++ b/tests/configs/wslc_state_aware_provision_bridged.json @@ -5,11 +5,9 @@ "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..4a2b91e8b 100644 --- a/tests/configs/wslc_state_aware_provision_rejected_denied.json +++ b/tests/configs/wslc_state_aware_provision_rejected_denied.json @@ -3,17 +3,19 @@ "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..8ae64ae54 100644 --- a/tests/configs/wslc_state_aware_provision_rejected_hosts.json +++ b/tests/configs/wslc_state_aware_provision_rejected_hosts.json @@ -4,13 +4,13 @@ "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..72e88c8d0 100644 --- a/tests/configs/wslc_state_aware_provision_rejected_proxy.json +++ b/tests/configs/wslc_state_aware_provision_rejected_proxy.json @@ -4,13 +4,13 @@ "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..e3ea54744 100644 --- a/tests/configs/wslc_state_aware_provision_with_filesystem.json +++ b/tests/configs/wslc_state_aware_provision_with_filesystem.json @@ -3,17 +3,19 @@ "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_stderr.json b/tests/configs/wslc_stderr.json index 636679074..8fc262a88 100644 --- a/tests/configs/wslc_stderr.json +++ b/tests/configs/wslc_stderr.json @@ -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..5c29aee6e 100644 --- a/tests/configs/wslc_tar_import_docker_save.json +++ b/tests/configs/wslc_tar_import_docker_save.json @@ -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..366133c7b 100644 --- a/tests/configs/wslc_tar_import_rootfs.json +++ b/tests/configs/wslc_tar_import_rootfs.json @@ -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..994fef240 100644 --- a/tests/configs/wslc_timeout.json +++ b/tests/configs/wslc_timeout.json @@ -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..f37913798 100644 --- a/tests/examples/wslc_hello_world.json +++ b/tests/examples/wslc_hello_world.json @@ -8,9 +8,7 @@ "network": { "defaultPolicy": "block" }, - "experimental": { - "wslc": { - "image": "alpine:latest" - } + "wslc": { + "image": "alpine:latest" } } 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) From fa7c71a69afa178b2124db7efee17b54ac2589d8 Mon Sep 17 00:00:00 2001 From: Soham Das Date: Fri, 28 Aug 2026 12:45:34 -0700 Subject: [PATCH 4/6] [WSLC] Finish the docs sweep for the experimental -> stable promotion The promotion updated the WSLc-specific guides and SDK READMEs but left four documents still describing WSLc as experimental. Two of them are actively wrong rather than merely stale: - README.md listed `wslc` among the "Experimental backends" that require `{ experimental: true }` or `--experimental`. This is the repo's front page, and the claim is now false. - docs/linux-wsl-roadmap-june-2026.md carried a copy-pasteable JSON example nesting `wslc` under `experimental` -- the exact shape the parser now rejects with the migration error. Its "Notes" section also still listed WSLC alongside Bubblewrap as awaiting promotion. The remaining two were internally contradictory after the promotion: - docs/wsl/wsl-container-support-plan.md had its preamble rewritten to say WSLc is stable while three JSON examples, the dispatch snippet, the architecture diagram, and a runnable CLI command all still showed the experimental gate. (The mixed top-level/nested examples predate this branch; this commit makes the whole document consistent.) - sdk/dotnet/README.md described `WslcContainment` as selecting "the experimental WSLC backend" and stated `Experimental` is required. The sample also set `Experimental = true`, which is now dead weight -- `resolve_runner` gates only MicroVm, WindowsSandbox, IsolationSession, and Hyperlight, and `require_experimental_optin` gates only WindowsSandbox and IsolationSession. Docs only; no behavior change. All 14 edited JSON blocks re-verified as parseable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9f02fa89-c89e-4a5d-b80d-5130252d3654 --- README.md | 2 +- docs/linux-wsl-roadmap-june-2026.md | 14 ++++---- docs/wsl/wsl-container-support-plan.md | 46 ++++++++++---------------- sdk/dotnet/README.md | 7 ++-- 4 files changed, 27 insertions(+), 42 deletions(-) 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/wsl/wsl-container-support-plan.md b/docs/wsl/wsl-container-support-plan.md index 856f2a94d..5fb82ae0f 100644 --- a/docs/wsl/wsl-container-support-plan.md +++ b/docs/wsl/wsl-container-support-plan.md @@ -26,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) @@ -86,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)), }; ``` @@ -399,10 +393,8 @@ from the config: "containment": "wslc", "process": { "commandLine": "echo hello" }, "network": { "defaultPolicy": "block" }, - "experimental": { - "wslc": { - "image": "alpine:latest" - } + "wslc": { + "image": "alpine:latest" } } ``` @@ -423,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" } } ``` @@ -457,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" } } ``` @@ -551,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 diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index f1de7106b..813444abf 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,8 +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 +store wins over the tar when both identify an already-cached image. WSLC is a +stable backend and needs no `Experimental` opt-in; the native unit must be built with WSLC support or execution returns `BackendUnavailable`. ### Network proxy From 17d27c2f79623916a79663b3bc7f74a0f6058c3d Mon Sep 17 00:00:00 2001 From: Soham Das Date: Fri, 28 Aug 2026 13:11:59 -0700 Subject: [PATCH 5/6] [WSLC] Move the C# state-aware envelope onto the stable section The promotion left three consumers still speaking the pre-promotion wire shape, which surfaced as four CI failures across two root causes. `MxcLifecycle.SetBackendConfig` nested every backend's per-phase config under `experimental..` unconditionally. Now that the parser rejects `experimental.wslc` outright, that produced a `malformed_request` before backend resolution could report anything useful. Give the C# SDK the same section-root switch the TypeScript SDK already carries in `BACKEND_SECTION_ROOT`, so a promoted backend writes a closed top-level `wslc.` section while the still-experimental backends keep their `experimental` nesting. The switch is exhaustive over the known backends and throws on an undeclared one, so adding a backend without choosing a root fails loudly instead of silently defaulting. That single emitter fix clears three of the four failures: the .NET `WslcBuildSwitch_MatchesNativeAvailabilityAndStagesRuntimeUnit` test on all three platforms, and the Rust `managed_state_aware_goldens_are_accepted_by_native_contract` test in `mxc_ffi`, which feeds the C# golden fixtures back through the native contract. The golden `state-aware-wslc-provision.json` and the `BuildProvisionEnvelope_WslcUsesV08AndNestsImageOptions` assertion move with it; the latter now also asserts that no `experimental` section is emitted at all, so a regression cannot pass by nesting the section twice. Separately, `check-dotnet-api-parity.js` compared the managed `StateAwareContainment` enum against the `require_experimental_optin` list in `state_aware.rs`. That list is the *experimental subset*, not the set of state-aware backends; the two were only coincidentally equal before this promotion and diverge the moment a backend is promoted. Compare against `backend_from_prefix` in `state_aware_dispatch.rs`, which is the actual registry of reachable state-aware backends. This also strengthens the check, since it now tracks the dispatcher rather than a gate that shrinks over time, and it drops a now-redundant second read of `state_aware_dispatch.rs`. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9f02fa89-c89e-4a5d-b80d-5130252d3654 --- scripts/check-dotnet-api-parity.js | 51 ++++++++----------- .../MxcLifecycleTests.cs | 9 +++- sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs | 38 ++++++++++++-- tests/policy/state-aware-wslc-provision.json | 10 ++-- 4 files changed, 65 insertions(+), 43 deletions(-) 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/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcLifecycleTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcLifecycleTests.cs index e79f5dd71..5d4a8c19c 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcLifecycleTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcLifecycleTests.cs @@ -424,8 +424,13 @@ public void BuildProvisionEnvelope_WslcUsesV08AndNestsImageOptions() Assert.Equal( "allow", root.GetProperty("network").GetProperty("defaultPolicy").GetString()); - var provision = root.GetProperty("experimental") - .GetProperty("wslc") + // WSLc is promoted to the stable surface, so its phase section is a + // closed top-level `wslc.` object rather than nested under + // `experimental`. The parser rejects the old shape outright. + 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( 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/tests/policy/state-aware-wslc-provision.json b/tests/policy/state-aware-wslc-provision.json index 6cef06dc3..21990d870 100644 --- a/tests/policy/state-aware-wslc-provision.json +++ b/tests/policy/state-aware-wslc-provision.json @@ -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" } } } From d784cc468382b900cdf251730d3b53746d1f1386 Mon Sep 17 00:00:00 2001 From: Soham Das Date: Fri, 4 Sep 2026 10:53:09 -0700 Subject: [PATCH 6/6] Addressed PR comments Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c28d1c5e-fe3e-4634-9dd7-246528cc9edd --- .github/copilot-instructions.md | 2 +- docs/schema.md | 4 +- .../mxc-state-aware-sandbox-api.md | 7 +- docs/wsl/wsl-container-getting-started.md | 21 ++- docs/wsl/wsl-container-support-plan.md | 6 +- docs/wsl/wslc-state-aware.md | 12 +- .../dev/mxc-config.schema.0.9.0-alpha.json | 2 +- schemas/dev/mxc-config.schema.0.9.0-dev.json | 2 +- schemas/schema-version.json | 2 +- .../MxcLifecycleTests.cs | 9 +- .../MxcSandboxTests.cs | 2 +- .../Microsoft.Mxc.Sdk/SchemaVersions.cs | 2 +- sdk/dotnet/README.md | 9 +- sdk/node/README.md | 12 +- sdk/node/src/generated/v0_9_0_alpha/wire.ts | 2 +- sdk/node/src/generated/wire.ts | 2 +- sdk/node/src/sandbox.ts | 16 ++- sdk/node/src/state-aware-helper.ts | 9 +- sdk/node/src/state-aware-types.ts | 2 +- sdk/node/tests/unit/platform.test.ts | 73 +++++++++++ sdk/node/tests/unit/sandbox.test.ts | 47 +++---- sdk/node/tests/unit/state-aware.test.ts | 6 +- src/backends/appcontainer/common/src/probe.rs | 4 - src/backends/wslc/common/src/policy.rs | 120 +++++------------- src/backends/wslc/common/src/sandbox.rs | 13 +- src/backends/wslc/common/src/state_aware.rs | 25 ++-- .../wslc/common/src/wsl_container_runner.rs | 88 +++---------- src/core/mxc-sdk/README.md | 22 ++-- src/core/mxc-sdk/src/lib.rs | 6 +- .../src/dev/state_aware/provision/wslc.rs | 2 +- src/core/mxc_engine/src/policy.rs | 22 +--- src/core/mxc_engine/src/state_aware.rs | 6 +- src/core/wxc/src/main.rs | 7 +- src/core/wxc_common/src/config_parser.rs | 100 +++++++++++++-- src/core/wxc_common/src/models.rs | 14 +- src/core/wxc_common/src/wire.rs | 4 +- src/ffi/mxc_ffi/src/state_aware.rs | 2 +- tests/configs/wslc_custom_registry.json | 2 +- tests/configs/wslc_custom_registry_ghcr.json | 2 +- tests/configs/wslc_custom_registry_quay.json | 2 +- tests/configs/wslc_denied_dotdot_alias.json | 2 +- tests/configs/wslc_denied_masking.json | 2 +- .../wslc_destroy_on_exit_false_rejected.json | 2 +- tests/configs/wslc_destroy_on_exit_true.json | 2 +- tests/configs/wslc_env_vars.json | 2 +- tests/configs/wslc_exit_code.json | 2 +- tests/configs/wslc_filesystem.json | 2 +- tests/configs/wslc_filesystem_object.json | 2 +- tests/configs/wslc_large_output.json | 2 +- .../wslc_most_specific_denied_parent.json | 2 +- tests/configs/wslc_network_isolated.json | 2 +- tests/configs/wslc_network_proxy.json | 2 +- tests/configs/wslc_port_mapping_multiple.json | 2 +- tests/configs/wslc_port_mapping_tcp.json | 2 +- tests/configs/wslc_python_hello.json | 2 +- tests/configs/wslc_python_stdlib.json | 2 +- tests/configs/wslc_readonly_mount.json | 2 +- .../configs/wslc_state_aware_deprovision.json | 2 +- .../configs/wslc_state_aware_exec_basic.json | 2 +- tests/configs/wslc_state_aware_exec_drip.json | 2 +- tests/configs/wslc_state_aware_exec_env.json | 2 +- .../configs/wslc_state_aware_exec_exit_0.json | 2 +- .../configs/wslc_state_aware_exec_exit_1.json | 2 +- .../configs/wslc_state_aware_exec_exit_7.json | 2 +- .../configs/wslc_state_aware_exec_proxy.json | 2 +- .../wslc_state_aware_exec_read_marker.json | 2 +- ..._state_aware_exec_rejected_filesystem.json | 2 +- .../wslc_state_aware_exec_write_marker.json | 2 +- tests/configs/wslc_state_aware_provision.json | 2 +- .../wslc_state_aware_provision_bridged.json | 2 +- ...state_aware_provision_rejected_denied.json | 2 +- ..._state_aware_provision_rejected_hosts.json | 2 +- ..._state_aware_provision_rejected_proxy.json | 2 +- ...state_aware_provision_with_filesystem.json | 2 +- tests/configs/wslc_state_aware_start.json | 2 +- tests/configs/wslc_state_aware_stop.json | 2 +- tests/configs/wslc_stderr.json | 2 +- .../configs/wslc_tar_import_docker_save.json | 2 +- tests/configs/wslc_tar_import_rootfs.json | 2 +- tests/configs/wslc_timeout.json | 2 +- tests/examples/wslc_hello_world.json | 2 +- tests/policy/request-wslc.json | 2 +- tests/policy/state-aware-wslc-exec.json | 2 +- tests/policy/state-aware-wslc-provision.json | 2 +- 84 files changed, 390 insertions(+), 388 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ce4b06a34..0eca3a87c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -187,7 +187,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`, 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 a WSLc container runs Linux while `ui` maps to Windows `JOB_OBJECT_UILIMIT_*` — 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`. | +| 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`). Because Seatbelt has no independent host-loopback posture, `validate()` rejects `network.ingress.hostLoopback` values that diverge from `network.ingress.default`; for the legacy shape, `config_parser.rs` separately rejects `network.proxy` combined with `defaultPolicy='allow'` (a proxy adds no enforcement when outbound is already unrestricted). 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`. | diff --git a/docs/schema.md b/docs/schema.md index 11e0fca3f..a47f39a9d 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -311,8 +311,8 @@ omitted one without applying any UI restriction — so the section's default-den reading does not hold on either. The reasons differ: no `ui` posture is truthful for a session-isolated sandbox (see [`isolation-session/state-aware-rust.md`](isolation-session/state-aware-rust.md)), -while a WSLc container runs Linux and has no analogue of the Windows job-object -limits `ui` maps to (see [`wsl/wslc-state-aware.md`](wsl/wslc-state-aware.md)). +while WSLc has no mechanism to enforce UI restrictions on a container (see +[`wsl/wslc-state-aware.md`](wsl/wslc-state-aware.md)). The Windows `processContainer.ui` sub-block carries additional ProcessContainer-only fields (`isolation`, `desktopSystemControl`, `systemSettings`, `ime`) and is valid only 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 3aa8f564f..b4b97417d 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., the top-level `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 e61e9b6ac..131636d95 100644 --- a/docs/wsl/wsl-container-getting-started.md +++ b/docs/wsl/wsl-container-getting-started.md @@ -147,7 +147,7 @@ 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 }, }; @@ -329,7 +329,7 @@ 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": { @@ -370,12 +370,12 @@ full network cutoff and `"allow"` is full outbound (NAT). ### `enforcementMode` must be `capabilities` -For the same reason, `network.enforcementMode: "firewall"` (or `"both"`) is -**rejected**: both ask for per-rule firewall enforcement inside a container that -has no `CAP_NET_ADMIN` to apply it with. The default `"capabilities"` is -accepted — it is an honest description of WSLC's all-or-nothing network, so an -explicitly supplied `"capabilities"` is accepted rather than refused merely for -being present. +`network.enforcementMode: "firewall"` (or `"both"`) is **rejected** for the same +reason as per-host filtering: both ask for per-rule firewall enforcement inside a +container that has no `CAP_NET_ADMIN` to apply it with. The default +`"capabilities"` is accepted — it is an honest description of WSLC's +all-or-nothing network, so an explicitly supplied `"capabilities"` is accepted +rather than refused merely for being present. ### Inbound: `allowLocalNetwork` is not supported @@ -408,9 +408,8 @@ the container. ### `ui` is not supported -A `ui` section is **rejected**. The section maps to Windows job-object UI limits -(`JOB_OBJECT_UILIMIT_*`); a WSLC container runs Linux inside the WSL2 VM, so -those limits have no analogue and nothing in the backend could apply them. +A `ui` section is **rejected** — the backend has no mechanism to enforce UI +restrictions on a container. The check is on **presence, not value**. `ui`'s defaults are full lockdown, so an explicitly supplied lockdown `ui` is indistinguishable *by value* from an diff --git a/docs/wsl/wsl-container-support-plan.md b/docs/wsl/wsl-container-support-plan.md index 5fb82ae0f..135b6e19c 100644 --- a/docs/wsl/wsl-container-support-plan.md +++ b/docs/wsl/wsl-container-support-plan.md @@ -144,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` --- @@ -337,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`. --- @@ -573,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 965a6a1b9..8dc795c9a 100644 --- a/docs/wsl/wslc-state-aware.md +++ b/docs/wsl/wslc-state-aware.md @@ -93,12 +93,12 @@ rules do not apply — see the empirical finding in the plan history). Proxy is | `process.timeout` | n/a | n/a | honored → `ExecConfig.timeout_ms` | | `lifecycle` | rejected (whole section, at parse) | rejected | rejected | -`ui` is rejected by **presence, not value**, on every phase. A WSLc container runs Linux, so the -section's Windows job-object UI limits (`JOB_OBJECT_UILIMIT_*`) have no analogue inside it and no -phase could honor it. Presence is the only workable test because `UiPolicy`'s defaults are full -lockdown — an explicitly supplied lockdown `ui` is indistinguishable *by value* from an absent one, -so a value-based check would let the single most restrictive request a caller can write through -unenforced. The parse-derived `ContainerPolicy::ui_specified` flag is what closes that gap. +`ui` is rejected by **presence, not value**, on every phase. WSLc has no mechanism to enforce UI +restrictions on a container, so no phase could honor it. Presence is the only workable test because +`UiPolicy`'s defaults are full lockdown — an explicitly supplied lockdown `ui` is indistinguishable +*by value* from an absent one, so a value-based check would let the single most restrictive request +a caller can write through unenforced. The parse-derived `ContainerPolicy::ui_specified` flag is +what closes that gap. Every rejection above **aborts the phase before anything is created**: the dispatcher runs each `validate_*` hook ahead of the phase body, and `connect_daemon()` lives inside `provision()`, so a 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 4bcb48756..9b5d762a1 100644 --- a/schemas/dev/mxc-config.schema.0.9.0-alpha.json +++ b/schemas/dev/mxc-config.schema.0.9.0-alpha.json @@ -1300,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", 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 a0fe332ab..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 (pre-promotion alias). Promoted to the top-level `wslc` section; still parsed here so the parser can reject it with a migration message instead of silently ignoring it." + "description": "WSL container backend config (pre-promotion alias)." } }, "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/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcLifecycleTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcLifecycleTests.cs index 5d4a8c19c..f257a501b 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcLifecycleTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcLifecycleTests.cs @@ -419,14 +419,11 @@ 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()); - // WSLc is promoted to the stable surface, so its phase section is a - // closed top-level `wslc.` object rather than nested under - // `experimental`. The parser rejects the old shape outright. Assert.False( root.TryGetProperty("experimental", out _), "a promoted backend must not emit an `experimental` section"); @@ -484,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()); @@ -536,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 00e30f9b1..33610f752 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/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 813444abf..3a122d8e5 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -273,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 a -stable backend and needs no `Experimental` opt-in; the native unit must 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 @@ -537,7 +536,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 @@ -626,7 +625,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 f18f56147..306e25df7 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -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`, `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)) | +| 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`, `seatbelt`, and `wslc` backends work out of the box. **Experimental backends** (`windows_sandbox`, `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. +`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. **Node.js:** ≥ 18. @@ -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`, `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; `isolation_session` and `windows_sandbox` are still experimental, so calls for those must pass `{ experimental: true }` — `wslc` is promoted and needs no opt-in). 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; when the failure came from an underlying platform API, the error also carries discrete diagnostic fields rather than a prose blob: 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 3dcf71810..c173664a9 100644 --- a/sdk/node/src/generated/v0_9_0_alpha/wire.ts +++ b/sdk/node/src/generated/v0_9_0_alpha/wire.ts @@ -722,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 { /** diff --git a/sdk/node/src/generated/wire.ts b/sdk/node/src/generated/wire.ts index 23e99c2d2..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 (pre-promotion alias). Promoted to the top-level `wslc` section; still parsed here so the parser can reject it with a migration message instead of silently ignoring it. + * WSL container backend config (pre-promotion alias). */ wslc?: Wslc | null; [k: string]: unknown; diff --git a/sdk/node/src/sandbox.ts b/sdk/node/src/sandbox.ts index 5854a075e..db18cc295 100644 --- a/sdk/node/src/sandbox.ts +++ b/sdk/node/src/sandbox.ts @@ -313,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 ec4f798fa..9f0808faa 100644 --- a/sdk/node/src/state-aware-helper.ts +++ b/sdk/node/src/state-aware-helper.ts @@ -10,12 +10,11 @@ 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 diff --git a/sdk/node/src/state-aware-types.ts b/sdk/node/src/state-aware-types.ts index 21d7d50cd..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 diff --git a/sdk/node/tests/unit/platform.test.ts b/sdk/node/tests/unit/platform.test.ts index 194641342..cf62395cf 100644 --- a/sdk/node/tests/unit/platform.test.ts +++ b/sdk/node/tests/unit/platform.test.ts @@ -388,6 +388,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')); + }); +}); + // The Bubblewrap probe gates on version, not just presence: `--clearenv` // (emitted unconditionally by the Rust argument builder) only exists in // bwrap 0.5.0+. Mirrors the Rust tests in diff --git a/sdk/node/tests/unit/sandbox.test.ts b/sdk/node/tests/unit/sandbox.test.ts index fd0645860..bb2d9fb41 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 { ContainerConfig, SandboxPolicy, SandboxingMethod } from '../../src/types.js'; import { platformSkip } from './test-helpers.js'; @@ -476,15 +476,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', @@ -1099,6 +1101,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', @@ -1125,18 +1140,6 @@ describe('createConfigFromPolicy', () => { const config = createConfigFromPolicy({ version: '0.6.0-alpha' }, 'wslc', 'my-container'); assert.strictEqual(config.containerId, 'my-container'); }); - - it('should no longer require experimental mode for a wslc config', () => { - const config = createConfigFromPolicy({ version: '0.6.0-alpha' }, 'wslc'); - config.process!.commandLine = 'echo hello'; - // WSLc is promoted, so the experimental gate no longer fires. A host - // without the WSLc runtime still rejects the request, but on - // availability — never on `--experimental`. - assert.throws( - () => spawnSandboxFromConfig(config), - (e: Error) => !/experimental mode/.test(e.message), - ); - }); }); describe('Bubblewrap', () => { @@ -1283,13 +1286,11 @@ describe('resolveExecutableAndArgs (containment validation)', { skip: platformSk }); it('should NOT require experimental mode for explicit wslc containment', () => { - // WSLc is promoted, so the experimental gate no longer fires for it. On a - // host without the WSLc runtime the availability check still rejects the - // request — that is a different error, and the point of this test. - assert.throws( - () => resolveExecutableAndArgs(makeConfig('wslc'), { executablePath: fakeExe }), - (e: Error) => !/experimental mode/.test(e.message), - ); + 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 }) { diff --git a/sdk/node/tests/unit/state-aware.test.ts b/sdk/node/tests/unit/state-aware.test.ts index 668ed0780..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', () => { @@ -637,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/src/backends/appcontainer/common/src/probe.rs b/src/backends/appcontainer/common/src/probe.rs index 1d8d184e8..07271063f 100644 --- a/src/backends/appcontainer/common/src/probe.rs +++ b/src/backends/appcontainer/common/src/probe.rs @@ -348,10 +348,6 @@ mod tests { #[test] fn probe_always_emits_wslc_available() { - // Twin of the isolation-session gate above: WSLc is a promoted - // (non-experimental) backend, so the SDK's availability check is the - // only thing standing between a caller and a `wslc` containment - // request. The field must always serialize, even when false. 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"); diff --git a/src/backends/wslc/common/src/policy.rs b/src/backends/wslc/common/src/policy.rs index edfc9c52e..0fc756bd7 100644 --- a/src/backends/wslc/common/src/policy.rs +++ b/src/backends/wslc/common/src/policy.rs @@ -24,21 +24,11 @@ //! mounted `rw`/`ro` parent is rejected, because WSLc has no overlay primitive //! to mask a subtree of a mounted volume. //! -//! # Rejection ordering -//! -//! Checks run filesystem → ui → network so a request that trips several gets a -//! stable, most-structural-first message rather than one that depends on field -//! order. The precedence is asserted by tests, not just documented. -//! -//! # Shared with the one-shot surface -//! -//! [`reject_ui_policy`] and [`reject_unsupported_enforcement_mode`] describe the -//! backend itself, not a phase, so `WSLContainerRunner::validate_runner` (which -//! serves both the run-to-completion `ScriptRunner` and the streaming -//! `SandboxBackend`) calls them too, retagging the message as a -//! [`WslcError::Rejected`](crate::error::WslcError::Rejected). Every one of -//! those call sites runs *before* any container is created, so a rejection -//! always aborts rather than leaving a live container behind. +//! Checks run filesystem → ui → network, so a request that trips several gets a +//! stable message rather than one that depends on field order (asserted by +//! tests). [`reject_ui_policy`] and [`reject_unsupported_enforcement_mode`] +//! describe the backend rather than a phase, so the one-shot `validate_runner` +//! calls them too. use wxc_common::models::{ExecutionRequest, NetworkEnforcementMode}; use wxc_common::mxc_error::MxcError; @@ -59,30 +49,28 @@ const ERR_PROXY_AT_PHASE: &str = "network.proxy is only honoured on the exec phase by the WSLc backend"; const ERR_PROXY_URL_FORM: &str = "WSLc: network.proxy requires the 'url' form (a routable proxy URL); the localhost and \ - builtinTestServer forms are not supported because a WSLc container runs in its own network \ + builtinTestServer forms are not supported because a WSL container runs in its own network \ namespace"; const ERR_UI_POLICY: &str = - "WSLc: the ui section is not supported. A WSLc container runs Linux, while `ui` maps to \ - Windows job-object UI restrictions (JOB_OBJECT_UILIMIT_*) that have no analogue inside it, \ - so no ui posture is truthful here. Omitting the ui section is accepted but applies no \ - restriction — it is not the lockdown the schema's default implies. Use a backend that \ - enforces UI policy if you need one"; + "WSLc: the ui section is not supported. The backend has no mechanism to enforce UI \ + restrictions on a container, so no ui posture is truthful here. Omitting the ui section is \ + accepted but applies no restriction — it is not the lockdown the schema's default implies. \ + Use a backend that enforces UI policy if you need one"; const ERR_ALLOW_LOCAL_NETWORK_STATE_AWARE: &str = "WSLc: network.allowLocalNetwork=true is not supported by the state-aware WSLc backend. The \ container's network is all-or-nothing (defaultPolicy 'block' → isolated, 'allow' → bridged \ NAT), and the state-aware provision phase has no port-mapping primitive to expose an \ inbound port"; const ERR_ENFORCEMENT_MODE: &str = - "WSLc: network.enforcementMode 'firewall' and 'both' are not supported. A WSLc container has \ + "WSLc: network.enforcementMode 'firewall' and 'both' are not supported. A WSL container has \ no CAP_NET_ADMIN for in-container firewall rules, and VM-level enforcement is not available \ without breaking other security guarantees (e.g. MDE). Remove the field or set it to \ 'capabilities' — WSLc's network is all-or-nothing at the container level"; /// Validate the request for the provision phase. `rw` / `ro` paths become /// volume mounts and `default_network_policy` selects the container network -/// mode; both are honoured here. Overlapping denied paths, a UI policy, host -/// filtering, inbound local networking, a non-default enforcement mode, and a -/// provision-phase proxy are rejected. +/// mode; both are honoured here. Everything else in the module table is +/// rejected. pub(crate) fn validate_provision_policy(request: &ExecutionRequest) -> Result<(), MxcError> { validate_denied_path_overlap( &request.policy.readwrite_paths, @@ -114,10 +102,9 @@ pub(crate) fn validate_post_provision_policy(request: &ExecutionRequest) -> Resu Ok(()) } -/// Validate the request for the exec phase. Filesystem and network mode are -/// fixed at provision (rejected here), a UI policy is never supported, and the -/// cooperative proxy is honoured and must be in `url` form so a routable value -/// reaches the container. +/// Validate the request for the exec phase. Filesystem, network mode and `ui` +/// are rejected; the cooperative proxy is honoured and must be in `url` form so +/// a routable value reaches the container. pub(crate) fn validate_exec_policy(request: &ExecutionRequest) -> Result<(), MxcError> { reject_filesystem_policy(request)?; reject_ui_policy(request)?; @@ -161,16 +148,7 @@ fn reject_host_filtering(request: &ExecutionRequest) -> Result<(), MxcError> { Ok(()) } -/// Reject any supplied UI policy. Presence-based, not value-based: the domain -/// `UiPolicy::default()` is full lockdown, so an explicitly-supplied lockdown -/// `ui` is indistinguishable from an absent one by value — the same blind spot -/// `network_specified` closes for the network policy. -/// -/// Shared by every WSLc phase and by the one-shot / streaming -/// `validate_runner`: the reason is the container's OS, not the lifecycle -/// phase, so there is no phase on either surface where a `ui` section could be -/// honoured. Runs after the filesystem check so a filesystem rejection keeps -/// precedence, and before the network checks. +/// Reject any supplied UI policy: WSLc cannot enforce UI restrictions. pub(crate) fn reject_ui_policy(request: &ExecutionRequest) -> Result<(), MxcError> { if request.policy.ui_specified { return Err(MxcError::policy_validation(ERR_UI_POLICY)); @@ -178,22 +156,8 @@ pub(crate) fn reject_ui_policy(request: &ExecutionRequest) -> Result<(), MxcErro Ok(()) } -/// Reject an enforcement mode WSLc cannot implement. -/// -/// Value-based, unlike [`reject_ui_policy`]: the default `capabilities` is an -/// honest description of what WSLc does (the container's network is -/// all-or-nothing, with nothing per-host to enforce), so an explicit -/// `capabilities` is accepted. `firewall` and `both` ask for per-rule -/// enforcement the container cannot perform — it has no `CAP_NET_ADMIN` — so -/// accepting either would assert a guarantee that does not exist. -/// -/// Called only by [`validate_provision_policy`] and the one-shot / streaming -/// `WSLContainerRunner::validate_runner` — the two places a network posture is -/// settable. Post-provision and exec deliberately do not call it: they reject -/// the whole network mode by presence via [`reject_post_provision_network_mode`] -/// (the parser sets `network_mode_specified` for `enforcementMode` too), which -/// is broader — routing them here would wrongly accept `capabilities` after -/// provision. +/// Reject an enforcement mode WSLc cannot implement. Value-based: an explicit +/// `capabilities` is accepted, since it describes what WSLc does. pub(crate) fn reject_unsupported_enforcement_mode( request: &ExecutionRequest, ) -> Result<(), MxcError> { @@ -205,12 +169,9 @@ pub(crate) fn reject_unsupported_enforcement_mode( } } -/// Reject inbound local networking at provision. The one-shot surface refuses -/// the same value but points at `wslc.portMappings`; the -/// state-aware provision phase has no such field, so its message must not offer -/// that escape hatch. Post-provision phases reject it by presence via -/// [`reject_post_provision_network_mode`] instead, since the posture is fixed -/// once the container exists. +/// Reject inbound local networking at provision. Separate from the one-shot +/// 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( @@ -220,12 +181,10 @@ fn reject_provision_allow_local_network(request: &ExecutionRequest) -> Result<() Ok(()) } -/// Reject any network *mode* field supplied after provision. The network -/// posture (`defaultPolicy` / `enforcementMode` / `allowLocalNetwork` / host -/// lists) is bound to the provision phase; presence — not value — is checked so -/// an explicit `defaultPolicy: "block"` (indistinguishable from an omitted -/// block by value) is rejected too. The cooperative proxy is a separate -/// exec-time concern handled by the callers. +/// Reject any network *mode* field supplied after provision: the posture is +/// bound to the provision phase. Presence, not value — an explicit +/// `defaultPolicy: "block"` is indistinguishable from an omitted one by value. +/// The cooperative proxy is a separate exec-time concern handled by the callers. fn reject_post_provision_network_mode(request: &ExecutionRequest) -> Result<(), MxcError> { if request.policy.network_mode_specified || request.policy.network_egress.is_some() @@ -433,10 +392,6 @@ mod tests { } // ---- ui (rejected on every phase) ---- - // - // A WSLc container runs Linux; `ui` maps to Windows job-object UI limits - // that have no analogue inside it. There is no phase where it could be - // honoured, so every validator refuses it. #[test] fn every_phase_rejects_supplied_ui() { @@ -454,11 +409,8 @@ mod tests { } } - /// Presence, not value. `UiPolicy::default()` is full lockdown, so an - /// explicitly-supplied lockdown `ui` is byte-identical to an absent one by - /// value — only `ui_specified` can tell them apart. Were this check - /// value-based, the most restrictive request a caller can write would be - /// the one that slipped through unenforced. + /// A value-based check would let the most restrictive request a caller can + /// write through unenforced, since that is also the default. #[test] fn provision_rejects_lockdown_equivalent_ui() { let req = request_with_policy(ContainerPolicy { @@ -496,9 +448,8 @@ mod tests { } /// Post-provision needs no dedicated `allowLocalNetwork` check: supplying - /// the field sets `network_mode_specified`, which the immutability check - /// already refuses. Pinned so the two rejections can't both be removed as - /// "redundant". + /// the field sets `network_mode_specified`, which immutability already + /// refuses. Pinned so both rejections can't be dropped as "redundant". #[test] fn post_provision_rejects_allow_local_network_as_a_mode_change() { let req = request_with_policy(ContainerPolicy { @@ -532,10 +483,8 @@ mod tests { } } - /// Value-based, unlike `ui`: `capabilities` is an honest description of - /// what WSLc does (an all-or-nothing container network with nothing - /// per-host to enforce), so an explicit `capabilities` is accepted rather - /// than refused for merely being present. + /// Guards against over-rejection: `capabilities` is honoured, so unlike + /// `ui` it must not be refused for merely being present. #[test] fn provision_accepts_explicit_capabilities_enforcement_mode() { let req = request_with_policy(ContainerPolicy { @@ -547,9 +496,8 @@ mod tests { // ---- rejection ordering ---- // - // A request that trips several checks must get a stable, most-structural- - // first message: filesystem -> ui -> network. Documented in the module - // header; pinned here so a reordering of the validator bodies is caught. + // filesystem -> ui -> network. Pinned so reordering the validator bodies is + // caught. #[test] fn filesystem_error_takes_precedence_over_ui() { diff --git a/src/backends/wslc/common/src/sandbox.rs b/src/backends/wslc/common/src/sandbox.rs index 3e5d3f021..85449def8 100644 --- a/src/backends/wslc/common/src/sandbox.rs +++ b/src/backends/wslc/common/src/sandbox.rs @@ -395,15 +395,12 @@ mod tests { use super::*; use crate::wsl_container_runner::START_CONTAINER_BANNER; - /// Rejections must abort, not tear down a container after building one. - /// - /// The message alone cannot prove that: on a host that *has* `wslcsdk.dll`, - /// a guard moved after `start_container` would leak a container and still - /// return this exact message. The banner is what makes the check - /// host-independent — `start_container` writes it as its first statement, - /// so an untouched buffer proves it was never entered. + /// The banner is what makes this host-independent: on a host that *has* + /// `wslcsdk.dll`, a guard moved after `start_container` would leak a + /// container and still return the same message. `start_container` writes the + /// banner first, so an untouched buffer proves it was never entered. #[test] - fn spawn_rejects_policy_before_touching_the_sdk() { + fn spawn_rejects_policy_before_touching_the_wslc_sdk() { let request = ExecutionRequest { containment: wxc_common::models::ContainmentBackend::Wslc, script_code: "echo hi".to_string(), diff --git a/src/backends/wslc/common/src/state_aware.rs b/src/backends/wslc/common/src/state_aware.rs index 1317c7ad2..ba0d83a5a 100644 --- a/src/backends/wslc/common/src/state_aware.rs +++ b/src/backends/wslc/common/src/state_aware.rs @@ -53,8 +53,6 @@ impl WslcStateAwareRunner { impl StatefulSandboxBackend for WslcStateAwareRunner { const ID_PREFIX: &'static str = "wslc"; const BACKEND_KEY: &'static str = "wslc"; - // WSLc is promoted to the stable surface: its per-phase config lives at the - // top-level `wslc` section, not under `experimental`. const SECTION_ROOT: SectionRoot = SectionRoot::Stable; type ProvisionConfig = WslcProvisionPhase; @@ -548,13 +546,8 @@ mod tests { assert!(runner.validate_deprovision(id, &request, None).is_err()); } - /// Every one of the five hooks must refuse a supplied `ui`. A WSLc - /// container runs Linux, so there is no phase where the section could be - /// honoured — and because the dispatcher calls these hooks *before* the - /// phase body, the refusal happens before the backend contacts the daemon. - /// - /// Enumerating all five (rather than testing the two shared validators) - /// is what catches a hook that forgets to call its validator at all. + /// Enumerating all five hooks (rather than testing the shared validator) is + /// what catches a hook that forgets to call its validator at all. #[test] fn every_validate_hook_rejects_supplied_ui() { let runner = WslcStateAwareRunner::new(); @@ -591,10 +584,9 @@ mod tests { } } - /// `allowLocalNetwork` and a `firewall` enforcement mode are refused at - /// provision, the only phase where the network posture is settable — so - /// neither can be silently dropped into the daemon's `ProvisionConfig`, - /// which carries only the binary [`NetworkMode`]. + /// Refused at provision, the only phase where the network posture is + /// settable — so neither can be silently dropped into the daemon's + /// `ProvisionConfig`, which carries only the binary [`NetworkMode`]. #[test] fn validate_provision_rejects_unimplementable_network_posture() { let runner = WslcStateAwareRunner::new(); @@ -629,10 +621,9 @@ mod tests { } } - /// Guards against over-rejection, which the refusal tests cannot see. Each - /// value is the near-miss of a rejected one — `capabilities` vs `firewall`, - /// `allowLocalNetwork: false` vs `true`, absent `ui` vs supplied — so a gate - /// that flipped between value- and presence-based would fail here only. + /// Guards against over-rejection. Each value is the near-miss of a rejected + /// one, so a gate that flipped between value- and presence-based would fail + /// here only. #[test] fn validate_provision_accepts_the_postures_wslc_can_honour() { let runner = WslcStateAwareRunner::new(); diff --git a/src/backends/wslc/common/src/wsl_container_runner.rs b/src/backends/wslc/common/src/wsl_container_runner.rs index 820d860db..758bedcf2 100644 --- a/src/backends/wslc/common/src/wsl_container_runner.rs +++ b/src/backends/wslc/common/src/wsl_container_runner.rs @@ -657,25 +657,14 @@ impl ScriptRunner for WSLContainerRunner { /// Mirrors the config parser so requests reaching the engine directly /// (an already-built `ExecutionRequest`, bypassing the parser) fail here /// instead of late in `execute` on the broken in-container iptables path. - /// - /// This is the single validation hook for **both** one-shot surfaces: - /// `ScriptRunner::run` calls it before `execute`, and - /// `SandboxBackend::spawn` calls it (via `SandboxBackend::validate`) before - /// `start_container`. Every rejection here therefore aborts the request - /// with no container, session, or WSL VM created. - /// - /// Checks run lifecycle → ui → network, so a request that trips several - /// gets a stable, most-structural-first message. fn validate_runner(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { reject_unsupported_lifecycle(request)?; - // Not phase-specific — a WSLc container runs Linux, so `ui` has no - // meaning on any surface. Shared with the state-aware phases. - policy::reject_ui_policy(request).map_err(as_rejection)?; + policy::reject_ui_policy(request).map_err(as_wslc_rejection)?; if request.policy.needs_host_filtering() { return Err(WslcError::Rejected( "WSLc: per-host egress filtering (allowedHosts with \ defaultPolicy='block', or blockedHosts with defaultPolicy='allow') \ - is not supported. A WSLc container has no CAP_NET_ADMIN for in-container \ + is not supported. A WSL container has no CAP_NET_ADMIN for in-container \ iptables, and VM-level enforcement is not available without breaking other \ security guarantees (e.g. MDE). Use network.proxy (defaultPolicy='allow') \ for cooperative host filtering, or remove the host lists." @@ -691,7 +680,7 @@ impl ScriptRunner for WSLContainerRunner { ) .into_response()); } - policy::reject_unsupported_enforcement_mode(request).map_err(as_rejection)?; + policy::reject_unsupported_enforcement_mode(request).map_err(as_wslc_rejection)?; // The shared validator returns an untagged response; retag it so its // rejections reach SDK callers as `policy_validation` like the checks above. validate_network_policy_support(request, NetworkPolicySupport::LEGACY) @@ -704,41 +693,23 @@ impl ScriptRunner for WSLContainerRunner { } } -/// Retag a shared [`policy`] rejection as a WSLc one, so it reaches SDK callers -/// as `policy_validation` (`FailurePhase::Rejected`) with the same message the -/// state-aware surface emits. -fn as_rejection(err: MxcError) -> ScriptResponse { +/// Retag a shared [`policy`] rejection so it reaches SDK callers as +/// `policy_validation` with the same message the state-aware surface emits. +fn as_wslc_rejection(err: MxcError) -> ScriptResponse { WslcError::Rejected(err.message).into_response() } -/// The first line [`WSLContainerRunner::start_container`] writes, before the -/// filesystem gate and any SDK call. Shared with the tests, which assert its -/// absence to prove a rejection aborted before any container work. +/// The first line [`WSLContainerRunner::start_container`] writes, before any +/// SDK call. Tests assert its absence to prove a rejection aborted early. pub(crate) const START_CONTAINER_BANNER: &str = "[WSLC] Starting WSL Container runner"; /// Refuses the `lifecycle` settings the one-shot surface cannot honour. /// -/// Value-based rather than presence-based (unlike `ui`), because the defaults -/// genuinely match the behaviour: -/// -/// * `destroyOnExit: true` (the default) is honoured — it selects -/// `WSLC_CONTAINER_FLAG_AUTO_REMOVE` and [`StartedContainer::destroy`] stops -/// and deletes the container. -/// * `destroyOnExit: false` is refused. [`StartedContainer`] owns the -/// [`WslcSessionGuard`], whose `Drop` terminates the session — and with it -/// the session-scoped container — regardless of the flag, and the WSLC SDK -/// has no cross-process re-attach. The outcome is identical to `true`, so -/// accepting `false` would promise a container that is already gone. -/// * `preservePolicy: true` asks for filesystem and network policy to outlive -/// the run. WSLc installs no persistent host-side enforcement: `rw`/`ro` -/// paths become container volume mounts and the network posture is a -/// container networking mode, both of which are properties of the container -/// object itself and cannot be retained independently of it. There is -/// nothing to preserve, so the request is refused rather than silently -/// dropped. -/// -/// The state-aware surface needs no counterpart: the parser rejects the whole -/// one-shot `lifecycle` section on state-aware requests. +/// Value-based, not presence-based: the default `destroyOnExit: true` is +/// honoured. `false` cannot be, because [`StartedContainer`] owns the +/// [`WslcSessionGuard`] whose `Drop` ends the session-scoped container either +/// way. The state-aware surface needs no counterpart — the parser rejects the +/// whole `lifecycle` section there. fn reject_unsupported_lifecycle(request: &ExecutionRequest) -> Result<(), ScriptResponse> { if !request.lifecycle.destroy_on_exit { return Err(WslcError::Rejected( @@ -1493,7 +1464,7 @@ impl WSLContainerRunner { return Err(WslcError::Rejected( "WSLC: network.proxy requires the 'url' form (a routable proxy URL); \ the localhost and builtinTestServer forms are not supported because a \ - WSLc container runs in its own network namespace." + WSL container runs in its own network namespace." .to_string(), ) .into_response()); @@ -2472,9 +2443,7 @@ mod tests { /// The two surfaces refuse `allowLocalNetwork` with deliberately different /// remedies: one-shot has `wslc.portMappings` to point at, /// state-aware has no port-mapping primitive at all. Unifying the messages - /// — the obvious tidy-up — would send state-aware users after a dead end. - /// Both must classify as `policy_validation`, which is the phase, not the - /// message, that `mxc_engine::dispatch::map_spawn_error` reads. + /// would send state-aware users after a dead end. #[test] fn both_surfaces_reject_allow_local_network_with_surface_specific_remedies() { let request = ExecutionRequest { @@ -2554,21 +2523,6 @@ mod tests { } } - // -- Accept-but-ignore closures -------------------------------------- - // - // Each of these fields used to be parsed, carried into the runner, and - // then never read — so a caller got a container that silently did not - // have the posture they asked for. They are now refused, and refused - // from `validate_runner`, which both `ScriptRunner::run` and - // `SandboxBackend::spawn` call *before* any container exists. - - /// A WSLc container runs Linux; `ui` maps to Windows job-object UI limits - /// (`JOB_OBJECT_UILIMIT_*`) with no analogue inside it. - /// - /// Presence-based: `UiPolicy::default()` is full lockdown, so an - /// explicitly-supplied lockdown `ui` is indistinguishable by value from an - /// absent one. A value-based check would let the single most restrictive - /// request a caller can write through unenforced. #[test] fn validate_runner_rejects_supplied_ui() { let request = ExecutionRequest { @@ -2658,11 +2612,6 @@ mod tests { } } - /// `firewall` / `both` ask for per-rule enforcement the container cannot - /// perform (no `CAP_NET_ADMIN`). Value-based, unlike `ui`: the default - /// `capabilities` honestly describes WSLc's all-or-nothing network, so an - /// explicit `capabilities` is accepted rather than refused for being - /// present. #[test] fn validate_runner_rejects_unimplementable_enforcement_modes() { let runner = WSLContainerRunner::new(&WslcConfig::default()); @@ -2700,11 +2649,8 @@ mod tests { assert!(runner.validate_runner(&request).is_ok()); } - /// The rejections must abort the request, not tear a container down after - /// building one. Both one-shot entry points route through - /// `validate_runner`, and both call it before any container exists — - /// `ScriptRunner::run` ahead of `execute`, and `SandboxBackend::spawn` - /// ahead of `start_container` (asserted in `sandbox.rs`). + /// A rejection must abort the request rather than tear a container down + /// after building one. `SandboxBackend::spawn`'s half is in `sandbox.rs`. #[test] fn validate_runner_rejects_before_any_container_work() { let request = ExecutionRequest { diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index 0f6d8882b..b50590ffa 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -321,10 +321,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. @@ -400,13 +401,12 @@ still reachable here through the state-aware lifecycle. ### WSLC WSLC runs a Linux container on a Windows host through the WSLC SDK. It is -opt-in on one axis only: build this crate with its **`wslc` feature**. The -backend is promoted, so it needs no `set_experimental(true)`. 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. +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::{ diff --git a/src/core/mxc-sdk/src/lib.rs b/src/core/mxc-sdk/src/lib.rs index 660236fb0..c7ea23174 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -51,9 +51,8 @@ //! | WSLC (WSL Container) | Windows | [`Containment::Wslc`] | //! //! WSLC is opt-in at **build** time only: compile this crate with its `wslc` -//! feature. It carries no `--experimental` gate — the section is part of the -//! stable config surface. Its container has no stdin (the WSLC SDK exposes no -//! process-input API), so [`Sandbox::take_stdin`] returns `None` for it. +//! 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 @@ -208,7 +207,6 @@ pub fn run(request: SandboxRequest) -> Result { /// `--experimental` flag. The experimental backends — WindowsSandbox and /// IsolationSession — are refused with /// [`ErrorCode::BackendUnavailable`] unless it is set, before any work is done. -/// WSLc is promoted and needs no opt-in. /// It is an API parameter rather than a field in the request JSON so that a /// config cannot grant itself experimental access. pub fn run_state_aware_json( 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 5c2d10bdc..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)] diff --git a/src/core/mxc_engine/src/policy.rs b/src/core/mxc_engine/src/policy.rs index 04b82ab47..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. /// @@ -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( @@ -1574,21 +1573,6 @@ mod tests { assert!(!config.gpu); } - #[test] - fn wslc_does_not_require_the_experimental_gate() { - // WSLc is promoted to the stable surface: selecting it must neither - // require nor silently flip the experimental gate. - 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 diff --git a/src/core/mxc_engine/src/state_aware.rs b/src/core/mxc_engine/src/state_aware.rs index 8b0b4a044..3afaa6d9a 100644 --- a/src/core/mxc_engine/src/state_aware.rs +++ b/src/core/mxc_engine/src/state_aware.rs @@ -322,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, diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index 088c5484d..798ad3c45 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -782,10 +782,9 @@ fn main() { output.probes.isolation_session_available = mxc_engine::isolation_session_available(); output }; - // Same story for WSLc: `appcontainer_common` cannot see that backend - // either. WSLc is promoted (non-experimental), so the SDK's - // availability check is the only gate on a `wslc` request — an - // un-overridden `false` here would make the backend unreachable. + // `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; diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 393c64d23..6fcbf99e7 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -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(()), }; @@ -1313,6 +1318,16 @@ fn convert_wire_config( // 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; @@ -1503,12 +1518,9 @@ fn convert_wire_state_aware( // deserialize above already proved `json` is a JSON object. let stable_raw = serde_json::from_str::(json).ok(); - // `wslc` is promoted to the stable surface but, unlike the other promoted - // sections, is not one-shot-only: the state-aware lifecycle reads its - // provision config from `wslc.provision`. So it is not a stray section — - // instead accept `provision` and reject the one-shot-only siblings, which - // the daemon-backed lifecycle does not honor, rather than silently - // dropping them. + // 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!( @@ -1516,6 +1528,13 @@ fn convert_wire_state_aware( 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"), @@ -5260,6 +5279,49 @@ mod tests { 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#"{ @@ -6025,6 +6087,20 @@ mod tests { 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", "wslc": {"image": "my-image:latest", "imageTarPath": "C:\\images\\alpine.tar"}}"#; diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs index f9eefe7e4..e918e650f 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -748,16 +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 by IsolationSession and WSLc today, neither of which has a - /// UI-restriction primitive: both refuse 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). diff --git a/src/core/wxc_common/src/wire.rs b/src/core/wxc_common/src/wire.rs index 05d500de2..e8685b2be 100644 --- a/src/core/wxc_common/src/wire.rs +++ b/src/core/wxc_common/src/wire.rs @@ -586,9 +586,7 @@ pub struct Experimental { pub test: Option, /// Windows Sandbox backend config. pub windows_sandbox: Option, - /// WSL container backend config (pre-promotion alias). Promoted to the - /// top-level `wslc` section; still parsed here so the parser can reject it - /// with a migration message instead of silently ignoring it. + /// WSL container backend config (pre-promotion alias). pub wslc: Option, /// IsolationSession backend config (Windows). pub isolation_session: Option, 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 2bd0d7b9c..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": { diff --git a/tests/configs/wslc_custom_registry_ghcr.json b/tests/configs/wslc_custom_registry_ghcr.json index 8206e38f0..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": { diff --git a/tests/configs/wslc_custom_registry_quay.json b/tests/configs/wslc_custom_registry_quay.json index ce85df6b9..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": { diff --git a/tests/configs/wslc_denied_dotdot_alias.json b/tests/configs/wslc_denied_dotdot_alias.json index 70ebb7e0b..3144ec025 100644 --- a/tests/configs/wslc_denied_dotdot_alias.json +++ b/tests/configs/wslc_denied_dotdot_alias.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-denied-dotdot-alias", "containment": "wslc", "process": { diff --git a/tests/configs/wslc_denied_masking.json b/tests/configs/wslc_denied_masking.json index 689462bfc..acc8e1c3a 100644 --- a/tests/configs/wslc_denied_masking.json +++ b/tests/configs/wslc_denied_masking.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-denied-masking", "containment": "wslc", "process": { diff --git a/tests/configs/wslc_destroy_on_exit_false_rejected.json b/tests/configs/wslc_destroy_on_exit_false_rejected.json index 05f2ec153..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": { diff --git a/tests/configs/wslc_destroy_on_exit_true.json b/tests/configs/wslc_destroy_on_exit_true.json index e2ce49457..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": { diff --git a/tests/configs/wslc_env_vars.json b/tests/configs/wslc_env_vars.json index 741150c03..d69f26059 100644 --- a/tests/configs/wslc_env_vars.json +++ b/tests/configs/wslc_env_vars.json @@ -1,5 +1,5 @@ { - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-env-vars", "containment": "wslc", "process": { diff --git a/tests/configs/wslc_exit_code.json b/tests/configs/wslc_exit_code.json index beb59200b..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": { diff --git a/tests/configs/wslc_filesystem.json b/tests/configs/wslc_filesystem.json index 02573933e..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": { diff --git a/tests/configs/wslc_filesystem_object.json b/tests/configs/wslc_filesystem_object.json index 8fcd23d1d..812fee0a1 100644 --- a/tests/configs/wslc_filesystem_object.json +++ b/tests/configs/wslc_filesystem_object.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-object-validation", "containment": "wslc", "process": { diff --git a/tests/configs/wslc_large_output.json b/tests/configs/wslc_large_output.json index 39c9afede..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": { diff --git a/tests/configs/wslc_most_specific_denied_parent.json b/tests/configs/wslc_most_specific_denied_parent.json index 3e6bf0399..ec37e4cbb 100644 --- a/tests/configs/wslc_most_specific_denied_parent.json +++ b/tests/configs/wslc_most_specific_denied_parent.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-most-specific-denied-parent", "containment": "wslc", "process": { diff --git a/tests/configs/wslc_network_isolated.json b/tests/configs/wslc_network_isolated.json index 33a6cbc6d..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": { diff --git a/tests/configs/wslc_network_proxy.json b/tests/configs/wslc_network_proxy.json index 22eef7721..6a9d5c44a 100644 --- a/tests/configs/wslc_network_proxy.json +++ b/tests/configs/wslc_network_proxy.json @@ -1,5 +1,5 @@ { - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-network-proxy", "containment": "wslc", "process": { diff --git a/tests/configs/wslc_port_mapping_multiple.json b/tests/configs/wslc_port_mapping_multiple.json index c49e88c99..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": { diff --git a/tests/configs/wslc_port_mapping_tcp.json b/tests/configs/wslc_port_mapping_tcp.json index 2424c72ee..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": { diff --git a/tests/configs/wslc_python_hello.json b/tests/configs/wslc_python_hello.json index d1cf24803..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": { diff --git a/tests/configs/wslc_python_stdlib.json b/tests/configs/wslc_python_stdlib.json index db958ef40..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": { diff --git a/tests/configs/wslc_readonly_mount.json b/tests/configs/wslc_readonly_mount.json index d8b98a936..f5a4f1069 100644 --- a/tests/configs/wslc_readonly_mount.json +++ b/tests/configs/wslc_readonly_mount.json @@ -1,5 +1,5 @@ { - "version": "0.6.0-alpha", + "version": "0.9.0-alpha", "containerId": "wslc-readonly-mount", "containment": "wslc", "process": { 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 07fddf6e8..d3f6f7c24 100644 --- a/tests/configs/wslc_state_aware_provision.json +++ b/tests/configs/wslc_state_aware_provision.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "provision", "containment": "wslc", "network": { diff --git a/tests/configs/wslc_state_aware_provision_bridged.json b/tests/configs/wslc_state_aware_provision_bridged.json index c258ae4b9..4e646d37f 100644 --- a/tests/configs/wslc_state_aware_provision_bridged.json +++ b/tests/configs/wslc_state_aware_provision_bridged.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "provision", "containment": "wslc", "network": { diff --git a/tests/configs/wslc_state_aware_provision_rejected_denied.json b/tests/configs/wslc_state_aware_provision_rejected_denied.json index 4a2b91e8b..a14ab0fec 100644 --- a/tests/configs/wslc_state_aware_provision_rejected_denied.json +++ b/tests/configs/wslc_state_aware_provision_rejected_denied.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "provision", "containment": "wslc", "filesystem": { diff --git a/tests/configs/wslc_state_aware_provision_rejected_hosts.json b/tests/configs/wslc_state_aware_provision_rejected_hosts.json index 8ae64ae54..9ea7e330e 100644 --- a/tests/configs/wslc_state_aware_provision_rejected_hosts.json +++ b/tests/configs/wslc_state_aware_provision_rejected_hosts.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "provision", "containment": "wslc", "network": { diff --git a/tests/configs/wslc_state_aware_provision_rejected_proxy.json b/tests/configs/wslc_state_aware_provision_rejected_proxy.json index 72e88c8d0..10b8d3e10 100644 --- a/tests/configs/wslc_state_aware_provision_rejected_proxy.json +++ b/tests/configs/wslc_state_aware_provision_rejected_proxy.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "provision", "containment": "wslc", "network": { diff --git a/tests/configs/wslc_state_aware_provision_with_filesystem.json b/tests/configs/wslc_state_aware_provision_with_filesystem.json index e3ea54744..ff82ececf 100644 --- a/tests/configs/wslc_state_aware_provision_with_filesystem.json +++ b/tests/configs/wslc_state_aware_provision_with_filesystem.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "phase": "provision", "containment": "wslc", "filesystem": { 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 8fc262a88..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": { diff --git a/tests/configs/wslc_tar_import_docker_save.json b/tests/configs/wslc_tar_import_docker_save.json index 5c29aee6e..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": { diff --git a/tests/configs/wslc_tar_import_rootfs.json b/tests/configs/wslc_tar_import_rootfs.json index 366133c7b..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": { diff --git a/tests/configs/wslc_timeout.json b/tests/configs/wslc_timeout.json index 994fef240..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": { diff --git a/tests/examples/wslc_hello_world.json b/tests/examples/wslc_hello_world.json index f37913798..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": { 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 21990d870..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": {