From 3cf0b7206f4f106c6664f31a7824027358f38b64 Mon Sep 17 00:00:00 2001 From: Soham Das Date: Thu, 1 Oct 2026 15:45:01 -0700 Subject: [PATCH 1/4] Honor portMappings on the WSLC state-aware provision surface Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d32878ac-e7c0-4db7-bbc3-1d6696a3003e --- .../wslc/wsl-container-getting-started.md | 41 ++++++- docs/backends/wslc/wslc-state-aware.md | 46 +++++++- .../architecture/container-lifecycle.md | 14 ++- docs/development/architecture/versioning.md | 5 +- docs/schema.md | 29 ++++- .../dev/mxc-config.schema.1.1.0-alpha.json | 7 ++ sdk/node/src/generated/v1_1_0_alpha/wire.ts | 4 + .../backends/wslc/common/container_steps.rs | 7 +- .../backends/wslc/common/daemon_protocol.rs | 19 +++- .../src/backends/wslc/common/state_aware.rs | 68 +++++++++++- .../src/bin/wslc_daemon/control_server.rs | 1 + .../src/bin/wslc_daemon/session_manager.rs | 14 +++ .../dev/state_aware.rs | 22 ++++ .../dev/state_aware_tests/provision.rs | 77 +++++++++++++ .../v0_9/state_aware.rs | 1 + .../v1_0/state_aware.rs | 1 + .../src/core/mxc_common/config_parser.rs | 55 ++++----- src/mxc-sdk/src/core/mxc_common/models.rs | 6 +- .../src/core/mxc_common/policy_identity.rs | 16 +++ src/mxc-sdk/src/core/mxc_common/sdk_input.rs | 18 +++ .../src/core/mxc_common/state_aware_input.rs | 1 + .../core/mxc_common/state_aware_operation.rs | 23 ++++ src/mxc-sdk/src/core/mxc_common/validator.rs | 96 +++++++++++++++- src/mxc-sdk/src/core/mxc_common/wire.rs | 22 ++-- .../dev/state_aware/provision/wslc.rs | 6 +- .../state_aware/provision/wslc.rs | 105 ++++++++++++++++++ src/mxc-sdk/tests/wslc_daemon_ipc.rs | 1 + .../wslc_state_aware_exec_port_bind.json | 10 ++ ...c_state_aware_provision_port_mappings.json | 18 +++ tests/scripts/run_wslc_state_aware_tests.ps1 | 55 ++++++++- 30 files changed, 726 insertions(+), 62 deletions(-) create mode 100644 tests/configs/wslc_state_aware_exec_port_bind.json create mode 100644 tests/configs/wslc_state_aware_provision_port_mappings.json diff --git a/docs/backends/wslc/wsl-container-getting-started.md b/docs/backends/wslc/wsl-container-getting-started.md index 8a2fe4bb4..084c40fcb 100644 --- a/docs/backends/wslc/wsl-container-getting-started.md +++ b/docs/backends/wslc/wsl-container-getting-started.md @@ -314,6 +314,44 @@ WSLC-specific settings go under `wslc` in the JSON config: | `gpu` | boolean | `false` | Enable GPU passthrough | | `storagePath` | string | System default | Host path for container storage (VHD) | | `imageTarPath` | string | — | Path to a local tar file to import as the image | +| `portMappings` | array | `[]` | Host→container TCP forwards; each entry takes `windowsPort` and `containerPort` | + +### Port mappings + +```json +"wslc": { + "portMappings": [ + { "windowsPort": 8080, "containerPort": 80 } + ] +} +``` + +TCP only — the WSLC SDK runtime returns `E_NOTIMPL` for UDP, so a `"udp"` +protocol is rejected. Two entries claiming the same `windowsPort` are also +rejected. + +The state-aware lifecycle takes the same list under +`wslc.provision.portMappings`, which requires schema `1.1.0-alpha`: + +```json +{ + "version": "1.1.0-alpha", + "phase": "provision", + "containment": "wslc", + "wslc": { + "provision": { + "portMappings": [ + { "windowsPort": 8080, "containerPort": 80 } + ] + } + } +} +``` + +The state-aware daemon creates one container per sandbox, so a forward belongs +to the sandbox that declared it. The session-wide `cpuCount` / `memoryMb` / +`gpu` / `storagePath` settings stay one-shot-only, because that daemon shares a +single WSL session across every sandbox. ### Image sources @@ -441,7 +479,8 @@ dispatch. In supported contracts, `network.enforcementMode`, `network.allowLocalNetwork`, host lists, and `network.proxy` are unknown fields and fail structural parsing; there is no legacy `"capabilities"` selector. Use the directional all-allow or all-deny posture above. Inbound reachability -requires explicit host-to-container forwards through `wslc.portMappings`; +requires explicit host-to-container forwards through `wslc.portMappings` +(one-shot) or `wslc.provision.portMappings` (state-aware); `ingress.default: "allow"` alone does not create them. **Caveats** diff --git a/docs/backends/wslc/wslc-state-aware.md b/docs/backends/wslc/wslc-state-aware.md index 0fa83af65..a93a4bc6a 100644 --- a/docs/backends/wslc/wslc-state-aware.md +++ b/docs/backends/wslc/wslc-state-aware.md @@ -58,11 +58,41 @@ against different sandboxes are serialized — correct, just not concurrent. See Exact adapters construct `mxc_common::models::WslcProvisionConfig` directly from `wslc.provision`. Engine-side checked binding preserves an absent -config, a present empty config, and supplied `image`/`imageTarPath` values -without reparsing JSON. An omitted image remains `None` until the backend -chooses its default. The exact contract type is converted once by its adapter; -the backend's provision associated type is the runtime-owned model, not a wire -deserialization DTO. +config, a present empty config, and supplied +`image`/`imageTarPath`/`portMappings` values without reparsing JSON. An omitted +image remains `None` until the backend chooses its default. The exact contract +type is converted once by its adapter; the backend's provision associated type +is the runtime-owned model, not a wire deserialization DTO. + +### Port mappings + +`wslc.provision.portMappings` forwards host ports into the sandbox's container, +using the same entry shape as the one-shot `wslc.portMappings` list: + +```json +{ + "version": "1.1.0-alpha", + "phase": "provision", + "containment": "wslc", + "wslc": { + "provision": { + "portMappings": [ + { "windowsPort": 8080, "containerPort": 80 } + ] + } + } +} +``` + +The field requires development contract `1.1.0-alpha`; the published +`0.9.0-alpha` WSLC contract does not declare it. The daemon creates one +container per sandbox, so a mapping applies only to the sandbox that declared +it, unlike the session-wide `cpuCount` / `memoryMb` / `gpu` / `storagePath` +knobs that remain one-shot-only. + +Both surfaces run the same duplicate check, so two entries claiming the same +`windowsPort` are rejected identically. Zero ports and non-TCP protocols are +rejected when the exact contract deserializes. ## Sandbox IDs @@ -262,3 +292,9 @@ fixtures **through the harness**, not by pointing `wxc-exec --config` at them di fix splits `exec` into an on-worker `ExecStart` (extract the thread-agnostic Win32 exit-event handle) + an off-thread wait + an on-worker `ExecReap`, with a per-container `in_flight` slot. This is tracked as follow-up work. + +- **Port mappings are Rust-only for now.** `schemas/schema-version.json` pins + `stateAwareWslc` to `0.9.0-alpha`, and the Node helper refuses a version other + than that default, so `wslc.provision.portMappings` is reachable today through + the typed Rust SDK and raw `1.1.0-alpha` JSON only. Node and .NET support + follows when the pin moves. diff --git a/docs/development/architecture/container-lifecycle.md b/docs/development/architecture/container-lifecycle.md index 25f2b742d..e60d3b820 100644 --- a/docs/development/architecture/container-lifecycle.md +++ b/docs/development/architecture/container-lifecycle.md @@ -409,7 +409,7 @@ interface ProvisionStateAwareRequest { provision?: { appId?: string }; }; wslc?: { - provision?: { image?: string; imageTarPath?: string }; + provision?: { image?: string; imageTarPath?: string; portMappings?: PortMapping[] }; }; } @@ -502,11 +502,21 @@ interface StateAwareBackendSections { // backend-specific config for those phases. }; wslc?: { - provision?: { image?: string; imageTarPath?: string }; + provision?: { image?: string; imageTarPath?: string; portMappings?: PortMapping[] }; }; } + +interface PortMapping { + windowsPort: number; // 1-65535 + containerPort: number; // 1-65535 + protocol?: 'tcp'; // TCP is the only protocol the WSLC runtime implements +} ``` +`wslc.provision.portMappings` requires schema `1.1.0-alpha`; the published +`0.9.0-alpha` WSLC contract does not declare it. Two entries claiming the same +`windowsPort` are rejected. + | Layer | Wire shape | Constraint | |---|---|---| | Outer key | Permanent camel-case backend section | Must match the selected state-aware-capable backend | diff --git a/docs/development/architecture/versioning.md b/docs/development/architecture/versioning.md index a8b523cb0..c86558c2c 100644 --- a/docs/development/architecture/versioning.md +++ b/docs/development/architecture/versioning.md @@ -132,7 +132,10 @@ reasons: are not registered and are rejected. A missing declaration is rejected too. Raw SDK entry points enforce the same exact set. High-level v1 one-shot and state-aware APIs select stable `1.0.0` internally and expose only the - backends supported by that contract. The compatibility constants in + backends supported by that contract. Capabilities the published contract does + not declare — Windows Sandbox state-aware, and + `wslc.provision.portMappings` — are reachable through the raw `1.1.0-alpha` + path only. The compatibility constants in `schemas/schema-version.json` do not authorize other versions within their minimum/maximum range. - **Product version** tracks the shipped artifacts and moves independently of the diff --git a/docs/schema.md b/docs/schema.md index 88f5c17f9..0f750975b 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -443,7 +443,8 @@ phase is being driven against an existing provisioned sandbox. State-aware envelopes use an exact backend-specific contract: - IsolationSession uses published `0.9.0-alpha`. -- WSLC uses published `0.9.0-alpha`; Windows Sandbox uses development +- WSLC uses published `0.9.0-alpha`, or development `1.1.0-alpha` for + `wslc.provision.portMappings`; Windows Sandbox uses development `1.1.0-alpha`. Contracts before `0.9.0-alpha` are retired. The supported published @@ -484,6 +485,32 @@ State-aware-capable backends today are `isolation_session`, `windows_sandbox`, and `wslc` (all Windows-only). IsolationSession does not require runtime experimental authorization; Windows Sandbox does. +WSLC `provision` accepts `wslc.provision.portMappings`, the same +`windowsPort` / `containerPort` / `protocol` entries as the one-shot +`wslc.portMappings` list, applied to the sandbox's own container: + +```json +{ + "$schema": "./schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "version": "1.1.0-alpha", + "phase": "provision", + "containment": "wslc", + "wslc": { + "provision": { + "image": "alpine:latest", + "portMappings": [ + { "windowsPort": 8080, "containerPort": 80 } + ] + } + } +} +``` + +Two entries claiming the same `windowsPort` are rejected on both surfaces. The +session sizing knobs (`cpuCount` / `memoryMb` / `gpu` / `storagePath`) stay +one-shot-only, because the state-aware daemon shares one WSL session across +every sandbox and cannot size them individually. + Full lifecycle API: [container lifecycle](container-lifecycle.md). ### Schema Versioning diff --git a/schemas/dev/mxc-config.schema.1.1.0-alpha.json b/schemas/dev/mxc-config.schema.1.1.0-alpha.json index 0c2e22efd..714b79fb7 100644 --- a/schemas/dev/mxc-config.schema.1.1.0-alpha.json +++ b/schemas/dev/mxc-config.schema.1.1.0-alpha.json @@ -1494,6 +1494,13 @@ "imageTarPath": { "description": "Optional path to a local container image archive.", "type": "string" + }, + "portMappings": { + "description": "Optional host-to-container TCP port mappings.", + "items": { + "$ref": "#/definitions/PortMapping" + }, + "type": "array" } }, "type": "object" diff --git a/sdk/node/src/generated/v1_1_0_alpha/wire.ts b/sdk/node/src/generated/v1_1_0_alpha/wire.ts index 94ec343ce..b66f13cd0 100644 --- a/sdk/node/src/generated/v1_1_0_alpha/wire.ts +++ b/sdk/node/src/generated/v1_1_0_alpha/wire.ts @@ -852,6 +852,10 @@ export interface WslcProvision { * Optional path to a local container image archive. */ imageTarPath?: string; + /** + * Optional host-to-container TCP port mappings. + */ + portMappings?: PortMapping[]; } /** diff --git a/src/mxc-sdk/src/backends/wslc/common/container_steps.rs b/src/mxc-sdk/src/backends/wslc/common/container_steps.rs index 1b7d651fc..1c47f20d2 100644 --- a/src/mxc-sdk/src/backends/wslc/common/container_steps.rs +++ b/src/mxc-sdk/src/backends/wslc/common/container_steps.rs @@ -751,22 +751,23 @@ pub unsafe fn create_daemon_session( /// /// # Safety /// `sdk` must hold valid function pointers and `session` must be a live handle. +#[allow(clippy::too_many_arguments)] pub unsafe fn create_daemon_container( sdk: &WslcSdk, session: WslcSession, image: &str, mounts: &[VolumeMount], + port_mappings: &[PortMapping], net_mode: WslcContainerNetworkingMode, keepalive: &mut ProcessSettings, logger: &mut Logger, ) -> Result { - // Daemon containers never forward ports (no state-aware port-mapping config) - // and set no container flags (auto-remove/gpu/privileged are one-shot-only). + // The container flags (auto-remove / gpu / privileged) are one-shot-only. let mut container_settings = ContainerSettings::build( sdk, image, mounts, - &[], + port_mappings, net_mode, WslcContainerFlags::WSLC_CONTAINER_FLAG_NONE, logger, diff --git a/src/mxc-sdk/src/backends/wslc/common/daemon_protocol.rs b/src/mxc-sdk/src/backends/wslc/common/daemon_protocol.rs index ac5ff533a..7b351f397 100644 --- a/src/mxc-sdk/src/backends/wslc/common/daemon_protocol.rs +++ b/src/mxc-sdk/src/backends/wslc/common/daemon_protocol.rs @@ -50,7 +50,7 @@ pub const MAX_EXEC_ID_BYTES: usize = 128; /// A new optional field counts: these structs do not deny unknown fields, so a /// daemon predating one drops it and acts on a request it only partly /// understood. -pub const PROTOCOL_VERSION: u32 = 6; +pub const PROTOCOL_VERSION: u32 = 7; fn deserialize_exec_id<'de, D>(deserializer: D) -> Result where @@ -98,6 +98,16 @@ pub enum NetworkMode { Bridged, } +/// A host → container TCP forward applied to one sandbox's container. +/// +/// TCP-only: the WSLC runtime returns `E_NOTIMPL` for UDP, and the exact +/// contract admits no other protocol, so none is carried on the wire. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct PortMapping { + pub windows_port: u16, + pub container_port: u16, +} + /// Inputs to `provision`: ensure the shared session (booting the WSL2 utility VM /// on first provision), resolve the image, and create the container. Mirrors the /// session/container-level fields of the one-shot `WslcConfig` plus the @@ -115,6 +125,9 @@ pub struct ProvisionConfig { /// Container network mode. #[serde(default)] pub network: NetworkMode, + /// Host → container TCP forwards for this sandbox's container. + #[serde(default)] + pub port_mappings: Vec, } /// Inputs to `start`: the container was created at provision; start boots it. @@ -419,6 +432,10 @@ mod tests { read_only: true, }], network: NetworkMode::Bridged, + port_mappings: vec![PortMapping { + windows_port: 8080, + container_port: 80, + }], })); } diff --git a/src/mxc-sdk/src/backends/wslc/common/state_aware.rs b/src/mxc-sdk/src/backends/wslc/common/state_aware.rs index 0060b88e4..f18a68c85 100644 --- a/src/mxc-sdk/src/backends/wslc/common/state_aware.rs +++ b/src/mxc-sdk/src/backends/wslc/common/state_aware.rs @@ -35,8 +35,8 @@ use crate::wslc_common::daemon_client::{ truncation_suffix, DaemonClient, DaemonError, DaemonExecOutcome, }; use crate::wslc_common::daemon_protocol::{ - DeprovisionConfig, ErrKind, ExecConfig, NetworkMode, ProvisionConfig, StartConfig, StopConfig, - VolumeMount, + DeprovisionConfig, ErrKind, ExecConfig, NetworkMode, PortMapping as DaemonPortMapping, + ProvisionConfig, StartConfig, StopConfig, VolumeMount, }; use crate::wslc_common::policy::{ exec_proxy_url, validate_exec_policy, validate_post_provision_policy, validate_provision_policy, @@ -528,6 +528,19 @@ fn build_provision_config( .as_ref() .and_then(|c| c.image.clone()) .unwrap_or_else(|| DEFAULT_IMAGE.to_string()); + let port_mappings = config + .as_ref() + .and_then(|c| c.port_mappings.as_ref()) + .map(|mappings| { + mappings + .iter() + .map(|mapping| DaemonPortMapping { + windows_port: mapping.windows_port, + container_port: mapping.container_port, + }) + .collect() + }) + .unwrap_or_default(); let image_tar_path = config.and_then(|c| c.image_tar_path); // WSLc provision-time filesystem-policy gate (D6 normalization → D3 @@ -556,6 +569,7 @@ fn build_provision_config( image_tar_path, volumes, network, + port_mappings, }) } @@ -880,6 +894,7 @@ mod tests { let phase = WslcProvisionConfig { image: Some("custom/image:tag".to_string()), image_tar_path: Some("C:\\images\\custom.tar".to_string()), + port_mappings: None, }; let cfg = build_provision_config(&ExecutionRequest::default(), Some(phase)).unwrap(); assert_eq!(cfg.image, "custom/image:tag"); @@ -895,6 +910,52 @@ mod tests { let cfg = build_provision_config(&ExecutionRequest::default(), phase).unwrap(); assert_eq!(cfg.image, "alpine:latest"); assert!(cfg.image_tar_path.is_none()); + assert!(cfg.port_mappings.is_empty()); + } + } + + #[test] + fn build_provision_config_forwards_port_mappings_to_the_daemon() { + let phase = WslcProvisionConfig { + port_mappings: Some(vec![ + wxc_common::models::PortMapping { + windows_port: 8080, + container_port: 80, + protocol: "tcp".to_string(), + }, + wxc_common::models::PortMapping { + windows_port: 8443, + container_port: 443, + protocol: "tcp".to_string(), + }, + ]), + ..Default::default() + }; + let cfg = build_provision_config(&ExecutionRequest::default(), Some(phase)).unwrap(); + assert_eq!( + cfg.port_mappings, + vec![ + DaemonPortMapping { + windows_port: 8080, + container_port: 80, + }, + DaemonPortMapping { + windows_port: 8443, + container_port: 443, + }, + ] + ); + } + + #[test] + fn build_provision_config_distinguishes_absent_from_empty_port_mappings() { + for mappings in [None, Some(Vec::new())] { + let phase = WslcProvisionConfig { + port_mappings: mappings, + ..Default::default() + }; + let cfg = build_provision_config(&ExecutionRequest::default(), Some(phase)).unwrap(); + assert!(cfg.port_mappings.is_empty()); } } @@ -905,6 +966,7 @@ mod tests { WslcProvisionConfig { image: Some("custom/image:tag".to_string()), image_tar_path: None, + port_mappings: None, }, "custom/image:tag", None, @@ -913,6 +975,7 @@ mod tests { WslcProvisionConfig { image: None, image_tar_path: Some("C:\\images\\custom.tar".to_string()), + port_mappings: None, }, "alpine:latest", Some("C:\\images\\custom.tar"), @@ -921,6 +984,7 @@ mod tests { WslcProvisionConfig { image: Some(String::new()), image_tar_path: Some(String::new()), + port_mappings: None, }, "", Some(""), diff --git a/src/mxc-sdk/src/bin/wslc_daemon/control_server.rs b/src/mxc-sdk/src/bin/wslc_daemon/control_server.rs index 7ae727061..0831cf15e 100644 --- a/src/mxc-sdk/src/bin/wslc_daemon/control_server.rs +++ b/src/mxc-sdk/src/bin/wslc_daemon/control_server.rs @@ -842,6 +842,7 @@ mod tests { image_tar_path: None, volumes: Vec::new(), network: Default::default(), + port_mappings: Vec::new(), }), ) .await diff --git a/src/mxc-sdk/src/bin/wslc_daemon/session_manager.rs b/src/mxc-sdk/src/bin/wslc_daemon/session_manager.rs index 42008e263..3a1371ae2 100644 --- a/src/mxc-sdk/src/bin/wslc_daemon/session_manager.rs +++ b/src/mxc-sdk/src/bin/wslc_daemon/session_manager.rs @@ -715,6 +715,15 @@ impl Worker { WslcContainerNetworkingMode::WSLC_CONTAINER_NETWORKING_MODE_BRIDGED } }; + let port_mappings: Vec = config + .port_mappings + .iter() + .map(|p| wxc_common::models::PortMapping { + windows_port: p.windows_port, + container_port: p.container_port, + protocol: "tcp".to_string(), + }) + .collect(); // SAFETY: `sdk`/`session` are valid; every buffer the SDK stores pointers // into is owned by a stationary local (`keepalive`) until create returns. @@ -736,6 +745,7 @@ impl Worker { session, &config.image, &mounts, + &port_mappings, net_mode, &mut keepalive, &mut self.logger, @@ -1210,6 +1220,7 @@ mod tests { image_tar_path: None, volumes: Vec::new(), network: Default::default(), + port_mappings: Vec::new(), }, reply, _retire_deadline: std::sync::mpsc::channel().0, @@ -1236,6 +1247,7 @@ mod tests { image_tar_path: None, volumes: Vec::new(), network: Default::default(), + port_mappings: Vec::new(), }, reply, _retire_deadline: std::sync::mpsc::channel().0, @@ -1601,6 +1613,7 @@ mod tests { image_tar_path: None, volumes: Vec::new(), network: Default::default(), + port_mappings: Vec::new(), }) .await .unwrap(); @@ -1663,6 +1676,7 @@ mod tests { image_tar_path: None, volumes: Vec::new(), network: Default::default(), + port_mappings: Vec::new(), }) .await .unwrap(); diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs index f22be133d..431596aac 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs @@ -51,14 +51,36 @@ fn convert_isolation_session_network(value: contract::IsolationSessionNetwork) - } } +fn convert_wslc_port_mapping(value: contract::PortMapping) -> crate::models::PortMapping { + let contract::PortMapping { + windows_port, + container_port, + protocol, + } = value; + crate::models::PortMapping { + windows_port: windows_port.get(), + container_port: container_port.get(), + protocol: match protocol.into_option() { + Some(contract::TransportProtocol::Tcp) | None => "tcp".to_string(), + }, + } +} + fn convert_wslc_provision(value: contract::WslcProvision) -> WslcProvisionConfig { let contract::WslcProvision { image, image_tar_path, + port_mappings, } = value; WslcProvisionConfig { image: image.into_option(), image_tar_path: image_tar_path.into_option(), + port_mappings: port_mappings.into_option().map(|mappings| { + mappings + .into_iter() + .map(convert_wslc_port_mapping) + .collect() + }), } } diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/provision.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/provision.rs index 2fe831fce..18ca377be 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/provision.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/provision.rs @@ -169,6 +169,83 @@ fn wslc_configuration_matches_explicit_values_without_wire_conversion() { } } +#[test] +fn wslc_port_mappings_convert_without_wire_conversion() { + for (fields, expected) in [ + (r#","wslc":{"provision":{}}"#, None), + (r#","wslc":{"provision":{"portMappings":[]}}"#, Some(vec![])), + ( + r#","wslc":{"provision":{"portMappings":[{"windowsPort":8080,"containerPort":80}]}}"#, + Some(vec![(8080, 80)]), + ), + ( + r#","wslc":{"provision":{"portMappings":[{"windowsPort":8080,"containerPort":80,"protocol":"tcp"}]}}"#, + Some(vec![(8080, 80)]), + ), + ( + r#","wslc":{"provision":{"portMappings":[{"windowsPort":8080,"containerPort":80},{"windowsPort":8443,"containerPort":443}]}}"#, + Some(vec![(8080, 80), (8443, 443)]), + ), + ] { + let json = source("wslc", fields); + let (_, operation) = adapt(&json); + let StateAwareOperation::Provision(StateAwareProvision::Wslc(config)) = operation else { + panic!("wrong operation"); + }; + let config = config.expect("provision config"); + let observed = config.port_mappings.as_ref().map(|mappings| { + mappings + .iter() + .map(|mapping| (mapping.windows_port, mapping.container_port)) + .collect::>() + }); + assert_eq!(observed, expected, "{json}"); + + // An omitted protocol normalizes to the only one WSLC implements. + for mapping in config.port_mappings.iter().flatten() { + assert_eq!(mapping.protocol, "tcp"); + } + } +} + +#[test] +fn wslc_port_mappings_do_not_leak_into_the_one_shot_wslc_section() { + let json = source( + "wslc", + r#","wslc":{"provision":{"portMappings":[{"windowsPort":8080,"containerPort":80}]}}"#, + ); + let (common, _) = adapt(&json); + assert_clean_common(&common); +} + +#[test] +fn wslc_rejects_duplicate_windows_ports() { + let json = source( + "wslc", + r#","wslc":{"provision":{"portMappings":[{"windowsPort":8080,"containerPort":80},{"windowsPort":8080,"containerPort":81}]}}"#, + ); + let request = contract::parse_request(&json).expect("exact contract accepts the shape"); + let Err(error) = crate::config_contract_adapters::dev::adapt_request(request) else { + panic!("duplicate windowsPort must be rejected"); + }; + let message = error.to_string(); + assert!(message.contains("duplicate windowsPort 8080"), "{message}"); + assert!(message.contains("wslc.provision.portMappings"), "{message}"); +} + +#[test] +fn wslc_accepts_the_same_container_port_behind_distinct_host_ports() { + let json = source( + "wslc", + r#","wslc":{"provision":{"portMappings":[{"windowsPort":8080,"containerPort":80},{"windowsPort":8081,"containerPort":80}]}}"#, + ); + let (_, operation) = adapt(&json); + let StateAwareOperation::Provision(StateAwareProvision::Wslc(Some(config))) = operation else { + panic!("wrong operation"); + }; + assert_eq!(config.port_mappings.expect("port mappings").len(), 2); +} + #[test] fn provision_common_fields_are_independent_of_backend_payload() { for backend in ["isolation_session", "windows_sandbox", "wslc"] { diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/state_aware.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/state_aware.rs index b480a6447..09470fe62 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/state_aware.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/state_aware.rs @@ -59,6 +59,7 @@ fn convert_wslc_provision(value: contract::WslcProvision) -> WslcProvisionConfig WslcProvisionConfig { image: image.into_option(), image_tar_path: image_tar_path.into_option(), + port_mappings: None, } } diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/state_aware.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/state_aware.rs index e9dc3763c..dabaadb66 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/state_aware.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/state_aware.rs @@ -59,6 +59,7 @@ fn convert_wslc_provision(value: contract::WslcProvision) -> WslcProvisionConfig WslcProvisionConfig { image: image.into_option(), image_tar_path: image_tar_path.into_option(), + port_mappings: None, } } diff --git a/src/mxc-sdk/src/core/mxc_common/config_parser.rs b/src/mxc-sdk/src/core/mxc_common/config_parser.rs index a1e940b9a..7a77274ee 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_parser.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_parser.rs @@ -1540,42 +1540,18 @@ fn normalize_common_request_ir( } 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. - converted.push(PortMapping { + // 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. + let converted: Vec = mappings + .into_iter() + .map(|m| PortMapping { windows_port: m.windows_port, container_port: m.container_port, protocol: "tcp".to_string(), - }); - } - // 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)); - } - } + }) + .collect(); + crate::validator::validate_port_mappings("wslc.portMappings", &converted)?; config.port_mappings = converted; } Some(config) @@ -2974,6 +2950,19 @@ mod tests { StateAwareProvision::Wslc(Some(crate::mxc_common::models::WslcProvisionConfig { image: Some("alpine:latest".into()), image_tar_path: None, + port_mappings: None, + })), + ), + ( + "wslc_state_aware_provision_port_mappings.json", + StateAwareProvision::Wslc(Some(crate::models::WslcProvisionConfig { + image: Some("python:3.12-alpine".into()), + image_tar_path: None, + port_mappings: Some(vec![crate::models::PortMapping { + windows_port: 18081, + container_port: 8080, + protocol: "tcp".into(), + }]), })), ), ] { diff --git a/src/mxc-sdk/src/core/mxc_common/models.rs b/src/mxc-sdk/src/core/mxc_common/models.rs index efc592dbd..12f3be0f0 100644 --- a/src/mxc-sdk/src/core/mxc_common/models.rs +++ b/src/mxc-sdk/src/core/mxc_common/models.rs @@ -287,8 +287,12 @@ pub struct IsolationSessionProvisionConfig { pub struct WslcProvisionConfig { /// Container image reference. The backend selects its default when absent. pub image: Option, + /// Local image tarball to import instead of pulling an image. pub image_tar_path: Option, + + /// Host-to-container TCP forwards applied to the sandbox's own container. + pub port_mappings: Option>, } /// Configuration specific to the LXC container backend. @@ -770,7 +774,7 @@ pub enum CaptureDenialsMode { } /// Port mapping for host↔container port forwarding. -#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PortMapping { /// Port on the Windows host. pub windows_port: u16, diff --git a/src/mxc-sdk/src/core/mxc_common/policy_identity.rs b/src/mxc-sdk/src/core/mxc_common/policy_identity.rs index c2a04b53f..341cd26f2 100644 --- a/src/mxc-sdk/src/core/mxc_common/policy_identity.rs +++ b/src/mxc-sdk/src/core/mxc_common/policy_identity.rs @@ -140,6 +140,7 @@ fn state_aware_config_projection(operation: &StateAwareOperation) -> Value { StateAwareOperation::Provision(StateAwareProvision::Wslc(Some(WslcProvisionConfig { image, image_tar_path, + port_mappings, }))) => { let mut config = Map::new(); if let Some(image) = image { @@ -148,6 +149,21 @@ fn state_aware_config_projection(operation: &StateAwareOperation) -> Value { if let Some(image_tar_path) = image_tar_path { config.insert("imageTarPath".into(), Value::String(image_tar_path.clone())); } + + // A forwarded host port is policy, so it belongs in the identity. + if let Some(port_mappings) = port_mappings { + let mappings = port_mappings + .iter() + .map(|mapping| { + let mut entry = Map::new(); + entry.insert("windowsPort".into(), Value::from(mapping.windows_port)); + entry.insert("containerPort".into(), Value::from(mapping.container_port)); + entry.insert("protocol".into(), Value::String(mapping.protocol.clone())); + Value::Object(entry) + }) + .collect(); + config.insert("portMappings".into(), Value::Array(mappings)); + } Value::Object(config) } StateAwareOperation::Provision( diff --git a/src/mxc-sdk/src/core/mxc_common/sdk_input.rs b/src/mxc-sdk/src/core/mxc_common/sdk_input.rs index 3f460c45c..aa144845e 100644 --- a/src/mxc-sdk/src/core/mxc_common/sdk_input.rs +++ b/src/mxc-sdk/src/core/mxc_common/sdk_input.rs @@ -131,6 +131,14 @@ impl SdkStateAwareInput { .to_string(), )); } + if wslc_port_mappings_requested(&operation) && version != ContractVersion::V1_1_0Alpha { + return Err(WxcError::ConfigParse(format!( + "WSLC state-aware provision port mappings require schema version \ + 1.1.0-alpha, got {}", + version.as_str() + ))); + } + operation.validate()?; Ok(Self { version, operation, @@ -185,6 +193,16 @@ impl SdkStateAwareInput { } } +/// WSLC port mappings exist only in the `1.1.0-alpha` exact contract. +fn wslc_port_mappings_requested(operation: &StateAwareOperation) -> bool { + matches!( + operation, + StateAwareOperation::Provision(crate::state_aware_operation::StateAwareProvision::Wslc( + Some(config) + )) if config.port_mappings.is_some() + ) +} + fn map_network(network: SdkNetworkInput) -> wire::Network { wire::Network { egress: network.egress.map(|egress| wire::NetworkEgress { diff --git a/src/mxc-sdk/src/core/mxc_common/state_aware_input.rs b/src/mxc-sdk/src/core/mxc_common/state_aware_input.rs index 6b94bf8b6..e27c0790c 100644 --- a/src/mxc-sdk/src/core/mxc_common/state_aware_input.rs +++ b/src/mxc-sdk/src/core/mxc_common/state_aware_input.rs @@ -61,6 +61,7 @@ impl StateAwareInput { contradictory.join(", "), ))); } + operation.validate()?; Ok(Self { common, operation }) } diff --git a/src/mxc-sdk/src/core/mxc_common/state_aware_operation.rs b/src/mxc-sdk/src/core/mxc_common/state_aware_operation.rs index 698790f59..951103d01 100644 --- a/src/mxc-sdk/src/core/mxc_common/state_aware_operation.rs +++ b/src/mxc-sdk/src/core/mxc_common/state_aware_operation.rs @@ -81,6 +81,29 @@ impl StateAwareOperation { | Self::Deprovision { sandbox_id } => Some(sandbox_id), } } + + /// Reject backend configuration the selected backend cannot enforce. + /// + /// Both the JSON and typed-SDK entry points call this, so neither surface + /// can accept a payload the other rejects. + pub(crate) fn validate(&self) -> Result<(), crate::error::WxcError> { + match self { + Self::Provision(StateAwareProvision::Wslc(Some(config))) => { + match config.port_mappings.as_deref() { + Some(mappings) => crate::validator::validate_port_mappings( + "wslc.provision.portMappings", + mappings, + ), + None => Ok(()), + } + } + Self::Provision(_) + | Self::Start { .. } + | Self::Exec { .. } + | Self::Stop { .. } + | Self::Deprovision { .. } => Ok(()), + } + } } #[cfg(test)] diff --git a/src/mxc-sdk/src/core/mxc_common/validator.rs b/src/mxc-sdk/src/core/mxc_common/validator.rs index fa879aaf9..e245e4589 100644 --- a/src/mxc-sdk/src/core/mxc_common/validator.rs +++ b/src/mxc-sdk/src/core/mxc_common/validator.rs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use crate::mxc_common::models::{ExecutionRequest, NetworkAction, ScriptResponse}; +use crate::mxc_common::error::WxcError; +use crate::mxc_common::models::{ExecutionRequest, NetworkAction, PortMapping, ScriptResponse}; use crate::mxc_common::mxc_error::MxcError; +use std::collections::HashSet; /// Declares which optional network policy features a backend enforces. /// @@ -172,6 +174,40 @@ pub fn validate_exec_common(request: &ExecutionRequest) -> Result<(), MxcError> Ok(()) } +/// Reject WSLC port mappings the WSLC runtime cannot apply. +/// +/// The exact JSON contract rejects a zero port structurally, but the typed +/// SDKs hand over plain `u16`, so the check has to live here too. +/// +/// The same host port on TCP and UDP would in principle be legal, so the +/// protocol stays in the duplicate key for when the WSLC runtime stops +/// returning `E_NOTIMPL` for UDP. +pub fn validate_port_mappings(field_path: &str, mappings: &[PortMapping]) -> Result<(), WxcError> { + for (index, mapping) in mappings.iter().enumerate() { + for (name, port) in [ + ("windowsPort", mapping.windows_port), + ("containerPort", mapping.container_port), + ] { + if port == 0 { + return Err(WxcError::ConfigParse(format!( + "{field_path}[{index}]: '{name}' must be > 0" + ))); + } + } + } + + let mut seen: HashSet<(u16, &str)> = HashSet::with_capacity(mappings.len()); + for mapping in mappings { + if !seen.insert((mapping.windows_port, mapping.protocol.as_str())) { + return Err(WxcError::ConfigParse(format!( + "{field_path}: duplicate windowsPort {} for protocol '{}'", + mapping.windows_port, mapping.protocol + ))); + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -190,6 +226,64 @@ mod tests { assert!(validate_common(&req).is_err()); } + fn port_mapping(windows_port: u16, container_port: u16) -> PortMapping { + PortMapping { + windows_port, + container_port, + protocol: "tcp".to_string(), + } + } + + #[test] + fn port_mapping_messages_name_the_caller_supplied_field_path() { + for field_path in ["wslc.portMappings", "wslc.provision.portMappings"] { + let zero = validate_port_mappings(field_path, &[port_mapping(0, 80)]).unwrap_err(); + assert!( + zero.to_string() + .ends_with(&format!("{field_path}[0]: 'windowsPort' must be > 0")), + "{zero}" + ); + + let duplicate = + validate_port_mappings(field_path, &[port_mapping(80, 8080), port_mapping(80, 81)]) + .unwrap_err(); + assert!( + duplicate.to_string().ends_with(&format!( + "{field_path}: duplicate windowsPort 80 for protocol 'tcp'" + )), + "{duplicate}" + ); + } + } + + #[test] + fn a_zero_port_outranks_an_earlier_duplicate() { + // Both surfaces share this helper, so changing the order would reword a + // user-facing rejection on each of them. + let mappings = [ + port_mapping(8080, 80), + port_mapping(8080, 81), + port_mapping(0, 82), + ]; + let error = validate_port_mappings("wslc.portMappings", &mappings).unwrap_err(); + assert!( + error + .to_string() + .ends_with("wslc.portMappings[2]: 'windowsPort' must be > 0"), + "{error}" + ); + } + + #[test] + fn distinct_host_ports_and_an_empty_list_are_accepted() { + assert!(validate_port_mappings("wslc.portMappings", &[]).is_ok()); + assert!(validate_port_mappings( + "wslc.portMappings", + &[port_mapping(8080, 80), port_mapping(8081, 80)] + ) + .is_ok()); + } + #[test] fn accepts_valid_script() { let req = ExecutionRequest { diff --git a/src/mxc-sdk/src/core/mxc_common/wire.rs b/src/mxc-sdk/src/core/mxc_common/wire.rs index 8a845fa79..db1d1cb89 100644 --- a/src/mxc-sdk/src/core/mxc_common/wire.rs +++ b/src/mxc-sdk/src/core/mxc_common/wire.rs @@ -489,24 +489,30 @@ pub struct Wslc { } /// Per-phase WSLc **provision** normalization input, constructed from the exact -/// `wslc.provision` contract. Carries only what the amortized daemon session -/// honors: the container image (or a local tarball to import). +/// `wslc.provision` contract. Carries what the amortized daemon session honors: +/// the container image (or a local tarball to import) and the container's own +/// host → container port forwards. /// /// 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. +/// section (readwrite / readonly paths, network), not from here. The session +/// sizing knobs (`cpuCount` / `memoryMb` / `gpu` / `storagePath`) are +/// deliberately absent: the daemon shares a single session across sandboxes, so +/// they cannot be set per sandbox. `portMappings` is container-scoped and so is +/// honored here. 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. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct WslcProvisionPhase { /// Container image reference (e.g. `alpine:latest`). Defaults to /// `alpine:latest` when omitted. pub image: Option, + /// Path to a local image tarball to import instead of pulling. pub image_tar_path: Option, + + /// Host → container TCP forwards applied to this sandbox's container. + pub port_mappings: Option>, } /// A single host → container port forward retained in normalized WSLC settings. diff --git a/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/provision/wslc.rs b/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/provision/wslc.rs index 22ce83a0f..4ff790199 100644 --- a/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/provision/wslc.rs +++ b/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/provision/wslc.rs @@ -2,7 +2,7 @@ // Licensed under the MIT License. use crate::mxc_contract::dev::state_aware::provision::ProvisionPhase; -use crate::mxc_contract::dev::{Filesystem, Network, Telemetry}; +use crate::mxc_contract::dev::{Filesystem, Network, PortMapping, Telemetry}; use crate::mxc_contract::dev::{OptionalField, Version}; use serde::Deserialize; @@ -22,6 +22,10 @@ pub struct WslcProvision { /// Optional path to a local container image archive. #[serde(default)] pub image_tar_path: OptionalField, + + /// Optional host-to-container TCP port mappings. + #[serde(default)] + pub port_mappings: OptionalField>, } /// State-aware WSLC settings. diff --git a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/state_aware/provision/wslc.rs b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/state_aware/provision/wslc.rs index 58db5195f..03c9e9600 100644 --- a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/state_aware/provision/wslc.rs +++ b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/state_aware/provision/wslc.rs @@ -113,6 +113,108 @@ fn accepts_provision_string_values() { } } +#[test] +fn accepts_provision_port_mappings() { + for mappings in [ + "[]", + r#"[{"windowsPort": 8080, "containerPort": 80}]"#, + r#"[{"windowsPort": 8080, "containerPort": 80, "protocol": "tcp"}]"#, + r#"[{"windowsPort": 8080, "containerPort": 80}, {"windowsPort": 8443, "containerPort": 443}]"#, + r#"[{"windowsPort": 65535, "containerPort": 1}]"#, + ] { + assert_valid(&request_with_additional_fields(&format!( + r#""wslc": {{"provision": {{"portMappings": {mappings}}}}}"# + ))); + } +} + +#[test] +fn accepts_port_mappings_alongside_image_selection() { + assert_valid(&request_with_additional_fields( + r#""wslc": {"provision": { + "image": "alpine:latest", + "imageTarPath": "C:\\images\\alpine.tar", + "portMappings": [{"windowsPort": 8080, "containerPort": 80}] + }}"#, + )); +} + +#[test] +fn rejects_port_mappings_missing_required_ports() { + for mapping in [r#"{"containerPort": 80}"#, r#"{"windowsPort": 8080}"#, "{}"] { + assert_invalid(&request_with_additional_fields(&format!( + r#""wslc": {{"provision": {{"portMappings": [{mapping}]}}}}"# + ))); + } +} + +#[test] +fn rejects_zero_and_out_of_range_ports() { + for value in ["0", "65536", "-1", "1.5"] { + for field in ["windowsPort", "containerPort"] { + let other = if field == "windowsPort" { + "containerPort" + } else { + "windowsPort" + }; + assert_invalid(&request_with_additional_fields(&format!( + r#""wslc": {{"provision": {{"portMappings": [{{"{field}": {value}, "{other}": 80}}]}}}}"# + ))); + } + } +} + +#[test] +fn rejects_non_tcp_port_mapping_protocol() { + for protocol in [r#""udp""#, r#""TCP""#, r#""sctp""#, r#""""#, "123", "true"] { + assert_invalid(&request_with_additional_fields(&format!( + r#""wslc": {{"provision": {{"portMappings": [ + {{"windowsPort": 8080, "containerPort": 80, "protocol": {protocol}}} + ]}}}}"# + ))); + } +} + +#[test] +fn rejects_invalid_port_mappings_container_types() { + for value in [r#""8080:80""#, "123", "true", "{}"] { + assert_invalid(&request_with_additional_fields(&format!( + r#""wslc": {{"provision": {{"portMappings": {value}}}}}"# + ))); + } + for item in ["123", "true", r#""8080:80""#, "[]"] { + assert_invalid(&request_with_additional_fields(&format!( + r#""wslc": {{"provision": {{"portMappings": [{item}]}}}}"# + ))); + } +} + +#[test] +fn rejects_null_port_mapping_fields() { + for field in [ + r#""wslc": {"provision": {"portMappings": null}}"#, + r#""wslc": {"provision": {"portMappings": [null]}}"#, + r#""wslc": {"provision": {"portMappings": [{"windowsPort": null, "containerPort": 80}]}}"#, + r#""wslc": {"provision": {"portMappings": [{"windowsPort": 8080, "containerPort": null}]}}"#, + r#""wslc": {"provision": {"portMappings": [{"windowsPort": 8080, "containerPort": 80, "protocol": null}]}}"#, + ] { + assert_invalid(&request_with_additional_fields(field)); + } +} + +#[test] +fn rejects_unknown_and_duplicate_port_mapping_fields() { + for field in [ + r#""wslc": {"provision": {"portMappings": [{"windowsPort": 8080, "containerPort": 80, "unknownField": true}]}}"#, + r#""wslc": {"provision": {"portMappings": [{"windowsPort": 8080, "containerPort": 80, "hostPort": 90}]}}"#, + r#""wslc": {"provision": {"portMappings": [{"windowsPort": 8080, "windowsPort": 8081, "containerPort": 80}]}}"#, + r#""wslc": {"provision": {"portMappings": [{"windowsPort": 8080, "containerPort": 80, "containerPort": 81}]}}"#, + r#""wslc": {"provision": {"portMappings": [], "portMappings": []}}"#, + ] { + assert_invalid(&request_with_additional_fields(field)); + } +} + #[test] fn phase_accepts_exact_and_escaped_spelling() { for phase in ["provision", "provis\\u0069on"] { @@ -215,6 +317,7 @@ fn rejects_null_optional_fields() { r#""wslc": {"provision": null}"#, r#""wslc": {"provision": {"image": null}}"#, r#""wslc": {"provision": {"imageTarPath": null}}"#, + r#""wslc": {"provision": {"portMappings": null}}"#, ] { assert_invalid(&request_with_additional_fields(field)); } @@ -234,6 +337,7 @@ fn rejects_unknown_fields_at_each_object_level() { r#""telemetry": {"unknownField": true}"#, r#""wslc": {"unknownField": true}"#, r#""wslc": {"provision": {"unknownField": true}}"#, + r#""wslc": {"provision": {"portMappings": [{"windowsPort": 1, "containerPort": 2, "unknownField": true}]}}"#, ] { assert_invalid(&request_with_additional_fields(field)); } @@ -306,6 +410,7 @@ fn rejects_duplicate_nested_fields() { r#""wslc": {"provision": {}, "provision": {}}"#, r#""wslc": {"provision": {"image": "a", "image": "b"}}"#, r#""wslc": {"provision": {"imageTarPath": "a", "imageTarPath": "b"}}"#, + r#""wslc": {"provision": {"portMappings": [], "portMappings": []}}"#, ] { assert_invalid(&request_with_additional_fields(field)); } diff --git a/src/mxc-sdk/tests/wslc_daemon_ipc.rs b/src/mxc-sdk/tests/wslc_daemon_ipc.rs index 159cfdbaa..220146e39 100644 --- a/src/mxc-sdk/tests/wslc_daemon_ipc.rs +++ b/src/mxc-sdk/tests/wslc_daemon_ipc.rs @@ -113,6 +113,7 @@ fn full_lifecycle_over_pipe() { image_tar_path: None, volumes: Vec::new(), network: Default::default(), + port_mappings: Vec::new(), }) .expect("provision"); diff --git a/tests/configs/wslc_state_aware_exec_port_bind.json b/tests/configs/wslc_state_aware_exec_port_bind.json new file mode 100644 index 000000000..e41f49da8 --- /dev/null +++ b/tests/configs/wslc_state_aware_exec_port_bind.json @@ -0,0 +1,10 @@ +{ + "$schema": "../../schemas/stable/mxc-config.schema.1.0.0.json", + "version": "1.0.0", + "phase": "exec", + "sandboxId": "wslc:{{SANDBOX_ID}}", + "process": { + "commandLine": "python3 -c 'import socket; s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1); s.bind((\"0.0.0.0\",8080)); s.close(); print(\"STATE_AWARE_PORT_MAPPING_OK\")'", + "timeout": 30000 + } +} diff --git a/tests/configs/wslc_state_aware_provision_port_mappings.json b/tests/configs/wslc_state_aware_provision_port_mappings.json new file mode 100644 index 000000000..06c6a8dbd --- /dev/null +++ b/tests/configs/wslc_state_aware_provision_port_mappings.json @@ -0,0 +1,18 @@ +{ + "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "version": "1.1.0-alpha", + "phase": "provision", + "containment": "wslc", + "network": { + "egress": { "default": "allow" }, + "ingress": { "default": "allow", "hostLoopback": "allow" } + }, + "wslc": { + "provision": { + "image": "python:3.12-alpine", + "portMappings": [ + { "windowsPort": 18081, "containerPort": 8080, "protocol": "tcp" } + ] + } + } +} diff --git a/tests/scripts/run_wslc_state_aware_tests.ps1 b/tests/scripts/run_wslc_state_aware_tests.ps1 index 0b6386675..867719765 100644 --- a/tests/scripts/run_wslc_state_aware_tests.ps1 +++ b/tests/scripts/run_wslc_state_aware_tests.ps1 @@ -128,7 +128,7 @@ if (-not $SkipSetup) { $SetupScript = Join-Path $RepoRoot "scripts\setup-wslc.ps1" if (Test-Path $SetupScript) { Write-Host "Pre-pulling WSLc images (pass -SkipSetup to skip)..." -ForegroundColor Cyan - & $SetupScript -WxcExecPath $WxcExec -Image @("alpine:latest") -Force + & $SetupScript -WxcExecPath $WxcExec -Image @("alpine:latest", "python:3.12-alpine") -Force if ($LASTEXITCODE -ne 0) { Write-Host "WARN: setup-wslc.ps1 reported failures; continuing anyway." -ForegroundColor Yellow } @@ -821,8 +821,59 @@ try { } } -# ---------------- Lifecycle D: validation rejections ---------------- +# ---------------- Lifecycle CP: provision-time port mappings ---------------- + +# `wslc.provision.portMappings` is container-scoped, so the daemon applies it to +# the container this sandbox owns. Mirrors the one-shot port-mapping tests: the +# container binding the mapped port is the observable signal that the forward +# was configured rather than dropped. Requires schema 1.1.0-alpha, which is the +# only contract declaring the field. +$script:portSandboxId = $null +$portDeprovisionedOk = $false +try { + $portProvisionedOk = Run-StateAwareTest "CP: provision (port mappings)" { + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_port_mappings.json' + $envObj = Assert-ResultEnvelope $r "port-mapping provision" + if ($envObj) { $script:portSandboxId = [string]$envObj.result.sandboxId } + } + + $portStartedOk = $false + if ($portProvisionedOk) { + $portStartedOk = Run-StateAwareTest "CP: start" { + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_start.json' -SandboxId $script:portSandboxId + $null = Assert-ResultEnvelope $r "port-mapping start" + } + } + + if ($portStartedOk) { + Run-StateAwareTest "CP: exec binds the mapped container port" { + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_exec_port_bind.json' -SandboxId $script:portSandboxId + Assert-True ($r.ExitCode -eq 0) "exit code = 0" + Assert-True ($r.Stdout -match 'STATE_AWARE_PORT_MAPPING_OK') ` + "container bound the mapped port ($($r.Stdout.Trim()))" + } | Out-Null + } + + if ($portProvisionedOk) { + Run-StateAwareTest "CP: stop" { + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_stop.json' -SandboxId $script:portSandboxId + $null = Assert-ResultEnvelope $r "port-mapping stop" + } | Out-Null + $portDeprovPassed = Run-StateAwareTest "CP: deprovision" { + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision.json' -SandboxId $script:portSandboxId + $null = Assert-ResultEnvelope $r "port-mapping deprovision" + } + if ($portDeprovPassed) { $portDeprovisionedOk = $true } + } +} finally { + if ($null -ne $script:portSandboxId -and -not $portDeprovisionedOk) { + Write-Host "" + Write-Host "[cleanup] best-effort deprovision of $script:portSandboxId" -ForegroundColor DarkGray + try { $null = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision.json' -SandboxId $script:portSandboxId } catch { } + } +} +# ---------------- Lifecycle D: validation rejections ---------------- # Validation runs before any daemon call, so these never provision a real # sandbox and need no cleanup. They cover the provision-phase honor-matrix # rejection cells. From 432d853beb9f4aabf4a745fd74222cb9dfc735ca Mon Sep 17 00:00:00 2001 From: Soham Das Date: Thu, 1 Oct 2026 16:40:20 -0700 Subject: [PATCH 2/4] More changes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d32878ac-e7c0-4db7-bbc3-1d6696a3003e --- src/mxc-sdk/src/core/mxc_common/wire.rs | 24 +++-- .../wslc_state_aware_exec_port_bind.json | 2 +- tests/scripts/run_wslc_state_aware_tests.ps1 | 92 ++++++++++++++++--- 3 files changed, 93 insertions(+), 25 deletions(-) diff --git a/src/mxc-sdk/src/core/mxc_common/wire.rs b/src/mxc-sdk/src/core/mxc_common/wire.rs index db1d1cb89..a5f510dff 100644 --- a/src/mxc-sdk/src/core/mxc_common/wire.rs +++ b/src/mxc-sdk/src/core/mxc_common/wire.rs @@ -488,19 +488,20 @@ pub struct Wslc { pub provision: Option, } -/// Per-phase WSLc **provision** normalization input, constructed from the exact -/// `wslc.provision` contract. Carries what the amortized daemon session honors: -/// the container image (or a local tarball to import) and the container's own -/// host → container port forwards. +/// Per-phase WSLc **provision** normalization input, mirroring the exact +/// `wslc.provision` contract: 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 session -/// sizing knobs (`cpuCount` / `memoryMb` / `gpu` / `storagePath`) are -/// deliberately absent: the daemon shares a single session across sandboxes, so -/// they cannot be set per sandbox. `portMappings` is container-scoped and so is -/// honored here. 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. +/// sizing knobs (`cpuCount` / `memoryMb` / `gpu` / `storagePath`) are absent +/// because the daemon shares a single session across sandboxes and cannot set +/// them per sandbox. 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. +/// +/// State-aware provisioning builds `models::WslcProvisionConfig` directly, so +/// nothing populates this struct today; it documents the normalized shape. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct WslcProvisionPhase { @@ -510,9 +511,6 @@ pub struct WslcProvisionPhase { /// Path to a local image tarball to import instead of pulling. pub image_tar_path: Option, - - /// Host → container TCP forwards applied to this sandbox's container. - pub port_mappings: Option>, } /// A single host → container port forward retained in normalized WSLC settings. diff --git a/tests/configs/wslc_state_aware_exec_port_bind.json b/tests/configs/wslc_state_aware_exec_port_bind.json index e41f49da8..17f114b12 100644 --- a/tests/configs/wslc_state_aware_exec_port_bind.json +++ b/tests/configs/wslc_state_aware_exec_port_bind.json @@ -4,7 +4,7 @@ "phase": "exec", "sandboxId": "wslc:{{SANDBOX_ID}}", "process": { - "commandLine": "python3 -c 'import socket; s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1); s.bind((\"0.0.0.0\",8080)); s.close(); print(\"STATE_AWARE_PORT_MAPPING_OK\")'", + "commandLine": "sh -c 'nohup python3 -c \"import socket; s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1); s.bind((\\\"0.0.0.0\\\",8080)); s.listen(8); c,_=s.accept(); c.sendall(b\\\"STATE_AWARE_PORT_MAPPING_OK\\\"); c.close()\" >/tmp/listener.log 2>&1 & sleep 1; echo LISTENER_STARTED'", "timeout": 30000 } } diff --git a/tests/scripts/run_wslc_state_aware_tests.ps1 b/tests/scripts/run_wslc_state_aware_tests.ps1 index 867719765..7fb052713 100644 --- a/tests/scripts/run_wslc_state_aware_tests.ps1 +++ b/tests/scripts/run_wslc_state_aware_tests.ps1 @@ -144,7 +144,8 @@ function ConvertTo-StateAwareInvocation { param( [hashtable]$Request, [string]$ConfigFile, - [string]$SandboxId + [string]$SandboxId, + [int]$WindowsPort ) if ($ConfigFile) { @@ -166,6 +167,17 @@ function ConvertTo-StateAwareInvocation { } catch { throw "Config fixture is not valid JSON: $path ($($_.Exception.Message))" } + + # The fixture carries a schema-valid default so the static config corpus + # still validates; a caller that needs a port nothing else owns + # overrides it here. + if ($WindowsPort) { + $mappings = $requestObject.wslc.provision.portMappings + if (-not $mappings) { + throw "Fixture $ConfigFile has no wslc.provision.portMappings to override" + } + foreach ($mapping in $mappings) { $mapping.windowsPort = $WindowsPort } + } } elseif ($Request) { $requestObject = $Request.Clone() if (-not $Request.ContainsKey('version')) { @@ -235,11 +247,12 @@ function Invoke-StateAware { [hashtable]$Request, [string]$ConfigFile, [string]$SandboxId, + [int]$WindowsPort, [switch]$DryRun ) $invocation = ConvertTo-StateAwareInvocation ` - -Request $Request -ConfigFile $ConfigFile -SandboxId $SandboxId + -Request $Request -ConfigFile $ConfigFile -SandboxId $SandboxId -WindowsPort $WindowsPort $argList = @('--operation', $invocation.Operation) if ($invocation.Operation -ne 'provision') { @@ -368,6 +381,49 @@ function Test-DaemonRunning { $null -ne (Get-Process -Name $DaemonProcName -ErrorAction SilentlyContinue) } +# Bind port 0 so the OS picks a free ephemeral port, then release it. The port +# is only probably still free by the time the caller binds it. +function Get-FreeTcpPort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $listener.Start() + try { + $listener.LocalEndpoint.Port + } finally { + $listener.Stop() + } +} + +# Connect to a mapped host port and return the bytes the container's listener +# sends back, or $null. Binding inside the container succeeds in any Linux +# netns whether or not the host mapping was ever configured, so only this +# round trip shows the forward exists. +function Get-TcpResponse { + param([int]$Port, [int]$TimeoutMs = 5000, [int]$RetryMs = 250) + + $deadline = (Get-Date).AddMilliseconds($TimeoutMs) + while ((Get-Date) -lt $deadline) { + $client = [System.Net.Sockets.TcpClient]::new() + try { + $connect = $client.ConnectAsync('127.0.0.1', $Port) + if ($connect.Wait(1000) -and $client.Connected) { + $stream = $client.GetStream() + $stream.ReadTimeout = 2000 + $buffer = New-Object byte[] 256 + $read = $stream.Read($buffer, 0, $buffer.Length) + if ($read -gt 0) { + return [System.Text.Encoding]::ASCII.GetString($buffer, 0, $read) + } + } + } catch { + # Listener not up yet, or the forward is absent; retry until the deadline. + } finally { + $client.Dispose() + } + Start-Sleep -Milliseconds $RetryMs + } + $null +} + $script:TestResults = @() $script:currentTestPassed = $true $script:currentTestFirstFailReason = $null @@ -824,15 +880,16 @@ try { # ---------------- Lifecycle CP: provision-time port mappings ---------------- # `wslc.provision.portMappings` is container-scoped, so the daemon applies it to -# the container this sandbox owns. Mirrors the one-shot port-mapping tests: the -# container binding the mapped port is the observable signal that the forward -# was configured rather than dropped. Requires schema 1.1.0-alpha, which is the -# only contract declaring the field. +# the container this sandbox owns. The container starts a listener on the mapped +# container port and the assertion connects to the host port to read a sentinel +# back. Requires schema 1.1.0-alpha, the only contract declaring the field. $script:portSandboxId = $null +$script:portStarted = $false $portDeprovisionedOk = $false +$hostPort = Get-FreeTcpPort try { $portProvisionedOk = Run-StateAwareTest "CP: provision (port mappings)" { - $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_port_mappings.json' + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_port_mappings.json' -WindowsPort $hostPort $envObj = Assert-ResultEnvelope $r "port-mapping provision" if ($envObj) { $script:portSandboxId = [string]$envObj.result.sandboxId } } @@ -842,15 +899,21 @@ try { $portStartedOk = Run-StateAwareTest "CP: start" { $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_start.json' -SandboxId $script:portSandboxId $null = Assert-ResultEnvelope $r "port-mapping start" + $script:portStarted = $true } } if ($portStartedOk) { - Run-StateAwareTest "CP: exec binds the mapped container port" { + Run-StateAwareTest "CP: host reaches the container through the mapped port ($hostPort -> 8080)" { $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_exec_port_bind.json' -SandboxId $script:portSandboxId - Assert-True ($r.ExitCode -eq 0) "exit code = 0" - Assert-True ($r.Stdout -match 'STATE_AWARE_PORT_MAPPING_OK') ` - "container bound the mapped port ($($r.Stdout.Trim()))" + Assert-True ($r.ExitCode -eq 0) "listener exec exit code = 0" + Assert-True ($r.Stdout -match 'LISTENER_STARTED') "container listener started" + + $response = Get-TcpResponse -Port $hostPort + Assert-True ($null -ne $response) ` + "host connected to 127.0.0.1:$hostPort (forward configured)" + Assert-True ($response -match 'STATE_AWARE_PORT_MAPPING_OK') ` + "host read the container's sentinel back through the forward (got '$response')" } | Out-Null } @@ -858,6 +921,7 @@ try { Run-StateAwareTest "CP: stop" { $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_stop.json' -SandboxId $script:portSandboxId $null = Assert-ResultEnvelope $r "port-mapping stop" + $script:portStarted = $false } | Out-Null $portDeprovPassed = Run-StateAwareTest "CP: deprovision" { $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision.json' -SandboxId $script:portSandboxId @@ -869,6 +933,12 @@ try { if ($null -ne $script:portSandboxId -and -not $portDeprovisionedOk) { Write-Host "" Write-Host "[cleanup] best-effort deprovision of $script:portSandboxId" -ForegroundColor DarkGray + + # Deprovisioning a started container can fail or strand container + # processes, so stop it first when start succeeded. + if ($script:portStarted) { + try { $null = Invoke-StateAware -ConfigFile 'wslc_state_aware_stop.json' -SandboxId $script:portSandboxId } catch { } + } try { $null = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision.json' -SandboxId $script:portSandboxId } catch { } } } From 4e2797149a8365caf9cab0036aa7b62f4d2bd04e Mon Sep 17 00:00:00 2001 From: Soham Das Date: Mon, 5 Oct 2026 09:32:12 -0700 Subject: [PATCH 3/4] Reject non-TCP port mapping protocols and follow the SDK type move Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d32878ac-e7c0-4db7-bbc3-1d6696a3003e --- src/mxc-sdk/src/core/mxc_common/validator.rs | 38 ++++++++++++++++--- .../src/core/mxc_engine/state_aware.rs | 1 + src/mxc-sdk/src/state_aware_sdk.rs | 1 + 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/mxc-sdk/src/core/mxc_common/validator.rs b/src/mxc-sdk/src/core/mxc_common/validator.rs index e245e4589..b3b359d17 100644 --- a/src/mxc-sdk/src/core/mxc_common/validator.rs +++ b/src/mxc-sdk/src/core/mxc_common/validator.rs @@ -176,12 +176,12 @@ pub fn validate_exec_common(request: &ExecutionRequest) -> Result<(), MxcError> /// Reject WSLC port mappings the WSLC runtime cannot apply. /// -/// The exact JSON contract rejects a zero port structurally, but the typed -/// SDKs hand over plain `u16`, so the check has to live here too. -/// -/// The same host port on TCP and UDP would in principle be legal, so the -/// protocol stays in the duplicate key for when the WSLC runtime stops -/// returning `E_NOTIMPL` for UDP. +/// The exact JSON contract rejects a zero port and a non-TCP protocol +/// structurally, but a caller building the runtime config directly hands over +/// a plain `u16` and `String`, so the checks have to live here too. The daemon +/// wire format carries no protocol and the worker rebuilds every mapping as +/// TCP, so accepting anything else here would silently apply a different +/// mapping than the caller asked for. pub fn validate_port_mappings(field_path: &str, mappings: &[PortMapping]) -> Result<(), WxcError> { for (index, mapping) in mappings.iter().enumerate() { for (name, port) in [ @@ -194,6 +194,12 @@ pub fn validate_port_mappings(field_path: &str, mappings: &[PortMapping]) -> Res ))); } } + if mapping.protocol != "tcp" { + return Err(WxcError::ConfigParse(format!( + "{field_path}[{index}]: 'protocol' must be 'tcp', got '{}'", + mapping.protocol + ))); + } } let mut seen: HashSet<(u16, &str)> = HashSet::with_capacity(mappings.len()); @@ -284,6 +290,26 @@ mod tests { .is_ok()); } + #[test] + fn a_protocol_the_daemon_cannot_carry_is_rejected() { + // The daemon wire format drops the protocol and the worker rebuilds + // every mapping as TCP, so anything else would be applied as something + // the caller did not ask for. + for protocol in ["udp", "UDP", "Tcp", "sctp", ""] { + let mapping = PortMapping { + windows_port: 8080, + container_port: 80, + protocol: protocol.to_string(), + }; + let error = validate_port_mappings("wslc.portMappings", &[mapping]) + .expect_err("only 'tcp' is applicable"); + assert!( + error.to_string().contains("'protocol' must be 'tcp'"), + "{error}" + ); + } + } + #[test] fn accepts_valid_script() { let req = ExecutionRequest { diff --git a/src/mxc-sdk/src/core/mxc_engine/state_aware.rs b/src/mxc-sdk/src/core/mxc_engine/state_aware.rs index 7b4cb9b1e..7e4a0c71a 100644 --- a/src/mxc-sdk/src/core/mxc_engine/state_aware.rs +++ b/src/mxc-sdk/src/core/mxc_engine/state_aware.rs @@ -977,6 +977,7 @@ mod tests { Some(crate::mxc_common::models::WslcProvisionConfig { image, image_tar_path, + port_mappings: None, }) }; let mut input = sdk_input(StateAwareOperation::Provision(RuntimeProvision::Wslc( diff --git a/src/mxc-sdk/src/state_aware_sdk.rs b/src/mxc-sdk/src/state_aware_sdk.rs index f1bc61694..1e78c3069 100644 --- a/src/mxc-sdk/src/state_aware_sdk.rs +++ b/src/mxc-sdk/src/state_aware_sdk.rs @@ -55,6 +55,7 @@ impl ProvisionContainment { Some(crate::mxc_common::models::WslcProvisionConfig { image: image.clone(), image_tar_path: image_tar_path.clone(), + port_mappings: None, }) }; RuntimeProvision::Wslc(config) From e2ab4f3de1cb73bd1d55911599542d0fbdc0e2cf Mon Sep 17 00:00:00 2001 From: Soham Das Date: Mon, 5 Oct 2026 12:22:18 -0700 Subject: [PATCH 4/4] Addressed PR comments Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d32878ac-e7c0-4db7-bbc3-1d6696a3003e --- docs/backends/wslc/wslc-state-aware.md | 41 ++++-- .../architecture/container-lifecycle.md | 7 +- docs/schema.md | 7 + .../src/backends/wslc/common/policy.rs | 20 +++ .../src/backends/wslc/common/state_aware.rs | 53 ++++++- .../wslc/common/wsl_container_runner.rs | 66 +++++++++ .../src/bin/wslc_daemon/session_manager.rs | 63 ++++++-- .../dev/state_aware.rs | 6 +- .../dev/state_aware_tests/provision.rs | 3 +- .../src/core/mxc_common/config_parser.rs | 6 +- .../src/core/mxc_common/policy_identity.rs | 44 ++++++ src/mxc-sdk/src/core/mxc_common/sdk_input.rs | 73 ++++++++- .../mxc_common/state_aware_binding_tests.rs | 60 +++++++- .../core/mxc_common/state_aware_operation.rs | 4 +- src/mxc-sdk/src/core/mxc_common/wire.rs | 18 +-- .../state_aware/provision/wslc.rs | 10 ++ .../v1_0_0/state_aware/provision/wslc.rs | 10 ++ ...lc_state_aware_exec_sibling_port_bind.json | 10 ++ ...tate_aware_provision_unmapped_sibling.json | 15 ++ tests/scripts/run_wslc_state_aware_tests.ps1 | 139 +++++++++++++++++- 20 files changed, 586 insertions(+), 69 deletions(-) create mode 100644 tests/configs/wslc_state_aware_exec_sibling_port_bind.json create mode 100644 tests/configs/wslc_state_aware_provision_unmapped_sibling.json diff --git a/docs/backends/wslc/wslc-state-aware.md b/docs/backends/wslc/wslc-state-aware.md index a93a4bc6a..03ef2462e 100644 --- a/docs/backends/wslc/wslc-state-aware.md +++ b/docs/backends/wslc/wslc-state-aware.md @@ -74,6 +74,10 @@ using the same entry shape as the one-shot `wslc.portMappings` list: "version": "1.1.0-alpha", "phase": "provision", "containment": "wslc", + "network": { + "egress": { "default": "allow" }, + "ingress": { "default": "allow", "hostLoopback": "allow" } + }, "wslc": { "provision": { "portMappings": [ @@ -84,15 +88,26 @@ using the same entry shape as the one-shot `wslc.portMappings` list: } ``` -The field requires development contract `1.1.0-alpha`; the published -`0.9.0-alpha` WSLC contract does not declare it. The daemon creates one -container per sandbox, so a mapping applies only to the sandbox that declared -it, unlike the session-wide `cpuCount` / `memoryMb` / `gpu` / `storagePath` -knobs that remain one-shot-only. +Port mappings require bridged networking. A request that leaves `network` out, +or sets it to the all-`deny` isolated posture, is rejected at provision: the +container has no networking for a forward to reach. -Both surfaces run the same duplicate check, so two entries claiming the same -`windowsPort` are rejected identically. Zero ports and non-TCP protocols are -rejected when the exact contract deserializes. +The field requires development contract `1.1.0-alpha`. State-aware WSLC +requests default to `0.9.0-alpha`, so the version has to be set explicitly. +The daemon shares one session (VM) but creates a separate container for each +provision, so a mapping applies only to the container that declared it, unlike +the session-wide `cpuCount` / `memoryMb` / `gpu` / `storagePath` knobs that +remain one-shot-only. + +The one-shot `wslc.portMappings` list and `wslc.provision.portMappings` share +one duplicate check, so two entries claiming the same `windowsPort` are +rejected identically. Zero ports and non-TCP protocols are rejected when the +exact contract deserializes. + +The forward listens on `127.0.0.1` only, so a mapped port reaches the container +from the host itself and not from other machines. WSLC installs it when the +container starts rather than at provision, so a `windowsPort` that another +process already holds on loopback fails the `start` phase, not `provision`. ## Sandbox IDs @@ -293,8 +308,8 @@ fixtures **through the harness**, not by pointing `wxc-exec --config` at them di handle) + an off-thread wait + an on-worker `ExecReap`, with a per-container `in_flight` slot. This is tracked as follow-up work. -- **Port mappings are Rust-only for now.** `schemas/schema-version.json` pins - `stateAwareWslc` to `0.9.0-alpha`, and the Node helper refuses a version other - than that default, so `wslc.provision.portMappings` is reachable today through - the typed Rust SDK and raw `1.1.0-alpha` JSON only. Node and .NET support - follows when the pin moves. +- **No typed SDK can set port mappings yet.** The Rust, Node, and .NET v1 SDKs + all pin the published stable contract `1.0.0`, which does not declare the + field, and released schemas are immutable. Raw `1.1.0-alpha` JSON through the + FFI or `wxc-exec` is the only way to reach it today. Promoting `1.1.0-alpha` + to a stable release is what lets the three SDKs expose it together. diff --git a/docs/development/architecture/container-lifecycle.md b/docs/development/architecture/container-lifecycle.md index e60d3b820..110229bd1 100644 --- a/docs/development/architecture/container-lifecycle.md +++ b/docs/development/architecture/container-lifecycle.md @@ -513,9 +513,10 @@ interface PortMapping { } ``` -`wslc.provision.portMappings` requires schema `1.1.0-alpha`; the published -`0.9.0-alpha` WSLC contract does not declare it. Two entries claiming the same -`windowsPort` are rejected. +`wslc.provision.portMappings` requires schema `1.1.0-alpha`; state-aware WSLC +requests default to `0.9.0-alpha`. Two entries claiming the same `windowsPort` +are rejected. The v1 SDKs pin stable `1.0.0` and cannot set the field, so it is +reachable only through raw `1.1.0-alpha` JSON. | Layer | Wire shape | Constraint | |---|---|---| diff --git a/docs/schema.md b/docs/schema.md index 0f750975b..0526af50c 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -495,6 +495,10 @@ WSLC `provision` accepts `wslc.provision.portMappings`, the same "version": "1.1.0-alpha", "phase": "provision", "containment": "wslc", + "network": { + "egress": { "default": "allow" }, + "ingress": { "default": "allow", "hostLoopback": "allow" } + }, "wslc": { "provision": { "image": "alpine:latest", @@ -506,6 +510,9 @@ WSLC `provision` accepts `wslc.provision.portMappings`, the same } ``` +Mappings require the bridged (all-`allow`) posture shown above; the isolated +posture is rejected at provision. + Two entries claiming the same `windowsPort` are rejected on both surfaces. The session sizing knobs (`cpuCount` / `memoryMb` / `gpu` / `storagePath`) stay one-shot-only, because the state-aware daemon shares one WSL session across diff --git a/src/mxc-sdk/src/backends/wslc/common/policy.rs b/src/mxc-sdk/src/backends/wslc/common/policy.rs index 6e6630424..face42553 100644 --- a/src/mxc-sdk/src/backends/wslc/common/policy.rs +++ b/src/mxc-sdk/src/backends/wslc/common/policy.rs @@ -80,6 +80,26 @@ pub(crate) fn network_is_isolated(request: &ExecutionRequest) -> bool { .is_none_or(|egress| egress.default == NetworkAction::Deny) } +/// Reject port mappings that isolated networking cannot carry. `field_path` +/// names the list in the caller's own request shape. +/// +/// WslcCreateContainer fails with E_INVALIDARG when port mappings accompany +/// isolated networking. +pub(crate) fn reject_port_mappings_without_bridged_network( + request: &ExecutionRequest, + field_path: &str, + has_port_mappings: bool, +) -> Result<(), MxcError> { + if has_port_mappings && network_is_isolated(request) { + return Err(MxcError::policy_validation(format!( + "WSLc: {field_path} requires bridged networking. Set network.egress.default, \ + network.ingress.default and network.ingress.hostLoopback all to 'allow', or remove \ + the port mappings" + ))); + } + Ok(()) +} + /// No firewall is installed inside or outside a WSLc container. NONE denies all /// connectivity; BRIDGED cannot promise either inbound or host-loopback filtering. pub(crate) fn validate_directional_network(request: &ExecutionRequest) -> Result<(), MxcError> { diff --git a/src/mxc-sdk/src/backends/wslc/common/state_aware.rs b/src/mxc-sdk/src/backends/wslc/common/state_aware.rs index f18a68c85..ba005f5ce 100644 --- a/src/mxc-sdk/src/backends/wslc/common/state_aware.rs +++ b/src/mxc-sdk/src/backends/wslc/common/state_aware.rs @@ -528,7 +528,7 @@ fn build_provision_config( .as_ref() .and_then(|c| c.image.clone()) .unwrap_or_else(|| DEFAULT_IMAGE.to_string()); - let port_mappings = config + let port_mappings: Vec = config .as_ref() .and_then(|c| c.port_mappings.as_ref()) .map(|mappings| { @@ -564,6 +564,12 @@ fn build_provision_config( let volumes = build_daemon_volumes(request)?; let network = map_network(request); + crate::wslc_common::policy::reject_port_mappings_without_bridged_network( + request, + "wslc.provision.portMappings", + !port_mappings.is_empty(), + )?; + Ok(ProvisionConfig { image, image_tar_path, @@ -918,12 +924,12 @@ mod tests { fn build_provision_config_forwards_port_mappings_to_the_daemon() { let phase = WslcProvisionConfig { port_mappings: Some(vec![ - wxc_common::models::PortMapping { + crate::mxc_common::models::PortMapping { windows_port: 8080, container_port: 80, protocol: "tcp".to_string(), }, - wxc_common::models::PortMapping { + crate::mxc_common::models::PortMapping { windows_port: 8443, container_port: 443, protocol: "tcp".to_string(), @@ -931,7 +937,7 @@ mod tests { ]), ..Default::default() }; - let cfg = build_provision_config(&ExecutionRequest::default(), Some(phase)).unwrap(); + let cfg = build_provision_config(&bridged_request(), Some(phase)).unwrap(); assert_eq!( cfg.port_mappings, vec![ @@ -947,6 +953,45 @@ mod tests { ); } + fn bridged_request() -> ExecutionRequest { + ExecutionRequest { + policy: ContainerPolicy { + network_egress: Some(NetworkEgressPolicy { + default: NetworkAction::Allow, + ..Default::default() + }), + network_ingress: Some(NetworkIngressPolicy { + default: NetworkAction::Allow, + host_loopback: NetworkAction::Allow, + }), + ..Default::default() + }, + ..Default::default() + } + } + + #[test] + fn isolated_networking_rejects_port_mappings_before_the_daemon_sees_them() { + let phase = WslcProvisionConfig { + port_mappings: Some(vec![crate::mxc_common::models::PortMapping { + windows_port: 8080, + container_port: 80, + protocol: "tcp".to_string(), + }]), + ..Default::default() + }; + let err = build_provision_config(&ExecutionRequest::default(), Some(phase)).unwrap_err(); + assert_eq!( + err.code, + crate::mxc_common::mxc_error::MxcErrorCode::PolicyValidation + ); + assert!( + err.message.contains("portMappings"), + "the message must name the field to remove; got: {}", + err.message + ); + } + #[test] fn build_provision_config_distinguishes_absent_from_empty_port_mappings() { for mappings in [None, Some(Vec::new())] { diff --git a/src/mxc-sdk/src/backends/wslc/common/wsl_container_runner.rs b/src/mxc-sdk/src/backends/wslc/common/wsl_container_runner.rs index 19efad8a6..4037824c1 100644 --- a/src/mxc-sdk/src/backends/wslc/common/wsl_container_runner.rs +++ b/src/mxc-sdk/src/backends/wslc/common/wsl_container_runner.rs @@ -430,6 +430,12 @@ impl ScriptRunner for WSLContainerRunner { policy_mapping::container_working_directory(&request.working_directory) .map_err(|msg| WslcError::Rejected(msg).into_response())?; policy::reject_ui_policy(request).map_err(as_wslc_rejection)?; + policy::reject_port_mappings_without_bridged_network( + request, + "wslc.portMappings", + !self.config.port_mappings.is_empty(), + ) + .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, policy::network_policy_support()) @@ -1839,6 +1845,66 @@ mod tests { assert!(runner.validate_runner(&request).is_ok()); } + #[test] + fn one_shot_rejects_port_mappings_under_isolated_networking() { + let config = WslcConfig { + port_mappings: vec![crate::mxc_common::models::PortMapping { + windows_port: 8080, + container_port: 80, + protocol: "tcp".to_string(), + }], + ..Default::default() + }; + let request = ExecutionRequest { + containment: crate::mxc_common::models::ContainmentBackend::Wslc, + ..Default::default() + }; + + let err = WSLContainerRunner::new(&config) + .validate_runner(&request) + .unwrap_err(); + assert_eq!( + err.failure_phase, + crate::mxc_common::models::FailurePhase::Rejected + ); + assert!( + err.error_message.contains("wslc.portMappings"), + "the message must name the one-shot list; got: {}", + err.error_message + ); + } + + #[test] + fn one_shot_accepts_port_mappings_under_bridged_networking() { + let config = WslcConfig { + port_mappings: vec![crate::mxc_common::models::PortMapping { + windows_port: 8080, + container_port: 80, + protocol: "tcp".to_string(), + }], + ..Default::default() + }; + let request = ExecutionRequest { + containment: crate::mxc_common::models::ContainmentBackend::Wslc, + policy: crate::mxc_common::models::ContainerPolicy { + network_egress: Some(crate::mxc_common::models::NetworkEgressPolicy { + default: crate::mxc_common::models::NetworkAction::Allow, + ..Default::default() + }), + network_ingress: Some(crate::mxc_common::models::NetworkIngressPolicy { + default: crate::mxc_common::models::NetworkAction::Allow, + host_loopback: crate::mxc_common::models::NetworkAction::Allow, + }), + ..Default::default() + }, + ..Default::default() + }; + + assert!(WSLContainerRunner::new(&config) + .validate_runner(&request) + .is_ok()); + } + /// `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. diff --git a/src/mxc-sdk/src/bin/wslc_daemon/session_manager.rs b/src/mxc-sdk/src/bin/wslc_daemon/session_manager.rs index 3a1371ae2..0c4f9676f 100644 --- a/src/mxc-sdk/src/bin/wslc_daemon/session_manager.rs +++ b/src/mxc-sdk/src/bin/wslc_daemon/session_manager.rs @@ -38,8 +38,8 @@ use mxc_sdk::mxc_common::logger::{Logger, Mode}; use mxc_sdk::mxc_common::models::{FailurePhase, ScriptResponse}; use mxc_sdk::wslc_common::container_steps::{self, OutStream, OutputSink, ProcessSettings}; use mxc_sdk::wslc_common::daemon_protocol::{ - DeprovisionConfig, ErrKind, ExecConfig, ExecTerminal, NetworkMode, ProvisionConfig, - StartConfig, StopConfig, + DeprovisionConfig, ErrKind, ExecConfig, ExecTerminal, NetworkMode, PortMapping, + ProvisionConfig, StartConfig, StopConfig, }; use mxc_sdk::wslc_common::image; use mxc_sdk::wslc_common::policy_mapping; @@ -715,15 +715,7 @@ impl Worker { WslcContainerNetworkingMode::WSLC_CONTAINER_NETWORKING_MODE_BRIDGED } }; - let port_mappings: Vec = config - .port_mappings - .iter() - .map(|p| wxc_common::models::PortMapping { - windows_port: p.windows_port, - container_port: p.container_port, - protocol: "tcp".to_string(), - }) - .collect(); + let port_mappings = to_model_port_mappings(&config.port_mappings); // SAFETY: `sdk`/`session` are valid; every buffer the SDK stores pointers // into is owned by a stationary local (`keepalive`) until create returns. @@ -1155,6 +1147,20 @@ impl Drop for ComApartment { } } +/// The daemon IPC frame carries no protocol, so every mapping is rebuilt as TCP. +fn to_model_port_mappings( + mappings: &[PortMapping], +) -> Vec { + mappings + .iter() + .map(|mapping| mxc_sdk::mxc_common::models::PortMapping { + windows_port: mapping.windows_port, + container_port: mapping.container_port, + protocol: "tcp".to_string(), + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -1165,6 +1171,41 @@ mod tests { // ---- No-WSL unit tests (run everywhere, never touch the SDK) ---- + #[test] + fn port_mappings_convert_to_tcp_models_in_order() { + let converted = to_model_port_mappings(&[ + PortMapping { + windows_port: 8080, + container_port: 80, + }, + PortMapping { + windows_port: 8443, + container_port: 443, + }, + ]); + + assert_eq!( + converted, + vec![ + mxc_sdk::mxc_common::models::PortMapping { + windows_port: 8080, + container_port: 80, + protocol: "tcp".to_string(), + }, + mxc_sdk::mxc_common::models::PortMapping { + windows_port: 8443, + container_port: 443, + protocol: "tcp".to_string(), + }, + ] + ); + } + + #[test] + fn no_port_mappings_convert_to_an_empty_list() { + assert!(to_model_port_mappings(&[]).is_empty()); + } + /// Build a worker entry around a handle that is never dereferenced. fn test_entry(retired: bool) -> ContainerEntry { unsafe extern "C" fn release_noop( diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs index 431596aac..b0265f49f 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs @@ -51,13 +51,15 @@ fn convert_isolation_session_network(value: contract::IsolationSessionNetwork) - } } -fn convert_wslc_port_mapping(value: contract::PortMapping) -> crate::models::PortMapping { +fn convert_wslc_port_mapping( + value: contract::PortMapping, +) -> crate::mxc_common::models::PortMapping { let contract::PortMapping { windows_port, container_port, protocol, } = value; - crate::models::PortMapping { + crate::mxc_common::models::PortMapping { windows_port: windows_port.get(), container_port: container_port.get(), protocol: match protocol.into_option() { diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/provision.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/provision.rs index 18ca377be..bb2ff82fa 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/provision.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/provision.rs @@ -225,7 +225,8 @@ fn wslc_rejects_duplicate_windows_ports() { r#","wslc":{"provision":{"portMappings":[{"windowsPort":8080,"containerPort":80},{"windowsPort":8080,"containerPort":81}]}}"#, ); let request = contract::parse_request(&json).expect("exact contract accepts the shape"); - let Err(error) = crate::config_contract_adapters::dev::adapt_request(request) else { + let Err(error) = crate::mxc_common::config_contract_adapters::dev::adapt_request(request) + else { panic!("duplicate windowsPort must be rejected"); }; let message = error.to_string(); diff --git a/src/mxc-sdk/src/core/mxc_common/config_parser.rs b/src/mxc-sdk/src/core/mxc_common/config_parser.rs index 7a77274ee..a717aa457 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_parser.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_parser.rs @@ -1551,7 +1551,7 @@ fn normalize_common_request_ir( protocol: "tcp".to_string(), }) .collect(); - crate::validator::validate_port_mappings("wslc.portMappings", &converted)?; + crate::mxc_common::validator::validate_port_mappings("wslc.portMappings", &converted)?; config.port_mappings = converted; } Some(config) @@ -2955,10 +2955,10 @@ mod tests { ), ( "wslc_state_aware_provision_port_mappings.json", - StateAwareProvision::Wslc(Some(crate::models::WslcProvisionConfig { + StateAwareProvision::Wslc(Some(crate::mxc_common::models::WslcProvisionConfig { image: Some("python:3.12-alpine".into()), image_tar_path: None, - port_mappings: Some(vec![crate::models::PortMapping { + port_mappings: Some(vec![crate::mxc_common::models::PortMapping { windows_port: 18081, container_port: 8080, protocol: "tcp".into(), diff --git a/src/mxc-sdk/src/core/mxc_common/policy_identity.rs b/src/mxc-sdk/src/core/mxc_common/policy_identity.rs index 341cd26f2..8d3d1dbd6 100644 --- a/src/mxc-sdk/src/core/mxc_common/policy_identity.rs +++ b/src/mxc-sdk/src/core/mxc_common/policy_identity.rs @@ -999,6 +999,50 @@ mod tests { "imageTarPath": "C:\\images\\custom.tar", }), ), + ( + "wslc", + Some(r#"{"portMappings":[]}"#), + serde_json::json!({"portMappings": []}), + ), + ( + "wslc", + Some(r#"{"portMappings":[{"windowsPort":8080,"containerPort":80}]}"#), + serde_json::json!({ + "portMappings": [ + {"windowsPort": 8080, "containerPort": 80, "protocol": "tcp"}, + ], + }), + ), + ( + "wslc", + Some(r#"{"portMappings":[{"windowsPort":8081,"containerPort":80}]}"#), + serde_json::json!({ + "portMappings": [ + {"windowsPort": 8081, "containerPort": 80, "protocol": "tcp"}, + ], + }), + ), + ( + "wslc", + Some(r#"{"portMappings":[{"windowsPort":8080,"containerPort":81}]}"#), + serde_json::json!({ + "portMappings": [ + {"windowsPort": 8080, "containerPort": 81, "protocol": "tcp"}, + ], + }), + ), + ( + "wslc", + Some( + r#"{"image":"alpine:latest","portMappings":[{"windowsPort":8080,"containerPort":80}]}"#, + ), + serde_json::json!({ + "image": "alpine:latest", + "portMappings": [ + {"windowsPort": 8080, "containerPort": 80, "protocol": "tcp"}, + ], + }), + ), ] { let backend_section = match (backend, payload) { (_, None) => String::new(), diff --git a/src/mxc-sdk/src/core/mxc_common/sdk_input.rs b/src/mxc-sdk/src/core/mxc_common/sdk_input.rs index aa144845e..bcd15c725 100644 --- a/src/mxc-sdk/src/core/mxc_common/sdk_input.rs +++ b/src/mxc-sdk/src/core/mxc_common/sdk_input.rs @@ -197,7 +197,7 @@ impl SdkStateAwareInput { fn wslc_port_mappings_requested(operation: &StateAwareOperation) -> bool { matches!( operation, - StateAwareOperation::Provision(crate::state_aware_operation::StateAwareProvision::Wslc( + StateAwareOperation::Provision(crate::mxc_common::state_aware_operation::StateAwareProvision::Wslc( Some(config) )) if config.port_mappings.is_some() ) @@ -285,4 +285,75 @@ mod tests { assert!(error.to_string().contains("do not support Windows Sandbox")); } } + + fn mapping(windows_port: u16, container_port: u16) -> crate::mxc_common::models::PortMapping { + crate::mxc_common::models::PortMapping { + windows_port, + container_port, + protocol: "tcp".to_string(), + } + } + + fn wslc_provision( + port_mappings: Option>, + ) -> StateAwareOperation { + StateAwareOperation::Provision( + crate::mxc_common::state_aware_operation::StateAwareProvision::Wslc(Some( + crate::mxc_common::models::WslcProvisionConfig { + image: None, + image_tar_path: None, + port_mappings, + }, + )), + ) + } + + #[test] + fn the_stable_contract_rejects_port_mappings_even_when_the_list_is_empty() { + for mappings in [Vec::new(), vec![mapping(8080, 80)]] { + let error = + SdkStateAwareInput::new(ContractVersion::V1_0_0, wslc_provision(Some(mappings))) + .expect_err("1.0.0 does not declare the field"); + assert!( + error.to_string().contains("1.1.0-alpha"), + "the message must name the version that declares it; got: {error}" + ); + } + } + + #[test] + fn the_alpha_contract_accepts_port_mappings() { + SdkStateAwareInput::new( + ContractVersion::V1_1_0Alpha, + wslc_provision(Some(vec![mapping(8080, 80)])), + ) + .expect("1.1.0-alpha declares the field"); + } + + #[test] + fn absent_port_mappings_are_accepted_on_both_contracts() { + for version in [ContractVersion::V1_0_0, ContractVersion::V1_1_0Alpha] { + SdkStateAwareInput::new(version, wslc_provision(None)) + .expect("an absent list clears the gate on every supported contract"); + } + } + + #[test] + fn the_constructor_validates_ports_before_normalization() { + for mappings in [ + vec![mapping(0, 80)], + vec![mapping(8080, 0)], + vec![mapping(8080, 80), mapping(8080, 81)], + ] { + let error = SdkStateAwareInput::new( + ContractVersion::V1_1_0Alpha, + wslc_provision(Some(mappings)), + ) + .expect_err("a direct caller bypasses the exact contract's structural checks"); + assert!( + error.to_string().contains("portMappings"), + "the message must name the field; got: {error}" + ); + } + } } diff --git a/src/mxc-sdk/src/core/mxc_common/state_aware_binding_tests.rs b/src/mxc-sdk/src/core/mxc_common/state_aware_binding_tests.rs index 40bd14b9b..f910211e5 100644 --- a/src/mxc-sdk/src/core/mxc_common/state_aware_binding_tests.rs +++ b/src/mxc-sdk/src/core/mxc_common/state_aware_binding_tests.rs @@ -4,6 +4,7 @@ use super::*; use crate::mxc_common::config_parser::load_mxc_request_from_json; use crate::mxc_common::logger::{Logger, Mode}; +use crate::mxc_common::models::PortMapping; use crate::mxc_common::mxc_error::MxcErrorCode; use crate::mxc_common::state_aware_backend::{ null_pipe_handle, DeprovisionResult, ExecHandle, ExecOutcome, ExecStdio, ProvisionResult, @@ -22,7 +23,7 @@ enum Config { Absent, Unit, Isolation(Option), - Wslc(Option, Option), + Wslc(Option, Option, Option>), } trait Case: Sized { @@ -75,7 +76,11 @@ impl Case for Wslc { const PREFIX: &'static str = "wslc"; fn observe(config: Option<&Self::ProvisionConfig>) -> Config { config.map_or(Config::Absent, |config| { - Config::Wslc(config.image.clone(), config.image_tar_path.clone()) + Config::Wslc( + config.image.clone(), + config.image_tar_path.clone(), + config.port_mappings.clone(), + ) }) } fn bind( @@ -438,22 +443,63 @@ fn wslc_provision_preserves_each_backend_observable_configuration() { value["wslc"] = json!({}); assert_dispatch::(&value, Config::Absent); for (config, expected) in [ - (json!({}), Config::Wslc(None, None)), + (json!({}), Config::Wslc(None, None, None)), ( json!({"image": "custom"}), - Config::Wslc(Some("custom".into()), None), + Config::Wslc(Some("custom".into()), None, None), ), ( json!({"imageTarPath": "image.tar"}), - Config::Wslc(None, Some("image.tar".into())), + Config::Wslc(None, Some("image.tar".into()), None), ), ( json!({"image": "custom", "imageTarPath": "image.tar"}), - Config::Wslc(Some("custom".into()), Some("image.tar".into())), + Config::Wslc(Some("custom".into()), Some("image.tar".into()), None), ), ( json!({"image": "", "imageTarPath": ""}), - Config::Wslc(Some(String::new()), Some(String::new())), + Config::Wslc(Some(String::new()), Some(String::new()), None), + ), + ( + json!({"portMappings": []}), + Config::Wslc(None, None, Some(Vec::new())), + ), + ( + json!({"portMappings": [{"windowsPort": 8080, "containerPort": 80}]}), + Config::Wslc( + None, + None, + Some(vec![PortMapping { + windows_port: 8080, + container_port: 80, + protocol: "tcp".into(), + }]), + ), + ), + ( + json!({ + "image": "custom", + "portMappings": [ + {"windowsPort": 8080, "containerPort": 80}, + {"windowsPort": 8443, "containerPort": 443}, + ], + }), + Config::Wslc( + Some("custom".into()), + None, + Some(vec![ + PortMapping { + windows_port: 8080, + container_port: 80, + protocol: "tcp".into(), + }, + PortMapping { + windows_port: 8443, + container_port: 443, + protocol: "tcp".into(), + }, + ]), + ), ), ] { value["wslc"] = json!({"provision": config}); diff --git a/src/mxc-sdk/src/core/mxc_common/state_aware_operation.rs b/src/mxc-sdk/src/core/mxc_common/state_aware_operation.rs index 951103d01..863699fc4 100644 --- a/src/mxc-sdk/src/core/mxc_common/state_aware_operation.rs +++ b/src/mxc-sdk/src/core/mxc_common/state_aware_operation.rs @@ -86,11 +86,11 @@ impl StateAwareOperation { /// /// Both the JSON and typed-SDK entry points call this, so neither surface /// can accept a payload the other rejects. - pub(crate) fn validate(&self) -> Result<(), crate::error::WxcError> { + pub(crate) fn validate(&self) -> Result<(), crate::mxc_common::error::WxcError> { match self { Self::Provision(StateAwareProvision::Wslc(Some(config))) => { match config.port_mappings.as_deref() { - Some(mappings) => crate::validator::validate_port_mappings( + Some(mappings) => crate::mxc_common::validator::validate_port_mappings( "wslc.provision.portMappings", mappings, ), diff --git a/src/mxc-sdk/src/core/mxc_common/wire.rs b/src/mxc-sdk/src/core/mxc_common/wire.rs index a5f510dff..62d5bc22a 100644 --- a/src/mxc-sdk/src/core/mxc_common/wire.rs +++ b/src/mxc-sdk/src/core/mxc_common/wire.rs @@ -488,20 +488,10 @@ pub struct Wslc { pub provision: Option, } -/// Per-phase WSLc **provision** normalization input, mirroring the exact -/// `wslc.provision` contract: 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 session -/// sizing knobs (`cpuCount` / `memoryMb` / `gpu` / `storagePath`) are absent -/// because the daemon shares a single session across sandboxes and cannot set -/// them per sandbox. 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. -/// -/// State-aware provisioning builds `models::WslcProvisionConfig` directly, so -/// nothing populates this struct today; it documents the normalized shape. +/// Unused partial normalization shape for the WSLc **provision** phase: it +/// carries only the image fields and does not track the exact contract, which +/// also declares `portMappings`. State-aware provisioning builds +/// `models::WslcProvisionConfig` directly, so nothing populates this struct. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct WslcProvisionPhase { diff --git a/src/mxc-sdk/tests/mxc_contract/v0_9_0_alpha/state_aware/provision/wslc.rs b/src/mxc-sdk/tests/mxc_contract/v0_9_0_alpha/state_aware/provision/wslc.rs index 2477ca6dc..afca5d8bf 100644 --- a/src/mxc-sdk/tests/mxc_contract/v0_9_0_alpha/state_aware/provision/wslc.rs +++ b/src/mxc-sdk/tests/mxc_contract/v0_9_0_alpha/state_aware/provision/wslc.rs @@ -100,6 +100,16 @@ fn rejects_null_unknown_and_foreign_fields() { } } +#[test] +fn rejects_the_development_only_port_mappings_field() { + for fields in [ + r#","wslc": {"provision": {"portMappings": []}}"#, + r#","wslc": {"provision": {"portMappings": [{"windowsPort": 8080, "containerPort": 80}]}}"#, + ] { + assert_invalid(&request(fields)); + } +} + #[test] fn rejects_duplicate_wslc_provision_fields() { for fields in [ diff --git a/src/mxc-sdk/tests/mxc_contract/v1_0_0/state_aware/provision/wslc.rs b/src/mxc-sdk/tests/mxc_contract/v1_0_0/state_aware/provision/wslc.rs index 25a81ef65..a00321682 100644 --- a/src/mxc-sdk/tests/mxc_contract/v1_0_0/state_aware/provision/wslc.rs +++ b/src/mxc-sdk/tests/mxc_contract/v1_0_0/state_aware/provision/wslc.rs @@ -100,6 +100,16 @@ fn rejects_null_unknown_and_foreign_fields() { } } +#[test] +fn rejects_the_development_only_port_mappings_field() { + for fields in [ + r#","wslc": {"provision": {"portMappings": []}}"#, + r#","wslc": {"provision": {"portMappings": [{"windowsPort": 8080, "containerPort": 80}]}}"#, + ] { + assert_invalid(&request(fields)); + } +} + #[test] fn rejects_duplicate_wslc_provision_fields() { for fields in [ diff --git a/tests/configs/wslc_state_aware_exec_sibling_port_bind.json b/tests/configs/wslc_state_aware_exec_sibling_port_bind.json new file mode 100644 index 000000000..b6829ec18 --- /dev/null +++ b/tests/configs/wslc_state_aware_exec_sibling_port_bind.json @@ -0,0 +1,10 @@ +{ + "$schema": "../../schemas/stable/mxc-config.schema.1.0.0.json", + "version": "1.0.0", + "phase": "exec", + "sandboxId": "wslc:{{SANDBOX_ID}}", + "process": { + "commandLine": "sh -c 'nohup python3 -c \"import socket; s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1); s.bind((\\\"0.0.0.0\\\",8080)); s.listen(8); c,_=s.accept(); c.sendall(b\\\"SIBLING_SENTINEL_MUST_NOT_ESCAPE\\\"); c.close()\" >/tmp/listener.log 2>&1 & sleep 1; echo LISTENER_STARTED'", + "timeout": 30000 + } +} diff --git a/tests/configs/wslc_state_aware_provision_unmapped_sibling.json b/tests/configs/wslc_state_aware_provision_unmapped_sibling.json new file mode 100644 index 000000000..c8c0e5214 --- /dev/null +++ b/tests/configs/wslc_state_aware_provision_unmapped_sibling.json @@ -0,0 +1,15 @@ +{ + "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "version": "1.1.0-alpha", + "phase": "provision", + "containment": "wslc", + "network": { + "egress": { "default": "allow" }, + "ingress": { "default": "allow", "hostLoopback": "allow" } + }, + "wslc": { + "provision": { + "image": "python:3.12-alpine" + } + } +} diff --git a/tests/scripts/run_wslc_state_aware_tests.ps1 b/tests/scripts/run_wslc_state_aware_tests.ps1 index 7fb052713..a0a3dd455 100644 --- a/tests/scripts/run_wslc_state_aware_tests.ps1 +++ b/tests/scripts/run_wslc_state_aware_tests.ps1 @@ -381,8 +381,8 @@ function Test-DaemonRunning { $null -ne (Get-Process -Name $DaemonProcName -ErrorAction SilentlyContinue) } -# Bind port 0 so the OS picks a free ephemeral port, then release it. The port -# is only probably still free by the time the caller binds it. +# Bind port 0 on loopback so the OS picks a free ephemeral port, then release +# it. WSLC forwards on 127.0.0.1 only, so that is the scope that has to be free. function Get-FreeTcpPort { $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) $listener.Start() @@ -424,6 +424,40 @@ function Get-TcpResponse { $null } +# Provision and start a sandbox with a mapped host port, retrying when another +# process takes the port first. WSLC installs the forward at container start +# rather than at provision, so a port claimed in between fails the start phase +# with HRESULT 0x80072740. +function New-PortMappedSandbox { + param([int]$MaxAttempts = 3) + + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + $port = Get-FreeTcpPort + $provision = Invoke-StateAware ` + -ConfigFile 'wslc_state_aware_provision_port_mappings.json' -WindowsPort $port + $provisionEnv = Parse-Envelope -Stdout $provision.Stdout + if ((Envelope-Arm $provisionEnv) -ne 'result') { + return @{ Port = $port; SandboxId = $null; Provision = $provision; Start = $null } + } + + $sandboxId = [string]$provisionEnv.result.sandboxId + $start = Invoke-StateAware -ConfigFile 'wslc_state_aware_start.json' -SandboxId $sandboxId + $startEnv = Parse-Envelope -Stdout $start.Stdout + $portTaken = (Envelope-Arm $startEnv) -eq 'error' -and + "$($startEnv.error.message)" -match '0x80072740' + + if (-not $portTaken -or $attempt -eq $MaxAttempts) { + return @{ Port = $port; SandboxId = $sandboxId; Provision = $provision; Start = $start } + } + + Write-Host " host port $port was taken before start; retrying" -ForegroundColor DarkGray + try { + $null = Invoke-StateAware ` + -ConfigFile 'wslc_state_aware_deprovision.json' -SandboxId $sandboxId + } catch { } + } +} + $script:TestResults = @() $script:currentTestPassed = $true $script:currentTestFirstFailReason = $null @@ -886,19 +920,18 @@ try { $script:portSandboxId = $null $script:portStarted = $false $portDeprovisionedOk = $false -$hostPort = Get-FreeTcpPort +$portSandbox = New-PortMappedSandbox +$hostPort = $portSandbox.Port +$script:portSandboxId = $portSandbox.SandboxId try { $portProvisionedOk = Run-StateAwareTest "CP: provision (port mappings)" { - $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_port_mappings.json' -WindowsPort $hostPort - $envObj = Assert-ResultEnvelope $r "port-mapping provision" - if ($envObj) { $script:portSandboxId = [string]$envObj.result.sandboxId } + $null = Assert-ResultEnvelope $portSandbox.Provision "port-mapping provision" } $portStartedOk = $false if ($portProvisionedOk) { $portStartedOk = Run-StateAwareTest "CP: start" { - $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_start.json' -SandboxId $script:portSandboxId - $null = Assert-ResultEnvelope $r "port-mapping start" + $null = Assert-ResultEnvelope $portSandbox.Start "port-mapping start" $script:portStarted = $true } } @@ -943,6 +976,96 @@ try { } } +# ---------------- Lifecycle CQ: mapped-container ownership ---------------- + +# The forward belongs to one container, so a sibling listening on the same +# container port must not answer on it, a second container cannot take a host +# port already forwarded, and deprovisioning must release the port for reuse. +$script:cqSandboxId = $null +$script:cqStarted = $false +$script:cqSiblingId = $null +$script:cqCollisionId = $null +$script:cqReuseId = $null +$cqSandbox = New-PortMappedSandbox +$cqPort = $cqSandbox.Port +$script:cqSandboxId = $cqSandbox.SandboxId +try { + $cqReady = $false + if ($null -ne $script:cqSandboxId -and (Envelope-Arm (Parse-Envelope -Stdout $cqSandbox.Start.Stdout)) -eq 'result') { + $script:cqStarted = $true + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_exec_port_bind.json' -SandboxId $script:cqSandboxId + $cqReady = ($r.ExitCode -eq 0 -and $r.Stdout -match 'LISTENER_STARTED') + } + + if ($cqReady) { + Run-StateAwareTest "CQ: an unmapped sibling on the same container port never answers the forward" { + $script:cqSiblingId = Provision-Sandbox -ConfigFile 'wslc_state_aware_provision_unmapped_sibling.json' + Assert-True ($null -ne $script:cqSiblingId) "sibling provisioned" + if ($script:cqSiblingId) { + $null = Invoke-StateAware -ConfigFile 'wslc_state_aware_start.json' -SandboxId $script:cqSiblingId + $s = Invoke-StateAware -ConfigFile 'wslc_state_aware_exec_sibling_port_bind.json' -SandboxId $script:cqSiblingId + Assert-True ($s.Stdout -match 'LISTENER_STARTED') "sibling listener started on the same container port" + + $response = Get-TcpResponse -Port $cqPort + Assert-True ($response -match 'STATE_AWARE_PORT_MAPPING_OK') ` + "the forward answered from its own container (got '$response')" + Assert-True ($response -notmatch 'SIBLING_SENTINEL_MUST_NOT_ESCAPE') ` + "the unmapped sibling never reached the host port (got '$response')" + } + } | Out-Null + + Run-StateAwareTest "CQ: a second container cannot claim a host port already forwarded" { + $p = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_port_mappings.json' -WindowsPort $cqPort + $envObj = Parse-Envelope -Stdout $p.Stdout + if ((Envelope-Arm $envObj) -eq 'result') { + $script:cqCollisionId = [string]$envObj.result.sandboxId + $s = Invoke-StateAware -ConfigFile 'wslc_state_aware_start.json' -SandboxId $script:cqCollisionId + Assert-True ($s.ExitCode -ne 0) "the colliding sandbox failed to start" + $startEnv = Parse-Envelope -Stdout $s.Stdout + $code = if ($startEnv) { $startEnv.error.code } else { '' } + Assert-True ($code -eq 'backend_error') "the collision surfaced as 'backend_error' (got '$code')" + Assert-True ("$($startEnv.error.message)" -match '0x80072740') ` + "the message carries the address-in-use HRESULT (got '$($startEnv.error.message)')" + } else { + Assert-True $false "the colliding provision returned an envelope: $($p.Stdout)" + } + } | Out-Null + } + + # Release the port, then prove a fresh sandbox can forward it again. + if ($script:cqSandboxId) { + if ($script:cqStarted) { + $null = Invoke-StateAware -ConfigFile 'wslc_state_aware_stop.json' -SandboxId $script:cqSandboxId + $script:cqStarted = $false + } + $null = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision.json' -SandboxId $script:cqSandboxId + $script:cqSandboxId = $null + + Run-StateAwareTest "CQ: deprovision releases the host port for reuse ($cqPort)" { + $p = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_port_mappings.json' -WindowsPort $cqPort + $envObj = Assert-ResultEnvelope $p "same-port reprovision" + if ($envObj) { + $script:cqReuseId = [string]$envObj.result.sandboxId + $s = Invoke-StateAware -ConfigFile 'wslc_state_aware_start.json' -SandboxId $script:cqReuseId + $null = Assert-ResultEnvelope $s "same-port start" + $e = Invoke-StateAware -ConfigFile 'wslc_state_aware_exec_port_bind.json' -SandboxId $script:cqReuseId + Assert-True ($e.Stdout -match 'LISTENER_STARTED') "listener started in the reusing sandbox" + $response = Get-TcpResponse -Port $cqPort + Assert-True ($response -match 'STATE_AWARE_PORT_MAPPING_OK') ` + "the reused host port forwards again (got '$response')" + } + } | Out-Null + } +} finally { + foreach ($id in @($script:cqSiblingId, $script:cqCollisionId, $script:cqReuseId, $script:cqSandboxId)) { + if ($id) { + Write-Host "[cleanup] best-effort deprovision of $id" -ForegroundColor DarkGray + try { $null = Invoke-StateAware -ConfigFile 'wslc_state_aware_stop.json' -SandboxId $id } catch { } + try { $null = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision.json' -SandboxId $id } catch { } + } + } +} + # ---------------- Lifecycle D: validation rejections ---------------- # Validation runs before any daemon call, so these never provision a real # sandbox and need no cleanup. They cover the provision-phase honor-matrix