diff --git a/docs/backends/wslc/wsl-container-getting-started.md b/docs/backends/wslc/wsl-container-getting-started.md
index 1f8c32e22..05c5894e3 100644
--- a/docs/backends/wslc/wsl-container-getting-started.md
+++ b/docs/backends/wslc/wsl-container-getting-started.md
@@ -395,9 +395,42 @@ The separate `provision` operation takes the same list under
The lifecycle daemon creates one container per sandbox, so a forward belongs
to the sandbox that declared it. The session-wide `cpuCount` / `memoryMb` /
-`gpu` settings remain available only for create-and-run execution, because every
-lifecycle `provision` currently joins one shared session. The daemon itself holds
-several independently sized sessions; what no caller can yet do is ask for one.
+`gpu` settings size the session rather than the sandbox, so a lifecycle caller
+asks for them on a separate `provision-session` operation and then names the
+returned session on each `provision`:
+
+```json
+{
+ "version": "1.1.0-alpha",
+ "phase": "provision-session",
+ "containment": "wslc",
+ "wslc": {
+ "provisionSession": { "cpuCount": 4, "memoryMb": 8192 }
+ }
+}
+```
+
+```json
+{
+ "version": "1.1.0-alpha",
+ "phase": "provision",
+ "containment": "wslc",
+ "wslc": {
+ "provision": {
+ "image": "alpine:latest",
+ "sessionId": "wslcsess:0123456789abcdef0123456789abcdef"
+ }
+ }
+}
+```
+
+Every sandbox in the session shares that VM, so `cpuCount: 4` means the VM has
+four virtual CPUs rather than four per sandbox. Omitting `sessionId` joins an
+implicit session shared by every request that names none, which is what a WSLC
+`provision` has always done. Release the session with `deprovision-session`
+once its sandboxes are gone; it is refused while any remain. See
+[the lifecycle guide](wslc-state-aware.md#sessions).
+
`storagePath` is create-and-run-only
for a different reason: the daemon leases its store from a slot pool rather
than taking a caller-named path, so warming its cache is a separate step. See
@@ -643,7 +676,9 @@ policy-persistence primitive, so there is nothing for the flag to select.
Container lifecycle JSON requests differ: they reject the whole `lifecycle` section
at parse time, because a multi-invocation sandbox's lifetime is driven by the
-explicit `provision` / `deprovision` phases rather than by per-run flags.
+explicit `provision` / `deprovision` phases rather than by per-run flags. The
+session those sandboxes live in has its own pair, `provision-session` /
+`deprovision-session`.
## Supported workloads
diff --git a/docs/backends/wslc/wslc-state-aware.md b/docs/backends/wslc/wslc-state-aware.md
index bc8ff7495..8ce8bcb2c 100644
--- a/docs/backends/wslc/wslc-state-aware.md
+++ b/docs/backends/wslc/wslc-state-aware.md
@@ -101,10 +101,10 @@ container has no networking for a forward to reach.
The field requires development contract `1.1.0-alpha`. Requests declaring
`0.9.0-alpha` cannot include it and must select `1.1.0-alpha` 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` knobs that
-remain available only for create-and-run execution. `storagePath` is not among them either way:
+The daemon creates a separate container for each provision, so a mapping
+applies only to the container that declared it. `cpuCount`, `memoryMb`, and
+`gpu` instead size the session's VM and are set by `provision-session`
+(see [Sessions](#sessions)). `storagePath` remains create-and-run-only:
the daemon leases its store from the slot pool rather than taking a caller-named
path (see [Image store](#image-store)).
@@ -123,10 +123,33 @@ process already holds on loopback fails the `start` phase, not `provision`.
A WSLc session is one WSL2 utility VM. A daemon holds as many of them as it is
asked for, each with its own leased image store, and places sandboxes in them.
+A caller addresses a session through two lifecycle phases of its own:
+
+| Phase | Carries | Returns |
+|-------|---------|---------|
+| `provision-session` | `wslc.provisionSession.cpuCount`, `memoryMb`, `gpu` | `result.sessionId` |
+| `deprovision-session` | `sessionId` | an empty result |
+
+A sandbox joins one by naming it on `wslc.provision.sessionId`. Sizing belongs
+to the session, so `cpuCount: 4` means the VM has four virtual CPUs shared by
+every sandbox in it, not a four-CPU quota per sandbox — which is why a
+`provision` declares no sizing of its own.
+
+Both session phases declare `containment: "wslc"` and are routed by it, since a
+session ID carries no backend prefix to route by. WSLc is the only backend that
+owns a session, so a session phase naming any other containment is refused with
+`unsupported_containment`.
+
+Both phases require contract `1.1.0-alpha`. No v1 SDK can reach them: the Rust,
+Node, and .NET v1 surfaces all emit published contract `1.0.0`, which declares
+no session fields. Use MXC request JSON through
+`mxc_sdk::__ffi::run_lifecycle_json`, the FFI, or `wxc-exec` until a new
+stable contract publishes.
+
A `provision` that names no session joins the daemon's **implicit** session,
which is booted on first use and shared by every request that names none. That
-is the whole of the daemon's behaviour, and the only reachable behaviour today:
-nothing on the public surface carries a session id yet.
+is what every provision did before sessions became addressable, so an existing
+caller is unaffected.
Session ids take the form `wslcsess:<32 lowercase hex>`. The implicit session
gets a random id like any other and it is never handed back, so one caller
@@ -134,10 +157,48 @@ cannot name the session everyone else is sharing and release it from under them.
Releasing the implicit session by id is refused for the same reason; it goes
when nothing is using it, or when the daemon does.
+Malformed IDs return `malformed_id` for both session placement and release.
+
+> **A session ID is a bearer capability.** The daemon's pipe is owner-only, so
+> any same-user process holding one can place sandboxes in that session and
+> release it. Nothing well-known is ever minted — not `wslcsess:default`, not
+> anything derivable — and `wxc-exec` refuses an ID that is not
+> `wslcsess:` plus 32 lowercase hex before the daemon is contacted. Treat the
+> ID as opaque and keep it out of logs, as mxc does.
+
The SDK is given one name per session, derived from its id
(`mxc-wslc-daemon-<32 hex>`). Two live sessions under one name is an untested
SDK failure mode, which is why the name is not fixed.
+### A sandbox's policy identity does not cover its session's sizing
+
+When telemetry or diagnostic logging is active, `wxc-exec` computes
+`MXC.PolicyHash` from the request **before** dispatch. In-process lifecycle
+entry points do not emit this record. A `provision-session` hashes the sizing
+it declares. A sandbox joining an existing session declares none, so two otherwise identical
+`provision` requests hash identically whether they land in a two-CPU VM or an
+eight-CPU VM with GPU passthrough — and `gpu` is a capability grant.
+
+The gap is accepted rather than papered over:
+
+- **Restating sizing on the provision request** would make the hash an
+ unverified caller claim. The request is hashed before the daemon resolves the
+ session, so a request could declare no GPU and still land in a session that
+ has one. A false attestation is worse than a known omission.
+- **Hashing the `sessionId`** would give two identically sized sessions
+ unrelated identities, and would put a bearer capability in a log.
+
+What the hash does record is the one related fact the request carries:
+`joinsNamedSession: true` when `wslc.provision.sessionId` is present, meaning
+this sandbox's VM was sized by another request rather than by the host defaults
+the implicit session uses. It does not identify which session, or what sizing.
+A request that names no session hashes exactly as it did before the session
+surface existed.
+
+Closing the gap properly means establishing identity after the backend resolves
+the session, which changes when identity is computed for every backend. That is
+tracked separately.
+
### How many sessions
Nothing in mxc bounds the number of concurrent sessions. Neither the store pool,
@@ -215,6 +276,10 @@ failed ... (0x80004005)`, blaming the image store for a processor problem.
Checking up front returns `policy_validation` naming the processor count and
the host's.
+`memoryMb` must fit in the SDK's unsigned 32-bit megabyte field
+(`0` through `4294967295`). Session validation rejects a larger value before
+contacting the daemon, including during a dry run.
+
`0` means "use the default" for both (2 processors, 2000 MB) and is passed
through rather than measured. `gpu` is a feature flag, not a quantity; whether
passthrough is available is answered by the runtime component check. The
@@ -257,23 +322,42 @@ an unused session until someone explicitly released it — makes an abandoned
session pin a utility VM, a store slot, *and* the daemon, with nothing able to
reclaim any of them short of ending the process by hand.
-## Sandbox IDs
+A `deprovision-session` naming a session that still holds something is
+**refused**, not cascaded. The sandboxes in a session are addressed
+individually and their owners still hold those ids, so deleting sandboxes the
+caller never named would be worse than an error. Deprovision them first, then
+release the session.
+
+## Sandbox and session IDs
-`provision` mints an id of the form `wslc:<32 lowercase hex>` (`wslc:` + a UUID simple form).
-"MXC request JSON" carries this id in `sandboxId` for every post-provision phase
-(`start` / `exec` / `stop` / `deprovision`). Direct `wxc-exec` calls omit it from JSON and pass it
-as `--container-id`; the dispatcher derives the backend from the `wslc:` prefix (later operations do
-**not** repeat `containment`).
+`provision` mints an id of the form `wslc:<32 lowercase hex>` (`wslc:` + a UUID simple form), and
+`provision-session` mints `wslcsess:<32 lowercase hex>` the same way.
+"MXC request JSON" carries the sandbox id in `sandboxId` for every post-provision phase
+(`start` / `exec` / `stop` / `deprovision`), and the session id in `sessionId` for
+`deprovision-session`. Direct `wxc-exec` calls omit the id from JSON and pass it
+as `--container-id` or `--session-id` respectively; the two flags are mutually exclusive, and a
+phase supplied the wrong one is told which it wanted. The dispatcher derives the backend from the
+`wslc:` prefix for sandbox-addressed phases (later operations do **not** repeat `containment`), and
+from the declared `containment` for the session phases.
+
+The session a sandbox joins is not a routing value, so it stays in the request body at
+`wslc.provision.sessionId` rather than moving to a flag.
## Phase → WSLc SDK mapping
| Phase | Daemon action (WSLc SDK) |
|-------|--------------------------|
-| provision | Load SDK, ensure the session (`WslcCreateSession` on first use), resolve/import image, `WslcCreateContainer` with a keepalive init process (so the container survives across separate `exec` phases). The container is created **not started** (`started: false`). |
+| provision-session | Load SDK, lease a store slot, `WslcCreateSession` with the requested sizing on a thread of its own, then validate the session with a store probe. Returns the minted `sessionId`. |
+| provision | Load SDK, ensure the session (`WslcCreateSession` on first use when none was named), resolve/import image, `WslcCreateContainer` with a keepalive init process (so the container survives across separate `exec` phases). The container is created **not started** (`started: false`). |
| start | `WslcStartContainer` — starts the container minted at provision-time (the keepalive init keeps it warm across later `exec` phases); marks it `started`. |
| exec | `WslcCreateContainerProcess` in the warm container; stream stdout/stderr and return the process exit code. Stdin is unavailable because the WSLc SDK exposes no process-input API. A timeout or caller cancellation SIGKILLs the **process**, not the container. |
| stop | `WslcStopContainer`. |
| deprovision | `WslcDeleteContainer`. The session stays warm for the next provision and is released once nothing is using it (see [Session lifetime](#session-lifetime)). |
+| deprovision-session | Refuse while anything is still in the session, else terminate the VM and return its store slot. |
+
+A dry run of either session phase validates the request and stops there: no ID
+is minted, no VM is booted, no store slot is leased, and the daemon is never
+contacted.
A session is created with the CPU count, memory, and GPU flag it was asked for,
and always with a **boot timeout** (180s, the same budget create-and-run
@@ -320,8 +404,11 @@ that lets a caller request a second session.
A returned slot keeps its images — that is the point of leasing rather than
minting. A slot that has never been leased holds nothing at all, and an uncached
-image under the isolated posture is refused before the VM is reached, so a cold
-slot fails a provision outright rather than pulling. Warm the pool with either:
+image under the isolated posture is refused before a container is created.
+The error names the selected store's slot and path, the uncached image, and
+the command to warm it. MXC does not relocate the session to another store or
+pull outside the declared network policy. The SDK session may already be
+running when its image cache is inspected. Warm the pool with either:
```powershell
.\scripts\setup-wslc.ps1 -Image alpine:latest -DaemonSlots 1
@@ -611,12 +698,25 @@ it can observe idle-teardown within seconds.
## Testing
`tests/scripts/run_wslc_state_aware_tests.ps1` is the multi-invocation E2E harness (requires a WSL2
-host that can reach a registry or already has the image cached in daemon store slot 0, and `wxc-wslc-daemon.exe` staged next to `wxc-exec.exe`). It exercises
+host that can reach a registry or already has the images cached in daemon store slots 0–2, and `wxc-wslc-daemon.exe` staged next to `wxc-exec.exe`). It exercises
core lifecycle, warm-reuse (a marker written by one `exec` is read back by a separate `exec`
process — only possible if the container stayed warm), filesystem volumes, bridged networking +
proxy, validation rejections, exec concurrency, and idle teardown. Fixtures live in
`tests/configs/wslc_state_aware_*.json`.
+The session tests place two sandboxes in one session and compare their VM
+boot IDs. They also keep two identically sized sessions alive together and
+check that releasing one leaves the other's sandbox usable. The setup
+preflight warms three stores for the implicit session and the two explicit
+sessions, because isolated provisions cannot pull into a cold store.
+
+The cold-store regression uses a private pool and daemon discovery root,
+warms only slot 0 with `-DaemonSlots 1`, and holds that slot with a sandbox.
+An isolated provision on slot 1 must name the cold store in its refusal,
+leave no container behind, and leave the original warm sandbox usable.
+This case needs registry access even with `-SkipSetup`; it does not reuse or
+modify the normal image cache.
+
The concurrency section launches a phase without waiting for it (`Start-StateAware` /
`Wait-StateAware`), which is what lets it observe two sandboxes running at once, a refused
same-container second exec, and a lifecycle command issued while a run is in flight. Every other
@@ -630,17 +730,24 @@ run individually or in an arbitrary order:
- **Order is mandatory.** A sandbox must go through `provision → start → exec… → stop → deprovision`.
`provision` is what boots the session and mints the id; every other phase fails without it
(`start`/`exec` before provision → `not_provisioned` / `not_started`, and any phase after
- `deprovision` → `not_provisioned`).
+ `deprovision` → `not_provisioned`). An explicit session widens that to
+ `provision-session → provision → start → exec… → stop → deprovision → deprovision-session`:
+ a `provision` cannot name a session that does not exist yet, and a
+ `deprovision-session` is refused while a sandbox is still in it.
- **The id must be threaded through.** `provision` returns the real `wslc:<32-hex>` id on stdout
- (`result.sandboxId`). The post-provision fixtures (`_start`, `_stop`, `_deprovision`, and every
- `_exec_*`) ship with a literal **`{{SANDBOX_ID}}` placeholder**. These test inputs use
- "MXC request JSON"; the harness replaces the placeholder before converting the request to
- direct executor CLI form.
+ (`result.sandboxId`), and `provision-session` returns `wslcsess:<32-hex>` in `result.sessionId`.
+ The post-provision fixtures (`_start`, `_stop`, `_deprovision`, and every
+ `_exec_*`) ship with a literal **`{{SANDBOX_ID}}` placeholder**; the session fixtures
+ (`_provision_in_session`, `_deprovision_session`) ship with **`{{SESSION_ID}}`**. These test
+ inputs use "MXC request JSON"; the harness replaces the placeholders before converting the
+ request to direct executor CLI form.
`run_wslc_state_aware_tests.ps1` handles both concerns automatically (it drives the phases in order
-and does the `{{SANDBOX_ID}}` substitution from each provision's output). It then removes `phase`
-and `sandboxId` from the JSON and passes them as `--operation` and `--container-id`. Exercise these
-fixtures **through the harness**, not by pointing `wxc-exec --config` at them directly.
+and does the `{{SANDBOX_ID}}` / `{{SESSION_ID}}` substitution from each provision's output). It then
+removes `phase` and the phase's routing id from the JSON and passes them as `--operation` and
+`--container-id` or `--session-id`. The session a `provision` joins stays in the body, since it is
+configuration rather than routing. Exercise these fixtures **through the harness**, not by pointing
+`wxc-exec --config` at them directly.
## Known limitations
@@ -654,8 +761,13 @@ fixtures **through the harness**, not by pointing `wxc-exec --config` at them di
one-for-one, so it spans Rust, Node, .NET, the versioned references, and schema regeneration.
This is tracked as follow-up work.
-- **No typed SDK can set port mappings yet.** The Rust, Node, and .NET v1 SDKs
- all use published stable contract `1.0.0`, which does not declare the
- field, and released schemas are immutable. "MXC request JSON" using contract `1.1.0-alpha` 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.
+- **No typed SDK can set port mappings or sessions yet.** The Rust, Node, and
+ .NET v1 SDKs all use published stable contract `1.0.0`, which declares
+ neither `wslc.provision.portMappings` nor any session field, and released
+ schemas are immutable. "MXC request JSON" using contract `1.1.0-alpha` through the
+ raw Rust entry point `mxc_sdk::__ffi::run_lifecycle_json`, FFI, or `wxc-exec`
+ reaches them today. Promoting `1.1.0-alpha` to a stable release is what lets
+ the three typed SDKs expose them together.
+
+- **A sandbox's policy identity does not cover its session's sizing.** See
+ [the section above](#a-sandboxs-policy-identity-does-not-cover-its-sessions-sizing).
diff --git a/docs/containment-policy/dev/policy.md b/docs/containment-policy/dev/policy.md
index 87e2fb9c9..4924aee90 100644
--- a/docs/containment-policy/dev/policy.md
+++ b/docs/containment-policy/dev/policy.md
@@ -169,16 +169,91 @@ operations exist for **IsolationSession, WSLc, and Windows Sandbox** in this
contract. `provision` requires a concrete `containment` and must omit
`sandboxId`; subsequent phases require the ID returned by provision
(`iso:...`, `wslc:...`, or `wsb:...`) and infer the backend from its prefix.
-Every phase requires `version`; all admit optional `$schema`, `_comment`,
-and `telemetry.enabled`.
+WSLc also supports `provision-session` and `deprovision-session`, which require
+`containment: "wslc"` and carry no `sandboxId`. Every phase requires `version`;
+all admit optional `$schema`, `_comment`, and `telemetry.enabled`.
| Phase | Allowed request fields besides the common fields |
|---|---|
| `provision` / `isolation_session` | Required all-allow `network`; optional `isolationSession.provision.appId` (string). No filesystem, UI, or proxy. |
-| `provision` / `wslc` | Optional `filesystem`, `network`, and `wslc.provision.image`, `imageTarPath`, or `portMappings`. Port mappings require all-allow bridged networking; per-container CPU/memory/GPU/storage are not available at provision. |
+| `provision-session` / `wslc` | Optional `wslc.provisionSession.cpuCount`, `memoryMb`, and `gpu`; no process, filesystem, network, or UI policy. |
+| `provision` / `wslc` | Optional `filesystem`, `network`, and `wslc.provision.image`, `imageTarPath`, `portMappings`, or `sessionId`. Port mappings require all-allow bridged networking; CPU/memory/GPU sizing belongs to the session, not the sandbox. No storage-path override. |
| `provision` / `windows_sandbox` | Optional `filesystem` only. No network, UI, proxy, or backend-specific settings; provision stores the immutable filesystem policy. Requires experimental authorization. |
| `start`, `stop`, `deprovision` | Required `sandboxId`; no process or policy sections. |
| `exec` | Required `sandboxId` and `process` (non-empty `commandLine`); optional `network` and `runtimeConfig` in the JSON shape. All three backends reject supplied network policy at exec and inherit their provision posture. IsolationSession and Windows Sandbox reject a proxy; WSLc accepts an exec proxy. |
+| `deprovision-session` / `wslc` | Required `sessionId`; no process, filesystem, network, UI, or backend configuration. |
+
+### WSLc sessions
+
+A session is one WSL2 utility VM shared by the sandboxes placed in it.
+`provision-session` sets VM sizing; it does not create a sandbox:
+
+```json
+{
+ "version": "1.1.0-alpha",
+ "phase": "provision-session",
+ "containment": "wslc",
+ "wslc": {
+ "provisionSession": { "cpuCount": 2, "memoryMb": 4096, "gpu": false }
+ }
+}
+```
+
+| Sizing field | Meaning |
+|---|---|
+| `cpuCount` | Optional non-negative integer, up to `4294967295`; a positive value cannot exceed the host's logical processor count. Omitted or `0` uses the SDK default. |
+| `memoryMb` | Optional non-negative integer; the backend rejects values above `4294967295`. A ceiling, not a reservation, so it may exceed installed host RAM. Omitted or `0` uses the SDK default. |
+| `gpu` | Optional boolean, default `false`; enables GPU passthrough when supported by the host runtime. |
+
+Success returns `{"result":{"sessionId":"wslcsess:0123456789abcdef0123456789abcdef"}}`.
+The ID shown here is illustrative: use the returned value, which has exactly
+32 lowercase hexadecimal digits after `wslcsess:`, in later requests.
+Treat it as an opaque bearer capability and keep it out of logs.
+
+Place a sandbox in that session with `wslc.provision.sessionId`:
+
+```json
+{
+ "version": "1.1.0-alpha",
+ "phase": "provision",
+ "containment": "wslc",
+ "wslc": {
+ "provision": {
+ "image": "alpine:latest",
+ "sessionId": "wslcsess:0123456789abcdef0123456789abcdef"
+ }
+ }
+}
+```
+
+The VM's CPU, memory, and GPU settings apply to every sandbox in the session,
+not as per-sandbox quotas. Omitting `sessionId` joins the implicit shared
+session, preserving existing behavior. An isolated provision needs its image
+already cached in the selected session's store or supplied as `imageTarPath`.
+
+After deprovisioning every sandbox in the session, release it:
+
+```json
+{
+ "version": "1.1.0-alpha",
+ "phase": "deprovision-session",
+ "containment": "wslc",
+ "sessionId": "wslcsess:0123456789abcdef0123456789abcdef"
+}
+```
+
+Release returns `{"result":{}}`. A session still holding sandboxes or work
+is refused rather than cascading deletion; the implicit session cannot be
+released explicitly. Unused sessions can expire before explicit release.
+Malformed IDs return `malformed_id`; a valid ID that no longer names a
+session returns `not_provisioned`.
+
+A dry run validates either session request without contacting the daemon,
+minting an ID, creating a VM or store, or reserving capacity, and returns
+`{"result":{}}`. Neither session phase is available through typed SDK V1,
+which still targets contract `1.0.0`.
+
+### Sandbox examples
Example WSLc provision with a container-specific TCP port mapping:
@@ -228,7 +303,8 @@ with the returned one):
}
```
-When calling the executor directly, pass routing fields via `--operation` and
-`--container-id` instead of putting `phase` and `sandboxId` in the JSON file;
-the executor inserts them before parsing. JSON library/FFI calls include
-those fields as shown above.
+When calling the executor directly, pass `phase` via `--operation`, `sandboxId`
+via `--container-id`, and a release's `sessionId` via `--session-id` instead
+of putting those fields in the JSON file; the executor inserts them before
+parsing. Placement's nested `wslc.provision.sessionId` stays in JSON.
+JSON library/FFI calls include routing fields as shown above.
diff --git a/docs/development/architecture/container-lifecycle.md b/docs/development/architecture/container-lifecycle.md
index a789aa95c..25dc0cee9 100644
--- a/docs/development/architecture/container-lifecycle.md
+++ b/docs/development/architecture/container-lifecycle.md
@@ -446,8 +446,9 @@ Backend-routing fields:
| Field | Type | Required | Description |
|---|---|---|---|
-| `containment` | `ContainmentType` or `ContainmentBackend` member | One-shot: yes. State-aware: yes for `provision`, absent for `start` / `exec` / `stop` / `deprovision`. | Backend selection on calls that do not yet have a `sandboxId`. |
-| `sandboxId` | branded string | State-aware non-provision: yes. Otherwise absent. | Opaque sandbox id returned by `provision`. Carries the backend prefix used to route non-provision calls (§5). |
+| `containment` | `ContainmentType` or `ContainmentBackend` member | One-shot: yes. State-aware: yes for `provision` and both session phases, absent for `start` / `exec` / `stop` / `deprovision`. | Backend selection on calls that carry no backend-prefixed `sandboxId`. |
+| `sandboxId` | branded string | State-aware sandbox-addressed phases: yes. Otherwise absent. | Opaque sandbox id returned by `provision`. Carries the backend prefix used to route those calls (§5). |
+| `sessionId` | string | `deprovision-session`: yes. Otherwise absent. | Opaque session id returned by `provision-session`. Carries no routing prefix, which is why its phase declares `containment`. |
State-aware-only fields:
@@ -456,29 +457,66 @@ State-aware-only fields:
| `phase` | `Phase` member | Yes | Discriminator. Absence means a one-shot request. |
| `process` | `ProcessConfig` | Required for `exec`; absent otherwise. | Cross-backend execution fields. |
+#### Raw JSON entry points
+
+Rust's `mxc_sdk::__ffi::run_lifecycle_json` returns the response-envelope JSON
+as a string. The C function `mxc_run_state_aware_json` accepts NUL-terminated
+UTF-8 request JSON and writes its response to `MxcStateAwareResult`.
+Both accept the routing fields shown above and use the declared contract.
+
+| Phase | Successful response |
+|---|---|
+| `provision` | `result.sandboxId` and any backend metadata |
+| `start`, `stop`, `deprovision` | `{"result":{}}` |
+| `provision-session` | `result.sessionId`; WSLc contract `1.1.0-alpha` only |
+| `deprovision-session` | `{"result":{}}`; WSLc contract `1.1.0-alpha` only |
+
+Session operations are not exposed by typed SDK V1. Their sizing, placement,
+and cleanup rules are described in the
+[development policy reference](../../containment-policy/dev/policy.md#wslc-sessions).
+With `dry_run` enabled, either entry point validates without allocating
+resources or minting IDs and returns `{"result":{}}`. A non-dry-run `exec`
+uses Rust's `execute_lifecycle_json` or a C exec entry point instead.
+
+The separate `experimental` argument authorizes experimental backends; without
+it they fail as `backend_unavailable` before execution. Production backends
+ignore this argument. Rust failures return `Error` with the matching
+`ErrorCode`; C failures populate the status and error fields, leaving the
+response JSON absent.
+
+The C status is both returned and stored in `out->status`. A null `out` returns
+`MXC_STATUS_NULL_ARGUMENT` before dispatch. Release every populated result with
+`mxc_state_aware_result_free`, including failures.
+
#### `wxc-exec` lifecycle transport
-"MXC request JSON" retains `phase` and `sandboxId`. Direct
+"MXC request JSON" retains `phase`, `sandboxId`, and `sessionId`. Direct
`wxc-exec` lifecycle calls instead remove those routing fields from the supplied JSON
and pass them as command-line arguments:
```text
+wxc-exec.exe policy.json --operation provision-session
wxc-exec.exe policy.json --operation provision
wxc-exec.exe policy.json --operation start --container-id iso:abc
wxc-exec.exe policy.json --operation exec --container-id iso:abc -- command arg
wxc-exec.exe policy.json --operation stop --container-id iso:abc
wxc-exec.exe policy.json --operation deprovision --container-id iso:abc
+wxc-exec.exe policy.json --operation deprovision-session --session-id wslcsess:0123456789abcdef0123456789abcdef
```
-Provision JSON still contains `containment`; later operations route from the
-`--container-id` prefix. Supplying `phase` or `sandboxId` in CLI JSON is rejected rather
+Replace the illustrative session ID with `result.sessionId` from a prior
+`provision-session` response; a correctly formatted ID must also name a live session.
+
+Provision and session JSON still contains `containment`; sandbox-addressed operations route from the
+`--container-id` prefix. Supplying `phase`, `sandboxId`, or `sessionId` in CLI JSON is rejected rather
than overriding the command-line routing. Without `--operation`, `wxc-exec` accepts
only one-shot JSON.
`--operation` cannot be combined with delete or maintenance routing, including
`--delete`, its `--containername` option, setup modes, probing, force-reclaim, or
-audit modes. Lifecycle identity comes only from `--container-id`; conflicting options
-are rejected rather than ignored.
+audit modes. Lifecycle identity comes only from `--container-id` or `--session-id`,
+which are mutually exclusive; an operation supplied the wrong one is told which it
+wanted. Conflicting options are rejected rather than ignored.
Cross-cutting fields available to state-aware (state-aware-only at top level — backends
declare which phases honor them, see §10.3):
diff --git a/docs/development/architecture/telemetry.md b/docs/development/architecture/telemetry.md
index 59b941cb9..49a4a6f9d 100644
--- a/docs/development/architecture/telemetry.md
+++ b/docs/development/architecture/telemetry.md
@@ -175,7 +175,7 @@ the one-shot path, a clean non-zero sandbox exit is not treated as an MXC error.
| `mxc.outcome` | string | `"success"` or `"failure"` |
| `mxc.duration_ms` | uint64 | Total execution time |
| `mxc.failure_reason` | string | Failure category (if applicable) |
-| `mxc.phase` | string | State-aware lifecycle phase (`provision`\|`start`\|`exec`\|`stop`\|`deprovision`); empty for one-shot executions |
+| `mxc.phase` | string | Lifecycle phase (`provision-session`\|`provision`\|`start`\|`exec`\|`stop`\|`deprovision`\|`deprovision-session`); empty for create-and-run execution |
| `__TlgCV__` | string | Microsoft Correlation Vector (MS-CV) — the lifecycle correlation key (see [Correlating a lifecycle](#correlating-a-lifecycle)); empty for one-shot executions |
### MXC.Error
@@ -283,19 +283,19 @@ MS-CV is a hierarchical, propagatable identifier of the form
`..…` — a random base (128 bits, 22 base64 chars) plus a
dotted chain of decimal elements. MXC uses it as follows:
-- **`provision`** seeds a fresh random base (`correlation_vector::seed()`) and
- returns it to the client in its result envelope as `result.correlationVector`.
- This is the lifecycle's root vector.
-- The client **relays** that vector verbatim into every later phase (the SDK
- surfaces it as `ProvisionResult.correlationVector` and accepts it back as
- `SandboxSpawnOptions.correlationVector`, sent on the wire as the top-level
- `correlationVector` field).
-- Each non-`provision` phase **spins** the relayed base
- (`correlation_vector::spin()`) to derive a distinct child vector that still
- shares the lifecycle's base prefix. Spin (rather than a plain extend) is used
- because `exec` is multi-invocation and the client is a dumb relay — a plain
- extend would collapse every phase to the same `base.0`, whereas spin folds in a
- coarse timestamp + entropy so sibling phases get distinct, ordered vectors.
+- **`provision`** seeds a fresh random base and persists it locally once the
+ sandbox ID is known.
+- **`start`, `exec`, `stop`, and `deprovision`** recall that persisted root and
+ spin distinct child vectors sharing its base prefix. Successful deprovision
+ removes the record; a dry run keeps it.
+- Callers neither receive nor relay a correlation vector. It is internal to
+ MXC, not a JSON request or response field.
+
+`provision-session` and `deprovision-session` each seed an independent vector
+when telemetry is authorized. They do not read, create, refresh, sweep, or
+delete the sandbox correlation store. Session IDs are not correlation-store
+keys or vector seeds. This applies to the executor, raw JSON entry points,
+and typed engine calls. Sandbox operations retain their existing store cleanup.
An analyst groups all phases of one lifecycle by the shared **base prefix** of
`__TlgCV__`, and orders/distinguishes phases within it by the spun elements.
@@ -307,11 +307,11 @@ element boundary, if needed to stay within the cap — never truncating a
multi-digit element to a falsified value like `.42` → `.4`) and the vector is
never mutated again. `increment` likewise freezes when its trailing element is
already at `u32::MAX` and so cannot advance (the spec's value-overflow
-behaviour). Every later phase then relays and emits that same frozen vector
+behaviour). Every later sandbox phase then emits that same frozen vector
verbatim, so correlation by base prefix still holds — only the per-phase
ordering elements stop advancing.
-**Canonical validation.** A relayed vector is only built on (spun) when it is
+**Canonical validation.** A persisted vector is only built on (spun) when it is
*relayable* — a well-formed mutable vector (canonical 22-char base64 base whose
final char encodes a zero low nibble, followed by one or more canonical decimal
`u32` elements) or a valid frozen (`!`-terminated) vector. Elements must be
@@ -325,15 +325,15 @@ base rather than emitted verbatim.
The correlation vector is **not** derived from the `sandbox_id`, so no
caller-supplied identity (e.g. a UPN embedded in an IsolationSession
`iso:` id) is ever involved: the base is pure randomness. Spin is defensive
-— a missing, empty, or malformed relayed value falls back to a fresh seed rather
+— a missing, empty, or malformed stored value falls back to a fresh seed rather
than panicking telemetry. `__TlgCV__` is empty for one-shot executions (which have
no lifecycle to correlate) and for a crash during `provision` before the vector is
-stashed. It is only computed and emitted when experimental telemetry is active, so
-provision output is unchanged when telemetry is off.
+stashed. It is only computed and emitted when telemetry is authorized; response
+envelopes never contain it.
-**Why a relayed random vector rather than a hashed `sandbox_id`.** An alternative
+**Why a random vector rather than a hashed `sandbox_id`.** An alternative
design would derive the correlation key deterministically from the `sandbox_id`
-(e.g. a hash), avoiding the wire/SDK relay entirely. We deliberately do **not** do
+(e.g. a hash), avoiding persistence of a random root. We deliberately do **not** do
this, for two reasons:
1. **PII-safety.** A `sandbox_id` is caller-influenced and can embed identity — an
@@ -349,9 +349,8 @@ this, for two reasons:
the hierarchical parent/child structure (base prefix + spun elements) that lets
an analyst reconstruct phase ordering, not just group-by a flat key.
-The client relay is therefore a required part of the design, not incidental
-plumbing: it is how the random root vector minted at `provision` reaches the later
-per-phase executor processes, which otherwise share no state.
+Local persistence lets the random root minted at `provision` reach later
+executor processes without exposing it to callers.
### Crash telemetry (panic hook)
diff --git a/docs/development/architecture/versioning.md b/docs/development/architecture/versioning.md
index 9948187fd..2db7a64b4 100644
--- a/docs/development/architecture/versioning.md
+++ b/docs/development/architecture/versioning.md
@@ -136,9 +136,12 @@ reasons:
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. 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
+ not declare — Windows Sandbox state-aware, `wslc.provision.portMappings`,
+ and WSLc session operations (`provision-session`, `deprovision-session`,
+ and `wslc.provision.sessionId`) — require MXC request JSON declaring
+ `1.1.0-alpha`, or the in-process engine with that contract.
+ `stateAwareWslc` names this development contract; it does not change the
+ published `1.0.0` target of the V1 SDKs. 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/development/build-and-test/schema-codegen.md b/docs/development/build-and-test/schema-codegen.md
index 20cded92f..9fa3bdb68 100644
--- a/docs/development/build-and-test/schema-codegen.md
+++ b/docs/development/build-and-test/schema-codegen.md
@@ -20,7 +20,7 @@ identities.
## Sources of truth
`src/mxc-sdk/src/core/mxc_contract/dev/` defines the exact mutable
-`1.1.0-alpha` contract. Its one-shot and seven state-aware request roots are
+`1.1.0-alpha` contract. Its one-shot and nine state-aware request roots are
independent closed Rust types. Constrained primitives and the `string_enum!`
and `string_marker!` macros implement `JsonSchema` so deserialization and
generated constants cannot drift.
@@ -92,10 +92,12 @@ The exact schema uses one shared `definitions` table and nested `if`/`then`
dispatch:
1. absence of `phase` selects one-shot;
-2. a present `phase` selects provision, start, exec, stop, or deprovision;
+2. a present `phase` selects provision-session, provision, start, exec, stop,
+ deprovision, or deprovision-session;
3. provision additionally selects a backend-specific root by `containment`
(Windows Sandbox, IsolationSession, or WSLC in v1.1 development;
- IsolationSession or WSLC in published v0.9 and v1.0).
+ IsolationSession or WSLC in published v0.9 and v1.0). The two session phases
+ select by `containment` as well, but admit only WSLC.
This structure evaluates only the relevant branch and gives more focused
editor diagnostics than a bare request-root union. One-shot and exec require
diff --git a/docs/schema.md b/docs/schema.md
index c88b7e4d0..5bca3bd11 100644
--- a/docs/schema.md
+++ b/docs/schema.md
@@ -39,11 +39,16 @@ Typed Rust, .NET, and Node SDK callers use `ContainerRequest`,
`ProvisionRequest`, and `ExecutionRequest`, not JSON. Node and .NET produce
"MXC request JSON" internally.
-Creation requests omit `phase` and `sandboxId`. Lifecycle requests include
-`phase`, and operations after provision include `sandboxId`. For executor
-lifecycle calls, supply those values through `--operation` and `--container-id`,
-not in the JSON file. The executor inserts them before using the same contract
-parser.
+Creation requests omit `phase`, `sandboxId`, and `sessionId`. Lifecycle
+requests include `phase`. Sandbox-addressed operations (`start`, `exec`,
+`stop`, and `deprovision`) take `sandboxId`; `deprovision-session` instead
+takes `sessionId`. Both provision operations create their own IDs.
+
+For executor lifecycle calls, supply `phase` through `--operation` and the
+target ID through `--container-id` or `--session-id`, not as top-level JSON
+fields. The executor inserts them before using the same contract parser.
+`wslc.provision.sessionId` stays in the JSON body: it selects the session a new
+sandbox joins, not the target of the operation.
For example, starting a provisioned WSLc container uses this "MXC request JSON"
with `mxc_run_state_aware_json`:
@@ -497,7 +502,8 @@ These examples use ["MXC request JSON"](#mxc-request-json). For executor
calls, use the conversion described above.
The development schema documents JSON objects for container lifecycle
-operations (`provision` / `start` / `exec` / `stop` / `deprovision`).
+operations (`provision-session` / `provision` / `start` / `exec` / `stop` /
+`deprovision` / `deprovision-session`).
The create-and-run JSON above describes container setup and a workload together.
Lifecycle JSON instead identifies an operation: `provision` allocates a new
container; later operations use the returned `sandboxId`.
@@ -506,8 +512,8 @@ Choose a supported contract version for the backend and operation:
- IsolationSession uses published `0.9.0-alpha`.
- 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`.
+ `wslc.provision.portMappings` and the session operations; Windows Sandbox
+ uses development `1.1.0-alpha`.
Contracts before `0.9.0-alpha` are retired. The supported published
`0.9.0-alpha` and `1.0.0` contracts describe create-and-run JSON requests plus
@@ -518,30 +524,34 @@ development schema for `1.1.0-alpha`:
{
"$schema": "./schemas/dev/mxc-config.schema.1.1.0-alpha.json",
"version": "1.1.0-alpha",
- "phase": "exec", // One of: provision | start | exec | stop | deprovision
- "sandboxId": "wsb:abcd1234", // Required for non-provision phases.
- // Prefix routes to the backend (wsb: -> windows_sandbox,
- // iso: -> isolation_session).
- "containment": "windows_sandbox", // Required for `provision`; ignored for other phases
- // (the backend is inferred from sandboxId).
+ "phase": "exec",
+ "sandboxId": "wsb:abcd1234",
"process": { "commandLine": "echo hi" }
- // Cross-cutting fields (process / filesystem / network / ui) sit at the TOP
- // level, exactly as in a create-and-run request -- there is no wrapping `config`
- // object. Backend- and phase-specific config, when a phase has any, nests
- // under its permanent backend section, e.g.:
- // "isolationSession": { "provision": { "appId": "PFN:Contoso.App_8wekyb3d8bbwe" } }
}
```
-Phase / sandboxId / containment validation:
-
-| Phase | `sandboxId` | `containment` |
-|---|---|---|
-| `provision` | (not allowed) | **Required** — picks the backend whose `provision` mints a fresh sandboxId |
-| `start` | **Required** (`:`) | Ignored if present |
-| `exec` | **Required** | Ignored if present |
-| `stop` | **Required** | Ignored if present |
-| `deprovision` | **Required** | Ignored if present |
+The `sandboxId` prefix routes sandbox-addressed operations to the backend;
+those requests do not accept `containment`. Provision and both WSLc session
+operations instead require `containment`. Cross-cutting policy stays at the
+top level, and backend-specific settings stay under the backend section
+(such as `wslc.provision`).
+
+Phase / sandboxId / sessionId / containment validation:
+
+| Phase | `sandboxId` | `sessionId` | `containment` |
+|---|---|---|---|
+| `provision-session` | (not allowed) | (not allowed) | **Required** — `wslc` only |
+| `provision` | (not allowed) | (not allowed) | **Required** — picks the backend whose `provision` mints a fresh sandboxId |
+| `start` | **Required** (`:`) | (not allowed) | (not allowed) |
+| `exec` | **Required** | (not allowed) | (not allowed) |
+| `stop` | **Required** | (not allowed) | (not allowed) |
+| `deprovision` | **Required** | (not allowed) | (not allowed) |
+| `deprovision-session` | (not allowed) | **Required** (`wslcsess:`) | **Required** — `wslc` only |
+
+A session ID carries no backend prefix, so the session phases declare their
+containment instead of being routed by their target. Any containment other than
+`wslc` is refused with `unsupported_containment`: no other backend owns a
+session that sandboxes are placed into.
Backends supporting container lifecycle operations today are `isolation_session`, `windows_sandbox`,
and `wslc` (all Windows-only). IsolationSession does not require runtime
@@ -575,12 +585,60 @@ 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
-available only for create-and-run execution, because every lifecycle `provision`
-currently joins one shared session and so cannot size sandboxes individually.
-The daemon itself holds several independently sized sessions; what no caller can
-yet do is ask for one.
+Two entries claiming the same `windowsPort` are rejected on both surfaces.
+
+#### WSLC session operations
+
+Sizing is a property of the session rather than of a sandbox, so a WSLC caller
+asks for a sized session first and then places sandboxes in it:
+
+```json
+{
+ "$schema": "./schemas/dev/mxc-config.schema.1.1.0-alpha.json",
+ "version": "1.1.0-alpha",
+ "phase": "provision-session",
+ "containment": "wslc",
+ "wslc": {
+ "provisionSession": {
+ "cpuCount": 4,
+ "memoryMb": 8192,
+ "gpu": false
+ }
+ }
+}
+```
+
+The response carries `result.sessionId` (`wslcsess:<32 lowercase hex>`). A
+`provision` joins that session by naming it:
+
+```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",
+ "sessionId": "wslcsess:0123456789abcdef0123456789abcdef"
+ }
+ }
+}
+```
+
+`cpuCount: 4` means the VM has four virtual CPUs shared by every sandbox in the
+session, not four per sandbox, which is why a `provision` declares no sizing of
+its own. Omitting `sessionId` joins an implicit session shared by every request
+that names none — the behaviour every WSLC `provision` had before these
+operations existed.
+
+A `deprovision-session` names the session in a top-level `sessionId` and is
+refused while any sandbox is still in it; deprovision those first. Session IDs
+are opaque bearer values: any same-user process holding one can release that
+session, so keep them out of logs.
+
+The create-and-run `wslc` section keeps its own `cpuCount` / `memoryMb` / `gpu`
+/ `storagePath` knobs, which size the session that one run creates for itself.
Full lifecycle API: [container lifecycle](container-lifecycle.md).
diff --git a/docs/telemetry.md b/docs/telemetry.md
index 438528de1..f6eb2f868 100644
--- a/docs/telemetry.md
+++ b/docs/telemetry.md
@@ -166,4 +166,5 @@ application-specific policy under `SOFTWARE\Policies\Microsoft\`.
## See also
- [Telemetry architecture](development/architecture/telemetry.md)
+- [Lifecycle correlation](development/architecture/telemetry.md#correlating-a-lifecycle)
- [Telemetry consent design](development/architecture/telemetry-consent-design.md)
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 76880db38..97c7b68ed 100644
--- a/schemas/dev/mxc-config.schema.1.1.0-alpha.json
+++ b/schemas/dev/mxc-config.schema.1.1.0-alpha.json
@@ -88,11 +88,54 @@
"else": {
"else": {
"else": {
- "else": false,
+ "else": {
+ "else": {
+ "else": false,
+ "if": {
+ "properties": {
+ "phase": {
+ "const": "deprovision-session"
+ }
+ },
+ "required": [
+ "phase"
+ ]
+ },
+ "then": {
+ "else": false,
+ "if": {
+ "properties": {
+ "containment": {
+ "const": "wslc"
+ }
+ },
+ "required": [
+ "containment"
+ ]
+ },
+ "then": {
+ "$ref": "#/definitions/WslcDeprovisionSessionRequest"
+ }
+ }
+ },
+ "if": {
+ "properties": {
+ "phase": {
+ "const": "deprovision"
+ }
+ },
+ "required": [
+ "phase"
+ ]
+ },
+ "then": {
+ "$ref": "#/definitions/DeprovisionRequest"
+ }
+ },
"if": {
"properties": {
"phase": {
- "const": "deprovision"
+ "const": "stop"
}
},
"required": [
@@ -100,13 +143,13 @@
]
},
"then": {
- "$ref": "#/definitions/DeprovisionRequest"
+ "$ref": "#/definitions/StopRequest"
}
},
"if": {
"properties": {
"phase": {
- "const": "stop"
+ "const": "exec"
}
},
"required": [
@@ -114,13 +157,13 @@
]
},
"then": {
- "$ref": "#/definitions/StopRequest"
+ "$ref": "#/definitions/ExecRequest"
}
},
"if": {
"properties": {
"phase": {
- "const": "exec"
+ "const": "start"
}
},
"required": [
@@ -128,13 +171,13 @@
]
},
"then": {
- "$ref": "#/definitions/ExecRequest"
+ "$ref": "#/definitions/StartRequest"
}
},
"if": {
"properties": {
"phase": {
- "const": "start"
+ "const": "provision"
}
},
"required": [
@@ -142,27 +185,27 @@
]
},
"then": {
- "$ref": "#/definitions/StartRequest"
- }
- },
- "if": {
- "properties": {
- "phase": {
- "const": "provision"
- }
- },
- "required": [
- "phase"
- ]
- },
- "then": {
- "else": {
"else": {
- "else": false,
+ "else": {
+ "else": false,
+ "if": {
+ "properties": {
+ "containment": {
+ "const": "wslc"
+ }
+ },
+ "required": [
+ "containment"
+ ]
+ },
+ "then": {
+ "$ref": "#/definitions/WslcProvisionRequest"
+ }
+ },
"if": {
"properties": {
"containment": {
- "const": "wslc"
+ "const": "isolation_session"
}
},
"required": [
@@ -170,13 +213,13 @@
]
},
"then": {
- "$ref": "#/definitions/WslcProvisionRequest"
+ "$ref": "#/definitions/IsolationSessionProvisionRequest"
}
},
"if": {
"properties": {
"containment": {
- "const": "isolation_session"
+ "const": "windows_sandbox"
}
},
"required": [
@@ -184,13 +227,26 @@
]
},
"then": {
- "$ref": "#/definitions/IsolationSessionProvisionRequest"
+ "$ref": "#/definitions/WindowsSandboxProvisionRequest"
+ }
+ }
+ },
+ "if": {
+ "properties": {
+ "phase": {
+ "const": "provision-session"
}
},
+ "required": [
+ "phase"
+ ]
+ },
+ "then": {
+ "else": false,
"if": {
"properties": {
"containment": {
- "const": "windows_sandbox"
+ "const": "wslc"
}
},
"required": [
@@ -198,7 +254,7 @@
]
},
"then": {
- "$ref": "#/definitions/WindowsSandboxProvisionRequest"
+ "$ref": "#/definitions/WslcProvisionSessionRequest"
}
}
}
@@ -283,6 +339,12 @@
],
"type": "object"
},
+ "DeprovisionSessionPhase": {
+ "enum": [
+ "deprovision-session"
+ ],
+ "type": "string"
+ },
"ExecPhase": {
"enum": [
"exec"
@@ -1224,6 +1286,12 @@
],
"type": "string"
},
+ "ProvisionSessionPhase": {
+ "enum": [
+ "provision-session"
+ ],
+ "type": "string"
+ },
"RuntimeConfig": {
"additionalProperties": false,
"description": "Runtime configuration supplied alongside the sandbox policy.",
@@ -1328,6 +1396,16 @@
},
"type": "object"
},
+ "StateAwareWslcSession": {
+ "additionalProperties": false,
+ "description": "WSLc settings for provisioning a shared utility VM.",
+ "properties": {
+ "provisionSession": {
+ "$ref": "#/definitions/WslcProvisionSession"
+ }
+ },
+ "type": "object"
+ },
"StopPhase": {
"enum": [
"stop"
@@ -1515,6 +1593,42 @@
],
"type": "string"
},
+ "WslcDeprovisionSessionRequest": {
+ "additionalProperties": false,
+ "description": "Request to release an empty WSLc session.",
+ "properties": {
+ "$schema": {
+ "description": "JSON Schema URL for editor validation.",
+ "type": "string"
+ },
+ "_comment": {
+ "description": "Annotation ignored by the runtime."
+ },
+ "containment": {
+ "$ref": "#/definitions/WslcContainment"
+ },
+ "phase": {
+ "$ref": "#/definitions/DeprovisionSessionPhase"
+ },
+ "sessionId": {
+ "description": "Session identifier returned by provision-session.",
+ "type": "string"
+ },
+ "telemetry": {
+ "$ref": "#/definitions/Telemetry"
+ },
+ "version": {
+ "$ref": "#/definitions/Version"
+ }
+ },
+ "required": [
+ "containment",
+ "phase",
+ "sessionId",
+ "version"
+ ],
+ "type": "object"
+ },
"WslcProvision": {
"additionalProperties": false,
"description": "WSLC settings accepted during provisioning.",
@@ -1533,6 +1647,10 @@
"$ref": "#/definitions/PortMapping"
},
"type": "array"
+ },
+ "sessionId": {
+ "description": "Session to join; omitted to use the shared default VM.",
+ "type": "string"
}
},
"type": "object"
@@ -1583,6 +1701,63 @@
"version"
],
"type": "object"
+ },
+ "WslcProvisionSession": {
+ "additionalProperties": false,
+ "description": "Utility VM sizing shared by every sandbox in the session.",
+ "properties": {
+ "cpuCount": {
+ "description": "Virtual CPUs available to the session's utility VM.",
+ "maximum": 4294967295,
+ "minimum": 0.0,
+ "type": "integer"
+ },
+ "gpu": {
+ "description": "Enable GPU passthrough for the session's utility VM.",
+ "type": "boolean"
+ },
+ "memoryMb": {
+ "description": "Utility VM memory ceiling, in megabytes.",
+ "maximum": 18446744073709551615,
+ "minimum": 0.0,
+ "type": "integer"
+ }
+ },
+ "type": "object"
+ },
+ "WslcProvisionSessionRequest": {
+ "additionalProperties": false,
+ "description": "Request to create a WSLc session.",
+ "properties": {
+ "$schema": {
+ "description": "JSON Schema URL for editor validation.",
+ "type": "string"
+ },
+ "_comment": {
+ "description": "Annotation ignored by the runtime."
+ },
+ "containment": {
+ "$ref": "#/definitions/WslcContainment"
+ },
+ "phase": {
+ "$ref": "#/definitions/ProvisionSessionPhase"
+ },
+ "telemetry": {
+ "$ref": "#/definitions/Telemetry"
+ },
+ "version": {
+ "$ref": "#/definitions/Version"
+ },
+ "wslc": {
+ "$ref": "#/definitions/StateAwareWslcSession"
+ }
+ },
+ "required": [
+ "containment",
+ "phase",
+ "version"
+ ],
+ "type": "object"
}
}
}
diff --git a/schemas/schema-version.json b/schemas/schema-version.json
index e0913809a..5f6dc6b31 100644
--- a/schemas/schema-version.json
+++ b/schemas/schema-version.json
@@ -4,7 +4,7 @@
"maxSupported": "1.1.0-alpha",
"stateAware": "0.9.0-alpha",
"stateAwareWindowsSandbox": "1.1.0-alpha",
- "stateAwareWslc": "0.9.0-alpha",
+ "stateAwareWslc": "1.1.0-alpha",
"stableLatest": "1.0.0",
"sdkMajorTargets": {
"1": "1.0.0"
diff --git a/scripts/versioning/check-schema-versions.js b/scripts/versioning/check-schema-versions.js
index 053068b8b..db31ab7be 100644
--- a/scripts/versioning/check-schema-versions.js
+++ b/scripts/versioning/check-schema-versions.js
@@ -83,14 +83,18 @@ if (publishedVersions.at(-1) !== stableLatest) {
`Exact Rust contract registry latest published version is "${publishedVersions.at(-1)}" but canonical stableLatest is "${stableLatest}"`
);
}
-for (const [label, version, requiredRoot] of [
- ["stateAware", stateAware, "isolation_session_provision"],
+for (const [label, version, requiredRoots] of [
+ ["stateAware", stateAware, ["isolation_session_provision"]],
[
"stateAwareWindowsSandbox",
stateAwareWindowsSandbox,
- "windows_sandbox_provision",
+ ["windows_sandbox_provision"],
+ ],
+ [
+ "stateAwareWslc",
+ stateAwareWslc,
+ ["wslc_provision", "wslc_provision_session", "wslc_deprovision_session"],
],
- ["stateAwareWslc", stateAwareWslc, "wslc_provision"],
]) {
if (!registryByVersion.has(version)) {
errors.push(`Canonical ${label} version "${version}" is absent from the exact Rust registry`);
@@ -98,10 +102,12 @@ for (const [label, version, requiredRoot] of [
}
try {
const roots = requestRootsForContract(registryByVersion.get(version));
- if (!roots.has(requiredRoot)) {
- errors.push(
- `Canonical ${label} version "${version}" does not support ${requiredRoot}`
- );
+ for (const requiredRoot of requiredRoots) {
+ if (!roots.has(requiredRoot)) {
+ errors.push(
+ `Canonical ${label} version "${version}" does not support ${requiredRoot}`
+ );
+ }
}
} catch (error) {
errors.push(
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 5d85caab1..4a16ac6ed 100644
--- a/sdk/node/src/generated/v1_1_0_alpha/wire.ts
+++ b/sdk/node/src/generated/v1_1_0_alpha/wire.ts
@@ -64,6 +64,8 @@ export interface DeprovisionRequest {
version: Version;
}
+export type DeprovisionSessionPhase = "deprovision-session";
+
export type ExecPhase = "exec";
/**
@@ -640,6 +642,8 @@ export type ProcessContainerUiSystemSettings = "none" | "parameters" | "display"
export type ProvisionPhase = "provision";
+export type ProvisionSessionPhase = "provision-session";
+
/**
* Runtime configuration supplied alongside the sandbox policy.
*/
@@ -728,6 +732,13 @@ export interface StateAwareWslc {
provision?: WslcProvision;
}
+/**
+ * WSLc settings for provisioning a shared utility VM.
+ */
+export interface StateAwareWslcSession {
+ provisionSession?: WslcProvisionSession;
+}
+
export type StopPhase = "stop";
/**
@@ -842,6 +853,28 @@ export interface WindowsSandboxProvisionRequest {
export type WslcContainment = "wslc";
+/**
+ * Request to release an empty WSLc session.
+ */
+export interface WslcDeprovisionSessionRequest {
+ /**
+ * JSON Schema URL for editor validation.
+ */
+ $schema?: string;
+ /**
+ * Annotation ignored by the runtime.
+ */
+ _comment?: unknown;
+ containment: WslcContainment;
+ phase: DeprovisionSessionPhase;
+ /**
+ * Session identifier returned by provision-session.
+ */
+ sessionId: string;
+ telemetry?: Telemetry;
+ version: Version;
+}
+
/**
* WSLC settings accepted during provisioning.
*/
@@ -858,6 +891,10 @@ export interface WslcProvision {
* Optional host-to-container TCP port mappings.
*/
portMappings?: PortMapping[];
+ /**
+ * Session to join; omitted to use the shared default VM.
+ */
+ sessionId?: string;
}
/**
@@ -902,7 +939,44 @@ export interface WslcProvisionRequest {
wslc?: StateAwareWslc;
}
+/**
+ * Utility VM sizing shared by every sandbox in the session.
+ */
+export interface WslcProvisionSession {
+ /**
+ * Virtual CPUs available to the session's utility VM.
+ */
+ cpuCount?: number;
+ /**
+ * Enable GPU passthrough for the session's utility VM.
+ */
+ gpu?: boolean;
+ /**
+ * Utility VM memory ceiling, in megabytes.
+ */
+ memoryMb?: number;
+}
+
+/**
+ * Request to create a WSLc session.
+ */
+export interface WslcProvisionSessionRequest {
+ /**
+ * JSON Schema URL for editor validation.
+ */
+ $schema?: string;
+ /**
+ * Annotation ignored by the runtime.
+ */
+ _comment?: unknown;
+ containment: WslcContainment;
+ phase: ProvisionSessionPhase;
+ telemetry?: Telemetry;
+ version: Version;
+ wslc?: StateAwareWslcSession;
+}
+
/**
* Exact mutable MXC development configuration contract.
*/
-export type MXCConfiguration = OneShotRequest | DeprovisionRequest | StopRequest | ExecRequest | StartRequest | WslcProvisionRequest | IsolationSessionProvisionRequest | WindowsSandboxProvisionRequest;
+export type MXCConfiguration = OneShotRequest | WslcDeprovisionSessionRequest | DeprovisionRequest | StopRequest | ExecRequest | StartRequest | WslcProvisionRequest | IsolationSessionProvisionRequest | WindowsSandboxProvisionRequest | WslcProvisionSessionRequest;
diff --git a/src/ffi/mxc_ffi/src/lib.rs b/src/ffi/mxc_ffi/src/lib.rs
index 6e8f05a46..46c7a1258 100644
--- a/src/ffi/mxc_ffi/src/lib.rs
+++ b/src/ffi/mxc_ffi/src/lib.rs
@@ -15,9 +15,10 @@
//! - **Streaming** (`streaming` module) — [`mxc_spawn_json`] accepts the same
//! document and returns an opaque live handle.
//! - **State-aware lifecycle** (`state_aware` module) —
-//! [`mxc_run_state_aware_json`] drives the envelope phases (provision /
-//! start / stop / deprovision), and [`mxc_exec_state_aware_json`] runs the
-//! exec phase as a live streaming handle (reusing the streaming externs).
+//! [`mxc_run_state_aware_json`] drives the envelope phases (provision-session /
+//! provision / start / stop / deprovision / deprovision-session), and
+//! [`mxc_exec_state_aware_json`] runs the exec phase as a live streaming handle
+//! (reusing the streaming externs).
//!
//! ## Ingress rule
//!
diff --git a/src/ffi/mxc_ffi/src/state_aware.rs b/src/ffi/mxc_ffi/src/state_aware.rs
index 4dd14ba77..e8afe01db 100644
--- a/src/ffi/mxc_ffi/src/state_aware.rs
+++ b/src/ffi/mxc_ffi/src/state_aware.rs
@@ -6,8 +6,9 @@
//! Entry points mirror the SDK's [`mxc_sdk::__ffi::run_lifecycle_json`],
//! [`mxc_engine::exec_state_aware_attached`] and [`mxc_sdk::__ffi::execute_lifecycle_json`]:
//!
-//! - [`mxc_run_state_aware_json`] drives the **envelope phases** (`provision` /
-//! `start` / `stop` / `deprovision`, and a dry run of any phase): JSON
+//! - [`mxc_run_state_aware_json`] drives the **envelope phases** (`provision-session` /
+//! `provision` / `start` / `stop` / `deprovision` / `deprovision-session`,
+//! and a dry run of any phase): JSON
//! request in, JSON response envelope out, filled into an
//! [`MxcStateAwareResult`].
//! - [`mxc_exec_state_aware_attached_json`] drives the **exec phase attached
@@ -100,26 +101,7 @@ impl MxcStateAwareResult {
}
}
-/// Run a state-aware lifecycle request (envelope phases) and capture the
-/// response-envelope JSON.
-///
-/// Parses `request_json_utf8` (the wire-format request, with a `phase` field),
-/// runs the requested phase, and writes the outcome into `*out`. A non-dry-run
-/// `exec` produces no envelope and is rejected here — run it through
-/// [`mxc_exec_state_aware_attached_json`] to attach the workload to this
-/// process's stdio, or [`mxc_exec_state_aware_json`] to drive the pipes
-/// directly.
-///
-/// Returns the resulting status code (also stored in `out->status`). Returns
-/// [`MXC_STATUS_NULL_ARGUMENT`] **without running the phase** if `out` is null:
-/// the caller has nowhere to receive a sandbox id, so provisioning one would
-/// strand it — nothing else can reclaim a sandbox whose only handle was
-/// discarded. [`mxc_exec_state_aware_json`] checks its out-parameter first for
-/// the same reason.
-///
-/// `experimental` is nonzero to permit an experimental backend such as Windows
-/// Sandbox; with zero such a backend is refused with `backend_unavailable`
-/// before any work is done. Production backends ignore it.
+/// Run an [MXC lifecycle JSON request](https://github.com/microsoft/mxc/blob/main/docs/development/architecture/container-lifecycle.md#raw-json-entry-points) and capture its response envelope.
///
/// # Safety
/// - `request_json_utf8` must be null or a valid NUL-terminated UTF-8 C string.
@@ -668,6 +650,23 @@ mod tests {
assert!(out.error.message_utf8.is_null());
}
+ #[test]
+ fn a_malformed_session_release_keeps_its_id_error_through_ffi() {
+ for dry_run in [false, true] {
+ let mut out = call(
+ r#"{"version":"1.1.0-alpha","phase":"deprovision-session",
+ "containment":"wslc","sessionId":"wslcsess:short"}"#,
+ dry_run,
+ );
+ assert_eq!(out.status, crate::MXC_STATUS_MALFORMED_ID);
+ assert!(out.response_json_utf8.is_null());
+ assert!(!out.error.message_utf8.is_null());
+
+ // SAFETY: this call returned the result's owned allocations.
+ unsafe { mxc_state_aware_result_free(&mut out) };
+ }
+ }
+
#[test]
fn exact_payload_errors_keep_their_paths_and_source_coordinates() {
for fields in [
diff --git a/src/mxc-sdk/src/backends/wslc/common/daemon_client.rs b/src/mxc-sdk/src/backends/wslc/common/daemon_client.rs
index ddbe993ff..616f58816 100644
--- a/src/mxc-sdk/src/backends/wslc/common/daemon_client.rs
+++ b/src/mxc-sdk/src/backends/wslc/common/daemon_client.rs
@@ -40,9 +40,9 @@ use serde::Serialize;
use crate::wslc_common::container_steps::OutStream;
use crate::wslc_common::daemon_protocol::{
- encode_frame, CancelExecConfig, DaemonRequest, DaemonResponse, DeprovisionConfig, ErrKind,
- ExecConfig, ExecTerminal, ProvisionConfig, StartConfig, StopConfig, StreamFrame,
- MAX_FRAME_SIZE, PROTOCOL_VERSION,
+ encode_frame, CancelExecConfig, DaemonRequest, DaemonResponse, DeprovisionConfig,
+ DeprovisionSessionConfig, ErrKind, ExecConfig, ExecTerminal, ProvisionConfig,
+ ProvisionSessionConfig, StartConfig, StopConfig, StreamFrame, MAX_FRAME_SIZE, PROTOCOL_VERSION,
};
use crate::wslc_common::daemon_record::{live_daemon, DaemonRecord, TransitionLock};
@@ -343,6 +343,20 @@ impl DaemonClient {
}
}
+ pub fn provision_session(&self, config: ProvisionSessionConfig) -> DaemonResult {
+ match self.call(&DaemonRequest::ProvisionSession(config))? {
+ DaemonResponse::SessionProvisioned { session_ref } => Ok(session_ref),
+ DaemonResponse::Err { kind, message } => Err(DaemonError::Daemon { kind, message }),
+ other => Err(DaemonError::transport(format!(
+ "unexpected reply to provision_session: {other:?}"
+ ))),
+ }
+ }
+
+ pub fn deprovision_session(&self, config: DeprovisionSessionConfig) -> DaemonResult<()> {
+ expect_ok(self.call(&DaemonRequest::DeprovisionSession(config))?)
+ }
+
/// Provision a container; returns the daemon-minted sandbox id.
pub fn provision(&self, config: ProvisionConfig) -> DaemonResult {
match self.call(&DaemonRequest::Provision(config))? {
diff --git a/src/mxc-sdk/src/backends/wslc/common/image.rs b/src/mxc-sdk/src/backends/wslc/common/image.rs
index 131cf4b79..d56595264 100644
--- a/src/mxc-sdk/src/backends/wslc/common/image.rs
+++ b/src/mxc-sdk/src/backends/wslc/common/image.rs
@@ -809,8 +809,18 @@ pub unsafe fn begin_resolve(
}
}
-/// The refusal for an uncached image a sandbox declaring no egress cannot pull.
fn no_egress_refusal(image: &str, store: &StoreHint) -> ScriptResponse {
+ if let StoreHint::DaemonSlot(index) = store {
+ return WslcError::Rejected(format!(
+ "WSLC isolated provision refused: daemon image store slot {index} at '{}' is cold \
+ for image '{image}'. No container was created. Release the session or let it \
+ expire before warming this store with wxc-exec.exe --setup-wslc --image {image}{}. \
+ Alternatively, import a local tar with wslc.provision.imageTarPath or allow egress.",
+ store.store_path(),
+ store.warm_instruction(),
+ ))
+ .into_response();
+ }
WslcError::Rejected(format!(
"WSLC image '{}' is not cached, and this sandbox declares no egress. \
Pulling it would reach the registry on the host's network, before the \
@@ -1707,6 +1717,22 @@ mod tests {
);
}
+ #[test]
+ fn an_isolated_provision_names_the_selected_cold_store_and_how_to_warm_it() {
+ let refusal = no_egress_refusal("alpine:latest", &StoreHint::DaemonSlot(1));
+ let message = refusal.error_message;
+ assert_eq!(
+ refusal.failure_phase,
+ crate::mxc_common::models::FailurePhase::Rejected
+ );
+ assert!(message.contains("daemon image store slot 1"));
+ assert!(message.contains(store_slots::slot_path(1).to_string_lossy().as_ref()));
+ assert!(message.contains("cold for image 'alpine:latest'"));
+ assert!(message.contains("No container was created"));
+ assert!(message.contains("--wslc-daemon-slots 2"));
+ assert!(message.contains("wslc.provision.imageTarPath"));
+ }
+
#[test]
fn a_higher_slot_asks_for_enough_slots_to_reach_it() {
let instruction = StoreHint::DaemonSlot(2).warm_instruction();
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 fcc9a07c3..6b8a994be 100644
--- a/src/mxc-sdk/src/backends/wslc/common/state_aware.rs
+++ b/src/mxc-sdk/src/backends/wslc/common/state_aware.rs
@@ -22,12 +22,15 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use crate::mxc_common::logger::{Logger, Mode};
-use crate::mxc_common::models::{ContainerPolicy, ExecutionRequest, WslcProvisionConfig};
+use crate::mxc_common::models::{
+ ContainerPolicy, ExecutionRequest, WslcProvisionConfig, WslcProvisionSessionConfig,
+};
use crate::mxc_common::mxc_error::MxcError;
use crate::mxc_common::state_aware_backend::{
null_pipe_handle, DeprovisionResult, ExecHandle, ExecOutcome, ExecStdio, ProvisionResult,
- StartResult, StatefulSandboxBackend, StopResult,
+ ProvisionSessionResult, StartResult, StatefulSandboxBackend, StopResult,
};
+use crate::mxc_common::state_aware_operation::validate_wslc_session_id;
use crate::mxc_common::validator::validate_state_aware_network_policy_support;
use crate::wslc_common::container_steps::OutStream;
@@ -35,8 +38,9 @@ use crate::wslc_common::daemon_client::{
truncation_suffix, DaemonClient, DaemonError, DaemonExecOutcome,
};
use crate::wslc_common::daemon_protocol::{
- DeprovisionConfig, ErrKind, ExecConfig, NetworkMode, PortMapping as DaemonPortMapping,
- ProvisionConfig, StartConfig, StopConfig, VolumeMount,
+ DeprovisionConfig, DeprovisionSessionConfig, ErrKind, ExecConfig, NetworkMode,
+ PortMapping as DaemonPortMapping, ProvisionConfig,
+ ProvisionSessionConfig as DaemonProvisionSessionConfig, StartConfig, StopConfig, VolumeMount,
};
use crate::wslc_common::policy::{
exec_proxy_url, validate_exec_policy, validate_post_provision_policy, validate_provision_policy,
@@ -83,6 +87,21 @@ impl StatefulSandboxBackend for WslcStateAwareRunner {
type StopMetadata = ();
type DeprovisionMetadata = ();
+ fn provision_session(
+ &mut self,
+ _request: &ExecutionRequest,
+ config: Option,
+ ) -> Result {
+ let session_config = build_provision_session_config(config);
+
+ let client = connect_daemon()?;
+ let session_id = client
+ .provision_session(session_config)
+ .map_err(map_daemon_error)?;
+
+ Ok(ProvisionSessionResult { session_id })
+ }
+
fn provision(
&mut self,
request: &ExecutionRequest,
@@ -95,8 +114,6 @@ impl StatefulSandboxBackend for WslcStateAwareRunner {
.provision(provision_config)
.map_err(map_daemon_error)?;
- // The daemon mints a fully `wslc:`-prefixed id; return it verbatim so
- // later phases present the daemon's own map key.
Ok(ProvisionResult {
sandbox_id,
metadata: None,
@@ -148,6 +165,20 @@ impl StatefulSandboxBackend for WslcStateAwareRunner {
Ok(DeprovisionResult { metadata: None })
}
+ fn deprovision_session(
+ &mut self,
+ session_id: &str,
+ _request: &ExecutionRequest,
+ ) -> Result<(), MxcError> {
+ validate_wslc_session_id(session_id)?;
+ let client = connect_daemon()?;
+ client
+ .deprovision_session(DeprovisionSessionConfig {
+ session_ref: session_id.to_string(),
+ })
+ .map_err(map_daemon_error)
+ }
+
/// Runs one command in the warm container. Relayed callers receive live
/// output on this process's stdout/stderr; piped callers receive synthesized
/// native stdout/stderr pipes fed from the same daemon stream.
@@ -172,8 +203,11 @@ impl StatefulSandboxBackend for WslcStateAwareRunner {
fn validate_provision(
&self,
request: &ExecutionRequest,
- _config: Option<&WslcProvisionConfig>,
+ config: Option<&WslcProvisionConfig>,
) -> Result<(), MxcError> {
+ if let Some(session_id) = config.and_then(|config| config.session_id.as_deref()) {
+ validate_wslc_session_id(session_id)?;
+ }
validate_state_aware_network_policy_support(
request,
crate::wslc_common::policy::network_policy_support(),
@@ -181,6 +215,34 @@ impl StatefulSandboxBackend for WslcStateAwareRunner {
validate_provision_policy(request)
}
+ fn validate_provision_session(
+ &self,
+ request: &ExecutionRequest,
+ config: Option<&WslcProvisionSessionConfig>,
+ ) -> Result<(), MxcError> {
+ validate_session_sizing(config)?;
+ validate_state_aware_network_policy_support(
+ request,
+ crate::wslc_common::policy::network_policy_support(),
+ )?;
+
+ // Filesystem and network policy belong to containers, not the session.
+ validate_post_provision_policy(request)
+ }
+
+ fn validate_deprovision_session(
+ &self,
+ session_id: &str,
+ request: &ExecutionRequest,
+ ) -> Result<(), MxcError> {
+ validate_wslc_session_id(session_id)?;
+ validate_state_aware_network_policy_support(
+ request,
+ crate::wslc_common::policy::network_policy_support(),
+ )?;
+ validate_post_provision_policy(request)
+ }
+
fn validate_start(
&self,
sandbox_id: &str,
@@ -474,6 +536,39 @@ fn truncated_run_error(exit_code: i32) -> MxcError {
.with_details(serde_json::json!({ "exitCode": exit_code }))
}
+fn build_provision_session_config(
+ config: Option,
+) -> DaemonProvisionSessionConfig {
+ let config = config.unwrap_or_default();
+ DaemonProvisionSessionConfig {
+ cpu_count: config.cpu_count,
+ memory_mb: config.memory_mb,
+ gpu: config.gpu.unwrap_or(false),
+ }
+}
+
+fn validate_session_sizing(config: Option<&WslcProvisionSessionConfig>) -> Result<(), MxcError> {
+ let Some(config) = config else {
+ return Ok(());
+ };
+ if let Some(memory_mb) = config.memory_mb {
+ u32::try_from(memory_mb).map_err(|_| {
+ MxcError::policy_validation(format!(
+ "wslc.provisionSession.memoryMb {memory_mb} exceeds the {} MB the WSLc SDK accepts",
+ u32::MAX
+ ))
+ })?;
+ }
+ crate::wslc_common::container_steps::check_sizing_against_host(
+ crate::wslc_common::container_steps::SessionSizing {
+ cpu_count: config.cpu_count,
+ memory_mb: config.memory_mb,
+ gpu: config.gpu.unwrap_or(false),
+ },
+ )
+ .map_err(|response| MxcError::policy_validation(response.error_message))
+}
+
/// Map a typed [`DaemonError`] onto the matching wire-format [`MxcError`] code.
fn map_daemon_error(err: DaemonError) -> MxcError {
match err {
@@ -541,7 +636,11 @@ fn build_provision_config(
.collect()
})
.unwrap_or_default();
- let image_tar_path = config.and_then(|c| c.image_tar_path);
+ let image_tar_path = config.as_ref().and_then(|c| c.image_tar_path.clone());
+ let session_id = config.and_then(|c| c.session_id);
+ if let Some(session_id) = session_id.as_deref() {
+ validate_wslc_session_id(session_id)?;
+ }
// WSLc provision-time filesystem-policy gate (D6 normalization → D3
// delegation → denied-path overlap), shared verbatim with the one-shot
@@ -576,11 +675,7 @@ fn build_provision_config(
volumes,
network,
port_mappings,
-
- // No request can name a session yet: nothing on the public surface
- // carries one, so every provision joins the daemon's implicit session,
- // which is exactly what it did before sessions became addressable.
- session_ref: None,
+ session_ref: session_id,
})
}
@@ -644,6 +739,177 @@ fn split_env(env: &[String]) -> Vec<(String, String)> {
.collect()
}
+#[cfg(test)]
+mod session_tests {
+ use super::*;
+ use crate::mxc_common::mxc_error::MxcErrorCode;
+
+ const VALID_SESSION_ID: &str = "wslcsess:0123456789abcdef0123456789abcdef";
+
+ #[test]
+ fn validate_session_id_accepts_a_daemon_minted_id() {
+ assert!(validate_wslc_session_id(VALID_SESSION_ID).is_ok());
+ }
+
+ #[test]
+ fn validate_session_id_rejects_everything_else() {
+ let mut runner = WslcStateAwareRunner::new();
+ for session_id in [
+ "",
+ "wslcsess",
+ "wslcsess:",
+ "0123456789abcdef0123456789abcdef",
+ "wslc:0123456789abcdef0123456789abcdef",
+ "wslcsess:default",
+ "wslcsess:0123456789abcdef0123456789abcde",
+ "wslcsess:0123456789abcdef0123456789abcdef0",
+ "wslcsess:0123456789ABCDEF0123456789ABCDEF",
+ "wslcsess:0123456789abcdef0123456789abcdeg",
+ ] {
+ let error = validate_wslc_session_id(session_id)
+ .expect_err("malformed session IDs must not be accepted");
+ assert_eq!(error.code, MxcErrorCode::MalformedId);
+ let error = runner
+ .deprovision_session(session_id, &ExecutionRequest::default())
+ .expect_err("direct session release must reject a malformed ID before connecting");
+ assert_eq!(error.code, MxcErrorCode::MalformedId);
+ }
+ }
+
+ #[test]
+ fn absent_sizing_reaches_the_daemon_as_host_defaults() {
+ for config in [None, Some(WslcProvisionSessionConfig::default())] {
+ let forwarded = build_provision_session_config(config);
+ assert_eq!(forwarded.cpu_count, None);
+ assert_eq!(forwarded.memory_mb, None);
+ assert!(
+ !forwarded.gpu,
+ "an unrequested GPU must not become a passthrough grant"
+ );
+ }
+ }
+
+ #[test]
+ fn each_sizing_field_is_forwarded_to_the_daemon() {
+ let forwarded = build_provision_session_config(Some(WslcProvisionSessionConfig {
+ cpu_count: Some(4),
+ memory_mb: Some(8192),
+ gpu: Some(true),
+ }));
+ assert_eq!(forwarded.cpu_count, Some(4));
+ assert_eq!(forwarded.memory_mb, Some(8192));
+ assert!(forwarded.gpu);
+
+ let gpu_off = build_provision_session_config(Some(WslcProvisionSessionConfig {
+ gpu: Some(false),
+ ..Default::default()
+ }));
+ assert!(!gpu_off.gpu);
+ }
+
+ #[test]
+ fn zero_sizing_is_accepted_and_forwarded_as_the_sdk_default() {
+ for config in [
+ WslcProvisionSessionConfig {
+ cpu_count: Some(0),
+ ..Default::default()
+ },
+ WslcProvisionSessionConfig {
+ memory_mb: Some(0),
+ ..Default::default()
+ },
+ WslcProvisionSessionConfig {
+ cpu_count: Some(0),
+ memory_mb: Some(0),
+ gpu: Some(false),
+ },
+ ] {
+ validate_session_sizing(Some(&config))
+ .unwrap_or_else(|error| panic!("zero selects the SDK default: {error}"));
+ let forwarded = build_provision_session_config(Some(config));
+ assert_eq!(forwarded.cpu_count, config.cpu_count);
+ assert_eq!(forwarded.memory_mb, config.memory_mb);
+ assert!(!forwarded.gpu);
+ }
+ assert!(validate_session_sizing(None).is_ok());
+ }
+
+ #[test]
+ fn session_memory_is_bounded_by_the_sdk_integer_width() {
+ for memory_mb in [u64::from(u32::MAX) + 1, u64::MAX] {
+ let error = validate_session_sizing(Some(&WslcProvisionSessionConfig {
+ memory_mb: Some(memory_mb),
+ ..Default::default()
+ }))
+ .expect_err("the SDK cannot represent this memory size");
+ assert_eq!(error.code, MxcErrorCode::PolicyValidation);
+ assert!(error.message.contains("wslc.provisionSession.memoryMb"));
+ assert!(error.message.contains(&u32::MAX.to_string()));
+ }
+ assert!(validate_session_sizing(Some(&WslcProvisionSessionConfig {
+ memory_mb: Some(u64::from(u32::MAX)),
+ ..Default::default()
+ }))
+ .is_ok());
+ }
+
+ #[test]
+ fn session_cpu_validation_reuses_the_create_and_run_host_check() {
+ let expected = crate::wslc_common::container_steps::check_sizing_against_host(
+ crate::wslc_common::container_steps::SessionSizing {
+ cpu_count: Some(u32::MAX),
+ ..Default::default()
+ },
+ )
+ .expect_err("this host cannot supply u32::MAX virtual CPUs");
+ let error = validate_session_sizing(Some(&WslcProvisionSessionConfig {
+ cpu_count: Some(u32::MAX),
+ ..Default::default()
+ }))
+ .expect_err("session provisioning must reject the same host CPU limit");
+ assert_eq!(error.code, MxcErrorCode::PolicyValidation);
+ assert_eq!(error.message, expected.error_message);
+ }
+
+ #[test]
+ fn a_provision_forwards_the_session_it_names_and_omits_one_it_does_not() {
+ let forward = |session_id: Option<&str>| {
+ build_provision_config(
+ &ExecutionRequest::default(),
+ Some(WslcProvisionConfig {
+ image: Some("alpine:latest".to_string()),
+ session_id: session_id.map(str::to_string),
+ ..Default::default()
+ }),
+ )
+ };
+
+ assert_eq!(forward(None).unwrap().session_ref, None);
+ assert_eq!(
+ forward(Some(VALID_SESSION_ID)).unwrap().session_ref,
+ Some(VALID_SESSION_ID.to_string())
+ );
+ }
+
+ #[test]
+ fn a_provision_naming_a_malformed_session_is_refused_before_the_daemon() {
+ let runner = WslcStateAwareRunner::new();
+ let config = WslcProvisionConfig {
+ session_id: Some("wslcsess:not-hex".to_string()),
+ ..Default::default()
+ };
+
+ let validated = runner
+ .validate_provision(&ExecutionRequest::default(), Some(&config))
+ .expect_err("a malformed session must not validate");
+ assert_eq!(validated.code, MxcErrorCode::MalformedId);
+
+ let built = build_provision_config(&ExecutionRequest::default(), Some(config))
+ .expect_err("a malformed session must not be forwarded");
+ assert_eq!(built.code, MxcErrorCode::MalformedId);
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -905,6 +1171,7 @@ mod tests {
let phase = WslcProvisionConfig {
image: Some("custom/image:tag".to_string()),
image_tar_path: Some("C:\\images\\custom.tar".to_string()),
+ session_id: None,
port_mappings: None,
};
let cfg = build_provision_config(&ExecutionRequest::default(), Some(phase)).unwrap();
@@ -1016,6 +1283,7 @@ mod tests {
WslcProvisionConfig {
image: Some("custom/image:tag".to_string()),
image_tar_path: None,
+ session_id: None,
port_mappings: None,
},
"custom/image:tag",
@@ -1025,6 +1293,7 @@ mod tests {
WslcProvisionConfig {
image: None,
image_tar_path: Some("C:\\images\\custom.tar".to_string()),
+ session_id: None,
port_mappings: None,
},
"alpine:latest",
@@ -1034,6 +1303,7 @@ mod tests {
WslcProvisionConfig {
image: Some(String::new()),
image_tar_path: Some(String::new()),
+ session_id: None,
port_mappings: None,
},
"",
diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/mod.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/mod.rs
index 6aef6b30a..ec62024e0 100644
--- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/mod.rs
+++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/mod.rs
@@ -21,11 +21,17 @@ pub(crate) fn adapt_request(request: contract::Request) -> Result {
+ state_aware::provision_session_into_input(request)
+ }
contract::Request::Provision(request) => state_aware::provision_into_input(request),
contract::Request::Start(request) => state_aware::start_into_input(request),
contract::Request::Exec(request) => state_aware::exec_into_input(request),
contract::Request::Stop(request) => state_aware::stop_into_input(request),
contract::Request::Deprovision(request) => state_aware::deprovision_into_input(request),
+ contract::Request::DeprovisionSession(request) => {
+ state_aware::deprovision_session_into_input(request)
+ }
}?;
Ok(AdaptedConfigRequest::StateAware(input))
}
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 b0265f49f..e665d714e 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
@@ -5,7 +5,9 @@ use crate::mxc_common::config_contract_adapters::dev::common::{
convert_filesystem, convert_network, convert_process, convert_runtime_config, convert_telemetry,
};
use crate::mxc_common::error::WxcError;
-use crate::mxc_common::models::{IsolationSessionProvisionConfig, WslcProvisionConfig};
+use crate::mxc_common::models::{
+ IsolationSessionProvisionConfig, WslcProvisionConfig, WslcProvisionSessionConfig,
+};
use crate::mxc_common::state_aware_input::StateAwareInput;
use crate::mxc_common::state_aware_operation::{StateAwareOperation, StateAwareProvision};
use crate::mxc_common::wire;
@@ -72,11 +74,13 @@ fn convert_wslc_provision(value: contract::WslcProvision) -> WslcProvisionConfig
let contract::WslcProvision {
image,
image_tar_path,
+ session_id,
port_mappings,
} = value;
WslcProvisionConfig {
image: image.into_option(),
image_tar_path: image_tar_path.into_option(),
+ session_id: session_id.into_option(),
port_mappings: port_mappings.into_option().map(|mappings| {
mappings
.into_iter()
@@ -91,6 +95,30 @@ fn convert_state_aware_wslc(value: contract::StateAwareWslc) -> Option WslcProvisionSessionConfig {
+ let contract::WslcProvisionSession {
+ cpu_count,
+ memory_mb,
+ gpu,
+ } = value;
+ WslcProvisionSessionConfig {
+ cpu_count: cpu_count.into_option(),
+ memory_mb: memory_mb.into_option(),
+ gpu: gpu.into_option(),
+ }
+}
+
+fn convert_state_aware_wslc_session(
+ value: contract::StateAwareWslcSession,
+) -> Option {
+ let contract::StateAwareWslcSession { provision_session } = value;
+ provision_session
+ .into_option()
+ .map(convert_wslc_provision_session)
+}
+
fn state_aware_common(
schema: contract::OptionalField,
comment: contract::OptionalField,
@@ -123,6 +151,25 @@ fn state_aware_common(
}
}
+pub(super) fn provision_session_into_input(
+ request: contract::WslcProvisionSessionRequest,
+) -> Result {
+ let contract::WslcProvisionSessionRequest {
+ schema,
+ comment,
+ version,
+ phase: contract::ProvisionSessionPhase,
+ containment: contract::WslcContainment,
+ telemetry,
+ wslc,
+ } = request;
+ let sizing = wslc
+ .into_option()
+ .and_then(convert_state_aware_wslc_session);
+ let common = state_aware_common(schema, comment, version, telemetry);
+ StateAwareInput::new(common, StateAwareOperation::ProvisionSession(sizing))
+}
+
pub(super) fn provision_into_input(
request: contract::ProvisionRequest,
) -> Result {
@@ -267,6 +314,25 @@ pub(super) fn deprovision_into_input(
StateAwareInput::new(common, StateAwareOperation::Deprovision { sandbox_id })
}
+pub(super) fn deprovision_session_into_input(
+ request: contract::WslcDeprovisionSessionRequest,
+) -> Result {
+ let contract::WslcDeprovisionSessionRequest {
+ schema,
+ comment,
+ version,
+ phase: contract::DeprovisionSessionPhase,
+ containment: contract::WslcContainment,
+ session_id,
+ telemetry,
+ } = request;
+ let common = state_aware_common(schema, comment, version, telemetry);
+ StateAwareInput::new(
+ common,
+ StateAwareOperation::DeprovisionSession { session_id },
+ )
+}
+
#[cfg(test)]
#[path = "state_aware_tests/mod.rs"]
mod tests;
diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/mod.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/mod.rs
index bada1f8d3..c71a154b6 100644
--- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/mod.rs
+++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/mod.rs
@@ -5,5 +5,6 @@ mod common;
mod deprovision;
mod exec;
mod provision;
+mod session;
mod start;
mod stop;
diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/session.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/session.rs
new file mode 100644
index 000000000..c7ce9ebb9
--- /dev/null
+++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/session.rs
@@ -0,0 +1,126 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use super::common::{adapt, assert_clean_common};
+use crate::mxc_common::models::WslcProvisionSessionConfig;
+use crate::mxc_common::state_aware_operation::StateAwareOperation;
+
+fn provision_session(
+ fields: &str,
+) -> (
+ crate::mxc_common::common_request_ir::CommonRequestIR,
+ StateAwareOperation,
+) {
+ adapt(&format!(
+ r#"{{"version":"1.1.0-alpha","phase":"provision-session","containment":"wslc"{fields}}}"#
+ ))
+}
+
+#[test]
+fn an_absent_wslc_section_leaves_the_sizing_absent() {
+ for fields in ["", r#","wslc":{}"#, r#","wslc":{"provisionSession":{}}"#] {
+ let (common, operation) = provision_session(fields);
+ assert_clean_common(&common);
+ assert!(common.process.is_none());
+ assert!(common.filesystem.is_none());
+ assert!(common.network.is_none());
+
+ let expected = if fields.contains("provisionSession") {
+ Some(WslcProvisionSessionConfig::default())
+ } else {
+ None
+ };
+ assert_eq!(
+ operation,
+ StateAwareOperation::ProvisionSession(expected),
+ "{fields}"
+ );
+ }
+}
+
+#[test]
+fn each_sizing_field_survives_adaptation_independently() {
+ let (_, operation) = provision_session(
+ r#","wslc":{"provisionSession":{"cpuCount":4,"memoryMb":8192,"gpu":true}}"#,
+ );
+ assert_eq!(
+ operation,
+ StateAwareOperation::ProvisionSession(Some(WslcProvisionSessionConfig {
+ cpu_count: Some(4),
+ memory_mb: Some(8192),
+ gpu: Some(true),
+ }))
+ );
+
+ let (_, gpu_off) = provision_session(r#","wslc":{"provisionSession":{"gpu":false}}"#);
+ assert_eq!(
+ gpu_off,
+ StateAwareOperation::ProvisionSession(Some(WslcProvisionSessionConfig {
+ gpu: Some(false),
+ ..Default::default()
+ })),
+ "an explicit false must not read as an absent field"
+ );
+}
+
+#[test]
+fn a_session_release_carries_its_id_and_nothing_else() {
+ let (common, operation) = adapt(
+ r#"{"version":"1.1.0-alpha","phase":"deprovision-session","containment":"wslc",
+ "sessionId":"wslcsess:0123456789abcdef0123456789abcdef"}"#,
+ );
+ assert_clean_common(&common);
+ assert!(common.process.is_none());
+ assert!(common.filesystem.is_none());
+ assert!(common.network.is_none());
+ assert_eq!(
+ operation,
+ StateAwareOperation::DeprovisionSession {
+ session_id: "wslcsess:0123456789abcdef0123456789abcdef".to_string(),
+ }
+ );
+}
+
+#[test]
+fn both_session_phases_declare_wslc_containment() {
+ let (_, provisioned) = provision_session("");
+ let (_, released) = adapt(
+ r#"{"version":"1.1.0-alpha","phase":"deprovision-session","containment":"wslc",
+ "sessionId":"wslcsess:0123456789abcdef0123456789abcdef"}"#,
+ );
+ for operation in [provisioned, released] {
+ assert_eq!(
+ operation.containment(),
+ Some(crate::mxc_common::models::ContainmentBackend::Wslc)
+ );
+ assert!(operation.sandbox_id().is_none());
+ }
+}
+
+#[test]
+fn a_provision_carries_the_session_it_joins_only_when_one_is_named() {
+ let config = |fields: &str| {
+ let (_, operation) = adapt(&format!(
+ r#"{{"version":"1.1.0-alpha","phase":"provision","containment":"wslc",
+ "wslc":{{"provision":{{{fields}}}}}}}"#
+ ));
+ let StateAwareOperation::Provision(
+ crate::mxc_common::state_aware_operation::StateAwareProvision::Wslc(config),
+ ) = operation
+ else {
+ panic!("expected a WSLc provision");
+ };
+ config.expect("a present provision section yields a config")
+ };
+
+ assert_eq!(config(r#""image":"alpine:latest""#).session_id, None);
+ assert_eq!(
+ config(r#""sessionId":"wslcsess:0123456789abcdef0123456789abcdef""#).session_id,
+ Some("wslcsess:0123456789abcdef0123456789abcdef".to_string())
+ );
+ assert_eq!(
+ config(r#""sessionId":"""#).session_id,
+ Some(String::new()),
+ "an explicitly empty string is distinct from an absent field"
+ );
+}
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 09470fe62..62f38d183 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(),
+ session_id: None,
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 dabaadb66..0a728aa32 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(),
+ session_id: None,
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 c4a349e16..622e56149 100644
--- a/src/mxc-sdk/src/core/mxc_common/config_parser.rs
+++ b/src/mxc-sdk/src/core/mxc_common/config_parser.rs
@@ -105,6 +105,12 @@ pub struct LoadOptions<'a> {
pub cli_command: &'a [String],
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct CliTargetId<'a> {
+ pub flag: &'a str,
+ pub value: &'a str,
+}
+
/// Workspace-internal exact one-shot contract selected by a trusted typed
/// producer.
///
@@ -206,6 +212,52 @@ fn exact_containment_error(error: crate::mxc_contract::dev::ContainmentProbeErro
ParseError::StateAware(MxcError::malformed_request(message))
}
+fn session_containment_error(
+ phase: ContractPhase,
+ error: crate::mxc_contract::dev::ContainmentProbeError,
+) -> ParseError {
+ use crate::mxc_contract::dev::ContainmentProbeError;
+
+ let phase = phase.as_str();
+ match error {
+ ContainmentProbeError::InvalidDeclaration(source) => {
+ ParseError::StateAware(MxcError::malformed_request(format!(
+ "Invalid containment declaration for {phase} phase: {source}"
+ )))
+ }
+ ContainmentProbeError::UnsupportedContainment(_) => {
+ ParseError::StateAware(unsupported_session_containment(phase))
+ }
+ }
+}
+
+fn unsupported_session_containment(phase: &str) -> MxcError {
+ MxcError::unsupported_containment(format!(
+ "the {phase} phase requires containment \"wslc\": no other backend owns a session that \
+ sandboxes are placed into"
+ ))
+}
+
+fn deserialize_session_root(
+ json: &str,
+ phase: ContractPhase,
+ contract: &'static str,
+) -> Result
+where
+ T: serde::de::DeserializeOwned,
+{
+ match crate::mxc_contract::dev::probe_containment(json)
+ .map_err(|error| session_containment_error(phase, error))?
+ {
+ crate::mxc_contract::dev::Containment::Wslc => {
+ deserialize_development_root(json, contract, "1.1", true)
+ }
+ _ => Err(ParseError::StateAware(unsupported_session_containment(
+ phase.as_str(),
+ ))),
+ }
+}
+
fn development_network_migration(contract: &str, path: Option<&str>) -> Option<&'static str> {
match (contract, path) {
("IsolationSession provision", Some("network.proxy")) => Some(
@@ -486,6 +538,10 @@ fn deserialize_development_request(
.map_err(|error| ParseError::OneShot(WxcError::ConfigParse(error.to_string())))?;
Ok(Request::OneShot(Box::new(request)))
}
+ Some(Phase::ProvisionSession) => {
+ deserialize_session_root(json, Phase::ProvisionSession, "WSLC provision-session")
+ .map(Request::ProvisionSession)
+ }
Some(Phase::Provision) => {
let request = match dev::probe_containment(json).map_err(exact_containment_error)? {
Containment::WindowsSandbox => {
@@ -515,6 +571,10 @@ fn deserialize_development_request(
Some(Phase::Deprovision) => {
deserialize_development_root(json, "deprovision", "1.1", true).map(Request::Deprovision)
}
+ Some(Phase::DeprovisionSession) => {
+ deserialize_session_root(json, Phase::DeprovisionSession, "WSLC deprovision-session")
+ .map(Request::DeprovisionSession)
+ }
}
}
@@ -522,7 +582,7 @@ fn parse_exact_development(json: &str, logger: &mut Logger) -> Result {
@@ -534,9 +594,7 @@ fn parse_exact_development(json: &str, logger: &mut Logger) -> Result normalize_state_aware(input, logger, true)
.map(MxcRequest::StateAware)
- .map_err(|error| {
- ParseError::StateAware(MxcError::malformed_request(error.to_string()))
- }),
+ .map_err(|error| ParseError::StateAware(error.into_state_aware())),
}
}
@@ -674,7 +732,7 @@ pub fn load_state_aware_request_from_json_with_options(
json_str: &str,
logger: &mut Logger,
phase: Phase,
- sandbox_id: Option<&str>,
+ target_id: Option>,
cli_command: &[String],
) -> Result {
let result = (|| {
@@ -689,18 +747,15 @@ pub fn load_state_aware_request_from_json_with_options(
)));
}
- // The CLI collapses whichever identity flag it was given into one
- // argument, so the rejection below can name a flag the caller did not
- // pass once more than one exists.
let required_flag = match phase.target_id_kind() {
TargetIdKind::None => None,
TargetIdKind::SandboxId => Some("--container-id"),
TargetIdKind::SessionId => Some("--session-id"),
};
- match (required_flag, sandbox_id) {
- (None, Some(_)) => {
+ match (required_flag, target_id) {
+ (None, Some(supplied)) => {
return Err(ParseError::StateAware(MxcError::malformed_request(
- format!("the {phase} operation does not accept --container-id"),
+ format!("the {phase} operation does not accept {}", supplied.flag),
)));
}
(None, None) => {}
@@ -709,7 +764,15 @@ pub fn load_state_aware_request_from_json_with_options(
format!("the {phase} operation requires {flag}"),
)));
}
- (Some(flag), Some("")) => {
+ (Some(flag), Some(supplied)) if supplied.flag != flag => {
+ return Err(ParseError::StateAware(MxcError::malformed_request(
+ format!(
+ "the {phase} operation requires {flag}, not {}",
+ supplied.flag
+ ),
+ )));
+ }
+ (Some(flag), Some(supplied)) if supplied.value.is_empty() => {
return Err(ParseError::StateAware(MxcError::malformed_request(
format!("{flag} must not be empty"),
)));
@@ -726,7 +789,10 @@ pub fn load_state_aware_request_from_json_with_options(
let routed_json = source
.splice_lifecycle_routing(
phase.as_str(),
- phase.target_id_kind().wire_member().zip(sandbox_id),
+ phase
+ .target_id_kind()
+ .wire_member()
+ .zip(target_id.map(|supplied| supplied.value)),
)
.ok_or_else(|| {
ParseError::StateAware(MxcError::malformed_request(
@@ -1724,6 +1790,20 @@ mod tests {
Logger::new(Mode::Buffer)
}
+ fn container_flag(value: &str) -> CliTargetId<'_> {
+ CliTargetId {
+ flag: "--container-id",
+ value,
+ }
+ }
+
+ fn session_flag(value: &str) -> CliTargetId<'_> {
+ CliTargetId {
+ flag: "--session-id",
+ value,
+ }
+ }
+
fn repository_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
@@ -2967,6 +3047,7 @@ mod tests {
StateAwareProvision::Wslc(Some(crate::mxc_common::models::WslcProvisionConfig {
image: Some("alpine:latest".into()),
image_tar_path: None,
+ session_id: None,
port_mappings: None,
})),
),
@@ -2975,6 +3056,7 @@ mod tests {
StateAwareProvision::Wslc(Some(crate::mxc_common::models::WslcProvisionConfig {
image: Some("python:3.12-alpine".into()),
image_tar_path: None,
+ session_id: None,
port_mappings: Some(vec![crate::mxc_common::models::PortMapping {
windows_port: 18081,
container_port: 8080,
@@ -4541,7 +4623,7 @@ mod tests {
json,
&mut test_logger(),
phase,
- sandbox_id,
+ sandbox_id.map(container_flag),
&[],
)
.unwrap_or_else(|error| panic!("{phase}: {error:?}"));
@@ -4560,7 +4642,7 @@ mod tests {
json,
&mut test_logger(),
Phase::Start,
- Some("iso:cli"),
+ Some(container_flag("iso:cli")),
&[],
)
.unwrap_err();
@@ -4574,7 +4656,7 @@ mod tests {
r#"{"version":"1.0.0","containment":"wslc"}"#,
&mut test_logger(),
Phase::Provision,
- Some("wslc:abc"),
+ Some(container_flag("wslc:abc")),
&[],
)
.unwrap_err();
@@ -4599,13 +4681,13 @@ mod tests {
r#"{"version":"1.1.0-alpha","containment":"wslc"}"#,
&mut test_logger(),
Phase::ProvisionSession,
- Some("wslcsess:abc"),
+ Some(session_flag("wslcsess:0123456789abcdef0123456789abcdef")),
&[],
)
.unwrap_err();
assert!(provision_session_error
.message()
- .contains("the provision-session operation does not accept --container-id"));
+ .contains("the provision-session operation does not accept --session-id"));
let missing = load_state_aware_request_from_json_with_options(
r#"{"version":"1.1.0-alpha","containment":"wslc"}"#,
@@ -4623,7 +4705,7 @@ mod tests {
r#"{"version":"1.1.0-alpha","containment":"wslc"}"#,
&mut test_logger(),
Phase::DeprovisionSession,
- Some(""),
+ Some(session_flag("")),
&[],
)
.unwrap_err();
@@ -4631,26 +4713,151 @@ mod tests {
}
#[test]
- fn session_phases_pass_cli_routing_and_are_then_refused_by_every_registered_contract() {
- for (phase, session_id) in [
- (Phase::ProvisionSession, None),
- (Phase::DeprovisionSession, Some("wslcsess:abcd")),
+ fn a_rejection_names_the_identity_flag_the_caller_supplied() {
+ let wrong_flag_for_a_sandbox = load_state_aware_request_from_json_with_options(
+ r#"{"version":"1.1.0-alpha"}"#,
+ &mut test_logger(),
+ Phase::Start,
+ Some(session_flag("wslcsess:0123456789abcdef0123456789abcdef")),
+ &[],
+ )
+ .unwrap_err();
+ assert_eq!(
+ wrong_flag_for_a_sandbox.message(),
+ "malformed_request: the start operation requires --container-id, not --session-id"
+ );
+
+ let wrong_flag_for_a_session = load_state_aware_request_from_json_with_options(
+ r#"{"version":"1.1.0-alpha","containment":"wslc"}"#,
+ &mut test_logger(),
+ Phase::DeprovisionSession,
+ Some(container_flag("wslc:abcd")),
+ &[],
+ )
+ .unwrap_err();
+ assert_eq!(
+ wrong_flag_for_a_session.message(),
+ "malformed_request: the deprovision-session operation requires --session-id, not \
+ --container-id"
+ );
+
+ let unwanted_container_id = load_state_aware_request_from_json_with_options(
+ r#"{"version":"1.1.0-alpha","containment":"wslc"}"#,
+ &mut test_logger(),
+ Phase::ProvisionSession,
+ Some(container_flag("wslc:abcd")),
+ &[],
+ )
+ .unwrap_err();
+ assert_eq!(
+ unwanted_container_id.message(),
+ "malformed_request: the provision-session operation does not accept --container-id"
+ );
+ }
+
+ #[test]
+ fn session_phases_route_through_the_development_contract() {
+ let provisioned = load_state_aware_request_from_json_with_options(
+ r#"{"version":"1.1.0-alpha","containment":"wslc"}"#,
+ &mut test_logger(),
+ Phase::ProvisionSession,
+ None,
+ &[],
+ )
+ .unwrap();
+ assert_eq!(provisioned.phase(), Phase::ProvisionSession);
+ assert_eq!(
+ provisioned.containment(),
+ Some(ContainmentBackend::Wslc),
+ "a session phase routes by its declared containment"
+ );
+ assert!(provisioned.session_id().is_none());
+
+ let released = load_state_aware_request_from_json_with_options(
+ r#"{"version":"1.1.0-alpha","containment":"wslc"}"#,
+ &mut test_logger(),
+ Phase::DeprovisionSession,
+ Some(session_flag("wslcsess:0123456789abcdef0123456789abcdef")),
+ &[],
+ )
+ .unwrap();
+ assert_eq!(released.phase(), Phase::DeprovisionSession);
+ assert_eq!(
+ released.session_id(),
+ Some("wslcsess:0123456789abcdef0123456789abcdef")
+ );
+ assert!(released.sandbox_id().is_none());
+ }
+
+ #[test]
+ fn malformed_session_ids_keep_their_error_code_in_cli_requests() {
+ for session_id in [
+ "wslcsess:short",
+ "wslc:0123456789abcdef0123456789abcdef",
+ "wslcsess:0123456789ABCDEF0123456789ABCDEF",
] {
let error = load_state_aware_request_from_json_with_options(
r#"{"version":"1.1.0-alpha","containment":"wslc"}"#,
&mut test_logger(),
+ Phase::DeprovisionSession,
+ Some(session_flag(session_id)),
+ &[],
+ )
+ .unwrap_err();
+ let ParseError::StateAware(error) = error else {
+ panic!("a session release must return a lifecycle error");
+ };
+ assert_eq!(error.code, MxcErrorCode::MalformedId);
+ }
+ }
+
+ #[test]
+ fn session_phases_are_refused_by_the_published_contracts() {
+ for version in [r#""0.9.0-alpha""#, r#""1.0.0""#] {
+ let error = load_state_aware_request_from_json_with_options(
+ &format!(r#"{{"version":{version},"containment":"wslc"}}"#),
+ &mut test_logger(),
+ Phase::ProvisionSession,
+ None,
+ &[],
+ )
+ .unwrap_err();
+ assert_eq!(error.message(), "malformed_request: Unsupported phase");
+ }
+ }
+
+ #[test]
+ fn a_session_phase_refuses_a_containment_that_owns_no_session() {
+ for (containment, phase, target) in [
+ (
+ r#""isolation_session""#,
+ Phase::ProvisionSession,
+ None::<&str>,
+ ),
+ (r#""windows_sandbox""#, Phase::ProvisionSession, None),
+ (r#""not_a_backend""#, Phase::ProvisionSession, None),
+ (
+ r#""isolation_session""#,
+ Phase::DeprovisionSession,
+ Some("wslcsess:0123456789abcdef0123456789abcdef"),
+ ),
+ ] {
+ let error = load_state_aware_request_from_json_with_options(
+ &format!(r#"{{"version":"1.1.0-alpha","containment":{containment}}}"#),
+ &mut test_logger(),
phase,
- session_id,
+ target.map(session_flag),
&[],
)
.unwrap_err();
- assert_eq!(
- error.message(),
- "malformed_request: Unsupported phase",
- "{phase} must reach the contract's phase probe, so the refusal comes from the \
- registered contracts rather than from the routing category"
+ let message = error.message();
+ assert!(
+ message.starts_with("unsupported_containment:"),
+ "{containment} on {phase} must be refused as an unsupported containment, got \
+ {message}"
);
+ assert!(message.contains(r#"containment "wslc""#), "{message}");
}
}
@@ -4744,7 +4951,7 @@ mod tests {
}"#,
&mut logger,
Phase::Exec,
- Some("iso:abc"),
+ Some(container_flag("iso:abc")),
&["echo".to_string(), "hello".to_string()],
)
.unwrap();
diff --git a/src/mxc-sdk/src/core/mxc_common/error.rs b/src/mxc-sdk/src/core/mxc_common/error.rs
index 7e4cf7473..37cb5ed5e 100644
--- a/src/mxc-sdk/src/core/mxc_common/error.rs
+++ b/src/mxc-sdk/src/core/mxc_common/error.rs
@@ -3,11 +3,16 @@
use thiserror::Error;
+use crate::mxc_common::mxc_error::MxcError;
+
#[derive(Debug, Error)]
pub enum WxcError {
#[error("Configuration parse error: {0}")]
ConfigParse(String),
+ #[error(transparent)]
+ StateAware(Box),
+
#[error("Validation error: {0}")]
Validation(String),
@@ -36,6 +41,21 @@ pub enum WxcError {
StringConversion(String),
}
+impl WxcError {
+ pub(crate) fn into_state_aware(self) -> MxcError {
+ match self {
+ Self::StateAware(error) => *error,
+ error => MxcError::malformed_request(error.to_string()),
+ }
+ }
+}
+
+impl From for WxcError {
+ fn from(error: MxcError) -> Self {
+ Self::StateAware(Box::new(error))
+ }
+}
+
impl From for WxcError {
fn from(err: serde_json::Error) -> Self {
WxcError::ConfigParse(err.to_string())
diff --git a/src/mxc-sdk/src/core/mxc_common/models.rs b/src/mxc-sdk/src/core/mxc_common/models.rs
index 7921fe8ef..5a352cd50 100644
--- a/src/mxc-sdk/src/core/mxc_common/models.rs
+++ b/src/mxc-sdk/src/core/mxc_common/models.rs
@@ -256,6 +256,9 @@ pub struct WslcProvisionConfig {
/// Local image tarball to import instead of pulling an image.
pub image_tar_path: Option,
+ /// Join this session, or the shared default session when absent.
+ pub session_id: Option,
+
/// Host-to-container TCP forwards applied to the sandbox's own container.
pub port_mappings: Option>,
}
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 80ae4587a..51ec9b5b8 100644
--- a/src/mxc-sdk/src/core/mxc_common/policy_identity.rs
+++ b/src/mxc-sdk/src/core/mxc_common/policy_identity.rs
@@ -99,17 +99,7 @@ pub fn policy_hash(request: &ExecutionRequest) -> String {
hash_canonical_json(&canonical)
}
-/// Compute a state-aware policy identity from the backend resolved from the
-/// lifecycle request and its typed operation.
-///
-/// Only allow-listed provision fields enter the hash. An absent config remains
-/// JSON `null`, a present config with no fields remains `{}`, and explicit empty
-/// strings remain distinct, preserving identities for exact accepted requests.
-///
-/// A session's identity covers the sizing it declares. A sandbox placed into an
-/// existing session declares none, and this hash is computed from the request
-/// before dispatch, so a sandbox's identity does not cover the sizing of the
-/// session it joins.
+/// Hash the declared lifecycle policy without resolving the session's CPU, memory, or GPU settings.
pub fn state_aware_policy_hash(
request: &ExecutionRequest,
backend: &str,
@@ -130,8 +120,6 @@ pub fn state_aware_policy_hash(
hash_canonical_json(&canonical_json(&projection))
}
-/// Exhaustive matching forces new operation/config fields to be classified
-/// explicitly instead of accidentally hashing a newly added secret.
fn state_aware_config_projection(operation: &StateAwareOperation) -> Value {
match operation {
StateAwareOperation::Provision(StateAwareProvision::IsolationSession(Some(
@@ -146,6 +134,7 @@ fn state_aware_config_projection(operation: &StateAwareOperation) -> Value {
StateAwareOperation::Provision(StateAwareProvision::Wslc(Some(WslcProvisionConfig {
image,
image_tar_path,
+ session_id,
port_mappings,
}))) => {
let mut config = Map::new();
@@ -156,6 +145,11 @@ fn state_aware_config_projection(operation: &StateAwareOperation) -> Value {
config.insert("imageTarPath".into(), Value::String(image_tar_path.clone()));
}
+ // Session IDs are capabilities, so record their presence without their values.
+ if session_id.is_some() {
+ config.insert("joinsNamedSession".into(), Value::Bool(true));
+ }
+
// A forwarded host port is policy, so it belongs in the identity.
if let Some(port_mappings) = port_mappings {
let mappings = port_mappings
@@ -987,6 +981,61 @@ mod tests {
}
}
+ #[test]
+ fn joining_a_session_is_recorded_without_distinguishing_which_session() {
+ let base = request();
+ let joined = |session_id: Option<&str>| {
+ state_aware_policy_hash(
+ &base,
+ "wslc",
+ &StateAwareOperation::Provision(StateAwareProvision::Wslc(Some(
+ WslcProvisionConfig {
+ image: Some("alpine:latest".to_string()),
+ session_id: session_id.map(str::to_string),
+ ..Default::default()
+ },
+ ))),
+ )
+ };
+
+ let implicit = joined(None);
+ let named = joined(Some("wslcsess:aaaa"));
+ assert_ne!(
+ implicit, named,
+ "a sandbox sized by the host defaults must not share an identity with one whose \
+ sizing was chosen by another request"
+ );
+ assert_eq!(
+ named,
+ joined(Some("wslcsess:bbbb")),
+ "the session id is a bearer capability and must not reach the identity"
+ );
+ }
+
+ #[test]
+ fn an_unnamed_session_leaves_the_provision_identity_untouched() {
+ let base = request();
+ let hash = |config: WslcProvisionConfig| {
+ state_aware_policy_hash(
+ &base,
+ "wslc",
+ &StateAwareOperation::Provision(StateAwareProvision::Wslc(Some(config))),
+ )
+ };
+
+ assert_eq!(
+ hash(WslcProvisionConfig {
+ image: Some("alpine:latest".to_string()),
+ ..Default::default()
+ }),
+ hash(WslcProvisionConfig {
+ image: Some("alpine:latest".to_string()),
+ session_id: None,
+ ..Default::default()
+ })
+ );
+ }
+
fn parse_state_aware(
json: &str,
) -> crate::mxc_common::state_aware_request::ParsedStateAwareRequest {
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 38005f780..7cb1f6ae4 100644
--- a/src/mxc-sdk/src/core/mxc_common/sdk_input.rs
+++ b/src/mxc-sdk/src/core/mxc_common/sdk_input.rs
@@ -138,7 +138,7 @@ impl SdkStateAwareInput {
version.as_str()
)));
}
- if session_operation(&operation) && version != ContractVersion::V1_1_0Alpha {
+ if wslc_session_requested(&operation) && version != ContractVersion::V1_1_0Alpha {
return Err(WxcError::ConfigParse(format!(
"state-aware session operations require schema version \
1.1.0-alpha, got {}",
@@ -210,11 +210,15 @@ fn wslc_port_mappings_requested(operation: &StateAwareOperation) -> bool {
)
}
-fn session_operation(operation: &StateAwareOperation) -> bool {
- matches!(
- operation,
- StateAwareOperation::ProvisionSession(_) | StateAwareOperation::DeprovisionSession { .. }
- )
+fn wslc_session_requested(operation: &StateAwareOperation) -> bool {
+ match operation {
+ StateAwareOperation::ProvisionSession(_)
+ | StateAwareOperation::DeprovisionSession { .. } => true,
+ StateAwareOperation::Provision(
+ crate::mxc_common::state_aware_operation::StateAwareProvision::Wslc(Some(config)),
+ ) => config.session_id.is_some(),
+ _ => false,
+ }
}
fn map_network(network: SdkNetworkInput) -> wire::Network {
@@ -316,6 +320,7 @@ mod tests {
crate::mxc_common::models::WslcProvisionConfig {
image: None,
image_tar_path: None,
+ session_id: None,
port_mappings,
},
)),
@@ -352,6 +357,51 @@ mod tests {
}
}
+ fn wslc_provision_in_session(session_id: Option<&str>) -> StateAwareOperation {
+ StateAwareOperation::Provision(
+ crate::mxc_common::state_aware_operation::StateAwareProvision::Wslc(Some(
+ crate::mxc_common::models::WslcProvisionConfig {
+ session_id: session_id.map(str::to_string),
+ ..Default::default()
+ },
+ )),
+ )
+ }
+
+ #[test]
+ fn the_stable_contract_rejects_session_ids_even_when_empty() {
+ for session_id in ["", "wslcsess:0123456789abcdef0123456789abcdef"] {
+ let error = SdkStateAwareInput::new(
+ ContractVersion::V1_0_0,
+ wslc_provision_in_session(Some(session_id)),
+ )
+ .expect_err("1.0.0 does not declare wslc.provision.sessionId");
+ assert!(
+ error.to_string().contains("1.1.0-alpha"),
+ "the message must name the contract that declares session placement: {error}"
+ );
+ }
+ }
+
+ #[test]
+ fn the_alpha_contract_preserves_session_id_presence() {
+ for session_id in ["", "wslcsess:0123456789abcdef0123456789abcdef"] {
+ let operation = wslc_provision_in_session(Some(session_id));
+ let input = SdkStateAwareInput::new(ContractVersion::V1_1_0Alpha, operation.clone())
+ .expect("1.1.0-alpha declares wslc.provision.sessionId");
+ let (_, observed) = input.into_normalization_input().unwrap().into_parts();
+ assert_eq!(observed, operation);
+ }
+ }
+
+ #[test]
+ fn an_omitted_session_id_is_accepted_on_both_contracts() {
+ for version in [ContractVersion::V1_0_0, ContractVersion::V1_1_0Alpha] {
+ SdkStateAwareInput::new(version, wslc_provision_in_session(None))
+ .expect("unnamed-session provisioning must remain compatible");
+ }
+ }
+
#[test]
fn the_constructor_validates_ports_before_normalization() {
for mappings in [
diff --git a/src/mxc-sdk/src/core/mxc_common/splice.rs b/src/mxc-sdk/src/core/mxc_common/splice.rs
index 88c46f91f..9e4661b72 100644
--- a/src/mxc-sdk/src/core/mxc_common/splice.rs
+++ b/src/mxc-sdk/src/core/mxc_common/splice.rs
@@ -276,7 +276,10 @@ mod tests {
fn lifecycle_routing_carries_a_session_id_in_its_own_member() {
let source = CommandSource::parse(r#"{"version":"1.1.0-alpha"}"#).unwrap();
let spliced = source
- .splice_lifecycle_routing("deprovision-session", Some(("sessionId", "wslcsess:abcd")))
+ .splice_lifecycle_routing(
+ "deprovision-session",
+ Some(("sessionId", "wslcsess:0123456789abcdef0123456789abcdef")),
+ )
.unwrap();
assert_eq!(
@@ -284,7 +287,7 @@ mod tests {
serde_json::json!({
"version": "1.1.0-alpha",
"phase": "deprovision-session",
- "sessionId": "wslcsess:abcd"
+ "sessionId": "wslcsess:0123456789abcdef0123456789abcdef"
})
);
}
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 026508fb8..4d3f7298c 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
@@ -692,7 +692,7 @@ fn wslc_binding_carries_session_sizing_and_the_session_id_through() {
let bound =
bind_wslc::>(session_parsed(StateAwareOperation::DeprovisionSession {
- session_id: "wslcsess:abcd".into(),
+ session_id: "wslcsess:0123456789abcdef0123456789abcdef".into(),
}))
.unwrap();
assert_eq!(bound.phase(), Phase::DeprovisionSession);
@@ -701,7 +701,7 @@ fn wslc_binding_carries_session_sizing_and_the_session_id_through() {
let BoundStateAwareOperation::DeprovisionSession { session_id } = operation else {
panic!("expected a bound session deprovision");
};
- assert_eq!(session_id, "wslcsess:abcd");
+ assert_eq!(session_id, "wslcsess:0123456789abcdef0123456789abcdef");
}
#[test]
@@ -709,7 +709,7 @@ fn session_operations_never_bind_to_a_backend_without_sessions() {
for operation in [
StateAwareOperation::ProvisionSession(None),
StateAwareOperation::DeprovisionSession {
- session_id: "wslcsess:abcd".into(),
+ session_id: "wslcsess:0123456789abcdef0123456789abcdef".into(),
},
] {
let error =
@@ -742,7 +742,7 @@ fn session_phases_keep_the_same_validation_execution_and_dry_run_order_as_every_
assert_dispatch_parsed::(
|| {
session_parsed(StateAwareOperation::DeprovisionSession {
- session_id: "wslcsess:abcd".into(),
+ session_id: "wslcsess:0123456789abcdef0123456789abcdef".into(),
})
},
Config::Absent,
diff --git a/src/mxc-sdk/src/core/mxc_common/state_aware_dispatch.rs b/src/mxc-sdk/src/core/mxc_common/state_aware_dispatch.rs
index dcd4ea5ce..a564fc3f2 100644
--- a/src/mxc-sdk/src/core/mxc_common/state_aware_dispatch.rs
+++ b/src/mxc-sdk/src/core/mxc_common/state_aware_dispatch.rs
@@ -679,6 +679,8 @@ mod tests {
use std::cell::RefCell;
use std::time::Duration;
+ const SESSION_ID: &str = "wslcsess:0123456789abcdef0123456789abcdef";
+
/// Fully-configurable backend stub for dispatcher tests.
/// Each phase is wired to either succeed (the default — empty result with
/// no metadata) or fail with a typed `MxcError` (set via the `*_error`
@@ -1083,14 +1085,14 @@ mod tests {
let env = assert_envelope(
dispatch_state_aware(
&mut b,
- session_bound(Phase::DeprovisionSession, Some("wslcsess:abcd")),
+ session_bound(Phase::DeprovisionSession, Some(SESSION_ID)),
false,
)
.unwrap(),
);
assert_eq!(b.validate_deprovision_session_calls.get(), 1);
assert_eq!(b.deprovision_session_calls.get(), 1);
- assert_eq!(*b.released.borrow(), vec!["wslcsess:abcd".to_string()]);
+ assert_eq!(*b.released.borrow(), vec![SESSION_ID.to_string()]);
assert_eq!(env, json!({"result": {}}));
}
@@ -1133,7 +1135,7 @@ mod tests {
let mut b = SessionStubBackend::new();
for bound in [
session_bound(Phase::ProvisionSession, None),
- session_bound(Phase::DeprovisionSession, Some("wslcsess:abcd")),
+ session_bound(Phase::DeprovisionSession, Some(SESSION_ID)),
] {
assert_eq!(
assert_envelope(dispatch_state_aware(&mut b, bound, true).unwrap()),
@@ -1153,7 +1155,7 @@ mod tests {
let mut b = StubBackend::new();
for bound in [
bound(Phase::ProvisionSession, None),
- bound(Phase::DeprovisionSession, Some("wslcsess:abcd")),
+ bound(Phase::DeprovisionSession, Some(SESSION_ID)),
] {
let error = dispatch_state_aware(&mut b, bound, dry_run).unwrap_err();
assert_eq!(error.code, MxcErrorCode::UnsupportedPhase, "{dry_run}");
@@ -1166,7 +1168,7 @@ mod tests {
fn a_session_phase_cannot_be_streamed_as_an_exec() {
let mut b = SessionStubBackend::new();
for phase in [Phase::ProvisionSession, Phase::DeprovisionSession] {
- let bound = session_bound(phase, Some("wslcsess:abcd"));
+ let bound = session_bound(phase, Some(SESSION_ID));
let error = dispatch_state_aware_exec(&mut b, bound).unwrap_err();
assert_eq!(error.code, MxcErrorCode::MalformedRequest);
assert_eq!(
@@ -1448,7 +1450,7 @@ mod tests {
for operation in [
StateAwareOperation::ProvisionSession(None),
StateAwareOperation::DeprovisionSession {
- session_id: "wslcsess:deadbeef".into(),
+ session_id: SESSION_ID.into(),
},
] {
let parsed = ParsedStateAwareRequest::new(ExecutionRequest::default(), 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 5395dc8cc..0b5ebbe4e 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
@@ -13,6 +13,7 @@ use crate::mxc_common::models::{
ContainmentBackend, IsolationSessionProvisionConfig, WslcProvisionConfig,
WslcProvisionSessionConfig,
};
+use crate::mxc_common::mxc_error::MxcError;
use crate::mxc_common::state_aware_request::Phase;
/// Backend-specific provision input, before backend-owned validation/defaulting.
@@ -128,7 +129,9 @@ impl StateAwareOperation {
None => Ok(()),
}
}
- Self::DeprovisionSession { session_id } => validate_session_id(session_id),
+ Self::DeprovisionSession { session_id } => {
+ validate_wslc_session_id(session_id).map_err(Into::into)
+ }
// Session sizing carries no cross-backend constraint; the WSLc
// backend decides what it can honor.
@@ -145,7 +148,7 @@ impl StateAwareOperation {
const SESSION_ID_TAG: &str = "wslcsess:";
const SESSION_ID_HEX_DIGITS: usize = 32;
-fn validate_session_id(session_id: &str) -> Result<(), crate::mxc_common::error::WxcError> {
+pub(crate) fn validate_wslc_session_id(session_id: &str) -> Result<(), MxcError> {
let well_formed = session_id
.strip_prefix(SESSION_ID_TAG)
.is_some_and(|token| {
@@ -157,7 +160,7 @@ fn validate_session_id(session_id: &str) -> Result<(), crate::mxc_common::error:
if well_formed {
Ok(())
} else {
- Err(crate::mxc_common::error::WxcError::ConfigParse(format!(
+ Err(MxcError::malformed_id(format!(
"a session id must be `{SESSION_ID_TAG}` followed by {SESSION_ID_HEX_DIGITS} \
lowercase hex digits"
)))
diff --git a/src/mxc-sdk/src/core/mxc_common/telemetry/correlation_state.rs b/src/mxc-sdk/src/core/mxc_common/telemetry/correlation_state.rs
index e4d2bda31..30561c6c2 100644
--- a/src/mxc-sdk/src/core/mxc_common/telemetry/correlation_state.rs
+++ b/src/mxc-sdk/src/core/mxc_common/telemetry/correlation_state.rs
@@ -1,20 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-//! Per-lifecycle persistence for the [`super::correlation_vector`] root, so a
-//! state-aware sandbox's phases share one telemetry lineage without a caller
-//! ever holding or relaying a vector.
-//!
-//! `provision` mints its vector the ordinary way ([`super::correlation_vector::seed`],
-//! before its `sandbox_id` exists) and, once dispatch mints that id, this module
-//! persists the vector under it. Every later phase recalls the persisted root
-//! and [`super::correlation_vector::spin`]s a child off it; `deprovision`
-//! forgets the record on success. A record that cannot be found or read (never
-//! persisted, removed, corrupted, or the store is unavailable) degrades to a
-//! fresh, disconnected [`super::correlation_vector::seed`] — lineage is lost,
-//! but a phase never derives a vector from `sandbox_id` itself, which — unlike
-//! a persisted root — is not guaranteed to carry 128 bits of entropy and, on
-//! some backends, can embed caller-supplied data.
+//! Per-sandbox telemetry correlation with independent vectors for session operations.
use std::fs;
use std::path::{Path, PathBuf};
@@ -23,6 +10,7 @@ use std::time::{Duration, SystemTime};
use crate::mxc_common::mxc_error::{MxcError, MxcErrorCode};
use crate::mxc_common::state_aware_dispatch::DispatchOutcome;
+use crate::mxc_common::state_aware_request::Phase;
use super::correlation_vector::{is_relayable, seed, spin};
@@ -33,19 +21,15 @@ const STORE_LOCK_RETRY_COUNT: usize = 200;
const STORE_LOCK_RETRY_DELAY: Duration = Duration::from_millis(5);
const PRUNE_DELETE_LIMIT: usize = 256;
-/// Pre-dispatch correlation vector for a state-aware phase. `provision` always
-/// seeds a fresh vector (its `sandbox_id` doesn't exist yet — persist it via
-/// [`on_provision_outcome`] once dispatch mints one). Every other phase recalls
-/// the persisted lifecycle root for `sandbox_id` and spins a child off it, or
-/// seeds a fresh, disconnected vector if `sandbox_id` is absent or has no
-/// record. Returns an empty string when `active` is false, so an inactive
-/// telemetry provider pays for no RNG/clock/file-system work.
-pub fn pre_dispatch_vector(active: bool, is_provision: bool, sandbox_id: Option<&str>) -> String {
+pub fn pre_dispatch_vector(active: bool, phase: Phase, sandbox_id: Option<&str>) -> String {
if !active {
return String::new();
}
+ if matches!(phase, Phase::ProvisionSession | Phase::DeprovisionSession) {
+ return seed();
+ }
match sandbox_id {
- Some(id) if !is_provision => {
+ Some(id) if phase != Phase::Provision => {
// Load and refresh the active lifecycle before sweeping abandoned
// records. Otherwise a valid sandbox whose last phase was over the
// retention threshold ago would delete its own root here.
@@ -553,8 +537,48 @@ mod tests {
#[test]
fn inactive_pre_dispatch_vector_is_empty() {
- assert_eq!(pre_dispatch_vector(false, true, None), "");
- assert_eq!(pre_dispatch_vector(false, false, Some("wsb:abcdef01")), "");
+ assert_eq!(pre_dispatch_vector(false, Phase::Provision, None), "");
+ assert_eq!(
+ pre_dispatch_vector(false, Phase::Exec, Some("wsb:abcdef01")),
+ ""
+ );
+ }
+
+ #[test]
+ fn session_phases_seed_without_sweeping_sandbox_records() {
+ let store = Arc::new(MemoryStore::default());
+ let _guard = replace_store(store.clone());
+ let root = seed();
+ store.persist("wslc:existing", &root);
+
+ for phase in [Phase::ProvisionSession, Phase::DeprovisionSession] {
+ assert_eq!(pre_dispatch_vector(false, phase, None), "");
+ let first = pre_dispatch_vector(true, phase, None);
+ let second = pre_dispatch_vector(true, phase, None);
+ assert!(is_relayable(&first));
+ assert!(is_relayable(&second));
+ assert_ne!(first, second);
+ }
+ assert_eq!(*store.prune_count.lock().unwrap(), 0);
+ assert_eq!(
+ store.record("wslc:existing").as_deref(),
+ Some(root.as_str())
+ );
+ }
+
+ #[test]
+ fn session_phases_do_not_create_a_correlation_store_or_lock() {
+ let directory = tempfile::tempdir().unwrap();
+ let store = directory.path().join("correlation");
+ let _guard = StoreDirGuard::set(&store);
+
+ for phase in [Phase::ProvisionSession, Phase::DeprovisionSession] {
+ assert!(is_relayable(&pre_dispatch_vector(true, phase, None)));
+ }
+ assert!(
+ !store.exists(),
+ "session phases must not access the sandbox correlation store"
+ );
}
#[test]
@@ -562,13 +586,13 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let _guard = StoreDirGuard::set(tmp.path());
- let provisioned = pre_dispatch_vector(true, true, None);
+ let provisioned = pre_dispatch_vector(true, Phase::Provision, None);
assert!(is_relayable(&provisioned));
// A non-provision phase with no sandbox_id (should never happen in
// practice; the parser requires one) still degrades to a fresh seed
// rather than panicking or emitting an empty vector.
- let no_id = pre_dispatch_vector(true, false, None);
+ let no_id = pre_dispatch_vector(true, Phase::Exec, None);
assert!(is_relayable(&no_id));
}
@@ -579,15 +603,15 @@ mod tests {
let sandbox_id = "wsb:12345678";
// provision: seed, then persist once the id is known.
- let provisioned = pre_dispatch_vector(true, true, None);
+ let provisioned = pre_dispatch_vector(true, Phase::Provision, None);
on_provision_outcome(true, &provisioned, &provision_envelope(sandbox_id));
let base_of = |cv: &str| cv.split('.').next().unwrap().to_string();
let provisioned_base = base_of(&provisioned);
// start / exec / stop all recall the same root and spin off it.
- for _ in 0..3 {
- let spun = pre_dispatch_vector(true, false, Some(sandbox_id));
+ for phase in [Phase::Start, Phase::Exec, Phase::Stop] {
+ let spun = pre_dispatch_vector(true, phase, Some(sandbox_id));
assert!(is_relayable(&spun));
assert_eq!(base_of(&spun), provisioned_base);
assert_ne!(
@@ -597,7 +621,7 @@ mod tests {
}
// deprovision recalls the same root one last time, then forgets it.
- let deprovisioned = pre_dispatch_vector(true, false, Some(sandbox_id));
+ let deprovisioned = pre_dispatch_vector(true, Phase::Deprovision, Some(sandbox_id));
assert_eq!(base_of(&deprovisioned), provisioned_base);
on_deprovision_outcome(
true,
@@ -608,7 +632,7 @@ mod tests {
// A later phase (e.g. a caller mistakenly reusing the id after
// teardown) finds no record and gets a disconnected vector.
- let after_teardown = pre_dispatch_vector(true, false, Some(sandbox_id));
+ let after_teardown = pre_dispatch_vector(true, Phase::Exec, Some(sandbox_id));
assert_ne!(base_of(&after_teardown), provisioned_base);
}
@@ -645,7 +669,7 @@ mod tests {
let _guard = StoreDirGuard::set(tmp.path());
let sandbox_id = "wsb:87654321";
- let provisioned = pre_dispatch_vector(true, true, None);
+ let provisioned = pre_dispatch_vector(true, Phase::Provision, None);
on_provision_outcome(true, &provisioned, &provision_envelope(sandbox_id));
let provisioned_base = provisioned.split('.').next().unwrap().to_string();
@@ -656,7 +680,7 @@ mod tests {
&Err(MxcError::backend_error("teardown failed, please retry")),
);
- let retried = pre_dispatch_vector(true, false, Some(sandbox_id));
+ let retried = pre_dispatch_vector(true, Phase::Deprovision, Some(sandbox_id));
assert_eq!(
retried.split('.').next().unwrap(),
provisioned_base,
@@ -669,7 +693,7 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let _guard = StoreDirGuard::set(tmp.path());
- let provisioned = pre_dispatch_vector(true, true, None);
+ let provisioned = pre_dispatch_vector(true, Phase::Provision, None);
// No `result.sandboxId` at all.
on_provision_outcome(
true,
@@ -678,8 +702,8 @@ mod tests {
);
// Nothing to recall for any id: every subsequent phase seeds fresh.
- let a = pre_dispatch_vector(true, false, Some("wsb:abcdef01"));
- let b = pre_dispatch_vector(true, false, Some("wsb:abcdef01"));
+ let a = pre_dispatch_vector(true, Phase::Exec, Some("wsb:abcdef01"));
+ let b = pre_dispatch_vector(true, Phase::Exec, Some("wsb:abcdef01"));
assert_ne!(
a.split('.').next().unwrap(),
b.split('.').next().unwrap(),
@@ -692,15 +716,15 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let _guard = StoreDirGuard::set(tmp.path());
- let provisioned = pre_dispatch_vector(true, true, None);
+ let provisioned = pre_dispatch_vector(true, Phase::Provision, None);
on_provision_outcome(
true,
&provisioned,
&Err(MxcError::backend_error("provisioning failed")),
);
- let a = pre_dispatch_vector(true, false, Some("wsb:abcdef01"));
- let b = pre_dispatch_vector(true, false, Some("wsb:abcdef01"));
+ let a = pre_dispatch_vector(true, Phase::Exec, Some("wsb:abcdef01"));
+ let b = pre_dispatch_vector(true, Phase::Exec, Some("wsb:abcdef01"));
assert_ne!(a.split('.').next().unwrap(), b.split('.').next().unwrap());
}
@@ -777,8 +801,8 @@ mod tests {
let store = Arc::new(MemoryStore::default());
let _guard = replace_store(store.clone());
- let _ = pre_dispatch_vector(true, true, None);
- let _ = pre_dispatch_vector(true, true, None);
+ let _ = pre_dispatch_vector(true, Phase::Provision, None);
+ let _ = pre_dispatch_vector(true, Phase::Provision, None);
assert_eq!(
*store.prune_count.lock().unwrap_or_else(|e| e.into_inner()),
@@ -806,7 +830,7 @@ mod tests {
store.record(sandbox_id).unwrap().split('.').next().unwrap(),
base
);
- let later = pre_dispatch_vector(true, false, Some(sandbox_id));
+ let later = pre_dispatch_vector(true, Phase::Exec, Some(sandbox_id));
assert_eq!(later.split('.').next().unwrap(), base);
}
@@ -850,7 +874,7 @@ mod tests {
filetime::set_file_mtime(&path, FileTime::from_system_time(stale_time)).unwrap();
filetime::set_file_mtime(&abandoned_path, FileTime::from_system_time(stale_time)).unwrap();
- let spun = pre_dispatch_vector(true, false, Some(sandbox_id));
+ let spun = pre_dispatch_vector(true, Phase::Exec, Some(sandbox_id));
assert_eq!(spun.split('.').next().unwrap(), base);
assert!(path.exists());
assert!(
@@ -901,7 +925,7 @@ mod tests {
)
.unwrap();
- let spun = pre_dispatch_vector(true, false, Some(sandbox_id));
+ let spun = pre_dispatch_vector(true, Phase::Exec, Some(sandbox_id));
assert_eq!(spun.split('.').next().unwrap(), base);
assert!(path.exists());
}
@@ -918,7 +942,7 @@ mod tests {
filetime::set_file_mtime(&path, FileTime::from_system_time(stale_time)).unwrap();
}
- let _ = pre_dispatch_vector(true, true, None);
+ let _ = pre_dispatch_vector(true, Phase::Provision, None);
let remaining_records = fs::read_dir(tmp.path())
.unwrap()
@@ -938,7 +962,7 @@ mod tests {
let tmp_path = store.tmp_path("wsb:fresh000").unwrap();
fs::create_dir_all(tmp.path()).unwrap();
fs::write(&tmp_path, seed()).unwrap();
- let _ = pre_dispatch_vector(true, true, None);
+ let _ = pre_dispatch_vector(true, Phase::Provision, None);
assert!(
tmp_path.exists(),
@@ -961,7 +985,7 @@ mod tests {
),
)
.unwrap();
- let _ = pre_dispatch_vector(true, true, None);
+ let _ = pre_dispatch_vector(true, Phase::Provision, None);
assert!(
!tmp_path.exists(),
diff --git a/src/mxc-sdk/src/core/mxc_contract/dev/mod.rs b/src/mxc-sdk/src/core/mxc_contract/dev/mod.rs
index ded0c9c5b..ef280938d 100644
--- a/src/mxc-sdk/src/core/mxc_contract/dev/mod.rs
+++ b/src/mxc-sdk/src/core/mxc_contract/dev/mod.rs
@@ -250,6 +250,7 @@ pub use stable::{
pub use state_aware::{probe_containment, Containment, ContainmentProbeError};
pub use state_aware::{probe_phase, Phase, PhaseProbeError};
pub use state_aware::{DeprovisionPhase, DeprovisionRequest};
+pub use state_aware::{DeprovisionSessionPhase, WslcDeprovisionSessionRequest};
pub use state_aware::{ExecPhase, ExecRequest};
pub use state_aware::{
IsolationSessionContainment, IsolationSessionNetwork, IsolationSessionNetworkAllow,
@@ -257,6 +258,9 @@ pub use state_aware::{
IsolationSessionProvisionRequest, StateAwareIsolationSession,
};
pub use state_aware::{ProvisionPhase, ProvisionRequest};
+pub use state_aware::{
+ ProvisionSessionPhase, StateAwareWslcSession, WslcProvisionSession, WslcProvisionSessionRequest,
+};
pub use state_aware::{StartPhase, StartRequest};
pub use state_aware::{StateAwareWslc, WslcContainment, WslcProvision, WslcProvisionRequest};
pub use state_aware::{StopPhase, StopRequest};
diff --git a/src/mxc-sdk/src/core/mxc_contract/dev/request.rs b/src/mxc-sdk/src/core/mxc_contract/dev/request.rs
index e3e33b494..b7fb8be15 100644
--- a/src/mxc-sdk/src/core/mxc_contract/dev/request.rs
+++ b/src/mxc-sdk/src/core/mxc_contract/dev/request.rs
@@ -6,6 +6,7 @@ use super::state_aware::{probe_containment, Containment, ContainmentProbeError};
use super::state_aware::{probe_phase, Phase, PhaseProbeError};
use super::state_aware::{
DeprovisionRequest, ExecRequest, ProvisionRequest, StartRequest, StopRequest,
+ WslcDeprovisionSessionRequest, WslcProvisionSessionRequest,
};
use super::NetworkAction;
@@ -14,6 +15,8 @@ use super::NetworkAction;
pub enum Request {
/// A one-shot execution request with no lifecycle phase.
OneShot(Box),
+ ProvisionSession(WslcProvisionSessionRequest),
+
/// A state-aware provision request selected by its containment backend.
Provision(ProvisionRequest),
/// A state-aware start request.
@@ -24,6 +27,7 @@ pub enum Request {
Stop(StopRequest),
/// A state-aware deprovision request.
Deprovision(DeprovisionRequest),
+ DeprovisionSession(WslcDeprovisionSessionRequest),
}
/// An error encountered while selecting or deserializing a development request.
@@ -77,6 +81,18 @@ fn parse_provision(json: &str) -> Result {
}
}
+fn parse_session(json: &str, contract: &'static str) -> Result
+where
+ T: serde::de::DeserializeOwned,
+{
+ match probe_containment(json)? {
+ Containment::Wslc => deserialize(json, contract),
+ other => Err(RequestParseError::Containment(
+ ContainmentProbeError::UnsupportedContainment(other.as_str().to_string()),
+ )),
+ }
+}
+
fn parse_one_shot(json: &str) -> Result {
let request: OneShotRequest = deserialize(json, "one-shot")?;
validate_one_shot_request(&request)?;
@@ -130,28 +146,20 @@ fn validate_isolation_session_one_shot_network(
Ok(())
}
-/// Selects and deserializes one exact development request from raw JSON source.
-///
-/// An absent `phase` selects the one-shot contract. A present phase selects its
-/// corresponding state-aware contract, with provision requests additionally
-/// selected by their required `containment` declaration. The selected concrete
-/// request still requires the exact `1.1.0-alpha` version marker.
-///
-/// # Errors
-///
-/// Returns [`RequestParseError::Phase`] when the phase declaration is malformed
-/// or unsupported, [`RequestParseError::Containment`] when a provision
-/// containment declaration is malformed or unsupported, and
-/// [`RequestParseError::InvalidRequest`] when the selected concrete contract
-/// rejects the document. Returns [`RequestParseError::InvalidCombination`] when
-/// individually valid fields violate a request-level invariant.
+/// Parse an MXC request using the development contract.
pub fn parse_request(json: &str) -> Result {
match probe_phase(json)? {
None => parse_one_shot(json),
+ Some(Phase::ProvisionSession) => {
+ parse_session(json, "WSLC provision-session").map(Request::ProvisionSession)
+ }
Some(Phase::Provision) => parse_provision(json).map(Request::Provision),
Some(Phase::Start) => deserialize(json, "start").map(Request::Start),
Some(Phase::Exec) => deserialize(json, "exec").map(Request::Exec),
Some(Phase::Stop) => deserialize(json, "stop").map(Request::Stop),
Some(Phase::Deprovision) => deserialize(json, "deprovision").map(Request::Deprovision),
+ Some(Phase::DeprovisionSession) => {
+ parse_session(json, "WSLC deprovision-session").map(Request::DeprovisionSession)
+ }
}
}
diff --git a/src/mxc-sdk/src/core/mxc_contract/dev/schema.rs b/src/mxc-sdk/src/core/mxc_contract/dev/schema.rs
index 8d1e4f51c..9e89f5235 100644
--- a/src/mxc-sdk/src/core/mxc_contract/dev/schema.rs
+++ b/src/mxc-sdk/src/core/mxc_contract/dev/schema.rs
@@ -8,7 +8,8 @@ use serde_json::{json, Value};
use super::{
DeprovisionRequest, ExecRequest, IsolationSessionProvisionRequest, OneShotRequest,
- StartRequest, StopRequest, WindowsSandboxProvisionRequest, WslcProvisionRequest,
+ StartRequest, StopRequest, WindowsSandboxProvisionRequest, WslcDeprovisionSessionRequest,
+ WslcProvisionRequest, WslcProvisionSessionRequest,
};
fn subschema(generator: &mut SchemaGenerator) -> Value {
@@ -50,6 +51,14 @@ fn provision_dispatch(windows_sandbox: Value, isolation_session: Value, wslc: Va
)
}
+fn session_dispatch(wslc: Value) -> Value {
+ branch(
+ discriminator("containment", "wslc"),
+ wslc,
+ Value::Bool(false),
+ )
+}
+
fn one_shot_dispatch(one_shot: Value) -> Value {
json!({
"allOf": [
@@ -98,28 +107,38 @@ fn one_shot_dispatch(one_shot: Value) -> Value {
#[allow(clippy::too_many_arguments)]
fn phase_dispatch(
+ provision_session: Value,
provision: Value,
start: Value,
exec: Value,
stop: Value,
deprovision: Value,
+ deprovision_session: Value,
) -> Value {
branch(
- discriminator("phase", "provision"),
- provision,
+ discriminator("phase", "provision-session"),
+ provision_session,
branch(
- discriminator("phase", "start"),
- start,
+ discriminator("phase", "provision"),
+ provision,
branch(
- discriminator("phase", "exec"),
- exec,
+ discriminator("phase", "start"),
+ start,
branch(
- discriminator("phase", "stop"),
- stop,
+ discriminator("phase", "exec"),
+ exec,
branch(
- discriminator("phase", "deprovision"),
- deprovision,
- Value::Bool(false),
+ discriminator("phase", "stop"),
+ stop,
+ branch(
+ discriminator("phase", "deprovision"),
+ deprovision,
+ branch(
+ discriminator("phase", "deprovision-session"),
+ deprovision_session,
+ Value::Bool(false),
+ ),
+ ),
),
),
),
@@ -127,16 +146,12 @@ fn phase_dispatch(
)
}
-/// Generates the unrendered JSON Schema for the mutable `1.1.0-alpha`
-/// development contract.
-///
-/// The document selects one of eight closed request roots through nested
-/// `if`/`then` discriminators. This avoids reporting validation failures from
-/// unrelated lifecycle phases while retaining a shared definitions table.
+/// Generate the development contract's JSON Schema.
pub fn development_schema() -> Value {
let mut generator = SchemaGenerator::default();
let one_shot = subschema::(&mut generator);
+ let wslc_session = subschema::(&mut generator);
let windows_sandbox = subschema::(&mut generator);
let isolation_session = subschema::(&mut generator);
let wslc = subschema::(&mut generator);
@@ -144,9 +159,18 @@ pub fn development_schema() -> Value {
let exec = subschema::(&mut generator);
let stop = subschema::(&mut generator);
let deprovision = subschema::(&mut generator);
+ let wslc_release_session = subschema::(&mut generator);
let provision = provision_dispatch(windows_sandbox, isolation_session, wslc);
- let state_aware = phase_dispatch(provision, start, exec, stop, deprovision);
+ let state_aware = phase_dispatch(
+ session_dispatch(wslc_session),
+ provision,
+ start,
+ exec,
+ stop,
+ deprovision,
+ session_dispatch(wslc_release_session),
+ );
let dispatch = branch(
json!({ "required": ["phase"] }),
state_aware,
@@ -172,6 +196,7 @@ mod tests {
const ROOT_NAMES: &[&str] = &[
"OneShotRequest",
+ "WslcProvisionSessionRequest",
"WindowsSandboxProvisionRequest",
"IsolationSessionProvisionRequest",
"WslcProvisionRequest",
@@ -179,6 +204,7 @@ mod tests {
"ExecRequest",
"StopRequest",
"DeprovisionRequest",
+ "WslcDeprovisionSessionRequest",
];
#[test]
@@ -189,6 +215,7 @@ mod tests {
regenerated_schema: development_schema(),
request_roots: ROOT_NAMES,
phase_markers: &[
+ ("WslcProvisionSessionRequest", "provision-session"),
("WindowsSandboxProvisionRequest", "provision"),
("IsolationSessionProvisionRequest", "provision"),
("WslcProvisionRequest", "provision"),
@@ -196,11 +223,14 @@ mod tests {
("ExecRequest", "exec"),
("StopRequest", "stop"),
("DeprovisionRequest", "deprovision"),
+ ("WslcDeprovisionSessionRequest", "deprovision-session"),
],
containment_markers: &[
+ ("WslcProvisionSessionRequest", "wslc"),
("WindowsSandboxProvisionRequest", "windows_sandbox"),
("IsolationSessionProvisionRequest", "isolation_session"),
("WslcProvisionRequest", "wslc"),
+ ("WslcDeprovisionSessionRequest", "wslc"),
],
compatibility_aliases: false,
one_shot_required: &["process"],
diff --git a/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/deprovision_session.rs b/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/deprovision_session.rs
new file mode 100644
index 000000000..c5b78a7fd
--- /dev/null
+++ b/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/deprovision_session.rs
@@ -0,0 +1,32 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use crate::mxc_contract::dev::state_aware::provision::WslcContainment;
+use crate::mxc_contract::dev::{OptionalField, Telemetry, Version};
+use serde::Deserialize;
+
+string_marker! {
+ pub struct DeprovisionSessionPhase => "deprovision-session";
+}
+
+/// Request to release an empty WSLc session.
+#[derive(Debug, Deserialize)]
+#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct WslcDeprovisionSessionRequest {
+ /// JSON Schema URL for editor validation.
+ #[serde(rename = "$schema", default)]
+ pub schema: OptionalField,
+
+ /// Annotation ignored by the runtime.
+ #[serde(rename = "_comment", default)]
+ pub comment: OptionalField,
+ pub version: Version,
+ pub phase: DeprovisionSessionPhase,
+ pub containment: WslcContainment,
+
+ /// Session identifier returned by provision-session.
+ pub session_id: String,
+ #[serde(default)]
+ pub telemetry: OptionalField,
+}
diff --git a/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/mod.rs b/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/mod.rs
index b6e56bb63..43cf70ba0 100644
--- a/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/mod.rs
+++ b/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/mod.rs
@@ -56,13 +56,16 @@ macro_rules! string_marker {
}
mod deprovision;
+mod deprovision_session;
mod exec;
mod phase;
mod provision;
+mod provision_session;
mod start;
mod stop;
pub use deprovision::{DeprovisionPhase, DeprovisionRequest};
+pub use deprovision_session::{DeprovisionSessionPhase, WslcDeprovisionSessionRequest};
pub use exec::{ExecPhase, ExecRequest};
pub use phase::{probe_phase, Phase, PhaseProbeError};
pub use provision::{probe_containment, Containment, ContainmentProbeError};
@@ -74,5 +77,8 @@ pub use provision::{
pub use provision::{ProvisionPhase, ProvisionRequest};
pub use provision::{StateAwareWslc, WslcContainment, WslcProvision, WslcProvisionRequest};
pub use provision::{WindowsSandboxContainment, WindowsSandboxProvisionRequest};
+pub use provision_session::{
+ ProvisionSessionPhase, StateAwareWslcSession, WslcProvisionSession, WslcProvisionSessionRequest,
+};
pub use start::{StartPhase, StartRequest};
pub use stop::{StopPhase, StopRequest};
diff --git a/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/phase.rs b/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/phase.rs
index 439193527..cd6451b1e 100644
--- a/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/phase.rs
+++ b/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/phase.rs
@@ -4,6 +4,8 @@
/// Lifecycle phase declared by a state-aware configuration request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
+ ProvisionSession,
+
/// Provision a new sandbox.
Provision,
/// Start a provisioned sandbox.
@@ -14,17 +16,20 @@ pub enum Phase {
Stop,
/// Release a provisioned sandbox and its resources.
Deprovision,
+ DeprovisionSession,
}
impl Phase {
/// Returns the exact registered spelling of this phase.
pub const fn as_str(self) -> &'static str {
match self {
+ Phase::ProvisionSession => "provision-session",
Phase::Provision => "provision",
Phase::Start => "start",
Phase::Exec => "exec",
Phase::Stop => "stop",
Phase::Deprovision => "deprovision",
+ Phase::DeprovisionSession => "deprovision-session",
}
}
@@ -33,11 +38,13 @@ impl Phase {
/// Returns `None` when `value` is not an exact supported spelling.
pub fn parse_exact(value: &str) -> Option {
match value {
+ "provision-session" => Some(Phase::ProvisionSession),
"provision" => Some(Phase::Provision),
"start" => Some(Phase::Start),
"exec" => Some(Phase::Exec),
"stop" => Some(Phase::Stop),
"deprovision" => Some(Phase::Deprovision),
+ "deprovision-session" => Some(Phase::DeprovisionSession),
_ => None,
}
}
@@ -139,6 +146,50 @@ mod tests {
assert_eq!(phase.unwrap().as_str(), "deprovision");
}
+ #[test]
+ fn probe_accepts_both_session_phases() {
+ for (json, expected) in [
+ (r#"{"phase": "provision-session"}"#, Phase::ProvisionSession),
+ (
+ r#"{"phase": "deprovision-session"}"#,
+ Phase::DeprovisionSession,
+ ),
+ ] {
+ assert_eq!(probe_phase(json).unwrap(), Some(expected));
+ }
+ }
+
+ #[test]
+ fn probe_keeps_the_sandbox_phases_distinct_from_the_session_phases() {
+ assert_eq!(
+ probe_phase(r#"{"phase": "provision"}"#).unwrap(),
+ Some(Phase::Provision)
+ );
+ assert_eq!(
+ probe_phase(r#"{"phase": "deprovision"}"#).unwrap(),
+ Some(Phase::Deprovision)
+ );
+ assert!(matches!(
+ probe_phase(r#"{"phase": "provision-sessions"}"#),
+ Err(PhaseProbeError::UnsupportedPhase(_))
+ ));
+ }
+
+ #[test]
+ fn every_phase_round_trips_through_its_registered_spelling() {
+ for phase in [
+ Phase::ProvisionSession,
+ Phase::Provision,
+ Phase::Start,
+ Phase::Exec,
+ Phase::Stop,
+ Phase::Deprovision,
+ Phase::DeprovisionSession,
+ ] {
+ assert_eq!(Phase::parse_exact(phase.as_str()), Some(phase));
+ }
+ }
+
#[test]
fn probe_ignores_unrelated_fields() {
let json = r#"{
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 4ff790199..afc86c7f5 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
@@ -23,6 +23,10 @@ pub struct WslcProvision {
#[serde(default)]
pub image_tar_path: OptionalField,
+ /// Session to join; omitted to use the shared default VM.
+ #[serde(default)]
+ pub session_id: OptionalField,
+
/// Optional host-to-container TCP port mappings.
#[serde(default)]
pub port_mappings: OptionalField>,
diff --git a/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/provision_session.rs b/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/provision_session.rs
new file mode 100644
index 000000000..4e9ee28fa
--- /dev/null
+++ b/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/provision_session.rs
@@ -0,0 +1,58 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use crate::mxc_contract::dev::state_aware::provision::WslcContainment;
+use crate::mxc_contract::dev::{OptionalField, Telemetry, Version};
+use serde::Deserialize;
+
+string_marker! {
+ pub struct ProvisionSessionPhase => "provision-session";
+}
+
+/// Utility VM sizing shared by every sandbox in the session.
+#[derive(Debug, Deserialize)]
+#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct WslcProvisionSession {
+ /// Virtual CPUs available to the session's utility VM.
+ #[serde(default)]
+ pub cpu_count: OptionalField,
+
+ /// Utility VM memory ceiling, in megabytes.
+ #[serde(default)]
+ pub memory_mb: OptionalField,
+
+ /// Enable GPU passthrough for the session's utility VM.
+ #[serde(default)]
+ pub gpu: OptionalField,
+}
+
+/// WSLc settings for provisioning a shared utility VM.
+#[derive(Debug, Deserialize)]
+#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct StateAwareWslcSession {
+ #[serde(default)]
+ pub provision_session: OptionalField,
+}
+
+/// Request to create a WSLc session.
+#[derive(Debug, Deserialize)]
+#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct WslcProvisionSessionRequest {
+ /// JSON Schema URL for editor validation.
+ #[serde(rename = "$schema", default)]
+ pub schema: OptionalField,
+
+ /// Annotation ignored by the runtime.
+ #[serde(rename = "_comment", default)]
+ pub comment: OptionalField,
+ pub version: Version,
+ pub phase: ProvisionSessionPhase,
+ pub containment: WslcContainment,
+ #[serde(default)]
+ pub telemetry: OptionalField,
+ #[serde(default)]
+ pub wslc: OptionalField,
+}
diff --git a/src/mxc-sdk/src/core/mxc_contract/registry.rs b/src/mxc-sdk/src/core/mxc_contract/registry.rs
index cf6a5d7de..68e8b9bde 100644
--- a/src/mxc-sdk/src/core/mxc_contract/registry.rs
+++ b/src/mxc-sdk/src/core/mxc_contract/registry.rs
@@ -142,6 +142,10 @@ const V1_1_ROOTS: &[ContractRequestRoot] = &[
fixture_directory: "one_shot",
schema_definition: "OneShotRequest",
},
+ ContractRequestRoot {
+ fixture_directory: "wslc_provision_session",
+ schema_definition: "WslcProvisionSessionRequest",
+ },
ContractRequestRoot {
fixture_directory: "windows_sandbox_provision",
schema_definition: "WindowsSandboxProvisionRequest",
@@ -170,6 +174,10 @@ const V1_1_ROOTS: &[ContractRequestRoot] = &[
fixture_directory: "deprovision",
schema_definition: "DeprovisionRequest",
},
+ ContractRequestRoot {
+ fixture_directory: "wslc_deprovision_session",
+ schema_definition: "WslcDeprovisionSessionRequest",
+ },
];
/// Metadata for every configuration contract currently registered by this
@@ -287,6 +295,34 @@ mod tests {
.any(|root| root.fixture_directory() == "wslc_provision"));
}
+ #[test]
+ fn only_the_development_contract_advertises_the_session_roots() {
+ let session_roots = ["wslc_provision_session", "wslc_deprovision_session"];
+ let development = descriptor(ContractVersion::V1_1_0Alpha);
+
+ for root in session_roots {
+ assert!(
+ development
+ .request_roots()
+ .iter()
+ .any(|candidate| candidate.fixture_directory() == root),
+ "the development contract must advertise {root}"
+ );
+ }
+
+ for version in [ContractVersion::V0_9_0Alpha, ContractVersion::V1_0_0] {
+ for root in session_roots {
+ assert!(
+ !descriptor(version)
+ .request_roots()
+ .iter()
+ .any(|candidate| candidate.fixture_directory() == root),
+ "published {version:?} must not advertise {root}"
+ );
+ }
+ }
+ }
+
#[test]
fn supported_contracts_advertise_generated_request_roots() {
for version in [
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 39c284d68..ea8945794 100644
--- a/src/mxc-sdk/src/core/mxc_engine/state_aware.rs
+++ b/src/mxc-sdk/src/core/mxc_engine/state_aware.rs
@@ -50,14 +50,8 @@ fn isolation_session_unavailable() -> MxcError {
)
}
-/// This phase's telemetry correlation vector, purely internal to MXC: no
-/// caller ever supplies or relays one. `provision` (whose `sandboxId` doesn't
-/// exist yet) mints a fresh vector; every later sandbox phase recalls the same
-/// lifecycle root persisted by `provision` and spins a distinct child off it —
-/// see [`telemetry::correlation_state`]. Session phases carry no `sandboxId`,
-/// so each seeds its own vector.
fn phase_correlation(active: bool, phase: Phase, sandbox_id: Option<&str>) -> String {
- telemetry::correlation_state::pre_dispatch_vector(active, phase == Phase::Provision, sandbox_id)
+ telemetry::correlation_state::pre_dispatch_vector(active, phase, sandbox_id)
}
fn surface_attached_warnings(logger: &mut Logger, mut surface: impl FnMut(&str)) {
@@ -468,7 +462,7 @@ fn normalize_sdk_state_aware(
) -> Result {
let mut parsed =
crate::mxc_common::config_parser::normalize_sdk_state_aware_request(input, logger)
- .map_err(|error| Error::from(MxcError::malformed_request(error.to_string())))?;
+ .map_err(|error| Error::from(error.into_state_aware()))?;
parsed.set_experimental_enabled(experimental);
Ok(parsed)
}
@@ -981,6 +975,8 @@ pub fn exec_state_aware_pty_json(
#[cfg(test)]
mod tests {
use super::*;
+ #[cfg(all(target_os = "windows", feature = "wslc"))]
+ use crate::mxc_common::models::ExecutionRequest;
use crate::mxc_common::mxc_error::MxcErrorCode;
use crate::mxc_common::sdk_input::{
SdkFilesystemInput, SdkNetworkAction, SdkNetworkEgressInput, SdkNetworkIngressInput,
@@ -1009,6 +1005,7 @@ mod tests {
Some(crate::mxc_common::models::WslcProvisionConfig {
image,
image_tar_path,
+ session_id: None,
port_mappings: None,
})
};
@@ -1392,6 +1389,20 @@ mod tests {
);
}
+ #[test]
+ fn session_phase_correlation_does_not_open_the_sandbox_store() {
+ let directory = tempfile::tempdir().unwrap();
+ let store = directory.path().join("correlation");
+ let _guard = StoreDirGuard::set(&store);
+ for phase in [Phase::ProvisionSession, Phase::DeprovisionSession] {
+ assert!(telemetry::correlation_vector::is_relayable(
+ &phase_correlation(true, phase, None,)
+ ));
+ assert!(phase_correlation(false, phase, None).is_empty());
+ }
+ assert!(!store.exists());
+ }
+
#[test]
fn attached_warning_surface_drains_retained_warnings() {
let mut logger = Logger::new(Mode::Buffer);
@@ -1634,14 +1645,17 @@ mod tests {
]
}
+ fn parsed_session(operation: StateAwareOperation) -> ParsedStateAwareRequest {
+ let input = SdkStateAwareInput::new(ContractVersion::V1_1_0Alpha, operation).unwrap();
+ normalize_sdk_state_aware(input, false, &mut Logger::new(Mode::Buffer)).unwrap()
+ }
+
fn session_refusal_on_each_route(
operation: &StateAwareOperation,
dry_run: bool,
) -> [(&'static str, Error); 2] {
let input = || SdkStateAwareInput::new(ContractVersion::V1_1_0Alpha, operation.clone());
- let parsed =
- normalize_sdk_state_aware(input().unwrap(), false, &mut Logger::new(Mode::Buffer))
- .unwrap();
+ let parsed = parsed_session(operation.clone());
let envelope = run_state_aware(parsed, dry_run).unwrap_err();
let typed = run_typed_state_aware_request(input().unwrap(), false, dry_run).unwrap_err();
[
@@ -1668,21 +1682,131 @@ mod tests {
#[cfg(all(target_os = "windows", feature = "wslc"))]
#[test]
- fn a_session_phase_reaches_wslc_and_is_refused_as_unsupported_until_it_is_implemented() {
+ fn a_dry_run_session_phase_validates_without_minting_or_booting_anything() {
for operation in session_operations() {
+ let envelope = run_state_aware(parsed_session(operation.clone()), true).unwrap();
+ let DispatchOutcome::Envelope(envelope) = envelope else {
+ panic!("a dry run must return a result envelope");
+ };
+ assert_eq!(envelope, serde_json::json!({"result": {}}));
+ let input = SdkStateAwareInput::new(ContractVersion::V1_1_0Alpha, operation).unwrap();
+ let result = run_typed_state_aware_request(input, false, true).unwrap();
+ assert_eq!(
+ result.session_id, None,
+ "a dry run must not mint a session id"
+ );
+ assert_eq!(result.sandbox_id, None);
+ }
+ }
+
+ #[cfg(all(target_os = "windows", feature = "wslc"))]
+ #[test]
+ fn invalid_session_sizing_is_refused_before_live_or_dry_run_dispatch() {
+ for config in [
+ crate::mxc_common::models::WslcProvisionSessionConfig {
+ memory_mb: Some(u64::from(u32::MAX) + 1),
+ ..Default::default()
+ },
+ crate::mxc_common::models::WslcProvisionSessionConfig {
+ cpu_count: Some(u32::MAX),
+ ..Default::default()
+ },
+ ] {
for dry_run in [false, true] {
+ let operation = StateAwareOperation::ProvisionSession(Some(config));
for (route, error) in session_refusal_on_each_route(&operation, dry_run) {
assert_eq!(
error.code,
- crate::ErrorCode::UnsupportedPhase,
- "{route} with dry_run={dry_run}: {error:?}"
+ crate::ErrorCode::PolicyValidation,
+ "{route}, {config:?}, dry_run={dry_run}: {error:?}"
);
- assert!(error.message.contains("wslc"), "{route}: {error:?}");
}
}
}
}
+ #[cfg(all(target_os = "windows", feature = "wslc"))]
+ #[test]
+ fn zero_session_sizing_validates_as_the_sdk_default() {
+ let parsed = parsed_session(StateAwareOperation::ProvisionSession(Some(
+ crate::mxc_common::models::WslcProvisionSessionConfig {
+ cpu_count: Some(0),
+ memory_mb: Some(0),
+ gpu: Some(false),
+ },
+ )));
+ let result = run_state_aware_typed(parsed, true).unwrap();
+ assert!(result.session_id.is_none());
+ assert!(result.sandbox_id.is_none());
+ }
+
+ #[cfg(all(target_os = "windows", feature = "wslc"))]
+ #[test]
+ fn a_malformed_session_id_is_refused_before_the_daemon_is_contacted() {
+ for session_id in [
+ "wslcsess:short",
+ "wslc:0123456789abcdef0123456789abcdef",
+ "0123456789abcdef0123456789abcdef",
+ "wslcsess:0123456789ABCDEF0123456789ABCDEF",
+ ] {
+ for dry_run in [false, true] {
+ let parsed = ParsedStateAwareRequest::new(
+ ExecutionRequest::default(),
+ StateAwareOperation::DeprovisionSession {
+ session_id: session_id.to_string(),
+ },
+ );
+ let error = run_state_aware(parsed, dry_run).unwrap_err();
+ assert_eq!(error.code, MxcErrorCode::MalformedId, "dry_run={dry_run}");
+ }
+ }
+ }
+
+ #[cfg(all(target_os = "windows", feature = "wslc"))]
+ #[test]
+ fn malformed_session_ids_have_the_same_error_on_join_and_release() {
+ for session_id in [
+ "",
+ "wslcsess:short",
+ "wslc:0123456789abcdef0123456789abcdef",
+ "wslcsess:0123456789ABCDEF0123456789ABCDEF",
+ ] {
+ for request in [
+ serde_json::json!({
+ "version": "1.1.0-alpha",
+ "phase": "provision",
+ "containment": "wslc",
+ "wslc": { "provision": { "sessionId": session_id } }
+ }),
+ serde_json::json!({
+ "version": "1.1.0-alpha",
+ "phase": "deprovision-session",
+ "containment": "wslc",
+ "sessionId": session_id
+ }),
+ ] {
+ for dry_run in [false, true] {
+ let error = run_state_aware_json(&request.to_string(), dry_run, false)
+ .expect_err("a malformed session ID must be refused");
+ assert_eq!(error.code, crate::ErrorCode::MalformedId);
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn typed_session_release_preserves_the_malformed_id_error() {
+ let error = SdkStateAwareInput::new(
+ ContractVersion::V1_1_0Alpha,
+ StateAwareOperation::DeprovisionSession {
+ session_id: "wslcsess:short".to_string(),
+ },
+ )
+ .unwrap_err()
+ .into_state_aware();
+ assert_eq!(error.code, MxcErrorCode::MalformedId);
+ }
+
#[test]
fn session_operations_require_the_development_contract() {
for operation in session_operations() {
@@ -1697,13 +1821,16 @@ mod tests {
let minted = typed_dispatch_result(
TypedDispatchOutcome::<(), (), (), ()>::ProvisionSession(
crate::mxc_common::state_aware_backend::ProvisionSessionResult {
- session_id: "wslcsess:abcd".to_string(),
+ session_id: "wslcsess:0123456789abcdef0123456789abcdef".to_string(),
},
),
no_provision_metadata,
)
.unwrap();
- assert_eq!(minted.session_id.as_deref(), Some("wslcsess:abcd"));
+ assert_eq!(
+ minted.session_id.as_deref(),
+ Some("wslcsess:0123456789abcdef0123456789abcdef")
+ );
assert_eq!(minted.sandbox_id, None);
assert_eq!(minted.metadata, None);
@@ -1720,9 +1847,14 @@ mod tests {
#[test]
fn the_sdk_facade_cannot_silently_discard_a_minted_session_id() {
let result = crate::state_aware_sdk::StateAwareResult::from_engine(
- EngineStateAwareResult::provision_session("wslcsess:abcd".to_string()),
+ EngineStateAwareResult::provision_session(
+ "wslcsess:0123456789abcdef0123456789abcdef".to_string(),
+ ),
+ );
+ assert_eq!(
+ result.session_id.as_deref(),
+ Some("wslcsess:0123456789abcdef0123456789abcdef")
);
- assert_eq!(result.session_id.as_deref(), Some("wslcsess:abcd"));
assert!(
result.clone().into_lifecycle().is_err(),
diff --git a/src/mxc-sdk/src/lib.rs b/src/mxc-sdk/src/lib.rs
index cef215ddf..de8436f83 100644
--- a/src/mxc-sdk/src/lib.rs
+++ b/src/mxc-sdk/src/lib.rs
@@ -415,24 +415,7 @@ pub mod __ffi {
wait_with_output(spawn_container_json(request_json, experimental)?)
}
- /// Run a lifecycle request (as a JSON string) and return the
- /// response-envelope JSON string.
- ///
- /// Handles the envelope phases (`provision`, `start`, `stop`, `deprovision`)
- /// and a dry run of any phase. A non-dry-run execution produces no envelope, so
- /// it is rejected here; use an execution entry point instead:
- /// [`execute_lifecycle_json`] to drive the pipes yourself.
- ///
- /// The request JSON is the same wire format the executor accepts (an object with
- /// a `phase` field). Errors (malformed request, unsupported phase, backend
- /// failures) come back as an [`Error`] with the matching [`ErrorCode`].
- ///
- /// `experimental` is the in-process equivalent of the executor's
- /// `--experimental` flag. It permits selecting an experimental backend, which
- /// is otherwise refused with [`ErrorCode::BackendUnavailable`] before any work
- /// is done, and is ignored for production backends.
- /// It is an API parameter rather than a field in the request JSON so that a
- /// config cannot grant itself experimental access.
+ /// Run an [MXC lifecycle JSON request](https://github.com/microsoft/mxc/blob/main/docs/development/architecture/container-lifecycle.md#raw-json-entry-points) and return its JSON response envelope.
pub fn run_lifecycle_json(
request_json: &str,
dry_run: bool,
diff --git a/src/mxc-sdk/src/state_aware_sdk.rs b/src/mxc-sdk/src/state_aware_sdk.rs
index 4e0f15e95..4fa151b12 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(),
+ session_id: None,
port_mappings: None,
})
};
diff --git a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures.rs b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures.rs
index 0530bdecb..f150daab8 100644
--- a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures.rs
+++ b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures.rs
@@ -3,7 +3,8 @@
use mxc_sdk::mxc_contract::dev::{
parse_request, DeprovisionRequest, ExecRequest, IsolationSessionProvisionRequest, Request,
- StartRequest, StopRequest, WindowsSandboxProvisionRequest, WslcProvisionRequest,
+ StartRequest, StopRequest, WindowsSandboxProvisionRequest, WslcDeprovisionSessionRequest,
+ WslcProvisionRequest, WslcProvisionSessionRequest,
};
const VERSION: &str = "v1_1_0_alpha";
@@ -33,6 +34,7 @@ fn assert_one_shot_fixtures() {
#[test]
fn accepts_and_rejects_every_discovered_fixture() {
assert_one_shot_fixtures();
+ assert_root_fixtures::("wslc_provision_session");
assert_root_fixtures::("windows_sandbox_provision");
assert_root_fixtures::("isolation_session_provision");
assert_root_fixtures::("wslc_provision");
@@ -40,4 +42,45 @@ fn accepts_and_rejects_every_discovered_fixture() {
assert_root_fixtures::("exec");
assert_root_fixtures::("stop");
assert_root_fixtures::("deprovision");
+ assert_root_fixtures::("wslc_deprovision_session");
+}
+
+#[test]
+fn the_request_parser_selects_each_session_root() {
+ for (root, expect_provision_session) in
+ [("wslc_provision_session", true), ("wslc_deprovision_session", false)]
+ {
+ for (name, json) in read_fixtures(root, "valid") {
+ let request = parse_request(&json)
+ .unwrap_or_else(|error| panic!("valid fixture '{root}/valid/{name}': {error}"));
+ let selected = if expect_provision_session {
+ matches!(request, Request::ProvisionSession(_))
+ } else {
+ matches!(request, Request::DeprovisionSession(_))
+ };
+ assert!(selected, "'{root}/valid/{name}' selected {request:?}");
+ }
+ }
+}
+
+#[test]
+fn the_request_parser_refuses_a_session_phase_for_a_foreign_containment() {
+ for phase in ["provision-session", "deprovision-session"] {
+ for containment in ["isolation_session", "windows_sandbox"] {
+ let json = format!(
+ r#"{{"version":"1.1.0-alpha","phase":"{phase}","containment":"{containment}",
+ "sessionId":"wslcsess:0123456789abcdef0123456789abcdef"}}"#
+ );
+ let error = parse_request(&json)
+ .err()
+ .unwrap_or_else(|| panic!("{containment} must not satisfy {phase}"));
+ assert!(
+ matches!(
+ error,
+ mxc_sdk::mxc_contract::dev::RequestParseError::Containment(_)
+ ),
+ "{phase}/{containment} must be refused as an unsupported containment: {error}"
+ );
+ }
+ }
}
diff --git a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_deprovision_session/invalid/foreign_sandbox_id.json b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_deprovision_session/invalid/foreign_sandbox_id.json
new file mode 100644
index 000000000..2a5badd25
--- /dev/null
+++ b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_deprovision_session/invalid/foreign_sandbox_id.json
@@ -0,0 +1,6 @@
+{
+ "version": "1.1.0-alpha",
+ "phase": "deprovision-session",
+ "containment": "wslc",
+ "sandboxId": "wslc:0123456789abcdef0123456789abcdef"
+}
diff --git a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_deprovision_session/invalid/missing_session_id.json b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_deprovision_session/invalid/missing_session_id.json
new file mode 100644
index 000000000..4001e53ff
--- /dev/null
+++ b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_deprovision_session/invalid/missing_session_id.json
@@ -0,0 +1,5 @@
+{
+ "version": "1.1.0-alpha",
+ "phase": "deprovision-session",
+ "containment": "wslc"
+}
diff --git a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_deprovision_session/valid/minimal.json b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_deprovision_session/valid/minimal.json
new file mode 100644
index 000000000..e83bd4896
--- /dev/null
+++ b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_deprovision_session/valid/minimal.json
@@ -0,0 +1,6 @@
+{
+ "version": "1.1.0-alpha",
+ "phase": "deprovision-session",
+ "containment": "wslc",
+ "sessionId": "wslcsess:0123456789abcdef0123456789abcdef"
+}
diff --git a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision/valid/named_session.json b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision/valid/named_session.json
new file mode 100644
index 000000000..1a9f910da
--- /dev/null
+++ b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision/valid/named_session.json
@@ -0,0 +1,11 @@
+{
+ "version": "1.1.0-alpha",
+ "phase": "provision",
+ "containment": "wslc",
+ "wslc": {
+ "provision": {
+ "image": "alpine:latest",
+ "sessionId": "wslcsess:0123456789abcdef0123456789abcdef"
+ }
+ }
+}
diff --git a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/invalid/foreign_sandbox_provision.json b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/invalid/foreign_sandbox_provision.json
new file mode 100644
index 000000000..49dc19e7c
--- /dev/null
+++ b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/invalid/foreign_sandbox_provision.json
@@ -0,0 +1,10 @@
+{
+ "version": "1.1.0-alpha",
+ "phase": "provision-session",
+ "containment": "wslc",
+ "wslc": {
+ "provision": {
+ "image": "alpine:latest"
+ }
+ }
+}
diff --git a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/invalid/foreign_session_id.json b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/invalid/foreign_session_id.json
new file mode 100644
index 000000000..01052e4ad
--- /dev/null
+++ b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/invalid/foreign_session_id.json
@@ -0,0 +1,6 @@
+{
+ "version": "1.1.0-alpha",
+ "phase": "provision-session",
+ "containment": "wslc",
+ "sessionId": "wslcsess:0123456789abcdef0123456789abcdef"
+}
diff --git a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/valid/minimal.json b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/valid/minimal.json
new file mode 100644
index 000000000..32948989e
--- /dev/null
+++ b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/valid/minimal.json
@@ -0,0 +1,5 @@
+{
+ "version": "1.1.0-alpha",
+ "phase": "provision-session",
+ "containment": "wslc"
+}
diff --git a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/valid/sized.json b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/valid/sized.json
new file mode 100644
index 000000000..c2c9e8a96
--- /dev/null
+++ b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/valid/sized.json
@@ -0,0 +1,12 @@
+{
+ "version": "1.1.0-alpha",
+ "phase": "provision-session",
+ "containment": "wslc",
+ "wslc": {
+ "provisionSession": {
+ "cpuCount": 4,
+ "memoryMb": 8192,
+ "gpu": true
+ }
+ }
+}
diff --git a/src/tools/wxc/src/main.rs b/src/tools/wxc/src/main.rs
index 685738be2..a30ad4fd0 100644
--- a/src/tools/wxc/src/main.rs
+++ b/src/tools/wxc/src/main.rs
@@ -11,7 +11,7 @@ use std::time::Instant;
use clap::{Parser, ValueEnum};
use mxc_sdk::mxc_common::audit::{AuditEvent, AuditEventName, RejectionReason};
-use mxc_sdk::mxc_common::config_parser::{LoadOptions, ParseError, RequestInputError};
+use mxc_sdk::mxc_common::config_parser::{CliTargetId, LoadOptions, ParseError, RequestInputError};
#[cfg(target_os = "windows")]
use mxc_sdk::mxc_common::diagnostic::DiagnosticConfig;
use mxc_sdk::mxc_common::error::WxcError;
@@ -87,6 +87,14 @@ struct Cli {
#[arg(long = "container-id", requires = "operation")]
container_id: Option,
+ /// Session ID returned by provision-session, used by deprovision-session.
+ #[arg(
+ long = "session-id",
+ requires = "operation",
+ conflicts_with = "container_id"
+ )]
+ session_id: Option,
+
/// Path to diagnostic log file (appends, creates if missing)
#[arg(long = "log-file")]
log_file: Option,
@@ -177,7 +185,8 @@ struct Cli {
"probe",
"force_reclaim",
"operation",
- "container_id"
+ "container_id",
+ "session_id"
]
)]
#[cfg_attr(
@@ -232,21 +241,25 @@ impl Cli {
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum CliOperation {
+ ProvisionSession,
Provision,
Start,
Exec,
Stop,
Deprovision,
+ DeprovisionSession,
}
impl From for mxc_sdk::mxc_common::state_aware_request::Phase {
fn from(operation: CliOperation) -> Self {
match operation {
+ CliOperation::ProvisionSession => Self::ProvisionSession,
CliOperation::Provision => Self::Provision,
CliOperation::Start => Self::Start,
CliOperation::Exec => Self::Exec,
CliOperation::Stop => Self::Stop,
CliOperation::Deprovision => Self::Deprovision,
+ CliOperation::DeprovisionSession => Self::DeprovisionSession,
}
}
}
@@ -533,14 +546,9 @@ fn run_state_aware_main(
.as_ref()
.and_then(|config| config.requested_sandbox_kind);
- // This phase's Microsoft Correlation Vector (MS-CV), purely internal to
- // MXC — no caller supplies or relays one. `provision` seeds a fresh
- // vector (persisted post-dispatch below, once its `sandbox_id` exists);
- // every later phase recalls that persisted lifecycle root and spins a
- // child off it. Empty when telemetry is inactive.
let correlation = telemetry::correlation_state::pre_dispatch_vector(
telemetry_active,
- is_provision,
+ parsed.phase(),
sandbox_id.as_deref(),
);
@@ -1333,12 +1341,25 @@ fn main() {
let config_json = config_json.expect("config_json is Some on non-delete paths");
if let Some(operation) = cli.operation {
let phase = operation.into();
+ let target_id = cli
+ .container_id
+ .as_deref()
+ .map(|value| CliTargetId {
+ flag: "--container-id",
+ value,
+ })
+ .or_else(|| {
+ cli.session_id.as_deref().map(|value| CliTargetId {
+ flag: "--session-id",
+ value,
+ })
+ });
let parsed =
match mxc_sdk::mxc_common::config_parser::load_state_aware_request_from_json_with_options(
&config_json,
&mut logger,
phase,
- cli.container_id.as_deref(),
+ target_id,
&cli.command,
) {
Ok(parsed) => parsed,
@@ -1788,11 +1809,13 @@ mod tests {
#[test]
fn cli_accepts_lifecycle_operation_and_container_id() {
for (name, expected) in [
+ ("provision-session", CliOperation::ProvisionSession),
("provision", CliOperation::Provision),
("start", CliOperation::Start),
("exec", CliOperation::Exec),
("stop", CliOperation::Stop),
("deprovision", CliOperation::Deprovision),
+ ("deprovision-session", CliOperation::DeprovisionSession),
] {
let cli = parse_cli(&["wxc-exec", "policy.json", "--operation", name]);
assert_eq!(cli.operation, Some(expected));
@@ -1855,9 +1878,56 @@ mod tests {
let help = Cli::command().render_help().to_string();
assert!(help.contains("--container-id"));
+ assert!(help.contains("--session-id"));
assert!(!help.contains("--sandbox-id"));
}
+ #[test]
+ fn cli_keeps_the_two_lifecycle_identities_on_separate_flags() {
+ let cli = parse_cli(&[
+ "wxc-exec",
+ "policy.json",
+ "--operation",
+ "deprovision-session",
+ "--session-id",
+ "wslcsess:0123456789abcdef0123456789abcdef",
+ ]);
+ assert_eq!(
+ cli.session_id.as_deref(),
+ Some("wslcsess:0123456789abcdef0123456789abcdef")
+ );
+ assert_eq!(cli.container_id, None);
+
+ let error = match Cli::try_parse_from([
+ "wxc-exec",
+ "policy.json",
+ "--session-id",
+ "wslcsess:0123456789abcdef0123456789abcdef",
+ ]) {
+ Err(error) => error,
+ Ok(_) => panic!("--session-id must require --operation"),
+ };
+ assert_eq!(
+ error.kind(),
+ clap::error::ErrorKind::MissingRequiredArgument
+ );
+
+ let error = match Cli::try_parse_from([
+ "wxc-exec",
+ "policy.json",
+ "--operation",
+ "deprovision-session",
+ "--container-id",
+ "wslc:0123456789abcdef0123456789abcdef",
+ "--session-id",
+ "wslcsess:0123456789abcdef0123456789abcdef",
+ ]) {
+ Err(error) => error,
+ Ok(_) => panic!("--container-id and --session-id must conflict"),
+ };
+ assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
+ }
+
#[test]
fn cli_rejects_lifecycle_operation_with_utility_modes() {
for utility_mode in [
@@ -2097,11 +2167,13 @@ mod tests {
logger.enable_file_sink(&log_path).unwrap();
for operation in [
+ CliOperation::ProvisionSession,
CliOperation::Provision,
CliOperation::Start,
CliOperation::Exec,
CliOperation::Stop,
CliOperation::Deprovision,
+ CliOperation::DeprovisionSession,
] {
let error = lifecycle_input_error(
operation,
@@ -2127,17 +2199,24 @@ mod tests {
let log = std::fs::read_to_string(log_path).unwrap();
assert_eq!(
log.matches(r#""reason":"malformed_json""#).count(),
- 5,
+ 7,
"each lifecycle input failure must emit one ConfigRejected record: {log}"
);
- for phase in ["provision", "start", "exec", "stop", "deprovision"] {
+ for phase in [
+ "provision-session",
+ "provision",
+ "start",
+ "exec",
+ "stop",
+ "deprovision",
+ "deprovision-session",
+ ] {
assert_eq!(
log.matches(&format!(r#""phase":"{phase}""#)).count(),
1,
"missing lifecycle phase {phase}: {log}"
);
}
-
let mut logger = test_logger();
let source_path = directory.path().join("source-audit.log");
logger.enable_file_sink(&source_path).unwrap();
@@ -3095,14 +3174,31 @@ mod tests {
#[test]
fn pre_dispatch_vector_is_empty_when_telemetry_inactive() {
- // Inactive telemetry does no RNG/clock work regardless of phase.
- assert!(telemetry::correlation_state::pre_dispatch_vector(false, true, None).is_empty());
- assert!(telemetry::correlation_state::pre_dispatch_vector(
- false,
- false,
- Some("wsb:12345678")
- )
- .is_empty());
+ for phase in [
+ Phase::ProvisionSession,
+ Phase::Provision,
+ Phase::Start,
+ Phase::Exec,
+ Phase::Stop,
+ Phase::Deprovision,
+ Phase::DeprovisionSession,
+ ] {
+ assert!(
+ telemetry::correlation_state::pre_dispatch_vector(false, phase, None).is_empty()
+ );
+ }
+ }
+
+ #[test]
+ fn session_phase_correlation_never_creates_the_sandbox_store() {
+ let directory = tempfile::tempdir().unwrap();
+ let store = directory.path().join("correlation");
+ let _guard = StoreDirGuard::set(&store);
+ for phase in [Phase::ProvisionSession, Phase::DeprovisionSession] {
+ let vector = telemetry::correlation_state::pre_dispatch_vector(true, phase, None);
+ assert!(telemetry::correlation_vector::is_relayable(&vector));
+ }
+ assert!(!store.exists());
}
#[test]
@@ -3111,19 +3207,18 @@ mod tests {
let _guard = StoreDirGuard::set(tmp.path());
let sandbox_id = "wsb:12345678";
- // Active provision seeds a fresh valid vector.
- let provisioned = telemetry::correlation_state::pre_dispatch_vector(true, true, None);
+ let provisioned =
+ telemetry::correlation_state::pre_dispatch_vector(true, Phase::Provision, None);
assert!(telemetry::correlation_vector::is_relayable(&provisioned));
- // Persisting it (as `run_state_aware_main` does post-dispatch) lets a
- // later phase recall it and spin a distinct child off the same base.
let outcome: Result = Ok(DispatchOutcome::Envelope(
serde_json::json!({ "result": { "sandboxId": sandbox_id } }),
));
telemetry::correlation_state::on_provision_outcome(true, &provisioned, &outcome);
let base_prefix = provisioned.split('.').next().unwrap();
- let spun = telemetry::correlation_state::pre_dispatch_vector(true, false, Some(sandbox_id));
+ let spun =
+ telemetry::correlation_state::pre_dispatch_vector(true, Phase::Start, Some(sandbox_id));
assert!(spun.starts_with(&format!("{base_prefix}.")), "{spun:?}");
assert_ne!(spun, provisioned);
}
diff --git a/tests/configs/wslc_state_aware_deprovision_session.json b/tests/configs/wslc_state_aware_deprovision_session.json
new file mode 100644
index 000000000..9a00b62fa
--- /dev/null
+++ b/tests/configs/wslc_state_aware_deprovision_session.json
@@ -0,0 +1,7 @@
+{
+ "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json",
+ "version": "1.1.0-alpha",
+ "phase": "deprovision-session",
+ "containment": "wslc",
+ "sessionId": "{{SESSION_ID}}"
+}
diff --git a/tests/configs/wslc_state_aware_provision_in_session.json b/tests/configs/wslc_state_aware_provision_in_session.json
new file mode 100644
index 000000000..9959fc568
--- /dev/null
+++ b/tests/configs/wslc_state_aware_provision_in_session.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": "deny" }
+ },
+ "wslc": {
+ "provision": {
+ "image": "alpine:latest",
+ "sessionId": "{{SESSION_ID}}"
+ }
+ }
+}
diff --git a/tests/configs/wslc_state_aware_provision_session.json b/tests/configs/wslc_state_aware_provision_session.json
new file mode 100644
index 000000000..679db0350
--- /dev/null
+++ b/tests/configs/wslc_state_aware_provision_session.json
@@ -0,0 +1,12 @@
+{
+ "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json",
+ "version": "1.1.0-alpha",
+ "phase": "provision-session",
+ "containment": "wslc",
+ "wslc": {
+ "provisionSession": {
+ "cpuCount": 1,
+ "memoryMb": 3072
+ }
+ }
+}
diff --git a/tests/scripts/README.md b/tests/scripts/README.md
index d0c81e400..ff9b19810 100644
--- a/tests/scripts/README.md
+++ b/tests/scripts/README.md
@@ -45,8 +45,8 @@ Linux / macOS (`.sh`):
| `run_windows_sandbox_state_aware_tests.ps1` | Windows Sandbox state-aware lifecycle E2E (single VM held across provision/start/exec*/stop/deprovision) | Windows Sandbox enabled |
| `run_isolation_session_tests.ps1` | IsolationSession one-shot E2E suite | Interactive local session; OS-side IsolationSession service |
| `run_isolation_session_state_aware_tests.ps1` | IsolationSession provision/start/exec/stop/deprovision E2E suite | Interactive local session; OS-side IsolationSession service |
-| `run_wslc_all_tests.ps1` | All WSLC create-and-run and container-lifecycle E2E tests; delegates the lifecycle half to `run_wslc_state_aware_tests.ps1` | WSL2, WSLC SDK, staged daemon, and registry access for the image preflight, which warms both the create-and-run store and daemon store slot 0 (`-SkipSetup` skips it and needs both already warm, because the lifecycle fixtures deny egress) |
-| `run_wslc_state_aware_tests.ps1` | WSLC provision/start/exec/stop/deprovision lifecycle E2E suite, plus warm reuse, exec concurrency and idle teardown | WSL2, WSLC SDK, `wxc-wslc-daemon.exe` staged beside `wxc-exec.exe`, and `alpine:latest` / `python:3.12-alpine` cached in daemon store slot 0 (warm it with `scripts\setup-wslc.ps1 -DaemonSlots 1`) |
+| `run_wslc_all_tests.ps1` | All WSLC create-and-run and container-lifecycle E2E tests; delegates the lifecycle half to `run_wslc_state_aware_tests.ps1` | WSL2, WSLC SDK, staged daemon, and registry access for the image preflight, which warms the create-and-run store and daemon store slots 0 through 2 (`-SkipSetup` needs all stores already warm, because the lifecycle fixtures deny egress) |
+| `run_wslc_state_aware_tests.ps1` | WSLC container and session lifecycle E2E suite, including shared sandboxes, identical-session independence, guest CPU/memory sizing, cold-store refusal, warm reuse, exec concurrency and idle teardown; GPU enforcement is not verified | WSL2, WSLC SDK, `wxc-wslc-daemon.exe` staged beside `wxc-exec.exe`, and `alpine:latest` / `python:3.12-alpine` cached in daemon store slots 0 through 2 (warm them with `scripts\setup-wslc.ps1 -DaemonSlots 3`); the fresh cold-store regression requires registry access even with `-SkipSetup` |
| `run_processcontainer_all_tests.ps1` | Process container (AppContainer / BaseContainer) primitives suite — tier probes, rw/ro/denied matrix, enumeration-only grants, UI mitigations, DACL restore, crash recovery, directional networking. Dispatches to the per-area `run_processcontainer_*_test.ps1` scripts | `wxc-exec.exe`, `wxc-ui-probe.exe`, `plm.exe` and `winhttp-proxy-shim.exe` beside `wxc-exec.exe` |
| `T3-Workloads.ps1` | Real workloads (pwsh, git, node, python, cmd) on top of the T3 primitives. A missing interpreter is reported as a skip, not a failure | `wxc-exec.exe`; `pwsh` / `git` / `node` / `python` each optional, gating their own cases |
| `run_telemetry_consent_smoke_test.ps1` | Consent maintenance, presentation, policy, and exit-code smoke tests | Debug `wxc-exec.exe` built with `test-support` |
diff --git a/tests/scripts/run_wslc_all_tests.ps1 b/tests/scripts/run_wslc_all_tests.ps1
index 389a8f927..4c3fa8dbc 100644
--- a/tests/scripts/run_wslc_all_tests.ps1
+++ b/tests/scripts/run_wslc_all_tests.ps1
@@ -17,10 +17,8 @@
# .\run_wslc_all_tests.ps1 -SkipSetup # skip pre-pull (assume cache is warm)
#
# Image pre-pull:
-# This script invokes scripts\setup-wslc.ps1 as a preflight to populate the
-# WSLC image cache. The runner pulls on a cache miss by itself, so this only
-# keeps per-test timings clear of one-off download cost. Pass -SkipSetup to
-# bypass and let the first test that needs an image pull it.
+# Warm the create-and-run store and three daemon stores. The lifecycle
+# fixtures deny egress, so -SkipSetup requires all of these stores to be warm.
#
# Prerequisites for tar import tests:
#
@@ -75,11 +73,8 @@ if (-not $WxcExec -or -not (Test-Path $WxcExec)) {
exit 1
}
-# Preflight: warm the WSLC image cache so per-test timings stay clear of one-off
-# download cost. The runner pulls on a miss by itself, so -SkipSetup is a
-# supported way to exercise that path instead. The lifecycle fixtures provision
-# with the isolated posture, which refuses a registry pull, so the daemon's slot
-# has to be warmed here too or the delegated suite below starts cold.
+# Preflight: warm the create-and-run store and stores for the implicit session
+# and two explicit sessions.
if (-not $SkipSetup) {
$SetupScript = Join-Path $RepoRoot "scripts\setup-wslc.ps1"
if (Test-Path $SetupScript) {
@@ -94,14 +89,11 @@ if (-not $SkipSetup) {
"ghcr.io/linuxserver/baseimage-alpine:3.21",
"quay.io/fedora/fedora-minimal:latest"
)
- & $SetupScript -WxcExecPath $WxcExec -Image $images -DaemonSlots 1 -Force
+ & $SetupScript -WxcExecPath $WxcExec -Image $images -DaemonSlots 3 -Force
if ($LASTEXITCODE -ne 0) {
- # The create-and-run half pulls on a miss, but the delegated
- # lifecycle suite provisions with the isolated posture and cannot,
- # so a cold slot 0 surfaces much later as unrelated failures.
- Write-Host "ERROR: setup-wslc.ps1 failed; daemon store slot 0 may be cold." -ForegroundColor Red
+ Write-Host "ERROR: setup-wslc.ps1 failed; daemon store slots 0 through 2 may be cold." -ForegroundColor Red
Write-Host " The delegated lifecycle suite refuses registry pulls, so it cannot recover." -ForegroundColor Yellow
- Write-Host " Resolve the setup failure above, or pass -SkipSetup if both stores are warm." -ForegroundColor Yellow
+ Write-Host " Resolve the setup failure above, or pass -SkipSetup if all stores are warm." -ForegroundColor Yellow
exit 1
}
Write-Host ""
@@ -477,11 +469,6 @@ $null = $results.Add((Run-WslcTest "wslc_destroy_on_exit_false_rejected.json" `
-OutputContains "destroyOnExit=false"))
Write-Host "`n--- State-Aware Lifecycle Tests ---" -ForegroundColor Cyan
-# Delegate the multi-invocation provision/start/exec/stop/deprovision lifecycle
-# to its owning harness (it needs the daemon binary, mints + threads sandbox ids,
-# and drives the idle-teardown watchdog). The preflight above warmed the daemon
-# store slot as well, so skip its redundant preflight. Fold its exit code into
-# the summary.
$saScript = Join-Path $PSScriptRoot "run_wslc_state_aware_tests.ps1"
$saArgs = @{ WxcExecPath = $WxcExec; SkipSetup = $true }
if ($Debug) { $saArgs.Debug = $true }
diff --git a/tests/scripts/run_wslc_state_aware_tests.ps1 b/tests/scripts/run_wslc_state_aware_tests.ps1
index 587fb32fe..be907f6cd 100644
--- a/tests/scripts/run_wslc_state_aware_tests.ps1
+++ b/tests/scripts/run_wslc_state_aware_tests.ps1
@@ -11,7 +11,8 @@
.DESCRIPTION
Each test invokes wxc-exec.exe with lifecycle routing in --operation /
- --container-id and a base64-encoded phase-specific request payload.
+ --container-id / --session-id and a base64-encoded phase-specific request
+ payload.
Provision / start / stop / deprovision return a JSON envelope on stdout
(asserted on `result` / `error`); a successful exec streams the script's
own stdout (relayed from the daemon) and exits with the script's exit code.
@@ -22,6 +23,11 @@
and (2) after the last sandbox is deprovisioned the daemon idles out and
exits on its own.
+ Explicit-session tests cover shared sandboxes and independent lifetimes
+ for identically sized VMs, plus guest CPU and memory for different sizing.
+ GPU passthrough enforcement is not verified by this suite.
+ The cold-store regression warms one private store and requires registry access.
+
Requires: Windows 11, WSL2, the WSLC SDK runtime (wslcsdk.dll staged next
to the binaries), registry access or an already-cached alpine:latest, and a
wxc-exec.exe + wxc-wslc-daemon.exe
@@ -50,7 +56,8 @@
.PARAMETER SkipSetup
Skip the image-cache warming preflight. The provision fixtures declare deny
- egress, which refuses a registry pull, so the cache has to be warm already.
+ egress, so daemon store slots 0 through 2 must already contain the images.
+ The cold-store regression still warms its own isolated store.
.EXAMPLE
.\run_wslc_state_aware_tests.ps1
@@ -124,23 +131,18 @@ if (-not $ConfigDir) {
# ---------------- Image pre-pull preflight ----------------
-# The daemon leases its store from the slot pool, not from
-# %TEMP%\mxc-wslc-sessions, and the fixtures below provision with the isolated
-# posture, which refuses a registry pull. Slot 0 is the one a daemon holding a
-# single session leases, so that is the store these images have to be in.
+# Warm stores for the implicit session and two explicit sessions; isolated
+# provisions cannot pull images into a cold store.
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", "python:3.12-alpine") -DaemonSlots 1 -Force
+ & $SetupScript -WxcExecPath $WxcExec -Image @("alpine:latest", "python:3.12-alpine") -DaemonSlots 3 -Force
if ($LASTEXITCODE -ne 0) {
- # Continuing would leave slot 0 cold, and the isolated fixtures
- # cannot pull, so every later provision fails for a reason that
- # looks nothing like this preflight.
- Write-Host "ERROR: setup-wslc.ps1 failed to warm daemon store slot 0." -ForegroundColor Red
+ Write-Host "ERROR: setup-wslc.ps1 failed to warm daemon store slots 0 through 2." -ForegroundColor Red
Write-Host " The lifecycle fixtures provision with the isolated posture, which refuses a" -ForegroundColor Yellow
Write-Host " registry pull, so a cold slot fails every provision rather than pulling." -ForegroundColor Yellow
- Write-Host " Resolve the setup failure above, or pass -SkipSetup if slot 0 is already warm." -ForegroundColor Yellow
+ Write-Host " Resolve the setup failure above, or pass -SkipSetup if all three slots are warm." -ForegroundColor Yellow
exit 1
}
Write-Host ""
@@ -156,6 +158,7 @@ function ConvertTo-StateAwareInvocation {
[hashtable]$Request,
[string]$ConfigFile,
[string]$SandboxId,
+ [string]$SessionId,
[int]$WindowsPort
)
@@ -173,6 +176,16 @@ function ConvertTo-StateAwareInvocation {
$json = $json -replace 'wslc:\{\{SANDBOX_ID\}\}', $SandboxId
$json = $json -replace '\{\{SANDBOX_ID\}\}', $SandboxId
}
+
+ if ($json -match '\{\{SESSION_ID\}\}') {
+ if (-not $SessionId) {
+ throw "Fixture $ConfigFile contains {{SESSION_ID}} but -SessionId was not supplied"
+ }
+ if (-not $SessionId.StartsWith('wslcsess:')) {
+ throw "Fixture $ConfigFile requires a wslcsess: session ID, got '$SessionId'"
+ }
+ $json = $json -replace '\{\{SESSION_ID\}\}', $SessionId
+ }
try {
$requestObject = $json | ConvertFrom-Json
} catch {
@@ -213,29 +226,41 @@ function ConvertTo-StateAwareInvocation {
}
if ($phase -isnot [string]) { throw "State-aware request must contain a string 'phase'" }
- $routingSandboxId = $null
- if ($phase -ne 'provision') {
+ $targetMember = switch ($phase) {
+ 'provision-session' { $null }
+ 'provision' { $null }
+ 'deprovision-session' { 'sessionId' }
+ default { 'sandboxId' }
+ }
+ $targetFlag = switch ($targetMember) {
+ 'sessionId' { '--session-id' }
+ 'sandboxId' { '--container-id' }
+ default { $null }
+ }
+
+ $routingId = $null
+ if ($targetMember) {
if ($requestObject -is [System.Collections.IDictionary]) {
- $sandboxIdKey = $requestObject.Keys |
- Where-Object { $_ -ceq 'sandboxId' } |
+ $targetKey = $requestObject.Keys |
+ Where-Object { $_ -ceq $targetMember } |
Select-Object -First 1
- if ($null -eq $sandboxIdKey) {
- throw "State-aware '$phase' request must contain a string 'sandboxId'"
+ if ($null -eq $targetKey) {
+ throw "State-aware '$phase' request must contain a string '$targetMember'"
}
- $routingSandboxId = $requestObject[$sandboxIdKey]
- $null = $requestObject.Remove($sandboxIdKey)
+ $routingId = $requestObject[$targetKey]
+ $null = $requestObject.Remove($targetKey)
} else {
- $sandboxIdProperty = $requestObject.PSObject.Properties |
- Where-Object { $_.Name -ceq 'sandboxId' } |
+ $targetProperty = $requestObject.PSObject.Properties |
+ Where-Object { $_.Name -ceq $targetMember } |
Select-Object -First 1
- if ($null -eq $sandboxIdProperty) {
- throw "State-aware '$phase' request must contain a string 'sandboxId'"
+ if ($null -eq $targetProperty) {
+ throw "State-aware '$phase' request must contain a string '$targetMember'"
}
- $routingSandboxId = $sandboxIdProperty.Value
- $requestObject.PSObject.Properties.Remove($sandboxIdProperty.Name)
+ $routingId = $targetProperty.Value
+ $requestObject.PSObject.Properties.Remove($targetProperty.Name)
}
- if ($routingSandboxId -isnot [string]) {
- throw "State-aware '$phase' request must contain a string 'sandboxId'"
+ if ($routingId -isnot [string]) {
+ throw "State-aware '$phase' request must contain a string '$targetMember'"
}
}
@@ -243,7 +268,9 @@ function ConvertTo-StateAwareInvocation {
$b64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($json))
@{
Operation = [string]$phase
- SandboxId = $routingSandboxId
+ TargetFlag = $targetFlag
+ TargetId = $routingId
+ SandboxId = if ($targetMember -eq 'sandboxId') { $routingId } else { $null }
ConfigBase64 = $b64
}
}
@@ -265,16 +292,18 @@ function Start-StateAware {
[hashtable]$Request,
[string]$ConfigFile,
[string]$SandboxId,
+ [string]$SessionId,
[int]$WindowsPort,
[switch]$DryRun
)
$invocation = ConvertTo-StateAwareInvocation `
- -Request $Request -ConfigFile $ConfigFile -SandboxId $SandboxId -WindowsPort $WindowsPort
+ -Request $Request -ConfigFile $ConfigFile -SandboxId $SandboxId `
+ -SessionId $SessionId -WindowsPort $WindowsPort
$argList = @('--operation', $invocation.Operation)
- if ($invocation.Operation -ne 'provision') {
- $argList += @('--container-id', $invocation.SandboxId)
+ if ($invocation.TargetFlag) {
+ $argList += @($invocation.TargetFlag, $invocation.TargetId)
}
if ($DryRun) { $argList += '--dry-run' }
if ($Debug) { $argList += '--debug' }
@@ -331,15 +360,17 @@ function Start-StateAwareLines {
[hashtable]$Request,
[string]$ConfigFile,
[string]$SandboxId,
+ [string]$SessionId,
[int]$WindowsPort
)
$invocation = ConvertTo-StateAwareInvocation `
- -Request $Request -ConfigFile $ConfigFile -SandboxId $SandboxId -WindowsPort $WindowsPort
+ -Request $Request -ConfigFile $ConfigFile -SandboxId $SandboxId `
+ -SessionId $SessionId -WindowsPort $WindowsPort
$argList = @('--operation', $invocation.Operation)
- if ($invocation.Operation -ne 'provision') {
- $argList += @('--container-id', $invocation.SandboxId)
+ if ($invocation.TargetFlag) {
+ $argList += @($invocation.TargetFlag, $invocation.TargetId)
}
if ($Debug) { $argList += '--debug' }
$argList += @('--config-base64', $invocation.ConfigBase64)
@@ -433,13 +464,14 @@ function Invoke-StateAware {
[hashtable]$Request,
[string]$ConfigFile,
[string]$SandboxId,
+ [string]$SessionId,
[int]$WindowsPort,
[switch]$DryRun
)
Wait-StateAware (Start-StateAware `
-Request $Request -ConfigFile $ConfigFile -SandboxId $SandboxId `
- -WindowsPort $WindowsPort -DryRun:$DryRun)
+ -SessionId $SessionId -WindowsPort $WindowsPort -DryRun:$DryRun)
}
# Like Invoke-StateAware but records the wall-clock arrival time of each stdout
@@ -457,8 +489,8 @@ function Invoke-StateAwareStreaming {
-Request $Request -ConfigFile $ConfigFile -SandboxId $SandboxId
$argList = @('--operation', $invocation.Operation)
- if ($invocation.Operation -ne 'provision') {
- $argList += @('--container-id', $invocation.SandboxId)
+ if ($invocation.TargetFlag) {
+ $argList += @($invocation.TargetFlag, $invocation.TargetId)
}
if ($Debug) { $argList += '--debug' }
$argList += @('--config-base64', $invocation.ConfigBase64)
@@ -664,6 +696,72 @@ function Assert-ResultEnvelope {
return $envObj
}
+function Assert-ErrorEnvelope {
+ param($Result, [string]$ExpectedCode, [string]$What)
+ $envObj = Parse-Envelope -Stdout $Result.Stdout
+ $isExpected = (Envelope-Arm $envObj) -eq 'error' -and
+ $envObj.error.code -eq $ExpectedCode -and $Result.ExitCode -ne 0
+ if (-not $isExpected) {
+ Write-Host " Stdout: $($Result.Stdout)" -ForegroundColor Gray
+ Write-Host " Stderr: $($Result.Stderr)" -ForegroundColor Gray
+ }
+ Assert-True $isExpected "$What returns $ExpectedCode and exits non-zero"
+ return $envObj
+}
+
+function Get-SandboxBootId {
+ param([string]$SandboxId, [string]$What)
+
+ # The Linux boot ID identifies the VM, not an individual container.
+ $r = Invoke-StateAware -Request @{
+ version = '1.1.0-alpha'
+ phase = 'exec'
+ sandboxId = $SandboxId
+ process = @{ commandLine = 'cat /proc/sys/kernel/random/boot_id'; timeout = 30000 }
+ }
+ $bootId = $r.Stdout.Trim()
+ $valid = $r.ExitCode -eq 0 -and
+ $bootId -cmatch '^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$'
+ if (-not $valid) {
+ Write-Host " Stdout: $($r.Stdout)" -ForegroundColor Gray
+ Write-Host " Stderr: $($r.Stderr)" -ForegroundColor Gray
+ }
+ Assert-True $valid "$What reads a valid VM boot ID"
+ if ($valid) { return $bootId }
+ return $null
+}
+
+function Assert-SandboxSizing {
+ param([string]$SandboxId, [int]$CpuCount, [int]$MemoryMb, [string]$What)
+
+ $r = Invoke-StateAware -Request @{
+ version = '1.1.0-alpha'
+ phase = 'exec'
+ sandboxId = $SandboxId
+ process = @{
+ commandLine = 'nproc; awk ''/^MemTotal:/ { print $2 }'' /proc/meminfo'
+ timeout = 30000
+ }
+ }
+ $values = $r.Stdout.Trim() -split '\s+'
+ $valid = $r.ExitCode -eq 0 -and $values.Count -eq 2 -and
+ $values[0] -match '^\d+$' -and $values[1] -match '^\d+$'
+ Assert-True $valid "$What reports guest CPU count and memory"
+ if (-not $valid) {
+ Write-Host " Stdout: $($r.Stdout)" -ForegroundColor Gray
+ Write-Host " Stderr: $($r.Stderr)" -ForegroundColor Gray
+ return
+ }
+ Assert-True ([int]$values[0] -eq $CpuCount) `
+ "$What has $CpuCount CPUs (reported $($values[0]))"
+ $expectedKiB = [long]$MemoryMb * 1024
+
+ # Allow up to 10% for guest-kernel reservations excluded from MemTotal.
+ $minimumKiB = [math]::Ceiling($expectedKiB * 0.9)
+ Assert-True ([long]$values[1] -ge $minimumKiB -and [long]$values[1] -le $expectedKiB) `
+ "$What guest memory is within 10% below $MemoryMb MB (reported $($values[1]) KiB)"
+}
+
# Provision a throwaway sandbox and return its sandbox_id (or $null).
function Provision-Sandbox {
param([string]$ConfigFile = 'wslc_state_aware_provision.json')
@@ -1870,6 +1968,381 @@ try {
}
}
+# ---------------- Lifecycle K: explicit sessions ----------------
+
+$sharedSandboxes = @(
+ @{ Name = 'A1'; Id = $null; Started = $false; Released = $false; BootId = $null },
+ @{ Name = 'A2'; Id = $null; Started = $false; Released = $false; BootId = $null }
+)
+$independentSandbox = @{
+ Name = 'B'; Id = $null; Started = $false; Released = $false; BootId = $null
+}
+$resizedSandbox = @{
+ Name = 'C'; Id = $null; Started = $false; Released = $false
+}
+$resizedSession = @{
+ Name = 'C'; Id = $null; Released = $false; Sandboxes = @($resizedSandbox)
+}
+$explicitSessions = @(
+ @{ Name = 'A'; Id = $null; Released = $false; Sandboxes = $sharedSandboxes },
+ @{ Name = 'B'; Id = $null; Released = $false; Sandboxes = @($independentSandbox) },
+ $resizedSession
+)
+$sharedSession = $explicitSessions[0]
+$independentSession = $explicitSessions[1]
+$sessionFixture = Get-Content (Join-Path $ConfigDir 'wslc_state_aware_provision_session.json') -Raw |
+ ConvertFrom-Json
+$sessionSizing = $sessionFixture.wslc.provisionSession
+try {
+ Run-StateAwareTest "K: dry-run provision-session mints nothing" {
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_session.json' -DryRun
+ $envObj = Assert-ResultEnvelope $r "dry-run provision-session"
+ if ($envObj) {
+ Assert-True ($null -eq $envObj.result.sessionId) `
+ "a dry run must not mint a session id"
+ }
+ } | Out-Null
+
+ foreach ($session in @($sharedSession, $independentSession)) {
+ $provisioned = Run-StateAwareTest "K: provision-session $($session.Name) with identical sizing" {
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_session.json'
+ $envObj = Assert-ResultEnvelope $r "provision-session $($session.Name)"
+ if ($envObj) {
+ $session.Id = [string]$envObj.result.sessionId
+ Assert-True ($session.Id -cmatch '^wslcsess:[0-9a-f]{32}$') `
+ "session_id matches wslcsess:<32 lowercase hex>"
+ Assert-True ($null -eq $envObj.result.sandboxId) `
+ "provision-session returns no sandbox id"
+ }
+ }
+ if (-not $provisioned) { continue }
+
+ foreach ($sandbox in $session.Sandboxes) {
+ Run-StateAwareTest "K: provision and start sandbox $($sandbox.Name) in session $($session.Name)" {
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_in_session.json' `
+ -SessionId $session.Id
+ $envObj = Assert-ResultEnvelope $r "provision sandbox $($sandbox.Name)"
+ if ($null -eq $envObj) { return }
+ $sandbox.Id = [string]$envObj.result.sandboxId
+ Assert-True ($sandbox.Id -cmatch '^wslc:[0-9a-f]{32}$') `
+ "sandbox_id matches wslc:<32 lowercase hex>"
+
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_start.json' `
+ -SandboxId $sandbox.Id
+ $envObj = Assert-ResultEnvelope $r "start sandbox $($sandbox.Name)"
+ $sandbox.Started = $null -ne $envObj -and $r.ExitCode -eq 0
+ if ($sandbox.Started) {
+ $sandbox.BootId = Get-SandboxBootId -SandboxId $sandbox.Id -What $sandbox.Name
+ }
+ } | Out-Null
+ }
+ }
+
+ Run-StateAwareTest "K: two sandboxes share the named session's VM" {
+ Assert-True ($sharedSandboxes[0].Started -and $sharedSandboxes[1].Started) `
+ "both sandboxes are running at the same time"
+ Assert-True (-not [string]::IsNullOrEmpty($sharedSandboxes[0].BootId) -and
+ $sharedSandboxes[0].BootId -ceq $sharedSandboxes[1].BootId) `
+ "two sandboxes in the same session have the same VM boot ID"
+ foreach ($sandbox in $sharedSandboxes) {
+ if ($sandbox.Started) {
+ Assert-SandboxSizing -SandboxId $sandbox.Id -What $sandbox.Name `
+ -CpuCount $sessionSizing.cpuCount -MemoryMb $sessionSizing.memoryMb
+ }
+ }
+ } | Out-Null
+
+ Run-StateAwareTest "K: identical sizing creates two distinct sessions and VMs" {
+ Assert-True (-not [string]::IsNullOrEmpty($sharedSession.Id) -and
+ -not [string]::IsNullOrEmpty($independentSession.Id) -and
+ $sharedSession.Id -cne $independentSession.Id) `
+ "two sessions with identical settings have different IDs"
+ Assert-True ($sharedSandboxes[0].Started -and $independentSandbox.Started) `
+ "sandboxes in both sessions are live simultaneously"
+ Assert-True (-not [string]::IsNullOrEmpty($sharedSandboxes[0].BootId) -and
+ -not [string]::IsNullOrEmpty($independentSandbox.BootId) -and
+ $sharedSandboxes[0].BootId -cne $independentSandbox.BootId) `
+ "identically sized sessions have different VM boot IDs"
+ } | Out-Null
+
+ foreach ($sandbox in $sharedSandboxes) {
+ Run-StateAwareTest "K: session release is refused while sandbox $($sandbox.Name) remains" {
+ Assert-True $sandbox.Started "sandbox $($sandbox.Name) was started"
+ if (-not $sandbox.Started) { return }
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision_session.json' `
+ -SessionId $sharedSession.Id
+ $null = Assert-ErrorEnvelope $r 'policy_validation' "release of a held session"
+ $bootId = Get-SandboxBootId -SandboxId $sandbox.Id -What $sandbox.Name
+ Assert-True (-not [string]::IsNullOrEmpty($bootId) -and
+ $bootId -ceq $sandbox.BootId) "the refused release leaves the sandbox usable"
+ } | Out-Null
+
+ Run-StateAwareTest "K: stop and deprovision shared sandbox $($sandbox.Name)" {
+ Assert-True (-not [string]::IsNullOrEmpty($sandbox.Id)) `
+ "sandbox $($sandbox.Name) was provisioned"
+ if (-not $sandbox.Id) { return }
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_stop.json' -SandboxId $sandbox.Id
+ $null = Assert-ResultEnvelope $r "stop sandbox $($sandbox.Name)"
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision.json' `
+ -SandboxId $sandbox.Id
+ $envObj = Assert-ResultEnvelope $r "deprovision sandbox $($sandbox.Name)"
+ $sandbox.Released = $null -ne $envObj -and $r.ExitCode -eq 0
+ } | Out-Null
+ }
+
+ Run-StateAwareTest "K: deprovision-session once both shared sandboxes are gone" {
+ Assert-True ($sharedSandboxes[0].Released -and $sharedSandboxes[1].Released) `
+ "both sandboxes were deprovisioned before their session"
+ if (-not $sharedSession.Id) { return }
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision_session.json' `
+ -SessionId $sharedSession.Id
+ $envObj = Assert-ResultEnvelope $r "release session A"
+ $sharedSession.Released = $null -ne $envObj -and $r.ExitCode -eq 0
+ } | Out-Null
+
+ Run-StateAwareTest "K: the identically sized session survives the other's release" {
+ Assert-True ($sharedSession.Released -and $independentSandbox.Started) `
+ "session A is gone while session B still has a running sandbox"
+ if (-not $sharedSession.Released -or -not $independentSandbox.Started) { return }
+ $bootId = Get-SandboxBootId -SandboxId $independentSandbox.Id -What 'surviving session B'
+ Assert-True (-not [string]::IsNullOrEmpty($bootId) -and
+ $bootId -ceq $independentSandbox.BootId) `
+ "releasing session A leaves session B's original VM usable"
+ } | Out-Null
+
+ Run-StateAwareTest "K: different CPU and memory sizing reaches each session's guest" {
+ Assert-True ($sharedSession.Released -and $independentSandbox.Started) `
+ "session A returned its warm store while session B remains live"
+ if (-not $sharedSession.Released -or -not $independentSandbox.Started) { return }
+ Assert-SandboxSizing -SandboxId $independentSandbox.Id -What 'session B' `
+ -CpuCount $sessionSizing.cpuCount -MemoryMb $sessionSizing.memoryMb
+
+ $r = Invoke-StateAware -Request @{
+ version = '1.1.0-alpha'
+ phase = 'provision-session'
+ containment = 'wslc'
+ wslc = @{ provisionSession = @{ cpuCount = 2; memoryMb = 4096 } }
+ }
+ $envObj = Assert-ResultEnvelope $r "provision differently sized session C"
+ if ($null -eq $envObj) { return }
+ $resizedSession.Id = [string]$envObj.result.sessionId
+
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_in_session.json' `
+ -SessionId $resizedSession.Id
+ $envObj = Assert-ResultEnvelope $r "provision sandbox C"
+ if ($null -eq $envObj) { return }
+ $resizedSandbox.Id = [string]$envObj.result.sandboxId
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_start.json' `
+ -SandboxId $resizedSandbox.Id
+ $envObj = Assert-ResultEnvelope $r "start sandbox C"
+ $resizedSandbox.Started = $null -ne $envObj -and $r.ExitCode -eq 0
+ if (-not $resizedSandbox.Started) { return }
+ Assert-SandboxSizing -SandboxId $resizedSandbox.Id -What 'session C' `
+ -CpuCount 2 -MemoryMb 4096
+
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_stop.json' `
+ -SandboxId $resizedSandbox.Id
+ $null = Assert-ResultEnvelope $r "stop sandbox C"
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision.json' `
+ -SandboxId $resizedSandbox.Id
+ $envObj = Assert-ResultEnvelope $r "deprovision sandbox C"
+ $resizedSandbox.Released = $null -ne $envObj -and $r.ExitCode -eq 0
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision_session.json' `
+ -SessionId $resizedSession.Id
+ $envObj = Assert-ResultEnvelope $r "release session C"
+ $resizedSession.Released = $null -ne $envObj -and $r.ExitCode -eq 0
+ } | Out-Null
+
+ Run-StateAwareTest "K: a released session is no longer addressable" {
+ Assert-True $sharedSession.Released "session A was released"
+ if (-not $sharedSession.Released) { return }
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision_session.json' `
+ -SessionId $sharedSession.Id
+ $null = Assert-ErrorEnvelope $r 'not_provisioned' "release of an already released session"
+ } | Out-Null
+
+ Run-StateAwareTest "K: stop and deprovision the independent session's sandbox" {
+ Assert-True (-not [string]::IsNullOrEmpty($independentSandbox.Id)) `
+ "session B's sandbox was provisioned"
+ if (-not $independentSandbox.Id) { return }
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_stop.json' `
+ -SandboxId $independentSandbox.Id
+ $null = Assert-ResultEnvelope $r "stop session B's sandbox"
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision.json' `
+ -SandboxId $independentSandbox.Id
+ $envObj = Assert-ResultEnvelope $r "deprovision session B's sandbox"
+ $independentSandbox.Released = $null -ne $envObj -and $r.ExitCode -eq 0
+ } | Out-Null
+
+ Run-StateAwareTest "K: deprovision the independent session" {
+ Assert-True $independentSandbox.Released "session B's sandbox was deprovisioned"
+ if (-not $independentSession.Id) { return }
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision_session.json' `
+ -SessionId $independentSession.Id
+ $envObj = Assert-ResultEnvelope $r "release session B"
+ $independentSession.Released = $null -ne $envObj -and $r.ExitCode -eq 0
+ } | Out-Null
+
+ Run-StateAwareTest "K: provision naming an unknown session is refused" {
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_in_session.json' `
+ -SessionId 'wslcsess:00000000000000000000000000000000'
+ $envObj = Assert-ErrorEnvelope $r 'not_provisioned' "provision in an unknown session"
+ Assert-True ($null -eq $envObj.result.sandboxId) `
+ "a refused provision mints no sandbox"
+ } | Out-Null
+
+ Run-StateAwareTest "K: malformed placement and release IDs return malformed_id" {
+ foreach ($dryRun in @($false, $true)) {
+ foreach ($phase in @('provision', 'deprovision-session')) {
+ $req = @{
+ version = '1.1.0-alpha'
+ phase = $phase
+ containment = 'wslc'
+ }
+ if ($phase -eq 'provision') {
+ $req.wslc = @{ provision = @{ sessionId = 'wslcsess:not-hex' } }
+ } else {
+ $req.sessionId = 'wslcsess:not-hex'
+ }
+ $r = Invoke-StateAware -Request $req -DryRun:$dryRun
+ $null = Assert-ErrorEnvelope $r 'malformed_id' "$phase with a malformed session id"
+ }
+ }
+ } | Out-Null
+} finally {
+ foreach ($session in $explicitSessions) {
+ foreach ($sandbox in $session.Sandboxes) {
+ if (-not $sandbox.Id -or $sandbox.Released) { continue }
+ Run-StateAwareTest "K: cleanup sandbox $($sandbox.Name)" {
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision.json' `
+ -SandboxId $sandbox.Id
+ $envObj = Parse-Envelope -Stdout $r.Stdout
+ if ((Envelope-Arm $envObj) -eq 'error' -and
+ $envObj.error.code -eq 'not_provisioned') {
+ $sandbox.Released = $true
+ } else {
+ $envObj = Assert-ResultEnvelope $r "cleanup sandbox $($sandbox.Name)"
+ $sandbox.Released = $null -ne $envObj -and $r.ExitCode -eq 0
+ }
+ } | Out-Null
+ }
+ }
+ foreach ($session in $explicitSessions) {
+ if (-not $session.Id -or $session.Released) { continue }
+ Run-StateAwareTest "K: cleanup session $($session.Name)" {
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision_session.json' `
+ -SessionId $session.Id
+ $envObj = Parse-Envelope -Stdout $r.Stdout
+ if ((Envelope-Arm $envObj) -eq 'error' -and
+ $envObj.error.code -eq 'not_provisioned') {
+ $session.Released = $true
+ } else {
+ $envObj = Assert-ResultEnvelope $r "cleanup session $($session.Name)"
+ $session.Released = $null -ne $envObj -and $r.ExitCode -eq 0
+ }
+ } | Out-Null
+ }
+}
+
+# ---------------- Lifecycle L: one warmed slot ----------------
+
+Run-StateAwareTest "L: isolated provision names the cold store when the only warm slot is held" {
+ $root = Join-Path ([IO.Path]::GetTempPath()) ("mxc-wslc-cold-" + [guid]::NewGuid().ToString('N'))
+ $root = [IO.Path]::GetFullPath($root)
+ $oldLocalAppData = $env:LOCALAPPDATA
+ $oldStateRoot = $env:MXC_WSLC_STATE_ROOT
+ $sessionIds = [System.Collections.Generic.List[string]]::new()
+ $warmSandboxId = $null
+ $coldSandboxId = $null
+ $daemonProcess = $null
+ $null = New-Item -ItemType Directory -Path $root
+ try {
+
+ # Keep this test's stores and daemon discovery separate from normal runs.
+ $env:LOCALAPPDATA = $root
+ $env:MXC_WSLC_STATE_ROOT = Join-Path $root 'records'
+ & (Join-Path $RepoRoot 'scripts\setup-wslc.ps1') -WxcExecPath $WxcExec `
+ -StoragePath (Join-Path $root 'create-and-run') -Image @('alpine:latest') `
+ -DaemonSlots 1 -Force
+ $setupExit = $LASTEXITCODE
+ Assert-True ($setupExit -eq 0) "the private pool was warmed with -DaemonSlots 1"
+ if ($setupExit -ne 0) { return }
+
+ $coldStore = Join-Path $root 'mxc\wslc-store-slots\slot-1'
+ Assert-True (-not (Test-Path $coldStore)) "setup left the second slot cold"
+
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_session.json'
+ $record = Get-Content (Join-Path $env:MXC_WSLC_STATE_ROOT 'daemon.json') -Raw | ConvertFrom-Json
+ $daemonProcess = [System.Diagnostics.Process]::GetProcessById([int]$record.pid)
+ $envObj = Assert-ResultEnvelope $r "create the warm session"
+ if ($null -eq $envObj) { return }
+ $warmSessionId = [string]$envObj.result.sessionId
+ $sessionIds.Add($warmSessionId)
+
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_in_session.json' `
+ -SessionId $warmSessionId
+ $envObj = Assert-ResultEnvelope $r "hold the only warm slot with a sandbox"
+ if ($null -eq $envObj) { return }
+ $warmSandboxId = [string]$envObj.result.sandboxId
+
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_session.json'
+ $envObj = Assert-ResultEnvelope $r "create a session on the second slot"
+ if ($null -eq $envObj) { return }
+ $coldSessionId = [string]$envObj.result.sessionId
+ $sessionIds.Add($coldSessionId)
+
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_in_session.json' `
+ -SessionId $coldSessionId
+ $envObj = Assert-ErrorEnvelope $r 'policy_validation' "isolated provision on the cold store"
+ $coldSandboxId = [string]$envObj.result.sandboxId
+ $message = [string]$envObj.error.message
+ Assert-True ($message.Contains('daemon image store slot 1')) `
+ "the refusal names the selected slot"
+ Assert-True ($message.Contains($coldStore)) `
+ "the refusal names the actual private store path"
+ Assert-True ($message.Contains("cold for image 'alpine:latest'")) `
+ "the refusal identifies the cold cache rather than a missing registry image"
+ Assert-True ($message.Contains('No container was created')) `
+ "the cold-store refusal happens before container creation"
+ Assert-True ($null -eq $envObj.result.sandboxId) "the refused provision returns no sandbox"
+
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_start.json' -SandboxId $warmSandboxId
+ $null = Assert-ResultEnvelope $r "start the original warm sandbox after the refusal"
+ $null = Get-SandboxBootId -SandboxId $warmSandboxId -What 'the original warm sandbox'
+ } finally {
+ try {
+ foreach ($sandboxId in @($warmSandboxId, $coldSandboxId)) {
+ if (-not $sandboxId) { continue }
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision.json' `
+ -SandboxId $sandboxId
+ $null = Assert-ResultEnvelope $r "cleanup a private sandbox"
+ }
+ foreach ($sessionId in $sessionIds) {
+ $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision_session.json' `
+ -SessionId $sessionId
+ $envObj = Parse-Envelope -Stdout $r.Stdout
+ if ((Envelope-Arm $envObj) -ne 'error' -or
+ $envObj.error.code -ne 'not_provisioned') {
+ $null = Assert-ResultEnvelope $r "cleanup a private session"
+ }
+ }
+ } finally {
+ try {
+ if ($null -ne $daemonProcess -and -not $daemonProcess.WaitForExit(60000)) {
+ Assert-True $false "the private test daemon did not exit after cleanup"
+ Stop-Process -Id $daemonProcess.Id -Force
+ $daemonProcess.WaitForExit()
+ }
+ } finally {
+ if ($null -ne $daemonProcess) { $daemonProcess.Dispose() }
+ $env:LOCALAPPDATA = $oldLocalAppData
+ $env:MXC_WSLC_STATE_ROOT = $oldStateRoot
+ }
+ }
+ Remove-Item -LiteralPath $root -Recurse -Force
+ }
+} | Out-Null
+
# ---------------- Lifecycle S: store isolation from create-and-run ----------------
# A store's VHDX admits one live session, and the daemon used to default to the