From a2073a3674d5afa98a429a8dca28ccfe870972d4 Mon Sep 17 00:00:00 2001 From: Gudge Date: Tue, 6 Oct 2026 13:41:21 -0700 Subject: [PATCH] Complete V1.Dev exact-JSON SDK APIs This PR adds caller-authored exact-JSON execution and lifecycle APIs in the Rust, .NET, and Node SDKs. Requests retain their declared contract version while authorization and operation selection remain separate. Captured operations return partial output on timeout instead of waiting for inherited output pipes to close. Details * Publish capture, pipe, PTY, lifecycle, and validation entry points with native exact-contract routing and versioned SDK references. * Close timed-out .NET capture readers; settle Node captures without waiting for native stream close and reject read failures during process wait. * Preserve the original capture error if disposal fails; route the historical appcontainer PTY alias and accept deep annotations in .NET requests. * Keep .NET Dev options sealed and independent with reflection-free JSON and Native AOT smoke coverage. * Document and test exit-only timeout reporting for executor-backed PTYs. Tests * From src: cargo fmt --all -- --check; cargo clippy --workspace --all-targets -- -D warnings; cargo test -p mxc-sdk --lib dev::; cargo test -p mxc-sdk --test state_aware (passed). * From sdk\node: npm run typecheck --silent (passed); npm test --silent (458 passed, 21 skipped), including pending-read capture coverage. * .NET AOT smoke ran reflection-free; Native AOT publish and run passed; DevEntryPointTests and V1ApiSurfaceTests passed (12 total). Host-dependent IsolationSession workloads were skipped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f136b563-7d18-4c2e-8f86-43af66889e6a Generated-with: gpt-6-sol --- docs/api-reference/README.md | 10 + docs/api-reference/dotnet/v1/README.md | 1 + docs/api-reference/dotnet/v1/api.md | 6 +- docs/api-reference/dotnet/v1/dev.md | 67 +++ docs/api-reference/node/v1/README.md | 1 + docs/api-reference/node/v1/api.md | 10 +- docs/api-reference/node/v1/dev.md | 54 +++ docs/api-reference/rust/v1/README.md | 1 + docs/api-reference/rust/v1/api.md | 3 +- docs/api-reference/rust/v1/dev.md | 51 +++ docs/development/architecture/versioning.md | 5 + .../Microsoft.Mxc.Sdk.AotSmokeTest/Program.cs | 8 + .../DevEntryPointTests.cs | 130 ++++++ .../V1/MxcLifecycleE2ETests.cs | 25 ++ .../V1/V1ApiSurfaceTests.cs | 11 + .../Microsoft.Mxc.Sdk/MxcJsonSerialization.cs | 3 + .../V1/Dev/DevJsonRequest.cs | 93 +++++ .../Microsoft.Mxc.Sdk/V1/Dev/JsonOptions.cs | 21 + .../Microsoft.Mxc.Sdk/V1/Dev/MxcContainer.cs | 133 ++++++ .../Microsoft.Mxc.Sdk/V1/Dev/MxcLifecycle.cs | 202 +++++++++ sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcProcess.cs | 19 + sdk/dotnet/README.md | 11 + sdk/node/README.md | 12 + sdk/node/package.json | 10 +- .../src/bindings/process-container-pty.ts | 26 +- sdk/node/src/bindings/pty.ts | 46 +++ sdk/node/src/bindings/run.ts | 39 +- sdk/node/src/bindings/state-aware.ts | 6 +- sdk/node/src/bindings/streaming.ts | 40 +- sdk/node/src/v1/capture.ts | 72 ++++ sdk/node/src/v1/dev/index.ts | 283 +++++++++++++ sdk/node/src/v1/lifecycle.ts | 22 +- sdk/node/tests/integration/dev-api.ts | 23 ++ sdk/node/tests/integration/dev.test.ts | 23 ++ .../tests/unit/process-container-pty.test.ts | 100 +++++ .../tests/unit/state-aware-binding.test.ts | 10 +- sdk/node/tests/unit/v1-dev.test.ts | 388 ++++++++++++++++++ src/mxc-sdk/README.md | 8 + src/mxc-sdk/src/dev.rs | 33 ++ src/mxc-sdk/src/dev/one_shot.rs | 102 +++++ src/mxc-sdk/src/dev/state_aware.rs | 252 ++++++++++++ src/mxc-sdk/src/lib.rs | 6 + src/mxc-sdk/tests/state_aware.rs | 38 +- 43 files changed, 2358 insertions(+), 46 deletions(-) create mode 100644 docs/api-reference/dotnet/v1/dev.md create mode 100644 docs/api-reference/node/v1/dev.md create mode 100644 docs/api-reference/rust/v1/dev.md create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk.Tests/DevEntryPointTests.cs create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/DevJsonRequest.cs create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/JsonOptions.cs create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/MxcContainer.cs create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/MxcLifecycle.cs create mode 100644 sdk/node/src/v1/capture.ts create mode 100644 sdk/node/src/v1/dev/index.ts create mode 100644 sdk/node/tests/integration/dev-api.ts create mode 100644 sdk/node/tests/integration/dev.test.ts create mode 100644 sdk/node/tests/unit/v1-dev.test.ts create mode 100644 src/mxc-sdk/src/dev.rs create mode 100644 src/mxc-sdk/src/dev/one_shot.rs create mode 100644 src/mxc-sdk/src/dev/state_aware.rs diff --git a/docs/api-reference/README.md b/docs/api-reference/README.md index 4638c6171..5e89d8565 100644 --- a/docs/api-reference/README.md +++ b/docs/api-reference/README.md @@ -15,6 +15,16 @@ See the [consumer glossary](../glossary.md) for terminology. | .NET | [V1](dotnet/v1/README.md) | `Microsoft.Mxc.Sdk.V1` | | Node | [V1](node/v1/README.md) | `@microsoft/mxc-sdk/v1` | +V1.Dev exact-JSON entry points are listed in [Rust](rust/v1/dev.md), +[.NET](dotnet/v1/dev.md), and [Node](node/v1/dev.md). Each accepts a complete +caller-authored exact-version document. Use V1.Dev when the typed V1 API +cannot express the request, such as a supported historical contract or +fields in the mutable development contract. Prefer typed V1 for published +functionality; development-contract fields and experimental backends are +for development and testing, not production dependencies. The selected +contract must be registered in the MXC build, and experimental backend +authorization is an independent invocation option. + ## Choosing an operation | Task | Input | Result | diff --git a/docs/api-reference/dotnet/v1/README.md b/docs/api-reference/dotnet/v1/README.md index fbc2df535..571a09d28 100644 --- a/docs/api-reference/dotnet/v1/README.md +++ b/docs/api-reference/dotnet/v1/README.md @@ -7,6 +7,7 @@ Filesystem discovery helpers use `Microsoft.Mxc.Sdk.V1.Policy.Filesystem`. Discovery, probes, telemetry, and policy helpers use the same versioned boundary. - [Operation signatures](api.md) +- [V1.Dev exact-JSON API](dev.md) - [Types and fields](types.md) - [Cross-SDK requirements](../../README.md) diff --git a/docs/api-reference/dotnet/v1/api.md b/docs/api-reference/dotnet/v1/api.md index 101f01382..6f9283259 100644 --- a/docs/api-reference/dotnet/v1/api.md +++ b/docs/api-reference/dotnet/v1/api.md @@ -22,7 +22,11 @@ LXC, and Seatbelt direct execution. Existing-container PTY support remains IsolationSession-only. Seatbelt PTY rejects `guiAccess` and legacy `launchMethod: "open"`. Terminal handles give the caller explicit input, output, resize, and process ownership; there -is no separate public attached-console launch API or API accepting ["MXC request JSON"](../../../schema.md#mxc-request-json). +is no separate public attached-console launch API. Typed V1 does not accept +["MXC request JSON"](../../../schema.md#mxc-request-json); the +[V1.Dev exact-JSON API](dev.md) accepts caller-authored requests separately. +Captured existing-container execution closes its output readers on timeout +and returns the text collected so far with `TimedOut = true`. ## Discovery and validation diff --git a/docs/api-reference/dotnet/v1/dev.md b/docs/api-reference/dotnet/v1/dev.md new file mode 100644 index 000000000..001689bcd --- /dev/null +++ b/docs/api-reference/dotnet/v1/dev.md @@ -0,0 +1,67 @@ +# .NET V1.Dev exact-JSON API + +Public namespace: `Microsoft.Mxc.Sdk.V1.Dev`. All methods accept one complete +caller-authored exact-version JSON string; they do not stamp a version, parse +through typed V1, or take a duplicate container ID. Native exact parsing +rejects unregistered versions and unknown fields. Phase-specific methods +check the JSON phase before calling native code. + +`JsonOptions.Experimental` authorizes an experimental backend independently +of the JSON version. `PtyJsonOptions` is a separate option type for PTY +operations with its own `Experimental` and optional `MxcPtySize? Size` +(24 rows by 80 columns by default). Non-PTY operations accept only +`JsonOptions`. Return types are the existing V1 execution and process types; +lifecycle and validation results are complete native response JSON strings, +without narrowing through typed V1 metadata. When importing both typed and +dev namespaces, qualify the dev facade (for example, +`using Dev = Microsoft.Mxc.Sdk.V1.Dev;`). + +```csharp +// Microsoft.Mxc.Sdk.V1.Dev.MxcContainer +ExecutionResult RunJson(string json, JsonOptions? options = null); +Task RunJsonAsync(string json, JsonOptions? options = null, + CancellationToken cancellationToken = default); +MxcProcess SpawnJson(string json, JsonOptions? options = null); +Task SpawnJsonAsync(string json, JsonOptions? options = null, + CancellationToken cancellationToken = default); +MxcPtyProcess SpawnWithPtyJson(string json, PtyJsonOptions? options = null); + +// Microsoft.Mxc.Sdk.V1.Dev.MxcLifecycle +ExecutionResult RunInContainerJson(string json, JsonOptions? options = null); +Task RunInContainerJsonAsync(string json, JsonOptions? options = null, + CancellationToken cancellationToken = default); +MxcProcess SpawnInContainerJson(string json, JsonOptions? options = null); +Task SpawnInContainerJsonAsync(string json, JsonOptions? options = null, + CancellationToken cancellationToken = default); +MxcPtyProcess SpawnInContainerWithPtyJson(string json, PtyJsonOptions? options = null); + +string ProvisionContainerJson(string json, JsonOptions? options = null); +Task ProvisionContainerJsonAsync(string json, JsonOptions? options = null); +string StartContainerJson(string json, JsonOptions? options = null); +Task StartContainerJsonAsync(string json, JsonOptions? options = null); +string StopContainerJson(string json, JsonOptions? options = null); +Task StopContainerJsonAsync(string json, JsonOptions? options = null); +string DeprovisionContainerJson(string json, JsonOptions? options = null); +Task DeprovisionContainerJsonAsync(string json, JsonOptions? options = null); + +string ValidateProvisionJson(string json, JsonOptions? options = null); +Task ValidateProvisionJsonAsync(string json, JsonOptions? options = null); +string ValidateStartJson(string json, JsonOptions? options = null); +Task ValidateStartJsonAsync(string json, JsonOptions? options = null); +string ValidateStopJson(string json, JsonOptions? options = null); +Task ValidateStopJsonAsync(string json, JsonOptions? options = null); +string ValidateDeprovisionJson(string json, JsonOptions? options = null); +Task ValidateDeprovisionJsonAsync(string json, JsonOptions? options = null); +string ValidateProcessJson(string json, JsonOptions? options = null); +Task ValidateProcessJsonAsync(string json, JsonOptions? options = null); +``` + +PTY entry points are synchronous only. Only the four async run/spawn methods +take cancellation tokens, matching their existing typed V1 counterparts. +One-shot `RunJsonAsync` cancellation stops awaiting, not native execution; +`RunInContainerJsonAsync` disposes its owned process on cancellation. Async +lifecycle operations run off the caller's thread but have no cancellation +token. Captured output is returned as lossy UTF-8 text; live handles retain +the existing V1 stream and disposal contract. +Timed-out in-container capture closes its readers and returns partial text +without deprovisioning the caller-owned container. diff --git a/docs/api-reference/node/v1/README.md b/docs/api-reference/node/v1/README.md index 0ad11d797..76c6a6412 100644 --- a/docs/api-reference/node/v1/README.md +++ b/docs/api-reference/node/v1/README.md @@ -7,6 +7,7 @@ Filesystem discovery helpers and their result/options types use `policy.filesyst Discovery, probes, telemetry, and policy helpers use the same versioned boundary. - [Operation signatures](api.md) +- [V1.Dev exact-JSON API](dev.md) - [Types and fields](types.md) - [Cross-SDK requirements](../../README.md) diff --git a/docs/api-reference/node/v1/api.md b/docs/api-reference/node/v1/api.md index e60087d77..1c01738de 100644 --- a/docs/api-reference/node/v1/api.md +++ b/docs/api-reference/node/v1/api.md @@ -22,7 +22,15 @@ covers IsolationSession, Bubblewrap, LXC, and Seatbelt direct execution. Existing-container PTY support remains IsolationSession-only. Seatbelt PTY rejects `guiAccess` and legacy `launchMethod: "open"`. Terminal handles give the caller explicit input, output, resize, and process -ownership; there is no separate attached-console launch API or API accepting ["MXC request JSON"](../../../schema.md#mxc-request-json). +ownership; there is no separate attached-console launch API. Typed V1 does +not accept ["MXC request JSON"](../../../schema.md#mxc-request-json); the +[V1.Dev exact-JSON API](dev.md) accepts caller-authored requests separately. +Captured existing-container execution closes its output streams on timeout +and returns the text collected so far with `timedOut: true`. +Output read failures reject capture without waiting for the workload to exit. +For executor-backed ProcessContainer PTY, `timeoutMs` reaches `wxc-exec`, +but its exit channel does not distinguish a timeout from an ordinary exit: +`wait().timedOut` is `false` when the executor exits in either case. ## Discovery and validation diff --git a/docs/api-reference/node/v1/dev.md b/docs/api-reference/node/v1/dev.md new file mode 100644 index 000000000..6934c570f --- /dev/null +++ b/docs/api-reference/node/v1/dev.md @@ -0,0 +1,54 @@ +# Node V1.Dev exact-JSON API + +Public subpath: `@microsoft/mxc-sdk/v1/dev`. All operations accept one +complete caller-authored exact-version JSON string and return Promises. +The SDK never stamps a version or reserializes the document through the V1 +stable mapper. Native exact parsing remains authoritative for unknown fields, +unsupported versions, policy, and backend availability. There is no +package-root alias. + +`JsonOptions` contains optional `experimental?: boolean` authorization, +independent of the authored JSON version. `PtyJsonOptions` additionally +contains optional `size?: MxcPtySize` (24 rows by 80 columns by default). +Execution returns the existing V1 result or process types. Lifecycle and +validation return the complete native response JSON string. Phase-specific +methods reject a different phase before native dispatch. + +```typescript +export function runJson(json: string, options?: JsonOptions): Promise; +export function spawnJson(json: string, options?: JsonOptions): Promise; +export function spawnWithPtyJson(json: string, options?: PtyJsonOptions): Promise; + +export function runInContainerJson(json: string, options?: JsonOptions): Promise; +export function spawnInContainerJson(json: string, options?: JsonOptions): Promise; +export function spawnInContainerWithPtyJson( + json: string, options?: PtyJsonOptions +): Promise; + +export function provisionContainerJson(json: string, options?: JsonOptions): Promise; +export function startContainerJson(json: string, options?: JsonOptions): Promise; +export function stopContainerJson(json: string, options?: JsonOptions): Promise; +export function deprovisionContainerJson(json: string, options?: JsonOptions): Promise; +export function validateProvisionJson(json: string, options?: JsonOptions): Promise; +export function validateStartJson(json: string, options?: JsonOptions): Promise; +export function validateStopJson(json: string, options?: JsonOptions): Promise; +export function validateDeprovisionJson(json: string, options?: JsonOptions): Promise; +export function validateProcessJson(json: string, options?: JsonOptions): Promise; +``` + +Captured stdout and stderr are returned as lossy UTF-8 text. Pipe and PTY +operations resolve with owning V1 handles after startup. Existing-container +execution does not deprovision its caller-owned container. On timeout, +in-container capture closes its output streams and returns the text collected +so far. If capture fails, disposal cannot replace the +original read or wait error; read failures reject without waiting for the +workload to exit. The Node ProcessContainer one-shot PTY route +passes the original UTF-8 JSON through `wxc-exec --config-base64`, including +the historical `0.9.0-alpha` `appcontainer` alias, rather than the stable +typed mapper. Its Promise resolves when the executor starts, before the executor +finishes policy validation; subsequent failures appear in combined terminal +output and its exit code. This route cannot expose structured native warnings +or output metadata through its current terminal channel. It forwards +`process.timeout` to `wxc-exec`, but `wait().timedOut` remains `false` on an +executor exit even if the executor enforced that timeout; the terminal +channel supplies no distinct timeout outcome. diff --git a/docs/api-reference/rust/v1/README.md b/docs/api-reference/rust/v1/README.md index d23bf5fbf..c2aab4909 100644 --- a/docs/api-reference/rust/v1/README.md +++ b/docs/api-reference/rust/v1/README.md @@ -7,6 +7,7 @@ Filesystem discovery helpers and their result/options types use `v1::policy::fil Discovery, probes, telemetry, and policy helpers use the same versioned boundary. - [Operation signatures](api.md) +- [V1.Dev exact-JSON API](dev.md) - [Types and fields](types.md) - [Cross-SDK requirements](../../README.md) diff --git a/docs/api-reference/rust/v1/api.md b/docs/api-reference/rust/v1/api.md index 90b613703..0184365f6 100644 --- a/docs/api-reference/rust/v1/api.md +++ b/docs/api-reference/rust/v1/api.md @@ -6,7 +6,8 @@ Public entrypoint: `mxc_sdk::v1`. [Types](types.md) | [Overview](README.md) Signatures describe the typed consumer API and omit implementation bodies. Callers do not supply JSON or a schema version. -JSON request adapters are not part of the supported V1 API. +The separate [V1.Dev exact-JSON API](dev.md) accepts caller-authored requests +for one-shot execution and state-aware lifecycle operations. Attached execution is not exposed by the V1 SDK. ## Choosing a launch operation diff --git a/docs/api-reference/rust/v1/dev.md b/docs/api-reference/rust/v1/dev.md new file mode 100644 index 000000000..e384c29b1 --- /dev/null +++ b/docs/api-reference/rust/v1/dev.md @@ -0,0 +1,51 @@ +# Rust V1.Dev exact-JSON API + +Public entrypoint: `mxc_sdk::v1::dev`. All methods accept one complete, +caller-authored exact-version JSON document and execute through the +in-process engine. No method stamps a version, reserializes the request, or +takes a second container ID. Invalid versions, unknown fields, and malformed +JSON are rejected by the exact contract parser. + +`JsonOptions { experimental: bool }` authorizes an experimental backend +independently of the authored version. `PtyJsonOptions` also carries +`size: MxcPtySize` (24 rows by 80 columns by default). The full request, +including its version, phase, and any container ID, is a single JSON string. +Result and process types are the existing `mxc_sdk::v1` types. + +`run_json` and `run_in_container_json` return byte-preserving +`ExecutionResult` values; a nonzero workload exit or timeout is an outcome, +not an API error. Pipe and PTY spawns return owned `MxcProcess` and +`MxcPtyProcess` handles. Existing-container execution does not deprovision +the caller-owned container. The phase-specific lifecycle and validation +methods require the matching `phase` in the JSON before dispatch; validation +uses native dry-run behavior. They return the complete native response +envelope as a `String`, including backend-specific metadata. + +```rust +pub fn run_json(json: &str, options: JsonOptions) -> Result; +pub fn spawn_json(json: &str, options: JsonOptions) -> Result; +pub fn spawn_with_pty_json(json: &str, options: PtyJsonOptions) + -> Result; + +pub fn run_in_container_json(json: &str, options: JsonOptions) + -> Result; +pub fn spawn_in_container_json(json: &str, options: JsonOptions) -> Result; +pub fn spawn_in_container_with_pty_json(json: &str, options: PtyJsonOptions) + -> Result; + +pub fn provision_container_json(json: &str, options: JsonOptions) -> Result; +pub fn start_container_json(json: &str, options: JsonOptions) -> Result; +pub fn stop_container_json(json: &str, options: JsonOptions) -> Result; +pub fn deprovision_container_json(json: &str, options: JsonOptions) -> Result; +pub fn validate_provision_json(json: &str, options: JsonOptions) -> Result; +pub fn validate_start_json(json: &str, options: JsonOptions) -> Result; +pub fn validate_stop_json(json: &str, options: JsonOptions) -> Result; +pub fn validate_deprovision_json(json: &str, options: JsonOptions) -> Result; +pub fn validate_process_json(json: &str, options: JsonOptions) -> Result; +``` + +Capture uses pipe-backed execution: a backend without piped execution +rejects this mode with `UnsupportedContainment` even when experimental +access is authorized. PTY and state-aware support also depend on the +selected backend and host. Unsupported modes fail rather than switching to +another I/O mode. diff --git a/docs/development/architecture/versioning.md b/docs/development/architecture/versioning.md index 9948187fd..970416f32 100644 --- a/docs/development/architecture/versioning.md +++ b/docs/development/architecture/versioning.md @@ -71,6 +71,11 @@ take a caller-supplied schema version. Each v1 SDK targets the published exact Raw configuration APIs require the caller to declare a registered `version` to select an immutable published or mutable development contract. +Rust `mxc_sdk::v1::dev`, .NET `Microsoft.Mxc.Sdk.V1.Dev`, and Node +`@microsoft/mxc-sdk/v1/dev` accept complete caller-authored one-shot or +state-aware JSON strings using the native exact-version parsing path. +Experimental backend authorization is supplied separately in invocation +options, not inferred from the document's version. ```typescript // sdk/node/src/types.ts diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.AotSmokeTest/Program.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.AotSmokeTest/Program.cs index 4c8ffeb2a..9dd40f7e5 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.AotSmokeTest/Program.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.AotSmokeTest/Program.cs @@ -15,6 +15,7 @@ using System.Text.Json.Nodes; using Microsoft.Mxc.Sdk; using Microsoft.Mxc.Sdk.V1; +using Dev = Microsoft.Mxc.Sdk.V1.Dev; static void Check(bool condition, string what) { @@ -34,6 +35,13 @@ static void CheckRoundTrip(T value, Func check, string name) } var initialPtySize = new MxcPtySize(40, 120); +CheckRoundTrip(new Dev.JsonOptions { Experimental = true }, + x => x.Experimental, "dev JSON options experimental authorization"); +CheckRoundTrip(new Dev.PtyJsonOptions(), + x => !x.Experimental && x.Size is null, "dev PTY options without initial dimensions"); +CheckRoundTrip(new Dev.PtyJsonOptions { Experimental = true, Size = initialPtySize }, + x => x.Experimental && x.Size == initialPtySize, + "dev PTY options experimental authorization and initial dimensions"); CheckRoundTrip(new TelemetryConfig(), x => x.Enabled is null, "omitted telemetry enabled"); CheckRoundTrip(new TelemetryConfig { Enabled = false }, x => x.Enabled == false, "explicit disabled telemetry"); foreach (var enabled in new bool?[] { null, true, false }) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/DevEntryPointTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/DevEntryPointTests.cs new file mode 100644 index 000000000..3ceeaadb3 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/DevEntryPointTests.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Dev = Microsoft.Mxc.Sdk.V1.Dev; +using Microsoft.Mxc.Sdk.V1; +using Xunit; + +namespace Microsoft.Mxc.Sdk.Tests; + +public class DevEntryPointTests +{ + [Fact] + public async Task DevEntryPointsRejectInvalidDocumentsWithoutLaunching() + { + var synchronous = Assert.Throws( + () => Dev.MxcContainer.SpawnWithPtyJson("{}")); + Assert.Equal(ErrorCode.MalformedRequest, synchronous.Code); + + var asynchronous = await Assert.ThrowsAsync( + () => Dev.MxcLifecycle.ProvisionContainerJsonAsync("{}")); + Assert.Equal(ErrorCode.MalformedRequest, asynchronous.Code); + } + + [Fact] + public async Task PhaseSpecificMethodsRejectMismatchesBeforeDispatch() + { + const string start = """{"version":"1.0.0","phase":"start","sandboxId":"iso:unused"}"""; + var sync = Assert.Throws(() => Dev.MxcLifecycle.StopContainerJson(start)); + Assert.Equal(ErrorCode.MalformedRequest, sync.Code); + Assert.Contains("requires phase 'stop'", sync.Message); + + var asynchronous = await Assert.ThrowsAsync( + () => Dev.MxcLifecycle.ValidateProcessJsonAsync(start)); + Assert.Equal(ErrorCode.MalformedRequest, asynchronous.Code); + + var exec = Assert.Throws( + () => Dev.MxcLifecycle.SpawnInContainerJson(start)); + Assert.Equal(ErrorCode.MalformedRequest, exec.Code); + + const string duplicate = + """{"version":"1.0.0","phase":"start","phase":"stop","sandboxId":"iso:unused"}"""; + var repeated = Assert.Throws( + () => Dev.MxcLifecycle.StartContainerJson(duplicate)); + Assert.Equal(ErrorCode.MalformedRequest, repeated.Code); + } + + [Fact] + public void ExactContractErrorsReachPublicDevFacade() + { + const string unregistered = + """{"version":"999.0.0","process":{"commandLine":"echo must-not-run"}}"""; + var error = Assert.Throws(() => Dev.MxcContainer.RunJson(unregistered)); + Assert.Equal(ErrorCode.MalformedRequest, error.Code); + + const string unknownField = + """{"version":"1.0.0","phase":"start","sandboxId":"iso:unused","extra":true}"""; + error = Assert.Throws( + () => Dev.MxcLifecycle.ValidateStartJson(unknownField)); + Assert.Equal(ErrorCode.MalformedRequest, error.Code); + } + + [Fact] + public void ExperimentalAuthorizationIsSeparateFromTheDevelopmentContract() + { + const string json = + """{"version":"1.1.0-alpha","phase":"provision","containment":"windows_sandbox"}"""; + var error = Assert.Throws( + () => Dev.MxcLifecycle.ValidateProvisionJson(json)); + Assert.Equal(ErrorCode.BackendUnavailable, error.Code); + Assert.Contains("experimental", error.Message); + + try + { + Dev.MxcLifecycle.ValidateProvisionJson(json, new Dev.JsonOptions { Experimental = true }); + } + catch (MxcException authorized) + { + Assert.NotEqual(ErrorCode.BackendUnavailable, authorized.Code); + } + } + + [Fact] + public void EmbeddedNullCannotChangeTheNativeDocument() + { + const string json = """{"version":"1.0.0","process":{"commandLine":"echo"}}""" + "\0"; + var error = Assert.Throws(() => Dev.MxcContainer.RunJson(json)); + Assert.Equal(ErrorCode.MalformedRequest, error.Code); + } + + [Fact] + public void DeepCommentReachesNativeExactParser() + { + static string Request(int depth) => + "{\"version\":\"1.0.0\",\"phase\":\"start\",\"sandboxId\":\"nosuchbackend:abc123\",\"_comment\":" + + new string('[', depth) + "0" + new string(']', depth) + "}"; + + var accepted = Assert.Throws( + () => Dev.MxcLifecycle.ValidateStartJson(Request(70))); + Assert.Equal(ErrorCode.UnsupportedContainment, accepted.Code); + + var tooDeep = Assert.Throws( + () => Dev.MxcLifecycle.ValidateStartJson(Request(130))); + Assert.Equal(ErrorCode.MalformedRequest, tooDeep.Code); + } + + [Fact] + public void ExistingContainerExecReachesNativeBackendValidation() + { + const string json = + """{"version":"1.0.0","phase":"exec","sandboxId":"nosuchbackend:abc123","process":{"commandLine":"echo hi"}}"""; + var error = Assert.Throws( + () => Dev.MxcLifecycle.SpawnInContainerJson(json)); + Assert.Equal(ErrorCode.UnsupportedContainment, error.Code); + } + + [Fact] + public void PtyEntryPointsHaveNoAsyncOverloads() + { + Assert.Null(typeof(Dev.MxcContainer).GetMethod("SpawnWithPtyJsonAsync")); + Assert.Null(typeof(Dev.MxcLifecycle).GetMethod("SpawnInContainerWithPtyJsonAsync")); + } + + [Fact] + public void DevOptionsAreClosedAndTerminalControlsStayWithPtyOperations() + { + Assert.True(typeof(Dev.JsonOptions).IsSealed); + Assert.True(typeof(Dev.PtyJsonOptions).IsSealed); + Assert.False(typeof(Dev.JsonOptions).IsAssignableFrom(typeof(Dev.PtyJsonOptions))); + } +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcLifecycleE2ETests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcLifecycleE2ETests.cs index da2cdc912..bed770cc9 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcLifecycleE2ETests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcLifecycleE2ETests.cs @@ -195,6 +195,31 @@ public void Exec_ReportsConfiguredTimeout() } } + [Fact] + public async Task Exec_CapturedTimeoutClosesDescendantHeldOutput() + { + IsolationSessionHost.Require(); + + var started = ProvisionAndStart(); + using (started.Teardown) + { + var request = new ExecutionRequest( + $"{Cmd} /c echo MXC_TIMEOUT_PARTIAL & start /b {Cmd} /c ping -n 60 127.0.0.1 >nul & ping -n 60 127.0.0.1 >nul") + { + TimeoutMs = 750, + }; + var capture = MxcLifecycle.RunInContainerAsync( + started.Id, request, cancellationToken: TestContext.Current.CancellationToken); + var finished = await Task.WhenAny( + capture, Task.Delay(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)); + Assert.Same(capture, finished); + + var result = await capture; + Assert.True(result.TimedOut); + Assert.Contains("MXC_TIMEOUT_PARTIAL", result.Stdout); + } + } + /// /// Stop and deprovision must be reachable through this binding, and a /// deprovisioned id must not still be usable. diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/V1ApiSurfaceTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/V1ApiSurfaceTests.cs index f3bd147f4..c49f00e8a 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/V1ApiSurfaceTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/V1ApiSurfaceTests.cs @@ -4,6 +4,7 @@ using Microsoft.Mxc.Sdk; using Microsoft.Mxc.Sdk.V1; using Microsoft.Mxc.Sdk.V1.Policy; +using Dev = Microsoft.Mxc.Sdk.V1.Dev; using Xunit; namespace Microsoft.Mxc.Sdk.Tests.V1; @@ -25,13 +26,23 @@ public void RequestAndRuntimeTypesUseTheirV1Namespaces() public void PublicSurfaceUsesContainerRequestWithoutAggregatePolicyTypes() { var exportedTypes = typeof(ContainerRequest).Assembly.GetExportedTypes(); + var devTypes = new[] + { + typeof(Dev.JsonOptions), + typeof(Dev.PtyJsonOptions), + typeof(Dev.MxcContainer), + typeof(Dev.MxcLifecycle), + }; Assert.All( exportedTypes, type => Assert.Equal( type == typeof(Filesystem) || type == typeof(FilesystemPolicyResult) ? "Microsoft.Mxc.Sdk.V1.Policy" + : devTypes.Contains(type) + ? "Microsoft.Mxc.Sdk.V1.Dev" : "Microsoft.Mxc.Sdk.V1", type.Namespace)); + Assert.All(devTypes, type => Assert.Contains(type, exportedTypes)); Assert.Contains(typeof(ContainerRequest), exportedTypes); Assert.Contains(typeof(MxcContainer), exportedTypes); diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcJsonSerialization.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcJsonSerialization.cs index d6eb9acf0..a25972153 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcJsonSerialization.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcJsonSerialization.cs @@ -6,6 +6,7 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using Microsoft.Mxc.Sdk.V1; +using Microsoft.Mxc.Sdk.V1.Dev; namespace Microsoft.Mxc.Sdk; @@ -45,6 +46,8 @@ public CamelCaseJsonStringEnumConverter() [JsonSerializable(typeof(RunOptions))] [JsonSerializable(typeof(SpawnOptions))] [JsonSerializable(typeof(SpawnWithPtyOptions))] +[JsonSerializable(typeof(JsonOptions))] +[JsonSerializable(typeof(PtyJsonOptions))] [JsonSerializable(typeof(ProvisionOptions))] [JsonSerializable(typeof(StartOptions))] [JsonSerializable(typeof(StopOptions))] diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/DevJsonRequest.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/DevJsonRequest.cs new file mode 100644 index 000000000..efbdd980a --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/DevJsonRequest.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using System.Text.Json; + +namespace Microsoft.Mxc.Sdk.V1.Dev; + +internal static class DevJsonRequest +{ + private static readonly Encoding StrictUtf8 = new UTF8Encoding(false, true); + // The phase/timeout inspection must not reject nesting the native JSON parser accepts. + private static readonly JsonDocumentOptions InspectionOptions = new() { MaxDepth = 128 }; + + internal static uint? Inspect(string json, string operation, string? expectedPhase = null) + { + ArgumentNullException.ThrowIfNull(json); + try + { + using var document = JsonDocument.Parse(json, InspectionOptions); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + throw new MxcException(ErrorCode.MalformedRequest, + $"{operation} requires a JSON object"); + } + + JsonElement phase = default; + var phaseCount = 0; + foreach (var property in document.RootElement.EnumerateObject()) + { + if (property.Name != "phase") + { + continue; + } + phase = property.Value; + phaseCount++; + } + if (phaseCount > 1) + { + throw new MxcException(ErrorCode.MalformedRequest, + $"{operation} contains duplicate phase declarations"); + } + if (expectedPhase is null && phaseCount != 0) + { + throw new MxcException(ErrorCode.MalformedRequest, + $"{operation} requires a one-shot document without phase"); + } + if (expectedPhase is not null && + (phaseCount != 1 || phase.ValueKind != JsonValueKind.String || + phase.GetString() != expectedPhase)) + { + throw new MxcException(ErrorCode.MalformedRequest, + $"{operation} requires phase '{expectedPhase}'"); + } + + if (document.RootElement.TryGetProperty("process", out var process) && + process.ValueKind == JsonValueKind.Object && + process.TryGetProperty("timeout", out var timeout) && + timeout.ValueKind == JsonValueKind.Number && + timeout.TryGetUInt32(out var milliseconds)) + { + return milliseconds; + } + return null; + } + catch (JsonException error) + { + throw new MxcException(ErrorCode.MalformedRequest, + $"{operation} received invalid JSON: {error.Message}", error); + } + } + + internal static byte[] ToNullTerminatedUtf8(string json) + { + if (json.Contains('\0')) + { + throw new MxcException(ErrorCode.MalformedRequest, + "exact JSON must not contain an embedded NUL character"); + } + byte[] buffer; + try + { + buffer = StrictUtf8.GetBytes(json); + } + catch (EncoderFallbackException error) + { + throw new MxcException(ErrorCode.MalformedRequest, + "exact JSON contains an invalid UTF-16 sequence", error); + } + Array.Resize(ref buffer, buffer.Length + 1); + return buffer; + } +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/JsonOptions.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/JsonOptions.cs new file mode 100644 index 000000000..0a018b0d5 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/JsonOptions.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Mxc.Sdk.V1.Dev; + +/// Invocation controls for caller-authored exact JSON. +public sealed class JsonOptions +{ + /// Authorize experimental backends independently of the JSON version. + public bool Experimental { get; set; } +} + +/// Invocation controls for caller-authored exact JSON with a PTY. +public sealed class PtyJsonOptions +{ + /// Authorize experimental backends independently of the JSON version. + public bool Experimental { get; set; } + + /// Initial terminal dimensions; defaults to 24 rows by 80 columns. + public MxcPtySize? Size { get; set; } +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/MxcContainer.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/MxcContainer.cs new file mode 100644 index 000000000..efc2d6b68 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/MxcContainer.cs @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.InteropServices; +using Microsoft.Mxc.Sdk; +using Microsoft.Mxc.Sdk.Native; +using NativeSandbox = Microsoft.Mxc.Sdk.Native.MxcSandbox; + +namespace Microsoft.Mxc.Sdk.V1.Dev; + +/// One-shot caller-authored exact-JSON entry points. +public static class MxcContainer +{ + static MxcContainer() => NativeLibraryResolver.Initialize(); + + public static ExecutionResult RunJson(string json, JsonOptions? options = null) + { + DevJsonRequest.Inspect(json, nameof(RunJson)); + var request = DevJsonRequest.ToNullTerminatedUtf8(json); + unsafe + { + fixed (byte* requestPtr = request) + { + MxcRunResult result = default; + var status = NativeMethods.mxc_run_json( + requestPtr, options?.Experimental == true ? 1 : 0, &result); + try + { + if (status != (int)ErrorCode.Success) + { + throw NativeError.ToException(status, result.error, "running exact JSON failed"); + } + + var metadataJson = PtrToString(result.output_metadata_json_utf8); + var warningsJson = PtrToString(result.warnings_json_utf8); + return new ExecutionResult + { + ExitCode = result.exit_code, + TimedOut = result.timed_out != 0, + Stdout = PtrToString(result.stdout_utf8) ?? string.Empty, + Stderr = PtrToString(result.stderr_utf8) ?? string.Empty, + OutputMetadata = string.IsNullOrEmpty(metadataJson) + ? null : MxcJson.Deserialize(metadataJson), + Warnings = string.IsNullOrEmpty(warningsJson) + ? Array.Empty() : MxcJson.Deserialize(warningsJson) + ?? throw new MxcException(ErrorCode.BackendError, + "native runtime returned null warnings"), + }; + } + finally + { + NativeMethods.mxc_run_result_free(&result); + } + } + } + } + + public static Task RunJsonAsync( + string json, + JsonOptions? options = null, + CancellationToken cancellationToken = default) => + Microsoft.Mxc.Sdk.V1.MxcLifecycle.RunBlockingOperationAsync( + () => RunJson(json, options), _ => { }, cancellationToken); + + public static MxcProcess SpawnJson(string json, JsonOptions? options = null) + { + var timeout = DevJsonRequest.Inspect(json, nameof(SpawnJson)); + var request = DevJsonRequest.ToNullTerminatedUtf8(json); + unsafe + { + fixed (byte* requestPtr = request) + { + NativeSandbox* handle = null; + MxcErrorDetail error = default; + var status = NativeMethods.mxc_spawn_json( + requestPtr, options?.Experimental == true ? 1 : 0, &handle, &error); + if (status != (int)ErrorCode.Success) + { + try + { + throw NativeError.ToException(status, error, "spawning exact JSON failed"); + } + finally + { + NativeMethods.mxc_error_detail_free(&error); + } + } + return new MxcProcess(MxcSandboxHandle.FromRaw(handle), timeout); + } + } + } + + public static Task SpawnJsonAsync( + string json, + JsonOptions? options = null, + CancellationToken cancellationToken = default) => + Microsoft.Mxc.Sdk.V1.MxcLifecycle.RunBlockingOperationAsync( + () => SpawnJson(json, options), late => late.Dispose(), cancellationToken); + + public static MxcPtyProcess SpawnWithPtyJson(string json, PtyJsonOptions? options = null) + { + var timeout = DevJsonRequest.Inspect(json, nameof(SpawnWithPtyJson)); + var size = MxcPtySize.ResolveInitial(options?.Size); + size.Validate(nameof(options)); + var request = DevJsonRequest.ToNullTerminatedUtf8(json); + unsafe + { + fixed (byte* requestPtr = request) + { + NativeSandbox* handle = null; + MxcErrorDetail error = default; + var status = NativeMethods.mxc_spawn_pty_json( + requestPtr, options?.Experimental == true ? 1 : 0, + size.Rows, size.Columns, &handle, &error); + if (status != (int)ErrorCode.Success) + { + try + { + throw NativeError.ToException(status, error, "spawning exact JSON PTY failed"); + } + finally + { + NativeMethods.mxc_error_detail_free(&error); + } + } + return new MxcPtyProcess(MxcSandboxHandle.FromRaw(handle), timeout); + } + } + } + + private static unsafe string? PtrToString(byte* pointer) => + pointer is null ? null : Marshal.PtrToStringUTF8((IntPtr)pointer); +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/MxcLifecycle.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/MxcLifecycle.cs new file mode 100644 index 000000000..83311ebe3 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/MxcLifecycle.cs @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Mxc.Sdk; +using Microsoft.Mxc.Sdk.Native; +using NativeSandbox = Microsoft.Mxc.Sdk.Native.MxcSandbox; + +namespace Microsoft.Mxc.Sdk.V1.Dev; + +/// Caller-authored exact-JSON execution and lifecycle operations. +public static class MxcLifecycle +{ + static MxcLifecycle() => NativeLibraryResolver.Initialize(); + + public static ExecutionResult RunInContainerJson(string json, JsonOptions? options = null) => + RunInContainerJsonAsync(json, options).GetAwaiter().GetResult(); + + public static async Task RunInContainerJsonAsync( + string json, + JsonOptions? options = null, + CancellationToken cancellationToken = default) + { + DevJsonRequest.Inspect(json, nameof(RunInContainerJson), "exec"); + using var process = await SpawnInContainerJsonAsync(json, options, cancellationToken) + .ConfigureAwait(false); + var (outcome, stdout, stderr) = await process + .WaitForExitWithOutputAsync(cancellationToken).ConfigureAwait(false); + return new ExecutionResult + { + ExitCode = outcome.ExitCode, + TimedOut = outcome.TimedOut, + Stdout = Encoding.UTF8.GetString(stdout), + Stderr = Encoding.UTF8.GetString(stderr), + OutputMetadata = process.OutputMetadata, + Warnings = process.Warnings, + }; + } + + public static MxcProcess SpawnInContainerJson(string json, JsonOptions? options = null) + { + var timeout = DevJsonRequest.Inspect(json, nameof(SpawnInContainerJson), "exec"); + var request = DevJsonRequest.ToNullTerminatedUtf8(json); + unsafe + { + fixed (byte* requestPtr = request) + { + NativeSandbox* handle = null; + MxcErrorDetail error = default; + var status = NativeMethods.mxc_exec_state_aware_json( + requestPtr, options?.Experimental == true ? 1 : 0, &handle, &error); + if (status != (int)ErrorCode.Success) + { + try + { + throw NativeError.ToException(status, error, "spawning exact JSON exec failed"); + } + finally + { + NativeMethods.mxc_error_detail_free(&error); + } + } + return new MxcProcess(MxcSandboxHandle.FromRaw(handle), timeout); + } + } + } + + public static Task SpawnInContainerJsonAsync( + string json, + JsonOptions? options = null, + CancellationToken cancellationToken = default) => + Microsoft.Mxc.Sdk.V1.MxcLifecycle.RunBlockingOperationAsync( + () => SpawnInContainerJson(json, options), late => late.Dispose(), cancellationToken); + + public static MxcPtyProcess SpawnInContainerWithPtyJson( + string json, + PtyJsonOptions? options = null) + { + var timeout = DevJsonRequest.Inspect(json, nameof(SpawnInContainerWithPtyJson), "exec"); + var size = MxcPtySize.ResolveInitial(options?.Size); + size.Validate(nameof(options)); + var request = DevJsonRequest.ToNullTerminatedUtf8(json); + unsafe + { + fixed (byte* requestPtr = request) + { + NativeSandbox* handle = null; + MxcErrorDetail error = default; + var status = NativeMethods.mxc_state_aware_exec_pty( + requestPtr, options?.Experimental == true ? 1 : 0, + size.Rows, size.Columns, &handle, &error); + if (status != (int)ErrorCode.Success) + { + try + { + throw NativeError.ToException(status, error, "spawning exact JSON exec PTY failed"); + } + finally + { + NativeMethods.mxc_error_detail_free(&error); + } + } + return new MxcPtyProcess( + MxcSandboxHandle.FromRaw(handle), MxcProcess.NormalizeTimeout(timeout)); + } + } + } + + public static string ProvisionContainerJson(string json, JsonOptions? options = null) => + RunPhase(json, nameof(ProvisionContainerJson), "provision", false, options); + + public static Task ProvisionContainerJsonAsync(string json, JsonOptions? options = null) => + Task.Run(() => ProvisionContainerJson(json, options)); + + public static string StartContainerJson(string json, JsonOptions? options = null) => + RunPhase(json, nameof(StartContainerJson), "start", false, options); + + public static Task StartContainerJsonAsync(string json, JsonOptions? options = null) => + Task.Run(() => StartContainerJson(json, options)); + + public static string StopContainerJson(string json, JsonOptions? options = null) => + RunPhase(json, nameof(StopContainerJson), "stop", false, options); + + public static Task StopContainerJsonAsync(string json, JsonOptions? options = null) => + Task.Run(() => StopContainerJson(json, options)); + + public static string DeprovisionContainerJson(string json, JsonOptions? options = null) => + RunPhase(json, nameof(DeprovisionContainerJson), "deprovision", false, options); + + public static Task DeprovisionContainerJsonAsync( + string json, + JsonOptions? options = null) => + Task.Run(() => DeprovisionContainerJson(json, options)); + + public static string ValidateProvisionJson(string json, JsonOptions? options = null) => + RunPhase(json, nameof(ValidateProvisionJson), "provision", true, options); + + public static Task ValidateProvisionJsonAsync( + string json, + JsonOptions? options = null) => + Task.Run(() => ValidateProvisionJson(json, options)); + + public static string ValidateStartJson(string json, JsonOptions? options = null) => + RunPhase(json, nameof(ValidateStartJson), "start", true, options); + + public static Task ValidateStartJsonAsync(string json, JsonOptions? options = null) => + Task.Run(() => ValidateStartJson(json, options)); + + public static string ValidateStopJson(string json, JsonOptions? options = null) => + RunPhase(json, nameof(ValidateStopJson), "stop", true, options); + + public static Task ValidateStopJsonAsync(string json, JsonOptions? options = null) => + Task.Run(() => ValidateStopJson(json, options)); + + public static string ValidateDeprovisionJson(string json, JsonOptions? options = null) => + RunPhase(json, nameof(ValidateDeprovisionJson), "deprovision", true, options); + + public static Task ValidateDeprovisionJsonAsync( + string json, + JsonOptions? options = null) => + Task.Run(() => ValidateDeprovisionJson(json, options)); + + public static string ValidateProcessJson(string json, JsonOptions? options = null) => + RunPhase(json, nameof(ValidateProcessJson), "exec", true, options); + + public static Task ValidateProcessJsonAsync(string json, JsonOptions? options = null) => + Task.Run(() => ValidateProcessJson(json, options)); + + private static string RunPhase( + string json, string operation, string phase, bool dryRun, JsonOptions? options) + { + DevJsonRequest.Inspect(json, operation, phase); + var request = DevJsonRequest.ToNullTerminatedUtf8(json); + unsafe + { + fixed (byte* requestPtr = request) + { + MxcStateAwareResult result = default; + var status = NativeMethods.mxc_run_state_aware_json( + requestPtr, dryRun ? 1 : 0, options?.Experimental == true ? 1 : 0, &result); + try + { + if (status != (int)ErrorCode.Success) + { + throw NativeError.ToException(status, result.error, "exact JSON lifecycle failed"); + } + var responseJson = result.response_json_utf8 is null + ? null : Marshal.PtrToStringUTF8((IntPtr)result.response_json_utf8); + return string.IsNullOrEmpty(responseJson) + ? throw new MxcException(ErrorCode.BackendError, + "native lifecycle response has no JSON") + : responseJson; + } + finally + { + NativeMethods.mxc_state_aware_result_free(&result); + } + } + } + } +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcProcess.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcProcess.cs index 94d5b5ada..17f07045f 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcProcess.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcProcess.cs @@ -565,9 +565,21 @@ Task ReadAll(Stream? s) => var stdoutTask = ReadAll(outStream); var stderrTask = ReadAll(errStream); + var timeoutCloseFailed = false; try { var result = await WaitAsync(cancellationToken).ConfigureAwait(false); + if (result.TimedOut) + { + timeoutCloseFailed = + !CloseQuietly(outCloser, outStream is not null) | + !CloseQuietly(errCloser, errStream is not null); + if (timeoutCloseFailed) + { + throw new MxcException(ErrorCode.BackendError, + "timed-out capture could not close its output readers"); + } + } var stdout = await stdoutTask.ConfigureAwait(false); var stderr = await stderrTask.ConfigureAwait(false); return (result, stdout, stderr); @@ -589,6 +601,13 @@ await Task.WhenAll(SwallowAsync(stdoutTask), SwallowAsync(stderrTask)) } catch { + if (timeoutCloseFailed) + { + // Without a closer, waiting for a descendant-held pipe can never finish. + KillQuietly(); + _ = Task.WhenAll(SwallowAsync(stdoutTask), SwallowAsync(stderrTask)); + throw; + } // On cancellation/failure the reader tasks may be parked in a blocking // native read that only returns once the child's pipes reach EOF. Kill // the child first so those write ends close and the reads return; diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index ab5549bcd..4dcac771d 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -8,6 +8,13 @@ versioned public API is in `Microsoft.Mxc.Sdk.V1`. All request and policy types in this API use the SDK-selected contract version; callers do not select that version. See the [consumer glossary](https://github.com/microsoft/mxc/blob/main/docs/glossary.md) for terminology. +`Microsoft.Mxc.Sdk.V1.Dev` provides +[exact-JSON APIs](https://github.com/microsoft/mxc/blob/main/docs/api-reference/dotnet/v1/dev.md). +The dev operations accept complete caller-authored, exact-version JSON and +authorize experimental backends separately. Lifecycle and validation return +the native response JSON string; captured output uses the existing V1 result +type. The PTY dev entry points are synchronous only. + ## Run to completion ```csharp @@ -160,6 +167,10 @@ support cancellation. Backend and phase-specific policy requirements are described in the [IsolationSession](https://github.com/microsoft/mxc/blob/main/docs/development/architecture/backends/isolation-session/state-aware-rust.md) and [WSLC](https://github.com/microsoft/mxc/blob/main/docs/backends/wslc/wslc-state-aware.md) guides. + +Captured in-container execution returns partial output on timeout without +waiting for descendant-held output pipes. + `MxcLifecycle.SpawnInContainerWithPty(id, request, options?)` starts an IsolationSession exec with a caller-controlled terminal and returns an `MxcPtyProcess`. Set `SpawnInContainerWithPtyOptions.Size` to choose initial diff --git a/sdk/node/README.md b/sdk/node/README.md index 6d49b8491..8e812157c 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -9,6 +9,13 @@ The package root exports no public APIs. These APIs use the SDK-selected contrac version; callers do not select it. See the [consumer glossary](https://github.com/microsoft/mxc/blob/main/docs/glossary.md) for terminology. +`@microsoft/mxc-sdk/v1/dev` provides +[exact-JSON APIs](https://github.com/microsoft/mxc/blob/main/docs/api-reference/node/v1/dev.md). +The dev operations accept complete caller-authored, exact-version JSON and +authorize experimental backends separately. They return Promises; lifecycle +and validation return the native response JSON string. ProcessContainer PTY +passes the original JSON through `wxc-exec` without stable typed mapping. + ```bash npm install @microsoft/mxc-sdk ``` @@ -107,6 +114,9 @@ Terminal stderr is merged into `output`. Closing `input` requests terminal EOF when supported; raw-mode applications must use their own completion protocol. Seatbelt rejects PTY mode with `guiAccess` or legacy `launchMethod: "open"`. Unsupported combinations are rejected before sandbox creation. +ProcessContainer PTY passes `timeoutMs` to `wxc-exec`, but the executor +channel reports only an exit code: `terminal.wait().timedOut` is `false` +even if that timeout ended the workload. ## Lifecycle API @@ -152,6 +162,8 @@ try { `spawnInContainer` returns a `Promise` with live standard pipes; `runInContainer` returns a `Promise` with captured output. +On timeout, it returns partial captured text without waiting for a +descendant-held output stream to close. `spawnInContainerWithPty(containerId, request, options?)` starts an IsolationSession exec with a caller-driven terminal and returns a `Promise`. Set `options.size` for initial dimensions; it defaults diff --git a/sdk/node/package.json b/sdk/node/package.json index a2cd260bc..ea3ad0434 100644 --- a/sdk/node/package.json +++ b/sdk/node/package.json @@ -15,12 +15,20 @@ "import": "./dist/v1/index.js", "default": "./dist/v1/index.js" }, + "./v1/dev": { + "types": "./dist/v1/dev/index.d.ts", + "import": "./dist/v1/dev/index.js", + "default": "./dist/v1/dev/index.js" + }, "./package.json": "./package.json" }, "typesVersions": { "*": { "v1": [ "./dist/v1/index.d.ts" + ], + "v1/dev": [ + "./dist/v1/dev/index.d.ts" ] } }, @@ -36,7 +44,7 @@ "watch": "tsc --watch", "clean": "rimraf dist", "test": "npm run test:unit", - "test:unit": "npm run build:test-unit && node --test dist-tests/tests/unit/sandbox.test.js dist-tests/tests/unit/v1-sandbox-policy.test.js dist-tests/tests/unit/sdk-v1-conformance.test.js dist-tests/tests/unit/policy.test.js dist-tests/tests/unit/logger.test.js dist-tests/tests/unit/errors.test.js dist-tests/tests/unit/state-aware-types.test.js dist-tests/tests/unit/state-aware.test.js dist-tests/tests/unit/state-aware-binding.test.js dist-tests/tests/unit/platform.test.js dist-tests/tests/unit/probe.test.js dist-tests/tests/unit/native-library.test.js dist-tests/tests/unit/binding-run.test.js dist-tests/tests/unit/streaming-binding.test.js dist-tests/tests/unit/pty-binding.test.js dist-tests/tests/unit/process-container-pty.test.js dist-tests/tests/unit/pty-options.test.js dist-tests/tests/unit/inprocess-run.test.js dist-tests/tests/unit/sandbox-process.test.js dist-tests/tests/unit/telemetry.test.js dist-tests/tests/unit/telemetry-request-worker.test.js dist-tests/tests/unit/wire-conformance.test.js dist-tests/tests/unit/wire-conformance-state-aware.test.js", + "test:unit": "npm run build:test-unit && node --test dist-tests/tests/unit/sandbox.test.js dist-tests/tests/unit/v1-sandbox-policy.test.js dist-tests/tests/unit/sdk-v1-conformance.test.js dist-tests/tests/unit/policy.test.js dist-tests/tests/unit/logger.test.js dist-tests/tests/unit/errors.test.js dist-tests/tests/unit/state-aware-types.test.js dist-tests/tests/unit/state-aware.test.js dist-tests/tests/unit/state-aware-binding.test.js dist-tests/tests/unit/platform.test.js dist-tests/tests/unit/probe.test.js dist-tests/tests/unit/native-library.test.js dist-tests/tests/unit/binding-run.test.js dist-tests/tests/unit/streaming-binding.test.js dist-tests/tests/unit/pty-binding.test.js dist-tests/tests/unit/process-container-pty.test.js dist-tests/tests/unit/pty-options.test.js dist-tests/tests/unit/inprocess-run.test.js dist-tests/tests/unit/sandbox-process.test.js dist-tests/tests/unit/telemetry.test.js dist-tests/tests/unit/telemetry-request-worker.test.js dist-tests/tests/unit/wire-conformance.test.js dist-tests/tests/unit/wire-conformance-state-aware.test.js dist-tests/tests/unit/v1-dev.test.js", "test:integration": "cd tests/integration && npm install && npm run build && npm test", "prepublishOnly": "npm run build", "typecheck:integration": "node tests/integration/check-package-build.mjs", diff --git a/sdk/node/src/bindings/process-container-pty.ts b/sdk/node/src/bindings/process-container-pty.ts index 891fa5c78..0babf6c56 100644 --- a/sdk/node/src/bindings/process-container-pty.ts +++ b/sdk/node/src/bindings/process-container-pty.ts @@ -140,7 +140,7 @@ export function createNodePtyProcess( // Work around the in-process PTY binding's lack of ProcessContainer support by // launching the same exact one-shot request through wxc-exec under node-pty. async function spawnWithWxcExecutablePty( - request: OneShotRequest, + requestJson: string, experimental: boolean, rows: number, columns: number, @@ -154,7 +154,6 @@ async function spawnWithWxcExecutablePty( }); } - const requestJson = JSON.stringify(request); const args = [ '--config-base64', Buffer.from(requestJson, 'utf8').toString('base64'), @@ -190,9 +189,16 @@ async function spawnWithWxcExecutablePty( } } -type SpawnExecutablePtyImplementation = typeof spawnWithWxcExecutablePty; +type SpawnExecutablePtyImplementation = ( + request: OneShotRequest, + experimental: boolean, + rows: number, + columns: number, +) => Promise; -let implementation = spawnWithWxcExecutablePty; +let implementation: SpawnExecutablePtyImplementation = + (request, experimental, rows, columns) => + spawnWithWxcExecutablePty(JSON.stringify(request), experimental, rows, columns); /** @internal Replaces node-pty dependencies for unit tests. */ export function _setProcessContainerPtyDependencies( @@ -207,7 +213,8 @@ export function _setProcessContainerPtyDependencies( export function _setSpawnProcessContainerWithPtyImplementation( replacement?: SpawnExecutablePtyImplementation, ): void { - implementation = replacement ?? spawnWithWxcExecutablePty; + implementation = replacement ?? ((request, experimental, rows, columns) => + spawnWithWxcExecutablePty(JSON.stringify(request), experimental, rows, columns)); } export function spawnProcessContainerWithPty( @@ -218,3 +225,12 @@ export function spawnProcessContainerWithPty( ): Promise { return implementation(request, experimental, rows, columns); } + +export function spawnProcessContainerWithPtyJson( + requestJson: string, + experimental: boolean, + rows: number, + columns: number, +): Promise { + return spawnWithWxcExecutablePty(requestJson, experimental, rows, columns); +} diff --git a/sdk/node/src/bindings/pty.ts b/sdk/node/src/bindings/pty.ts index c1c51e7ce..901e2763a 100644 --- a/sdk/node/src/bindings/pty.ts +++ b/sdk/node/src/bindings/pty.ts @@ -323,3 +323,49 @@ export function spawnBindingSandboxWithPty( ): Promise { return spawnPtyImplementation(request, experimental, rows, columns); } + +function spawnBindingSandboxWithPtyJsonNative( + requestJson: string, + experimental: boolean, + rows: number, + columns: number, + timeoutMs?: number, +): Promise { + const ptyNative = getPtyNative(); + return createPtyFromJson( + timeoutMs, + rows, + columns, + (outHandle, error, completion) => ptyNative.spawnPty( + requestJson, + experimental ? 1 : 0, + rows, + columns, + outHandle, + error, + completion, + ), + ptyNative, + getStreamingNative(), + nodeStreamFactory, + ); +} + +let rawPtyImplementation = spawnBindingSandboxWithPtyJsonNative; + +/** @internal Replaces raw one-shot PTY creation for unit tests. */ +export function _setSpawnBindingSandboxWithPtyJsonImplementation( + implementation?: typeof spawnBindingSandboxWithPtyJsonNative, +): void { + rawPtyImplementation = implementation ?? spawnBindingSandboxWithPtyJsonNative; +} + +export function spawnBindingSandboxWithPtyJson( + requestJson: string, + experimental: boolean, + rows: number, + columns: number, + timeoutMs?: number, +): Promise { + return rawPtyImplementation(requestJson, experimental, rows, columns, timeoutMs); +} diff --git a/sdk/node/src/bindings/run.ts b/sdk/node/src/bindings/run.ts index b37e6ec26..97640aa57 100644 --- a/sdk/node/src/bindings/run.ts +++ b/sdk/node/src/bindings/run.ts @@ -176,6 +176,13 @@ export function runOneShotJson( async function runOneShotJsonAsyncNative( request: OneShotRequest, experimental: boolean, +): Promise { + return runRawOneShotJsonAsyncNative(JSON.stringify(request), experimental); +} + +async function runRawOneShotJsonAsyncNative( + requestJson: string, + experimental: boolean, ): Promise { const native = loadMxcFfi(); try { @@ -183,7 +190,6 @@ async function runOneShotJsonAsyncNative( const result = {} as AbiRunResult; let filled = false; try { - const requestJson = JSON.stringify(request); const status = await new Promise((resolve, reject) => { run.async( requestJson, @@ -214,6 +220,7 @@ type AsyncRunImplementation = ( ) => Promise; let asyncRunImplementation = runOneShotJsonAsyncNative; +let rawAsyncRunImplementation = runRawOneShotJsonAsyncNative; /** @internal Replaces the async native call for one process's unit tests. */ export function _setBindingRunAsyncImplementation( @@ -222,15 +229,31 @@ export function _setBindingRunAsyncImplementation( asyncRunImplementation = implementation ?? runOneShotJsonAsyncNative; } +/** @internal Replaces the raw async native call for unit tests. */ +export function _setBindingRawRunAsyncImplementation( + implementation?: typeof runRawOneShotJsonAsyncNative, +): void { + rawAsyncRunImplementation = implementation ?? runRawOneShotJsonAsyncNative; +} + +function rethrowRunError(error: unknown): never { + if (error instanceof MxcError) throw error; + throw new MxcError( + 'backend_error', + error instanceof Error ? error.message : String(error), + ); +} + export function runOneShotJsonAsync( request: OneShotRequest, experimental = false, ): Promise { - return asyncRunImplementation(request, experimental).catch((error: unknown) => { - if (error instanceof MxcError) throw error; - throw new MxcError( - 'backend_error', - error instanceof Error ? error.message : String(error), - ); - }); + return asyncRunImplementation(request, experimental).catch(rethrowRunError); +} + +export function runRawOneShotJsonAsync( + requestJson: string, + experimental: boolean, +): Promise { + return rawAsyncRunImplementation(requestJson, experimental).catch(rethrowRunError); } diff --git a/sdk/node/src/bindings/state-aware.ts b/sdk/node/src/bindings/state-aware.ts index 45a99af43..d1e613791 100644 --- a/sdk/node/src/bindings/state-aware.ts +++ b/sdk/node/src/bindings/state-aware.ts @@ -102,7 +102,11 @@ function decodeStateAwareResult( 'state-aware request failed', ); } - return decodeString(result.responseJsonUtf8) ?? '{}'; + const responseJson = decodeString(result.responseJsonUtf8); + if (responseJson === undefined || responseJson.length === 0) { + throw new MxcError('backend_error', 'native lifecycle response is missing JSON'); + } + return responseJson; } export async function runBindingStateAwareRequestWithNative( diff --git a/sdk/node/src/bindings/streaming.ts b/sdk/node/src/bindings/streaming.ts index 5a6ce10e1..ff8982753 100644 --- a/sdk/node/src/bindings/streaming.ts +++ b/sdk/node/src/bindings/streaming.ts @@ -470,10 +470,19 @@ export async function createStreamingDriver( native: StreamingNativeFacade, factory: NativeStreamFactory, experimental = false, +): Promise { + return createStreamingDriverFromJson(JSON.stringify(request), native, factory, experimental); +} + +/** Spawn a one-shot process without changing the caller's exact JSON. */ +export async function createStreamingDriverFromJson( + requestJson: string, + native: StreamingNativeFacade, + factory: NativeStreamFactory, + experimental = false, ): Promise { const outHandle: Pointer[] = [null]; const error = {} as AbiErrorDetail; - const requestJson = JSON.stringify(request); const status = await new Promise((resolve, reject) => { native.spawn( requestJson, @@ -557,6 +566,35 @@ export async function spawnBindingSandboxProcess( return createSandboxProcess(driver, request.process.timeout); } +let rawOneShotProcessFactory: + | ((requestJson: string, experimental: boolean, timeoutMs?: number) => Promise) + | undefined; + +/** @internal Replaces raw one-shot process creation for unit tests. */ +export function _setBindingRawSandboxProcessFactory( + factory?: typeof rawOneShotProcessFactory, +): void { + rawOneShotProcessFactory = factory; +} + +export async function spawnBindingSandboxProcessJson( + requestJson: string, + experimental: boolean, + timeoutMs?: number, +): Promise { + if (rawOneShotProcessFactory !== undefined) { + return rawOneShotProcessFactory(requestJson, experimental, timeoutMs); + } + ensureSupportedNodeVersion(); + const driver = await createStreamingDriverFromJson( + requestJson, + getStreamingNative(), + nodeStreamFactory, + experimental, + ); + return createSandboxProcess(driver, timeoutMs); +} + export function spawnBindingSandboxProcessSync( request: OneShotRequest, experimental = false, diff --git a/sdk/node/src/v1/capture.ts b/sdk/node/src/v1/capture.ts new file mode 100644 index 000000000..b721ad658 --- /dev/null +++ b/sdk/node/src/v1/capture.ts @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Readable } from 'node:stream'; +import type { MxcProcess, WaitResult } from './container-process.js'; + +function collectStream(stream: Readable | null): { + result: Promise; + closeForTimeout: () => void; +} { + if (stream === null) { + return { result: Promise.resolve(''), closeForTimeout: () => {} }; + } + + let timedOut = false; + const chunks: Buffer[] = []; + let resolveResult!: (value: string) => void; + const collect = (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }; + const result = new Promise((resolve, reject) => { + resolveResult = resolve; + stream.on('data', collect); + stream.once('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + stream.once('error', reject); + stream.once('close', () => { + if (timedOut) { + resolve(Buffer.concat(chunks).toString('utf8')); + } else if (!stream.readableEnded) { + reject(new Error('captured output closed before EOF')); + } + }); + }); + return { + result, + closeForTimeout: () => { + timedOut = true; + stream.pause(); + if (stream.readableLength > 0) stream.read(stream.readableLength); + stream.off('data', collect); + resolveResult(Buffer.concat(chunks).toString('utf8')); + stream.destroy(); + }, + }; +} + +/** Capture both streams concurrently, stopping reads after a process timeout. */ +export async function captureProcessOutput( + proc: MxcProcess, +): Promise<{ result: WaitResult; stdout: string; stderr: string }> { + const stdout = collectStream(proc.standardOutput); + const stderr = collectStream(proc.standardError); + const reads = Promise.all([stdout.result, stderr.result]); + try { + const wait = proc.wait(); + const readFailure = new Promise((_, reject) => { + void reads.catch(reject); + }); + const result = await Promise.race([wait, readFailure]); + if (result.timedOut) { + stdout.closeForTimeout(); + stderr.closeForTimeout(); + } + const [out, err] = await reads; + return { result, stdout: out, stderr: err }; + } catch (error) { + stdout.closeForTimeout(); + stderr.closeForTimeout(); + await Promise.allSettled([reads]); + throw error; + } +} diff --git a/sdk/node/src/v1/dev/index.ts b/sdk/node/src/v1/dev/index.ts new file mode 100644 index 000000000..9f4d3b6a1 --- /dev/null +++ b/sdk/node/src/v1/dev/index.ts @@ -0,0 +1,283 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** Caller-authored exact-JSON operations. */ + +import type { ExecutionResult } from '../types.js'; +import type { MxcProcess } from '../container-process.js'; +import type { MxcPtyProcess, MxcPtySize } from '../mxc-pty-process.js'; +import { MxcError } from '../errors.js'; +import { captureProcessOutput } from '../capture.js'; +import { diagLog } from '../../diagnostic.js'; +import { runRawOneShotJsonAsync, type BindingRunResult } from '../../bindings/run.js'; +import { + spawnBindingSandboxProcessJson, + spawnStateAwareBindingSandboxProcessAsync, +} from '../../bindings/streaming.js'; +import { + execStateAwareBindingSandboxWithPty, + spawnBindingSandboxWithPtyJson, +} from '../../bindings/pty.js'; +import { spawnProcessContainerWithPtyJson } from '../../bindings/process-container-pty.js'; +import { runBindingStateAwareRequestAsync } from '../../bindings/state-aware.js'; + +/** Authorization supplied independently of the JSON contract version. */ +export interface JsonOptions { + experimental?: boolean; +} + +/** Invocation controls for an exact-JSON PTY operation. */ +export interface PtyJsonOptions extends JsonOptions { + size?: MxcPtySize; +} + +type Phase = 'provision' | 'start' | 'exec' | 'stop' | 'deprovision'; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function checkOptions(name: string, options: JsonOptions, pty = false): void { + if (!isRecord(options)) { + throw new MxcError('malformed_request', `${name} options must be an object`); + } + for (const [key, value] of Object.entries(options)) { + if (key === 'experimental' && (value === undefined || typeof value === 'boolean')) continue; + if (pty && key === 'size') continue; + throw new MxcError('malformed_request', `${name} does not support option '${key}'`); + } +} + +function requestInfo( + json: string, + name: string, + phase?: Phase, +): { containment: unknown; timeoutMs: number | undefined } { + if (typeof json !== 'string') { + throw new MxcError('malformed_request', `${name} requires a JSON string`); + } + if (Buffer.from(json, 'utf8').toString('utf8') !== json) { + throw new MxcError('malformed_request', `${name} requires valid UTF-8 JSON text`); + } + let request: unknown; + try { + request = JSON.parse(json); + } catch (error) { + throw new MxcError( + 'malformed_request', + `${name} received invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (!isRecord(request)) { + throw new MxcError('malformed_request', `${name} requires a JSON object`); + } + if (phase === undefined ? Object.hasOwn(request, 'phase') : request.phase !== phase) { + throw new MxcError( + 'malformed_request', + phase === undefined + ? `${name} requires a one-shot document without phase` + : `${name} requires phase '${phase}'`, + ); + } + const timeout = isRecord(request.process) ? request.process.timeout : undefined; + return { + containment: request.containment, + timeoutMs: typeof timeout === 'number' && Number.isInteger(timeout) + && timeout >= 0 && timeout <= 0xffff_ffff ? timeout : undefined, + }; +} + +function ptySize(name: string, options: PtyJsonOptions): MxcPtySize { + checkOptions(name, options, true); + if (options.size === null) { + throw new MxcError('malformed_request', `${name} size must be an object`); + } + const size = options.size ?? { rows: 24, columns: 80 }; + if (!isRecord(size) || typeof size.rows !== 'number' || typeof size.columns !== 'number' + || !Number.isInteger(size.rows) || !Number.isInteger(size.columns) + || size.rows < 1 || size.rows > 32767 + || size.columns < 1 || size.columns > 32767 + || Object.keys(size).some((key) => key !== 'rows' && key !== 'columns')) { + throw new MxcError('malformed_request', 'PTY rows and columns must be integers between 1 and 32767'); + } + return size; +} + +function captureResult(result: BindingRunResult): ExecutionResult { + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + timedOut: result.timedOut, + warnings: result.warnings, + ...(result.outputMetadata === undefined ? {} : { outputMetadata: result.outputMetadata }), + }; +} + +function phaseResult( + json: string, + name: string, + phase: Phase, + dryRun: boolean, + options: JsonOptions, +): Promise { + checkOptions(name, options); + requestInfo(json, name, phase); + return runBindingStateAwareRequestAsync({ + requestJson: json, + dryRun, + experimental: options.experimental === true, + }); +} + +/** Capture a one-shot request. */ +export async function runJson(json: string, options: JsonOptions = {}): Promise { + checkOptions('runJson', options); + requestInfo(json, 'runJson'); + return captureResult(await runRawOneShotJsonAsync(json, options.experimental === true)); +} + +/** Spawn a one-shot request with pipes. */ +export async function spawnJson(json: string, options: JsonOptions = {}): Promise { + checkOptions('spawnJson', options); + const { timeoutMs } = requestInfo(json, 'spawnJson'); + return spawnBindingSandboxProcessJson(json, options.experimental === true, timeoutMs); +} + +/** Spawn a one-shot request with a PTY. */ +export async function spawnWithPtyJson( + json: string, + options: PtyJsonOptions = {}, +): Promise { + const size = ptySize('spawnWithPtyJson', options); + const { containment, timeoutMs } = requestInfo(json, 'spawnWithPtyJson'); + if (process.platform === 'win32' && + (containment === undefined || containment === 'process' + || containment === 'processcontainer' || containment === 'appcontainer')) { + return spawnProcessContainerWithPtyJson( + json, options.experimental === true, size.rows, size.columns, + ); + } + return spawnBindingSandboxWithPtyJson( + json, options.experimental === true, size.rows, size.columns, timeoutMs, + ); +} + +/** Capture execution in a caller-owned container. */ +export async function runInContainerJson( + json: string, + options: JsonOptions = {}, +): Promise { + checkOptions('runInContainerJson', options); + const { timeoutMs } = requestInfo(json, 'runInContainerJson', 'exec'); + const proc = await spawnStateAwareBindingSandboxProcessAsync( + json, options.experimental === true, timeoutMs, + ); + let failed = false; + try { + const { result: outcome, stdout, stderr } = await captureProcessOutput(proc); + return { + stdout, stderr, + exitCode: outcome.exitCode, + timedOut: outcome.timedOut, + warnings: [...proc.warnings], + ...(proc.outputMetadata === undefined ? {} : { outputMetadata: proc.outputMetadata }), + }; + } catch (error) { + failed = true; + throw error; + } finally { + try { + proc.dispose(); + } catch (error) { + if (!failed) throw error; + diagLog(`state-aware: disposing buffered exact-JSON exec after failure: ${ + error instanceof Error ? error.message : String(error) + }`); + } + } +} + +/** Spawn execution with pipes in a caller-owned container. */ +export async function spawnInContainerJson( + json: string, + options: JsonOptions = {}, +): Promise { + checkOptions('spawnInContainerJson', options); + const { timeoutMs } = requestInfo(json, 'spawnInContainerJson', 'exec'); + return spawnStateAwareBindingSandboxProcessAsync( + json, options.experimental === true, timeoutMs, + ); +} + +/** Spawn execution with a PTY in a caller-owned container. */ +export async function spawnInContainerWithPtyJson( + json: string, + options: PtyJsonOptions = {}, +): Promise { + const size = ptySize('spawnInContainerWithPtyJson', options); + const { timeoutMs } = requestInfo(json, 'spawnInContainerWithPtyJson', 'exec'); + return execStateAwareBindingSandboxWithPty( + json, options.experimental === true, size.rows, size.columns, timeoutMs, + ); +} + +/** Provision a container. */ +export async function provisionContainerJson( + json: string, + options: JsonOptions = {}, +): Promise { + return phaseResult(json, 'provisionContainerJson', 'provision', false, options); +} + +/** Start a provisioned container. */ +export async function startContainerJson(json: string, options: JsonOptions = {}): Promise { + return phaseResult(json, 'startContainerJson', 'start', false, options); +} + +/** Stop a running container. */ +export async function stopContainerJson(json: string, options: JsonOptions = {}): Promise { + return phaseResult(json, 'stopContainerJson', 'stop', false, options); +} + +/** Deprovision a container. */ +export async function deprovisionContainerJson( + json: string, + options: JsonOptions = {}, +): Promise { + return phaseResult(json, 'deprovisionContainerJson', 'deprovision', false, options); +} + +/** Validate a provision request. */ +export async function validateProvisionJson( + json: string, + options: JsonOptions = {}, +): Promise { + return phaseResult(json, 'validateProvisionJson', 'provision', true, options); +} + +/** Validate a start request. */ +export async function validateStartJson(json: string, options: JsonOptions = {}): Promise { + return phaseResult(json, 'validateStartJson', 'start', true, options); +} + +/** Validate a stop request. */ +export async function validateStopJson(json: string, options: JsonOptions = {}): Promise { + return phaseResult(json, 'validateStopJson', 'stop', true, options); +} + +/** Validate a deprovision request. */ +export async function validateDeprovisionJson( + json: string, + options: JsonOptions = {}, +): Promise { + return phaseResult(json, 'validateDeprovisionJson', 'deprovision', true, options); +} + +/** Validate an existing-container execution request. */ +export async function validateProcessJson( + json: string, + options: JsonOptions = {}, +): Promise { + return phaseResult(json, 'validateProcessJson', 'exec', true, options); +} diff --git a/sdk/node/src/v1/lifecycle.ts b/sdk/node/src/v1/lifecycle.ts index d97447c27..66a844f7a 100644 --- a/sdk/node/src/v1/lifecycle.ts +++ b/sdk/node/src/v1/lifecycle.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { Readable } from 'node:stream'; import { diagLog } from '../diagnostic.js'; +import { captureProcessOutput } from './capture.js'; import { MxcError } from './errors.js'; import { runBindingStateAwareRequestAsync, @@ -167,20 +167,6 @@ function assertStateAwareStreamingOptions( } } -function collectStream(stream: Readable | null): Promise { - if (stream === null) { - return Promise.resolve(''); - } - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - stream.on('data', (chunk: Buffer | string) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - stream.once('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); - stream.once('error', reject); - }); -} - /** * Provision a container from a closed backend-specific request. */ @@ -301,12 +287,10 @@ export async function runInContainer( assertStateAwareStreamingOptions('runInContainer', options); assertPipedExecBackend('runInContainer', containerId); const proc = await spawnInContainer(containerId, request, options); - const stdoutPromise = collectStream(proc.standardOutput); - const stderrPromise = collectStream(proc.standardError); - const waitPromise = Promise.all([proc.wait(), stdoutPromise, stderrPromise]); + const waitPromise = captureProcessOutput(proc); let failed = false; try { - const [result, stdout, stderr] = await waitPromise; + const { result, stdout, stderr } = await waitPromise; return { stdout, stderr, diff --git a/sdk/node/tests/integration/dev-api.ts b/sdk/node/tests/integration/dev-api.ts new file mode 100644 index 000000000..fe193263b --- /dev/null +++ b/sdk/node/tests/integration/dev-api.ts @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + runJson, + spawnWithPtyJson, + provisionContainerJson, + type JsonOptions, + type PtyJsonOptions, +} from '@microsoft/mxc-sdk/v1/dev'; +import type { ExecutionResult, MxcPtyProcess } from '@microsoft/mxc-sdk/v1'; + +export function verifyPackedDevSignatures( + json: string, + options: JsonOptions, + ptyOptions: PtyJsonOptions, +): [Promise, Promise, Promise] { + return [ + runJson(json, options), + spawnWithPtyJson(json, ptyOptions), + provisionContainerJson(json, options), + ]; +} diff --git a/sdk/node/tests/integration/dev.test.ts b/sdk/node/tests/integration/dev.test.ts new file mode 100644 index 000000000..6886b7220 --- /dev/null +++ b/sdk/node/tests/integration/dev.test.ts @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from 'node:assert/strict'; +import { it } from 'node:test'; +import { runJson, validateProcessJson } from '@microsoft/mxc-sdk/v1/dev'; +import { MxcError } from '@microsoft/mxc-sdk/v1'; + +it('V1.Dev rejects unknown fields and unregistered exact versions through native', async () => { + const unregistered = + '{"version":"999.0.0","process":{"commandLine":"echo must-not-run"}}'; + await assert.rejects( + runJson(unregistered), + (error: unknown) => error instanceof MxcError && error.code === 'malformed_request', + ); + + const unknownField = + '{"version":"1.0.0","phase":"exec","sandboxId":"iso:unused","process":{"commandLine":"echo must-not-run"},"extra":true}'; + await assert.rejects( + validateProcessJson(unknownField), + (error: unknown) => error instanceof MxcError && error.code === 'malformed_request', + ); +}); diff --git a/sdk/node/tests/unit/process-container-pty.test.ts b/sdk/node/tests/unit/process-container-pty.test.ts index 962fcc868..2a27722e4 100644 --- a/sdk/node/tests/unit/process-container-pty.test.ts +++ b/sdk/node/tests/unit/process-container-pty.test.ts @@ -13,7 +13,10 @@ import { _setProcessContainerPtyDependencies, createNodePtyProcess, spawnProcessContainerWithPty, + spawnProcessContainerWithPtyJson, } from '../../src/bindings/process-container-pty.js'; +import { spawnWithPtyJson } from '../../src/v1/dev/index.js'; +import { spawnWithPty } from '../../src/v1/container.js'; class FakePty implements IPty { readonly pid = 42; @@ -150,6 +153,103 @@ describe('ProcessContainer node-pty binding', () => { } }); + it('transports raw dev JSON byte-for-byte without stable mapping', async () => { + const pty = new FakePty(); + let args: string[] = []; + _setProcessContainerPtyDependencies({ + findExecutable: () => 'C:\\mxc\\wxc-exec.exe', + loadNodePty: async () => ({ + spawn: (_file, spawnArgs) => { + args = [...spawnArgs]; + return pty; + }, + }) as typeof import('node-pty'), + }); + const json = ' { "version":"1.1.0-alpha", "containment":"processcontainer", "process":{"commandLine":"cmd","timeout":1e3},"future":true } '; + try { + const terminal = process.platform === 'win32' + ? await spawnWithPtyJson(json, { + experimental: true, size: { rows: 30, columns: 90 }, + }) + : await spawnProcessContainerWithPtyJson(json, true, 30, 90); + assert.strictEqual(Buffer.from(args[1], 'base64').toString('utf8'), json); + assert.deepStrictEqual(args, ['--config-base64', args[1], '--experimental']); + terminal.dispose(); + pty.exit(0); + } finally { + _setProcessContainerPtyDependencies(); + } + }); + + it('routes the historical appcontainer alias through wxc-exec unchanged', { + skip: process.platform !== 'win32', + }, async () => { + const pty = new FakePty(); + let args: string[] = []; + _setProcessContainerPtyDependencies({ + findExecutable: () => 'C:\\mxc\\wxc-exec.exe', + loadNodePty: async () => ({ + spawn: (_file, spawnArgs) => { + args = [...spawnArgs]; + return pty; + }, + }) as typeof import('node-pty'), + }); + const json = ' { "version":"0.9.0-alpha","containment":"appcontainer","process":{"commandLine":"cmd.exe","timeout":3000} } '; + try { + const terminal = await spawnWithPtyJson(json, { + size: { rows: 25, columns: 80 }, + }); + assert.deepStrictEqual(args, [ + '--config-base64', + Buffer.from(json, 'utf8').toString('base64'), + ]); + terminal.dispose(); + pty.exit(0); + } finally { + _setProcessContainerPtyDependencies(); + } + }); + + it('uses the same executor timeout outcome for typed and raw PTY calls', { + skip: process.platform !== 'win32', + }, async () => { + const processes: FakePty[] = []; + const requests: string[] = []; + _setProcessContainerPtyDependencies({ + findExecutable: () => 'C:\\mxc\\wxc-exec.exe', + loadNodePty: async () => ({ + spawn: (_file, spawnArgs) => { + requests.push(Buffer.from(spawnArgs[1], 'base64').toString('utf8')); + const pty = new FakePty(); + processes.push(pty); + return pty; + }, + }) as typeof import('node-pty'), + }); + const rawJson = '{"version":"1.0.0","containment":"processcontainer","process":{"commandLine":"cmd.exe","timeout":25}}'; + try { + const typed = await spawnWithPty({ + containment: { type: 'processcontainer' }, + command: 'cmd.exe', + timeoutMs: 25, + }); + const raw = await spawnWithPtyJson(rawJson); + assert.strictEqual(JSON.parse(requests[0]).process.timeout, 25); + assert.strictEqual(requests[1], rawJson); + processes.forEach((pty) => pty.exit(-1)); + const outcomes = await Promise.all([typed.wait(), raw.wait()]); + assert.deepStrictEqual(outcomes, [ + { exitCode: -1, timedOut: false }, + { exitCode: -1, timedOut: false }, + ]); + typed.dispose(); + raw.dispose(); + } finally { + _setProcessContainerPtyDependencies(); + } + }); + it('fails with remediation when wxc-exec is unavailable', async () => { _setProcessContainerPtyDependencies({ findExecutable: () => null, diff --git a/sdk/node/tests/unit/state-aware-binding.test.ts b/sdk/node/tests/unit/state-aware-binding.test.ts index 9b786f66d..48846f8e4 100644 --- a/sdk/node/tests/unit/state-aware-binding.test.ts +++ b/sdk/node/tests/unit/state-aware-binding.test.ts @@ -47,12 +47,14 @@ const REQUEST: BindingStateAwareRequest = { }; describe('state-aware native binding ownership', () => { - it('forwards flags and frees the populated result exactly once', async () => { + it('forwards flags and rejects a missing response while freeing the result', async () => { const native = new FakeStateAwareNative(); - assert.strictEqual( - await runBindingStateAwareRequestWithNative(REQUEST, native), - '{}', + await assert.rejects( + () => runBindingStateAwareRequestWithNative(REQUEST, native), + (error: unknown) => error instanceof MxcError + && error.code === 'backend_error' + && error.message.includes('missing JSON'), ); assert.deepStrictEqual(native.calls, [{ request: REQUEST.requestJson, diff --git a/sdk/node/tests/unit/v1-dev.test.ts b/sdk/node/tests/unit/v1-dev.test.ts new file mode 100644 index 000000000..7bf921e31 --- /dev/null +++ b/sdk/node/tests/unit/v1-dev.test.ts @@ -0,0 +1,388 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from 'node:assert/strict'; +import { PassThrough, Readable } from 'node:stream'; +import { afterEach, it } from 'node:test'; +import { _setBindingRawRunAsyncImplementation } from '../../src/bindings/run.js'; +import { + _setBindingRawSandboxProcessFactory, + _setStateAwareBindingSandboxProcessFactory, +} from '../../src/bindings/streaming.js'; +import { + _setSpawnBindingSandboxWithPtyJsonImplementation, + _setExecStateAwareBindingSandboxWithPtyImplementation, +} from '../../src/bindings/pty.js'; +import { _setBindingStateAwareAsyncImplementation } from '../../src/bindings/state-aware.js'; +import { MxcProcess, type NativeLifecycleDriver } from '../../src/v1/container-process.js'; +import { captureProcessOutput } from '../../src/v1/capture.js'; +import { MxcPtyProcess } from '../../src/v1/mxc-pty-process.js'; +import { runInContainer } from '../../src/v1/lifecycle.js'; +import type { ContainerId } from '../../src/v1/lifecycle-types.js'; +import * as dev from '../../src/v1/dev/index.js'; + +afterEach(() => { + _setBindingRawRunAsyncImplementation(); + _setBindingRawSandboxProcessFactory(); + _setStateAwareBindingSandboxProcessFactory(); + _setSpawnBindingSandboxWithPtyJsonImplementation(); + _setExecStateAwareBindingSandboxWithPtyImplementation(); + _setBindingStateAwareAsyncImplementation(); +}); + +function fakeProcess(stdout = 'out', stderr = 'err'): MxcProcess { + const standardOutput = new PassThrough(); + const standardError = new PassThrough(); + const driver: NativeLifecycleDriver = { + id: 42, + standardInput: null, + standardOutput, + standardError, + poll: () => ({ exitCode: 7, timedOut: false, running: false }), + wait: async () => ({ exitCode: 7, timedOut: false }), + warnings: () => ['warning'], + outputMetadata: () => undefined, + kill: () => {}, + killForTimeout: () => {}, + free: async () => {}, + }; + const process = new MxcProcess(driver); + standardOutput.end(stdout); + standardError.end(stderr); + return process; +} + +function fakeTerminal(): MxcPtyProcess { + return new MxcPtyProcess({ + id: 43, standardInput: null, standardOutput: null, standardError: null, + poll: () => ({ exitCode: 0, timedOut: false, running: true }), + wait: async () => ({ exitCode: 0, timedOut: false }), + warnings: () => [], outputMetadata: () => undefined, + kill: () => {}, killForTimeout: () => {}, free: async () => {}, + }, () => {}); +} + +it('one-shot capture passes the original JSON and independent authorization', async () => { + const json = ' { "version":"1.1.0-alpha","process":{"commandLine":"echo","timeout":1e2} } '; + let received: [string, boolean] | undefined; + _setBindingRawRunAsyncImplementation(async (request, experimental) => { + received = [request, experimental]; + return { + stdout: 'out', + stderr: 'err', + exitCode: 7, + timedOut: false, + warnings: ['warning'], + }; + }); + const result = await dev.runJson(json, { experimental: true }); + assert.deepStrictEqual(received, [json, true]); + assert.deepStrictEqual(result, { + stdout: 'out', stderr: 'err', exitCode: 7, timedOut: false, warnings: ['warning'], + }); +}); + +it('one-shot live modes pass the exact request and timeout to their bindings', async () => { + const json = '{"version":"1.0.0","containment":"seatbelt","process":{"commandLine":"echo","timeout":120}}'; + let pipeArgs: unknown[] = []; + let ptyArgs: unknown[] = []; + const pipe = fakeProcess(); + _setBindingRawSandboxProcessFactory(async (...args) => { + pipeArgs = args; + return pipe; + }); + _setSpawnBindingSandboxWithPtyJsonImplementation(async (...args) => { + ptyArgs = args; + return fakeTerminal(); + }); + const spawned = await dev.spawnJson(json, { experimental: true }); + const terminal = await dev.spawnWithPtyJson(json, { size: { rows: 30, columns: 90 } }); + assert.strictEqual(spawned, pipe); + assert.deepStrictEqual(pipeArgs, [json, true, 120]); + assert.deepStrictEqual(ptyArgs, [json, false, 30, 90, 120]); + spawned.dispose(); + terminal.dispose(); +}); + +it('state-aware execution captures streams and preserves container ownership', async () => { + const json = '{"version":"1.0.0","phase":"exec","sandboxId":"iso:abc","process":{"commandLine":"echo","timeout":250}}'; + let received: unknown[] = []; + const proc = fakeProcess(); + _setStateAwareBindingSandboxProcessFactory((...args) => { + received = args; + return proc; + }); + const output = await dev.runInContainerJson(json, { experimental: true }); + assert.deepStrictEqual(received, [json, true, 250]); + assert.deepStrictEqual(output, { + stdout: 'out', stderr: 'err', exitCode: 7, timedOut: false, warnings: ['warning'], + }); + assert.throws(() => proc.standardOutput, /disposed/); +}); + +it('typed and raw captured execs return partial output when pipes stay open after timeout', { + timeout: 2000, +}, async () => { + _setStateAwareBindingSandboxProcessFactory(() => { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + stdout.write('partial out'); + stderr.write('partial err'); + const driver: NativeLifecycleDriver = { + id: 42, + standardInput: null, + standardOutput: stdout, + standardError: stderr, + poll: () => ({ exitCode: -1, timedOut: true, running: false }), + wait: async () => ({ exitCode: -1, timedOut: true }), + warnings: () => [], + outputMetadata: () => undefined, + kill: () => {}, + killForTimeout: () => {}, + free: async () => {}, + }; + return new MxcProcess(driver); + }); + const json = '{"version":"1.0.0","phase":"exec","sandboxId":"iso:abc","process":{"commandLine":"echo"}}'; + const requests = [ + dev.runInContainerJson(json), + runInContainer('iso:abc' as ContainerId<'isolation_session'>, { command: 'echo' }), + ]; + for (const result of await Promise.all(requests)) { + assert.deepStrictEqual(result, { + stdout: 'partial out', + stderr: 'partial err', + exitCode: -1, + timedOut: true, + warnings: [], + }); + } +}); + +it('settles capture before a pending native read permits stream close', { + timeout: 2000, +}, async () => { + for (const waitFails of [false, true]) { + let finishDestroy: (() => void) | undefined; + const output = new Readable({ + read() {}, + destroy(_error, callback) { + finishDestroy = () => callback(null); + }, + }); + output.push('partial output'); + const driver: NativeLifecycleDriver = { + id: 42, + standardInput: null, + standardOutput: output, + standardError: null, + poll: () => ({ exitCode: -1, timedOut: true, running: false }), + wait: async () => { + if (waitFails) throw new Error('native wait failed'); + return { exitCode: -1, timedOut: true }; + }, + warnings: () => [], + outputMetadata: () => undefined, + kill: () => {}, + killForTimeout: () => {}, + free: async () => {}, + }; + const proc = new MxcProcess(driver); + try { + if (waitFails) { + await assert.rejects(captureProcessOutput(proc), /native wait failed/); + } else { + assert.deepStrictEqual(await captureProcessOutput(proc), { + result: { exitCode: -1, timedOut: true }, + stdout: 'partial output', + stderr: '', + }); + } + assert.strictEqual(output.destroyed, true); + assert.strictEqual(output.closed, false); + } finally { + finishDestroy?.(); + proc.dispose(); + } + } +}); + +it('preserves the capture failure when process disposal also fails', async () => { + let cleanupAttempted = false; + _setStateAwareBindingSandboxProcessFactory(() => { + const output = new Readable({ + read() { this.destroy(new Error('capture read failed')); }, + }); + const driver: NativeLifecycleDriver = { + id: 42, + standardInput: null, + standardOutput: output, + standardError: null, + poll: () => ({ exitCode: 0, timedOut: false, running: true }), + wait: async () => ({ exitCode: 0, timedOut: false }), + warnings: () => [], + outputMetadata: () => undefined, + kill: () => {}, + killForTimeout: () => {}, + free: async () => {}, + }; + const proc = new MxcProcess(driver); + proc.dispose = () => { + cleanupAttempted = true; + throw new Error('dispose failed'); + }; + return proc; + }); + const json = '{"version":"1.0.0","phase":"exec","sandboxId":"iso:abc","process":{"commandLine":"echo"}}'; + await assert.rejects(dev.runInContainerJson(json), /capture read failed/); + assert.strictEqual(cleanupAttempted, true); +}); + +it('typed and raw capture reject read failures while process wait is pending', { + timeout: 2000, +}, async () => { + const releaseFree: Array<() => void> = []; + _setStateAwareBindingSandboxProcessFactory(() => { + const output = new Readable({ + read() { this.destroy(new Error('capture read failed')); }, + }); + const driver: NativeLifecycleDriver = { + id: 42, + standardInput: null, + standardOutput: output, + standardError: null, + poll: () => ({ exitCode: 0, timedOut: false, running: true }), + wait: async () => ({ exitCode: 0, timedOut: false }), + warnings: () => [], + outputMetadata: () => undefined, + kill: () => {}, + killForTimeout: () => {}, + free: () => new Promise((resolve) => { + releaseFree.push(resolve); + }), + }; + return new MxcProcess(driver); + }); + const json = '{"version":"1.0.0","phase":"exec","sandboxId":"iso:abc","process":{"commandLine":"echo"}}'; + try { + await Promise.all([ + assert.rejects(dev.runInContainerJson(json), /capture read failed/), + assert.rejects( + runInContainer('iso:abc' as ContainerId<'isolation_session'>, { command: 'echo' }), + /capture read failed/, + ), + ]); + } finally { + for (const release of releaseFree) release(); + } +}); + +it('successful stream EOF does not finish capture before process exit', async () => { + let running = true; + let captured = false; + const output = new PassThrough(); + const driver: NativeLifecycleDriver = { + id: 42, + standardInput: null, + standardOutput: output, + standardError: null, + poll: () => ({ exitCode: 0, timedOut: false, running }), + wait: async () => ({ exitCode: 0, timedOut: false }), + warnings: () => [], + outputMetadata: () => undefined, + kill: () => {}, + killForTimeout: () => {}, + free: async () => {}, + }; + const proc = new MxcProcess(driver); + try { + const result = captureProcessOutput(proc).then((value) => { + captured = true; + return value; + }); + const ended = new Promise((resolve) => output.once('end', resolve)); + output.end('finished'); + await ended; + assert.strictEqual(captured, false); + running = false; + assert.deepStrictEqual(await result, { + result: { exitCode: 0, timedOut: false }, + stdout: 'finished', + stderr: '', + }); + } finally { + proc.dispose(); + } +}); + +it('state-aware pipe and PTY spawns forward exact JSON and terminal controls', async () => { + const json = ' { "version":"1.1.0-alpha","phase":"exec","sandboxId":"iso:abc","process":{"commandLine":"echo","timeout":300} } '; + let pipeArgs: unknown[] = []; + let ptyArgs: unknown[] = []; + _setStateAwareBindingSandboxProcessFactory((...args) => { + pipeArgs = args; + return fakeProcess(); + }); + _setExecStateAwareBindingSandboxWithPtyImplementation(async (...args) => { + ptyArgs = args; + return fakeTerminal(); + }); + const proc = await dev.spawnInContainerJson(json, { experimental: true }); + const pty = await dev.spawnInContainerWithPtyJson(json, { + experimental: true, + size: { rows: 25, columns: 100 }, + }); + assert.deepStrictEqual(pipeArgs, [json, true, 300]); + assert.deepStrictEqual(ptyArgs, [json, true, 25, 100, 300]); + proc.dispose(); + pty.dispose(); +}); + +it('all lifecycle phases return the unchanged native response and dry-run flag', async () => { + const received: Array<{ requestJson: string; dryRun: boolean; experimental: boolean }> = []; + const reply = '{"result":{"sandboxId":"wsb:123","metadata":{"future":17}}}'; + _setBindingStateAwareAsyncImplementation(async (request) => { + received.push(request); + return reply; + }); + const phases = [ + ['provision', dev.provisionContainerJson, dev.validateProvisionJson], + ['start', dev.startContainerJson, dev.validateStartJson], + ['stop', dev.stopContainerJson, dev.validateStopJson], + ['deprovision', dev.deprovisionContainerJson, dev.validateDeprovisionJson], + ] as const; + for (const [phase, execute, validate] of phases) { + const json = ` { "version":"1.1.0-alpha","phase":"${phase}","sandboxId":"wsb:123" } `; + assert.strictEqual(await execute(json, { experimental: true }), reply); + assert.strictEqual(await validate(json, { experimental: true }), reply); + assert.deepStrictEqual(received.slice(-2), [ + { requestJson: json, experimental: true, dryRun: false }, + { requestJson: json, experimental: true, dryRun: true }, + ]); + } + const exec = '{"version":"1.0.0","phase":"exec","sandboxId":"iso:abc","process":{"commandLine":"echo"}}'; + assert.strictEqual(await dev.validateProcessJson(exec), reply); + assert.deepStrictEqual(received.at(-1), { requestJson: exec, experimental: false, dryRun: true }); +}); + +it('rejects phase mismatches, malformed options and invalid Unicode before native calls', async () => { + _setBindingStateAwareAsyncImplementation(async () => { + assert.fail('native must not be called'); + }); + const start = '{"version":"1.0.0","phase":"start","sandboxId":"iso:abc"}'; + await assert.rejects(dev.stopContainerJson(start), { code: 'malformed_request' }); + await assert.rejects(dev.spawnInContainerJson(start), { code: 'malformed_request' }); + await assert.rejects(dev.runJson(start), { code: 'malformed_request' }); + await assert.rejects(dev.validateProcessJson(start), { code: 'malformed_request' }); + // @ts-expect-error Deliberately exercise runtime validation of invalid options. + await assert.rejects(dev.startContainerJson(start, { experimental: 'yes' }), + { code: 'malformed_request' }); + // @ts-expect-error Deliberately exercise runtime validation of an invalid PTY size. + await assert.rejects(dev.spawnWithPtyJson(start, { size: null }), + { code: 'malformed_request' }); + await assert.rejects(dev.spawnWithPtyJson(start, { + // @ts-expect-error Unknown PTY size fields must fail at runtime too. + size: { rows: 24, columns: 80, extra: true }, + }), { code: 'malformed_request' }); + await assert.rejects(dev.startContainerJson(start + '\uD800'), { code: 'malformed_request' }); +}); diff --git a/src/mxc-sdk/README.md b/src/mxc-sdk/README.md index b5b68029e..8f11e8c64 100644 --- a/src/mxc-sdk/README.md +++ b/src/mxc-sdk/README.md @@ -10,6 +10,14 @@ configures the backend. See the [consumer glossary](https://github.com/microsoft/mxc/blob/main/docs/glossary.md) for terminology. +`mxc_sdk::v1::dev` provides +[exact-JSON API signatures](https://github.com/microsoft/mxc/blob/main/docs/api-reference/rust/v1/dev.md). +V1.Dev runs caller-authored exact-version requests in-process. It provides +one-shot and existing-container capture, pipe and PTY spawns, lifecycle +operations, and dry-run validation. Lifecycle calls return the complete +native response JSON; capture and live process handles reuse the V1 result +types. Backend support remains mode-dependent. + ## Run to completion and spawn Build a `ContainerRequest` directly, then choose captured output with `run` or diff --git a/src/mxc-sdk/src/dev.rs b/src/mxc-sdk/src/dev.rs new file mode 100644 index 000000000..c39859e8e --- /dev/null +++ b/src/mxc-sdk/src/dev.rs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Caller-authored exact-JSON APIs. + +mod one_shot; +mod state_aware; + +pub use one_shot::{run_json, spawn_json, spawn_with_pty_json}; +pub use state_aware::{ + deprovision_container_json, provision_container_json, run_in_container_json, + spawn_in_container_json, spawn_in_container_with_pty_json, start_container_json, + stop_container_json, validate_deprovision_json, validate_process_json, validate_provision_json, + validate_start_json, validate_stop_json, +}; + +use crate::sandbox::MxcPtySize; + +/// Invocation controls for an exact-JSON operation. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct JsonOptions { + /// Authorize experimental backends separately from the JSON contract version. + pub experimental: bool, +} + +/// Invocation controls for an exact-JSON terminal operation. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PtyJsonOptions { + /// Authorize experimental backends separately from the JSON contract version. + pub experimental: bool, + /// Initial terminal dimensions. + pub size: MxcPtySize, +} diff --git a/src/mxc-sdk/src/dev/one_shot.rs b/src/mxc-sdk/src/dev/one_shot.rs new file mode 100644 index 000000000..39c8cf0c0 --- /dev/null +++ b/src/mxc-sdk/src/dev/one_shot.rs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Exact-JSON one-shot execution operations. + +use crate::mxc_engine::Error; +use crate::sandbox::{ExecutionResult, MxcProcess, MxcPtyProcess}; + +use super::{JsonOptions, PtyJsonOptions}; + +/// Run a caller-authored exact-version one-shot request to completion and capture its output. +/// +/// The registered `version` and one-shot request root are parsed by the engine +/// without stamping or reserializing the input. `options.experimental` only +/// authorizes an experimental backend; it does not select a contract version. +/// Backends without pipe-backed execution reject this capture mode. +/// Nonzero workload exit and timeout are results, not API errors. Parser, +/// launch, and output-read failures return [`Error`]. +pub fn run_json(json: &str, options: JsonOptions) -> Result { + crate::__ffi::run_json(json, options.experimental) +} + +/// Spawn a caller-authored exact-version one-shot request with live pipes. +pub fn spawn_json(json: &str, options: JsonOptions) -> Result { + crate::__ffi::spawn_container_json(json, options.experimental) +} + +/// Spawn a caller-authored exact-version one-shot request with a PTY. +pub fn spawn_with_pty_json(json: &str, options: PtyJsonOptions) -> Result { + crate::__ffi::spawn_with_pty_json(json, options.experimental, options.size) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mxc_engine::ErrorCode; + + #[test] + fn one_shot_spawns_use_exact_parser() { + let json = "{}"; + let options = JsonOptions::default(); + let pty = PtyJsonOptions::default(); + let pipe_error = match spawn_json(json, options) { + Err(error) => error, + Ok(_) => panic!("missing version must fail"), + }; + let pty_error = match spawn_with_pty_json(json, pty) { + Err(error) => error, + Ok(_) => panic!("missing version must fail"), + }; + assert_eq!(pipe_error.code, ErrorCode::MalformedRequest); + assert_eq!(pty_error.code, ErrorCode::MalformedRequest); + } + + #[test] + fn run_json_uses_exact_parser_and_rejects_invalid_requests() { + for json in [ + "{ not json", + r#"{"version":"999.0.0","process":{"commandLine":"echo hi"}}"#, + r#"{"version":"1.0.0","process":{"commandLine":"echo hi"},"extra":true}"#, + ] { + let error = crate::v1::dev::run_json(json, JsonOptions::default()).unwrap_err(); + assert_eq!(error.code, ErrorCode::MalformedRequest, "{json}: {error}"); + } + } + + #[test] + fn run_json_rejects_lifecycle_root() { + let json = r#"{"version":"1.0.0","phase":"start","sandboxId":"iso:unused"}"#; + let error = crate::v1::dev::run_json(json, JsonOptions::default()).unwrap_err(); + assert_eq!(error.code, ErrorCode::MalformedRequest); + } + + #[test] + fn run_json_experimental_option_does_not_select_a_contract_version() { + let json = r#"{"version":"999.0.0","process":{"commandLine":"echo must-not-run"}}"#; + for experimental in [false, true] { + let error = crate::v1::dev::run_json(json, JsonOptions { experimental }).unwrap_err(); + assert_eq!(error.code, ErrorCode::MalformedRequest); + } + } + + #[cfg(target_os = "windows")] + #[test] + #[ignore = "requires an elevated, host-prepped Windows host (see docs/host-prep.md)"] + fn run_json_captures_process_container_output() { + let json = r#"{ + "version":"1.0.0", + "containment":"process", + "process":{"commandLine":"cmd /c echo hello-dev-json","timeout":30000}, + "filesystem":{"readwritePaths":["C:\\Windows\\Temp"]} + }"#; + let output = crate::v1::dev::run_json(json, JsonOptions::default()) + .expect("one-shot ProcessContainer request should run"); + assert_eq!(output.outcome, crate::v1::WaitResult::Exited(0)); + assert!( + String::from_utf8_lossy(&output.stdout).contains("hello-dev-json"), + "captured stdout: {:?}", + output.stdout + ); + } +} diff --git a/src/mxc-sdk/src/dev/state_aware.rs b/src/mxc-sdk/src/dev/state_aware.rs new file mode 100644 index 000000000..4e86b5d07 --- /dev/null +++ b/src/mxc-sdk/src/dev/state_aware.rs @@ -0,0 +1,252 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Exact-JSON execution and lifecycle operations for state-aware containers. + +use crate::mxc_contract::dev::{probe_phase, Phase, PhaseProbeError}; +use crate::mxc_engine::{Error, ErrorCode}; +use crate::sandbox::{ExecutionResult, MxcProcess, MxcPtyProcess}; + +use super::{JsonOptions, PtyJsonOptions}; + +fn require_phase(json: &str, expected: Phase, operation: &str) -> Result<(), Error> { + let declared = probe_phase(json).map_err(|error| { + let message = match error { + PhaseProbeError::InvalidDeclaration(source) => { + format!("Invalid phase declaration: {source}") + } + PhaseProbeError::UnsupportedPhase(_) => "Unsupported phase".to_string(), + }; + Error::new(ErrorCode::MalformedRequest, message) + })?; + match declared { + Some(actual) if actual == expected => Ok(()), + Some(actual) => Err(Error::new( + ErrorCode::MalformedRequest, + format!( + "{operation} requires phase '{}', got '{}'", + expected.as_str(), + actual.as_str() + ), + )), + None => Err(Error::new( + ErrorCode::MalformedRequest, + format!("{operation} requires phase '{}'", expected.as_str()), + )), + } +} + +fn run_phase( + json: &str, + expected: Phase, + operation: &str, + dry_run: bool, + options: JsonOptions, +) -> Result { + require_phase(json, expected, operation)?; + crate::__ffi::run_lifecycle_json(json, dry_run, options.experimental) +} + +/// Capture an exec workload without taking ownership of its container. +pub fn run_in_container_json(json: &str, options: JsonOptions) -> Result { + require_phase(json, Phase::Exec, "run_in_container_json")?; + crate::wait_with_output(crate::__ffi::execute_lifecycle_json( + json, + options.experimental, + )?) +} + +/// Spawn an exec workload with live pipes; the caller owns the returned process. +pub fn spawn_in_container_json(json: &str, options: JsonOptions) -> Result { + require_phase(json, Phase::Exec, "spawn_in_container_json")?; + crate::__ffi::execute_lifecycle_json(json, options.experimental) +} + +/// Spawn an exec workload with a PTY; the caller owns the returned terminal. +pub fn spawn_in_container_with_pty_json( + json: &str, + options: PtyJsonOptions, +) -> Result { + require_phase(json, Phase::Exec, "spawn_in_container_with_pty_json")?; + crate::__ffi::spawn_in_container_with_pty_json(json, options.size, options.experimental) +} + +/// Provision a container and return its complete native response JSON. +pub fn provision_container_json(json: &str, options: JsonOptions) -> Result { + run_phase( + json, + Phase::Provision, + "provision_container_json", + false, + options, + ) +} + +/// Start a container and return its complete native response JSON. +pub fn start_container_json(json: &str, options: JsonOptions) -> Result { + run_phase(json, Phase::Start, "start_container_json", false, options) +} + +/// Stop a container and return its complete native response JSON. +pub fn stop_container_json(json: &str, options: JsonOptions) -> Result { + run_phase(json, Phase::Stop, "stop_container_json", false, options) +} + +/// Deprovision a container and return its complete native response JSON. +pub fn deprovision_container_json(json: &str, options: JsonOptions) -> Result { + run_phase( + json, + Phase::Deprovision, + "deprovision_container_json", + false, + options, + ) +} + +/// Validate provision without allocating a container; return the complete response JSON. +pub fn validate_provision_json(json: &str, options: JsonOptions) -> Result { + run_phase( + json, + Phase::Provision, + "validate_provision_json", + true, + options, + ) +} + +/// Validate start without starting a container; return the complete response JSON. +pub fn validate_start_json(json: &str, options: JsonOptions) -> Result { + run_phase(json, Phase::Start, "validate_start_json", true, options) +} + +/// Validate stop without stopping a container; return the complete response JSON. +pub fn validate_stop_json(json: &str, options: JsonOptions) -> Result { + run_phase(json, Phase::Stop, "validate_stop_json", true, options) +} + +/// Validate deprovision without releasing a container; return the complete response JSON. +pub fn validate_deprovision_json(json: &str, options: JsonOptions) -> Result { + run_phase( + json, + Phase::Deprovision, + "validate_deprovision_json", + true, + options, + ) +} + +/// Validate an exec request without starting a workload; return the complete response JSON. +pub fn validate_process_json(json: &str, options: JsonOptions) -> Result { + run_phase(json, Phase::Exec, "validate_process_json", true, options) +} + +#[cfg(test)] +mod tests { + use super::*; + + type JsonPhaseCall = fn(&str, JsonOptions) -> Result; + + #[test] + fn phase_specific_calls_reject_a_different_phase_before_dispatch() { + let start = r#"{"version":"1.0.0","phase":"start","sandboxId":"iso:unused"}"#; + let stop = r#"{"version":"1.0.0","phase":"stop","sandboxId":"iso:unused"}"#; + let options = JsonOptions::default(); + let calls: &[(&str, JsonPhaseCall)] = &[ + ("provision_container_json", provision_container_json), + ("stop_container_json", stop_container_json), + ("deprovision_container_json", deprovision_container_json), + ("validate_provision_json", validate_provision_json), + ("validate_stop_json", validate_stop_json), + ("validate_deprovision_json", validate_deprovision_json), + ("validate_process_json", validate_process_json), + ]; + for (name, call) in calls { + let error = call(start, options).unwrap_err(); + assert_eq!(error.code, ErrorCode::MalformedRequest, "{name}"); + assert!(error.message.contains(name), "{error}"); + } + for (name, call) in [ + ( + "start_container_json", + start_container_json as JsonPhaseCall, + ), + ("validate_start_json", validate_start_json), + ] { + let error = call(stop, options).unwrap_err(); + assert_eq!(error.code, ErrorCode::MalformedRequest, "{name}"); + assert!(error.message.contains(name), "{error}"); + } + + for (name, error) in [ + ( + "run_in_container_json", + run_in_container_json(start, options).err(), + ), + ( + "spawn_in_container_json", + spawn_in_container_json(start, options).err(), + ), + ( + "spawn_in_container_with_pty_json", + spawn_in_container_with_pty_json(start, PtyJsonOptions::default()).err(), + ), + ] { + let error = error.expect("a non-exec phase must not start a workload"); + assert_eq!(error.code, ErrorCode::MalformedRequest, "{name}"); + assert!(error.message.contains(name), "{error}"); + } + } + + #[test] + fn phase_probe_rejects_missing_duplicate_and_invalid_phase() { + for json in [ + r#"{"version":"1.0.0","process":{"commandLine":"echo hi"}}"#, + r#"{"version":"1.0.0","phase":"start","phase":"stop"}"#, + r#"{"version":"1.0.0","phase":null}"#, + r#"{"version":"1.0.0","phase":"no-such-phase"}"#, + "{ not json", + ] { + let error = start_container_json(json, JsonOptions::default()).unwrap_err(); + assert_eq!(error.code, ErrorCode::MalformedRequest, "{json}: {error}"); + } + } + + #[test] + fn matching_phase_is_still_checked_by_the_exact_contract() { + for json in [ + r#"{"version":"999.0.0","phase":"start","sandboxId":"iso:unused"}"#, + r#"{"version":"1.0.0","phase":"start","sandboxId":"iso:unused","extra":true}"#, + ] { + let error = validate_start_json(json, JsonOptions::default()).unwrap_err(); + assert_eq!(error.code, ErrorCode::MalformedRequest, "{json}: {error}"); + } + } + + #[test] + fn exec_paths_reach_native_backend_validation_without_launching() { + let json = r#"{"version":"1.0.0","phase":"exec","sandboxId":"nosuchbackend:abc123","process":{"commandLine":"echo hi"}}"#; + let options = JsonOptions::default(); + for error in [ + run_in_container_json(json, options).err(), + spawn_in_container_json(json, options).err(), + spawn_in_container_with_pty_json(json, PtyJsonOptions::default()).err(), + validate_process_json(json, options).err(), + ] { + let error = error.expect("an unregistered backend must not run a workload"); + assert_eq!(error.code, ErrorCode::UnsupportedContainment, "{error}"); + } + } + + #[test] + fn dry_run_requires_separate_experimental_authorization() { + let json = + r#"{"version":"1.1.0-alpha","phase":"provision","containment":"windows_sandbox"}"#; + let error = validate_provision_json(json, JsonOptions::default()).unwrap_err(); + assert_eq!(error.code, ErrorCode::BackendUnavailable); + assert!(error.message.contains("experimental"), "{error}"); + + if let Err(error) = validate_provision_json(json, JsonOptions { experimental: true }) { + assert_ne!(error.code, ErrorCode::BackendUnavailable, "{error}"); + } + } +} diff --git a/src/mxc-sdk/src/lib.rs b/src/mxc-sdk/src/lib.rs index cef215ddf..27fbe382a 100644 --- a/src/mxc-sdk/src/lib.rs +++ b/src/mxc-sdk/src/lib.rs @@ -202,6 +202,7 @@ pub use core_modules::plm; pub use core_modules::process_security_environment_spec; mod configs; +mod dev; mod options; mod policy; mod sandbox; @@ -225,6 +226,11 @@ use sandbox::{ExecutionResult, MxcProcess}; /// schema version. /// pub mod v1 { + /// Caller-authored exact-JSON execution and lifecycle operations. + pub mod dev { + pub use crate::dev::*; + } + pub use crate::mxc_engine::{ available_backends, platform_support, AvailableBackend, BackendCapability, BubblewrapNetworkSupport, Error, ErrorCode, PlatformSupport, ProxyEnforcement, diff --git a/src/mxc-sdk/tests/state_aware.rs b/src/mxc-sdk/tests/state_aware.rs index 89d96af1e..75c255eb3 100644 --- a/src/mxc-sdk/tests/state_aware.rs +++ b/src/mxc-sdk/tests/state_aware.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Host-independent tests for typed lifecycle APIs and internal binding adapters. +//! Host-independent tests for typed and raw lifecycle APIs and internal binding adapters. //! //! These exercise request parsing, phase routing, and error mapping without a //! live host backend. The lifecycle backends — IsolationSession, WSLc @@ -20,9 +20,10 @@ use mxc_sdk::__ffi::{execute_lifecycle_json, run_lifecycle_json}; use mxc_sdk::v1::{ - container, ContainerId, DeprovisionOptions, ExecutionRequest, ExecutionResult, LifecycleResult, - MxcProcess, ProvisionOptions, ProvisionRequest, ProvisionResult, RunInContainerOptions, - SpawnInContainerOptions, StartOptions, StopOptions, ValidationResult, + container, dev, ContainerId, DeprovisionOptions, ExecutionRequest, ExecutionResult, + LifecycleResult, MxcProcess, MxcPtyProcess, ProvisionOptions, ProvisionRequest, + ProvisionResult, RunInContainerOptions, SpawnInContainerOptions, StartOptions, StopOptions, + ValidationResult, }; use mxc_sdk::v1::{Error, ErrorCode}; @@ -61,6 +62,35 @@ fn typed_lifecycle_api_is_operation_specific() { ) -> Result = container::validate_process; } +#[test] +fn dev_json_api_is_public_and_operation_specific() { + type JsonPhaseCall = fn(&str, dev::JsonOptions) -> Result; + + let _: fn(&str, dev::JsonOptions) -> Result = dev::run_json; + let _: fn(&str, dev::JsonOptions) -> Result = dev::spawn_json; + let _: fn(&str, dev::PtyJsonOptions) -> Result = dev::spawn_with_pty_json; + let _: fn(&str, dev::JsonOptions) -> Result = + dev::run_in_container_json; + let _: fn(&str, dev::JsonOptions) -> Result = dev::spawn_in_container_json; + let _: fn(&str, dev::PtyJsonOptions) -> Result = + dev::spawn_in_container_with_pty_json; + let _: [JsonPhaseCall; 9] = [ + dev::provision_container_json, + dev::start_container_json, + dev::stop_container_json, + dev::deprovision_container_json, + dev::validate_provision_json, + dev::validate_start_json, + dev::validate_stop_json, + dev::validate_deprovision_json, + dev::validate_process_json, + ]; + + let json = r#"{"version":"0.9.0-alpha","phase":"start","sandboxId":"nosuchbackend:abc123"}"#; + let error = dev::validate_start_json(json, dev::JsonOptions::default()).unwrap_err(); + assert_eq!(error.code, ErrorCode::UnsupportedContainment); +} + #[test] fn typed_state_aware_requests_have_no_caller_schema_version() { let _ = ProvisionRequest::isolation_session(None);