diff --git a/.azure-pipelines/templates/Mac.Build.Job.yml b/.azure-pipelines/templates/Mac.Build.Job.yml index 8df568e71..cbeffcc22 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 e0e29a721..daf2c6ea1 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: | + $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.setvariable variable=MXC_W365_TEST_OPENSSL]$openssl" + 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) }}: @@ -128,6 +157,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 "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 + 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: | @@ -155,6 +196,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/.github/workflows/Build.Linux.Job.yml b/.github/workflows/Build.Linux.Job.yml index 472c80643..f0a3e3ba3 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 @@ -172,6 +175,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 e14f41ecd..36b919e0a 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 91fe8a4f9..f54bbd1be 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: | + $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' + } + "MXC_W365_TEST_OPENSSL=$openssl" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Test loopback command quoting shell: pwsh run: ..\tests\scripts\test_loopback_anchor_command.ps1 @@ -158,6 +167,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 000000000..ef355d030 --- /dev/null +++ b/docs/backends/w365agents/w365agents-backend.md @@ -0,0 +1,204 @@ +# 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 `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 + +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`. 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. + +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 +# 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 +``` + +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, +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 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/containment-policy/dev/policy.md b/docs/containment-policy/dev/policy.md index 64a78fc36..8f1d56b38 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 98cf23149..da8aca020 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 a52e32201..857a48919 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 333779a74..6bce2120f 100644 --- a/docs/development/build-and-test/ci-validation-infrastructure.md +++ b/docs/development/build-and-test/ci-validation-infrastructure.md @@ -6,13 +6,41 @@ 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). ## At a glance +W365 checkout/release uses offline trusted-TLS functional fixtures in the +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 +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. + +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. The Linux arm64 job +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. +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` / `Build.MacOS.Job.yml`, and for ProcessContainer, IsolationSession, and WSLC the diff --git a/docs/schema.md b/docs/schema.md index 63bf568f4..ee8308dfb 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 e92c8ddcb..36a47d772 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" @@ -1594,9 +1668,12 @@ dependencies = [ "hyperlight-unikraft", "libc", "libloading", + "native-tls", "nix 0.29.0", "quick-xml", + "rcgen", "same-file", + "schannel", "schemars", "semver", "serde", @@ -1725,6 +1802,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 +1888,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 +1991,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 +2049,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 +2130,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 +2193,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 +2258,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" @@ -2530,6 +2694,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 +2859,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" @@ -3033,6 +3233,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 +3516,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 +3544,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 908457d75..c07336742 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 e22ab3c5f..80d0ebd44 100644 --- a/src/mxc-sdk/Cargo.toml +++ b/src/mxc-sdk/Cargo.toml @@ -67,6 +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 = ["native-tls"] } uuid.workspace = true widestring.workspace = true @@ -79,6 +80,8 @@ uuid.workspace = true zip = { version = "9", default-features = false, features = ["deflate"], optional = true } [dev-dependencies] +rcgen = "0.14" +native-tls = "0.2" filetime = "0.2" libloading = "0.8" quick-xml.workspace = true @@ -88,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/mod.rs b/src/mxc-sdk/src/backends/mod.rs index 6b2c14341..166332e1c 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 000000000..0d556e276 --- /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 000000000..fdd3f2e5d --- /dev/null +++ b/src/mxc-sdk/src/backends/w365agents/service.rs @@ -0,0 +1,387 @@ +// 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::{RootCerts, 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::NativeTls) + .root_certs(RootCerts::PlatformVerifier); + #[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); + let mut response = response; + let value = 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, + ) + }) + .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 + .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, + "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; + 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 000000000..0664b8810 --- /dev/null +++ b/src/mxc-sdk/src/backends/w365agents/tests.rs @@ -0,0 +1,791 @@ +// 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(()); + +#[cfg(any(target_os = "windows", target_os = "macos"))] +const FIXTURE_IDENTITY_PASSWORD: &str = "fixture-only"; + +#[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(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(); + 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", + &format!("pass:{FIXTURE_IDENTITY_PASSWORD}"), + "-keypbe", + "PBE-SHA1-3DES", + "-certpbe", + "PBE-SHA1-3DES", + "-macalg", + "sha1", + ]) + .output() + .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(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() { + 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 { + #[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(), + ) + .unwrap(); + native_tls::TlsAcceptor::new(identity).unwrap() +} + +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>, + #[cfg(target_os = "windows")] + _key: FixtureKey, +} +impl Fixture { + fn new() -> Self { + 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(); + #[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(); + 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(); + #[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") { + 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), + #[cfg(target_os = "windows")] + _key: key, + } + } + 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.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: {length}\r\nx-ms-request-id: fixture-request\r\nConnection: close\r\n\r\n{body}" + ))); + } + 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" + ); + 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() + .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, 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"); + 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}?"))); + + 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")); +} diff --git a/src/mxc-sdk/src/core/mxc_common/cmdline.rs b/src/mxc-sdk/src/core/mxc_common/cmdline.rs index 507a342e4..cd86cb94c 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 6b6f556da..805a75706 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 157ac49d3..0462402a8 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 0f5227ead..2586001b3 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 90ab00c7e..66ad9b01c 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 310f515e9..e67ff61b9 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 99ab0c149..38ecfd426 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 2868e720d..5714f67e5 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 c6c9b7688..5f5e92ebe 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 646b18f58..31e50590e 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 c1b55457f..72b395b08 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 03081f93c..c73ce894a 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 26dac762e..d320078e7 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 2753cd2bd..9a2cb5d5e 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)