WSLC Getting Started

August 26, 2026 · View on GitHub

This guide walks you through setting up the WSL Container (WSLC) backend for MXC, which lets you run Linux containers on Windows using the WSLC SDK.

Note: WSLC is an experimental feature. It requires the --experimental CLI flag, { experimental: true } in TypeScript SDK spawn options, or SandboxRequest::set_experimental(true) in the Rust SDK.

Prerequisites

RequirementDetails
Windows 11Required for WSL2 and the WSLC SDK
WSL 2.9.9+The installed WSL runtime package must meet the WSLC minimum; see Step 1 below for installation
WSLC SDKwslcsdk.dll is a separate client SDK and must be in the same directory as the running executable (wxc-exec.exe, or your own binary when using the Rust SDK)
Container imagesPre-pulled or available from a registry with network access

Step 1 — Install WSL 2.9.9+

The WSLC SDK requires WSL version 2.9.9 or later. Update WSL to the latest version. Note that 2.9.9 may only be available on the pre-release channel until it reaches the default Store channel, so include --pre-release:

wsl --update --pre-release

Verify your WSL version after updating:

wsl --version

The WSL version should be 2.9.9.0 or later. If wsl --update --pre-release does not bring you to the required version, build WSL from the master branch:

git clone https://github.com/microsoft/WSL.git
cd WSL
git checkout master

Follow the build instructions in the WSL repository README to build and install.

Note: Building the WSL repo installs the WSL runtime (the system service). This is separate from wslcsdk.dll, which is the client SDK library. The DLL is bundled in the MXC repo under external/wslc-sdk/ and is automatically extracted when you build MXC with --with-wslc (Step 2).

Step 2 — Build MXC with WSLC support

Build wxc-exec.exe with the wslc feature flag. This compiles the WSLC backend and copies wslcsdk.dll next to the binary:

cd <repo-root>
.\build.bat --with-wslc

Verify the binary starts without errors:

.\src\target\x86_64-pc-windows-msvc\release\wxc-exec.exe --help

Note: wxc-exec.exe does not require wslcsdk.dll at startup. The DLL is loaded at runtime only when the WSLC backend is invoked. All other backends (Process Container, Windows Sandbox) work without it.

Step 3 — Pre-pull container images

MXC is an execution layer and does not pull container images at run time. Pre-pull each image you intend to use into the WSLC SDK cache before invoking a config that references it:

cd <repo-root>
.\scripts\setup-wslc.ps1 -Image alpine:latest, python:3.12-alpine

Or pull a single image directly:

.\src\target\x86_64-pc-windows-msvc\release\wxc-exec.exe `
    --setup-wslc --image alpine:latest

Pulled images persist in the cache until you remove them — pay the cost once per image, not once per run.

Storage path consistency: the cache lives under the WSLC storage_path (default %TEMP%\mxc-wslc-sessions). If your runtime configs override experimental.wslc.storagePath, pass the same value here with -StoragePath (or --storage-path on wxc-exec.exe), otherwise the runner will not find what you just pulled.

If you forget this step, the next wxc-exec.exe invocation will fail fast with an actionable error pointing back at the --setup-wslc command — your image name pre-filled — so the first-time stumble is self-correcting.

Step 4 — Verify WSLC is working

Run the included hello world example config from the repo root:

cd <repo-root>
.\src\target\x86_64-pc-windows-msvc\release\wxc-exec.exe --experimental --debug examples\wslc_hello_world.json

Expected output:

Hello from WSL Container!
Linux <hostname> 6.6.x-microsoft-standard-WSL2 ... x86_64 Linux

Two-step lifecycle

Once setup is done, the day-to-day flow is two distinct commands:

# (one-time per image) pre-pull into the SDK cache
.\scripts\setup-wslc.ps1 -Image <image>

# (any number of times) execute against the cached image
.\src\target\x86_64-pc-windows-msvc\release\wxc-exec.exe `
    --experimental my-config.json

This separation keeps wxc-exec.exe hermetic and fast at run time — the runner never reaches for the network, never blocks on a pull, and its failure modes are decoupled from registry availability.

Usage

TypeScript SDK

Use createConfigFromPolicy() to build a config, then customize WSLC-specific fields before spawning:

import { createConfigFromPolicy, spawnSandboxFromConfig } from '@microsoft/mxc-sdk';

const policy = {
  version: '0.6.0-alpha',
  network: { allowOutbound: true },
};

const config = createConfigFromPolicy(policy, 'wslc');
config.process!.commandLine = 'python3 -c "print(\'Hello from WSLC\')"';
config.experimental!.wslc!.image = 'python:3.12-alpine';
config.experimental!.wslc!.cpuCount = 2;
config.experimental!.wslc!.memoryMb = 1024;

// PTY mode (interactive terminal):
const ptyProcess = spawnSandboxFromConfig(config, { experimental: true });

// Non-PTY mode (reliable exit codes, separate stdout/stderr):
const child = spawnSandboxFromConfig(config, { experimental: true, usePty: false });
child.stdout?.on('data', (data) => console.log(data.toString()));
child.on('close', (code) => console.log('Exit code:', code));

Rust SDK

The Rust SDK (mxc-sdk) runs WSLC in-process — it does not spawn wxc-exec.exe. Build the crate with its wslc feature, select the backend with build_request_with_containment, and opt into experimental features on the request (the library-side equivalent of --experimental):

# Cargo.toml
[target.'cfg(target_os = "windows")'.dependencies]
mxc-sdk = { path = "…/src/core/mxc-sdk", features = ["wslc"] }
use mxc_sdk::{
    build_request_with_containment, run, spawn_sandbox, Containment, SandboxPolicy, WslcSection,
};

let policy = SandboxPolicy {
    version: "0.7.0-alpha".to_string(),
    filesystem: None,
    network: None,
    ui: None,
    timeout_ms: None,
};

let wslc = WslcSection {
    image: "python:3.12-alpine".to_string(),
    cpu_count: Some(2),
    memory_mb: Some(1024),
    ..Default::default()
};

let mut request = build_request_with_containment(&policy, &Containment::Wslc(wslc), None)?;
request
    .set_script("python3 -c \"print('Hello from WSLC')\"")
    .set_experimental(true);

// Run to completion, capturing output…
let output = run(request.clone())?;
println!("{}", String::from_utf8_lossy(&output.stdout));

// …or stream it live (read stdout/stderr while it runs, kill it, wait).
let mut sandbox = spawn_sandbox(request)?;
let stdout = sandbox.take_stdout().expect("stdout");

WslcSection mirrors the experimental.wslc block below; WslcSection::default() matches the SDK default (alpine:latest). Settings go through the same parser the executor uses, so a rejected value (e.g. a port mapping with a zero or duplicated host port) fails at build_request_with_containment rather than at spawn.

Notes and limits:

  • Windows only. Selecting Containment::Wslc on Linux/macOS, or without the wslc feature, fails with ErrorCode::UnsupportedContainment.
  • No stdin. The WSLC SDK exposes no process-input API, so Sandbox::take_stdin() returns None for a WSL container.
  • No host pid. The process runs inside the WSL VM, so Sandbox::id() is 0; use kill() (which stops the container and everything in it) to terminate it.
  • Discovery. platform_support() lists "wslc" in available_methods only when this host can actually run it (wslcsdk.dll loads and the WSLC runtime reports no missing components).

Configuration Reference

JSON config

WSLC-specific settings go under experimental.wslc in the JSON config:

FieldTypeDefaultDescription
imagestring"alpine:latest"Container image (DockerHub, GHCR, MCR, etc.)
cpuCountnumberHost defaultNumber of CPU cores for the container
memoryMbnumberHost defaultMemory limit in MB
gpubooleanfalseEnable GPU passthrough
storagePathstringSystem defaultHost path for container storage (VHD)
imageTarPathstringPath to a local tar file to import as the image

Image sources

All three sources require pre-pulling/importing before the runner can use them. The runner only checks the local cache; see Step 3 for the setup commands.

1. Pre-pulled from DockerHub (default registry):

.\scripts\setup-wslc.ps1 -Image alpine:latest
"experimental": { "wslc": { "image": "alpine:latest" } }

2. Pre-pulled from a custom registry (no auth):

.\scripts\setup-wslc.ps1 -Image ghcr.io/linuxserver/baseimage-alpine:3.21
"experimental": { "wslc": { "image": "ghcr.io/linuxserver/baseimage-alpine:3.21" } }

Tested registries: DockerHub, mcr.microsoft.com, ghcr.io, quay.io.

3. Import from a local tar file (no pre-pull needed):

"experimental": {
  "wslc": {
    "image": "my-image:latest",
    "imageTarPath": "C:\\path\\to\\image.tar"
  }
}

Both docker export (rootfs) and docker save (image archive) formats are supported — the format is auto-detected. Tar import happens on first use; no separate --setup-wslc step is required.

Network configuration

PolicyWSLC Behavior
"allowOutbound": trueBridged networking (full access)
"allowOutbound": falseNo networking (isolated)

allowedHosts / blockedHosts do not work on WSLC today. They are accepted by the config builders (for parity across the SDKs), but the backend enforces them with in-container iptables, and the container is not granted CAP_NET_ADMIN — so the rules cannot be installed and the run fails at spawn rather than silently going unenforced. Express WSLC network intent with allowOutbound until enforcement moves to a VM-level network policy API.

Network proxy (cooperative, unprivileged)

WSLC supports a cooperative HTTP/HTTPS proxy: setting network.proxy routes a container's egress through a proxy you provide. WSLC cannot apply an iptables drop-floor — the container has no CAP_NET_ADMIN and MXC has no VM-level enforcement hook — so, exactly like the Bubblewrap backend, enforcement is cooperative, applied by handing the workload proxy environment variables that well-behaved clients honor.

How it works

  1. When network.proxy is set, the runner translates it into the HTTP_PROXY, HTTPS_PROXY, http_proxy, and https_proxy environment variables inside the container (via WslcSetProcessSettingsEnvVariables). Any caller-supplied values for these keys — including NO_PROXY / no_proxy — are stripped from the initial process environment first. Because WSLC merges the process environment onto the image's baked-in ENV, the runner also sets NO_PROXY / no_proxy to the empty string, so an image-baked exemption (e.g. ENV NO_PROXY=*) cannot silently disable the proxy. This sanitizes the process's starting environment only; see the cooperative-model caveat below.
  2. Cooperative tools (curl, wget, Python requests, Node https, etc.) honor the env vars and their traffic flows through the proxy.

Only the url form is supported. A WSLC container runs in its own network namespace (a separate WSL system VM), so a host- or distro-loopback proxy is not reachable from inside the container. The proxy must be a routable address the container can reach:

{
  "version": "0.6.0-alpha",
  "containment": "wslc",
  "process": { "commandLine": "curl -fsSL https://example.com && echo OK" },
  "network": {
    "defaultPolicy": "allow",
    "proxy": { "url": "http://proxy.example:8080" }
  },
  "experimental": { "wslc": { "image": "alpine:latest" } }
}

The localhost and builtinTestServer proxy forms are rejected at config-parse time for WSLC (they imply a host-loopback / MXC-run proxy that the container cannot reach). The proxy also requires defaultPolicy: "allow" and no allowedHosts / blockedHosts: the container must have outbound networking to reach the proxy, and host lists are not forwarded to it — configs that combine the proxy with a block default or host lists are rejected.

Per-host filtering is not supported

WSLC cannot enforce per-host egress filtering. allowedHosts with defaultPolicy: "block" (an allowlist) or blockedHosts with defaultPolicy: "allow" (a blocklist) would require in-container iptables rules, but a WSLC container runs without CAP_NET_ADMIN (the SDK's Privileged flag does not grant it), so those rules cannot be applied — and MXC has no VM-level enforcement hook either (WSLC cannot expose one without breaking other security promises such as MDE). Rather than fail the run at exec time, such configs are rejected at config-parse time:

WSLc: per-host egress filtering (allowedHosts with defaultPolicy='block', or
blockedHosts with defaultPolicy='allow') is not supported. ...

Use network.proxy (with defaultPolicy: "allow") for cooperative host filtering at the proxy layer, or remove the host lists. The bare defaultPolicy forms with no host lists remain supported: "block" is a full network cutoff and "allow" is full outbound (NAT).

Inbound: allowLocalNetwork is not supported

network.allowLocalNetwork: true (a blanket grant to bind/listen and accept inbound connections) is rejected at config-parse time for WSLC. A WSLC container runs in the NAT'd WSL2 VM and MXC does not honor a blanket inbound-listen grant — only explicit host→container forwards via experimental.wslc portMappings have any inbound effect, so accepting the flag would silently promise reachability the backend never delivers. Expose specific ports with portMappings instead. (allowLocalNetwork: false, the default, is a no-op and is accepted.)

Caveats

  • Cooperative model, not enforcement. Only clients that honor the proxy env vars are routed through the proxy. Tools that bypass them (raw sockets, custom HTTP clients, statically-linked binaries that ignore the env) are not contained. WSLC cannot provide a hard network floor — the container has no CAP_NET_ADMIN and MXC has no VM-level enforcement hook. For strict network isolation, use "allowOutbound": false (no networking) instead.
  • Consumer-provided proxy. MXC does not start a proxy for WSLC; you supply a reachable one via url. Any host filtering is the proxy's responsibility — the runner does not forward allowedHosts / blockedHosts to it.

Filesystem mounts

Paths in filesystem.readwritePaths and filesystem.readonlyPaths are mounted into the container. Host path C:\workspace becomes /mnt/c/workspace inside the container.

Troubleshooting

ErrorCauseFix
WSLC backend not compiledBinary built without --features wslcRebuild with build.bat --with-wslc
Failed to load wslcsdk.dllDLL not in same directory as wxc-exec.exeCopy wslcsdk.dll next to the binary
WSLC runtime unavailableWSL runtime package is missing, older than 2.9.9, or the Virtual Machine Platform optional component is disabledUpdate WSL with wsl --update --pre-release, verify the installed version with wsl --version, and enable the Virtual Machine Platform optional component if required. The WSLC SDK DLL is a separate dependency and does not replace the WSL runtime package.
WSLC runtime unavailable. Missing components: SdkNeedsUpdateThe opposite direction: your installed WSL is newer than the WSLc SDK this MXC build ships (pinned by WSLC_SDK_VERSION in src/backends/wslc/common/build.rs)Update MXC to a build with a newer pinned SDK. Do not update WSL — it is already ahead, and updating it further will not clear this.
WSLC image '<name>' not found locallyImage was not pre-pulled, and no imageTarPath is setRun .\scripts\setup-wslc.ps1 -Image <name> (or wxc-exec.exe --setup-wslc --image <name>); match the -StoragePath to your config's experimental.wslc.storagePath if set
WSLC is an experimental featureMissing --experimental flagAdd --experimental to CLI or { experimental: true } in SDK
experimental mode error in SDKSandboxSpawnOptions.experimental not setPass { experimental: true } to spawn functions
Container exits with code -1Process failed or timed outCheck stderr output with --debug flag

Example Configs

Maintaining the SDK bindings

The WSLC SDK FFI bindings (wslcsdk_sys.rs) are generated by bindgen from the SDK header and committed to the repo. For how they work and the exact procedure to follow on every SDK version bump, see wslc-sdk-bindings.md.