MXC State-Aware Sandbox API
September 23, 2026 · View on GitHub
Detailed design proposal. Compiled 2026-04-28.
Contents
Part I — Motivation and principles
Part II — Consumer-facing surface
Part III — Backend-author surface
Part IV — Operational concerns
Part V — Bounds
1. Summary
This document proposes a state-aware sandbox API for MXC, surfaced alongside the existing
one-shot spawnSandbox* family. Five lifecycle phases are exposed at the SDK level:
provision, start, exec, stop, deprovision. Each is a discrete call. Provision returns an
opaque SandboxId string the caller persists and forwards to subsequent calls. The API
surface ships stable from 0.6.0 — state-awareness is not itself gated by an
--experimental flag. Per-stage configuration is typed per-backend per-phase
under each backend's permanent top-level section. Runtime authorization remains
independent until that backend's participation graduates (§13). Backends opt in by implementing a new
StatefulSandboxBackend Rust trait. The existing ScriptRunner trait is unchanged. A
backend's participation mode (state-aware, ephemeral, or both) is declared by which
trait or traits it implements.
The mental model: spawnSandbox is the composition of the five phases into one call.
State-aware exposes them individually so callers can hold a sandbox between calls, run
multiple workloads inside it, and tear it down explicitly.
Sandbox state is owned by the backend's underlying service. The SandboxId is the only
handle the caller gets; persisting it between calls is the caller's responsibility. MXC
retains no state between calls and does not become a sandbox orchestrator. Backends with
no meaningful state continue to expose only the one-shot surface; state-aware
participation is fully opt-in.
The proposal adds artefacts at five layers of MXC. Each row points into the section that elaborates.
| MXC layer | What's new | What's unchanged |
|---|---|---|
| TypeScript SDK (§6) | Five new functions: provisionSandbox, startSandbox, execInSandbox / execInSandboxAsync, stopSandbox, deprovisionSandbox. Branded SandboxId<C> type tagging ids by backend (containment named once at provision, inferred from the id thereafter). Per-(backend, phase) typed *Config interfaces (e.g. IsolationSessionProvisionConfig) that absorb cross-cutting fields directly — no separate policy parameter. Per-phase typed *Result types per backend. AbortSignal cancellation for promise-returning operations via the existing SandboxSpawnOptions; live exec callers use MxcSandboxProcess.kill(). Typed MxcError class carrying a closed-enum code. | spawnSandbox family preserved. ContainmentBackend extension mechanism reused. The existing wire-format-aligned ProcessConfig / FilesystemConfig / NetworkConfig / UiConfig interfaces from sdk/node/src/types.ts are reused as field types inside the new state-aware Configs. SandboxSpawnOptions reused as the third-arg options bag (gains signal?: AbortSignal). Existing typed *Config naming convention reused. |
| JSON wire format (§7) | Top-level phase discriminator. Top-level sandboxId. containment carried on provision only; non-provision phases route via the sandboxId prefix. Per-phase nesting under each backend's permanent top-level section. Named envelope types as a TypeScript discriminated union over phase. Exact registered roots admit only the cross-cutting fields supported by each backend and phase. | One-shot remains the no-phase request mode and uses its own exact versioned roots. |
| Rust executor (§9) | Exact registered request contracts selected by version, phase, and provision containment; typed neutral operations; checked backend binding; and StatefulSandboxBackend dispatch. | ScriptRunner trait. Existing one-shot dispatch path. Existing backends function without modification. |
| Error model (§8) | Closed enum of 12 error codes. MxcError class with code: ErrorCode. details open object as escape hatch for backend-specific structured information. Exact-root structural failures precede backend validation. | One-shot retains its existing response surface, while exact-contract failures use that surface's structural-error mapping. |
| Plug-in surface (§11) | Implement StatefulSandboxBackend (in addition to or instead of ScriptRunner). Define typed per-(backend, phase) *Config interfaces. Declare the backend's ID_PREFIX and BACKEND_KEY consts on the trait impl. Document the cross-cutting policy honor matrix. | Ephemeral-only backends require no changes. The ContainmentBackend Rust enum is extended, not replaced. |
2. Context and motivation
MXC's existing containment surface runs each invocation as a self-contained lifecycle: set up the sandbox, execute the workload, tear it down. This shape fits backends whose sandboxes carry no meaningful state between invocations.
It does not fit backends whose sandboxes are inherently persistent. A provisioned isolation session has a long-lived user profile holding installed tools, configuration, and credentials. A WSL distribution is a long-lived Linux environment with its own filesystem and package set. A Hyper-V virtual machine is a running OS instance. A Docker container can host a service that lives across many client interactions. For all of these, sandbox state is not a side-effect of the workload; it is part of what the workload depends on. A one-shot API forces these backends to fold the full provision/start/exec/stop/deprovision sequence into every call, discarding any state the workload accumulated.
This proposal introduces a state-aware lifecycle surface alongside the existing one-shot API, so backends with meaningful persistent state can expose that state to callers as a first-class concept. IsolationSession is the first such backend.
The design holds three constraints throughout:
- MXC does not take on responsibility for any persistent storage. The durable identifier of a stateful sandbox belongs to the backend's underlying service; persisting it across calls is the caller's responsibility.
- The contract supports easy plug-in by backend developers. Per-phase configuration is typed per-backend in a way that backends with different native lifecycle models can map cleanly.
- MXC's charter stays scoped to managing and executing within sandboxes, ephemeral or persistent. MXC is the conduit into sandbox APIs, not a state manager itself.
3. Design philosophy
Three principles shape decisions throughout the rest of the document.
Backends declare semantics. Concurrency, idempotence, security guarantees, sequencing rules, allowed phase transitions, cross-cutting policy honor, and error mapping are per-backend. MXC standardises the envelope that conveys backend responses (success result or typed error) but does not impose universal semantics on top. For example, a double-stop call returns whatever the backend reports, and MXC surfaces that response unchanged.
Layered validation. The SDK validates the envelope (recognised containment, required
fields, branded SandboxId<C> type, typed *Config shape). The MXC dispatch layer
re-validates the envelope and adds capability checks. The backend implementation validates
per-stage config field values and cross-cutting policy semantics. Each layer validates
what it cheaply can, so obvious errors surface without an unnecessary subprocess
round-trip.
Honest opt-in surface. State-aware participation is declared explicitly by the backend implementor. A backend that does not declare state-aware support continues to expose only the one-shot surface, and state-aware methods called against it return a typed error. The API surface reflects what each backend actually supports rather than papering over capability gaps with no-op stubs.
4. Lifecycle model
The state-aware API exposes five lifecycle phases. Each is a discrete call. Together they
compose into the full sandbox lifecycle that one-shot spawnSandbox runs end-to-end.
| Phase | Valid from state | Resulting state | Output | Purpose |
|---|---|---|---|---|
provision | (not provisioned) | provisioned | sandboxId, optional metadata | Allocate the sandbox resource |
start | provisioned | running | optional metadata | Bring the sandbox to a state where it can host workloads |
exec | running | running | stdout, stderr, exit code | Run a workload; may be called any number of times |
stop | running | provisioned | optional metadata | Take the sandbox out of running; the provisioned resource remains |
deprovision | provisioned | (not provisioned) | optional metadata | Release the provisioned resource; the SandboxId becomes invalid |
The five phases form a small state machine over three states: not-provisioned,
provisioned, and running. The SandboxId is valid from provision through deprovision;
once deprovision returns, the id is assumed to no longer route to any backend resource.
A backend whose underlying API has no meaningful equivalent for provision, start,
stop, or deprovision can omit the implementation entirely; the trait provides
default no-op bodies for those four (§9.2). The default provision mints a synthetic
sandbox_id of the form <ID_PREFIX>:<random-token> that subsequent calls echo back.
exec is always required — every state-aware backend must execute the workload to be
useful. The backend's documentation states which phases are no-ops. The MXC dispatch
layer treats substantive and no-op implementations identically; SDK signatures do not
differentiate them.
Each backend declares one of three participation modes:
- Ephemeral-only: implements only the existing one-shot
ScriptRunnertrait. State-aware calls against this backend returnerror.code: "unsupported_phase"(§8). - State-aware-only: implements only the new
StatefulSandboxBackendtrait. One-shot calls returnunsupported_phase. - Both: implements both traits. The relationship between the two code paths (whether the one-shot path internally invokes the stateful lifecycle or runs as a separate implementation) is the implementor's choice.
Stages beyond these five (snapshot, suspend, attach, restore, etc.) are deferred (§14). A backend with native support can expose them privately under its permanent backend section until they are universalised.
5. Identifiers
The SandboxId returned by provision is the only identifier the caller uses to refer to
the provisioned sandbox in later calls. It is an opaque string at every observable layer
(TS SDK, JSON wire format, CLI output).
The backend generates the SandboxId during provision. A backend whose underlying API
requires caller-supplied identifiers (e.g., one that uses registration and provisioning
IDs) mints them inside the backend implementation and encodes them into the id string.
A backend whose underlying API generates identifiers itself (Docker, future Hyper-V)
captures the generated value and encodes it. The first segment is a
backend-specific prefix (e.g., iso:, docker:); past the prefix, the encoding is
opaque to MXC.
The prefix is required: backend authors register their tag alongside the backend's
ContainmentBackend variant, and the dispatcher uses it to route non-provision calls
without a separate containment field on the wire (§7.1).
Non-provision roots are deliberately backend-neutral and are not version-affine:
an exact contract validates the phase fields, then a recognised sandboxId
prefix selects the backend. Consequently, a v0.9 start/exec/stop/deprovision
request can operate on a sandbox provisioned through a newer contract when the
caller holds its valid ID. Provision remains version- and backend-specific.
The wire spec and the SDK observably disagree on which error fires for an unrecognised prefix, and this is by design:
| Source | Behaviour for an unrecognised sandboxId prefix |
|---|---|
| SDK (TypeScript) | Throws MxcError { code: 'malformed_id' } before invoking mxc_state_aware or mxc_state_aware_exec. The SDK matches the prefix against the closed StateAwareContainmentBackend union it was compiled with; an unknown prefix is treated as a malformed id. See sdk/node/src/state-aware-helper.ts. |
| Native FFI entry points | Return MxcError { code: 'unsupported_containment' }. The Rust dispatcher parses the prefix successfully but the prefix-to-backend lookup table has no entry for it. See src/core/wxc_common/src/state_aware_dispatch.rs. |
A recognised prefix with a malformed body is malformed_id from both sources
(§8). The same prefix is exposed on the StatefulSandboxBackend trait as
const ID_PREFIX: &'static str (§9.2) so the default provision body can mint
synthetic ids with the right prefix; the trait const and the dispatcher's routing table
read from the same source, eliminating drift within Rust.
A second const, const BACKEND_KEY: &'static str, lives alongside ID_PREFIX on the
trait (§9.2). It carries the wire-format containment value for the backend (e.g.,
"isolation_session") and matches the SDK's StateAwareContainmentBackend member name.
Checked binding verifies it against the provision backend or the backend resolved
from a later operation's ID. Exact adapters have already converted configuration
into runtime values; dispatch does not navigate JSON. ID_PREFIX and BACKEND_KEY are deliberately distinct
strings: ID_PREFIX is a compact tag chosen for sandbox-id brevity (e.g. "iso")
while BACKEND_KEY is the full backend name shared with the SDK type system (e.g.
"isolation_session"). Backends that pick a long BACKEND_KEY for SDK readability
are not forced to repeat that length in every persisted sandbox id.
The SDK exposes the id as a branded TypeScript string parameterised by backend:
type SandboxId<C extends StateAwareContainmentBackend> =
string & { readonly __mxcBrand: 'SandboxId'; readonly __mxcBackend: C };
The runtime value is a plain string; the brand exists at compile time only. The
__mxcBackend phantom field carries the backend identity through the type system so
non-provision SDK calls can infer their backend from the id without the caller restating
it. The brand also prevents callers from accidentally passing other strings (a
containerId, a path, a literal) where a SandboxId is expected.
Persisting the id between calls is the caller's responsibility. The caller chooses the
storage mechanism. MXC neither tracks the id after provision returns nor verifies its
validity until the caller passes it back. If an id refers to a resource that no longer
exists, the next call returns error.code: "stale_id" (§8); the caller decides whether
to re-provision or treat the failure terminally.
MXC detects stale_id by translating the backend's native lookup-failure error —
returned when the underlying service no longer recognises the resource — into the typed
MXC error code. MXC itself retains no caller-side state and performs no validity check
before the call reaches the backend. Each backend's plan doc (§11.6) documents which
native errors map to stale_id.
Disambiguation: sandboxId vs containerId. Two different identifiers exist on the
wire format and have different roles:
| Field | Where it appears | Source | Purpose |
|---|---|---|---|
sandboxId | State-aware wire envelope (§7); SDK return value from provisionSandbox | System-generated by the backend | Opaque routing identifier; must be passed to subsequent state-aware calls |
containerId | One-shot wire envelope (per docs/schema.md) | Caller-supplied (or auto-generated random hex) | Human-readable label, used as e.g. AppContainer profile name |
State-aware non-provision calls carry sandboxId on the request; provision returns it
on the response. A state-aware request may also carry containerId — the parser
preserves it into the request the backend receives — but it is inert for backends that
do not use it as a label, and it is never a routing key on the state-aware path.
One-shot calls carry containerId (when present); they do not carry sandboxId.
6. TypeScript SDK
The SDK adds five new functions, exported from @microsoft/mxc-sdk alongside the existing
one-shot entry points. Each function corresponds to a lifecycle phase from §4. The
state-aware surface does not use SandboxPolicy — its cross-cutting fields live
directly on the per-(backend, phase) Configs introduced below.
6.1 Type definitions
import type {
StateAwareSchemaVersion,
WINDOWS_SANDBOX_STATE_AWARE_VERSION,
} from '@microsoft/mxc-sdk';
type SandboxId<C extends StateAwareContainmentBackend> =
string & { readonly __mxcBrand: 'SandboxId'; readonly __mxcBackend: C };
type Phase = 'provision' | 'start' | 'exec' | 'stop' | 'deprovision';
type StateAwareContainmentBackend = Extract<
ContainmentBackend,
'isolation_session' | 'windows_sandbox' | 'wslc'
>;
// Per-(backend, phase) Configs. Each declares only the fields valid for that backend
// at that phase. Cross-cutting fields (`filesystem`, `network`, `ui`) appear inline
// at the Config root, only in phases where the backend honors them per its policy
// honor matrix (§10.3). Phases with no backend-specific or cross-cutting fields
// declare a Config carrying only `version?`.
// NOTE: the IsolationSession shapes below are *illustrative* — they show the
// per-(backend, phase) Config pattern, not the shipped IsolationSession
// contract. The authoritative shapes (including the provision-phase `appId`
// and the three-field provision metadata) live in
// `docs/isolation-session/state-aware-rust.md` and
// `sdk/node/src/state-aware-types.ts`. The same caveat applies to the worked
// example in §7.4 and the config-typing example in §10.2.
interface IsolationSessionProvisionConfig {
version?: '0.9.0-alpha';
// IsolationSession cannot filter or deny the container network, so provision
// requires this exact unrestricted posture. Filesystem policy is rejected (§10.3).
network: {
egress: { default: 'allow' };
ingress: { default: 'allow'; hostLoopback: 'allow' };
};
}
interface IsolationSessionStartConfig {
version?: '0.9.0-alpha';
}
interface IsolationSessionExecConfig {
version?: '0.9.0-alpha';
process: ProcessConfig;
}
interface IsolationSessionStopConfig {
version?: '0.9.0-alpha';
}
interface IsolationSessionDeprovisionConfig {
version?: '0.9.0-alpha';
}
interface IsolationSessionProvisionMetadata {
agentUserName: string;
agentUserSid: string;
ephemeralWorkspacePath: string;
}
// WindowsSandbox holds a single active sandbox behind a persistent host-side
// daemon. Filesystem policy is honored at
// provision and is immutable thereafter (see §10.3).
interface WindowsSandboxProvisionConfig {
version?: typeof WINDOWS_SANDBOX_STATE_AWARE_VERSION;
filesystem?: FilesystemConfig;
}
interface WindowsSandboxStartConfig {
version?: typeof WINDOWS_SANDBOX_STATE_AWARE_VERSION;
}
interface WindowsSandboxExecConfig {
version?: typeof WINDOWS_SANDBOX_STATE_AWARE_VERSION;
process: ProcessConfig;
}
interface WindowsSandboxStopConfig {
version?: typeof WINDOWS_SANDBOX_STATE_AWARE_VERSION;
}
interface WindowsSandboxDeprovisionConfig {
version?: typeof WINDOWS_SANDBOX_STATE_AWARE_VERSION;
}
// WindowsSandbox returns no metadata for any phase.
// Backend Config bundle — outer keys are state-aware-capable backends; inner per-phase
// entries carry the typed per-(backend, phase) Config. Used by the generic per-phase
// helpers below.
type ConfigsForBackend<C extends StateAwareContainmentBackend> =
C extends 'isolation_session' ? {
provision: IsolationSessionProvisionConfig;
start: IsolationSessionStartConfig;
exec: IsolationSessionExecConfig;
stop: IsolationSessionStopConfig;
deprovision: IsolationSessionDeprovisionConfig;
} : C extends 'windows_sandbox' ? {
provision: WindowsSandboxProvisionConfig;
start: WindowsSandboxStartConfig;
exec: WindowsSandboxExecConfig;
stop: WindowsSandboxStopConfig;
deprovision: WindowsSandboxDeprovisionConfig;
} : never;
type ProvisionConfigFor<C extends StateAwareContainmentBackend> =
ConfigsForBackend<C>['provision'];
type StartConfigFor<C extends StateAwareContainmentBackend> =
ConfigsForBackend<C>['start'];
type ExecConfigFor<C extends StateAwareContainmentBackend> =
ConfigsForBackend<C>['exec'];
type StopConfigFor<C extends StateAwareContainmentBackend> =
ConfigsForBackend<C>['stop'];
type DeprovisionConfigFor<C extends StateAwareContainmentBackend> =
ConfigsForBackend<C>['deprovision'];
// Per-backend metadata bundle. Backends omit phases that return no metadata.
interface StateAwareMetadata {
isolation_session?: {
provision?: IsolationSessionProvisionMetadata;
// IsolationSession returns no metadata for start, stop, deprovision
};
windows_sandbox?: Record<never, never>;
// WindowsSandbox returns no metadata for any phase (keyof never -> undefined).
// Future state-aware-capable backends add typed entries here.
}
type ProvisionMetadataFor<C extends StateAwareContainmentBackend> =
'provision' extends keyof NonNullable<StateAwareMetadata[C]>
? NonNullable<StateAwareMetadata[C]>['provision']
: undefined;
type StartMetadataFor<C extends StateAwareContainmentBackend> =
'start' extends keyof NonNullable<StateAwareMetadata[C]>
? NonNullable<StateAwareMetadata[C]>['start']
: undefined;
type StopMetadataFor<C extends StateAwareContainmentBackend> =
'stop' extends keyof NonNullable<StateAwareMetadata[C]>
? NonNullable<StateAwareMetadata[C]>['stop']
: undefined;
type DeprovisionMetadataFor<C extends StateAwareContainmentBackend> =
'deprovision' extends keyof NonNullable<StateAwareMetadata[C]>
? NonNullable<StateAwareMetadata[C]>['deprovision']
: undefined;
interface ProvisionResult<C extends StateAwareContainmentBackend> {
sandboxId: SandboxId<C>;
metadata?: ProvisionMetadataFor<C>;
}
interface StartResult<C extends StateAwareContainmentBackend> {
metadata?: StartMetadataFor<C>;
}
interface StopResult<C extends StateAwareContainmentBackend> {
metadata?: StopMetadataFor<C>;
}
interface DeprovisionResult<C extends StateAwareContainmentBackend> {
metadata?: DeprovisionMetadataFor<C>;
}
interface ExecResult {
stdout: string;
stderr: string;
exitCode: number;
}
FilesystemConfig, NetworkConfig, UiConfig, ProcessConfig are the existing
wire-format-aligned interfaces from sdk/node/src/types.ts, reused unchanged as field
types inside the per-(backend, phase) Configs. State-aware deliberately does not use
SandboxPolicy; consumers spell out wire-format-aligned values directly (e.g.,
network: { egress: { default: 'deny' } } for a backend that supports it).
A backend's per-(backend, phase) Config declares each cross-cutting field exactly once,
and only in the phase where the backend's policy honor matrix (§10.3) marks it as
applied. IsolationSession provision carries the backend-specific acknowledgment
and optional appId, not filesystem/network/UI policy. Its phase types reject
those policy fields (§10.3
explains how the matrix lands at compile time on the SDK and at runtime in Rust).
Phases with no backend-specific or cross-cutting fields declare a Config carrying only
version? — explicit and minimal. Adding a future state-aware backend is a localised
change: extend StateAwareContainmentBackend, define five new *Config interfaces, and
add an arm to ConfigsForBackend.
Each Config carries an optional version?: StateAwareSchemaVersion, using the
existing SDK type for the exact state-aware contract, currently 0.9.0-alpha.
When omitted, the SDK supplies STATE_AWARE_VERSION (0.9.0-alpha); an explicit
value must name that same registered state-aware contract. Other spellings are
rejected, not range-validated or negotiated. The emitted JSON envelope always
contains the required version declaration.
6.2 Method signatures
function provisionSandbox<C extends StateAwareContainmentBackend>(
containment: C,
config?: ProvisionConfigFor<C>,
options?: SandboxSpawnOptions,
): Promise<ProvisionResult<C>>;
function startSandbox<C extends StateAwareContainmentBackend>(
sandboxId: SandboxId<C>,
config?: StartConfigFor<C>,
options?: SandboxSpawnOptions,
): Promise<StartResult<C>>;
function execInSandbox(
sandboxId: SandboxId<'isolation_session'>,
config: IsolationSessionExecConfig,
options?: StateAwareStreamingOptions,
): MxcSandboxProcess;
function execInSandboxAsync(
sandboxId: SandboxId<'isolation_session'>,
config: IsolationSessionExecConfig,
options?: SandboxSpawnOptions,
): Promise<ExecResult>;
function execInSandboxAsync<C extends StateAwareContainmentBackend>(
sandboxId: SandboxId<C>,
config: ExecConfigFor<C>,
options: SandboxSpawnOptions & { dryRun: true },
): Promise<ExecResult>;
function stopSandbox<C extends StateAwareContainmentBackend>(
sandboxId: SandboxId<C>,
config?: StopConfigFor<C>,
options?: SandboxSpawnOptions,
): Promise<StopResult<C>>;
function deprovisionSandbox<C extends StateAwareContainmentBackend>(
sandboxId: SandboxId<C>,
config?: DeprovisionConfigFor<C>,
options?: SandboxSpawnOptions,
): Promise<DeprovisionResult<C>>;
For IsolationSession, execInSandbox returns an owning MxcSandboxProcess for
live stdin/stdout/stderr, waiting, termination, and disposal.
execInSandboxAsync is a buffered convenience that accumulates output and
resolves on exit. Windows Sandbox and WSLC do not expose piped native exec
streams, so Node supports only execInSandboxAsync(..., { dryRun: true }) for
their exec requests.
provisionSandbox takes containment as its first argument, binding the backend choice
into the returned SandboxId<C>. Subsequent calls (startSandbox, execInSandbox /
execInSandboxAsync, stopSandbox, deprovisionSandbox) infer the backend from the
branded id and do not restate it. The wire envelope mirrors this: provision carries
containment; non-provision phases route via the prefix on sandboxId (§5, §7.1).
Promise-returning operations accept SandboxSpawnOptions, including
signal?: AbortSignal for cancellation. Live execInSandbox accepts
StateAwareStreamingOptions and exposes cancellation through the returned
process's kill() method. State-aware calls require experimental authorization
only when the selected backend or policy is experimental. Windows Sandbox
requires backend authorization; IsolationSession and WSLC do not.
6.3 Example
import {
provisionSandbox,
startSandbox,
execInSandbox,
execInSandboxAsync,
stopSandbox,
deprovisionSandbox,
IsolationSessionProvisionConfig,
} from '@microsoft/mxc-sdk';
const provisionConfig: IsolationSessionProvisionConfig = {
// IsolationSession cannot filter or deny the container network, so provision
// requires this exact unrestricted posture.
network: {
egress: { default: 'allow' },
ingress: { default: 'allow', hostLoopback: 'allow' },
},
};
// Provision — cross-cutting fields apply at this phase per the IS honor matrix (§10.3).
const { sandboxId } = await provisionSandbox('isolation_session', provisionConfig);
// Start — IsolationSession takes no per-phase config here.
await startSandbox(sandboxId);
// Exec — buffered convenience for short workloads.
const result = await execInSandboxAsync(
sandboxId,
{ process: { commandLine: 'echo hello', timeout: 5000 } },
);
console.log(result.stdout); // "hello\n"
// Exec — streaming for long-running workloads.
const sandboxProcess = execInSandbox(
sandboxId,
{ process: { commandLine: 'C:\\workspace\\agent.exe --watch' } },
);
sandboxProcess.standardOutput?.on('data', (chunk) => process.stdout.write(chunk));
const { exitCode } = await sandboxProcess.waitAsync();
console.log(`agent exit: ${exitCode}`);
// Stop and deprovision when done. Stop and deprovision Configs carry only `version?`,
// so callers typically pass `{}` (or omit when no options are needed).
await stopSandbox(sandboxId);
await deprovisionSandbox(sandboxId);
6.4 Composition with the one-shot surface
spawnSandbox is the composition of the five state-aware phases run end-to-end. The two
surfaces share ContainmentBackend and the wire-format-aligned interfaces in
sdk/node/src/types.ts (ProcessConfig, FilesystemConfig, NetworkConfig, UiConfig).
They differ in granularity and in how those interfaces are surfaced: one-shot bundles
them inside ContainerConfig (which is itself produced from a SandboxPolicy by
createConfigFromPolicy); state-aware uses them as field types inside the
per-(backend, phase) Configs and does not involve SandboxPolicy at all. A backend
that participates in both modes can be invoked through either surface; a backend that
participates in only one returns unsupported_phase from the other (§8).
State-aware-capable backends extend ContainmentBackend and StateAwareContainmentBackend
the same way ephemeral backends extend ContainmentBackend. Cancellation via
AbortSignal is supported on promise-returning state-aware methods (via
signal?: AbortSignal on SandboxSpawnOptions). Live execInSandbox callers
cancel through the returned process's kill() method. Detached /
fire-and-forget exec (process outliving the SDK call) is deferred to v2 (§14).
6.5 Policy discovery
The existing policy-discovery helpers (getAvailableToolsPolicy,
getUserProfilePolicy, getTemporaryFilesPolicy) compose with state-aware Configs
unchanged. They produce FilesystemPolicyResult fragments — { readonlyPaths, readwritePaths } — whose shape matches FilesystemConfig's readonly / readwrite path
arrays. Consumers merge the fragments directly into the filesystem field of a
state-aware Config for a backend that honors filesystem policy at provision (e.g.
WindowsSandbox); IsolationSession rejects filesystem policy, so its provision Config
omits it.
7. Wire contract
The wire contract is a typed, JSON-serialised envelope shared by the TypeScript SDK,
mxc_ffi, and the executor CLI. The SDK passes the envelope to mxc_ffi; direct CLI
callers can provide the same envelope through --config-base64. Rust parses both paths
into the same request types (§9.1). The only open content is at the leaves of
ErrorEnvelope.details; every other field, including the error envelope's named
structured fields, is statically typed.
7.1 Request envelope
The envelope is a TypeScript discriminated union over a top-level phase field. When
phase is absent, the request targets the existing one-shot surface. When phase is
present, the request targets the state-aware surface. The two shapes do not coexist in a
single call — phase fully discriminates which interpretation applies.
interface OneShotRequest {
phase?: never; // discriminator: absent
version: string;
containment: ContainmentType | ContainmentBackend;
containerId?: string;
process: ProcessConfig;
filesystem?: FilesystemConfig;
network?: NetworkConfig;
ui?: UiConfig;
lifecycle?: LifecycleConfig;
processContainer?: ProcessContainerConfig;
lxc?: LxcConfig;
windowsSandbox?: OneShotWindowsSandbox;
wslc?: WslcConfig;
}
interface ProvisionStateAwareRequest {
phase: 'provision'; // discriminator
version: StateAwareSchemaVersion;
containment: StateAwareContainmentBackend;
filesystem?: FilesystemConfig; // backend declares per-phase honor
network?: NetworkConfig; // backend declares per-phase honor
ui?: UiConfig; // backend declares per-phase honor
isolationSession?: {
provision?: { appId?: string };
};
wslc?: {
provision?: { image?: string; imageTarPath?: string };
};
}
interface NonProvisionStateAwareRequest {
phase: 'start' | 'exec' | 'stop' | 'deprovision'; // discriminator
version: StateAwareSchemaVersion;
sandboxId: SandboxId<StateAwareContainmentBackend>; // backend resolved from prefix
process?: ProcessConfig; // exec only
filesystem?: FilesystemConfig; // backend declares per-phase honor
network?: NetworkConfig; // backend declares per-phase honor
ui?: UiConfig; // backend declares per-phase honor
}
type StateAwareRequest = ProvisionStateAwareRequest | NonProvisionStateAwareRequest;
type MxcRequest = OneShotRequest | StateAwareRequest;
The wire format is the JSON serialisation of an MxcRequest value. There is no
"stringified blob" anywhere in the contract; everything except ErrorEnvelope.details is
statically typed.
Top-level fields shared by both branches:
| Field | Type | Required | Description |
|---|---|---|---|
version | string | Yes | Exact backend-specific schema version. IsolationSession and WSLC use 0.9.0-alpha; Windows Sandbox uses 0.10.0-alpha. The SDK fills this field when the consumer Config omits it. |
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). |
State-aware-only fields:
| Field | Type | Required | Description |
|---|---|---|---|
phase | Phase member | Yes | Discriminator. Absence means a one-shot request. |
process | ProcessConfig | Required for exec; absent otherwise. | Cross-backend execution fields. |
Cross-cutting fields available to state-aware (state-aware-only at top level — backends declare which phases honor them, see §10.3):
| Field | Type | Description |
|---|---|---|
filesystem | FilesystemConfig | Filesystem access policy. |
network | NetworkConfig | Network access policy. |
ui | UiConfig | UI access policy. |
One-shot-only fields (containerId, lifecycle, processContainer, lxc) are not
enumerated here; their definitions live in docs/schema.md.
7.2 Backend-specific sections
Backend-specific configuration uses each backend's permanent top-level JSON section. The SDK builds these sections internally from the per-(backend, phase) Configs defined in §6.1:
interface StateAwareBackendSections {
isolationSession?: {
provision?: { appId?: string };
// start, exec, stop, deprovision omitted — IsolationSession has no
// backend-specific config for those phases.
};
wslc?: {
provision?: { image?: string; imageTarPath?: string };
};
}
| Layer | Wire shape | Constraint |
|---|---|---|
| Outer key | Permanent camel-case backend section | Must match the selected state-aware-capable backend |
| Inner key | A subset of Phase per backend's needs | Backends omit phases with no backend-specific config |
| Innermost value | Backend-specific fields only (no cross-cutting, no version) | The SDK extracts these from the consumer's per-(backend, phase) Config |
Compile-time enforcement of valid combinations lives on the SDK's per-(backend, phase)
Configs (§6.1), not on this illustrative aggregate. Raw-JSON callers writing
backend sections directly are validated by the exact Rust contract and
validate_<phase> hooks at runtime (§10.1). Runtime experimental authorization
is supplied separately through SandboxSpawnOptions.experimental or the
executor's --experimental flag; it is not a request JSON field.
For one-shot calls (phase absent), the top-level backend section directly holds
the one-shot config object (e.g., wslc?: WslcConfig), as documented in
docs/schema.md. State-aware requests use that same section for phase-specific
configuration.
7.3 Response convention
The response convention is phase-aware. FFI calls return owned response/error data; the executor CLI represents the same outcomes through stdout, stderr, and its exit code.
Executor CLI stream usage (state-aware):
| Phase / outcome | stdout | stderr |
|---|---|---|
| Non-exec (provision, start, stop, deprovision), success or failure | Single JSON envelope ({result} or {error}) | MXC diagnostic output (when --debug); empty otherwise |
| Exec, dispatch succeeded | Script's stdout | Script's stderr; MXC diagnostic also lands here when --debug is passed |
| Exec, dispatch failed | Single JSON envelope ({error}) | MXC diagnostic output (when --debug); empty otherwise |
stdout is authoritative: for non-exec phases it carries exactly one envelope; for exec
it carries either the script's output (success) or exactly one envelope (failure).
stderr is informational. MXC routes its diagnostic logger output to stderr in
state-aware mode so stdout remains parseable without sentinels. (One-shot dispatch
keeps its existing stdout logger behaviour — the stricter routing applies to
state-aware only.)
Configuration parse-phase failures that occur after the request is
discriminated as state-aware (i.e. the phase field was recognized) follow the
state-aware contract: the typed {error} envelope is the only primary output,
while the human-readable actionable parse diagnostic is written only to
configured auxiliary sinks (--log-file and the Windows diagnostic console). It
is not duplicated to the logger's primary console/buffer output, so such a parse
failure does not add stderr noise even with --debug. Dispatch-time failures,
including typed per-backend configuration errors, use the same auxiliary-only
diagnostic routing before the executor emits their typed {error} envelope.
CLI failures that occur before discrimination is possible — malformed base64,
non-UTF-8 bytes, or JSON so malformed that the phase field cannot be read —
cannot be attributed to the state-aware path. The diagnostic is written to the
primary output (stderr) and no
{error} envelope is emitted. Callers that require an envelope even for
unparseable input should validate that the payload is well-formed JSON before
invoking wxc-exec.
For exec specifically, MXC diagnostic output mixes with the script's own stderr when
--debug is passed. This is a small amount of pre- and post-dispatch noise; consumers
wanting clean separation should use --log-file <path> instead, which routes diagnostic
output to a file and leaves stderr as pure script content.
Envelope shape:
interface ErrorEnvelope {
code: ErrorCode;
message: string;
operation?: string;
nativeCode?: string;
remediation?: string;
details?: Record<string, unknown>;
}
type NonExecResponseEnvelope<TResult> = { result: TResult } | { error: ErrorEnvelope };
| Phase | TResult shape |
|---|---|
provision | { sandboxId: SandboxId<C>; metadata?: object } |
start | { metadata?: object } |
stop | { metadata?: object } |
deprovision | { metadata?: object } |
Distinguishing exec dispatch-failure from script execution:
The SDK uses exit code plus stdout content:
exitCode == 0: the script ran and exited successfully. SDK constructs{stdout, stderr, exitCode}from PTY / pipe events.exitCode != 0AND stdout's entire content parses as a complete{error: {...}}envelope: dispatch failed before the script ran; SDK surfaces the typed error.exitCode != 0AND stdout does NOT parse as an envelope: the script ran and exited non-zero. SDK constructs{stdout, stderr, exitCode}.
Because MXC diagnostic output is routed to stderr in state-aware mode, this
stdout-based discrimination has no false positives or negatives — the content is always
either pure envelope or pure script output.
code and message are always present. code is the machine-readable category a
consumer branches on; message is the human-readable description, and for a failure
raised by an underlying platform API it is that API's own message, passed through
verbatim rather than concatenated with the other fields.
The three optional named fields carry structured failure detail. operation and
nativeCode describe an underlying platform call; remediation describes the failure:
| Field | Meaning |
|---|---|
operation | The API call that failed, namespaced by its interface — e.g. IsoSessionOps.RunProcessWithOptionsAsync. Low-cardinality and free of call parameters, so it is safe to aggregate on in telemetry. Best-effort diagnostic, not a versioned contract — see below. |
nativeCode | The underlying platform status as a string. An HRESULT such as 0x80070490 on Windows; the field is platform-neutral, so another backend can carry an errno or equivalent. |
remediation | An actionable "how to fix it" hint, when the failure has one. |
Availability. These fields are currently populated only by IsolationSession
state-aware operations. Other backends may adopt them — treat all three as optional on
every backend, and branch program logic on code first.
Stability. Unlike code, which is a closed and versioned enum, the values of operation and nativeCode are best-effort diagnostics and may change without a schema version bump. They are derived from the underlying platform API — for IsolationSession, from the projected WinRT class and method names — which MXC does not own and cannot version. Consumers should aggregate on them for telemetry and log them for diagnosis, but branch program logic on code, and should not treat a particular operation value as a guarantee. (MXC's own end-to-end tests do pin exact values; that is deliberate — they verify MXC's mapping, and move with it in the same change.)
Invariant: operation marks that an API operation was in flight. A failure
MXC raises before or outside any API call — a malformed request or id, a policy
rejection, or an internal failure of MXC's own machinery — carries neither
operation nor nativeCode. It may still carry a remediation.
Which fields earn a place here. A named top-level field is for a backend-neutral
concept: operation, nativeCode and remediation all apply equally to a Windows
HRESULT, a Linux errno, or any other backend's failure. Backend-specific structured
data belongs in details instead. That is what keeps details from becoming vestigial
as named fields are added — it remains the designated home for anything without a
cross-backend meaning.
ErrorEnvelope.details is the only Record<string, unknown> in the contract. It's the
escape hatch backends use to convey structured failure information that has no dedicated
field. Each backend's plan doc (§11) specifies what details contains for which error
codes.
7.4 Worked example: IsolationSession end-to-end
A complete state-aware lifecycle, threading TS call → JSON the SDK serialises and passes
to the executor via --config-base64 → Rust trait method that dispatches → response
shape, across all five phases.
Phase 1 — provision
const config: IsolationSessionProvisionConfig = {
network: {
egress: { default: 'allow' },
ingress: { default: 'allow', hostLoopback: 'allow' },
},
};
const { sandboxId } = await provisionSandbox(
'isolation_session',
config,
);
// sandboxId = "iso:eyJ2ZXJzaW9uIjoxLCJhZ2VudFVzZXJOYW1lIjoiX2lzb19hYmNfMTIzIn0"
{
"version": "0.9.0-alpha",
"containment": "isolation_session",
"phase": "provision",
"network": {
"egress": { "default": "allow" },
"ingress": { "default": "allow", "hostLoopback": "allow" }
}
}
// Exact adaptation carries the all-allow network policy on the request. After
// checked binding and backend validation, the dispatcher calls:
backend.provision(&request, Some(provision_config))
// returns Ok(ProvisionResult {
// sandbox_id: "iso:eyJ2ZXJzaW9uIjoxLCJhZ2VudFVzZXJOYW1lIjoiX2lzb19hYmNfMTIzIn0".into(),
// metadata: Some(IsolationSessionProvisionMetadata {
// agent_user_name: "_iso_abc_123".into(),
// agent_user_sid: "S-1-5-21-1001".into(),
// ephemeral_workspace_path: "C:\\ProgramData\\...\\_iso_abc_123".into(),
// }),
// })
{ "result": { "sandboxId": "iso:eyJ2ZXJzaW9uIjoxLCJhZ2VudFVzZXJOYW1lIjoiX2lzb19hYmNfMTIzIn0", "metadata": { "agentUserName": "_iso_abc_123", "agentUserSid": "S-1-5-21-1001", "ephemeralWorkspacePath": "C:\\ProgramData\\...\\_iso_abc_123" } } }
Phase 2 — start
await startSandbox(
sandboxId,
undefined,
);
{
"version": "0.9.0-alpha",
"phase": "start",
"sandboxId": "iso:eyJ2ZXJzaW9uIjoxLCJhZ2VudFVzZXJOYW1lIjoiX2lzb19hYmNfMTIzIn0"
}
// `start` carries no per-phase config for this backend — its StartConfig is
// `()`, so the envelope above has no backend-specific section and the
// dispatcher passes None:
backend.start(
"iso:eyJ2ZXJzaW9uIjoxLCJhZ2VudFVzZXJOYW1lIjoiX2lzb19hYmNfMTIzIn0",
&request,
/* config */ None,
)
// returns Ok(StartResult { metadata: None })
{ "result": {} }
Phase 3 — exec (buffered)
const r = await execInSandboxAsync(
sandboxId,
{ process: { commandLine: 'echo hello', timeout: 5000 } },
);
// r = { stdout: "hello\n", stderr: "", exitCode: 0 }
{
"version": "0.9.0-alpha",
"phase": "exec",
"sandboxId": "iso:eyJ2ZXJzaW9uIjoxLCJhZ2VudFVzZXJOYW1lIjoiX2lzb19hYmNfMTIzIn0",
"process": { "commandLine": "echo hello", "timeout": 5000 }
}
// Parser populates request.script_code = "echo hello", request.script_timeout =
// 5000 from the wire-format `process` block (same path as one-shot). The
// dispatcher then calls:
backend.exec("iso:eyJ2ZXJzaW9uIjoxLCJhZ2VudFVzZXJOYW1lIjoiX2lzb19hYmNfMTIzIn0", &request, /* config */ None, ExecStdio::Piped)
// returns Ok(ExecHandle { ... pipe handles + waiter ... })
Wire response (raw streaming, no JSON envelope on success):
- stdout:
hello\n - stderr: (empty)
- exit code:
0
The SDK constructs { stdout: "hello\n", stderr: "", exitCode: 0 } from the
native process streams and completion result.
Phase 4 — stop
await stopSandbox(sandboxId, {});
{
"version": "0.9.0-alpha",
"phase": "stop",
"sandboxId": "iso:eyJ2ZXJzaW9uIjoxLCJhZ2VudFVzZXJOYW1lIjoiX2lzb19hYmNfMTIzIn0"
}
backend.stop("iso:eyJ2ZXJzaW9uIjoxLCJhZ2VudFVzZXJOYW1lIjoiX2lzb19hYmNfMTIzIn0", &request, /* config */ None)
// returns Ok(StopResult { metadata: None })
{ "result": {} }
Phase 5 — deprovision
await deprovisionSandbox(sandboxId, {});
{
"version": "0.9.0-alpha",
"phase": "deprovision",
"sandboxId": "iso:eyJ2ZXJzaW9uIjoxLCJhZ2VudFVzZXJOYW1lIjoiX2lzb19hYmNfMTIzIn0"
}
backend.deprovision("iso:eyJ2ZXJzaW9uIjoxLCJhZ2VudFVzZXJOYW1lIjoiX2lzb19hYmNfMTIzIn0", &request, /* config */ None)
// returns Ok(DeprovisionResult { metadata: None })
{ "result": {} }
Mapping summary
The SDK places backend-specific config under the backend's permanent top-level
section when serialising state-aware calls — consumers write appId directly on the
IsolationSessionProvisionConfig, the SDK builds the nested wire form. Cross-cutting
fields (filesystem / network / runtimeConfig / ui) on a per-(backend, phase) Config map directly
to top-level wire fields — they are already wire-format-aligned in the Config, so the
SDK passes them through unchanged. Cross-backend exec fields (commandLine, cwd,
env, timeout) flow through the top-level process block. The typed SDK requires commandLine. The executor CLI can complete an exec
template from arguments after --; it sets process.commandLine before parsing.
Trailing commands are rejected for every non-exec phase. The Node SDK receives
owned response data and native process streams through mxc_ffi. Responses unwrap
any result envelope at the SDK boundary so the caller sees a plain ProvisionResult /
StartResult / ExecResult / StopResult / DeprovisionResult.
8. Error model
Errors crossing the wire-format boundary are typed by a closed enum of error codes
defined at the MXC layer. Backends map their native errors to these codes; the SDK
throws an MxcError carrying the corresponding code field. An MxcError with
code: 'stale_id' thrown from IsolationSession behaves the same as one thrown from any
other state-aware backend, so caller error-handling code is portable across backends.
8.1 Error code enum
| Code | Meaning |
|---|---|
malformed_request | Structural request error: malformed JSON, missing required field, unknown or phase-inappropriate field, recursively unknown backend-specific field, or invalid phase-specific shape |
unsupported_containment | The backend named by containment (provision) or implied by the sandboxId prefix (non-provision) is not a recognised backend in this build. SDK callers: the SDK type-checks unknown sandboxId prefixes against the closed StateAwareContainmentBackend union before dispatching and instead throws malformed_id for an unknown prefix; unsupported_containment is reachable from the SDK only on the provision path. See §6.4 |
unsupported_phase | The backend does not support the requested call mode (state-aware call against an ephemeral-only backend, or one-shot call against a state-aware-only backend) |
backend_unavailable | The backend's runtime dependency is missing or unreachable (service not running, daemon stopped) |
malformed_id | The sandboxId does not have a recognised backend prefix, or has a recognised prefix but does not deserialise into the backend's native form. SDK callers also see this error code for any non-provision call whose sandboxId prefix is not in StateAwareContainmentBackend |
stale_id | The sandboxId deserialised but refers to a resource the backend no longer recognises |
not_provisioned | Phase requires a provisioned sandbox; none provided, or the id is in a pre-provision state |
not_started | Phase requires a started sandbox; the id is provisioned but not started |
already_started | start called on an already-running sandbox |
already_stopped | stop called on an already-stopped sandbox |
policy_validation | A request that passed the exact structural contract violates a backend semantic invariant or unsupported value combination |
backend_error | Catch-all for backend-specific failures; details carries structured information |
type ErrorCode =
| 'malformed_request'
| 'unsupported_containment'
| 'unsupported_phase'
| 'backend_unavailable'
| 'malformed_id'
| 'stale_id'
| 'not_provisioned'
| 'not_started'
| 'already_started'
| 'already_stopped'
| 'policy_validation'
| 'backend_error';
The set is closed at the MXC layer. Backend-specific failures that don't fit one of the
enumerated codes surface as backend_error with structured information in details.
Process-runtime kill conditions (a script exceeding its timeout, a backend forcibly
terminating a process) are not represented as typed wire-format errors. They surface as
sentinel exit codes from the exec process, matching the existing one-shot convention.
8.2 Details payload
details (introduced in §7.3) is an open Record<string, unknown>. Backends use it to
convey structured information that callers may inspect: a backend's native error code,
partial output captured before a timeout fired, or any other context. The shape of
details for each error code is documented in the relevant backend's own docs (§11).
8.3 TypeScript error class
The SDK throws (rejects) a single MxcError class. The wire-format error code lives on
the code field and is the discriminator callers pattern-match on:
class MxcError extends Error {
readonly code: ErrorCode;
readonly details?: Record<string, unknown>;
}
Callers discriminate by comparing .code to a wire-format error code string
(err instanceof MxcError && err.code === 'stale_id'). The TypeScript string-literal
union on ErrorCode gives the same IDE completion as a per-code class hierarchy.
9. Rust layer architecture
The Rust layer adds a new StatefulSandboxBackend trait alongside the existing
ScriptRunner trait. Each backend implementation in the workspace is a struct that
implements one trait, the other, or both, depending on its declared participation mode
(§4).
9.1 Exact request contracts and the shared execution model
src/core/wxc_common/src/config_deserialize.rs performs path-aware JSON
deserialization into the exact contract selected by version, phase, and
provision containment. Published v0.9 and development v0.10 select one-shot,
provision, start, exec, stop, or deprovision; provision then selects
the backend-specific closed root registered by that exact contract.
The exact contract is the JSON trust boundary. Its recursively closed request
types enforce required declarations, phase-inappropriate fields,
duplicate/unknown fields, rejected nulls, and recursively unknown fields.
Structural failures surface as malformed_request
before backend binding or policy validation. Exact adapters convert backend
payloads directly to runtime configurations, while common fields reuse
common_request_ir::CommonRequestIR conversion in config_parser.rs. The
internal common parser and executable equivalence harness have been removed.
When the native CLI supplies trailing command arguments, the loader first
splices the rendered command into process.commandLine and then parses that
effective document. Structural diagnostics are therefore relative to that
document; replacing or inserting the
command may shift a later same-line column from its position in the caller's
original bytes.
fn parse_mxc_request_json_with_cli(
json: &str,
logger: &mut Logger,
cli_command: &[String],
) -> Result<MxcRequest, ParseError> {
if cli_command.is_empty() {
return parse_exact_mxc_request_json(json, logger);
}
let (effective_json, override_log) = apply_cli_command(json, cli_command)?;
let request = parse_exact_mxc_request_json(&effective_json, logger)?;
if let Some(message) = override_log {
logger.log_line(&message);
}
Ok(request)
}
The state-aware adapter produces a checked input:
StateAwareInput {
common, // no phase, containment, sandboxId, or backend phase config
operation, // backend-tagged provision or a later operation + ID
}
Per-phase requirements (containment for provision, sandboxId for the
others, and process for exec) and phase-specific field exclusion are
enforced by the selected exact request root.
The construction boundary rejects contradictory common fields and one-shot
sections rather than silently discarding them. normalize_state_aware derives
a temporary phase/backend/ID context from the operation and invokes the shared
common conversion. It preserves policy presence and telemetry, infers the
existing containment context from the ID prefix without earlier ID validation,
and clears directional network defaults on later phases when no network policy
was supplied. It does not apply backend defaults or check host availability.
Backend binding and validate_<phase> hooks therefore receive only
structurally representable fields. They enforce semantic values, cross-field
invariants, backend identity, and policy capabilities; those representable
refusals surface as policy_validation.
Normalization populates the cross-cutting input fields (filesystem, network,
ui) into ExecutionRequest.policy (a ContainerPolicy) exactly as the
one-shot path does, and process populates ExecutionRequest's flat
script_code / working_directory / script_timeout / env fields. Typed
telemetry is populated from the common top-level telemetry field.
ParsedStateAwareRequest has private request: ExecutionRequest and
operation: StateAwareOperation fields with read-only accessors. The operation
is the sole phase authority. Provision carries
IsolationSession(Option<IsolationSessionProvisionConfig>), WindowsSandbox,
or Wslc(Option<WslcProvisionConfig>); later variants each carry a required
sandbox_id, with no backend-specific configuration today. Exec process
settings stay in the common request.
Absent provision configuration remains None; an empty provision object remains
a present config with absent fields; explicit empty strings remain supplied.
Equivalent absent/empty outer wrappers need not survive. Backend validation and
defaulting retain ownership of those values. Successful requests retain no raw
backend JSON or source text. The bundling does not modify ExecutionRequest's shape. Domain models
are exposed to the dispatch layer; the wire types are an implementation detail of
the parser and schema generation.
9.2 The trait
Backends implement the trait with two consts (id prefix and backend key, §5),
associated types for each phase's config and metadata, and method overrides where they
have substantive work.
Most methods have default no-op bodies; only exec is strictly required. Use () for
any associated type the backend does not need.
pub trait StatefulSandboxBackend {
/// Backend identifier prefix. Used as the leading `<tag>:` segment of every
/// `sandbox_id` minted by the default `provision` body, and read by the
/// dispatcher to route non-provision calls to this backend (§5).
const ID_PREFIX: &'static str;
/// Wire-format `containment` value for this backend, matching the SDK's
/// `StateAwareContainmentBackend` member name (e.g. `"isolation_session"`).
/// Checked binding verifies this backend identity before typed dispatch.
/// Distinct from `ID_PREFIX` — see §5 for the rationale.
const BACKEND_KEY: &'static str;
type ProvisionConfig;
type StartConfig;
type ExecConfig;
type StopConfig;
type DeprovisionConfig;
type ProvisionMetadata: serde::Serialize;
type StartMetadata: serde::Serialize;
type StopMetadata: serde::Serialize;
type DeprovisionMetadata: serde::Serialize;
/// Optional. Default mints `<ID_PREFIX>:<random-token>` for a stateless-
/// underneath backend; override when the backend has native provision work
/// (e.g., allocating a session, registering with the underlying service).
fn provision(
&mut self,
_request: &ExecutionRequest,
_config: Option<Self::ProvisionConfig>,
) -> Result<ProvisionResult<Self::ProvisionMetadata>, MxcError> {
Ok(ProvisionResult {
sandbox_id: format!("{}:{}", Self::ID_PREFIX, mint_random_token()),
metadata: None,
})
}
/// Optional. Default returns success with no metadata. Override when the
/// backend has substantive work to do at start.
fn start(
&mut self,
_sandbox_id: &str,
_request: &ExecutionRequest,
_config: Option<Self::StartConfig>,
) -> Result<StartResult<Self::StartMetadata>, MxcError> {
Ok(StartResult { metadata: None })
}
/// Required. Must execute the workload and return a handle.
///
/// `stdio` is authoritative and fixes the topology of the returned streams.
///
/// `Piped` means the caller drives the streams itself, so an
/// implementation must surface separate raw pipe
/// handles, allocate no pseudo-console, and not touch the host console.
/// `Relayed` means the handle is relayed to **the calling
/// process's** own stdio, where a pseudo-console is legitimate and stderr may
/// therefore arrive merged into stdout, leaving `ExecHandle::stderr` null. A
/// backend that probes the host to decide how to wire stdio must confine that
/// probe to the `Relayed` case, where the probing process is the relay
/// target.
///
/// Topology, not caller identity: "in-process" does not imply `Piped`.
///
/// A backend that cannot serve `Piped` at all — because it relays the
/// workload's output to the *host process's* own stdio rather than
/// returning streams — must refuse **before running anything**. The
/// workload is arbitrary and may not be idempotent, so a refusal issued
/// after the fact reports "unsupported" for something that has already
/// taken effect and whose output has already gone somewhere the caller
/// never asked for. `wxc_common::state_aware_backend::unsupported_piped_exec`
/// is the shared refusal.
fn exec(
&mut self,
sandbox_id: &str,
request: &ExecutionRequest,
config: Option<Self::ExecConfig>,
stdio: ExecStdio,
) -> Result<ExecHandle, MxcError>;
/// Optional. Default returns success with no metadata.
fn stop(
&mut self,
_sandbox_id: &str,
_request: &ExecutionRequest,
_config: Option<Self::StopConfig>,
) -> Result<StopResult<Self::StopMetadata>, MxcError> {
Ok(StopResult { metadata: None })
}
/// Optional. Default returns success with no metadata.
fn deprovision(
&mut self,
_sandbox_id: &str,
_request: &ExecutionRequest,
_config: Option<Self::DeprovisionConfig>,
) -> Result<DeprovisionResult<Self::DeprovisionMetadata>, MxcError> {
Ok(DeprovisionResult { metadata: None })
}
/// Per-phase validation hooks. Called by the dispatch layer before the
/// corresponding phase method. Default: accept all requests. Override to
/// add backend-specific checks (config field semantics, policy honor
/// enforcement, id format checks beyond the prefix). Failures surface as
/// the chosen `MxcError` code.
fn validate_provision(
&self,
_request: &ExecutionRequest,
_config: Option<&Self::ProvisionConfig>,
) -> Result<(), MxcError> {
Ok(())
}
fn validate_start(
&self,
_sandbox_id: &str,
_request: &ExecutionRequest,
_config: Option<&Self::StartConfig>,
) -> Result<(), MxcError> {
Ok(())
}
fn validate_exec(
&self,
_sandbox_id: &str,
_request: &ExecutionRequest,
_config: Option<&Self::ExecConfig>,
) -> Result<(), MxcError> {
Ok(())
}
fn validate_stop(
&self,
_sandbox_id: &str,
_request: &ExecutionRequest,
_config: Option<&Self::StopConfig>,
) -> Result<(), MxcError> {
Ok(())
}
fn validate_deprovision(
&self,
_sandbox_id: &str,
_request: &ExecutionRequest,
_config: Option<&Self::DeprovisionConfig>,
) -> Result<(), MxcError> {
Ok(())
}
}
pub struct ProvisionResult<M> {
pub sandbox_id: String,
pub metadata: Option<M>,
}
pub struct StartResult<M> {
pub metadata: Option<M>,
}
pub struct StopResult<M> {
pub metadata: Option<M>,
}
pub struct DeprovisionResult<M> {
pub metadata: Option<M>,
}
pub struct ExecHandle {
/// Stdout pipe handle from the running process. The relay path writes it to
/// the calling process's own stdout.
pub stdout: PipeHandle,
/// Stderr pipe handle from the running process. The relay path writes it to
/// the calling process's own stderr.
pub stderr: PipeHandle,
/// Stdin pipe handle. Not consumed by the relay, which forwards
/// no input; the streaming path hands it to an in-process caller.
pub stdin: PipeHandle,
/// Function to wait for exit; returns how the exec finished.
pub waiter: Box<dyn FnOnce() -> Result<ExecOutcome, MxcError> + Send>,
/// Function to terminate the process (called on AbortSignal). Fallible: a
/// platform that refuses the request must be able to say so, because a
/// caller that assumes a refused kill succeeded can block forever waiting
/// on a process that is still running.
pub terminator: Box<dyn FnOnce() -> Result<(), MxcError> + Send>,
/// Closes the backend's own stdin write end. `None` when the backend
/// exposes no stdin.
pub stdin_closer: Option<Box<dyn FnOnce() + Send>>,
}
/// How an exec finished, as distinct from why a wait failed. A timeout is an
/// outcome — the deadline was spent while the process ran, and the process is
/// no longer running — whereas `Err` means the exit could not be determined.
/// Deliberately not "the backend killed it": a workload that overruns its
/// deadline and then exits on its own has still missed it, and how far the
/// termination reaches is the backend's to state. Only `ExecStdio::Piped`
/// can observe `TimedOut`; a backend serving `ExecStdio::Relayed` reports
/// `Exited`.
pub enum ExecOutcome {
Exited(i32),
TimedOut,
}
Trait methods take &ExecutionRequest (the existing one-shot domain model from
wxc_common::models, populated by the same normalize_common_request_ir
parser path that serves one-shot calls), plus sandbox_id for non-provision
phases and an optional backend-specific typed config
(Self::<Phase>Config). Cross-cutting policy fields
flow through request.policy (a ContainerPolicy); per-exec process info flows
through request.script_code / request.working_directory / request.script_timeout
/ request.env; backend-specific config is adapted from the exact contract,
checked against the selected backend, and passed as the config parameter
(§9.3). Per-phase result types (ProvisionResult<M>, StartResult<M>,
StopResult<M>, DeprovisionResult<M>) carry the typed metadata return value;
ExecHandle exposes the running process's pipe handles for relay.
MxcError is the typed Rust equivalent of its SDK counterpart. PipeHandle is a
platform-abstracted pipe-handle wrapper — a kernel HANDLE on Windows, a file
descriptor on Linux. The executor's outer driver reads from ExecHandle.stdout /
stderr, awaits exit via waiter, and calls terminator to tear the exec down.
It does not write to stdin.
mint_random_token() is a small helper in wxc_common that produces a short hex string
(mirroring the SDK's randomBytes-based id minting in sandbox.ts); it is used by the
default provision body to construct synthetic ids for stateless-underneath backends.
Methods take &mut self, matching the existing ScriptRunner::run signature. Backends
do not need to accumulate state between calls within a backend instance — within a
single call a backend may use mutability to hold open service connections, but no state
needs to survive across phase calls.
Why the trait reuses ExecutionRequest
The trait could plausibly require its own per-phase request types (e.g., an
ExecRequest<C> containing typed ProcessConfig, FilesystemConfig, NetworkConfig,
and UiConfig fields) instead of taking &ExecutionRequest directly. The design rejects
that shape and reuses ExecutionRequest for five concrete reasons:
-
The field-ignore precedent is established across every existing backend. Every
ScriptRunnerimpl in the workspace today (AppContainer,BaseContainer,NanVix,WindowsSandbox,IsolationSession,Lxc,Wslc) takes&ExecutionRequestand reads only the fields it needs.NanVixandIsolationSessiongo further and actively reject fields they cannot honor (e.g.,NanVixScriptRunner::validate_runnerrejects filesystem paths, network rules, network proxy, and a non-empty working directory). State-aware follows the same pattern, so the trait ergonomic stays consistent across one-shot and state-aware surfaces. -
Process info is already typed on
ExecutionRequest. The wire-formatprocessblock (commandLine,cwd,env,timeout) deserialises intoExecutionRequest's flat fields (script_code,working_directory,script_timeout,env) via the existingRawProcessintermediate inconfig_parser.rs. Wrapping these four typed fields into a RustProcessConfigstruct adds no type safety the compiler does not already provide on the flat fields. The TypeScript-sideProcessConfiginsdk/node/src/types.tsis unchanged regardless. -
Cross-cutting policy is already typed on
ExecutionRequest. Existing backends readrequest.policy.readwrite_paths,request.policy.allowed_hosts,request.policy.network_proxy,request.policy.ui, etc. directly today. State-awareprovisionandvalidate_<phase>hooks read the same fields. SplittingContainerPolicyinto separateFilesystemConfig/NetworkConfig/UiConfigRust types would force a mechanical refactor across every backend without changing what any of them does. -
The existing extraction helpers already work for state-aware exec. The
IsolationSessionRunner::build_process_options(&ExecutionRequest)function inisolation_session_commonextracts process info into the runner's internalProcessOptionsstruct used to populateIsoSessionProcessOptionsforRunProcessWithOptionsAsync. State-awareexeccalls the same function with the same&ExecutionRequestargument; no new public Rust type closes a semantic gap that does not exist. -
No SDK or wire-format change is required. The TypeScript
ProcessConfig,FilesystemConfig,NetworkConfig, andUiConfiginterfaces insdk/node/src/types.tsare public consumer-facing types and remain unchanged. The wire JSON shape is unchanged. The Rust trait readingrequest.script_code,request.policy.allowed_hosts, etc. is an internal implementation choice invisible above the Rust layer.
What would justify deviating from ExecutionRequest reuse — none of which apply to the v1
surface in this proposal:
- A fundamentally new state-aware-only field that does not fit any existing
ExecutionRequestshape (e.g., a snapshot id for a hypotheticalrestorephase). - A type-system invariant only expressible via a wrapper struct (e.g., enforcing at
compile time that exec requests always carry a non-empty command line —
validate_exec_commonchecks this at runtime instead per §10.1). - An SDK-API evolution that introduces a new typed shape the Rust trait must mirror across the SDK-Rust boundary.
If any of these emerges, the trait gains the necessary type at that point. The v1
surface introduces none, so the trait stays minimal and reuses ExecutionRequest.
9.3 Dispatch
/// Dispatch outcome. Distinguishes structured-envelope responses (non-exec phases or
/// dispatch failure) from exec success (where stdio has already streamed live through
/// the relay).
enum DispatchOutcome {
Envelope(ResponseEnvelope),
ExecCompleted { exit_code: i32 },
}
fn run(req: MxcRequest, dry_run: bool) -> Result<DispatchOutcome, MxcError> {
match req {
MxcRequest::OneShot(r) => Ok(DispatchOutcome::Envelope(run_one_shot(r))),
MxcRequest::StateAware(parsed) => match resolve_backend(&parsed)? {
ContainmentBackend::IsolationSession => {
// Runtime authorization/build gates run before binding in the engine.
let bound = bind_isolation_session::<IsolationSessionRunner>(parsed)?;
let mut backend = IsolationSessionRunner::new();
dispatch_state_aware(&mut backend, bound, dry_run)
}
// additional state-aware backends added here
_ => Err(MxcError::UnsupportedPhase),
},
}
}
fn dispatch_state_aware<B: StatefulSandboxBackend>(
backend: &mut B,
bound: BoundStateAwareRequest<B>,
dry_run: bool,
) -> Result<DispatchOutcome, MxcError> {
let (request, operation) = bound.into_parts();
match operation {
BoundStateAwareOperation::Provision(config) => {
backend.validate_provision(&request, config.as_ref())?;
if dry_run { return Ok(DispatchOutcome::Envelope(empty_envelope())); }
let result = backend.provision(&request, config)?;
Ok(DispatchOutcome::Envelope(provision_envelope(result)))
}
BoundStateAwareOperation::Start { sandbox_id, config } => {
backend.validate_start(&sandbox_id, &request, config.as_ref())?;
if dry_run { return Ok(DispatchOutcome::Envelope(empty_envelope())); }
let result = backend.start(&sandbox_id, &request, config)?;
Ok(DispatchOutcome::Envelope(start_envelope(result)))
}
BoundStateAwareOperation::Exec { sandbox_id, config } => {
validate_exec_common(&request)?;
backend.validate_exec(&sandbox_id, &request, config.as_ref())?;
if dry_run { return Ok(DispatchOutcome::Envelope(empty_envelope())); }
let handle = backend.exec(&sandbox_id, &request, config, ExecStdio::Relayed)?;
// relay_exec_to_stdio streams the script's pipes to the executor's
// stdout/stderr live, awaits exit, and returns the script's exit code.
let exit_code = relay_exec_to_stdio(handle)?;
Ok(DispatchOutcome::ExecCompleted { exit_code })
}
BoundStateAwareOperation::Stop { sandbox_id, config } => {
backend.validate_stop(&sandbox_id, &request, config.as_ref())?;
if dry_run { return Ok(DispatchOutcome::Envelope(empty_envelope())); }
let result = backend.stop(&sandbox_id, &request, config)?;
Ok(DispatchOutcome::Envelope(stop_envelope(result)))
}
BoundStateAwareOperation::Deprovision { sandbox_id, config } => {
backend.validate_deprovision(&sandbox_id, &request, config.as_ref())?;
if dry_run { return Ok(DispatchOutcome::Envelope(empty_envelope())); }
let result = backend.deprovision(&sandbox_id, &request, config)?;
Ok(DispatchOutcome::Envelope(deprovision_envelope(result)))
}
}
}
resolve_backend(&parsed) reads parsed.containment() when phase() == Provision; for the
other phases it reads the prefix from parsed.sandbox_id() and looks it up in the
registered prefix table. Mismatches surface as unsupported_containment (unrecognised
prefix) or malformed_id (no prefix structure) per §8.
bind_isolation_session, bind_windows_sandbox, and bind_wslc live in
wxc_common::state_aware_binding, without backend crate dependencies. The engine
chooses a concrete backend after its existing routing/opt-in/availability gates.
An incompatible payload/backend pair is malformed_request, never None.
Binding is a static conversion, not JSON serialization, reparsing, or downcasting.
Both dispatch entry points accept BoundStateAwareRequest<B>; streaming accepts
only exec and passes ExecStdio::Piped, while the lifecycle path passes
ExecStdio::Relayed. Backend-specific piped-exec refusals remain unchanged.
Exact structural errors carry the full field path and source coordinates before
the successful request is constructed. Semantic errors remain backend-owned.
validate_exec_common
is a free function in validator.rs that checks cross-backend per-phase invariants
(e.g., request.script_code non-empty); other phases have no cross-backend common
checks today and skip directly to the backend's validate_<phase> hook.
Helper functions for handle-validation, envelope wrapping, and
empty-envelope construction are mechanical and elided. The executor's outer driver
invokes run and handles each outcome:
Ok(DispatchOutcome::Envelope(env))— write the envelope's JSON to stdout, exit 0.Ok(DispatchOutcome::ExecCompleted { exit_code })— exec already streamed live; the outer driver exits the executor process with this code. No JSON is emitted.Err(e)— convertMxcErrorto anerror-envelope (§7.3), write the JSON to stdout, exit non-zero.
9.4 Capability declaration
A backend's participation mode (§4) is declared by which traits it implements. Rust's type system enforces the declaration: dispatch arms can only invoke trait methods that the backend actually implements. Dispatch-wiring mismatches are compile-time errors, not runtime registry checks.
State-aware backends additionally register two consts on their trait impl alongside
their ContainmentBackend variant: ID_PREFIX (the sandbox-id tag, used by the
dispatcher to resolve non-provision calls to the right backend) and BACKEND_KEY (the
wire-format containment value, used for provision-phase routing and checked
typed binding). Both are described
in §5.
Per-stage config contents are also typed at compile time — the backend's associated
types declare the runtime configuration each phase consumes. Exact contracts own
JSON shape, and their adapters construct runtime values before binding. There is no
Record<string, unknown> shim between the wire format and the backend's typed input.
10. Per-stage configs and validation
Per-stage configs are typed end-to-end: TypeScript interfaces in the SDK package, Rust types in the backend's crate. The wire format is the JSON serialisation of those typed shapes.
10.1 Validation layers
| Layer | Validates | Failure surfaces as |
|---|---|---|
| SDK (TypeScript) | Recognised containment (provision); branded SandboxId<C> (other phases); required cross-backend fields (process.commandLine for exec); typed config shape (autocompletion + compile-time check) | Thrown at the call site, before any subprocess runs |
| MXC parser (Rust) | Exact registered version and closed request root; required phase fields; phase-inappropriate, unknown, and recursively unknown fields | error.code: malformed_request, unsupported_phase, unsupported_containment |
| MXC dispatch common (Rust) | Cross-backend per-phase invariants (e.g., validate_exec_common checks process.commandLine non-empty) | error.code: malformed_request, policy_validation |
Backend validate_<phase> hooks (Rust) | Per-backend per-phase invariants: config field values, cross-cutting policy honor (per the matrix in §10.3), id format checks beyond prefix matching | error.code: policy_validation, malformed_id, stale_id, backend_error, backend_unavailable |
The native CLI template form is resolved before these layers: a trailing
command on state-aware exec supplies or replaces process.commandLine, while
other phases reject trailing commands. The effective request presented to the
Rust parser still contains the required non-empty command.
Each layer validates only what it cheaply can. The SDK's typed config catches structural errors at compile time. The exact parser catches structural errors that escaped the SDK (e.g., from non-TypeScript callers). The backend catches semantic errors that depend on runtime state (e.g., "the configuration ID is recognised but not allowed for this agent user").
10.2 Backend-side config typing
A state-aware backend declares runtime configuration as trait associated types.
IsolationSession and WSLC provision types live in wxc_common::models, keeping
neutral operations and binding independent of backend crates. Exact contract
types separately define the JSON shape under each permanent top-level
<backend>.<phase> section; adapters map their fields exhaustively to runtime
values. Dispatch does not require Deserialize. The
TypeScript type exported from the SDK package is the consumer-facing per-(backend,
phase) Config from §6.1; it is a strict superset of the wire shape, adding
version? (for an optional exact schema declaration) and the cross-cutting filesystem /
network / ui fields in phases where the backend's policy honor matrix marks them
as applied (§10.3).
#[derive(Debug, Default)]
pub struct IsolationSessionProvisionConfig {
pub app_id: Option<String>,
}
interface IsolationSessionProvisionConfig {
version?: StateAwareSchemaVersion;
appId?: string;
network: {
egress: { default: 'allow' };
ingress: { default: 'allow'; hostLoopback: 'allow' };
};
}
The TypeScript Config carries version (which the SDK serialises to the top-level
wire version field) plus any cross-cutting fields the matrix marks as honored for
that phase. IsolationSession's required all-allow posture is a top-level network
policy. The Rust struct receives the appId from the wire's
isolationSession.provision block through exact adaptation and checked
binding to Self::ProvisionConfig (§9.3), while the network policy remains on the
execution request. The SDK is responsible for splitting
the consumer Config into top-level common fields (cross-cutting, version) and
the permanent backend section; Rust sees only the post-split shape.
provision is used here because it is IsolationSession's only phase with a
per-phase config; start, exec, stop and deprovision declare () and reject
any payload in their slot.
10.3 Cross-cutting policy honor matrix
Each backend declares which phases honor which cross-cutting field (filesystem,
network, ui). The matrix shape is the proposal-level contract: a row per
cross-cutting field, a column per phase, with values from the closed set
applied / rejected / ignored. Specific values per backend are documented in each
backend's plan doc (§11.6). The IsolationSession row set below mirrors the shipped
backend; the authoritative statement lives in
isolation-session/state-aware-rust.md:
| Field | provision | start | exec | stop | deprovision |
|---|---|---|---|---|---|
filesystem | rejected | rejected | rejected | rejected | rejected |
network | rejected | rejected | rejected | rejected | rejected |
| Backend-specific acknowledgment | required | rejected | rejected | rejected | rejected |
ui | rejected | rejected | rejected | rejected | rejected |
The backend cannot filter or deny networking, so the caller must supply
network.egress/network.ingress all-allow posture in the provision payload instead of
requesting a network policy. ui is rejected rather than ignored: an isolation
session isolates the host's UI from contained code but does not deny that code UI
capabilities, so no ui posture would be truthful and the section is refused rather
than silently dropped. An omitted ui is accepted and applies no restriction.
For WindowsSandbox, filesystem policy (readwrite/readonly/denied HOST paths) is
applied at provision and frozen for the life of the sandbox; later phases reject it.
network and ui are not yet honored at any phase (network isolation is enforced
unconditionally by the in-guest agent).
Known gap (
deniedPaths). WindowsSandbox honorsdeniedPathsonly as a best-effort provision-time rejection (a.wsbmapped share cannot express a Deny ACE), not as a hardened security boundary. See the "Known gap (deniedPaths)" caveat indocs/windows-sandbox/windows-sandbox.md.
| Field | provision | start | exec | stop | deprovision |
|---|---|---|---|---|---|
filesystem | applied | rejected | rejected | rejected | rejected |
network | rejected | rejected | rejected | rejected | rejected |
ui | rejected | rejected | rejected | rejected | rejected |
- Compile-time enforcement at the SDK. Each per-(backend, phase) Config (§6.1)
declares only the cross-cutting fields the matrix marks as
appliedfor that phase and that the runtime currently honors. TypeScript rejects callers passing fields the backend does not honor at that phase, and also fields the matrix would mark asappliedbut the runtime does not yet implement. For IsolationSession's matrix above,IsolationSessionProvisionConfigcarries the required backend-specific the directional all-allow network posture field; filesystem/network/UI policy is rejected. The start, exec, stop, and deprovision Configs carry none of these fields. Callers cannot accidentally pass them. - Runtime enforcement at Rust. The exact phase contract structurally
rejects fields that are not representable for that phase, including input
from raw-JSON callers or a future SDK whose typing has drifted. Those failures
surface as
malformed_request. Backendvalidate_<phase>hooks then reject unsupported values or combinations among fields the exact contract admits; those failures surface aspolicy_validation(§8). Together these checks are authoritative for wire-format consumers that bypass the SDK; the SDK types are a compile-time restatement of the same boundary.
Per-phase honor is the backend's choice and must be documented in its plan doc. When the matrix evolves (e.g., a new cross-cutting field lands at the SDK layer), each backend's per-phase Configs and Rust runtime checks must be updated in lockstep.
11. Plug-in guide for new backends
A backend author adding a new state-aware backend (or extending an existing ephemeral backend with state-aware support) follows this workflow. The §7.4 worked example illustrates the end-to-end shape; the steps below are the operational checklist.
11.1 Decide the participation mode
Pick one of the three modes from §4: ephemeral-only, state-aware-only, or both.
11.2 Implement the trait
The StatefulSandboxBackend trait signatures are in §9.2. Declare:
const ID_PREFIX: &'static str— the leading<tag>:segment for this backend'ssandbox_idvalues; also used by the dispatcher for non-provision routing (§5).const BACKEND_KEY: &'static str— the wire-formatcontainmentvalue for this backend, matching the SDK'sStateAwareContainmentBackendmember name (e.g.,"isolation_session"). Used by checked binding to verify backend identity and to resolveprovision-phase requests (§5).- Per-phase config associated types (
ProvisionConfig, ...,DeprovisionConfig). - Per-phase metadata associated types (
ProvisionMetadata, ...,DeprovisionMetadata). Use()for any associated type the backend does not need.
Implement exec — the only required method. Override provision, start, stop, or
deprovision only when the backend has substantive work to do in that phase; the trait
provides default no-op bodies otherwise. The default provision mints a synthetic
sandbox_id of the form <ID_PREFIX>:<random-token>; backends with native provision
(allocating a session, registering with the underlying service) override and produce
their own structured id.
Override validate_<phase> hooks for backend-specific pre-execution checks (config
field semantics, policy honor enforcement, id format verification beyond prefix
matching). Defaults are no-ops; only override the phases the backend has checks for.
Validation runs before the phase method; failures short-circuit and surface as typed
MxcError codes without invoking the backend.
11.3 Define typed *Config interfaces in the SDK
For each of the five lifecycle phases, add a typed TypeScript interface to
@microsoft/mxc-sdk. Each Config carries only the fields valid for that backend at
that phase: version? always, the cross-cutting filesystem / network / ui
fields in the phases where the backend honors them (§10.3), and any backend-specific
fields. Phases with no backend-specific or cross-cutting fields declare a Config
carrying only version?. Example shape (mirroring §6.1):
interface MyBackendProvisionConfig {
version?: StateAwareSchemaVersion;
// cross-cutting fields for phases where MyBackend's matrix marks `applied`
}
interface MyBackendStartConfig {
version?: StateAwareSchemaVersion;
// backend-specific start fields
}
// ... and similarly for exec, stop, deprovision
Add an arm to ConfigsForBackend<C> mapping the new backend's ContainmentBackend
member to its five phase Configs:
type ConfigsForBackend<C extends StateAwareContainmentBackend> =
C extends 'isolation_session' ? { /* IS phase Configs */ } :
C extends 'my_backend' ? {
provision: MyBackendProvisionConfig;
start: MyBackendStartConfig;
exec: MyBackendExecConfig;
stop: MyBackendStopConfig;
deprovision: MyBackendDeprovisionConfig;
} : never;
If the backend is absent from ContainmentBackend, add it there and to
StateAwareContainmentBackend.
11.4 Register in the ContainmentBackend enum
The dispatch layer in the executor matches on ContainmentBackend to route calls. Add a
variant for the new backend along with a dispatch arm that invokes the trait method via
dispatch_state_aware. The trait impl declares both ID_PREFIX and BACKEND_KEY (§5);
ID_PREFIX is the routing key for non-provision calls (so pick a short distinct tag
and treat it as permanent — persisted ids carry it), and BACKEND_KEY is the
wire-format containment value used for provision-phase routing and checked
binding. Extend the neutral operation and its mechanical binding helper for the
new backend, then use that helper in both engine dispatch paths. Compile-time errors catch
capability mismatches automatically (§9.4).
11.5 Add a config-parser case
State-aware exact contracts use permanent top-level backend sections for
backends that declare per-phase configs. Add the new shape to the exact
development contract and adapt it directly into the phase-specific runtime
config and StateAwareOperation. Regenerate the registered exact schema and
TypeScript artifacts. Preserve configuration presence through normalization and
binding; leave defaults and semantic checks in the backend.
11.6 Document the backend
A per-backend document at docs/<backend-or-feature>/<plan-name>.md is required (e.g.,
docs/isolation-session/state-aware-plan.md for IsolationSession's state-aware support
— mirroring the directory pattern used elsewhere in MXC docs). It must cover:
- Per-phase config shapes. The fields of each
*Configinterface, with allowed values and defaults. - Per-phase metadata shapes. The fields of each
*Metadatainterface returned by the backend (any subset of provision, start, stop, deprovision). Phases that return no metadata are omitted from the bundle. - Cross-cutting policy honor matrix. For each cross-cutting field (
filesystem,network,ui), which phases the backend applies, rejects, or ignores it at. Per §10.3. - Mode-specific fields. For backends participating in both ephemeral and state-aware modes: which fields are valid in each mode. Fields whose only sensible state-aware value is fixed should be hardcoded inside the state-aware implementation rather than exposed in the config.
- Idempotence behaviour per phase. Whether double-stop returns success or
already_stopped; whether double-provision creates a new resource or reuses one; what happens on deprovision-while-running. - Concurrency story. Whether multiple
execcalls against the samesandboxIdmay run simultaneously, or are serialised by the backend's underlying API. - Error mapping table. Which native errors from the backend's underlying API map to
which MXC error codes (§8). The catch-all
backend_erroris acceptable when no specific code fits, but the table should still describe whatdetailscontains in that case.
11.7 Add tests
Two categories:
- Feature-unavailable test (CI-runnable). The backend is exercised on a machine
without its runtime dependency (no service, no daemon, no kernel feature). The
expected result is a clean
backend_unavailableerror rather than a panic or hang. - Integration test on real infrastructure. The full lifecycle (provision, start, exec, stop, deprovision) plus a few exec variants. May be runner-script-driven and manually triggered if CI cannot reach the required infrastructure.
11.8 Update .github/copilot-instructions.md
Per the existing MXC contribution process, the central reference list of backends and key docs is updated for any backend addition or significant change.
12. Failure semantics
State-aware calls can fail at any phase. MXC does not impose a recovery mechanism; recovery is the caller's responsibility. This section describes the typical sandbox state after each phase fails, along with common recovery patterns.
12.1 Post-failure sandbox state by phase
| Phase failure | Sandbox state | Typical caller action |
|---|---|---|
provision fails | No sandboxId was returned | Retry, or surface the failure |
start fails | Sandbox is provisioned but not running | deprovision to clean up, or retry start |
exec fails | Sandbox is running (the failure occurred during exec, not before) | Retry exec, or proceed to stop / deprovision |
stop fails | Ambiguous: sandbox may be stopped, may still be running | Retry stop, or deprovision and accept potential resource leak from the backend's view |
deprovision fails | Ambiguous: resource may still exist, may have been cleaned up | Treat as best-effort; the next call against the sandboxId will surface stale_id if the resource is gone |
The "ambiguous" entries are a consequence of MXC's stateless conduit model: MXC does not
track the sandbox's last-known state, so after a failure the caller and the backend may
disagree on what state the resource is in. A subsequent call resolves the ambiguity by
surfacing either success or stale_id.
12.2 Mid-call SDK process death
If the SDK consumer's process dies while a state-aware call is in flight, the executor subprocess may still be running, and the backend's view of the resource depends on whether the underlying API call completed before the process died. The sandbox state is indeterminate.
Recovery uses the persisted sandboxId: on consumer restart, an attempt to
deprovision either succeeds (cleanup completes) or returns stale_id (resource
already gone). Either outcome leaves the caller in a known state. This pattern relies
on the consumer having persisted the sandboxId before the in-flight call began.
If provision itself dies mid-call, the sandboxId never reached the caller. Any
resource that was created is orphaned from the caller's perspective. Some backends
offer auto-reap policies tied to caller-process lifetime that can clean up such orphans
for ephemeral use; for state-aware use, where lifetimes are explicit and indefinite, an
operator-side cleanup tool (out of MXC's scope) is the explicit catch.
12.3 Best-effort recovery, not guaranteed
These patterns are best-effort, not transactional guarantees. The proposal does not introduce two-phase commit, distributed locks, or other heavyweight recovery primitives in MXC. Each backend's plan doc (§11.6) carries its specific recovery semantics.
13. Graduation path
The state-aware API surface (the five lifecycle phases, the wire-format envelope, the
error envelope, the trait) is stable from 0.6.0 onwards — it is not gated by an
--experimental flag. The only graduation axis is per-backend: whether a given
backend's state-aware participation, per-stage config shapes, and error mappings are
stable enough to rely on. A backend whose state-aware participation is still
experimental requires experimental: true on every state-aware call, just as one-shot
calls against experimental backends do today.
13.1 Wire-format placement rule
Per-stage config for backend X uses top-level <backend>.<phase> in both
development and published exact contracts. Runtime experimental authorization
is independent of JSON placement.
The phase-as-discriminator rule from §7.1 continues to apply post-graduation, just at
the top level: top-level <backend>: { ... } carries one-shot config when the call has
no phase, and top-level <backend>: { provision: {...}, start: {...}, ... } carries
per-phase configs when the call has phase. The two shapes do not coexist in a single
call.
13.2 Worked scenarios
A backend's ephemeral and state-aware paths graduate independently. The same backend can have a stable ephemeral path and an experimental state-aware path simultaneously, or vice versa — they are separate graduation events.
Backend's ephemeral path graduates; state-aware path stays experimental. Both surfaces retain their permanent JSON locations. Runtime authorization is removed only from the graduated surface.
Backend's state-aware path graduates. Per-stage config remains under
top-level <backend>.<phase>. The
experimental: true SDK option is not required for that backend's state-aware
calls, and the executor CLI accepts them without --experimental. For example, a
provision call against IsolationSession uses this shape:
{
"version": "0.9.0-alpha",
"phase": "provision",
"containment": "isolation_session",
"network": {
"egress": { "default": "allow" },
"ingress": { "default": "allow", "hostLoopback": "allow" }
},
"isolationSession": {
"provision": {
"appId": "PFN:Contoso.App_8wekyb3d8bbwe"
}
}
}
13.3 Versioning
Each backend's graduation event (ephemeral, state-aware, or both at once) triggers a
schema version bump in docs/versioning.md, following the existing MXC convention for
graduating features. The version bump and the associated SDK type changes (such as
dropping experimental: true requirements for graduated containment values) ship as a
single release.
14. Out of scope for v1
The following items are explicitly deferred. Each has a brief rationale and a likely path forward.
- Detached or long-running execs. A model where
execreturns a process id and the spawned process outlives the SDK call. The JS-async fire-and-forget pattern (don'tawaitexecInSandboxAsync) IS supported via the existing functions — the spawned process is tethered to the SDK consumer's lifetime, but the caller can move on without awaiting. True OS-level detachment (process owned by the OS service, independent of any caller) needs a different SDK contract (e.g., a futureexecInSandboxDetachedreturning a process id, no waiting for exit). Deferred to a later version with that dedicated function. - Additional lifecycle stages (snapshot, suspend, attach, restore). Backends with native support can expose them privately under their permanent backend section until universalisation.
- Cross-machine
SandboxIdportability. Ids are opaque, but their interpretation is backend-local in v1. A portable format with explicit scope tags is separate work. - Container-wide timeouts enforced by MXC. Tracking elapsed time across calls would require state. Backends impose their own timeout semantics through their underlying APIs.
- Per-backend metadata for
exec. Provision, start, stop, and deprovision return per-phase typed*Result<C>with optional metadata (§6, §7). Exec does not — adding metadata to a live-streaming response requires an out-of-band channel (sidechannel file descriptor, sentinel-marked envelope appended after the script's stdout, or switching to fully buffered, which loses live-streaming). Defer until a backend has a concrete need.