Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion docs/backends/wslc/wsl-container-getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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**
Expand Down
61 changes: 56 additions & 5 deletions docs/backends/wslc/wslc-state-aware.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,56 @@ 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",
"network": {
"egress": { "default": "allow" },
"ingress": { "default": "allow", "hostLoopback": "allow" }
},
"wslc": {
"provision": {
"portMappings": [
{ "windowsPort": 8080, "containerPort": 80 }
]
}
}
}
```

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.

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

Expand Down Expand Up @@ -262,3 +307,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.

- **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.
15 changes: 13 additions & 2 deletions docs/development/architecture/container-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ interface ProvisionStateAwareRequest {
provision?: { appId?: string };
};
wslc?: {
provision?: { image?: string; imageTarPath?: string };
provision?: { image?: string; imageTarPath?: string; portMappings?: PortMapping[] };
};
}

Expand Down Expand Up @@ -502,11 +502,22 @@ 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
Comment thread
dhoehna marked this conversation as resolved.
containerPort: number; // 1-65535
protocol?: 'tcp'; // TCP is the only protocol the WSLC runtime implements
}
```

`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 |
|---|---|---|
| Outer key | Permanent camel-case backend section | Must match the selected state-aware-capable backend |
Expand Down
5 changes: 4 additions & 1 deletion docs/development/architecture/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 35 additions & 1 deletion docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Comment on lines +446 to 448

Contracts before `0.9.0-alpha` are retired. The supported published
Expand Down Expand Up @@ -484,6 +485,39 @@ 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",
"network": {
"egress": { "default": "allow" },
"ingress": { "default": "allow", "hostLoopback": "allow" }
},
"wslc": {
"provision": {
"image": "alpine:latest",
"portMappings": [
{ "windowsPort": 8080, "containerPort": 80 }
]
}
}
}
```

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
every sandbox and cannot size them individually.

Full lifecycle API: [container lifecycle](container-lifecycle.md).

### Schema Versioning
Expand Down
7 changes: 7 additions & 0 deletions schemas/dev/mxc-config.schema.1.1.0-alpha.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions sdk/node/src/generated/v1_1_0_alpha/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

/**
Expand Down
7 changes: 4 additions & 3 deletions src/mxc-sdk/src/backends/wslc/common/container_steps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<WslcContainerGuard, ScriptResponse> {
// 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,
Expand Down
19 changes: 18 additions & 1 deletion src/mxc-sdk/src/backends/wslc/common/daemon_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, D::Error>
where
Expand Down Expand Up @@ -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
Expand All @@ -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<PortMapping>,
}

/// Inputs to `start`: the container was created at provision; start boots it.
Expand Down Expand Up @@ -419,6 +432,10 @@ mod tests {
read_only: true,
}],
network: NetworkMode::Bridged,
port_mappings: vec![PortMapping {
windows_port: 8080,
container_port: 80,
}],
}));
}

Expand Down
20 changes: 20 additions & 0 deletions src/mxc-sdk/src/backends/wslc/common/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down
Loading
Loading