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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions sdk/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ capability names are reserved and must not be added directly to

For long-lived sandboxes where you provision once, exec many times, and tear down at the end (e.g. agentic loops), use the state-aware lifecycle.

> **Backend support:** the state-aware lifecycle is currently implemented for `isolation_session` and `windows_sandbox` (both Windows-only; both still experimental, so every call must pass `{ experimental: true }`). The one-shot spawn APIs (`spawnSandbox` / `spawnSandboxFromConfig`) are the supported path for every other backend.
> **Backend support:** the state-aware lifecycle is currently implemented for `isolation_session`, `windows_sandbox`, and `wslc` (all Windows-only; all still experimental, so every call must pass `{ experimental: true }`). The one-shot spawn APIs (`spawnSandbox` / `spawnSandboxFromConfig`) are the supported path for every other backend.

```typescript
import {
Expand Down Expand Up @@ -265,6 +265,8 @@ await deprovisionSandbox(sandboxId, undefined, opts);

`windows_sandbox` follows the same shape (substitute the containment string and provide `filesystem.readwritePaths` / `readonlyPaths` at provision if needed). See [`docs/windows-sandbox/windows-sandbox.md`](https://github.com/microsoft/mxc/blob/main/docs/windows-sandbox/windows-sandbox.md) for the per-phase config matrix.

`wslc` follows the same shape and needs no provision config at all (it defaults to an `alpine:latest` container with no network). Provide `filesystem.readwritePaths` / `readonlyPaths` (mounted for the sandbox's lifetime), `network.defaultPolicy: 'allow'` (a bridged container; the default `'block'` gives no network), and/or a backend-specific `image` / `imageTarPath` at provision; inject a cooperative `network.proxy: { url }` per-exec. WSLc state-aware requests default to schema `0.8.0-alpha`. See [`docs/wsl/wslc-state-aware.md`](https://github.com/microsoft/mxc/blob/main/docs/wsl/wslc-state-aware.md) for the per-phase config matrix.

**Handling failures.** Every lifecycle call rejects with a typed `MxcError`. Branch on `code` first; when the failure came from an underlying platform API, the error also carries discrete diagnostic fields rather than a prose blob:

```typescript
Expand Down Expand Up @@ -397,10 +399,10 @@ spawnSandboxFromConfig(config, options?, workingDirectory?, env?) β†’ IPty | Chi
spawnSandbox(script, policy, options?, workingDirectory?, containerName?, env?) β†’ IPty
spawnSandboxAsync(script, policy, ...) β†’ Promise<{ stdout, stderr, exitCode }>

// State-aware lifecycle (currently `isolation_session` and `windows_sandbox` β€” both Windows-only)
// State-aware lifecycle (currently `isolation_session`, `windows_sandbox`, and `wslc` β€” all Windows-only)
// `config` on provisionSandbox is required for backends whose provision config
// has a required member (isolation_session: the network acknowledgment) and
// optional otherwise (windows_sandbox).
// optional otherwise (windows_sandbox, wslc).
provisionSandbox(containment, config, options?) β†’ Promise<ProvisionResult>
startSandbox(sandboxId, config?, options?) β†’ Promise<StartResult>
execInSandbox(sandboxId, config, options?) β†’ IPty // streaming
Expand Down
5 changes: 5 additions & 0 deletions sdk/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ export {
WindowsSandboxExecConfig,
WindowsSandboxStopConfig,
WindowsSandboxDeprovisionConfig,
WslcProvisionConfig,
WslcStartConfig,
WslcExecConfig,
WslcStopConfig,
WslcDeprovisionConfig,
ConfigsForBackend,
ProvisionConfigFor,
StartConfigFor,
Expand Down
45 changes: 39 additions & 6 deletions sdk/node/src/state-aware-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ import { Phase, StateAwareContainmentBackend } from './state-aware-types.js';

export const STATE_AWARE_VERSION = '0.6.0-alpha';

// WSLc's state-aware surface shipped at a later schema version than the
// `STATE_AWARE_VERSION` default above (the shared default for IsolationSession
// and Windows Sandbox). WSLc is intentionally NOT gate-locked to it: the
// backends were promoted independently, so WSLc carries its own later default.
// See `DEFAULT_STATE_AWARE_VERSION`.
export const WSLC_STATE_AWARE_VERSION = '0.8.0-alpha';

// Wire-format cross-cutting fields that live at the envelope's top level.
// Anything else on a per-(backend, phase) Config is backend-specific and is
// nested under `experimental.<backend>.<phase>`.
Expand All @@ -21,14 +28,39 @@ export const CROSS_CUTTING_FIELDS = ['filesystem', 'network', 'ui', 'process'] a
// declares its own `<BACKEND>_ID_PREFIX` const here.
export const ISOLATION_SESSION_ID_PREFIX = 'iso';
export const WINDOWS_SANDBOX_ID_PREFIX = 'wsb';
export const WSLC_ID_PREFIX = 'wslc';

// Per-backend default schema version stamped onto an envelope when the caller
// supplies none. Each backend's state-aware surface was promoted at its own
// schema version, so the default is backend-specific rather than a single
// global constant.
const DEFAULT_STATE_AWARE_VERSION: Record<StateAwareContainmentBackend, string> = {
isolation_session: STATE_AWARE_VERSION,
windows_sandbox: STATE_AWARE_VERSION,
wslc: WSLC_STATE_AWARE_VERSION,
};

// Mapping from a sandboxId's leading prefix segment to the wire-format
// backend key. Extended as more state-aware backends opt in.
export const PREFIX_TO_BACKEND: Record<string, StateAwareContainmentBackend> = {
[ISOLATION_SESSION_ID_PREFIX]: 'isolation_session',
[WINDOWS_SANDBOX_ID_PREFIX]: 'windows_sandbox',
// Exhaustive backend→prefix map. Typed `Record<StateAwareContainmentBackend,
// string>` so adding a backend to the union without registering a prefix here
// is a compile error β€” the same exhaustiveness guarantee the config, metadata,
// and default-version registries carry. Without it a new backend would compile
// with no prefix and fail every non-provision call at runtime with
// `malformed_id`.
export const BACKEND_TO_PREFIX: Record<StateAwareContainmentBackend, string> = {
isolation_session: ISOLATION_SESSION_ID_PREFIX,
windows_sandbox: WINDOWS_SANDBOX_ID_PREFIX,
wslc: WSLC_ID_PREFIX,
};

// Reverse lookup (prefix β†’ backend), derived from the exhaustive map above so
// the two can never drift. Used to route a sandboxId's leading prefix segment
// to its wire-format backend key.
export const PREFIX_TO_BACKEND: Record<string, StateAwareContainmentBackend> = Object.fromEntries(
(Object.entries(BACKEND_TO_PREFIX) as [StateAwareContainmentBackend, string][]).map(
([backend, prefix]) => [prefix, backend],
),
);

/**
* Resolves the wire-format backend key for a sandbox id by reading its
* leading prefix segment. Throws an `MxcError` with `code: 'malformed_id'`
Expand Down Expand Up @@ -67,7 +99,8 @@ export function buildStateAwareEnvelope(args: BuildEnvelopeArgs): Record<string,
// Copy of config; fields are removed as they are lifted into the envelope.
// Anything left becomes experimental.<backend>.<phase>.
const backendSpecific: Record<string, unknown> = { ...(config ?? {}) };
const version = (typeof backendSpecific.version === 'string' && backendSpecific.version) || STATE_AWARE_VERSION;
const defaultVersion = DEFAULT_STATE_AWARE_VERSION[backendKey] ?? STATE_AWARE_VERSION;
const version = (typeof backendSpecific.version === 'string' && backendSpecific.version) || defaultVersion;
delete backendSpecific.version;

const envelope: Record<string, unknown> = { version, phase };
Expand Down
91 changes: 88 additions & 3 deletions sdk/node/src/state-aware-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import {
ContainmentBackend,
FilesystemConfig,
NetworkConfig,
ProcessConfig,
} from './types.js';

Expand All @@ -18,7 +19,7 @@ export type Phase = 'provision' | 'start' | 'exec' | 'stop' | 'deprovision';
*/
export type StateAwareContainmentBackend = Extract<
ContainmentBackend,
'isolation_session' | 'windows_sandbox'
'isolation_session' | 'windows_sandbox' | 'wslc'
>;

/**
Expand Down Expand Up @@ -119,8 +120,8 @@ export interface WindowsSandboxProvisionConfig {
* sandbox. `readwritePaths` / `readonlyPaths` are mapped into the guest at
* the same absolute host path; `deniedPaths` name HOST paths the contained
* code must not reach. The SDK forwards this policy as-is; the backend
* enforces it at provision and rejects a `deniedPath` equal to or nested
* within a mapped share (`.wsb` has no Deny primitive).
* enforces it at provision and rejects a `deniedPaths` entry equal to or
* nested within a mapped share (`.wsb` has no Deny primitive).
*/
filesystem?: FilesystemConfig;
}
Expand All @@ -146,6 +147,79 @@ export interface WindowsSandboxDeprovisionConfig {
version?: string;
}

// WSLc per-(backend, phase) Configs. WSLc runs each sandbox as a warm
// container behind a persistent host-side daemon (one amortized WSL session
// shared across sandboxes). Filesystem mounts and network mode are applied at
// provision and frozen for the sandbox's lifetime; a cooperative env-var proxy
// may be injected per-exec.

export interface WslcProvisionConfig {
Comment thread
SohamDas2021 marked this conversation as resolved.
/** Schema version (semver). When omitted, the SDK fills in `0.8.0-alpha`. */
version?: string;
/**
* Filesystem policy applied at provision and frozen for the life of the
* sandbox. `readwritePaths` / `readonlyPaths` become container volume mounts
* at the same absolute host path. The backend runs the same object-identity
* normalization + delegation gate as the one-shot runner and rejects a
* `deniedPaths` entry equal to or nested within a mounted share (WSLc has no
* Deny mount primitive) with `code: 'policy_validation'`.
*/
filesystem?: FilesystemConfig;
/**
* Network mode applied at provision and frozen thereafter. Only
* `defaultPolicy` is honored: `'allow'` provisions a bridged container,
* `'block'` (the default when omitted) provisions with no network. Per-host
* filtering (`allowedHosts` / `blockedHosts`) and a `proxy` are rejected at
* provision (`code: 'policy_validation'`) β€” WSLc has no in-kernel iptables,
* and the cooperative proxy is an exec-phase concern (see
* {@link WslcExecConfig.network}).
*/
network?: NetworkConfig;
/**
* Container image reference (e.g. `alpine:latest`). Defaults to
* `alpine:latest` when omitted. Nested under
* `experimental.wslc.provision.image` on the wire.
*/
image?: string;
/**
* Path to a local image tarball to import instead of pulling. Nested under
* `experimental.wslc.provision.imageTarPath` on the wire.
*/
imageTarPath?: string;
}

export interface WslcStartConfig {
/** Schema version (semver). */
version?: string;
}

export interface WslcExecConfig {
/** Schema version (semver). */
version?: string;
process: ProcessConfig;
/**
* Per-exec network overrides. Only `proxy` is honored: it injects a
* cooperative `HTTP_PROXY` / `HTTPS_PROXY` into the command's environment
* (well-behaved HTTP clients honor it; raw-socket clients can bypass it).
* WSLc accepts only the `{ url }` proxy form β€” its containers run in their
* own network namespace, so the `localhost` / `builtinTestServer` loopback
* forms are unreachable and rejected. Every other network field β€” host
* filters, a `defaultPolicy` change, and `allowLocalNetwork` β€” is rejected
* with `code: 'policy_validation'` (network mode is fixed at provision).
*/
network?: NetworkConfig;
}

export interface WslcStopConfig {
/** Schema version (semver). */
version?: string;
}

export interface WslcDeprovisionConfig {
/** Schema version (semver). */
version?: string;
}

/**
* The five per-phase Config slots every state-aware backend must declare.
* `object` (not `Record<string, unknown>`) is the slot base: interfaces have
Expand Down Expand Up @@ -184,6 +258,13 @@ type StateAwareConfigRegistry = DefineStateAwareConfigRegistry<{
stop: WindowsSandboxStopConfig;
deprovision: WindowsSandboxDeprovisionConfig;
};
wslc: {
provision: WslcProvisionConfig;
start: WslcStartConfig;
exec: WslcExecConfig;
stop: WslcStopConfig;
deprovision: WslcDeprovisionConfig;
};
}>;

/** Compile-time guard: catches a backend with no registry entry. */
Expand Down Expand Up @@ -274,6 +355,10 @@ export type StateAwareMetadata = DefineStateAwareMetadataRegistry<{
// checks for `C = 'windows_sandbox'`. `Record<never, never>` has `keyof =
// never`, so every `*MetadataFor<'windows_sandbox'>` resolves to `undefined`.
windows_sandbox: Record<never, never>;
// WSLc returns no metadata for any phase (provision yields only the sandbox
// id). `Record<never, never>` has `keyof = never`, so every
// `*MetadataFor<'wslc'>` resolves to `undefined`.
wslc: Record<never, never>;
// Future state-aware-capable backends add typed entries here.
}>;

Expand Down
6 changes: 6 additions & 0 deletions sdk/node/tests/integration/test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,12 @@ export async function probeStateAwareRuntime<C extends StateAwareContainmentBack
});
return result.sandboxId;
}
case 'wslc': {
const result = await provisionSandbox('wslc', undefined, {
experimental: true,
});
return result.sandboxId;
}
default: {
const unhandled: never = backend;
throw new Error(`probeStateAwareRuntime: unhandled backend ${String(unhandled)}`);
Expand Down
112 changes: 112 additions & 0 deletions sdk/node/tests/unit/state-aware-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import {
StopConfigFor,
WindowsSandboxProvisionConfig,
WindowsSandboxStartConfig,
WslcProvisionConfig,
WslcStartConfig,
WslcExecConfig,
WslcStopConfig,
WslcDeprovisionConfig,
} from '../../src/state-aware-types.js';
import { backendForSandboxId } from '../../src/state-aware-helper.js';

Expand Down Expand Up @@ -364,3 +369,110 @@ describe('ProvisionResult<C>', () => {
assert.strictEqual(result.metadata?.ephemeralWorkspacePath, 'C:\\ProgramData\\ws');
});
});

describe('WslcProvisionConfig', () => {
it('accepts version, filesystem, network, and the backend-specific image knobs', () => {
const cfg: WslcProvisionConfig = {
version: '0.8.0-alpha',
filesystem: { readwritePaths: ['C:\\ws\\rw'], readonlyPaths: ['C:\\ws\\ro'] },
network: { defaultPolicy: 'allow' },
image: 'alpine:latest',
imageTarPath: 'C:\\images\\alpine.tar',
};
assert.strictEqual(cfg.image, 'alpine:latest');
assert.strictEqual(cfg.imageTarPath, 'C:\\images\\alpine.tar');
assert.strictEqual(cfg.filesystem?.readwritePaths?.[0], 'C:\\ws\\rw');
});

it('is entirely optional (every member optional)', () => {
const empty: WslcProvisionConfig = {};
assert.ok(empty);
});

it('rejects an undeclared backend-specific field', () => {
const cfg: WslcProvisionConfig = {
// @ts-expect-error β€” wslc provision declares no such field.
unsupportedSetting: { nested: true },
};
assert.ok(cfg);
});

it('rejects ui at provision', () => {
const cfg: WslcProvisionConfig = {
// @ts-expect-error β€” ui is not exposed on the wslc provision config.
ui: { disable: true, clipboard: 'none', injection: false },
};
assert.ok(cfg);
});
});

describe('WslcStartConfig / WslcStopConfig / WslcDeprovisionConfig', () => {
it('carry only version', () => {
const start: WslcStartConfig = { version: '0.8.0-alpha' };
const stop: WslcStopConfig = {};
const deprov: WslcDeprovisionConfig = {};
assert.strictEqual(start.version, '0.8.0-alpha');
assert.ok(stop);
assert.ok(deprov);

const wrongStart: WslcStartConfig = {
// @ts-expect-error β€” start accepts no backend-specific config.
image: 'alpine:latest',
};
assert.ok(wrongStart);
});
});

describe('WslcExecConfig', () => {
it('requires process and accepts an optional cooperative proxy', () => {
const cfg: WslcExecConfig = {
process: { commandLine: 'echo hi' },
network: { proxy: { url: 'http://127.0.0.1:8888' } },
};
assert.strictEqual(cfg.process.commandLine, 'echo hi');

// @ts-expect-error β€” exec config requires process.
const missing: WslcExecConfig = { network: { proxy: { url: 'http://127.0.0.1:8888' } } };
assert.ok(missing);
});
});

describe('Wslc metadata resolves to undefined for every phase', () => {
it('ProvisionResult carries no metadata and the id brands distinctly', () => {
const provMeta: ProvisionMetadataFor<'wslc'> = undefined;
const startMeta: StartMetadataFor<'wslc'> = undefined;
assert.strictEqual(provMeta, undefined);
assert.strictEqual(startMeta, undefined);

const result: ProvisionResult<'wslc'> = {
sandboxId: 'wslc:abcd' as SandboxId<'wslc'>,
};
assert.strictEqual(result.metadata, undefined);

function takesWslcId(_id: SandboxId<'wslc'>): void {
// body unused
}
// @ts-expect-error β€” an isolation_session id is not a wslc id.
takesWslcId('iso:abcd' as SandboxId<'isolation_session'>);
assert.ok(true);
});

it('routes a wslc: id to the wslc backend by prefix', () => {
const id = 'wslc:0123abcd' as SandboxId<'wslc'>;
assert.strictEqual(backendForSandboxId(id), 'wslc');
});
});

describe('ConfigsForBackend selects the wslc bundle', () => {
it('selects the Wslc bundle for the wslc backend', () => {
const bundle: ConfigsForBackend<'wslc'> = {
provision: { image: 'alpine:latest' },
start: {},
exec: { process: { commandLine: 'echo' } },
stop: {},
deprovision: {},
};
assert.strictEqual(bundle.provision.image, 'alpine:latest');
assert.strictEqual(bundle.exec.process.commandLine, 'echo');
});
});
Loading
Loading