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 3d7f7af37..7452ea172 100644 --- a/docs/api-reference/dotnet/v1/api.md +++ b/docs/api-reference/dotnet/v1/api.md @@ -21,7 +21,11 @@ and operation options. Create-and-run PTY support covers IsolationSession, Bubbl LXC, and Seatbelt direct execution. Existing-container PTY support remains IsolationSession-only. Seatbelt PTY rejects `guiAccess`. 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`. Read the terminal output concurrently rather than only after waiting. On macOS a workload cannot exit while its terminal output is still unread, so a caller 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 c9655046a..07bdac77b 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`. 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. Read the terminal output concurrently rather than only after waiting. On macOS a workload cannot exit while its terminal output is still unread, so a caller 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 e4924153c..89b5c21ce 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 2db7a64b4..54bf8e823 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 3eb58b8a2..066ca8c08 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 7870f9133..a29862718 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`. 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 030ba59b2..b5719ae14 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 e12b066a8..526a92f11 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 de8436f83..e39b78781 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);