From d9985fc7e4f220f29f39fdab4d7636020a5945b5 Mon Sep 17 00:00:00 2001 From: tbyfield <68976176+tbyfield@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:34:34 -0800 Subject: [PATCH 1/5] Implement W365 Agents Phase 2 checkout and release lifecycle Add environment-configured checkout and terminal release through the normal engine, preserving session cleanup identities and uncertain outcomes. Expose lifecycle operations through the existing Windows executor only; leave Linux and macOS CLI lifecycle support unchanged. Keep readiness, execution and public SDK access explicitly deferred. Validate parser and engine behavior with cross-platform trusted-TLS fixtures and fresh-process recovery through the Windows executor. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/Build.Linux.Job.yml | 9 + .github/workflows/Build.MacOS.Job.yml | 8 + .github/workflows/Build.Windows.Job.yml | 17 + .../backends/w365agents/w365agents-backend.md | 177 ++++++ docs/containment-policy/dev/policy.md | 19 +- .../designs/1615-w365a-backend/README.md | 58 +- .../designs/1615-w365a-backend/api.md | 10 + .../ci-validation-infrastructure.md | 12 + docs/schema.md | 9 + src/Cargo.lock | 279 ++++++++ src/ffi/mxc_ffi/src/state_aware.rs | 44 ++ src/mxc-sdk/Cargo.toml | 7 +- src/mxc-sdk/src/backends/mod.rs | 2 + src/mxc-sdk/src/backends/w365agents/mod.rs | 185 ++++++ .../src/backends/w365agents/service.rs | 379 +++++++++++ src/mxc-sdk/src/backends/w365agents/tests.rs | 594 ++++++++++++++++++ src/mxc-sdk/src/core/mxc_common/cmdline.rs | 6 +- .../dev/state_aware.rs | 15 +- .../dev/w365_agents.rs | 63 +- .../src/core/mxc_common/config_parser.rs | 83 ++- src/mxc-sdk/src/core/mxc_common/models.rs | 5 + .../src/core/mxc_common/policy_identity.rs | 4 +- .../core/mxc_common/state_aware_binding.rs | 24 + .../core/mxc_common/state_aware_dispatch.rs | 1 + .../core/mxc_common/state_aware_operation.rs | 11 + .../core/mxc_common/state_aware_request.rs | 17 + .../src/core/mxc_engine/backend_registry.rs | 15 +- src/mxc-sdk/src/core/mxc_engine/run.rs | 3 + .../src/core/mxc_engine/state_aware.rs | 38 ++ .../mxc_contract/v1_1_0_alpha/w365_agents.rs | 39 +- 30 files changed, 1966 insertions(+), 167 deletions(-) create mode 100644 docs/backends/w365agents/w365agents-backend.md create mode 100644 src/mxc-sdk/src/backends/w365agents/mod.rs create mode 100644 src/mxc-sdk/src/backends/w365agents/service.rs create mode 100644 src/mxc-sdk/src/backends/w365agents/tests.rs diff --git a/.github/workflows/Build.Linux.Job.yml b/.github/workflows/Build.Linux.Job.yml index 472c806434..ec606a43c0 100644 --- a/.github/workflows/Build.Linux.Job.yml +++ b/.github/workflows/Build.Linux.Job.yml @@ -172,6 +172,15 @@ jobs: cargo test --locked --release --target "${{ matrix.target }}" -p mxc-sdk -p mxc_ffi --lib state_aware cargo test --locked --release --target "${{ matrix.target }}" -p mxc-sdk --test state_aware + - name: Test W365 checkout and release with trusted TLS fixtures + working-directory: src + shell: bash + run: | + set -euo pipefail + names="$(cargo test --locked -p mxc-sdk --lib --target "${{ matrix.target }}" -- --list)" + grep -Fq "backend_modules::w365agents::tests::lifecycle_contract_and_recovery_fixture: test" <<< "$names" + cargo test --locked -p mxc-sdk --lib --target "${{ matrix.target }}" w365 -- --include-ignored + - name: Test native module lifetime working-directory: src run: | diff --git a/.github/workflows/Build.MacOS.Job.yml b/.github/workflows/Build.MacOS.Job.yml index e14f41ecd9..36b919e0ac 100644 --- a/.github/workflows/Build.MacOS.Job.yml +++ b/.github/workflows/Build.MacOS.Job.yml @@ -62,6 +62,14 @@ jobs: cargo test --locked --release --target aarch64-apple-darwin -p mxc-sdk -p mxc_ffi --lib state_aware cargo test --locked --release --target aarch64-apple-darwin -p mxc-sdk --test state_aware + - name: Test W365 checkout and release with trusted TLS fixtures + shell: bash + run: | + set -euo pipefail + names="$(cargo test --locked -p mxc-sdk --lib --target aarch64-apple-darwin -- --list)" + grep -Fq "backend_modules::w365agents::tests::lifecycle_contract_and_recovery_fixture: test" <<< "$names" + cargo test --locked -p mxc-sdk --lib --target aarch64-apple-darwin w365 -- --include-ignored + - name: Test Seatbelt streaming and PTY run: cargo test --locked --release --target aarch64-apple-darwin -p mxc-sdk --test streaming diff --git a/.github/workflows/Build.Windows.Job.yml b/.github/workflows/Build.Windows.Job.yml index 91fe8a4f9d..966c4f1aec 100644 --- a/.github/workflows/Build.Windows.Job.yml +++ b/.github/workflows/Build.Windows.Job.yml @@ -158,6 +158,23 @@ jobs: -BinaryDirectory 'src/target/${{ matrix.target }}/release' ` -Features '${{ matrix.features }}' + - name: Test W365 checkout and release with trusted TLS fixtures + shell: pwsh + run: | + cargo build --locked -p wxc --target '${{ matrix.target }}' ` + --target-dir target/w365-fixture --features test-support + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $env:MXC_W365_TEST_EXECUTOR = (Resolve-Path 'target/w365-fixture/${{ matrix.target }}/debug/wxc-exec.exe').Path + $names = @(cargo test --locked -p mxc-sdk --lib --target '${{ matrix.target }}' -- --list) + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + foreach ($case in @('lifecycle_contract_and_recovery_fixture', 'lifecycle_fresh_executor_fixture')) { + if (-not ($names -match "backend_modules::w365agents::tests::${case}: test")) { + throw "Missing required W365 fixture: $case" + } + } + cargo test --locked -p mxc-sdk --lib --target '${{ matrix.target }}' w365 -- --include-ignored + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Build debug executor for isolated telemetry smoke tests env: MXC_TELEMETRY_COMPONENT_PATH: '' diff --git a/docs/backends/w365agents/w365agents-backend.md b/docs/backends/w365agents/w365agents-backend.md new file mode 100644 index 0000000000..ca748639fc --- /dev/null +++ b/docs/backends/w365agents/w365agents-backend.md @@ -0,0 +1,177 @@ +# Windows 365 for Agents: checkout and release + +> **Audience:** MXC consumers and developers + +This experimental backend implements **Phase 2 only**: checkout through +`provision`, and terminal release through `stop` or `deprovision`. It is +included in normal Windows, Linux and macOS builds. Phase 2 exposes checkout +and release through the Windows executor's existing lifecycle interface, +with `--experimental` authorization. The Linux and macOS executors do not +expose lifecycle operations; adding that CLI support is outside this slice. +Local probes do not report W365 +availability and never authenticate, discover a pool or check out a Cloud PC. + +Start/readiness, captured or streaming exec, PTY, create-and-run execution +and placement sessions are unavailable. They fail explicitly without service +work. Typed W365 SDK authoring APIs do not exist. Rust, .NET and Node Dev SDK +JSON lifecycle access is also deliberately unavailable until cross-SDK +structured error metadata and public SDK validation are complete. +Native executor responses retain structured errors and cleanup identities. + +## Configuration and contract + +Use the `1.1.0-alpha` development contract. Provision accepts only these +settings under `w365Agents.provision`: + +| Field | Meaning | +| --- | --- | +| `poolId` | Required hyphenated ASCII GUID of the authorized pool. | +| `userType` | Optional, `Local`, `HumanUser`, or `Agentic`; omission sends no `user-type` header. | +| `userObjectId` | Optional hyphenated ASCII GUID; omission sends no `user-object-id` header. | + +Omission of either identity field does not infer a user or silently select +`Local`. Omitted `w365Agents`, an empty wrapper or missing provision settings +fail before checkout. Unknown fields, nulls and per-call endpoint, token or +timeout overrides are rejected. Non-provision operations reject `w365Agents`, +even `{}`. Unsupported filesystem, network, UI, clipboard and proxy policy +fails before service work; MXC cannot enforce local policy on this remote resource. + +Supply configuration in the **caller environment**: + +| Variable | Requirement | +| --- | --- | +| `W365A_CONTROLPLANE_URL` | Bare ASCII DNS hostname, without scheme, port, path, query, credentials or whitespace. HTTPS uses port 443. | +| `W365A_CP_TOKEN` | Nonempty resource-specific bearer token, without whitespace or control characters. | + +Missing, invalid or non-Unicode values fail locally. Each call reads the +environment again. MXC does not acquire, cache or renew tokens. The caller +renews credentials before a later cleanup call. Checkout uses the design's +45-second client budget; release uses its separate 30-second budget. These +are transport budgets, not promises about service reset or resource lifetime. +Production uses validated rustls TLS, no redirects and no ambient HTTP proxy. + +## Service contract and identity + +Checkout sends **one** bodyless POST to +`/api/pools/{poolId}/sessions?api-version=2.0`, forwarding only supplied +user-type and user-object-id headers. Release sends DELETE to +`/api/sessions/{sessionId}?api-version=2.0`, with `x-ms-sessionId`. +Every request has an independent `x-ms-client-request-id`; safe returned +`x-ms-request-id` values are retained on errors. There is no allocation +retry, hidden cleanup, replacement checkout or durable local registry. + +Successful checkout requires HTTP 200, `status: "Succeeded"` and a valid +`sessionId`. An omitted `computerId` uses the session ID; a supplied computer +ID must be a valid, equal GUID. IDs are compared case-insensitively and +returned with lowercase GUIDs as `w365:{poolId}:{sessionId}`. +Metadata includes the pool, session and computer IDs, not response URLs, +tokens or ticket-bearing metadata. + +Rejected successful checkout retains a valid cleanup identity as +`error.details.sandboxId`, `poolId` and `sessionId`, with +`possiblySucceeded: true`. No ID is fabricated for an absent or invalid +session ID. Unparseable, dropped and ambiguous checkout replies report +uncertainty and request context. Never automatically repeat that POST. +HTTP authorization, missing-pool, throttling and server failures have +operation-specific `details.code` values. + +Release validates the complete ID before DELETE and always targets its +session, never a rejected computer ID. HTTP 200, 202, 204 and 404 mean accepted +or already absent; they do **not** prove guest reset, sanitization or reusable +capacity. Both release alternatives return +`result.metadata.releaseRequested: true` and `restartable: false`. +Failed or uncertain release retains the ID in error details for an explicit +retry against the same session. A later checkout B does not replace or clean +up checkout A. + +### Service validation limit + +The implementation follows the approved design/reference API 2.0 header +contract. The supplied North Europe INT service evidence used API **3.0**, +a JSON body with `sessionKind: "local"` and `userObjectId`, and readiness +polling via POST with `x-ms-sessionId`. That is a different service contract: +it does not validate API 2.0 checkout for any accepted user type, omitted +identity or release. Explicit `userType: "Local"` sends the API 2.0 +`user-type: Local` header, not an API 3.0 `sessionKind` body. +No API-version compatibility layer or `Local` substitution is implemented. +Actual checkout/release compatibility for each user type remains to be confirmed +with the service owner and an explicitly authorized live run. + +## Optional live validation: explicit invocation only + +**Live Phase 2 validation has not been run.** Do not use another caller's +active checkout or token. Obtain authorization for a compatible API 2.0 +pool and user identity and confirm the release contract before allocating. +Use a normal executor, not a `test-support` build. Supply the two required +environment variables through the caller's approved credential mechanism; +never put bearer tokens in JSON, command-line arguments, shell history or logs. + +Save the following credential-free provision body as `provision.json`, +replacing the synthetic pool and user GUIDs with approved values: + +```json +{ + "version": "1.1.0-alpha", + "containment": "w365_agents", + "w365Agents": { + "provision": { + "poolId": "11111111-1111-1111-1111-111111111111", + "userType": "HumanUser", + "userObjectId": "22222222-2222-2222-2222-222222222222" + } + } +} +``` + +Run **once**, capturing stdout and the exit status: + +```text +wxc-exec.exe --config provision.json --operation provision --experimental +``` + +Immediately retain the returned `result.sandboxId`, or a rejected checkout's +`error.details.sandboxId`, together with deployment/account context. +Do not call start or exec in this slice. +Without a valid ID, report the client/server request IDs to the authorized +service operator for reconciliation; do not fabricate an ID or replay checkout. + +Save `{"version":"1.1.0-alpha"}` as `release.json`. In a fresh process with +valid, caller-renewed credentials, pass the retained ID unchanged: + +```text +wxc-exec.exe --config release.json --operation deprovision --container-id --experimental +``` + +`stop` is the alternative terminal release, not suspension. +Check the release exit status and complete response. On failed or uncertain +release, retain the ID and report primary/cleanup failures separately. +An explicit retry may use renewed credentials and the same ID; do not check +out a replacement as cleanup. Record unresolved IDs for service reconciliation. +Report release acceptance separately from service-confirmed reset completion. + +## Offline functional fixtures + +`backend_modules::w365agents::tests::lifecycle_contract_and_recovery_fixture` +uses a controlled TLS service through the normal development parser, +experimental authorization, checked binding and backend dispatch. +This fixture runs on Windows, Linux and macOS. +On Windows, `lifecycle_fresh_executor_fixture` additionally launches independent +`wxc-exec` processes for checkout and release with renewed synthetic credentials. +The latter is ignored by default because it requires a separately built +fixture executor; Windows CI explicitly requires and runs it. + +From `src\` on Windows: + +```powershell +cargo build --locked -p wxc --features test-support +$env:MXC_W365_TEST_EXECUTOR = (Resolve-Path target\debug\wxc-exec.exe).Path +cargo test --locked -p mxc-sdk --lib w365 -- --include-ignored +``` + +Windows CI uses a separate target directory so fixture executors cannot replace +shipping artifacts. Linux and macOS run the engine fixture without a fixture +executor. +Only test builds honor `MXC_W365_TEST_CA` and `MXC_W365_TEST_PORT`; they add a +trusted fixture certificate, never disable certificate or hostname validation. +Shipping builds ignore these variables. Fixtures use only loopback services, +synthetic credentials and generated certificates; no live tenant is needed. diff --git a/docs/containment-policy/dev/policy.md b/docs/containment-policy/dev/policy.md index 64a78fc36b..8f1d56b38f 100644 --- a/docs/containment-policy/dev/policy.md +++ b/docs/containment-policy/dev/policy.md @@ -323,7 +323,7 @@ of putting those fields in the JSON file; the executor inserts them before parsing. Placement's nested `wslc.provision.sessionId` stays in JSON. JSON library/FFI calls include routing fields as shown above. -### W365 Agents (contract only) +### W365 Agents (experimental checkout and release) In `1.1.0-alpha`, provision accepts `containment: "w365_agents"` and `w365Agents.provision`: @@ -343,9 +343,14 @@ or an agent invokes MXC. Service authentication differs by kind: `Local` disallows an auxiliary token; `HumanUser` allows one for assertion-based startup if it represents the same human user; `Agentic` requires one representing the selected Agent User. -This contract slice does not provide auxiliary-token settings or implement -session authentication. - -The backend is not implemented: provision returns `backend_unavailable`, even -with experimental opt-in. Later phases using a `w365:` ID return -`unsupported_containment`. +This lifecycle slice does not provide auxiliary-token settings, so Agentic +session authentication is not supported. + +With executor experimental opt-in, provision checks out a session and stop or +deprovision requests terminal release using its retained `w365:` ID. +Start/readiness and exec remain unavailable, and later phases reject +`w365Agents` settings. Service configuration currently comes from the caller +environment. Typed W365 SDK APIs do not exist, and Dev SDK JSON lifecycle +access remains unavailable. See the +[W365 backend guide](../../backends/w365agents/w365agents-backend.md) for +configuration, cleanup and service-validation limits. diff --git a/docs/development/architecture/designs/1615-w365a-backend/README.md b/docs/development/architecture/designs/1615-w365a-backend/README.md index 98cf231490..da8aca0206 100644 --- a/docs/development/architecture/designs/1615-w365a-backend/README.md +++ b/docs/development/architecture/designs/1615-w365a-backend/README.md @@ -95,6 +95,11 @@ not separate reasons to choose W365A. ## Proposed behavior +**Current implementation:** [Phase 2 checkout/release](../../../../backends/w365agents/w365agents-backend.md) +implements the approved environment-only provision contract and terminal release. +Start/readiness, exec and public Dev SDK access remain deferred. The full +lifecycle and SDK behavior below describe later stages, not current capabilities. + The proposal adds experimental containment `w365_agents` and the `w365Agents` phase settings. Callers use MXC request JSON through the executor or the existing @@ -213,10 +218,8 @@ Existing host checks and experimental classifications are retained. | --- | --- | --- | --- | | `w365_agents` (new) | Yes; lifecycle-only remote backend. | An MXC-supported client OS and architecture, DNS/TLS access, valid W365 service credentials and an assigned pool with compatible DesktopControl.Exec. No W365-specific client OS or architecture restriction. | `ProbeCoverage::NotDetected`; omitted from local availability results. Local probing cannot determine remote usability. | -As with WSLc, a Cargo feature gates the backend implementation and backend-only -optional dependencies, not request contracts or generated schemas. Standard -executor and SDK native release builds enable the feature; builds without it -return `backend_unavailable` before backend execution. +The backend implementation is included in normal builds on supported client +platforms; it has no opt-in Cargo feature. Every phase still requires runtime experimental authorization: `--experimental` for the executor or the Dev SDK's `JsonOptions` experimental flag, even when its request contract parses. @@ -253,27 +256,30 @@ MXC request JSON has `version: "1.1.0-alpha"` and one of `phase: "provision"`, Direct executor calls use existing `--operation` and `--container-id` controls for those routing values. No new CLI operation or SDK entrypoint is proposed. -Only the selected phase's `w365Agents.` object is accepted; unknown -fields and other phase payloads are rejected, not ignored. Provision requires +Only provision accepts `w365Agents.provision`; start, exec, stop and deprovision +use the ordinary `sandboxId` and process contracts and reject `w365Agents`, +including an empty object. Unknown fields are rejected, not ignored. Provision requires `w365Agents.provision.poolId`: absent `w365Agents`, `{}`, or a missing phase object fails backend validation; a supplied empty provision object lacks the -required field and fails contract parsing. For other phases, absent settings -use backend defaults/environment; a present `{}` stays present through parsing -and binding, then resolves the same defaults in the backend. Null is not an -alternative to omission. Parsers preserve presence; backend code owns defaults. +required field and fails contract parsing. Null is not an alternative to +omission. Parsers preserve presence; backend code owns defaults. | Field | Allowed input and omitted/empty behavior | | --- | --- | | `w365Agents.provision.poolId` | Required string, 36-character hyphenated ASCII-hex GUID (`8-4-4-4-12`). No empty/blank value or automatic selection. | | Provision `userType` | `"Local"`, `"HumanUser"`, or `"Agentic"` when supplied; empty/other values fail. Omission sends no `user-type` header; MXC does not select an identity or session kind. | | Provision `userObjectId` | If supplied, a canonical GUID sent as `user-object-id`; empty/blank fails. Omission sends no header. | -| Provision/stop/deprovision `cpBaseUrl` | Bare W365 service hostname for checkout and release, no scheme, credentials, query or path; otherwise use `W365A_CONTROLPLANE_URL`. Blank counts as absent. Missing/invalid resolved host fails before a service request. | -| Provision/stop/deprovision `cpBearerToken` | Nonblank resource-specific token or `W365A_CP_TOKEN`; blank counts as absent. Missing resolved token fails. Explicit nonblank host requires its own explicit nonblank token. | -| Start/exec `dpSuffix` | DNS suffix used to construct `{poolId}.{suffix}`, not a URL; otherwise `W365A_DP_SUFFIX`. Blank counts as absent. Same explicit-host/token pairing rule. | -| Start `dpBearerToken`; exec `bearerToken` | Nonblank resource-specific token for readiness/execution or `W365A_DP_TOKEN`; blank counts as absent. Missing resolved token fails; no borrowing an ambient token for an explicit host override. | -| Provision `provisionTimeoutMs` | Optional unsigned 64-bit milliseconds; default 45000. Zero is passed to transport, not treated as infinite. | -| Start `readyTimeoutMs` | Optional unsigned 64-bit milliseconds; default 120000. Zero performs one status poll. Timeout reports `attach_timeout` with last observed state; no implicit release. | -| Start `pollIntervalMs` | Optional unsigned 64-bit milliseconds; default 2000. Zero adds no sleep. No automatic retry of a failed HTTP status request. | + +Service configuration belongs to the caller environment, not request JSON. +Checkout and release require `W365A_CONTROLPLANE_URL` (a bare ASCII DNS +hostname without scheme, port, path, query, credentials or whitespace) and +`W365A_CP_TOKEN` (a nonempty resource-specific bearer token without whitespace +or control characters). Missing, blank, invalid or non-Unicode configuration +fails locally; MXC does not acquire or renew tokens. Read configuration again +for each invocation, including release in a fresh process with renewed credentials. +The existing design budgets are 45000 ms for checkout and 30000 ms for release; +there are no per-request timeout overrides or new renewal mechanism. +Readiness and exec environment settings remain deferred with their implementation. Service defaults are HTTPS port 443, checkout/release API `2.0`, readiness API `1.0` and exec capability `microsoft.mxc-exec`. Additional @@ -499,13 +505,13 @@ the shared scenarios below. | `mxc_contract_v1_1_0_alpha` W365 fixtures | All five phase requests, missing/empty/null/wrong-phase fields; published 1.0 requests. | Development fields parse only in their phase/version; preserve optional presence, reject unknowns/nulls; published fixtures and schemas unchanged. | | Engine authorization/binding tests | Invoke every phase through normal executor and Dev SDK builds, without experimental opt-in, then authorized. Repeat with the backend omitted. Include relayed/piped exec and unsupported create-and-run, placement-session and PTY requests. | Contract parsing is unchanged when the backend is omitted; missing backend or opt-in fails before backend work. No network for unsupported modes, no authorization through JSON; checked dispatch accepts relayed and piped exec. | | `w365agents_lifecycle` native fixture (new) | Unsupported filesystem/network/UI/clipboard/proxy blocks, including empty/default policy blocks, and malformed IDs. | Contract or backend validation fails before any checkout/status/upgrade/delete request; fixture records zero requests. | -| Lifecycle/config fixture | Omitted/empty phase settings, blank/whitespace hosts/tokens, missing environment; explicit host plus absent/blank token with an ambient token. | Defaults/presence follow agreed contract; missing configuration fails; no credential leaves for an explicit unpaired host. | +| Lifecycle/config fixture | Omitted/empty provision wrapper, missing/blank/invalid environment host or token, and rejected per-call overrides. | Presence follows the Phase 1 contract; missing configuration fails before requests; no per-call host or token is accepted. | | Lifecycle environment fixture | Omitted `process.env`, `[]`, and supplied entries, each with omitted/false/true `inheritDefaultEnv`. | Accepted requests forward only supplied entries, with none for omitted/empty env; the inheritance flag is not forwarded. Supplied env with false/omitted fails with `policy_validation` before any remote connection. No MXC host environment is forwarded. Actual guest inheritance and overrides are checked in the Phase 4 live scenario below. | | Lifecycle process-input fixture | Command with omitted/empty/supplied `cwd` and omitted/zero/positive `process.timeout`. | The service fixture receives the unchanged command, no `cwd` for omitted/empty input or the supplied guest path otherwise, and `timeoutMs` of 0 for omitted/zero input or the supplied positive millisecond value. | | Phase 4 live process-input scenario | Execute through the native lifecycle path in a known guest directory, with known non-sensitive guest-default variables and an override of one using `inheritDefaultEnv: true`. Repeat with omitted env and with `env: []` plus `inheritDefaultEnv: true`; run with omitted and zero `process.timeout`, then run a command whose normal duration exceeds a positive timeout. | Output identifies the selected directory, preserves the unmodified guest-default variable and reflects the override; omitted/empty env preserves guest defaults. Confirm and record guest handling of zero `timeoutMs` and applicable service-side timeout defaults and limits. The positively timed command terminates remotely without reaching normal completion; record the returned exit/error result. A local timeout or disconnect alone does not establish remote termination. | | `w365agents_cp_loopback` plus lifecycle fixture | Equal IDs or omitted computer ID; different/malformed computer ID with a valid session ID; lost response, unparseable body or missing/malformed session ID. | Equal/omitted computer IDs produce the same session-based ID. Rejected successful checkout reports `possiblySucceeded`; a valid session ID is retained with the pool ID and opaque `sandboxId` in error details, not a successful container result. An unavailable/invalid session ID produces no fabricated cleanup reference. No second POST occurs. | | Checkout/release/status/upgrade failure fixtures | 401, 403, 404, 429, 500, other non-success and malformed responses. | Errors retain operation-specific categories/request IDs; 500 checkout reports uncertainty; no automatic retry/fallback/escalation occurs. | -| Readiness fixture | Waiting/Live then Ready, unknown state, expired budget, `readyTimeoutMs: 0`. | Success only on Ready; timeout has last state; zero polls once; no release or replacement on timeout. | +| Readiness fixture (later stage) | Waiting/Live then Ready, unknown state and expired budget; environment configuration/budget controls to be finalized in Phase 3. | Success only on Ready; timeout has last state; no release or replacement on timeout. | | `w365agents_loopback` and lifecycle relay fixture | Input/EOF, stdout/stderr frames, EXIT 0, 7 and -1, oversized start payload, oversized/malformed received frames, loss before EXIT, remote ERROR and local output error. | I/O and actual EXIT codes are preserved, including -1. Oversized EXEC_START fails before dial; oversized/malformed received frames fail explicitly. Post-upgrade loss and remote ERROR return `backend_error` with distinct operation-specific codes and appropriate outcome-uncertainty details, not `ExecCompleted`; local failures also surface errors. Native errors go to stderr without altering already-relayed stdout, and commands are never replayed automatically after failure. | | Executor relay lifecycle fixture | Concurrent stdout/stderr of at least 1 MiB each, binary data, stdin/EOF and slow/closed output readers through executor relayed exec. | Exact separate output with concurrent draining; bounded relay buffers and explicit local I/O errors. EOF is forwarded without checkout or release. | | Rust/.NET/Node Dev lifecycle fixture (new) | Provision, start, repeated captured/spawned exec, stop and deprovision through each SDK with normal native libraries; repeat without experimental opt-in. | Same checked-out Cloud PC, complete lifecycle responses, separate output and actual exits; no executor subprocess or implicit release. Missing opt-in fails before network activity; PTY/create-and-run remain refused. | @@ -575,15 +581,11 @@ follow the [phase settings](#routing-and-phase-settings): | Request routing | `version: "1.1.0-alpha"`, `phase: "provision"` and `containment: "w365_agents"`. | | `w365Agents.provision.poolId` | Required canonical 36-character pool GUID for an existing, authorized test pool. The harness selects the pool; MXC does not discover or create it. | | `userType` and `userObjectId` | The delegated human-user scenario supplies `"HumanUser"` and the selected user's canonical object GUID. Both fields are optional in the proposed MXC contract; omission sends no corresponding header and does not infer a human identity. The caller supplies the identity information required by its service deployment. | -| `cpBaseUrl` and `cpBearerToken` | An approved bare W365 service hostname and a valid resource-specific token for checkout and release. These fields may be omitted only when their required values resolve from `W365A_CONTROLPLANE_URL` and `W365A_CP_TOKEN`. An explicit nonblank hostname requires an explicit nonblank token; an ambient token is not used for a host override. | -| `provisionTimeoutMs` | Optional checkout budget in milliseconds; omission uses 45000. | - -Subsequent readiness and exec requests use the returned container ID, -`dpSuffix` or `W365A_DP_SUFFIX`, and a separate resource-specific token: -`dpBearerToken` for start, `bearerToken` for exec, or `W365A_DP_TOKEN`. -The same explicit-host/token pairing rule applies. Stop/deprovision use the -checkout/release configuration and token again, including caller-renewed -credentials when needed for cleanup. +| Service configuration (environment only) | Supply `W365A_CONTROLPLANE_URL` and `W365A_CP_TOKEN` in the caller environment. Neither belongs in the provision document. | + +Stop/deprovision use the checkout/release environment again, including +caller-renewed credentials when needed for cleanup. Readiness and exec are +not available in Phase 2. Nightly live testing is proposed as follow-up beyond the initial experimental milestone. Unattended runs require an authorized test tenant, pool and identity, diff --git a/docs/development/architecture/designs/1615-w365a-backend/api.md b/docs/development/architecture/designs/1615-w365a-backend/api.md index a52e322011..857a489191 100644 --- a/docs/development/architecture/designs/1615-w365a-backend/api.md +++ b/docs/development/architecture/designs/1615-w365a-backend/api.md @@ -17,6 +17,16 @@ additions are optional Rust/.NET error details; Node's existing member is preserved. These are proposed capabilities, not working W365 examples. Typed W365 configuration and contract publication remain out of scope. +**Implementation boundary:** Phase 2 implements checkout and terminal release +through the Windows executor's existing lifecycle interface. The backend is +included and tested through the engine on Windows, Linux and macOS; lifecycle +CLI support for Linux and macOS is deferred to separate work. +All Dev SDK W365 JSON calls remain explicitly unavailable +until cross-SDK structured error metadata and SDK fixtures are ready. +Start/readiness and exec are also deferred. The examples in this proposal are +not runnable Phase 2 SDK examples. See the +[backend guide](../../../../backends/w365agents/w365agents-backend.md). + ## Caller scenario All examples implement the same [internal Windows build scenario](README.md#usage-scenario). diff --git a/docs/development/build-and-test/ci-validation-infrastructure.md b/docs/development/build-and-test/ci-validation-infrastructure.md index 333779a744..38840f916b 100644 --- a/docs/development/build-and-test/ci-validation-infrastructure.md +++ b/docs/development/build-and-test/ci-validation-infrastructure.md @@ -13,6 +13,18 @@ the individual local test scripts are documented in ## At a glance +W365 checkout/release uses offline trusted-TLS functional fixtures in the +existing Windows, Linux and macOS **build jobs**, not the scheduled live-service +matrix. Each job checks discovery of `lifecycle_contract_and_recovery_fixture` +and runs it through the normal parser/engine/backend paths. Windows additionally +builds a separate `test-support` executor, checks discovery of +`lifecycle_fresh_executor_fixture` and runs it in fresh processes. Linux and macOS +executors do not expose lifecycle operations in this slice. Normal artifacts +include W365 without a Cargo opt-in; test trust never enters shipping artifacts. +No OS/architecture matrix entry is removed. See the +[W365 backend guide](../../backends/w365agents/w365agents-backend.md) for local +commands, service-contract differences and the explicitly unrun live validation. + - Validation tests **never build from source**. They download the artifacts produced by `Build.Windows.Job.yml` / `Build.Linux.Job.yml` / `Build.MacOS.Job.yml`, and for ProcessContainer, IsolationSession, and WSLC the diff --git a/docs/schema.md b/docs/schema.md index 63bf568f44..ee8308dfb1 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -28,6 +28,15 @@ production configs and the dev schema when working on experimental features: "$schema": "./schemas/dev/mxc-config.schema.1.1.0-alpha.json" ``` +The development contract accepts experimental W365 provision settings +`w365Agents.provision.poolId`, optional `userType` (`"Local"`, `"HumanUser"`, or `"Agentic"`) and +optional `userObjectId`. Service configuration is environment-only; later +lifecycle phases reject `w365Agents`. The +[W365 backend guide](backends/w365agents/w365agents-backend.md) describes +Phase 2 checkout/release, unavailable execution modes, cleanup identities +and the intentionally deferred Dev SDK boundary. Acceptance by the development +schema alone does not authorize execution or establish live-service compatibility. + ### "MXC request JSON" "MXC request JSON" is a versioned request object defined by the diff --git a/src/Cargo.lock b/src/Cargo.lock index e92c8ddcb9..4124dbb010 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -131,6 +131,45 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -180,6 +219,15 @@ dependencies = [ "syn", ] +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -591,6 +639,12 @@ dependencies = [ "syn", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "der" version = "0.8.2" @@ -601,6 +655,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "derive_builder" version = "0.20.2" @@ -1596,6 +1670,8 @@ dependencies = [ "libloading", "nix 0.29.0", "quick-xml", + "rcgen", + "rustls", "same-file", "schemars", "semver", @@ -1725,6 +1801,31 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1786,6 +1887,15 @@ dependencies = [ "thiserror", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1880,6 +1990,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -1928,6 +2048,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "prettyplease" version = "0.2.37" @@ -2003,6 +2129,20 @@ dependencies = [ "rustversion", ] +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2052,6 +2192,20 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rust-embed" version = "8.11.0" @@ -2103,6 +2257,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" @@ -2126,6 +2289,21 @@ dependencies = [ "rustix", ] +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -2135,6 +2313,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2455,6 +2644,12 @@ dependencies = [ "syn", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.118" @@ -2530,6 +2725,36 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2665,6 +2890,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "ureq" version = "3.4.2" @@ -2677,10 +2908,12 @@ dependencies = [ "log", "native-tls", "percent-encoding", + "rustls", "rustls-pki-types", "ureq-proto", "utf8-zero", "webpki-root-certs", + "webpki-roots", ] [[package]] @@ -2895,6 +3128,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "widestring" version = "1.2.1" @@ -3033,6 +3275,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -3307,6 +3558,24 @@ dependencies = [ name = "wxc_ui_probe" version = "0.1.0" +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -3317,6 +3586,16 @@ dependencies = [ "rustix", ] +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/src/ffi/mxc_ffi/src/state_aware.rs b/src/ffi/mxc_ffi/src/state_aware.rs index 908457d75e..c073367425 100644 --- a/src/ffi/mxc_ffi/src/state_aware.rs +++ b/src/ffi/mxc_ffi/src/state_aware.rs @@ -643,6 +643,50 @@ mod tests { out } + #[test] + fn w365_sdk_lifecycle_access_remains_staged_off() { + for experimental in [false, true] { + for phase in ["provision", "start", "exec", "stop", "deprovision"] { + let mut value = serde_json::json!({ + "version":"1.1.0-alpha", "phase":phase, + }); + if phase == "provision" { + value["containment"] = serde_json::json!("w365_agents"); + value["w365Agents"] = serde_json::json!({"provision":{ + "poolId":"11111111-1111-1111-1111-111111111111", + }}); + } else { + value["sandboxId"] = serde_json::json!( + "w365:11111111-1111-1111-1111-111111111111:22222222-2222-2222-2222-222222222222" + ); + } + if phase == "exec" { + value["process"] = serde_json::json!({"commandLine":"echo must-not-run"}); + let error = mxc_sdk::mxc_engine::exec_state_aware_capture_json( + &value.to_string(), + experimental, + ) + .err() + .expect("W365 SDK capture remains unavailable"); + assert!(error + .to_string() + .contains("W365 Dev SDK access is deferred")); + } + let mut out = call_opt(&value.to_string(), false, experimental); + assert_eq!(out.status, crate::MXC_STATUS_BACKEND_UNAVAILABLE); + assert!(out.response_json_utf8.is_null()); + // SAFETY: the failed native call returned an owned error string. + let message = unsafe { std::ffi::CStr::from_ptr(out.error.message_utf8) }; + assert!(message + .to_str() + .unwrap() + .contains("W365 Dev SDK access is deferred")); + // SAFETY: all result strings were allocated by the native call. + unsafe { mxc_state_aware_result_free(&mut out) }; + } + } + } + /// The state-aware result carries the same API detail as the /// run-to-completion one, and maps the code the same way. #[test] diff --git a/src/mxc-sdk/Cargo.toml b/src/mxc-sdk/Cargo.toml index e22ab3c5ff..a45a9a4b6b 100644 --- a/src/mxc-sdk/Cargo.toml +++ b/src/mxc-sdk/Cargo.toml @@ -39,7 +39,8 @@ test-support = [] microvm = ["dep:aci_edge_sandboxes", "dep:libloading"] hyperlight = [ "dep:hyperlight-unikraft", - "dep:ureq", + "ureq/native-tls", + "ureq/gzip", "dep:flate2", "dep:tar", ] @@ -67,6 +68,7 @@ tokio.workspace = true tar = { version = "0.4", optional = true } unicode-general-category.workspace = true url.workspace = true +ureq = { version = "3", default-features = false, features = ["rustls"] } uuid.workspace = true widestring.workspace = true @@ -79,6 +81,8 @@ uuid.workspace = true zip = { version = "9", default-features = false, features = ["deflate"], optional = true } [dev-dependencies] +rcgen = "0.14" +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } filetime = "0.2" libloading = "0.8" quick-xml.workspace = true @@ -93,7 +97,6 @@ serial_test.workspace = true [target.'cfg(target_arch = "x86_64")'.dependencies] flate2 = { version = "1", optional = true } hyperlight-unikraft = { version = "=0.21.0", default-features = false, optional = true } -ureq = { version = "3", default-features = false, features = ["native-tls", "gzip"], optional = true } [target.'cfg(all(target_os = "windows", target_arch = "x86_64"))'.dependencies] aci_edge_sandboxes = { version = "=0.1.1", default-features = false, features = ["agent-bundled"], optional = true } diff --git a/src/mxc-sdk/src/backends/mod.rs b/src/mxc-sdk/src/backends/mod.rs index 6b2c143417..166332e1c0 100644 --- a/src/mxc-sdk/src/backends/mod.rs +++ b/src/mxc-sdk/src/backends/mod.rs @@ -5,6 +5,8 @@ pub mod bubblewrap { pub mod common; } +pub(crate) mod w365agents; + pub mod hyperlight { pub mod common; } diff --git a/src/mxc-sdk/src/backends/w365agents/mod.rs b/src/mxc-sdk/src/backends/w365agents/mod.rs new file mode 100644 index 0000000000..0d556e276e --- /dev/null +++ b/src/mxc-sdk/src/backends/w365agents/mod.rs @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Internal checkout/release implementation. Readiness and execution are deferred. + +mod service; + +use crate::mxc_common::models::ExecutionRequest; +use crate::mxc_common::mxc_error::MxcError; +use crate::mxc_common::state_aware_backend::{ + DeprovisionResult, ExecHandle, ExecStdio, ProvisionResult, StartResult, StatefulSandboxBackend, + StopResult, +}; +use crate::mxc_common::state_aware_operation::W365AgentsProvisionConfig; +use serde::Serialize; + +pub(crate) struct Runner; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CheckoutMetadata { + pool_id: String, + session_id: String, + computer_id: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ReleaseMetadata { + release_requested: bool, + restartable: bool, +} + +fn policy(request: &ExecutionRequest) -> Result<(), MxcError> { + let p = &request.policy; + if p.network_specified + || p.runtime_network_proxy_specified + || p.ui_specified + || p.network_proxy.is_enabled() + || p.least_privilege_mode + || !p.capabilities.is_empty() + || !p.readwrite_paths.is_empty() + || !p.readonly_paths.is_empty() + || !p.enumerate_paths.is_empty() + || !p.denied_paths.is_empty() + || p.capture_denials.is_some() + { + return Err(MxcError::policy_validation( + "w365_agents cannot enforce local filesystem, network, UI or proxy policy", + )); + } + Ok(()) +} + +pub(crate) fn deferred() -> MxcError { + MxcError::unsupported_phase( + "w365_agents Phase 2 supports checkout and terminal release only; start/readiness and exec require Phase 3", + ) +} + +impl StatefulSandboxBackend for Runner { + const ID_PREFIX: &'static str = "w365"; + const BACKEND_KEY: &'static str = "w365_agents"; + type ProvisionConfig = W365AgentsProvisionConfig; + type StartConfig = (); + type ExecConfig = (); + type StopConfig = (); + type DeprovisionConfig = (); + type ProvisionMetadata = CheckoutMetadata; + type StartMetadata = (); + type StopMetadata = ReleaseMetadata; + type DeprovisionMetadata = ReleaseMetadata; + + fn validate_provision( + &self, + request: &ExecutionRequest, + config: Option<&Self::ProvisionConfig>, + ) -> Result<(), MxcError> { + policy(request)?; + service::validate_provision(config)?; + service::Client::from_env().map(|_| ()) + } + + fn provision( + &mut self, + request: &ExecutionRequest, + config: Option, + ) -> Result, MxcError> { + self.validate_provision(request, config.as_ref())?; + let config = service::validate_provision(config.as_ref())?; + service::Client::from_env()?.checkout(config) + } + + fn validate_start( + &self, + id: &str, + request: &ExecutionRequest, + _: Option<&()>, + ) -> Result<(), MxcError> { + policy(request)?; + service::Identity::parse(id)?; + Err(deferred()) + } + + fn start( + &mut self, + id: &str, + request: &ExecutionRequest, + config: Option<()>, + ) -> Result, MxcError> { + self.validate_start(id, request, config.as_ref())?; + Err(deferred()) + } + + fn validate_exec( + &self, + id: &str, + request: &ExecutionRequest, + config: Option<&()>, + ) -> Result<(), MxcError> { + self.validate_start(id, request, config) + } + + fn exec( + &mut self, + id: &str, + request: &ExecutionRequest, + config: Option<()>, + _: ExecStdio, + ) -> Result { + self.validate_exec(id, request, config.as_ref())?; + Err(deferred()) + } + + fn validate_stop( + &self, + id: &str, + request: &ExecutionRequest, + _: Option<&()>, + ) -> Result<(), MxcError> { + policy(request)?; + service::Identity::parse(id)?; + service::Client::from_env().map(|_| ()) + } + + fn validate_deprovision( + &self, + id: &str, + request: &ExecutionRequest, + config: Option<&()>, + ) -> Result<(), MxcError> { + self.validate_stop(id, request, config) + } + + fn stop( + &mut self, + id: &str, + request: &ExecutionRequest, + config: Option<()>, + ) -> Result, MxcError> { + self.validate_stop(id, request, config.as_ref())?; + service::Client::from_env()?.release(&service::Identity::parse(id)?)?; + Ok(StopResult { + metadata: Some(ReleaseMetadata { + release_requested: true, + restartable: false, + }), + }) + } + + fn deprovision( + &mut self, + id: &str, + request: &ExecutionRequest, + config: Option<()>, + ) -> Result, MxcError> { + let result = self.stop(id, request, config)?; + Ok(DeprovisionResult { + metadata: result.metadata, + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/src/mxc-sdk/src/backends/w365agents/service.rs b/src/mxc-sdk/src/backends/w365agents/service.rs new file mode 100644 index 0000000000..372af56afb --- /dev/null +++ b/src/mxc-sdk/src/backends/w365agents/service.rs @@ -0,0 +1,379 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::{CheckoutMetadata, ProvisionResult}; +use crate::mxc_common::mxc_error::MxcError; +use crate::mxc_common::state_aware_operation::W365AgentsProvisionConfig; +use serde_json::{json, Value}; +use std::time::Duration; +use ureq::tls::{TlsConfig, TlsProvider}; + +const CHECKOUT_TIMEOUT: Duration = Duration::from_secs(45); +const RELEASE_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_RESPONSE: u64 = 1024 * 1024; + +fn guid(value: &str) -> bool { + value.len() == 36 + && value.bytes().enumerate().all(|(i, b)| { + if matches!(i, 8 | 13 | 18 | 23) { + b == b'-' + } else { + b.is_ascii_hexdigit() + } + }) +} + +pub(super) struct Identity { + pool_id: String, + session_id: String, +} + +impl Identity { + pub(super) fn parse(value: &str) -> Result { + let parts: Vec<_> = value.split(':').collect(); + if parts.len() != 3 || parts[0] != "w365" || !guid(parts[1]) || !guid(parts[2]) { + return Err(MxcError::malformed_id( + "w365 sandboxId must contain exactly a pool GUID and a session GUID", + )); + } + Ok(Self { + pool_id: parts[1].to_ascii_lowercase(), + session_id: parts[2].to_ascii_lowercase(), + }) + } + + fn encode(&self) -> String { + format!("w365:{}:{}", self.pool_id, self.session_id) + } +} + +pub(super) fn validate_provision( + config: Option<&W365AgentsProvisionConfig>, +) -> Result<&W365AgentsProvisionConfig, MxcError> { + let config = config + .ok_or_else(|| MxcError::policy_validation("w365Agents.provision.poolId is required"))?; + if !guid(&config.pool_id) + || config.user_object_id.as_deref().is_some_and(|id| !guid(id)) + || config + .user_type + .as_deref() + .is_some_and(|kind| !matches!(kind, "Local" | "HumanUser" | "Agentic")) + { + return Err(MxcError::policy_validation( + "invalid W365 pool or user identity", + )); + } + Ok(config) +} + +// Deliberately no Debug: the client owns the caller's bearer token. +pub(super) struct Client { + host: String, + token: String, +} + +impl Client { + pub(super) fn from_env() -> Result { + let read = |name: &str| { + std::env::var(name).map_err(|_| { + MxcError::policy_validation(format!( + "w365_agents requires {name} in the caller environment" + )) + }) + }; + let host = read("W365A_CONTROLPLANE_URL")?; + let token = read("W365A_CP_TOKEN")?; + let valid_host = host.len() <= 253 + && host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + }); + if !valid_host { + return Err(MxcError::policy_validation( + "W365A_CONTROLPLANE_URL must be a bare ASCII DNS hostname (no URL, port or path)", + )); + } + if token.is_empty() || !token.bytes().all(|b| b.is_ascii_graphic()) { + return Err(MxcError::policy_validation( + "W365A_CP_TOKEN must be nonempty ASCII without whitespace or control characters", + )); + } + Ok(Self { host, token }) + } + + fn agent(&self, timeout: Duration) -> Result<(ureq::Agent, String), MxcError> { + let tls = TlsConfig::builder().provider(TlsProvider::Rustls); + #[cfg(any(test, feature = "test-support"))] + let (tls, port) = test_tls(tls)?; + #[cfg(any(test, feature = "test-support"))] + if port != 443 && self.host != "localhost" { + return Err(MxcError::policy_validation( + "W365 fixture ports require localhost", + )); + } + #[cfg(not(any(test, feature = "test-support")))] + let port = 443; + let config = ureq::Agent::config_builder() + .tls_config(tls.build()) + .timeout_global(Some(timeout)) + .max_redirects(0) + .http_status_as_error(false) + // Never send caller credentials through an ambient HTTP proxy. + .proxy(None) + .build(); + Ok((config.into(), format!("https://{}:{port}", self.host))) + } + + pub(super) fn checkout( + &self, + config: &W365AgentsProvisionConfig, + ) -> Result, MxcError> { + self.checkout_once(config).map_err(|mut error| { + if let Some(details) = error.details.as_mut() { + details["poolId"] = json!(config.pool_id.to_ascii_lowercase()); + } + error + }) + } + + fn checkout_once( + &self, + config: &W365AgentsProvisionConfig, + ) -> Result, MxcError> { + let pool = config.pool_id.to_ascii_lowercase(); + let request_id = uuid::Uuid::new_v4().to_string(); + let (agent, base) = self.agent(CHECKOUT_TIMEOUT)?; + let url = format!("{base}/api/pools/{pool}/sessions?api-version=2.0"); + let mut request = agent + .post(&url) + .header("Authorization", format!("Bearer {}", self.token)) + .header("x-ms-client-request-id", &request_id); + if let Some(kind) = &config.user_type { + request = request.header("user-type", kind); + } + if let Some(oid) = &config.user_object_id { + request = request.header("user-object-id", oid); + } + // One allocation attempt. No polling, retry, redirect or compensating release. + let response = request.send_empty().map_err(|_| { + failure( + "checkout_transport", + "checkout", + None, + &request_id, + None, + true, + None, + ) + })?; + let status = response.status().as_u16(); + let server_id = safe_request_id(&response); + if !(200..300).contains(&status) { + let code = match status { + 401 | 403 => "auth_denied", + 404 => "pool_not_found", + 429 => "checkout_throttled", + 500 => "provision_timeout", + _ => "checkout_failed", + }; + return Err(failure( + code, + "checkout", + Some(status), + &request_id, + server_id.as_deref(), + status >= 500 || status == 408 || (200..400).contains(&status), + None, + )); + } + let mut response = response; + let body = response + .body_mut() + .with_config() + .limit(MAX_RESPONSE) + .read_to_string() + .map_err(|_| { + failure( + "checkout_response", + "checkout", + Some(status), + &request_id, + server_id.as_deref(), + true, + None, + ) + })?; + // Do not include any response body or ticket-bearing metadata in diagnostics. + let value: Value = serde_json::from_str(&body).map_err(|_| { + failure( + "unparsable_response", + "checkout", + Some(status), + &request_id, + server_id.as_deref(), + true, + None, + ) + })?; + let session = value + .get("sessionId") + .and_then(Value::as_str) + .filter(|id| guid(id)); + let identity = session.map(|id| Identity { + pool_id: pool.clone(), + session_id: id.to_ascii_lowercase(), + }); + let reject = |code| { + failure( + code, + "checkout", + Some(status), + &request_id, + server_id.as_deref(), + true, + identity.as_ref(), + ) + }; + let identity = identity + .as_ref() + .ok_or_else(|| reject("malformed_session_id"))?; + if status != 200 { + return Err(reject("checkout_failed")); + } + if let Some(computer) = value.get("computerId") { + if !computer + .as_str() + .is_some_and(|id| guid(id) && id.eq_ignore_ascii_case(&identity.session_id)) + { + return Err(reject("id_mismatch")); + } + } + if value.get("status").and_then(Value::as_str) != Some("Succeeded") { + return Err(reject("provision_failed")); + } + Ok(ProvisionResult { + sandbox_id: identity.encode(), + metadata: Some(CheckoutMetadata { + pool_id: pool, + session_id: identity.session_id.clone(), + computer_id: identity.session_id.clone(), + }), + }) + } + + pub(super) fn release(&self, identity: &Identity) -> Result<(), MxcError> { + let request_id = uuid::Uuid::new_v4().to_string(); + let (agent, base) = self.agent(RELEASE_TIMEOUT)?; + let url = format!( + "{base}/api/sessions/{}?api-version=2.0", + identity.session_id + ); + let response = agent + .delete(&url) + .header("Authorization", format!("Bearer {}", self.token)) + .header("x-ms-client-request-id", &request_id) + .header("x-ms-sessionId", &identity.session_id) + .call() + .map_err(|_| { + failure( + "release_transport", + "release", + None, + &request_id, + None, + true, + Some(identity), + ) + })?; + let status = response.status().as_u16(); + if matches!(status, 200 | 202 | 204 | 404) { + return Ok(()); + } + Err(failure( + if matches!(status, 401 | 403) { + "auth_denied" + } else { + "release_failed" + }, + "release", + Some(status), + &request_id, + safe_request_id(&response).as_deref(), + status >= 500 || status == 408 || (200..400).contains(&status), + Some(identity), + )) + } +} + +fn safe_request_id(response: &ureq::http::Response) -> Option { + response + .headers() + .get("x-ms-request-id") + .and_then(|v| v.to_str().ok()) + .filter(|v| { + v.len() <= 128 + && v.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')) + }) + .map(str::to_owned) +} + +fn failure( + code: &str, + operation: &str, + status: Option, + client_id: &str, + server_id: Option<&str>, + uncertain: bool, + identity: Option<&Identity>, +) -> MxcError { + let mut details = json!({ + "code": code, "operation": operation, "possiblySucceeded": uncertain, + "clientRequestId": client_id, + }); + if let Some(status) = status { + details["httpStatus"] = json!(status); + } + if let Some(id) = server_id { + details["requestId"] = json!(id); + } + if let Some(id) = identity { + details["poolId"] = json!(id.pool_id); + details["sessionId"] = json!(id.session_id); + details["sandboxId"] = json!(id.encode()); + } + MxcError::backend_error(format!("w365_agents {operation} failed: {code}")).with_details(details) +} + +#[cfg(any(test, feature = "test-support"))] +fn test_tls( + builder: ureq::tls::TlsConfigBuilder, +) -> Result<(ureq::tls::TlsConfigBuilder, u16), MxcError> { + use ureq::tls::{Certificate, RootCerts}; + let ca = std::env::var("MXC_W365_TEST_CA").ok(); + let port = std::env::var("MXC_W365_TEST_PORT").ok(); + match (ca, port) { + (None, None) => Ok((builder, 443)), + (Some(path), Some(port)) => { + let bytes = std::fs::read(path) + .map_err(|_| MxcError::policy_validation("cannot read fixture CA"))?; + let cert = Certificate::from_pem(&bytes) + .map_err(|_| MxcError::policy_validation("invalid fixture CA"))?; + let port = port + .parse() + .map_err(|_| MxcError::policy_validation("invalid fixture port"))?; + Ok(( + builder.root_certs(RootCerts::Specific(vec![cert].into())), + port, + )) + } + _ => Err(MxcError::policy_validation( + "fixture CA and port must be supplied together", + )), + } +} diff --git a/src/mxc-sdk/src/backends/w365agents/tests.rs b/src/mxc-sdk/src/backends/w365agents/tests.rs new file mode 100644 index 0000000000..9544381f27 --- /dev/null +++ b/src/mxc-sdk/src/backends/w365agents/tests.rs @@ -0,0 +1,594 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::mxc_common::{ + config_parser::load_mxc_request_from_json, + logger::{Logger, Mode}, + mxc_error::{MxcError, MxcErrorCode}, + state_aware_dispatch::DispatchOutcome, + state_aware_request::MxcRequest, +}; +use serde_json::{json, Value}; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +#[cfg(target_os = "windows")] +use std::time::Instant; + +const POOL: &str = "11111111-1111-1111-1111-111111111111"; +const A: &str = "22222222-2222-2222-2222-222222222222"; +const B: &str = "33333333-3333-3333-3333-333333333333"; +static ENV: Mutex<()> = Mutex::new(()); + +struct Environment(Vec<(&'static str, Option)>); +impl Environment { + fn new() -> Self { + Self( + [ + "W365A_CONTROLPLANE_URL", + "W365A_CP_TOKEN", + "MXC_W365_TEST_CA", + "MXC_W365_TEST_PORT", + ] + .into_iter() + .map(|key| (key, std::env::var_os(key))) + .collect(), + ) + } + fn set(&self, key: &str, value: &str) { + std::env::set_var(key, value); + } +} +impl Drop for Environment { + fn drop(&mut self) { + for (key, value) in &self.0 { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } +} + +struct Fixture { + port: u16, + ca: tempfile::NamedTempFile, + requests: Arc>>, + replies: Arc>>>, + stop: Arc, + worker: Option>, +} +impl Fixture { + fn new() -> Self { + let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap(); + let mut ca = tempfile::NamedTempFile::new().unwrap(); + ca.write_all(cert.cert.pem().as_bytes()).unwrap(); + let key = + rustls::pki_types::PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der()).into(); + let tls = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert.cert.der().clone()], key) + .unwrap(); + let tls = Arc::new(tls); + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + listener.set_nonblocking(true).unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let replies = Arc::new(Mutex::new( + std::collections::VecDeque::>::new(), + )); + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (seen, responses, stopping) = (requests.clone(), replies.clone(), stop.clone()); + let worker = std::thread::spawn(move || { + while !stopping.load(std::sync::atomic::Ordering::Relaxed) { + let socket = match listener.accept() { + Ok((socket, _)) => socket, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + continue; + } + Err(e) => panic!("{e}"), + }; + socket.set_nonblocking(false).unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + socket + .set_write_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let session = rustls::ServerConnection::new(tls.clone()).unwrap(); + let mut stream = rustls::StreamOwned::new(session, socket); + let mut data = Vec::new(); + let mut buf = [0; 1024]; + while !data.windows(4).any(|v| v == b"\r\n\r\n") { + match stream.read(&mut buf) { + Ok(0) => break, + Err(_) => break, + Ok(n) => data.extend_from_slice(&buf[..n]), + } + assert!(data.len() < 16384); + } + if data.is_empty() { + continue; + } // Rejected TLS never carries an HTTP request. + seen.lock().unwrap().push(String::from_utf8(data).unwrap()); + if let Some(Some(reply)) = responses.lock().unwrap().pop_front() { + stream.write_all(reply.as_bytes()).unwrap(); + stream.flush().unwrap(); + } + } + }); + Self { + port, + ca, + requests, + replies, + stop, + worker: Some(worker), + } + } + fn configure(&self, env: &Environment) { + env.set("W365A_CONTROLPLANE_URL", "localhost"); + env.set("W365A_CP_TOKEN", "fixture-token-never-log"); + env.set("MXC_W365_TEST_CA", self.ca.path().to_str().unwrap()); + env.set("MXC_W365_TEST_PORT", &self.port.to_string()); + } + fn reply(&self, status: u16, body: &str) { + self.replies.lock().unwrap().push_back(Some(format!( + "HTTP/1.1 {status} Fixture\r\nContent-Length: {}\r\nx-ms-request-id: fixture-request\r\nConnection: close\r\n\r\n{body}", + body.len() + ))); + } + fn drop_reply(&self) { + self.replies.lock().unwrap().push_back(None); + } + fn count(&self) -> usize { + self.requests.lock().unwrap().len() + } + fn request(&self, index: usize) -> String { + self.requests.lock().unwrap()[index].clone() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + self.stop.store(true, std::sync::atomic::Ordering::Relaxed); + self.worker.take().unwrap().join().unwrap(); + } +} + +fn provision() -> Value { + json!({"version":"1.1.0-alpha", "phase":"provision", "containment":"w365_agents", + "w365Agents":{"provision":{"poolId":POOL}}}) +} +fn request(phase: &str, session: &str) -> Value { + let mut value = json!({"version":"1.1.0-alpha","phase":phase, + "sandboxId":format!("w365:{POOL}:{session}")}); + if phase == "exec" { + value["process"] = json!({"commandLine":"echo must-not-run"}); + } + value +} +fn invoke(value: &Value, opt_in: bool) -> Result { + let MxcRequest::StateAware(mut parsed) = + load_mxc_request_from_json(&value.to_string(), &mut Logger::new(Mode::Buffer)) + .map_err(|e| e.to_mxc_error())? + else { + panic!("lifecycle"); + }; + parsed.set_experimental_enabled(opt_in); + match crate::mxc_engine::run_state_aware(parsed, false)? { + DispatchOutcome::Envelope(value) => Ok(value), + other => panic!("{other:?}"), + } +} + +#[test] +fn lifecycle_contract_and_recovery_fixture() { + let _lock = ENV.lock().unwrap(); + let env = Environment::new(); + let fixture = Fixture::new(); + fixture.configure(&env); + for key in ["W365A_CONTROLPLANE_URL", "W365A_CP_TOKEN"] { + std::env::remove_var(key); + assert_eq!( + invoke(&provision(), true).unwrap_err().code, + MxcErrorCode::PolicyValidation + ); + fixture.configure(&env); + } + let before = fixture.count(); + for phase in ["provision", "start", "exec", "stop", "deprovision"] { + let value = if phase == "provision" { + provision() + } else { + request(phase, A) + }; + assert_eq!( + invoke(&value, false).unwrap_err().code, + MxcErrorCode::BackendUnavailable + ); + } + for phase in ["start", "exec"] { + assert_eq!( + invoke(&request(phase, A), true).unwrap_err().code, + MxcErrorCode::UnsupportedPhase + ); + } + for phase in ["stop", "deprovision"] { + for bad in [ + "", + "../escape", + "not-guid", + "22222222-2222-2222-2222-222222222222:extra", + ] { + assert_eq!( + invoke(&request(phase, bad), true).unwrap_err().code, + MxcErrorCode::MalformedId + ); + } + } + for key in ["filesystem", "network", "ui", "clipboard", "runtimeConfig"] { + for phase in ["provision", "exec", "stop"] { + let mut value = if phase == "provision" { + provision() + } else { + request(phase, A) + }; + value[key] = json!({}); + assert!(invoke(&value, true).is_err(), "{key}/{phase}"); + } + } + for host in [ + "", + " ", + "https://localhost", + "localhost:443", + "localhost/path", + "localhost\r\nx:y", + ] { + env.set("W365A_CONTROLPLANE_URL", host); + assert_eq!( + invoke(&provision(), true).unwrap_err().code, + MxcErrorCode::PolicyValidation + ); + } + fixture.configure(&env); + for token in ["", " ", "\r\nsecret", "secret token"] { + env.set("W365A_CP_TOKEN", token); + assert_eq!( + invoke(&provision(), true).unwrap_err().code, + MxcErrorCode::PolicyValidation + ); + } + fixture.configure(&env); + let mut absent = provision(); + absent.as_object_mut().unwrap().remove("w365Agents"); + assert_eq!( + invoke(&absent, true).unwrap_err().code, + MxcErrorCode::PolicyValidation + ); + absent["w365Agents"] = json!({}); + assert_eq!( + invoke(&absent, true).unwrap_err().code, + MxcErrorCode::PolicyValidation + ); + assert_eq!(fixture.count(), before); + + let wrong_ca = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap(); + let mut wrong_file = tempfile::NamedTempFile::new().unwrap(); + wrong_file + .write_all(wrong_ca.cert.pem().as_bytes()) + .unwrap(); + env.set("MXC_W365_TEST_CA", wrong_file.path().to_str().unwrap()); + assert!(invoke(&provision(), true).is_err()); + assert_eq!( + fixture.count(), + before, + "invalid TLS cannot transmit credentials or checkout" + ); + fixture.configure(&env); + assert!(crate::mxc_engine::available_backends() + .iter() + .all(|b| b.backend != "w365_agents")); + assert_eq!( + fixture.count(), + before, + "local probing must not use W365 services" + ); + + for body in [ + json!({"sessionId":A,"computerId":A,"status":"Succeeded"}), + json!({"sessionId":A,"status":"Succeeded"}), + ] { + fixture.reply(200, &body.to_string()); + assert_eq!( + invoke(&provision(), true).unwrap()["result"]["sandboxId"], + format!("w365:{POOL}:{A}") + ); + } + let first = fixture.request(0).to_ascii_lowercase(); + assert!(first.starts_with(&format!("post /api/pools/{POOL}/sessions?api-version=2.0 "))); + assert!(!first.contains("user-type:")); + assert!(!first.contains("user-object-id:")); + assert!(first.contains("x-ms-client-request-id:")); + let mut human = provision(); + human["w365Agents"]["provision"]["userType"] = json!("HumanUser"); + human["w365Agents"]["provision"]["userObjectId"] = json!(B); + fixture.reply( + 200, + &json!({"sessionId":A,"status":"Succeeded"}).to_string(), + ); + invoke(&human, true).unwrap(); + let sent = fixture.request(2); + assert!(sent.to_ascii_lowercase().contains("user-type: humanuser")); + assert!(sent.contains(B)); + assert_eq!( + sent.split_once("\r\n\r\n").unwrap().1, + "", + "API 2.0 checkout is bodyless" + ); + + for (kind, oid) in [(true, false), (false, true)] { + let mut value = provision(); + if kind { + value["w365Agents"]["provision"]["userType"] = json!("HumanUser"); + } + if oid { + value["w365Agents"]["provision"]["userObjectId"] = json!(B); + } + fixture.reply( + 200, + &json!({"sessionId":A,"status":"Succeeded"}).to_string(), + ); + let n = fixture.count(); + invoke(&value, true).unwrap(); + let sent = fixture.request(n).to_ascii_lowercase(); + assert_eq!(sent.contains("user-type:"), kind); + assert_eq!(sent.contains("user-object-id:"), oid); + } + for user_type in ["Local", "HumanUser", "Agentic"] { + for oid in [false, true] { + let mut value = provision(); + value["w365Agents"]["provision"]["userType"] = json!(user_type); + if oid { + value["w365Agents"]["provision"]["userObjectId"] = json!(B); + } + let n = fixture.count(); + assert!(invoke(&value, false).is_err()); + assert_eq!(fixture.count(), n, "all user types require authorization"); + fixture.reply( + 200, + &json!({"sessionId":A,"status":"Succeeded"}).to_string(), + ); + invoke(&value, true).unwrap(); + let sent = fixture.request(n); + assert!(sent.contains(&format!("user-type: {user_type}\r\n"))); + assert_eq!(sent.contains("user-object-id:"), oid); + if oid { + assert!(sent.contains(&format!("user-object-id: {B}\r\n"))); + } + assert_eq!(sent.split_once("\r\n\r\n").unwrap().1, ""); + } + } + let upper = "AAAAAAAA-bbbb-CCCC-dddd-EEEEEEEEEEEE"; + let mut value = provision(); + value["w365Agents"]["provision"]["poolId"] = json!(upper); + fixture.reply( + 200, + &json!({ + "sessionId":upper,"computerId":upper.to_ascii_lowercase(),"status":"Succeeded", + "connectivityUrl":"https://example.invalid/?ticket=must-not-persist", + }) + .to_string(), + ); + let value = invoke(&value, true).unwrap(); + assert_eq!( + value["result"]["sandboxId"], + format!("w365:{0}:{0}", upper.to_ascii_lowercase()) + ); + assert!(!value.to_string().contains("must-not-persist")); + fixture.reply( + 201, + &json!({"sessionId":A,"status":"Succeeded"}).to_string(), + ); + assert_eq!( + invoke(&provision(), true).unwrap_err().details.unwrap()["sandboxId"], + format!("w365:{POOL}:{A}") + ); + + for body in [ + json!({"sessionId":A,"computerId":B,"status":"Succeeded"}), + json!({"sessionId":A,"computerId":"bad","status":"Succeeded"}), + json!({"sessionId":A,"computerId":null,"status":"Succeeded"}), + json!({"sessionId":A,"status":"Failed"}), + ] { + fixture.reply(200, &body.to_string()); + let error = invoke(&provision(), true).unwrap_err(); + let details = error.details.unwrap(); + assert_eq!(details["possiblySucceeded"], true); + assert_eq!(details["sandboxId"], format!("w365:{POOL}:{A}")); + fixture.reply(204, ""); + invoke(&request("deprovision", A), true).unwrap(); + assert!(fixture + .request(fixture.count() - 1) + .starts_with(&format!("DELETE /api/sessions/{A}?api-version=2.0"))); + } + for body in ["{bad", "{}", r#"{"sessionId":"bad","status":"Succeeded"}"#] { + let n = fixture.count(); + fixture.reply(200, body); + let details = invoke(&provision(), true).unwrap_err().details.unwrap(); + assert_eq!(details["possiblySucceeded"], true); + assert!(details.get("sandboxId").is_none()); + assert_eq!(fixture.count(), n + 1); + } + for status in [401, 403, 404, 408, 429, 500, 502, 201, 302, 418] { + let n = fixture.count(); + fixture.reply(status, "ticket-secret-must-not-surface"); + let error = invoke(&provision(), true).unwrap_err(); + assert!(!format!("{error:?}").contains("ticket-secret")); + let details = error.details.unwrap(); + assert_eq!(details["httpStatus"], status); + assert_eq!(details["requestId"], "fixture-request"); + assert_eq!( + details["possiblySucceeded"], + status >= 500 || status == 408 || (200..400).contains(&status) + ); + assert_eq!(fixture.count(), n + 1); + } + fixture.drop_reply(); + let n = fixture.count(); + assert_eq!( + invoke(&provision(), true).unwrap_err().details.unwrap()["possiblySucceeded"], + true + ); + assert_eq!(fixture.count(), n + 1); + + for phase in ["stop", "deprovision"] { + for status in [200, 202, 204, 404] { + fixture.reply(status, ""); + let value = invoke(&request(phase, A), true).unwrap(); + assert_eq!(value["result"]["metadata"]["releaseRequested"], true); + assert_eq!(value["result"]["metadata"]["restartable"], false); + } + } + for status in [401, 403, 408, 429, 500, 302, 418] { + let before = fixture.count(); + fixture.reply(status, "secret"); + let details = invoke(&request("stop", A), true) + .unwrap_err() + .details + .unwrap(); + assert_eq!(details["sandboxId"], format!("w365:{POOL}:{A}")); + assert_eq!(details["httpStatus"], status); + assert_eq!( + details["possiblySucceeded"], + status >= 500 || status == 408 || (200..400).contains(&status) + ); + assert_eq!( + fixture.count(), + before + 1, + "release must not retry automatically" + ); + env.set("W365A_CP_TOKEN", "renewed-fixture-token"); + fixture.reply(204, ""); + invoke(&request("deprovision", A), true).unwrap(); + let sent = fixture.request(fixture.count() - 1); + assert!(sent.contains("renewed-fixture-token")); + assert!(sent.starts_with(&format!("DELETE /api/sessions/{A}?api-version=2.0"))); + assert_eq!(fixture.count(), before + 2); + } + fixture.drop_reply(); + assert_eq!( + invoke(&request("deprovision", A), true) + .unwrap_err() + .details + .unwrap()["possiblySucceeded"], + true + ); + fixture.reply( + 200, + &json!({"sessionId":B,"status":"Succeeded"}).to_string(), + ); + invoke(&provision(), true).unwrap(); + fixture.reply(204, ""); + invoke(&request("deprovision", A), true).unwrap(); + assert!(fixture + .request(fixture.count() - 1) + .starts_with(&format!("DELETE /api/sessions/{A}?"))); +} + +// CI explicitly supplies a test-support executor. This never uses a live service. +#[cfg(target_os = "windows")] +#[test] +#[ignore = "requires a separately built test-support native executor"] +fn lifecycle_fresh_executor_fixture() { + let _lock = ENV.lock().unwrap(); + let env = Environment::new(); + let fixture = Fixture::new(); + fixture.configure(&env); + let exe = std::env::var("MXC_W365_TEST_EXECUTOR").expect("fixture executor required"); + let run = |mut value: Value, opt_in: bool| { + let phase = value["phase"].as_str().unwrap().to_owned(); + let id = value["sandboxId"].as_str().map(str::to_owned); + value.as_object_mut().unwrap().remove("phase"); + value.as_object_mut().unwrap().remove("sandboxId"); + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all(value.to_string().as_bytes()).unwrap(); + let mut command = std::process::Command::new(&exe); + command + .arg("--operation") + .arg(phase) + .arg("--config") + .arg(file.path()); + if let Some(id) = id { + command.arg("--container-id").arg(id); + } + if opt_in { + command.arg("--experimental"); + } + command + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + let mut child = command.spawn().unwrap(); + let until = Instant::now() + Duration::from_secs(20); + while child.try_wait().unwrap().is_none() { + if Instant::now() >= until { + child.kill().unwrap(); + panic!("executor fixture timed out"); + } + std::thread::sleep(Duration::from_millis(10)); + } + child.wait_with_output().unwrap() + }; + assert!(!run(provision(), false).status.success()); + assert!(!run(request("deprovision", "bad"), true).status.success()); + assert_eq!(fixture.count(), 0); + fixture.reply( + 200, + &json!({"sessionId":A,"status":"Succeeded"}).to_string(), + ); + let output = run(provision(), true); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let value: Value = serde_json::from_slice(&output.stdout).unwrap(); + let id = value["result"]["sandboxId"].as_str().unwrap(); + assert_eq!(id, format!("w365:{POOL}:{A}")); + assert!(!run(request("start", A), true).status.success()); + assert!(!run(request("exec", A), true).status.success()); + assert_eq!(fixture.count(), 1); + env.set("W365A_CP_TOKEN", "renewed-fixture-token"); + fixture.reply(408, "no"); + let failed = run(request("deprovision", A), true); + assert!(!failed.status.success()); + assert!(String::from_utf8_lossy(&failed.stdout).contains(id)); + let error: Value = serde_json::from_slice(&failed.stdout).unwrap(); + assert_eq!(error["error"]["details"]["httpStatus"], 408); + assert_eq!(error["error"]["details"]["possiblySucceeded"], true); + assert_eq!(error["error"]["details"]["sandboxId"], id); + assert_eq!(fixture.count(), 2, "release must not retry automatically"); + fixture.reply(202, ""); + assert!(run(request("stop", A), true).status.success()); + assert_eq!(fixture.count(), 3); + assert!(fixture + .request(2) + .starts_with(&format!("DELETE /api/sessions/{A}?"))); + fixture.reply( + 200, + &json!({"sessionId":A,"computerId":B,"status":"Succeeded"}).to_string(), + ); + let rejected = run(provision(), true); + assert!(!rejected.status.success()); + let error: Value = serde_json::from_slice(&rejected.stdout).unwrap(); + assert_eq!(error["error"]["details"]["sandboxId"], id); + assert_eq!(error["error"]["details"]["possiblySucceeded"], true); + fixture.reply(204, ""); + assert!(run(request("deprovision", A), true).status.success()); + assert_eq!(fixture.count(), 5); + assert!(fixture + .request(4) + .starts_with(&format!("DELETE /api/sessions/{A}?"))); +} diff --git a/src/mxc-sdk/src/core/mxc_common/cmdline.rs b/src/mxc-sdk/src/core/mxc_common/cmdline.rs index 507a342e42..cd86cb94c3 100644 --- a/src/mxc-sdk/src/core/mxc_common/cmdline.rs +++ b/src/mxc-sdk/src/core/mxc_common/cmdline.rs @@ -36,9 +36,9 @@ impl CommandLineContext { /// `process.commandLine`. pub fn for_backend(backend: &ContainmentBackend) -> Self { match backend { - ContainmentBackend::IsolationSession | ContainmentBackend::WindowsSandbox => { - Self::WindowsCommandProcessor - } + ContainmentBackend::IsolationSession + | ContainmentBackend::WindowsSandbox + | ContainmentBackend::W365Agents => Self::WindowsCommandProcessor, ContainmentBackend::Wslc | ContainmentBackend::Lxc | ContainmentBackend::Seatbelt diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs index 6b6f556daf..805a757066 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use super::w365_agents; -use crate::mxc_common::common_request_ir::CommonRequestIR; use crate::mxc_common::config_contract_adapters::dev::common::{ convert_filesystem, convert_network, convert_process, convert_runtime_config, convert_telemetry, }; @@ -204,13 +202,6 @@ pub(super) fn provision_into_input( fn w365_agents_provision_into_input( request: contract::W365AgentsProvisionRequest, ) -> Result { - let (_, input) = w365_agents_contract_into_input(request); - Err(input.unavailable()) -} - -pub(super) fn w365_agents_contract_into_input( - request: contract::W365AgentsProvisionRequest, -) -> (CommonRequestIR, w365_agents::Input) { let contract::W365AgentsProvisionRequest { schema, comment, @@ -220,13 +211,13 @@ pub(super) fn w365_agents_contract_into_input( telemetry, w365_agents, } = request; - ( + StateAwareInput::new( state_aware_common(schema, comment, version, telemetry), - w365_agents::Input::Provision( + StateAwareOperation::Provision(StateAwareProvision::W365Agents( w365_agents .into_option() .map(|wrapper| wrapper.provision.into_option().map(Into::into)), - ), + )), ) } diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/w365_agents.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/w365_agents.rs index 157ac49d30..0462402a8f 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/w365_agents.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/w365_agents.rs @@ -1,23 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Private input for a backend that is not implemented yet. +//! Mechanical adaptation of W365 provision settings. use crate::mxc_contract::dev as contract; -#[derive(Debug)] -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "Contract-only normalization input; runtime binding is a later slice" - ) -)] -pub(crate) struct Provision { - pub pool_id: String, - pub user_type: Option, - pub user_object_id: Option, -} +use crate::mxc_common::state_aware_operation::W365AgentsProvisionConfig as Provision; impl From for Provision { fn from(value: contract::W365AgentsProvision) -> Self { @@ -36,44 +24,25 @@ impl From for Provision { } } -#[derive(Debug)] -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "Contract-only normalization input; runtime binding is a later slice" - ) -)] -pub(crate) enum Input { - /// The outer option is wrapper presence; the inner option is provision presence. - Provision(Option>), -} - -impl Input { - pub(crate) fn unavailable(self) -> crate::mxc_common::error::WxcError { - // Runtime registration and checked binding are a later implementation slice. - drop(self); - crate::mxc_common::mxc_error::MxcError::backend_unavailable( - "w365_agents has a development request contract but its backend is not implemented", - ) - .into() - } -} - #[cfg(test)] mod tests { use super::*; use crate::mxc_common::common_request_ir::CommonRequestIR; use crate::mxc_common::config_contract_adapters::dev::{adapt_request, AdaptedConfigRequest}; + use crate::mxc_common::state_aware_operation::{StateAwareOperation, StateAwareProvision}; use serde_json::json; - fn adapt(source: &str) -> (CommonRequestIR, Input) { - let contract::Request::Provision(contract::ProvisionRequest::W365Agents(request)) = - contract::parse_request(source).expect("valid W365 provision fixture") + fn adapt(source: &str) -> (CommonRequestIR, StateAwareProvision) { + let AdaptedConfigRequest::StateAware(input) = + adapt_request(contract::parse_request(source).unwrap()).unwrap() else { panic!("expected private W365 normalization input"); }; - super::super::state_aware::w365_agents_contract_into_input(request) + let (common, operation) = input.into_parts(); + let StateAwareOperation::Provision(provision) = operation else { + panic!("provision") + }; + (common, provision) } macro_rules! fixture { @@ -90,7 +59,7 @@ mod tests { fn w365_agents_adapters_preserve_provision_settings_and_later_phase_inputs() { let (common, provision) = adapt(fixture!("w365_agents_provision/valid/complete.json")); assert_eq!(common.telemetry.unwrap().enabled, Some(false)); - let Input::Provision(Some(Some(config))) = provision else { + let StateAwareProvision::W365Agents(Some(Some(config))) = provision else { panic!("provision") }; assert_eq!(config.pool_id, "AAAAAAAA-bbbb-CCCC-dddd-EEEEEEEEEEEE"); @@ -152,7 +121,9 @@ mod tests { assert!(common.containment.is_none()); assert!(common.sandbox_id.is_none()); assert!(common.phase.is_none()); - let Input::Provision(settings) = input; + let StateAwareProvision::W365Agents(settings) = input else { + panic!("W365") + }; if let Some(Some(ref config)) = settings { assert!(config.user_type.is_none()); assert!(config.user_object_id.is_none()); @@ -180,8 +151,8 @@ mod tests { "Agentic", ), ] { - let (_, input) = adapt(source); - let Input::Provision(Some(Some(config))) = input else { + let (_, provision) = adapt(source); + let StateAwareProvision::W365Agents(Some(Some(config))) = provision else { panic!("expected provision settings"); }; assert_eq!(config.user_type.as_deref(), Some(expected)); diff --git a/src/mxc-sdk/src/core/mxc_common/config_parser.rs b/src/mxc-sdk/src/core/mxc_common/config_parser.rs index 0f5227ead8..2586001b36 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_parser.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_parser.rs @@ -14,6 +14,7 @@ use crate::mxc_common::models::{ use crate::mxc_common::mxc_error::MxcError; use crate::mxc_common::network_parser::{parse_network_policy, NetworkSections}; use crate::mxc_common::state_aware_input::StateAwareInput; +#[cfg(test)] use crate::mxc_common::state_aware_operation::{StateAwareOperation, StateAwareProvision}; use crate::mxc_common::state_aware_request::{ MxcRequest, ParsedStateAwareRequest, Phase, TargetIdKind, @@ -175,7 +176,7 @@ pub fn load_one_shot_request_from_contract( } }; - let result = normalize_common_request_ir(config, logger, true); + let result = normalize_common_request_ir(config, logger, true, None); log_one_shot_error(logger, &result); result } @@ -436,7 +437,7 @@ fn parse_exact_v0_9(json: &str, logger: &mut Logger) -> Result normalize_common_request_ir(config, logger, true) + ) => normalize_common_request_ir(config, logger, true, None) .map(MxcRequest::OneShot) .map_err(ParseError::OneShot), crate::mxc_common::config_contract_adapters::v0_9::AdaptedConfigRequest::StateAware( @@ -529,7 +530,7 @@ fn parse_exact_v1_0(json: &str, logger: &mut Logger) -> Result normalize_common_request_ir(config, logger, true) + ) => normalize_common_request_ir(config, logger, true, None) .map(MxcRequest::OneShot) .map_err(ParseError::OneShot), crate::mxc_common::config_contract_adapters::v1_0::AdaptedConfigRequest::StateAware( @@ -612,7 +613,7 @@ fn parse_exact_development(json: &str, logger: &mut Logger) -> Result { - normalize_common_request_ir(config, logger, true) + normalize_common_request_ir(config, logger, true, None) .map(MxcRequest::OneShot) .map_err(ParseError::OneShot) } @@ -1315,12 +1316,13 @@ pub(crate) fn map_wire_containment(c: Option<&wire::Containment>) -> Containment } } -fn state_aware_containment_from_id(sandbox_id: &str) -> Option { +fn state_aware_containment_from_id(sandbox_id: &str) -> Option { match sandbox_id.split_once(':')?.0 { - "wslc" => Some(wire::Containment::Wslc), - "wsb" => Some(wire::Containment::WindowsSandbox), - "iso" => Some(wire::Containment::IsolationSession), - "microvm" => Some(wire::Containment::Microvm), + "wslc" => Some(ContainmentBackend::Wslc), + "wsb" => Some(ContainmentBackend::WindowsSandbox), + "iso" => Some(ContainmentBackend::IsolationSession), + "microvm" => Some(ContainmentBackend::MicroVm), + "w365" => Some(ContainmentBackend::W365Agents), _ => None, } } @@ -1361,6 +1363,7 @@ fn normalize_common_request_ir( cfg: crate::mxc_common::common_request_ir::CommonRequestIR, logger: &mut Logger, require_process: bool, + lifecycle_containment: Option, ) -> Result { let _ignored_metadata = (&cfg.schema, &cfg.comment); let microvm_memory_mib = cfg.microvm.as_ref().and_then(|config| config.memory_mib); @@ -1433,7 +1436,12 @@ fn normalize_common_request_ir( // Containment backend selection. The wire enum has already constrained the // value to a known variant (invalid strings fail at deserialize); abstract // intents and the omitted case resolve to the OS-native backend here. - let containment = map_wire_containment(cfg.containment.as_ref()); + let requested_kind = lifecycle_containment.as_ref().map_or_else( + || requested_sandbox_kind(cfg.containment.as_ref()), + ContainmentBackend::wire_name, + ); + let containment = + lifecycle_containment.unwrap_or_else(|| map_wire_containment(cfg.containment.as_ref())); validate_single_backend_section(containment.clone(), &present_backend_sections)?; @@ -1673,7 +1681,7 @@ fn normalize_common_request_ir( let seatbelt = cfg.seatbelt.map(make_seatbelt_config); let telemetry = cfg.telemetry.map(|raw| TelemetryConfig { enabled: raw.enabled, - requested_sandbox_kind: Some(requested_sandbox_kind(cfg.containment.as_ref())), + requested_sandbox_kind: Some(requested_kind), }); // UI section. Capture presence before the typed mapping consumes `ui`: @@ -1725,24 +1733,12 @@ fn normalize_state_aware( let (common, operation) = input.into_parts(); let mut request = normalize_state_aware_common( common, - NormalizationContext { - phase: operation.phase(), - containment: match &operation { - StateAwareOperation::ProvisionSession(_) - | StateAwareOperation::DeprovisionSession { .. } => Some(wire::Containment::Wslc), - StateAwareOperation::Provision(provision) => Some(match provision { - StateAwareProvision::IsolationSession(_) => wire::Containment::IsolationSession, - StateAwareProvision::WindowsSandbox => wire::Containment::WindowsSandbox, - StateAwareProvision::MicroVm(_) => wire::Containment::Microvm, - StateAwareProvision::Wslc(_) => wire::Containment::Wslc, - }), - StateAwareOperation::Start { .. } - | StateAwareOperation::Exec { .. } - | StateAwareOperation::Stop { .. } - | StateAwareOperation::Deprovision { .. } => None, - }, - sandbox_id: operation.sandbox_id(), - }, + operation.phase(), + operation.containment().or_else(|| { + operation + .sandbox_id() + .and_then(state_aware_containment_from_id) + }), logger, )?; if !preserve_source_contract { @@ -1761,27 +1757,16 @@ pub fn normalize_sdk_state_aware_request( normalize_state_aware(input.into_normalization_input()?, logger, false) } -/// Temporary routing view; production derives it exclusively from the operation. -struct NormalizationContext<'a> { - phase: Phase, - containment: Option, - sandbox_id: Option<&'a str>, -} - fn normalize_state_aware_common( - mut common: crate::mxc_common::common_request_ir::CommonRequestIR, - context: NormalizationContext<'_>, + common: crate::mxc_common::common_request_ir::CommonRequestIR, + phase: Phase, + containment: Option, logger: &mut Logger, ) -> Result { let network_supplied = common.network.is_some(); - common.containment = if context.phase.routes_by_containment() { - context.containment - } else { - context.sandbox_id.and_then(state_aware_containment_from_id) - }; - let require_process = context.phase == Phase::Exec; - let mut request = normalize_common_request_ir(common, logger, require_process)?; - if context.phase != Phase::Provision && !network_supplied { + let require_process = phase == Phase::Exec; + let mut request = normalize_common_request_ir(common, logger, require_process, containment)?; + if phase != Phase::Provision && !network_supplied { request.policy.network_egress = None; request.policy.network_ingress = None; } @@ -2027,6 +2012,9 @@ mod tests { WindowsSandbox, MicroVm(Option>), Wslc(Option<(Option, Option)>), + W365Agents( + Option>, + ), } impl From<&MxcRequest> for RequestSnapshot { @@ -2056,6 +2044,9 @@ mod tests { (config.image.clone(), config.image_tar_path.clone()) })) } + StateAwareProvision::W365Agents(config) => { + ProvisionSnapshot::W365Agents(config.clone()) + } }), _ => None, }, diff --git a/src/mxc-sdk/src/core/mxc_common/models.rs b/src/mxc-sdk/src/core/mxc_common/models.rs index 90ab00c7e3..66ad9b01c8 100644 --- a/src/mxc-sdk/src/core/mxc_common/models.rs +++ b/src/mxc-sdk/src/core/mxc_common/models.rs @@ -44,6 +44,9 @@ pub enum ContainmentBackend { /// Uses `bwrap` to create namespace-isolated processes without root /// privileges. Selected on the wire as `"bubblewrap"`. Bubblewrap, + /// Experimental remote Windows 365 checkout lifecycle. + #[serde(rename = "w365_agents")] + W365Agents, } impl ContainmentBackend { @@ -60,6 +63,7 @@ impl ContainmentBackend { ContainmentBackend::IsolationSession => "isolation_session", ContainmentBackend::Seatbelt => "seatbelt", ContainmentBackend::Bubblewrap => "bubblewrap", + ContainmentBackend::W365Agents => "w365_agents", } } @@ -75,6 +79,7 @@ impl ContainmentBackend { ContainmentBackend::Seatbelt => Some("seatbelt"), ContainmentBackend::IsolationSession => Some("isolationSession"), ContainmentBackend::Hyperlight => Some("hyperlight"), + ContainmentBackend::W365Agents => Some("w365Agents"), ContainmentBackend::Bubblewrap | ContainmentBackend::MicroVm | ContainmentBackend::Vm => None, diff --git a/src/mxc-sdk/src/core/mxc_common/policy_identity.rs b/src/mxc-sdk/src/core/mxc_common/policy_identity.rs index 310f515e9b..e67ff61b9c 100644 --- a/src/mxc-sdk/src/core/mxc_common/policy_identity.rs +++ b/src/mxc-sdk/src/core/mxc_common/policy_identity.rs @@ -180,7 +180,9 @@ fn state_aware_config_projection(operation: &StateAwareOperation) -> Value { StateAwareProvision::IsolationSession(None) | StateAwareProvision::WindowsSandbox | StateAwareProvision::MicroVm(None) - | StateAwareProvision::Wslc(None), + | StateAwareProvision::Wslc(None) + // Pool/account coordinates are not containment policy. + | StateAwareProvision::W365Agents(_), ) => Value::Null, StateAwareOperation::ProvisionSession(Some(WslcProvisionSessionConfig { diff --git a/src/mxc-sdk/src/core/mxc_common/state_aware_binding.rs b/src/mxc-sdk/src/core/mxc_common/state_aware_binding.rs index 99ab0c1494..38ecfd426f 100644 --- a/src/mxc-sdk/src/core/mxc_common/state_aware_binding.rs +++ b/src/mxc-sdk/src/core/mxc_common/state_aware_binding.rs @@ -179,6 +179,30 @@ fn incompatible_payload(actual: &ContainmentBackend, backend: &str) -> MxcError )) } +/// Bind W365 provision inputs and ordinary sandbox-addressed operations. +pub fn bind_w365_agents( + parsed: ParsedStateAwareRequest, +) -> Result, MxcError> +where + B: StatefulSandboxBackend< + ProvisionConfig = crate::mxc_common::state_aware_operation::W365AgentsProvisionConfig, + StartConfig = (), + ExecConfig = (), + StopConfig = (), + DeprovisionConfig = (), + >, +{ + BoundStateAwareRequest::bind( + parsed, + ContainmentBackend::W365Agents, + "w365", + |provision| match provision { + StateAwareProvision::W365Agents(config) => Ok(config.flatten()), + other => Err(incompatible_payload(&other.containment(), B::BACKEND_KEY)), + }, + ) +} + /// Bind an IsolationSession operation without changing configuration presence. pub fn bind_isolation_session( parsed: ParsedStateAwareRequest, diff --git a/src/mxc-sdk/src/core/mxc_common/state_aware_dispatch.rs b/src/mxc-sdk/src/core/mxc_common/state_aware_dispatch.rs index 2868e720d4..5714f67e5e 100644 --- a/src/mxc-sdk/src/core/mxc_common/state_aware_dispatch.rs +++ b/src/mxc-sdk/src/core/mxc_common/state_aware_dispatch.rs @@ -260,6 +260,7 @@ pub(crate) fn backend_from_prefix(prefix: &str) -> Result Ok(ContainmentBackend::MicroVm), "wsb" => Ok(ContainmentBackend::WindowsSandbox), "wslc" => Ok(ContainmentBackend::Wslc), + "w365" => Ok(ContainmentBackend::W365Agents), // Future state-aware backends extend this list. other => Err(MxcError::unsupported_containment(format!( "no state-aware backend registered for prefix {:?}", diff --git a/src/mxc-sdk/src/core/mxc_common/state_aware_operation.rs b/src/mxc-sdk/src/core/mxc_common/state_aware_operation.rs index c6c9b7688d..5f5e92ebe3 100644 --- a/src/mxc-sdk/src/core/mxc_common/state_aware_operation.rs +++ b/src/mxc-sdk/src/core/mxc_common/state_aware_operation.rs @@ -27,6 +27,16 @@ pub enum StateAwareProvision { MicroVm(Option), /// An omitted image remains absent until the WSLC backend chooses a default. Wslc(Option), + /// Presence is preserved until the W365 backend validates provision. + W365Agents(Option>), +} + +/// Passive W365 provision inputs; defaults and enforcement belong to the backend. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct W365AgentsProvisionConfig { + pub pool_id: String, + pub user_type: Option, + pub user_object_id: Option, } impl StateAwareProvision { @@ -37,6 +47,7 @@ impl StateAwareProvision { Self::WindowsSandbox => ContainmentBackend::WindowsSandbox, Self::MicroVm(_) => ContainmentBackend::MicroVm, Self::Wslc(_) => ContainmentBackend::Wslc, + Self::W365Agents(_) => ContainmentBackend::W365Agents, } } } diff --git a/src/mxc-sdk/src/core/mxc_common/state_aware_request.rs b/src/mxc-sdk/src/core/mxc_common/state_aware_request.rs index 646b18f58d..31e50590e4 100644 --- a/src/mxc-sdk/src/core/mxc_common/state_aware_request.rs +++ b/src/mxc-sdk/src/core/mxc_common/state_aware_request.rs @@ -76,6 +76,23 @@ impl std::fmt::Display for Phase { } } +impl std::str::FromStr for Phase { + type Err = MxcError; + + fn from_str(value: &str) -> Result { + match value { + "provision-session" => Ok(Self::ProvisionSession), + "provision" => Ok(Self::Provision), + "start" => Ok(Self::Start), + "exec" => Ok(Self::Exec), + "stop" => Ok(Self::Stop), + "deprovision" => Ok(Self::Deprovision), + "deprovision-session" => Ok(Self::DeprovisionSession), + _ => Err(MxcError::unsupported_phase("unknown lifecycle operation")), + } + } +} + impl From for Phase { fn from(phase: crate::mxc_common::wire::Phase) -> Self { match phase { diff --git a/src/mxc-sdk/src/core/mxc_engine/backend_registry.rs b/src/mxc-sdk/src/core/mxc_engine/backend_registry.rs index c1b55457f7..72b395b082 100644 --- a/src/mxc-sdk/src/core/mxc_engine/backend_registry.rs +++ b/src/mxc-sdk/src/core/mxc_engine/backend_registry.rs @@ -31,7 +31,7 @@ pub(crate) struct BackendRegistration { pub(crate) probe_coverage: ProbeCoverage, } -static BACKENDS: [BackendRegistration; 10] = [ +static BACKENDS: [BackendRegistration; 11] = [ BackendRegistration { backend: ContainmentBackend::ProcessContainer, experimental: false, @@ -85,6 +85,13 @@ static BACKENDS: [BackendRegistration; 10] = [ experimental: false, probe_coverage: ProbeCoverage::Detected(&["linux"]), }, + BackendRegistration { + backend: ContainmentBackend::W365Agents, + experimental: true, + probe_coverage: ProbeCoverage::NotDetected( + "remote service usability is not locally detectable", + ), + }, ]; /// Every registered backend, for callers that must stay exhaustive over them. @@ -108,6 +115,7 @@ pub(crate) const CANONICAL_BACKEND_NAMES: &[(ContainmentBackend, &str)] = &[ (ContainmentBackend::IsolationSession, "isolation_session"), (ContainmentBackend::Seatbelt, "seatbelt"), (ContainmentBackend::Bubblewrap, "bubblewrap"), + (ContainmentBackend::W365Agents, "w365_agents"), ]; /// Every canonical backend wire name. @@ -146,6 +154,7 @@ pub(crate) fn registration(backend: &ContainmentBackend) -> &'static BackendRegi ContainmentBackend::IsolationSession => 7, ContainmentBackend::Seatbelt => 8, ContainmentBackend::Bubblewrap => 9, + ContainmentBackend::W365Agents => 10, }; &BACKENDS[index] } @@ -162,7 +171,7 @@ mod tests { assert!(names.insert(entry.backend.wire_name())); assert!(std::ptr::eq(registration(&entry.backend), entry)); } - assert_eq!(names.len(), 10); + assert_eq!(names.len(), 11); } #[test] @@ -174,7 +183,7 @@ mod tests { .collect(); assert_eq!( experimental, - BTreeSet::from(["microvm", "hyperlight", "windows_sandbox"]) + BTreeSet::from(["microvm", "hyperlight", "windows_sandbox", "w365_agents"]) ); } diff --git a/src/mxc-sdk/src/core/mxc_engine/run.rs b/src/mxc-sdk/src/core/mxc_engine/run.rs index 03081f93cd..c73ce894a7 100644 --- a/src/mxc-sdk/src/core/mxc_engine/run.rs +++ b/src/mxc-sdk/src/core/mxc_engine/run.rs @@ -188,6 +188,9 @@ fn resolve_runner_inner_windows( logger: &mut Logger, ) -> Result { match request.containment { + ContainmentBackend::W365Agents => Err(MxcError::unsupported_phase( + "w365_agents supports container lifecycle operations only", + )), ContainmentBackend::ProcessContainer => { use crate::mxc_common::sandbox_process::Runner; use crate::process_container_common::base_container_runner::BaseContainerRunner; diff --git a/src/mxc-sdk/src/core/mxc_engine/state_aware.rs b/src/mxc-sdk/src/core/mxc_engine/state_aware.rs index 26dac762ed..d320078e71 100644 --- a/src/mxc-sdk/src/core/mxc_engine/state_aware.rs +++ b/src/mxc-sdk/src/core/mxc_engine/state_aware.rs @@ -108,6 +108,15 @@ pub fn run_state_aware( parsed.request().experimental_enabled, )?; match backend { + crate::mxc_common::models::ContainmentBackend::W365Agents => { + let bound = crate::mxc_common::state_aware_binding::bind_w365_agents(parsed)?; + let mut runner = crate::backend_modules::w365agents::Runner; + crate::mxc_common::state_aware_dispatch::dispatch_state_aware( + &mut runner, + bound, + dry_run, + ) + } #[cfg(target_os = "windows")] crate::mxc_common::models::ContainmentBackend::WindowsSandbox => { let bound = crate::mxc_common::state_aware_binding::bind_windows_sandbox(parsed)?; @@ -377,6 +386,14 @@ fn exec_state_aware_with_capture_mode( parsed.request().experimental_enabled, )?; match backend { + crate::mxc_common::models::ContainmentBackend::W365Agents => { + let bound = crate::mxc_common::state_aware_binding::bind_w365_agents(parsed)?; + crate::mxc_common::state_aware_dispatch::dispatch_state_aware_exec( + &mut crate::backend_modules::w365agents::Runner, + bound, + )?; + Err(crate::backend_modules::w365agents::deferred()) + } #[cfg(target_os = "windows")] crate::mxc_common::models::ContainmentBackend::WindowsSandbox => { let bound = crate::mxc_common::state_aware_binding::bind_windows_sandbox(parsed)?; @@ -465,6 +482,14 @@ pub fn exec_state_aware_pty( parsed.request().experimental_enabled, )?; match backend { + crate::mxc_common::models::ContainmentBackend::W365Agents => { + let bound = crate::mxc_common::state_aware_binding::bind_w365_agents(parsed)?; + crate::mxc_common::state_aware_dispatch::dispatch_state_aware_exec_pty( + &mut crate::backend_modules::w365agents::Runner, + bound, + size, + ) + } #[cfg(all(target_os = "windows", feature = "isolation_session"))] crate::mxc_common::models::ContainmentBackend::IsolationSession => { let bound = crate::mxc_common::state_aware_binding::bind_isolation_session(parsed)?; @@ -1093,6 +1118,7 @@ pub fn run_state_aware_json( ) -> Result { let mut logger = Logger::new(Mode::Buffer); let parsed = parse_state_aware(request_json, experimental, &mut logger)?; + require_sdk_lifecycle_ready(&parsed)?; let value = run_state_aware_envelope(parsed, dry_run, &mut logger)?; serde_json::to_string(&value).map_err(|error| { Error::from(MxcError::backend_error(format!( @@ -1113,6 +1139,7 @@ pub fn exec_state_aware_json( ) -> Result, Error> { let mut logger = Logger::new(Mode::Buffer); let parsed = parse_state_aware(request_json, experimental, &mut logger)?; + require_sdk_lifecycle_ready(&parsed)?; exec_state_aware_parsed(parsed, &mut logger, stdin) } @@ -1122,6 +1149,7 @@ pub fn exec_state_aware_capture_json( ) -> Result, Error> { let mut logger = Logger::new(Mode::Buffer); let parsed = parse_state_aware(request_json, experimental, &mut logger)?; + require_sdk_lifecycle_ready(&parsed)?; exec_state_aware_capture_parsed(parsed, &mut logger) } @@ -1133,9 +1161,19 @@ pub fn exec_state_aware_pty_json( ) -> Result, Error> { let mut logger = Logger::new(Mode::Buffer); let parsed = parse_state_aware(request_json, experimental, &mut logger)?; + require_sdk_lifecycle_ready(&parsed)?; exec_state_aware_pty_parsed(parsed, &mut logger, size) } +fn require_sdk_lifecycle_ready(parsed: &ParsedStateAwareRequest) -> Result<(), Error> { + if parsed.request().containment == crate::mxc_common::models::ContainmentBackend::W365Agents { + return Err(MxcError::backend_unavailable( + "W365 Dev SDK access is deferred until cross-SDK error details and SDK validation are ready; use the experimental native executor", + ).into()); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/w365_agents.rs b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/w365_agents.rs index 2753cd2bd6..9a2cb5d5e9 100644 --- a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/w365_agents.rs +++ b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/w365_agents.rs @@ -60,24 +60,22 @@ fn w365_agents_contract_acceptance_never_enables_execution() { let source = value.to_string(); dev::parse_request(&source) .expect("structural acceptance is independent of availability"); - if phase != "provision" { + { let parsed = load_mxc_request_from_json(&source, &mut Logger::new(Mode::Buffer)) - .expect("later phases use normal lifecycle adaptation"); + .expect("normal lifecycle adaptation"); let mxc_sdk::mxc_common::state_aware_request::MxcRequest::StateAware(parsed) = parsed else { panic!("expected lifecycle request"); }; - assert_eq!(parsed.sandbox_id(), Some(ID)); - let error = mxc_sdk::mxc_common::state_aware_dispatch::resolve_backend(&parsed) - .expect_err("W365 is not registered"); - assert_eq!(error.code, MxcErrorCode::UnsupportedContainment); + assert!(!parsed.request().experimental_enabled); + if phase != "provision" { assert_eq!(parsed.sandbox_id(), Some(ID)); } + assert_eq!( + mxc_sdk::mxc_common::state_aware_dispatch::resolve_backend(&parsed).unwrap(), + mxc_sdk::mxc_common::models::ContainmentBackend::W365Agents, + ); continue; } - let error = load_mxc_request_from_json(&source, &mut Logger::new(Mode::Buffer)) - .expect_err("no runtime operation may be produced for this backend"); - assert_eq!(error.to_mxc_error().code, MxcErrorCode::BackendUnavailable); - assert!(error.to_mxc_error().message.contains("not implemented")); } } } @@ -156,16 +154,23 @@ fn w365_agents_identity_field_types_are_enforced() { } #[test] -fn w365_agents_supported_user_types_remain_unavailable() { +fn w365_agents_supported_user_types_preserve_experimental_authorization() { for user_type in ["Local", "HumanUser", "Agentic"] { let mut value = request("provision"); value["w365Agents"] = json!({"provision":settings("provision")}); value["w365Agents"]["provision"]["userType"] = json!(user_type); let source = value.to_string(); dev::parse_request(&source).expect("supported user type"); - let error = load_mxc_request_from_json(&source, &mut Logger::new(Mode::Buffer)) - .expect_err("contract support must not enable backend execution"); - assert_eq!(error.to_mxc_error().code, MxcErrorCode::BackendUnavailable); + let parsed = load_mxc_request_from_json(&source, &mut Logger::new(Mode::Buffer)) + .expect("normal lifecycle adaptation"); + let mxc_sdk::mxc_common::state_aware_request::MxcRequest::StateAware(parsed) = parsed else { + panic!("expected lifecycle request"); + }; + assert!(!parsed.request().experimental_enabled); + assert_eq!( + mxc_sdk::mxc_common::state_aware_dispatch::resolve_backend(&parsed).unwrap(), + mxc_sdk::mxc_common::models::ContainmentBackend::W365Agents, + ); } } @@ -239,11 +244,7 @@ fn w365_agents_dev_sdk_refuses_every_phase_with_or_without_opt_in() { "deprovision" => sdk::deprovision_container_json(&source, options).unwrap_err(), _ => unreachable!(), }; - let expected = if phase == "provision" { - mxc_sdk::v1::ErrorCode::BackendUnavailable - } else { - mxc_sdk::v1::ErrorCode::UnsupportedContainment - }; + let expected = mxc_sdk::v1::ErrorCode::BackendUnavailable; assert_eq!(error.code, expected, "{phase}, experimental={experimental}"); if phase == "exec" { let error = sdk::spawn_in_container_json(&source, options) From d2d77aa2d604df5ae76df80a6a1cc8796e84b378 Mon Sep 17 00:00:00 2001 From: tbyfield <68976176+tbyfield@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:08:07 -0800 Subject: [PATCH 2/5] Preserve W365 checkout cleanup IDs and require Azure fixtures Retain valid session identities from bounded checkout error responses without changing HTTP failure classification or replaying allocation. Cover recovery and unreadable bodies in TLS and fresh-executor fixtures, and require fixture discovery and execution on native Azure build hosts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .azure-pipelines/templates/Mac.Build.Job.yml | 2 + .azure-pipelines/templates/Rust.Build.Job.yml | 26 ++++++ .../backends/w365agents/w365agents-backend.md | 15 ++-- .../ci-validation-infrastructure.md | 13 ++- .../src/backends/w365agents/service.rs | 72 ++++++++-------- src/mxc-sdk/src/backends/w365agents/tests.rs | 84 ++++++++++++++++++- 6 files changed, 169 insertions(+), 43 deletions(-) diff --git a/.azure-pipelines/templates/Mac.Build.Job.yml b/.azure-pipelines/templates/Mac.Build.Job.yml index 8df568e71f..cbeffcc223 100644 --- a/.azure-pipelines/templates/Mac.Build.Job.yml +++ b/.azure-pipelines/templates/Mac.Build.Job.yml @@ -83,6 +83,8 @@ jobs: # retaining the rest of the package tests. - script: | set -euo pipefail + tests="$(cargo test --locked --release --target $(targetTriple) -p mxc-sdk --lib w365 -- --list)" + grep -q 'lifecycle_contract_and_recovery_fixture: test' <<< "$tests" cargo test --locked --release --target $(targetTriple) \ -p mxc_darwin -p mxc-sdk -p unix_test_proxy -p mxc_ffi -- \ --skip 'core_modules::mxc_pty::tests::' diff --git a/.azure-pipelines/templates/Rust.Build.Job.yml b/.azure-pipelines/templates/Rust.Build.Job.yml index e0e29a721e..356b064d3b 100644 --- a/.azure-pipelines/templates/Rust.Build.Job.yml +++ b/.azure-pipelines/templates/Rust.Build.Job.yml @@ -155,6 +155,32 @@ jobs: workingDirectory: $(Build.SourcesDirectory)/src condition: and(eq('${{ item.os }}', 'linux'), eq('${{ item.arch }}', 'x64')) + - script: | + set -euo pipefail + tests="$(cargo test --locked --release --target $(triplet) -p mxc-sdk --lib w365 -- --list)" + grep -q 'lifecycle_contract_and_recovery_fixture: test' <<< "$tests" + cargo test --locked --release --target $(triplet) -p mxc-sdk --lib w365 -- --include-ignored + displayName: Test W365 checkout and release fixtures + workingDirectory: $(Build.SourcesDirectory)/src + condition: and(eq('${{ item.os }}', 'linux'), eq('${{ item.arch }}', 'x64')) + + - pwsh: | + $ErrorActionPreference = 'Stop' + $fixtureTarget = Join-Path '$(Agent.TempDirectory)' 'w365-fixture' + cargo build --locked --release --target $(triplet) -p wxc --features test-support --target-dir $fixtureTarget + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $env:MXC_W365_TEST_EXECUTOR = (Resolve-Path (Join-Path $fixtureTarget '$(triplet)\release\wxc-exec.exe')).Path + $tests = cargo test --locked --release --target $(triplet) -p mxc-sdk --lib w365 -- --list + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + foreach ($name in @('lifecycle_contract_and_recovery_fixture', 'lifecycle_fresh_executor_fixture')) { + if (-not ($tests -match "${name}: test")) { throw "Missing W365 fixture: $name" } + } + cargo test --locked --release --target $(triplet) -p mxc-sdk --lib w365 -- --include-ignored + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + displayName: Test W365 checkout and fresh-executor release fixtures + workingDirectory: $(Build.SourcesDirectory)/src + condition: and(eq('${{ item.os }}', 'windows'), eq('${{ item.arch }}', 'x64')) + - task: EsrpCodeSigning@6 displayName: Code Sign condition: and(eq(${{ parameters.isOfficialBuild }}, true), eq('${{ item.os }}', 'windows')) diff --git a/docs/backends/w365agents/w365agents-backend.md b/docs/backends/w365agents/w365agents-backend.md index ca748639fc..152fb7232f 100644 --- a/docs/backends/w365agents/w365agents-backend.md +++ b/docs/backends/w365agents/w365agents-backend.md @@ -69,8 +69,11 @@ tokens or ticket-bearing metadata. Rejected successful checkout retains a valid cleanup identity as `error.details.sandboxId`, `poolId` and `sessionId`, with -`possiblySucceeded: true`. No ID is fabricated for an absent or invalid -session ID. Unparseable, dropped and ambiguous checkout replies report +`possiblySucceeded: true`. HTTP error replies also retain a valid session ID +from a parseable response body within the 1 MiB limit, preserving the HTTP +error code and uncertainty classification even when the body cannot be read +or parsed. No ID is fabricated for an absent or invalid session ID. +Unparseable, dropped and ambiguous checkout replies report uncertainty and request context. Never automatically repeat that POST. HTTP authorization, missing-pool, throttling and server failures have operation-specific `details.code` values. @@ -168,9 +171,11 @@ $env:MXC_W365_TEST_EXECUTOR = (Resolve-Path target\debug\wxc-exec.exe).Path cargo test --locked -p mxc-sdk --lib w365 -- --include-ignored ``` -Windows CI uses a separate target directory so fixture executors cannot replace -shipping artifacts. Linux and macOS run the engine fixture without a fixture -executor. +GitHub Actions and Azure Windows CI use a separate target directory so fixture +executors cannot replace shipping artifacts. Both CI systems run the engine +fixture on Linux and macOS without a fixture executor. Azure runs fixtures +only on native hosts: Windows/Linux x64 and macOS arm64; cross-compiled arm64 +Windows/Linux jobs do not execute them. Only test builds honor `MXC_W365_TEST_CA` and `MXC_W365_TEST_PORT`; they add a trusted fixture certificate, never disable certificate or hostname validation. Shipping builds ignore these variables. Fixtures use only loopback services, diff --git a/docs/development/build-and-test/ci-validation-infrastructure.md b/docs/development/build-and-test/ci-validation-infrastructure.md index 38840f916b..a0320b9cb6 100644 --- a/docs/development/build-and-test/ci-validation-infrastructure.md +++ b/docs/development/build-and-test/ci-validation-infrastructure.md @@ -6,7 +6,8 @@ How MXC runs its backend end-to-end suites across real operating systems, what each job covers today, and what to change when you need to add, remove, or retire something. -This document describes the GitHub Actions validation matrix only. PR-time +This document describes the GitHub Actions validation matrix, with Azure build +coverage noted for W365 below. PR-time build/lint/SDK validation is covered by [`pull-requests.md`](pull-requests.md); the individual local test scripts are documented in [`tests/scripts/README.md`](../../../tests/scripts/README.md). @@ -14,8 +15,8 @@ the individual local test scripts are documented in ## At a glance W365 checkout/release uses offline trusted-TLS functional fixtures in the -existing Windows, Linux and macOS **build jobs**, not the scheduled live-service -matrix. Each job checks discovery of `lifecycle_contract_and_recovery_fixture` +existing GitHub Actions and Azure Windows, Linux and macOS **build jobs**, not +the scheduled live-service matrix. Each job checks discovery of `lifecycle_contract_and_recovery_fixture` and runs it through the normal parser/engine/backend paths. Windows additionally builds a separate `test-support` executor, checks discovery of `lifecycle_fresh_executor_fixture` and runs it in fresh processes. Linux and macOS @@ -25,6 +26,12 @@ No OS/architecture matrix entry is removed. See the [W365 backend guide](../../backends/w365agents/w365agents-backend.md) for local commands, service-contract differences and the explicitly unrun live validation. +Azure's `Rust.Build.Job.yml` explicitly discovers and runs the engine fixture +on Linux x64 and both fixtures on Windows x64, using an isolated executor target +directory outside shipping artifacts. `Mac.Build.Job.yml` checks engine fixture +discovery before its existing package suite runs it on macOS arm64. Azure's +cross-compiled Windows/Linux arm64 jobs remain build-only. + - Validation tests **never build from source**. They download the artifacts produced by `Build.Windows.Job.yml` / `Build.Linux.Job.yml` / `Build.MacOS.Job.yml`, and for ProcessContainer, IsolationSession, and WSLC the diff --git a/src/mxc-sdk/src/backends/w365agents/service.rs b/src/mxc-sdk/src/backends/w365agents/service.rs index 372af56afb..164a7c4182 100644 --- a/src/mxc-sdk/src/backends/w365agents/service.rs +++ b/src/mxc-sdk/src/backends/w365agents/service.rs @@ -173,26 +173,8 @@ impl Client { })?; let status = response.status().as_u16(); let server_id = safe_request_id(&response); - if !(200..300).contains(&status) { - let code = match status { - 401 | 403 => "auth_denied", - 404 => "pool_not_found", - 429 => "checkout_throttled", - 500 => "provision_timeout", - _ => "checkout_failed", - }; - return Err(failure( - code, - "checkout", - Some(status), - &request_id, - server_id.as_deref(), - status >= 500 || status == 408 || (200..400).contains(&status), - None, - )); - } let mut response = response; - let body = response + let value = response .body_mut() .with_config() .limit(MAX_RESPONSE) @@ -207,27 +189,51 @@ impl Client { true, None, ) - })?; - // Do not include any response body or ticket-bearing metadata in diagnostics. - let value: Value = serde_json::from_str(&body).map_err(|_| { - failure( - "unparsable_response", - "checkout", - Some(status), - &request_id, - server_id.as_deref(), - true, - None, - ) - })?; + }) + .and_then(|body| { + // Never include response bodies or ticket-bearing metadata in diagnostics. + serde_json::from_str::(&body).map_err(|_| { + failure( + "unparsable_response", + "checkout", + Some(status), + &request_id, + server_id.as_deref(), + true, + None, + ) + }) + }); let session = value - .get("sessionId") + .as_ref() + .ok() + .and_then(|value| value.get("sessionId")) .and_then(Value::as_str) .filter(|id| guid(id)); let identity = session.map(|id| Identity { pool_id: pool.clone(), session_id: id.to_ascii_lowercase(), }); + if !(200..300).contains(&status) { + let code = match status { + 401 | 403 => "auth_denied", + 404 => "pool_not_found", + 429 => "checkout_throttled", + 500 => "provision_timeout", + _ => "checkout_failed", + }; + // Body failures must not replace the HTTP error; a valid ID still permits cleanup. + return Err(failure( + code, + "checkout", + Some(status), + &request_id, + server_id.as_deref(), + status >= 500 || status == 408 || (200..400).contains(&status), + identity.as_ref(), + )); + } + let value = value?; let reject = |code| { failure( code, diff --git a/src/mxc-sdk/src/backends/w365agents/tests.rs b/src/mxc-sdk/src/backends/w365agents/tests.rs index 9544381f27..7250b583b8 100644 --- a/src/mxc-sdk/src/backends/w365agents/tests.rs +++ b/src/mxc-sdk/src/backends/w365agents/tests.rs @@ -135,9 +135,11 @@ impl Fixture { env.set("MXC_W365_TEST_PORT", &self.port.to_string()); } fn reply(&self, status: u16, body: &str) { + self.reply_with_length(status, body, body.len()); + } + fn reply_with_length(&self, status: u16, body: &str, length: usize) { self.replies.lock().unwrap().push_back(Some(format!( - "HTTP/1.1 {status} Fixture\r\nContent-Length: {}\r\nx-ms-request-id: fixture-request\r\nConnection: close\r\n\r\n{body}", - body.len() + "HTTP/1.1 {status} Fixture\r\nContent-Length: {length}\r\nx-ms-request-id: fixture-request\r\nConnection: close\r\n\r\n{body}" ))); } fn drop_reply(&self) { @@ -422,6 +424,59 @@ fn lifecycle_contract_and_recovery_fixture() { assert!(details.get("sandboxId").is_none()); assert_eq!(fixture.count(), n + 1); } + for (status, code) in [(500, "provision_timeout"), (408, "checkout_failed")] { + let n = fixture.count(); + fixture.reply( + status, + &json!({ + "sessionId":upper, + "computerId":B, + "connectivityUrl":"https://example.invalid/?ticket=must-not-persist", + }) + .to_string(), + ); + let error = invoke(&provision(), true).unwrap_err(); + assert_eq!(error.code, MxcErrorCode::BackendError); + assert!(!format!("{error:?}").contains("must-not-persist")); + let details = error.details.unwrap(); + assert_eq!(details["code"], code); + assert_eq!(details["httpStatus"], status); + assert_eq!(details["possiblySucceeded"], true); + assert_eq!(details["requestId"], "fixture-request"); + assert!(uuid::Uuid::parse_str(details["clientRequestId"].as_str().unwrap()).is_ok()); + assert_eq!(details["poolId"], POOL); + assert_eq!(details["sessionId"], upper.to_ascii_lowercase()); + assert_eq!( + details["sandboxId"], + format!("w365:{POOL}:{}", upper.to_ascii_lowercase()) + ); + assert_eq!(fixture.count(), n + 1, "failed checkout must not replay"); + fixture.reply(204, ""); + invoke(&request("deprovision", upper), true).unwrap(); + assert!(fixture.request(n + 1).starts_with(&format!( + "DELETE /api/sessions/{}?api-version=2.0", + upper.to_ascii_lowercase() + ))); + + let valid_body = json!({"sessionId":A}).to_string(); + for (body, length) in [ + ("{bad", 4), + ("{}", 2), + (r#"{"sessionId":"bad"}"#, 19), + (valid_body.as_str(), valid_body.len() + 1), + (valid_body.as_str(), 1024 * 1024 + 1), + ] { + let n = fixture.count(); + fixture.reply_with_length(status, body, length); + let details = invoke(&provision(), true).unwrap_err().details.unwrap(); + assert_eq!(details["code"], code); + assert_eq!(details["httpStatus"], status); + assert_eq!(details["possiblySucceeded"], true); + assert!(details.get("sandboxId").is_none()); + assert!(details.get("sessionId").is_none()); + assert_eq!(fixture.count(), n + 1); + } + } for status in [401, 403, 404, 408, 429, 500, 502, 201, 302, 418] { let n = fixture.count(); fixture.reply(status, "ticket-secret-must-not-surface"); @@ -591,4 +646,29 @@ fn lifecycle_fresh_executor_fixture() { assert!(fixture .request(4) .starts_with(&format!("DELETE /api/sessions/{A}?"))); + + fixture.reply(500, &json!({"sessionId":B}).to_string()); + let failed = run(provision(), true); + assert!(!failed.status.success()); + let error: Value = serde_json::from_slice(&failed.stdout).unwrap(); + assert_eq!(error["error"]["details"]["code"], "provision_timeout"); + assert_eq!(error["error"]["details"]["httpStatus"], 500); + assert_eq!(error["error"]["details"]["possiblySucceeded"], true); + assert_eq!( + error["error"]["details"]["sandboxId"], + format!("w365:{POOL}:{B}") + ); + assert_eq!(fixture.count(), 6, "failed checkout must not replay"); + let mut cleanup = request("deprovision", B); + cleanup["sandboxId"] = error["error"]["details"]["sandboxId"].clone(); + env.set("W365A_CP_TOKEN", "renewed-error-cleanup-token"); + fixture.reply(204, ""); + assert!(run(cleanup, true).status.success()); + assert_eq!(fixture.count(), 7); + assert!(fixture + .request(6) + .starts_with(&format!("DELETE /api/sessions/{B}?"))); + assert!(fixture + .request(6) + .contains("Bearer renewed-error-cleanup-token")); } From 2ec64384d0075f0a589f2b1490fcc05237ce8b4f Mon Sep 17 00:00:00 2001 From: tbyfield <68976176+tbyfield@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:38:27 -0800 Subject: [PATCH 3/5] Keep Hyperlight native TLS scoped to x86_64 Preserve W365 rustls on all targets while restoring the architecture-specific optional native TLS and gzip features for Hyperlight. Add an Azure ARM64 dependency graph regression check. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .azure-pipelines/templates/Rust.Build.Job.yml | 12 ++++++++++++ .../build-and-test/ci-validation-infrastructure.md | 4 +++- src/mxc-sdk/Cargo.toml | 4 ++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.azure-pipelines/templates/Rust.Build.Job.yml b/.azure-pipelines/templates/Rust.Build.Job.yml index 356b064d3b..efac510aba 100644 --- a/.azure-pipelines/templates/Rust.Build.Job.yml +++ b/.azure-pipelines/templates/Rust.Build.Job.yml @@ -128,6 +128,18 @@ jobs: ${{ else }}: enableTest: 'false' + - script: | + set -euo pipefail + features="$(cargo tree --locked --target $(triplet) -p mxc-sdk --no-default-features --features hyperlight -e features -i ureq)" + grep -q 'ureq feature "rustls"' <<< "$features" + if grep -q 'ureq feature "native-tls' <<< "$features"; then + echo "ARM64 must not enable Hyperlight's x86_64-only native TLS dependency." >&2 + exit 1 + fi + displayName: Check ARM64 HTTP dependency features + workingDirectory: $(Build.SourcesDirectory)/src + condition: and(eq('${{ item.os }}', 'linux'), eq('${{ item.arch }}', 'arm64')) + # These are separate workspace members (not deps of lxc), so the # workspace-member-scoped build above doesn't pick them up. - script: | diff --git a/docs/development/build-and-test/ci-validation-infrastructure.md b/docs/development/build-and-test/ci-validation-infrastructure.md index a0320b9cb6..22639123b5 100644 --- a/docs/development/build-and-test/ci-validation-infrastructure.md +++ b/docs/development/build-and-test/ci-validation-infrastructure.md @@ -30,7 +30,9 @@ Azure's `Rust.Build.Job.yml` explicitly discovers and runs the engine fixture on Linux x64 and both fixtures on Windows x64, using an isolated executor target directory outside shipping artifacts. `Mac.Build.Job.yml` checks engine fixture discovery before its existing package suite runs it on macOS arm64. Azure's -cross-compiled Windows/Linux arm64 jobs remain build-only. +cross-compiled Windows/Linux arm64 jobs remain build-only. The Linux arm64 job +also checks that enabling `hyperlight` retains W365's rustls dependency without +enabling Hyperlight's x86_64-only native TLS dependency. - Validation tests **never build from source**. They download the artifacts produced by `Build.Windows.Job.yml` / `Build.Linux.Job.yml` / diff --git a/src/mxc-sdk/Cargo.toml b/src/mxc-sdk/Cargo.toml index a45a9a4b6b..8e27a12afc 100644 --- a/src/mxc-sdk/Cargo.toml +++ b/src/mxc-sdk/Cargo.toml @@ -39,8 +39,7 @@ test-support = [] microvm = ["dep:aci_edge_sandboxes", "dep:libloading"] hyperlight = [ "dep:hyperlight-unikraft", - "ureq/native-tls", - "ureq/gzip", + "dep:ureq", "dep:flate2", "dep:tar", ] @@ -97,6 +96,7 @@ serial_test.workspace = true [target.'cfg(target_arch = "x86_64")'.dependencies] flate2 = { version = "1", optional = true } hyperlight-unikraft = { version = "=0.21.0", default-features = false, optional = true } +ureq = { version = "3", default-features = false, features = ["native-tls", "gzip"], optional = true } [target.'cfg(all(target_os = "windows", target_arch = "x86_64"))'.dependencies] aci_edge_sandboxes = { version = "=0.1.1", default-features = false, features = ["agent-bundled"], optional = true } From ceb612a885bf6be7011ee4d7ef5c487734bd336e Mon Sep 17 00:00:00 2001 From: tbyfield <68976176+tbyfield@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:32:54 -0800 Subject: [PATCH 4/5] Use native TLS for W365 HTTPS and lifecycle fixtures Use platform trust for production HTTPS and replace the rustls test server with native TLS. Generate test certificates at runtime, package Windows identities with OpenSSL, and explicitly clean up imported fixture keys. Add Linux OpenSSL build prerequisites including ARM64 target libraries and update CI dependency checks and documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .azure-pipelines/templates/Rust.Build.Job.yml | 39 +++++- .github/workflows/Build.Linux.Job.yml | 3 + .github/workflows/Build.Windows.Job.yml | 9 ++ .../backends/w365agents/w365agents-backend.md | 19 ++- .../ci-validation-infrastructure.md | 7 +- src/Cargo.lock | 46 +------ src/mxc-sdk/Cargo.toml | 5 +- .../src/backends/w365agents/service.rs | 8 +- src/mxc-sdk/src/backends/w365agents/tests.rs | 113 ++++++++++++++++-- 9 files changed, 182 insertions(+), 67 deletions(-) diff --git a/.azure-pipelines/templates/Rust.Build.Job.yml b/.azure-pipelines/templates/Rust.Build.Job.yml index efac510aba..4826a00e7b 100644 --- a/.azure-pipelines/templates/Rust.Build.Job.yml +++ b/.azure-pipelines/templates/Rust.Build.Job.yml @@ -64,6 +64,9 @@ jobs: artifactPrefix: lxc-binaries workingDirectory: $(Build.SourcesDirectory)/src/tools/lxc cargoFeatures: hyperlight + ${{ if eq(item.arch, 'arm64') }}: + AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_LIB_DIR: /usr/lib/aarch64-linux-gnu + AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_INCLUDE_DIR: /usr/include signPattern: | lxc-exec unix-test-proxy @@ -98,11 +101,37 @@ jobs: steps: - checkout: self - # Install Linux ARM64 toolchain for arm64 linux builds + - pwsh: | + $opensslDirectory = Join-Path $env:ProgramFiles 'Git\usr\bin' + if (-not (Test-Path (Join-Path $opensslDirectory 'openssl.exe'))) { + throw 'Windows TLS fixtures require OpenSSL from Git for Windows' + } + Write-Host "##vso[task.prependpath]$opensslDirectory" + displayName: Expose OpenSSL for Windows TLS fixtures + condition: and(eq('${{ item.os }}', 'windows'), eq('${{ item.arch }}', 'x64')) + - script: | + set -euo pipefail sudo apt-get update + sudo apt-get install -y --no-install-recommends libssl-dev pkg-config + displayName: Install Linux TLS build dependencies + condition: eq('${{ item.os }}', 'linux') + + # Install Linux ARM64 toolchain for arm64 linux builds + - script: | + set -euo pipefail sudo apt-get install -y gcc-aarch64-linux-gnu - displayName: Install ARM64 toolchain + sudo dpkg --add-architecture arm64 + . /etc/os-release + sources="$(mktemp)" + trap 'rm -f "$sources"' EXIT + for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do + echo "deb [arch=arm64 signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg] http://ports.ubuntu.com/ubuntu-ports $suite main" >> "$sources" + done + apt_options=(-o "Dir::Etc::sourcelist=$sources" -o "Dir::Etc::sourceparts=-") + sudo apt-get "${apt_options[@]}" update + sudo apt-get "${apt_options[@]}" install -y --no-install-recommends libssl-dev:arm64 + displayName: Install ARM64 toolchain and target OpenSSL condition: and(eq('${{ item.os }}','linux'), eq('${{ item.arch }}','arm64')) - ${{ if eq(parameters.isOfficialBuild, true) }}: @@ -131,9 +160,9 @@ jobs: - script: | set -euo pipefail features="$(cargo tree --locked --target $(triplet) -p mxc-sdk --no-default-features --features hyperlight -e features -i ureq)" - grep -q 'ureq feature "rustls"' <<< "$features" - if grep -q 'ureq feature "native-tls' <<< "$features"; then - echo "ARM64 must not enable Hyperlight's x86_64-only native TLS dependency." >&2 + grep -q 'ureq feature "native-tls"' <<< "$features" + if grep -qE 'ureq feature "(gzip|rustls)"' <<< "$features"; then + echo "ARM64 must use native TLS without rustls or Hyperlight's x86_64-only gzip feature." >&2 exit 1 fi displayName: Check ARM64 HTTP dependency features diff --git a/.github/workflows/Build.Linux.Job.yml b/.github/workflows/Build.Linux.Job.yml index ec606a43c0..f0a3e3ba30 100644 --- a/.github/workflows/Build.Linux.Job.yml +++ b/.github/workflows/Build.Linux.Job.yml @@ -53,6 +53,9 @@ jobs: key: ${{ matrix.target }}-v2 cache-on-failure: false + - name: Install Linux TLS build dependencies + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libssl-dev pkg-config + - name: Install ARM64 cross linker if: matrix.target == 'aarch64-unknown-linux-gnu' run: sudo apt-get update && sudo apt-get install -y --no-install-recommends gcc-aarch64-linux-gnu diff --git a/.github/workflows/Build.Windows.Job.yml b/.github/workflows/Build.Windows.Job.yml index 966c4f1aec..a5423766c5 100644 --- a/.github/workflows/Build.Windows.Job.yml +++ b/.github/workflows/Build.Windows.Job.yml @@ -26,6 +26,15 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Expose OpenSSL for Windows TLS fixtures + shell: pwsh + run: | + $opensslDirectory = Join-Path $env:ProgramFiles 'Git\usr\bin' + if (-not (Test-Path (Join-Path $opensslDirectory 'openssl.exe'))) { + throw 'Windows TLS fixtures require OpenSSL from Git for Windows' + } + $opensslDirectory | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + - name: Test loopback command quoting shell: pwsh run: ..\tests\scripts\test_loopback_anchor_command.ps1 diff --git a/docs/backends/w365agents/w365agents-backend.md b/docs/backends/w365agents/w365agents-backend.md index 152fb7232f..ab97f7aa57 100644 --- a/docs/backends/w365agents/w365agents-backend.md +++ b/docs/backends/w365agents/w365agents-backend.md @@ -48,7 +48,14 @@ environment again. MXC does not acquire, cache or renew tokens. The caller renews credentials before a later cleanup call. Checkout uses the design's 45-second client budget; release uses its separate 30-second budget. These are transport budgets, not promises about service reset or resource lifetime. -Production uses validated rustls TLS, no redirects and no ambient HTTP proxy. +Production uses `native-tls` with the platform's certificate trust store: +SChannel on Windows, Secure Transport on macOS and OpenSSL on Linux. +Certificate and hostname validation remain enabled, with no redirects or +ambient HTTP proxy. Linux builds require OpenSSL development headers and +`pkg-config` (`libssl-dev pkg-config` on Debian/Ubuntu, `openssl-devel pkgconf-pkg-config` +on Fedora); deployed Linux hosts require the matching OpenSSL shared libraries +and CA certificates. ARM64 cross-builds need target-architecture OpenSSL headers +and libraries, not just the host development package. ## Service contract and identity @@ -166,6 +173,7 @@ fixture executor; Windows CI explicitly requires and runs it. From `src\` on Windows: ```powershell +# Requires openssl on PATH (included with Git for Windows). cargo build --locked -p wxc --features test-support $env:MXC_W365_TEST_EXECUTOR = (Resolve-Path target\debug\wxc-exec.exe).Path cargo test --locked -p mxc-sdk --lib w365 -- --include-ignored @@ -180,3 +188,12 @@ Only test builds honor `MXC_W365_TEST_CA` and `MXC_W365_TEST_PORT`; they add a trusted fixture certificate, never disable certificate or hostname validation. Shipping builds ignore these variables. Fixtures use only loopback services, synthetic credentials and generated certificates; no live tenant is needed. +`rcgen` generates fixture certificates. The loopback server uses SChannel on +Windows and `native-tls` elsewhere; the production client uses `native-tls` +on all platforms. Windows fixtures require +the OpenSSL command-line tool to package generated EC keys as PKCS#12 for +SChannel through the existing `schannel` bindings. The imported Windows test key +is explicitly deleted after the fixture server stops. CI uses the OpenSSL copy +included with Git for Windows. These certificate +and key files exist only in a temporary directory, removed after identity +loading; no certificates or private keys are checked in. diff --git a/docs/development/build-and-test/ci-validation-infrastructure.md b/docs/development/build-and-test/ci-validation-infrastructure.md index 22639123b5..ae8b40bdd7 100644 --- a/docs/development/build-and-test/ci-validation-infrastructure.md +++ b/docs/development/build-and-test/ci-validation-infrastructure.md @@ -31,8 +31,11 @@ on Linux x64 and both fixtures on Windows x64, using an isolated executor target directory outside shipping artifacts. `Mac.Build.Job.yml` checks engine fixture discovery before its existing package suite runs it on macOS arm64. Azure's cross-compiled Windows/Linux arm64 jobs remain build-only. The Linux arm64 job -also checks that enabling `hyperlight` retains W365's rustls dependency without -enabling Hyperlight's x86_64-only native TLS dependency. +also checks that enabling `hyperlight` retains W365's native TLS dependency +without enabling Hyperlight's x86_64-only gzip feature. Linux build jobs install +OpenSSL development headers and `pkg-config`; Azure's x64-to-arm64 cross-build +additionally installs `libssl-dev:arm64` from Ubuntu's ARM64 repository and points +the target-specific OpenSSL build variables at those headers and libraries. - Validation tests **never build from source**. They download the artifacts produced by `Build.Windows.Job.yml` / `Build.Linux.Job.yml` / diff --git a/src/Cargo.lock b/src/Cargo.lock index 4124dbb010..36a47d7724 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1668,11 +1668,12 @@ dependencies = [ "hyperlight-unikraft", "libc", "libloading", + "native-tls", "nix 0.29.0", "quick-xml", "rcgen", - "rustls", "same-file", + "schannel", "schemars", "semver", "serde", @@ -2289,21 +2290,6 @@ dependencies = [ "rustix", ] -[[package]] -name = "rustls" -version = "0.23.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -2313,17 +2299,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-webpki" -version = "0.103.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.22" @@ -2644,12 +2619,6 @@ dependencies = [ "syn", ] -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "2.0.118" @@ -2908,12 +2877,10 @@ dependencies = [ "log", "native-tls", "percent-encoding", - "rustls", "rustls-pki-types", "ureq-proto", "utf8-zero", "webpki-root-certs", - "webpki-roots", ] [[package]] @@ -3128,15 +3095,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webpki-roots" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "widestring" version = "1.2.1" diff --git a/src/mxc-sdk/Cargo.toml b/src/mxc-sdk/Cargo.toml index 8e27a12afc..80d0ebd44a 100644 --- a/src/mxc-sdk/Cargo.toml +++ b/src/mxc-sdk/Cargo.toml @@ -67,7 +67,7 @@ tokio.workspace = true tar = { version = "0.4", optional = true } unicode-general-category.workspace = true url.workspace = true -ureq = { version = "3", default-features = false, features = ["rustls"] } +ureq = { version = "3", default-features = false, features = ["native-tls"] } uuid.workspace = true widestring.workspace = true @@ -81,7 +81,7 @@ zip = { version = "9", default-features = false, features = ["deflate"], optiona [dev-dependencies] rcgen = "0.14" -rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +native-tls = "0.2" filetime = "0.2" libloading = "0.8" quick-xml.workspace = true @@ -91,6 +91,7 @@ tokio = { workspace = true, features = ["test-util"] } zip = { version = "9", default-features = false, features = ["deflate"] } [target.'cfg(target_os = "windows")'.dev-dependencies] +schannel = "0.1" serial_test.workspace = true [target.'cfg(target_arch = "x86_64")'.dependencies] diff --git a/src/mxc-sdk/src/backends/w365agents/service.rs b/src/mxc-sdk/src/backends/w365agents/service.rs index 164a7c4182..fdd3f2e5de 100644 --- a/src/mxc-sdk/src/backends/w365agents/service.rs +++ b/src/mxc-sdk/src/backends/w365agents/service.rs @@ -6,7 +6,7 @@ use crate::mxc_common::mxc_error::MxcError; use crate::mxc_common::state_aware_operation::W365AgentsProvisionConfig; use serde_json::{json, Value}; use std::time::Duration; -use ureq::tls::{TlsConfig, TlsProvider}; +use ureq::tls::{RootCerts, TlsConfig, TlsProvider}; const CHECKOUT_TIMEOUT: Duration = Duration::from_secs(45); const RELEASE_TIMEOUT: Duration = Duration::from_secs(30); @@ -107,7 +107,9 @@ impl Client { } fn agent(&self, timeout: Duration) -> Result<(ureq::Agent, String), MxcError> { - let tls = TlsConfig::builder().provider(TlsProvider::Rustls); + let tls = TlsConfig::builder() + .provider(TlsProvider::NativeTls) + .root_certs(RootCerts::PlatformVerifier); #[cfg(any(test, feature = "test-support"))] let (tls, port) = test_tls(tls)?; #[cfg(any(test, feature = "test-support"))] @@ -360,7 +362,7 @@ fn failure( fn test_tls( builder: ureq::tls::TlsConfigBuilder, ) -> Result<(ureq::tls::TlsConfigBuilder, u16), MxcError> { - use ureq::tls::{Certificate, RootCerts}; + use ureq::tls::Certificate; let ca = std::env::var("MXC_W365_TEST_CA").ok(); let port = std::env::var("MXC_W365_TEST_PORT").ok(); match (ca, port) { diff --git a/src/mxc-sdk/src/backends/w365agents/tests.rs b/src/mxc-sdk/src/backends/w365agents/tests.rs index 7250b583b8..f6d14a97d7 100644 --- a/src/mxc-sdk/src/backends/w365agents/tests.rs +++ b/src/mxc-sdk/src/backends/w365agents/tests.rs @@ -21,6 +21,80 @@ const A: &str = "22222222-2222-2222-2222-222222222222"; const B: &str = "33333333-3333-3333-3333-333333333333"; static ENV: Mutex<()> = Mutex::new(()); +#[cfg(target_os = "windows")] +struct FixtureKey(Option); + +#[cfg(target_os = "windows")] +impl Drop for FixtureKey { + fn drop(&mut self) { + use schannel::RawPointer; + use windows::Win32::Security::Cryptography::{NCryptDeleteKey, NCRYPT_KEY_HANDLE}; + + let key = self.0.take().unwrap(); + // Delete only this fixture's imported key; success also frees its handle. + match unsafe { NCryptDeleteKey(NCRYPT_KEY_HANDLE(key.as_ptr() as usize), 0) } { + Ok(()) => std::mem::forget(key), + Err(error) => { + eprintln!("cannot delete W365 fixture key: {error}"); + assert!(std::thread::panicking(), "fixture key cleanup failed"); + } + } + } +} + +#[cfg(target_os = "windows")] +fn fixture_tls( + cert: &rcgen::CertifiedKey, +) -> (schannel::schannel_cred::SchannelCred, FixtureKey) { + let directory = tempfile::tempdir().unwrap(); + let certificate = directory.path().join("certificate.pem"); + let key = directory.path().join("key.pem"); + let archive = directory.path().join("identity.p12"); + std::fs::write(&certificate, cert.cert.pem()).unwrap(); + std::fs::write(&key, cert.signing_key.serialize_pem()).unwrap(); + // SChannel's PEM loader only supports RSA; PKCS#12 also supports rcgen's EC keys. + let output = std::process::Command::new("openssl") + .args(["pkcs12", "-export", "-out"]) + .arg(&archive) + .arg("-inkey") + .arg(&key) + .arg("-in") + .arg(&certificate) + .args(["-passout", "pass:"]) + .output() + .expect("Windows W365 TLS fixtures require openssl on PATH"); + assert!( + output.status.success(), + "fixture PKCS#12 conversion failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + // SChannel needs a persisted server key; retain its exact handle for cleanup. + let store = schannel::cert_store::PfxImportOptions::new() + .password("") + .import(&std::fs::read(archive).unwrap()) + .unwrap(); + let certificate = store.certs().next().expect("fixture certificate required"); + let key = match certificate.private_key().silent(true).acquire().unwrap() { + schannel::cert_context::PrivateKey::NcryptKey(key) => FixtureKey(Some(key)), + _ => panic!("fixture EC key must use CNG"), + }; + let tls = schannel::schannel_cred::SchannelCred::builder() + .cert(certificate) + .acquire(schannel::schannel_cred::Direction::Inbound) + .unwrap(); + (tls, key) +} + +#[cfg(not(target_os = "windows"))] +fn fixture_tls(cert: &rcgen::CertifiedKey) -> native_tls::TlsAcceptor { + let identity = native_tls::Identity::from_pkcs8( + cert.cert.pem().as_bytes(), + cert.signing_key.serialize_pem().as_bytes(), + ) + .unwrap(); + native_tls::TlsAcceptor::new(identity).unwrap() +} + struct Environment(Vec<(&'static str, Option)>); impl Environment { fn new() -> Self { @@ -58,19 +132,21 @@ struct Fixture { replies: Arc>>>, stop: Arc, worker: Option>, + #[cfg(target_os = "windows")] + _key: FixtureKey, } impl Fixture { fn new() -> Self { - let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap(); + Self::with_hostname("localhost") + } + fn with_hostname(hostname: &str) -> Self { + let cert = rcgen::generate_simple_self_signed(vec![hostname.into()]).unwrap(); let mut ca = tempfile::NamedTempFile::new().unwrap(); ca.write_all(cert.cert.pem().as_bytes()).unwrap(); - let key = - rustls::pki_types::PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der()).into(); - let tls = rustls::ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(vec![cert.cert.der().clone()], key) - .unwrap(); - let tls = Arc::new(tls); + #[cfg(target_os = "windows")] + let (tls, key) = fixture_tls(&cert); + #[cfg(not(target_os = "windows"))] + let tls = fixture_tls(&cert); let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); let port = listener.local_addr().unwrap().port(); listener.set_nonblocking(true).unwrap(); @@ -97,8 +173,15 @@ impl Fixture { socket .set_write_timeout(Some(Duration::from_secs(5))) .unwrap(); - let session = rustls::ServerConnection::new(tls.clone()).unwrap(); - let mut stream = rustls::StreamOwned::new(session, socket); + #[cfg(target_os = "windows")] + let handshake = schannel::tls_stream::Builder::new().accept(tls.clone(), socket); + #[cfg(not(target_os = "windows"))] + let handshake = tls.accept(socket); + let mut stream = match handshake { + Ok(stream) => stream, + // Trust and hostname rejection probes deliberately fail the handshake. + Err(_) => continue, + }; let mut data = Vec::new(); let mut buf = [0; 1024]; while !data.windows(4).any(|v| v == b"\r\n\r\n") { @@ -126,6 +209,8 @@ impl Fixture { replies, stop, worker: Some(worker), + #[cfg(target_os = "windows")] + _key: key, } } fn configure(&self, env: &Environment) { @@ -289,6 +374,14 @@ fn lifecycle_contract_and_recovery_fixture() { before, "invalid TLS cannot transmit credentials or checkout" ); + let wrong_hostname = Fixture::with_hostname("not-localhost.invalid"); + wrong_hostname.configure(&env); + assert!(invoke(&provision(), true).is_err()); + assert_eq!( + wrong_hostname.count(), + 0, + "a trusted certificate for another hostname must not receive credentials or checkout" + ); fixture.configure(&env); assert!(crate::mxc_engine::available_backends() .iter() From 9f4d1ec0fa102f31b86d8bf185db83d2eb7da995 Mon Sep 17 00:00:00 2001 From: tbyfield <68976176+tbyfield@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:18:02 -0800 Subject: [PATCH 5/5] Fix native TLS fixture identity import and Windows tool selection Package generated EC fixture identities as PKCS12 for macOS import compatibility. Select the Windows OpenSSL executable directly instead of prepending Git Unix tools to PATH, preserving Windows tar for packaging validation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .azure-pipelines/templates/Rust.Build.Job.yml | 6 +-- .github/workflows/Build.Windows.Job.yml | 6 +-- .../backends/w365agents/w365agents-backend.md | 17 ++++--- .../ci-validation-infrastructure.md | 4 ++ src/mxc-sdk/src/backends/w365agents/tests.rs | 44 ++++++++++++++----- 5 files changed, 55 insertions(+), 22 deletions(-) diff --git a/.azure-pipelines/templates/Rust.Build.Job.yml b/.azure-pipelines/templates/Rust.Build.Job.yml index 4826a00e7b..daf2c6ea17 100644 --- a/.azure-pipelines/templates/Rust.Build.Job.yml +++ b/.azure-pipelines/templates/Rust.Build.Job.yml @@ -102,11 +102,11 @@ jobs: - checkout: self - pwsh: | - $opensslDirectory = Join-Path $env:ProgramFiles 'Git\usr\bin' - if (-not (Test-Path (Join-Path $opensslDirectory 'openssl.exe'))) { + $openssl = Join-Path $env:ProgramFiles 'Git\usr\bin\openssl.exe' + if (-not (Test-Path $openssl)) { throw 'Windows TLS fixtures require OpenSSL from Git for Windows' } - Write-Host "##vso[task.prependpath]$opensslDirectory" + Write-Host "##vso[task.setvariable variable=MXC_W365_TEST_OPENSSL]$openssl" displayName: Expose OpenSSL for Windows TLS fixtures condition: and(eq('${{ item.os }}', 'windows'), eq('${{ item.arch }}', 'x64')) diff --git a/.github/workflows/Build.Windows.Job.yml b/.github/workflows/Build.Windows.Job.yml index a5423766c5..f54bbd1be9 100644 --- a/.github/workflows/Build.Windows.Job.yml +++ b/.github/workflows/Build.Windows.Job.yml @@ -29,11 +29,11 @@ jobs: - name: Expose OpenSSL for Windows TLS fixtures shell: pwsh run: | - $opensslDirectory = Join-Path $env:ProgramFiles 'Git\usr\bin' - if (-not (Test-Path (Join-Path $opensslDirectory 'openssl.exe'))) { + $openssl = Join-Path $env:ProgramFiles 'Git\usr\bin\openssl.exe' + if (-not (Test-Path $openssl)) { throw 'Windows TLS fixtures require OpenSSL from Git for Windows' } - $opensslDirectory | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + "MXC_W365_TEST_OPENSSL=$openssl" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: Test loopback command quoting shell: pwsh diff --git a/docs/backends/w365agents/w365agents-backend.md b/docs/backends/w365agents/w365agents-backend.md index ab97f7aa57..ef355d030e 100644 --- a/docs/backends/w365agents/w365agents-backend.md +++ b/docs/backends/w365agents/w365agents-backend.md @@ -173,7 +173,7 @@ fixture executor; Windows CI explicitly requires and runs it. From `src\` on Windows: ```powershell -# Requires openssl on PATH (included with Git for Windows). +# Requires openssl on PATH, or MXC_W365_TEST_OPENSSL set to its executable path. cargo build --locked -p wxc --features test-support $env:MXC_W365_TEST_EXECUTOR = (Resolve-Path target\debug\wxc-exec.exe).Path cargo test --locked -p mxc-sdk --lib w365 -- --include-ignored @@ -190,10 +190,15 @@ Shipping builds ignore these variables. Fixtures use only loopback services, synthetic credentials and generated certificates; no live tenant is needed. `rcgen` generates fixture certificates. The loopback server uses SChannel on Windows and `native-tls` elsewhere; the production client uses `native-tls` -on all platforms. Windows fixtures require -the OpenSSL command-line tool to package generated EC keys as PKCS#12 for -SChannel through the existing `schannel` bindings. The imported Windows test key -is explicitly deleted after the fixture server stops. CI uses the OpenSSL copy -included with Git for Windows. These certificate +on all platforms. Windows and macOS fixtures require +the OpenSSL command-line tool to package generated EC keys as PKCS#12; their +native PEM identity importers do not support this key format. The archive uses +SHA-1/3DES for macOS import compatibility only, not for TLS connections or +production cryptography. The imported Windows test key is explicitly deleted +after the fixture server stops; `native-tls` owns a temporary macOS keychain +removed at process exit. Windows CI selects the OpenSSL executable +included with Git for Windows using `MXC_W365_TEST_OPENSSL`, without changing +`PATH` or replacing other Windows tools. Otherwise fixtures find `openssl` on +`PATH`. These certificate and key files exist only in a temporary directory, removed after identity loading; no certificates or private keys are checked in. diff --git a/docs/development/build-and-test/ci-validation-infrastructure.md b/docs/development/build-and-test/ci-validation-infrastructure.md index ae8b40bdd7..6bce2120f0 100644 --- a/docs/development/build-and-test/ci-validation-infrastructure.md +++ b/docs/development/build-and-test/ci-validation-infrastructure.md @@ -36,6 +36,10 @@ without enabling Hyperlight's x86_64-only gzip feature. Linux build jobs install OpenSSL development headers and `pkg-config`; Azure's x64-to-arm64 cross-build additionally installs `libssl-dev:arm64` from Ubuntu's ARM64 repository and points the target-specific OpenSSL build variables at those headers and libraries. +Windows and macOS TLS fixtures use OpenSSL to package their generated test +identities as PKCS#12. Windows jobs set `MXC_W365_TEST_OPENSSL` to Git for +Windows' executable without adding its Unix tools to `PATH`, so native Windows +archive tools remain selected. - Validation tests **never build from source**. They download the artifacts produced by `Build.Windows.Job.yml` / `Build.Linux.Job.yml` / diff --git a/src/mxc-sdk/src/backends/w365agents/tests.rs b/src/mxc-sdk/src/backends/w365agents/tests.rs index f6d14a97d7..0664b88100 100644 --- a/src/mxc-sdk/src/backends/w365agents/tests.rs +++ b/src/mxc-sdk/src/backends/w365agents/tests.rs @@ -21,6 +21,9 @@ const A: &str = "22222222-2222-2222-2222-222222222222"; const B: &str = "33333333-3333-3333-3333-333333333333"; static ENV: Mutex<()> = Mutex::new(()); +#[cfg(any(target_os = "windows", target_os = "macos"))] +const FIXTURE_IDENTITY_PASSWORD: &str = "fixture-only"; + #[cfg(target_os = "windows")] struct FixtureKey(Option); @@ -42,36 +45,52 @@ impl Drop for FixtureKey { } } -#[cfg(target_os = "windows")] -fn fixture_tls( - cert: &rcgen::CertifiedKey, -) -> (schannel::schannel_cred::SchannelCred, FixtureKey) { +#[cfg(any(target_os = "windows", target_os = "macos"))] +fn fixture_pkcs12(cert: &rcgen::CertifiedKey) -> Vec { let directory = tempfile::tempdir().unwrap(); let certificate = directory.path().join("certificate.pem"); let key = directory.path().join("key.pem"); let archive = directory.path().join("identity.p12"); std::fs::write(&certificate, cert.cert.pem()).unwrap(); std::fs::write(&key, cert.signing_key.serialize_pem()).unwrap(); - // SChannel's PEM loader only supports RSA; PKCS#12 also supports rcgen's EC keys. - let output = std::process::Command::new("openssl") + let openssl = std::env::var_os("MXC_W365_TEST_OPENSSL").unwrap_or_else(|| "openssl".into()); + // macOS cannot import rcgen's EC PKCS#8 PEM. These PKCS#12 algorithms work + // with its identity importer; they do not configure TLS or production crypto. + let output = std::process::Command::new(openssl) .args(["pkcs12", "-export", "-out"]) .arg(&archive) .arg("-inkey") .arg(&key) .arg("-in") .arg(&certificate) - .args(["-passout", "pass:"]) + .args([ + "-passout", + &format!("pass:{FIXTURE_IDENTITY_PASSWORD}"), + "-keypbe", + "PBE-SHA1-3DES", + "-certpbe", + "PBE-SHA1-3DES", + "-macalg", + "sha1", + ]) .output() - .expect("Windows W365 TLS fixtures require openssl on PATH"); + .expect("W365 TLS fixtures require openssl on PATH or MXC_W365_TEST_OPENSSL"); assert!( output.status.success(), "fixture PKCS#12 conversion failed: {}", String::from_utf8_lossy(&output.stderr) ); + std::fs::read(archive).unwrap() +} + +#[cfg(target_os = "windows")] +fn fixture_tls( + cert: &rcgen::CertifiedKey, +) -> (schannel::schannel_cred::SchannelCred, FixtureKey) { // SChannel needs a persisted server key; retain its exact handle for cleanup. let store = schannel::cert_store::PfxImportOptions::new() - .password("") - .import(&std::fs::read(archive).unwrap()) + .password(FIXTURE_IDENTITY_PASSWORD) + .import(&fixture_pkcs12(cert)) .unwrap(); let certificate = store.certs().next().expect("fixture certificate required"); let key = match certificate.private_key().silent(true).acquire().unwrap() { @@ -87,6 +106,11 @@ fn fixture_tls( #[cfg(not(target_os = "windows"))] fn fixture_tls(cert: &rcgen::CertifiedKey) -> native_tls::TlsAcceptor { + #[cfg(target_os = "macos")] + let identity = + native_tls::Identity::from_pkcs12(&fixture_pkcs12(cert), FIXTURE_IDENTITY_PASSWORD) + .unwrap(); + #[cfg(not(target_os = "macos"))] let identity = native_tls::Identity::from_pkcs8( cert.cert.pem().as_bytes(), cert.signing_key.serialize_pem().as_bytes(),