diff --git a/.azure-pipelines/Update.Feed.Dependencies.yml b/.azure-pipelines/Update.Feed.Dependencies.yml index d798c0ef1..7fb851515 100644 --- a/.azure-pipelines/Update.Feed.Dependencies.yml +++ b/.azure-pipelines/Update.Feed.Dependencies.yml @@ -1,5 +1,5 @@ -# Seeds the public shine-oss MxcDependencies feed with every crates.io crate in -# src/Cargo.lock. +# Seeds the public shine-oss MxcDependencies feeds with the dependencies pinned +# by src/Cargo.lock and the IsolationSession SDK package constants. # # This pipeline cannot be run with `/azp run`; run it manually from shine-oss # (Run pipeline at https://dev.azure.com/shine-oss/mxc/_build?definitionId=33). @@ -21,17 +21,26 @@ jobs: - checkout: self persistCredentials: true - # Overlay only the lockfile from the PR head onto the trusted checkout. cargo is never - # run and only Cargo.lock (data) is read, so no PR code executes. Review the PR's - # Cargo.lock first. + # Overlay only dependency-pin data from the PR head onto the trusted + # checkout. Neither Cargo nor the Rust source is executed: the script + # parses Cargo.lock and three string constants from the build helper. + # Review both files before running this manually triggered pipeline. - script: | set -euo pipefail git fetch origin "pull/${{ parameters.prNumber }}/head" - git checkout FETCH_HEAD -- src/Cargo.lock - echo "Overlaid Cargo.lock from PR #${{ parameters.prNumber }}" - displayName: Overlay Cargo.lock from PR + git checkout FETCH_HEAD -- \ + src/Cargo.lock \ + src/mxc-sdk/build/build_mxc_build_common.rs + echo "Overlaid dependency pins from PR #${{ parameters.prNumber }}" + displayName: Overlay dependency pins from PR - script: python3 .azure-pipelines/scripts/seed_feed.py displayName: Seed the MxcDependencies feed from Cargo.lock env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) + + - task: NuGetAuthenticate@1 + displayName: Authenticate to MxcDependencies + + - script: python3 .azure-pipelines/scripts/seed_feed.py --nuget-only + displayName: Seed the IsolationSession SDK package into MxcDependencies diff --git a/.azure-pipelines/scripts/seed_feed.py b/.azure-pipelines/scripts/seed_feed.py index 117f48ec1..0b4658f65 100644 --- a/.azure-pipelines/scripts/seed_feed.py +++ b/.azure-pipelines/scripts/seed_feed.py @@ -1,24 +1,35 @@ #!/usr/bin/env python3 -"""Seed the public MxcDependencies cargo feed from src/Cargo.lock. +"""Seed the public MxcDependencies feeds from pinned repository dependencies. -The feed can be read without credentials, so cargo does not send a token when it reads. -As a result, cargo cannot make Azure Artifacts pull a missing crate from its crates.io -upstream. This script instead sends an authenticated request for each locked crate, which -makes the feed fetch and cache it. Later credential-free reads then resolve every dependency. +The Cargo feed can be read without credentials, so cargo does not send a token when it +reads. As a result, cargo cannot make Azure Artifacts pull a missing crate from its +crates.io upstream. This script instead sends an authenticated request for each locked +crate, which makes the feed fetch and cache it. Later credential-free reads then resolve +every dependency. -The script only reads Cargo.lock and never runs cargo or build scripts, so it is safe to run -against a fork's lockfile. +The NuGet mode reads the pinned IsolationSession package identity and hash, downloads that +public package, verifies it, and publishes it through the authenticated NuGet client. """ +import hashlib import json import os +import re +import subprocess import sys +import tempfile import tomllib import urllib.request +from pathlib import Path from concurrent.futures import ThreadPoolExecutor, as_completed from typing import NamedTuple FEED_INDEX_URL = "https://pkgs.dev.azure.com/shine-oss/mxc/_packaging/MxcDependencies/Cargo/index" CARGO_LOCK = "src/Cargo.lock" +ISOLATION_SESSION_PIN = "src/mxc-sdk/build/build_mxc_build_common.rs" +NUGET_FEED_URL = ( + "https://pkgs.dev.azure.com/shine-oss/mxc/" + "_packaging/MxcDependencies/nuget/v3/index.json" +) MAX_WORKERS = 16 REQUEST_TIMEOUT = 30 # seconds; fail a stuck request instead of hanging the job @@ -82,7 +93,70 @@ def _read_locked_crates() -> list[Crate]: ] +def _read_isolation_session_pin() -> tuple[str, str, str]: + content = Path(ISOLATION_SESSION_PIN).read_text(encoding="utf-8") + + def constant(name: str) -> str: + match = re.search( + rf'pub const {name}: &str =\s*"([^"]+)";', + content, + ) + if not match: + raise RuntimeError(f"Could not read {name} from {ISOLATION_SESSION_PIN}") + return match.group(1) + + return ( + constant("PACKAGE_ID"), + constant("PACKAGE_VERSION"), + constant("PACKAGE_SHA256"), + ) + + +def _seed_nuget_package() -> None: + package_id, version, expected_hash = _read_isolation_session_pin() + lower_id = package_id.lower() + file_name = f"{lower_id}.{version}.nupkg" + source_url = f"https://api.nuget.org/v3-flatcontainer/{lower_id}/{version}/{file_name}" + + with tempfile.TemporaryDirectory() as directory: + package_path = Path(directory) / file_name + print(f"Downloading {package_id} {version} from NuGet.org for feed seeding") + with urllib.request.urlopen(source_url, timeout=REQUEST_TIMEOUT) as response: + package_path.write_bytes(response.read()) + + actual_hash = hashlib.sha256(package_path.read_bytes()).hexdigest() + if actual_hash.lower() != expected_hash.lower(): + raise RuntimeError( + f"{package_id} {version} hash mismatch: " + f"expected {expected_hash}, got {actual_hash}" + ) + + subprocess.run( + [ + "dotnet", + "nuget", + "push", + str(package_path), + "--source", + NUGET_FEED_URL, + "--api-key", + "AzureDevOps", + "--skip-duplicate", + ], + check=True, + ) + print(f"Seeded {package_id} {version} into MxcDependencies") + + def main() -> int: + if sys.argv[1:] == ["--nuget-only"]: + try: + _seed_nuget_package() + except (OSError, RuntimeError, subprocess.CalledProcessError) as error: + print(f"Failed to seed IsolationSession SDK package: {error}") + return 1 + return 0 + token = os.environ.get("SYSTEM_ACCESSTOKEN") if not token: print("SYSTEM_ACCESSTOKEN is not set; cannot authenticate to the feed") diff --git a/.github/workflows/Build.Windows.Job.yml b/.github/workflows/Build.Windows.Job.yml index e205cdcb2..3f2e04fe2 100644 --- a/.github/workflows/Build.Windows.Job.yml +++ b/.github/workflows/Build.Windows.Job.yml @@ -2,6 +2,12 @@ name: Windows Build on: workflow_call: + inputs: + run_isolation_session_lifted: + description: Run x64 Cargo validation against the published lifted IsolationSession SDK package. + required: false + default: false + type: boolean jobs: build: @@ -120,3 +126,147 @@ jobs: src/target/${{ matrix.target }}/release/wxc-test-driver.exe if-no-files-found: error retention-days: 1 + + isolation-session-lifted: + name: x64 isolation_session_lifted + if: inputs.run_isolation_session_lifted + runs-on: windows-2025 + defaults: + run: + working-directory: src + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Surface toolchain file at repo root + shell: bash + working-directory: ${{ github.workspace }} + run: cp src/rust-toolchain.toml rust-toolchain.toml + + - uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2.0.0 + with: + target: x86_64-pc-windows-msvc + override: false + rustflags: '' + + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Point cargo at the MxcDependencies feed + uses: ./.github/actions/setup-cargo-feed + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: src -> target + key: x86_64-pc-windows-msvc-isolation-session-lifted-v1 + cache-on-failure: false + + - name: Test lifted SDK and compile FFI tests + run: | + cargo test --locked --release --target x86_64-pc-windows-msvc ` + -p mxc-sdk --features isolation_session_lifted + cargo test --locked --release --target x86_64-pc-windows-msvc ` + -p mxc_ffi --features isolation_session_lifted --no-run + + - name: Build lifted CLI + run: cargo build --locked --release --target x86_64-pc-windows-msvc + -p wxc --no-default-features --features isolation_session_lifted + + - name: Build and test lifted .NET SDK + working-directory: sdk/dotnet + shell: pwsh + run: | + dotnet build Microsoft.Mxc.Sdk.slnx -p:MxcIsolationSessionLifted=true + if ($LASTEXITCODE -ne 0) { throw "dotnet build exited $LASTEXITCODE" } + + $testExe = 'Microsoft.Mxc.Sdk.Tests\bin\Debug\net8.0\Microsoft.Mxc.Sdk.Tests.exe' + if (-not (Test-Path -LiteralPath $testExe -PathType Leaf)) { + throw "The xUnit v3 test executable was not built: $testExe" + } + + $log = Join-Path $env:RUNNER_TEMP 'dotnet-test-isolation-session-lifted.log' + & $testExe -automated sync -noColor 2>&1 | + Tee-Object -FilePath $log + $code = $LASTEXITCODE + if ($code -ne 0) { throw "The lifted managed tests exited $code" } + $summary = Get-Content -LiteralPath $log | + Where-Object { $_ -match '"\$type":"test-assembly-finished"' } | + Select-Object -Last 1 | + ConvertFrom-Json + if ($null -eq $summary -or $summary.TestsTotal -eq 0) { + throw 'The lifted run passed no tests, so it did not establish managed coverage.' + } + if ($summary.TestsFailed -ne 0 -or $summary.TestsNotRun -ne 0) { + throw "The lifted managed run was incomplete: $($summary | ConvertTo-Json -Compress)" + } + + $pinLog = Join-Path $env:RUNNER_TEMP 'dotnet-test-isolation-session-lifted-pin.log' + & $testExe -automated sync -noColor ` + -method '*IsolationSessionBuildSwitch*' 2>&1 | + Tee-Object -FilePath $pinLog + $code = $LASTEXITCODE + if ($code -ne 0) { throw "The lifted build-switch pin exited $code" } + $pinSummary = Get-Content -LiteralPath $pinLog | + Where-Object { $_ -match '"\$type":"test-assembly-finished"' } | + Select-Object -Last 1 | + ConvertFrom-Json + if ( + $null -eq $pinSummary -or + $pinSummary.TestsTotal -ne 1 -or + $pinSummary.TestsFailed -ne 0 -or + $pinSummary.TestsSkipped -ne 0 -or + $pinSummary.TestsNotRun -ne 0 + ) { + throw 'The IsolationSession build-switch test did not run and pass in lifted mode.' + } + + - name: Pack lifted .NET SDK + working-directory: sdk/dotnet + shell: pwsh + run: | + dotnet pack Microsoft.Mxc.Sdk/Microsoft.Mxc.Sdk.csproj ` + --configuration Release ` + -p:MxcIsolationSessionLifted=true ` + -p:MxcPackSingleRid=true ` + -p:MxcPackageRid=win-x64 + if ($LASTEXITCODE -ne 0) { throw "dotnet pack exited $LASTEXITCODE" } + + $packages = @(Get-ChildItem Microsoft.Mxc.Sdk\bin\Release\*.nupkg) + if ($packages.Count -ne 1) { + throw "Expected exactly one packed SDK, found $($packages.Count)." + } + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead($packages[0].FullName) + try { + $entries = @($archive.Entries | ForEach-Object FullName) + foreach ($entry in @( + 'runtimes/win-x64/native/IsoSessionApp.dll', + 'runtimes/win-x64/native/IsoSession.manifest' + )) { + if ($entry -notin $entries) { + throw "Packed SDK is missing lifted runtime entry: $entry" + } + } + } + finally { + $archive.Dispose() + } + + - name: Verify adjacent activation payload + shell: pwsh + run: | + $releaseDir = 'target\x86_64-pc-windows-msvc\release' + $requiredFiles = @( + 'wxc-exec.exe', + 'IsoSessionApp.dll', + 'IsoSession.manifest' + ) + + foreach ($file in $requiredFiles) { + $path = Join-Path $releaseDir $file + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Lifted build did not stage required file: $path" + } + } diff --git a/.github/workflows/Build.yml b/.github/workflows/Build.yml index 0841fd502..96b46a294 100644 --- a/.github/workflows/Build.yml +++ b/.github/workflows/Build.yml @@ -41,6 +41,8 @@ jobs: windows: needs: dependency-feed-check uses: ./.github/workflows/Build.Windows.Job.yml + with: + run_isolation_session_lifted: true linux: needs: dependency-feed-check diff --git a/build.bat b/build.bat index eb1d6226b..4e436a82e 100644 --- a/build.bat +++ b/build.bat @@ -8,6 +8,7 @@ set "BUILD_ALL=0" set "WITH_NANVIX=0" set "WITH_WSLC=0" set "WITH_ISOLATION_SESSION=0" +set "WITH_ISOLATION_SESSION_LIFTED=0" set "WITH_HYPERLIGHT=0" :: Parse arguments @@ -21,6 +22,7 @@ if /i "%~1"=="--all" ( set "BUILD_ALL=1" & shift & goto :parse_arg if /i "%~1"=="--with-microvm" ( set "WITH_NANVIX=1" & shift & goto :parse_args ) if /i "%~1"=="--with-wslc" ( set "WITH_WSLC=1" & shift & goto :parse_args ) if /i "%~1"=="--with-isolation-session" ( set "WITH_ISOLATION_SESSION=1" & shift & goto :parse_args ) +if /i "%~1"=="--with-isolation-session-lifted" ( set "WITH_ISOLATION_SESSION=1" & set "WITH_ISOLATION_SESSION_LIFTED=1" & shift & goto :parse_args ) if /i "%~1"=="--with-hyperlight" ( set "WITH_HYPERLIGHT=1" & shift & goto :parse_args ) if /i "%~1"=="--help" ( goto :usage ) if /i "%~1"=="-h" ( goto :usage ) @@ -37,6 +39,17 @@ if "%BUILD_ALL%"=="0" if "%BUILD_ARCH%"=="" ( ) ) +if "%WITH_ISOLATION_SESSION_LIFTED%"=="1" ( + if "%BUILD_ALL%"=="1" ( + echo ERROR: Lifted IsolationSession currently supports x64 only because the pinned SDK package contains one AMD64 IsoSessionApp.dll. Use --x64 instead of --all. + exit /b 1 + ) + if /i "%BUILD_ARCH%"=="aarch64-pc-windows-msvc" ( + echo ERROR: Lifted IsolationSession currently supports x64 only because the pinned SDK package contains one AMD64 IsoSessionApp.dll. + exit /b 1 + ) +) + :: Build flags set "CARGO_FLAGS=--target" if "%BUILD_CONFIG%"=="release" set "CARGO_FLAGS=--release --target" @@ -46,12 +59,17 @@ set "PLM_FLAGS=--target" if "%BUILD_CONFIG%"=="release" set "PLM_FLAGS=--release --target" if "%WITH_NANVIX%"=="1" set "CARGO_FLAGS=--features microvm %CARGO_FLAGS%" if "%WITH_WSLC%"=="1" set "CARGO_FLAGS=--features wslc %CARGO_FLAGS%" -if "%WITH_ISOLATION_SESSION%"=="1" set "CARGO_FLAGS=--features isolation_session %CARGO_FLAGS%" +if "%WITH_ISOLATION_SESSION_LIFTED%"=="1" ( + set "CARGO_FLAGS=--features isolation_session_lifted %CARGO_FLAGS%" +) else if "%WITH_ISOLATION_SESSION%"=="1" ( + set "CARGO_FLAGS=--features isolation_session %CARGO_FLAGS%" +) if "%WITH_HYPERLIGHT%"=="1" set "CARGO_FLAGS=--features hyperlight %CARGO_FLAGS%" set "DOTNET_CONFIG=Release" if "%BUILD_CONFIG%"=="debug" set "DOTNET_CONFIG=Debug" set "DOTNET_BUILD_PROPERTIES=" if "%WITH_ISOLATION_SESSION%"=="1" set "DOTNET_BUILD_PROPERTIES=!DOTNET_BUILD_PROPERTIES! -p:MxcWithIsolationSession=true" +if "%WITH_ISOLATION_SESSION_LIFTED%"=="1" set "DOTNET_BUILD_PROPERTIES=!DOTNET_BUILD_PROPERTIES! -p:MxcIsolationSessionLifted=true" if "%WITH_WSLC%"=="1" set "DOTNET_BUILD_PROPERTIES=!DOTNET_BUILD_PROPERTIES! -p:MxcWithWslc=true" :: Build Rust @@ -166,6 +184,22 @@ for %%T in (x86_64-pc-windows-msvc aarch64-pc-windows-msvc) do ( if exist "sdk\node\bin\!SDK_ARCH!\wslcsdk.dll" del /Q "sdk\node\bin\!SDK_ARCH!\wslcsdk.dll" ) ) + if "!COPY_WSLC_RUNTIME!"=="1" ( + if "%WITH_ISOLATION_SESSION_LIFTED%"=="1" ( + for %%B in (IsoSessionApp.dll IsoSession.manifest) do ( + if not exist "!BIN_DIR!\%%B" ( + echo ERROR: Lifted IsolationSession Node runtime is missing !BIN_DIR!\%%B + exit /b 1 + ) + copy /Y "!BIN_DIR!\%%B" "sdk\node\bin\!SDK_ARCH!\" >nul + echo Copied !SDK_ARCH!\%%B + ) + ) else ( + for %%B in (IsoSessionApp.dll IsoSession.manifest) do ( + if exist "sdk\node\bin\!SDK_ARCH!\%%B" del /Q "sdk\node\bin\!SDK_ARCH!\%%B" + ) + ) + ) ) :: Copy the C# SDK's native library (mxc_ffi) into its runtime assets so a @@ -175,6 +209,11 @@ for %%T in (x86_64-pc-windows-msvc aarch64-pc-windows-msvc) do ( if exist "sdk\dotnet\Microsoft.Mxc.Sdk\runtimes\!RID!\native\wxc-wslc-daemon.exe" del /Q "sdk\dotnet\Microsoft.Mxc.Sdk\runtimes\!RID!\native\wxc-wslc-daemon.exe" if exist "sdk\dotnet\Microsoft.Mxc.Sdk\runtimes\!RID!\native\wslcsdk.dll" del /Q "sdk\dotnet\Microsoft.Mxc.Sdk\runtimes\!RID!\native\wslcsdk.dll" ) + if "!COPY_WSLC_RUNTIME!"=="1" if not "%WITH_ISOLATION_SESSION_LIFTED%"=="1" ( + for %%B in (IsoSessionApp.dll IsoSession.manifest) do ( + if exist "sdk\dotnet\Microsoft.Mxc.Sdk\runtimes\!RID!\native\%%B" del /Q "sdk\dotnet\Microsoft.Mxc.Sdk\runtimes\!RID!\native\%%B" + ) + ) if exist "!BIN_DIR!\mxc_ffi.dll" ( if not exist "sdk\dotnet\Microsoft.Mxc.Sdk\runtimes\!RID!\native" mkdir "sdk\dotnet\Microsoft.Mxc.Sdk\runtimes\!RID!\native" copy /Y "!BIN_DIR!\mxc_ffi.dll" "sdk\dotnet\Microsoft.Mxc.Sdk\runtimes\!RID!\native\" >nul @@ -193,8 +232,19 @@ for %%T in (x86_64-pc-windows-msvc aarch64-pc-windows-msvc) do ( echo Copied !RID!\native\%%B ) ) + if "%WITH_ISOLATION_SESSION_LIFTED%"=="1" if "!COPY_WSLC_RUNTIME!"=="1" ( + for %%B in (IsoSessionApp.dll IsoSession.manifest) do ( + if not exist "!BIN_DIR!\%%B" ( + echo ERROR: Lifted IsolationSession C# runtime unit is missing !BIN_DIR!\%%B + exit /b 1 + ) + copy /Y "!BIN_DIR!\%%B" "sdk\dotnet\Microsoft.Mxc.Sdk\runtimes\!RID!\native\" >nul + echo Copied !RID!\native\%%B + ) + ) if "!COPY_WSLC_RUNTIME!"=="1" ( >"sdk\dotnet\Microsoft.Mxc.Sdk\runtimes\!RID!\mxc-build-features.txt" echo isolation_session=%WITH_ISOLATION_SESSION% + >>"sdk\dotnet\Microsoft.Mxc.Sdk\runtimes\!RID!\mxc-build-features.txt" echo isolation_session_lifted=%WITH_ISOLATION_SESSION_LIFTED% >>"sdk\dotnet\Microsoft.Mxc.Sdk\runtimes\!RID!\mxc-build-features.txt" echo wslc=%WITH_WSLC% ) ) @@ -235,6 +285,7 @@ popd echo. echo Building SDK integration tests... +set "MXC_EXPECT_ISOLATION_SESSION_LIFTED=%WITH_ISOLATION_SESSION_LIFTED%" pushd sdk\node\tests\integration :: npm caches `file:` deps by package.json version. The local SDK version :: rarely bumps between builds, so a plain `npm install` keeps reusing the @@ -242,6 +293,13 @@ pushd sdk\node\tests\integration :: type-checking sees the dist we just rebuilt above. if exist node_modules\@microsoft\mxc-sdk rmdir /s /q node_modules\@microsoft\mxc-sdk call npm install & call npm run build +if "%WITH_ISOLATION_SESSION_LIFTED%"=="1" ( + node --test --test-name-pattern="lifted IsolationSession|unexpected binaries" dist\package.test.js + if errorlevel 1 ( + popd + goto :error_root + ) +) popd echo. @@ -311,7 +369,8 @@ echo --arm64 Build for ARM64 only echo --all Build both Windows architectures and create the .NET NuGet package echo --with-microvm Download and include NanVix micro-VM binaries echo --with-wslc Build with WSL Container (WSLC SDK) support -echo --with-isolation-session Build with IsolationSession backend (IsoEnvBroker) +echo --with-isolation-session Build with IsolationSession backend (inbox OS API) +echo --with-isolation-session-lifted Build with lifted IsolationSession (x64-only NuGet SDK + MSI runtime) echo --with-hyperlight Build with Hyperlight (micro-VM) backend (x86_64 only) echo -h, --help Show this help echo. diff --git a/docs/development/architecture/backends/isolation-session/oneshot.md b/docs/development/architecture/backends/isolation-session/oneshot.md index 7cdbbb4ca..88666182f 100644 --- a/docs/development/architecture/backends/isolation-session/oneshot.md +++ b/docs/development/architecture/backends/isolation-session/oneshot.md @@ -156,6 +156,17 @@ on an internal Windows feature flag. Activation goes through the WinRT activation factory for the `Windows.AI.IsolationSession.Preview` `IsoSessionOps` runtime class. +MXC supports two explicit IsolationSession build modes: + +- `isolation_session` uses the checked-in bindings and activates the inbox + Windows runtime. +- `isolation_session_lifted` generates bindings from the pinned + `Microsoft.AI.IsolationSession.SDK` package and stages the package's + registration-free activation payload beside the executable. + +The lifted mode verifies the packaged framework before activation and does not +fall back to the inbox runtime. Default builds enable neither mode. + The API surface includes the lifecycle methods plus `IsoSessionProcess` (the running-process handle). The runner uses the process surface for stdio relay (stdout / stderr / stdin pipe handles), @@ -165,11 +176,19 @@ flag when `wxc-exec`'s stdout is a TTY. ## Bindings Workflow -**Why a private WinMD.** The OS-side API ships its WinMD as part of an -internal Windows OS build (the exact file name and provenance are recorded -in `GENERATION_INFO.toml`). There is no public NuGet or release distribution -today. MXC stores generated Rust bindings in the workspace and tracks their -provenance. +**Inbox bindings.** The inbox mode uses a WinMD from an internal Windows OS +build (the exact file name and provenance are recorded in +`GENERATION_INFO.toml`). MXC stores the generated Rust bindings in the +workspace and tracks their provenance. + +**Lifted bindings and runtime.** The lifted mode pins +`Microsoft.AI.IsolationSession.SDK` version `0.202610.5`. The build restores it +through NuGet using the repository's configured `MxcDependencies` source into +an MXC-owned cache under the Cargo target directory, verifies the package hash, +generates bindings from the packaged WinMD, and stages `IsoSessionApp.dll` and +its activation manifest beside the consuming binary. For offline builds, +`ISOLATION_SESSION_SDK_PACKAGE` can point to a pre-fetched package with the +same expected hash. **Future direction.** The OS API is expected to land in the public Windows SDK eventually, at which point the `windows` crate (auto-generated from @@ -179,22 +198,20 @@ through the standard `windows` crate dependency. That milestone is currently far off — the private bindings remain the working approach for the foreseeable future. -**Generated bindings are committed.** +**Inbox generated bindings are committed.** `src/mxc-sdk/src/backends/isolation_session/bindings/bindings.rs` is a checked-in artifact. -The WinMD itself is **not** committed (binary, frequently updated). All -provenance lives in `GENERATION_INFO.toml`. - -**Regeneration.** When the OS-side API changes (or the consumed OS build -moves), the bindings must be regenerated by a Microsoft engineer with -access to the private WinMD. `windows-bindgen` X.Y generates code that -targets the `windows` X.Y crate, so a regenerator must use a -`windows-bindgen` release whose major.minor matches the workspace -`windows` crate. The generated bindings and the Rust code in this repo use -the `Windows.AI.IsolationSession.Preview` namespace (for example, -`IsoSessionOps`) — that is the naming to use when diagnosing regeneration or -version-coupling issues. The build-time check below catches the most common -slip — bumping the workspace `windows` crate without regenerating — by -comparing the workspace version against the recorded `target_windows_crate`. +The private WinMD itself is **not** committed (binary, frequently updated). +All provenance lives in `GENERATION_INFO.toml`. Lifted bindings are generated +from the restored package during the build and are not committed. + +**Regeneration.** When the inbox OS-side API changes (or the consumed OS build +moves), the committed bindings must be regenerated by a Microsoft engineer +with access to the private WinMD. `windows-bindgen` X.Y generates code that +targets the `windows` X.Y crate, so both inbox regeneration and lifted +build-time generation must use a `windows-bindgen` release whose major.minor +matches the workspace `windows` crate. The generated bindings and the Rust +code in this repo use the `Windows.AI.IsolationSession.Preview` namespace (for +example, `IsoSessionOps`). **`build.rs` version check.** `isolation_session_bindings/build.rs` reads the expected `windows` crate version from `GENERATION_INFO.toml` @@ -362,16 +379,23 @@ The following were observed during VM testing and are accepted for v0.1. **For end users:** - A Windows build with the IsolationSession feature enabled. -- The OS-side isolation-session host binary present in +- For inbox builds, the OS-side isolation-session host binary present in `%SystemRoot%\System32\` (ships with Windows as part of the OS-side service). +- For lifted builds, the generated activation manifest and + `IsoSessionApp.dll` distributed beside the executable. - WinRT initialized as MTA (handled by `wxc-exec`). **For developers:** - Standard Rust toolchain. -- `cargo build --features isolation_session` to build the feature into - `wxc-exec`. Default builds skip it — no impact on existing workflows. +- `cargo build --features isolation_session` to build against the inbox + runtime, or `cargo build --features isolation_session_lifted` to restore the + pinned package and stage the lifted runtime. Default builds enable neither + mode. +- .NET SDK 10 for the lifted package restore. The normal restore uses the + configured `MxcDependencies` feed and an MXC-owned cache; use + `ISOLATION_SESSION_SDK_PACKAGE` only for an explicitly pre-fetched package. - A private WinMD only when **regenerating** bindings. As long as the OS API hasn't changed, no regen is needed. diff --git a/docs/development/architecture/backends/isolation-session/state-aware-rust.md b/docs/development/architecture/backends/isolation-session/state-aware-rust.md index f7ac7d06c..138967aaa 100644 --- a/docs/development/architecture/backends/isolation-session/state-aware-rust.md +++ b/docs/development/architecture/backends/isolation-session/state-aware-rust.md @@ -436,7 +436,7 @@ wire-format `MxcError` codes via `map_lifecycle_error`: | `IsolationSessionError` variant | Wire `error.code` | Trigger | |---|---|---| | `Policy(...)` | `policy_validation` | A structurally representable request violates a backend semantic invariant — see the honor matrix above. Rejected by `validate_` hooks (state-aware) or `validate_runner` (one-shot); fields excluded by an exact request root fail earlier as `malformed_request`. | -| `ServiceUnavailable(...)` | `backend_unavailable` | Activation failure of the in-proc IsolationSession runtime API: it is unavailable on this OS build (not registered, or the OS feature gate is off). HRESULTs `CLASS_E_CLASSNOTAVAILABLE` (`0x80040111`) or `REGDB_E_CLASSNOTREG` (`0x80040154`). | +| `ServiceUnavailable(...)` | `backend_unavailable` | Activation failure of the in-proc IsolationSession runtime API: it is unavailable on this OS build (not registered, or the OS feature gate is off). HRESULTs `CLASS_E_CLASSNOTAVAILABLE` (`0x80040111`) or `REGDB_E_CLASSNOTREG` (`0x80040154`). Also raised when the pre-activation framework check refuses — see [Framework verification](#framework-verification) below. | | `Stale(...)` | `stale_id` | The OS service reports `HRESULT_FROM_WIN32(ERROR_NOT_FOUND)` (`0x80070490`) — the agent user is unknown to it. After `deprovision`, every non-provision op against the dead `sandboxId` triggers this. | | `Lifecycle(...)` | `backend_error` | Any other failure of a lifecycle op, whether the API reported it semantically or the call itself could not be completed. | @@ -495,6 +495,15 @@ semantic error channel, and **only** for non-provision operations. - *Non-provision only:* provision mints the agent user. There is no `sandboxId` yet, so reporting a stale one would be incoherent. +### Framework verification + +Before activating the runtime, the state-aware path runs the same pre-activation +framework check the one-shot path uses (`regfree::verify_framework`, gated behind +the lifted build). It asks the co-located `IsoSessionApp.dll` — via its exported +`VerifyIsoSessionFramework` — whether the matching IsolationSession runtime is +installed and loadable. Only `S_OK` proceeds; **any** other outcome (including an +older shim replying that it cannot verify) is fail-closed and refuses. + ## Cancellation State-aware exec (and other phases) use OS-level cancellation in v1: diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj index 6bcf850b1..98b636a7f 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj @@ -11,6 +11,7 @@ true $(DefineConstants);MXC_WITH_WSLC + true $(DefineConstants);MXC_WITH_ISOLATION_SESSION $(MSBuildProjectDirectory)/../../../src $(MxcSrcDir)/target/dotnet-test-support @@ -18,7 +19,8 @@ including when the managed tests themselves run in Release. --> debug dotnetsdk test-support - $(MxcTestCargoFeatures) isolation_session + $(MxcTestCargoFeatures) isolation_session + $(MxcTestCargoFeatures) isolation_session_lifted $(MxcTestCargoFeatures) wslc mxc_ffi.dll libmxc_ffi.dylib diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcContainerTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcContainerTests.cs index 19587b073..f05650b72 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcContainerTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcContainerTests.cs @@ -436,10 +436,15 @@ public void GetAvailableBackends_ReturnsTypedNativeProbe() Assert.NotNull(backends); if (OperatingSystem.IsWindows()) { + var support = MxcPlatform.GetPlatformSupport(); var processContainer = Assert.Single( backends, backend => backend.Backend == ContainmentBackend.ProcessContainer); Assert.NotNull(processContainer.Tier); + Assert.Equal( + support.AvailableMethods.Contains(ContainmentBackend.IsolationSession), + backends.Any( + backend => backend.Backend == ContainmentBackend.IsolationSession)); } } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/Microsoft.Mxc.Sdk.csproj b/sdk/dotnet/Microsoft.Mxc.Sdk/Microsoft.Mxc.Sdk.csproj index 99d6ed1ea..d8387b546 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/Microsoft.Mxc.Sdk.csproj +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/Microsoft.Mxc.Sdk.csproj @@ -45,11 +45,16 @@ + + false + true false false dotnetsdk - $(MxcCargoFeatures) isolation_session + $(MxcCargoFeatures) isolation_session + $(MxcCargoFeatures) isolation_session_lifted $(MxcCargoFeatures) wslc true @@ -67,14 +72,18 @@ $(MxcNativeFileName) $(MxcNativeFileName) and $(MxcSidecarFileName) $(MxcNativeUnitFileNames), wxc-wslc-daemon.exe, and wslcsdk.dll + $(MxcNativeUnitFileNames), IsoSessionApp.dll, and IsoSession.manifest 1 2 4 + $([MSBuild]::Add($(MxcNativeUnitExpectedCount), 2)) 0 1 + 0 + 1 0 1 - isolation_session=$(MxcIsolationSessionFeatureBit);wslc=$(MxcWslcFeatureBit) + isolation_session=$(MxcIsolationSessionFeatureBit);isolation_session_lifted=$(MxcIsolationSessionLiftedFeatureBit);wslc=$(MxcWslcFeatureBit) $(NETCoreSdkPortableRuntimeIdentifier) $(MxcHostRid.Substring($([MSBuild]::Add($(MxcHostRid.LastIndexOf('-')), 1)))) @@ -162,6 +171,8 @@ Text="mxc: PlatformTarget '$(PlatformTarget)' is not this host's '$(MxcHostArch)', and cargo builds the native unit for the host. Drop the PlatformTarget, or set it to '$(MxcHostArch)'." /> + + + + @@ -221,6 +239,8 @@ + + + @@ -302,8 +326,14 @@ $(TargetsForTfmSpecificContentInPackage);MxcAddNativeUnitToPackage + + + + + DependsOnTargets="MxcValidateLiftedPackage;GenerateNativeBindings;MxcLoadPrebuiltNativeUnit"> @@ -324,6 +354,8 @@ Text="mxc: pre-staged $(MxcOtherWindowsRid) is missing mxc_ffi.dll or plm.exe. Rebuild that RID before creating a multi-RID package." /> + { ); }); + if (process.env.MXC_EXPECT_ISOLATION_SESSION_LIFTED === '1') { + it('should include the lifted IsolationSession runtime payload', () => { + const missing = ISOLATION_SESSION_LIFTED_BINARIES.filter( + binary => !fs.existsSync(path.join(binDir, binary)), + ); + assert.deepStrictEqual( + missing, + [], + `Missing lifted IsolationSession binaries in ${binDir}: ${missing.join(', ')}`, + ); + }); + } + it('should not contain unexpected binaries', () => { if (!fs.existsSync(binDir)) { return; diff --git a/sdk/node/tests/integration/test-helpers.ts b/sdk/node/tests/integration/test-helpers.ts index 75895b459..98375ea84 100644 --- a/sdk/node/tests/integration/test-helpers.ts +++ b/sdk/node/tests/integration/test-helpers.ts @@ -136,9 +136,15 @@ export const EXPECTED_MACOS_BINARIES = [ 'unix-test-proxy', ]; +export const ISOLATION_SESSION_LIFTED_BINARIES = [ + 'IsoSessionApp.dll', + 'IsoSession.manifest', +]; + // Binaries that are optional (feature-gated or only present in certain builds) // but still legitimate if found in the package. const OPTIONAL_BINARIES = [ + ...ISOLATION_SESSION_LIFTED_BINARIES, 'wslcsdk.dll', // Only built with --with-wslc 'wxc-wslc-daemon.exe', // Only built with --with-wslc 'nanvixd.exe', // Only built with --with-microvm diff --git a/src/Cargo.lock b/src/Cargo.lock index e40b155c1..5db5fa82b 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -322,7 +322,7 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1333,7 +1333,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1515,6 +1515,7 @@ dependencies = [ "uuid", "widestring", "windows", + "windows-bindgen", "windows-collections", "windows-core", "windows-future", @@ -1557,8 +1558,10 @@ dependencies = [ "serde", "serde_ignored", "serde_json", + "sha2", "tempfile", "winresource", + "zip", ] [[package]] @@ -1689,7 +1692,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2745,6 +2748,17 @@ dependencies = [ "windows-numerics", ] +[[package]] +name = "windows-bindgen" +version = "0.62.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b541b67bb66a43de06888703b15519e40b3fc9b65917dc1ccd71aecfd7c5703" +dependencies = [ + "serde", + "serde_json", + "windows-threading 0.1.0", +] + [[package]] name = "windows-collections" version = "0.3.2" @@ -2762,7 +2776,7 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.2.1", "windows-result", "windows-strings", ] @@ -2774,8 +2788,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ "windows-core", - "windows-link", - "windows-threading", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -2800,6 +2814,12 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" @@ -2813,7 +2833,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ "windows-core", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2822,7 +2842,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2831,7 +2851,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2858,7 +2878,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2877,13 +2897,22 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-threading" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2892,7 +2921,7 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3082,9 +3111,11 @@ dependencies = [ "mxc-sdk", "serde", "serde_json", + "sha2", "tempfile", "windows", "winresource", + "zip", ] [[package]] diff --git a/src/Cargo.toml b/src/Cargo.toml index b823e9f8e..98d7ebf41 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -85,12 +85,14 @@ windows = { version = "0.62", features = [ "Win32_System_Pipes", "Win32_System_ProcessStatus", "Win32_System_Registry", + "Win32_System_ApplicationInstallationAndServicing", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_Time", "Win32_System_Variant", "Win32_System_WindowsProgramming", + "Win32_System_WinRT", "Win32_UI_Shell", ] } windows-core = "0.62" diff --git a/src/ffi/mxc_ffi/Cargo.toml b/src/ffi/mxc_ffi/Cargo.toml index 1182f4564..8f1bceb54 100644 --- a/src/ffi/mxc_ffi/Cargo.toml +++ b/src/ffi/mxc_ffi/Cargo.toml @@ -28,6 +28,8 @@ wslc = ["mxc-sdk/wslc"] # IsolationSession backend (Windows only). Needed by the # `attached_console_ffi` example. isolation_session = ["mxc-sdk/isolation_session"] +# Lifted SDK + MSI runtime: generated bindings and the staged activation payload. +isolation_session_lifted = ["isolation_session", "mxc-sdk/isolation_session_lifted"] # Opt-in C# SDK P/Invoke code generation (csbindgen) in build.rs. Off by # default so normal builds don't compile csbindgen or write into the source # tree. Local C# builds, the Windows x64 pipeline build, and the drift gate @@ -49,6 +51,8 @@ required-features = ["isolation_session"] [build-dependencies] csbindgen = { version = "1", optional = true } +sha2.workspace = true +zip = { version = "2", default-features = false, features = ["deflate"] } [target.'cfg(windows)'.build-dependencies] cc = "1" diff --git a/src/ffi/mxc_ffi/build.rs b/src/ffi/mxc_ffi/build.rs index c528396f7..1cd0027f6 100644 --- a/src/ffi/mxc_ffi/build.rs +++ b/src/ffi/mxc_ffi/build.rs @@ -26,10 +26,55 @@ fn main() { println!("cargo:rerun-if-changed=src/state_aware.rs"); println!("cargo:rerun-if-changed=build.rs"); + #[cfg(feature = "isolation_session")] + reconcile_isolation_session_runtime(); + #[cfg(feature = "dotnetsdk")] generate_csharp_bindings(); } +#[cfg(feature = "isolation_session")] +fn reconcile_isolation_session_runtime() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + + let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR")); + let target_dir = out_dir + .parent() + .and_then(|path| path.parent()) + .and_then(|path| path.parent()) + .expect("could not determine target directory from OUT_DIR"); + + // Feature configurations share the final profile directory but have + // separate build-script fingerprints. A missing tracked path keeps this + // reconciliation active whenever Cargo switches fingerprints. + println!( + "cargo:rerun-if-changed={}", + target_dir.join(".isosession-payload-state").display() + ); + + #[cfg(feature = "isolation_session_lifted")] + mxc_build_common::isolation_session_sdk::stage_runtime() + .unwrap_or_else(|error| panic!("IsolationSession SDK staging failed: {error}")); + + #[cfg(not(feature = "isolation_session_lifted"))] + { + for file_name in [ + "IsoSessionApp.dll", + "IsoSession.manifest", + "IsoSessionApp.runtimeversion", + ] { + let path = target_dir.join(file_name); + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => panic!("remove stale lifted payload {}: {error}", path.display()), + } + } + } +} + #[cfg(feature = "dotnetsdk")] fn generate_csharp_bindings() { use std::path::Path; diff --git a/src/mxc-sdk/Cargo.toml b/src/mxc-sdk/Cargo.toml index caab172e1..80651df5d 100644 --- a/src/mxc-sdk/Cargo.toml +++ b/src/mxc-sdk/Cargo.toml @@ -30,7 +30,15 @@ required-features = ["wslc"] default = [] wslc = ["link-wslcsdk", "dep:libloading", "dep:tar"] link-wslcsdk = ["dep:zip"] -isolation_session = ["dep:windows-collections", "dep:windows-future"] +isolation_session = [ + "dep:windows-collections", + "dep:windows-future", +] +isolation_session_lifted = [ + "isolation_session", + "dep:windows-bindgen", + "dep:zip", +] test-support = [] microvm = [] hyperlight = [ @@ -74,6 +82,7 @@ serde.workspace = true serde_json.workspace = true sha2.workspace = true uuid.workspace = true +windows-bindgen = { version = "=0.62.1", optional = true } zip = { version = "2", default-features = false, features = ["deflate"], optional = true } [dev-dependencies] diff --git a/src/mxc-sdk/build.rs b/src/mxc-sdk/build.rs index a3c26529d..5c8e96b74 100644 --- a/src/mxc-sdk/build.rs +++ b/src/mxc-sdk/build.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#[cfg(feature = "isolation_session")] #[path = "build/build_isolation_session_bindings.rs"] mod build_isolation_session_bindings; #[allow(dead_code)] @@ -27,7 +28,13 @@ mod nanvix_build_common; mod nanvix_common; fn main() -> Result<(), Box> { + #[cfg(feature = "isolation_session")] build_isolation_session_bindings::run(); + #[cfg(feature = "isolation_session_lifted")] + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + build_mxc_build_common::isolation_session_sdk::stage_runtime() + .unwrap_or_else(|error| panic!("IsolationSession SDK staging failed: {error}")); + } build_mxc_telemetry::run(); if let Some((bin_dir, use_prefetched_binaries)) = build_nanvix_binaries::run() { nanvix_build_common::stage_artifacts_next_to_exe(&bin_dir, !use_prefetched_binaries); diff --git a/src/mxc-sdk/build/build_isolation_session_bindings.rs b/src/mxc-sdk/build/build_isolation_session_bindings.rs index a8f27765e..b6736fd62 100644 --- a/src/mxc-sdk/build/build_isolation_session_bindings.rs +++ b/src/mxc-sdk/build/build_isolation_session_bindings.rs @@ -1,109 +1,205 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Build script that verifies the workspace's `windows` crate version matches -//! the version the generated bindings were produced for. +//! Selects the IsolationSession projection source for the requested build mode. +//! +//! Inbox builds use the committed OS-generated bindings and verify their +//! `windows` crate compatibility. Lifted builds restore the pinned SDK package +//! through the configured MxcDependencies feed and regenerate bindings from +//! its Preview WinMD. + +use std::path::Path; pub(crate) fn run() { - // Keep provenance inside the package so crates.io builds enforce the same - // generated-binding version coupling as repository builds. - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); - let info_path = std::path::Path::new(&manifest_dir) - .join("build") - .join("isolation_session_bindings") - .join("GENERATION_INFO.toml"); + println!("cargo:rerun-if-changed=build/build_isolation_session_bindings.rs"); + + #[cfg(feature = "isolation_session_lifted")] + generate_lifted_bindings(); - // If the provenance file doesn't exist yet (e.g., first-time setup before - // generation has been run), skip the check. - if !info_path.exists() { - return; + #[cfg(not(feature = "isolation_session_lifted"))] + verify_inbox_bindings_version(); +} + +#[cfg(feature = "isolation_session_lifted")] +fn generate_lifted_bindings() { + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR"); + let package = crate::build_mxc_build_common::isolation_session_sdk::resolve_package() + .unwrap_or_else(|e| panic!("IsolationSession SDK acquisition failed: {e}")); + let winmd_bytes = crate::build_mxc_build_common::isolation_session_sdk::read_entry( + &package, + "windows.ai.isolationsession.preview.winmd", + ) + .unwrap_or_else(|e| panic!("IsolationSession SDK metadata extraction failed: {e}")); + let winmd_path = Path::new(&out_dir).join("windows.ai.isolationsession.preview.winmd"); + std::fs::write(&winmd_path, &winmd_bytes) + .unwrap_or_else(|e| panic!("write extracted WinMD to {}: {e}", winmd_path.display())); + + let bindings_path = Path::new(&out_dir).join("bindings.rs"); + let warnings = windows_bindgen::bindgen([ + "--in", + winmd_path.to_str().expect("WinMD path is valid UTF-8"), + "--in", + "default", + "--out", + bindings_path + .to_str() + .expect("bindings path is valid UTF-8"), + "--filter", + "Windows.AI.IsolationSession.Preview", + "--reference", + "windows,skip-root,Windows.Foundation", + "--reference", + "windows,skip-root,Windows.UI", + "--flat", + "--implement", + ]); + + for line in format!("{warnings}") + .lines() + .filter(|line| !line.trim().is_empty()) + { + println!("cargo:warning=isosession-bindgen: {line}"); } - let contents = std::fs::read_to_string(&info_path).unwrap_or_default(); - - // Extract the expected windows crate version from the TOML. - let expected = contents.lines().find_map(|line| { - let line = line.trim(); - if line.starts_with("target_windows_crate") { - line.split('=') - .nth(1) - .map(|v| v.trim().trim_matches('"').to_string()) - } else { - None + let generated = std::fs::read_to_string(&bindings_path) + .unwrap_or_else(|e| panic!("read generated {}: {e}", bindings_path.display())); + std::fs::write(&bindings_path, strip_leading_inner_attrs(&generated)) + .unwrap_or_else(|e| panic!("rewrite cleaned {}: {e}", bindings_path.display())); +} + +#[cfg(feature = "isolation_session_lifted")] +fn strip_leading_inner_attrs(source: &str) -> String { + let mut result = String::with_capacity(source.len()); + let mut in_inner_attribute = false; + let mut started_body = false; + + for line in source.lines() { + if started_body { + result.push_str(line); + result.push('\n'); + continue; + } + if in_inner_attribute { + if line.contains(']') { + in_inner_attribute = false; + } + continue; + } + + let trimmed = line.trim_start(); + if trimmed.starts_with("#![") { + in_inner_attribute = !trimmed.contains(']'); + continue; } + if trimmed.is_empty() || trimmed.starts_with("//") { + result.push_str(line); + result.push('\n'); + continue; + } + + started_body = true; + result.push_str(line); + result.push('\n'); + } + + result +} + +#[cfg(not(feature = "isolation_session_lifted"))] +fn verify_inbox_bindings_version() { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"); + let info_path = Path::new(&manifest_dir) + .join("build") + .join("isolation_session_bindings") + .join("GENERATION_INFO.toml"); + println!("cargo:rerun-if-changed={}", info_path.display()); + let contents = std::fs::read_to_string(&info_path).unwrap_or_else(|error| { + panic!( + "isolation_session_bindings: read provenance {}: {error}", + info_path.display() + ) }); + let expected = contents + .lines() + .find_map(|line| { + let line = line.trim(); + if line.starts_with("target_windows_crate") { + line.split('=') + .nth(1) + .map(|value| value.trim().trim_matches('"').to_string()) + } else { + None + } + }) + .unwrap_or_else(|| { + panic!( + "isolation_session_bindings: {} has no target_windows_crate", + info_path.display() + ) + }); - let Some(expected_version) = expected else { - // No version constraint found — skip check. - return; - }; - - // Cargo packages include Cargo.lock at the crate root, while repository - // builds use the workspace lockfile one directory above mxc-sdk. - let manifest_dir = std::path::Path::new(&manifest_dir); - let lock_path = [ - manifest_dir.join("Cargo.lock"), - manifest_dir.join("../Cargo.lock"), - ] - .into_iter() - .find(|path| path.exists()); - - let Some(lock_path) = lock_path else { - return; - }; - - let lock_contents = std::fs::read_to_string(&lock_path).unwrap_or_default(); - - // Simple parser: find the [[package]] block for windows. - let actual_version = lock_contents + let lock_candidates = [ + Path::new(&manifest_dir).join("Cargo.lock"), + Path::new(&manifest_dir).join("..").join("Cargo.lock"), + ]; + let lock_path = lock_candidates + .iter() + .find(|path| path.is_file()) + .unwrap_or_else(|| { + panic!( + "isolation_session_bindings: no Cargo.lock found at the package root ({}) \ + or workspace root ({})", + lock_candidates[0].display(), + lock_candidates[1].display() + ) + }); + println!("cargo:rerun-if-changed={}", lock_path.display()); + let lock_contents = std::fs::read_to_string(&lock_path).unwrap_or_else(|error| { + panic!( + "isolation_session_bindings: read lockfile {}: {error}", + lock_path.display() + ) + }); + let actual_versions = lock_contents .split("[[package]]") - .find(|block| { - let has_name = block.lines().any(|l| l.trim() == "name = \"windows\""); - // Exclude windows-* crates (windows-core, windows-sys, etc.) - let not_prefixed = !block.lines().any(|l| { - let t = l.trim(); - t.starts_with("name = \"windows-") - }); - has_name && not_prefixed + .filter(|block| { + block + .lines() + .any(|line| line.trim() == "name = \"windows\"") }) - .and_then(|block| { - block.lines().find_map(|l| { - let t = l.trim(); - if t.starts_with("version = ") { - Some(t.split('=').nth(1)?.trim().trim_matches('"').to_string()) - } else { - None - } + .filter_map(|block| { + block.lines().find_map(|line| { + let line = line.trim(); + line.strip_prefix("version = ") + .map(|value| value.trim().trim_matches('"').to_string()) }) - }); + }) + .collect::>(); + assert_eq!( + actual_versions.len(), + 1, + "isolation_session_bindings: expected exactly one windows package in {}, found {:?}", + lock_path.display(), + actual_versions + ); + let actual = &actual_versions[0]; - // Build a caret requirement from the major.minor of `expected_version`. - // This matches "compatible with X.Y" — same loose-on-patch intent as the - // prior `starts_with` check, but via a real semver parser so e.g. "0.6" - // cannot silently accept "0.62". - let parts: Vec<&str> = expected_version.split('.').take(2).collect(); - let req_pattern = if parts.len() == 2 { - format!("^{}.{}", parts[0], parts[1]) - } else { - return; // Unexpected format — skip check rather than fail loudly. - }; - let Ok(req) = semver::VersionReq::parse(&req_pattern) else { - return; - }; - - if let Some(actual) = actual_version { - let Ok(actual_ver) = semver::Version::parse(&actual) else { - return; - }; - if !req.matches(&actual_ver) { - panic!( - "\n\n\ - isolation_session_bindings: generated code targets windows crate {expected},\n\ - but workspace has {actual}. Bindings must be regenerated.\n\ - \n", - expected = expected_version, - actual = actual, - ); - } - } + let parts = expected.split('.').take(2).collect::>(); + assert_eq!( + parts.len(), + 2, + "isolation_session_bindings: invalid target_windows_crate {expected:?}" + ); + let requirement = semver::VersionReq::parse(&format!("^{}.{}", parts[0], parts[1])) + .unwrap_or_else(|error| { + panic!("isolation_session_bindings: invalid target_windows_crate {expected:?}: {error}") + }); + let actual_version = semver::Version::parse(actual).unwrap_or_else(|error| { + panic!("isolation_session_bindings: invalid windows version {actual:?}: {error}") + }); + assert!( + requirement.matches(&actual_version), + "isolation_session_bindings: committed inbox bindings target windows crate \ + {expected}, but the workspace has {actual}; regenerate the inbox bindings" + ); } diff --git a/src/mxc-sdk/build/build_mxc_build_common.rs b/src/mxc-sdk/build/build_mxc_build_common.rs index 5b6034ab2..79bd94dac 100644 --- a/src/mxc-sdk/build/build_mxc_build_common.rs +++ b/src/mxc-sdk/build/build_mxc_build_common.rs @@ -6,10 +6,242 @@ //! Stamps `ProductName`, `FileDescription`, `OriginalFilename`, and //! `ProductVersion` (with the git commit hash) into MXC PE executables and libraries. +// This source-included helper is also compiled by binaries that do not expose +// the lifted feature, so their Cargo manifests cannot declare this cfg value. +#![allow(unexpected_cfgs)] + use std::io; use std::path::Path; use std::process::Command; +#[cfg(feature = "isolation_session_lifted")] +pub mod isolation_session_sdk { + use sha2::{Digest, Sha256}; + use std::io::Read; + use std::path::{Path, PathBuf}; + use std::process::Command; + + mod package_validation { + include!("isolation_session_bindings/package_validation.rs"); + } + + use package_validation::{ + package_file_name, package_runtime_instance, validate_runtime_architecture, + validate_runtime_manifest, + }; + + pub const PACKAGE_ID: &str = "Microsoft.AI.IsolationSession.SDK"; + pub const PACKAGE_VERSION: &str = "0.202610.5"; + pub const PACKAGE_SHA256: &str = + "b387c9d11808bf8864d3d7e4d6924f79bdcd6e7c4c54c4e2e49ab0e3525b3d2e"; + pub const PACKAGE_PATH_ENV: &str = "ISOLATION_SESSION_SDK_PACKAGE"; + + const RESTORE_PROJECT: &str = "IsolationSessionSdk.Restore.csproj"; + const NUGET_CONFIG: &str = "NuGet.Config"; + const APP_DLL: &str = "IsoSessionApp.dll"; + const RUNTIME_MANIFEST: &str = "IsoSession.manifest"; + + pub fn resolve_package() -> Result { + println!("cargo:rerun-if-env-changed={PACKAGE_PATH_ENV}"); + + if let Ok(value) = std::env::var(PACKAGE_PATH_ENV) { + let path = PathBuf::from(value); + verify_package(&path)?; + println!("cargo:rerun-if-changed={}", path.display()); + return Ok(path); + } + + let package_name = package_file_name(PACKAGE_ID, PACKAGE_VERSION); + let package_dir = mxc_nuget_cache_root()? + .join(PACKAGE_ID.to_ascii_lowercase()) + .join(PACKAGE_VERSION); + let package_path = package_dir.join(&package_name); + + if package_path.exists() { + verify_package(&package_path)?; + println!("cargo:rerun-if-changed={}", package_path.display()); + return Ok(package_path); + } + + restore_package()?; + verify_package(&package_path)?; + + println!("cargo:rerun-if-changed={}", package_path.display()); + Ok(package_path) + } + + pub fn stage_runtime() -> Result<(), String> { + let package = resolve_package()?; + let app_dll = read_entry(&package, APP_DLL)?; + let manifest = read_entry(&package, RUNTIME_MANIFEST)?; + let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH") + .map_err(|e| format!("CARGO_CFG_TARGET_ARCH is not set: {e}"))?; + validate_runtime_architecture(&app_dll, &target_arch)?; + let instance = package_runtime_instance(PACKAGE_VERSION)?; + validate_runtime_manifest(&manifest, &instance)?; + + let target_dir = target_profile_dir()?; + std::fs::write(target_dir.join(APP_DLL), app_dll) + .map_err(|e| format!("stage {APP_DLL} to {}: {e}", target_dir.display()))?; + std::fs::write(target_dir.join(RUNTIME_MANIFEST), manifest) + .map_err(|e| format!("stage {RUNTIME_MANIFEST} to {}: {e}", target_dir.display()))?; + Ok(()) + } + + pub fn read_entry(package: &Path, file_name: &str) -> Result, String> { + let file = std::fs::File::open(package) + .map_err(|e| format!("open NuGet package {}: {e}", package.display()))?; + let mut archive = zip::ZipArchive::new(file) + .map_err(|e| format!("read NuGet package {}: {e}", package.display()))?; + let wanted = file_name.to_ascii_lowercase(); + let entry_name = (0..archive.len()) + .find_map(|index| { + let name = archive.by_index(index).ok()?.name().to_string(); + let leaf = name.rsplit(['/', '\\']).next().unwrap_or(&name); + (leaf.to_ascii_lowercase() == wanted).then_some(name) + }) + .ok_or_else(|| { + format!( + "{file_name} was not found in NuGet package {}", + package.display() + ) + })?; + let mut entry = archive + .by_name(&entry_name) + .map_err(|e| format!("open NuGet entry {entry_name}: {e}"))?; + let mut bytes = Vec::new(); + entry + .read_to_end(&mut bytes) + .map_err(|e| format!("read NuGet entry {entry_name}: {e}"))?; + Ok(bytes) + } + + fn target_profile_dir() -> Result { + let out_dir = PathBuf::from( + std::env::var("OUT_DIR").map_err(|e| format!("OUT_DIR is not set: {e}"))?, + ); + out_dir + .parent() + .and_then(|path| path.parent()) + .and_then(|path| path.parent()) + .map(PathBuf::from) + .ok_or_else(|| { + format!( + "cannot resolve Cargo profile directory from {}", + out_dir.display() + ) + }) + } + + fn mxc_nuget_cache_root() -> Result { + Ok(target_profile_dir()?.join(".mxc-nuget").join("packages")) + } + + fn restore_package() -> Result<(), String> { + let manifest_dir = PathBuf::from( + std::env::var("CARGO_MANIFEST_DIR") + .map_err(|e| format!("CARGO_MANIFEST_DIR is not set: {e}"))?, + ); + let restore_inputs = restore_inputs_dir(&manifest_dir)?; + let restore_project = restore_inputs.join(RESTORE_PROJECT); + let nuget_config = restore_inputs.join(NUGET_CONFIG); + let cache_root = target_profile_dir()?.join(".mxc-nuget"); + let packages_root = cache_root.join("packages"); + let restore_output = PathBuf::from( + std::env::var("OUT_DIR").map_err(|e| format!("OUT_DIR is not set: {e}"))?, + ) + .join("isolation-session-nuget-obj"); + + println!("cargo:rerun-if-changed={}", restore_project.display()); + println!("cargo:rerun-if-changed={}", nuget_config.display()); + + std::fs::create_dir_all(&cache_root).map_err(|e| { + format!( + "create IsolationSession NuGet restore directory {}: {e}", + cache_root.display() + ) + })?; + + let output = Command::new("dotnet") + .arg("restore") + .arg(&restore_project) + .arg("--configfile") + .arg(&nuget_config) + .arg("--packages") + .arg(&packages_root) + .arg(format!( + "--property:RestoreOutputPath={}", + restore_output.display() + )) + .arg(format!( + "--property:IsolationSessionSdkVersion={PACKAGE_VERSION}" + )) + .args(["--nologo", "--verbosity", "minimal"]) + .output() + .map_err(|e| { + format!( + "launch dotnet restore for {PACKAGE_ID} {PACKAGE_VERSION}: {e}; \ + set {PACKAGE_PATH_ENV} to a pre-fetched package if dotnet is unavailable" + ) + })?; + + if output.status.success() { + return Ok(()); + } + + Err(format!( + "restore {PACKAGE_ID} {PACKAGE_VERSION} through {} failed with {}:\n{}\n{}\ + \nSet {PACKAGE_PATH_ENV} to a pre-fetched package for an offline build.", + nuget_config.display(), + output.status, + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim() + )) + } + + fn restore_inputs_dir(manifest_dir: &Path) -> Result { + for ancestor in manifest_dir.ancestors() { + for relative in [ + Path::new("build").join("isolation_session_bindings"), + Path::new("mxc-sdk") + .join("build") + .join("isolation_session_bindings"), + ] { + let candidate = ancestor.join(relative); + if candidate.join(RESTORE_PROJECT).is_file() + && candidate.join(NUGET_CONFIG).is_file() + { + return Ok(candidate); + } + } + } + + Err(format!( + "cannot locate the shared IsolationSession NuGet restore inputs from {}", + manifest_dir.display() + )) + } + + fn verify_package(path: &Path) -> Result<(), String> { + let bytes = std::fs::read(path) + .map_err(|e| format!("read IsolationSession SDK package {}: {e}", path.display()))?; + let actual = Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + if actual.eq_ignore_ascii_case(PACKAGE_SHA256) { + Ok(()) + } else { + Err(format!( + "IsolationSession SDK package hash mismatch for {}: expected {}, got {}", + path.display(), + PACKAGE_SHA256, + actual + )) + } + } +} + /// Embed Windows VersionInfo resource metadata into the binary being compiled. /// /// On non-Windows hosts and when building non-Windows targets on a Windows host @@ -17,6 +249,26 @@ use std::process::Command; /// The `ProductVersion` field is set to `+` /// so that every build encodes the exact source commit. pub fn embed_version_info(file_description: &str, original_filename: &str) { + embed_version_info_with_manifest(file_description, original_filename, None); +} + +/// Like [`embed_version_info`], but also fuses a side-by-side application +/// manifest into the binary in the **same** resource compile. +/// +/// `manifest_xml`, when `Some`, is the full text of an application manifest +/// (e.g. an assembly manifest carrying `` reg-free COM redirections). +/// It MUST be embedded in the same `winresource` compile as the version info: +/// two separate `.compile()` calls each emit a `.res` and the second linker +/// input silently clobbers the first, so the manifest and the version info have +/// to share one invocation. +/// +/// On non-Windows hosts / targets the manifest is ignored (there is no PE +/// manifest resource to fuse), matching [`embed_version_info`]'s no-op shape. +pub fn embed_version_info_with_manifest( + file_description: &str, + original_filename: &str, + manifest_xml: Option<&str>, +) { println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=Cargo.toml"); @@ -26,14 +278,14 @@ pub fn embed_version_info(file_description: &str, original_filename: &str) { #[cfg(windows)] { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { - embed_version_info_windows(file_description, original_filename); + embed_version_info_windows(file_description, original_filename, manifest_xml); } } // Suppress unused-variable warnings on non-Windows. #[cfg(not(windows))] { - let _ = (file_description, original_filename); + let _ = (file_description, original_filename, manifest_xml); } } @@ -64,8 +316,15 @@ pub fn embed_version_info_for_binary( } #[cfg(windows)] -fn embed_version_info_windows(file_description: &str, original_filename: &str) { - let resource = version_resource(file_description, original_filename); +fn embed_version_info_windows( + file_description: &str, + original_filename: &str, + manifest_xml: Option<&str>, +) { + let mut resource = version_resource(file_description, original_filename); + if let Some(xml) = manifest_xml { + resource.set_manifest(xml); + } resource .compile() .expect("failed to embed Windows version info"); diff --git a/src/mxc-sdk/build/isolation_session_bindings/IsolationSessionSdk.Restore.csproj b/src/mxc-sdk/build/isolation_session_bindings/IsolationSessionSdk.Restore.csproj new file mode 100644 index 000000000..3303ac9a2 --- /dev/null +++ b/src/mxc-sdk/build/isolation_session_bindings/IsolationSessionSdk.Restore.csproj @@ -0,0 +1,12 @@ + + + net10.0 + true + false + + + + + + diff --git a/src/mxc-sdk/build/isolation_session_bindings/LIFTED_SDK_INFO.toml b/src/mxc-sdk/build/isolation_session_bindings/LIFTED_SDK_INFO.toml new file mode 100644 index 000000000..4119bc498 --- /dev/null +++ b/src/mxc-sdk/build/isolation_session_bindings/LIFTED_SDK_INFO.toml @@ -0,0 +1,18 @@ +# Provenance for the lifted IsolationSession SDK package. +# +# Bindings are regenerated at build time from the Preview WinMD in the pinned +# SDK package. Run Update-IsoSessionSdk.ps1 rather than editing this file. + +[tool] +windows_bindgen_version = "0.62.1" +target_windows_crate = "0.62" +generated_date = "2026-10-06" + +[source] +nupkg = "Microsoft.AI.IsolationSession.SDK.0.202610.5.nupkg" +nupkg_sha256 = "b387c9d11808bf8864d3d7e4d6924f79bdcd6e7c4c54c4e2e49ab0e3525b3d2e" +winmd = "metadata/windows.ai.isolationsession.preview.winmd" +winmd_sha256 = "C6115B52525B06316BE77BEEB4FC4624C5CBB1CEC02E5493D86C46D90FABD6A9" +namespace = "Windows.AI.IsolationSession.Preview" +runtime_version = "2026_10" +build_guid = "e5ad0558-b9a6-a19a-f50e-cc2ebcaccf65" diff --git a/src/mxc-sdk/build/isolation_session_bindings/NuGet.Config b/src/mxc-sdk/build/isolation_session_bindings/NuGet.Config new file mode 100644 index 000000000..7563711b4 --- /dev/null +++ b/src/mxc-sdk/build/isolation_session_bindings/NuGet.Config @@ -0,0 +1,8 @@ + + + + + + + diff --git a/src/mxc-sdk/build/isolation_session_bindings/README.md b/src/mxc-sdk/build/isolation_session_bindings/README.md index 37c09c55e..0153b8867 100644 --- a/src/mxc-sdk/build/isolation_session_bindings/README.md +++ b/src/mxc-sdk/build/isolation_session_bindings/README.md @@ -1,18 +1,85 @@ -# Windows.AI.IsolationSession — WinMD Provenance +# Windows.AI.IsolationSession SDK selection -> **Audience:** MXC developers +MXC supports two explicit IsolationSession build modes: -This directory tracks provenance for the generated Rust bindings in -`src/mxc-sdk/src/backends/isolation_session/bindings/`. The WinMD file itself is NOT checked in. +| Cargo feature | Metadata and runtime | +|---|---| +| `isolation_session` | Uses the committed OS-generated Rust bindings and normal inbox WinRT activation. | +| `isolation_session_lifted` | Restores the pinned SDK package through the configured `MxcDependencies` feed, generates bindings from its Preview WinMD, and stages its lifted activation payload. This feature implies `isolation_session`. | -## Source +The repository does not contain the SDK `.nupkg`. Lifted builds run normal +`dotnet restore` using the checked-in `NuGet.Config`, which clears implicit +sources and selects the `MxcDependencies` feed. NuGet restores the exact package +version pinned in `src/mxc-sdk/build/build_mxc_build_common.rs` into the +Cargo target profile's `.mxc-nuget/packages` directory. MXC then independently +verifies the restored `.nupkg` SHA-256 before reading it. -The WinMD file is produced by an internal Microsoft Windows OS build and is -not publicly redistributable. +Set `ISOLATION_SESSION_SDK_PACKAGE` to use a local package without restoring. +The local package must still match the pinned version, contents, and SHA-256. +Normal builds never contact NuGet.org directly. -## Version Coupling +Before updating the pin, seed both the package and locked Cargo dependencies +into the configured feeds by running the `MXC-Update-Feed-Dependencies` +pipeline with this PR number. Builds without the local override fail until the +new package version is available from `MxcDependencies`. -The generated bindings depend on the `windows` crate at the version specified in -`GENERATION_INFO.toml` (`target_windows_crate`). If the workspace upgrades the -`windows` crate, `mxc-sdk`'s build script will fail with an actionable error -message instructing you to regenerate. +## Runtime prerequisite + +Lifted binaries bind the version-pinned runtime installed by the +IsolationSession MSI. Install the matching release with: + +```powershell +winget install Microsoft.AI.IsolationSession +``` + +The build stages `IsoSessionApp.dll` and `IsoSession.manifest` beside +the current MXC native module. MXC never falls back to the inbox runtime +when the payload or MSI is missing; it reports `BackendUnavailable` with this +remediation. + +The published package contains one AMD64 `IsoSessionApp.dll`, so lifted mode +currently supports only x64 (`x86_64-pc-windows-msvc` / `win-x64`). Inbox mode +continues to support both Windows architectures. Lifted ARM64 and multi-RID +builds are rejected before compilation or packaging. + +## Build commands + +From `src`: + +```powershell +# Existing inbox OS behavior +cargo build --release -p wxc --features isolation_session + +# Lifted SDK NuGet and MSI behavior +cargo build --release -p wxc --features isolation_session_lifted +``` + +The same features exist on `mxc-sdk` and `mxc_ffi`. From the repository root, +`build.bat --with-isolation-session` builds inbox mode and +`build.bat --with-isolation-session-lifted` builds lifted mode and copies the +payload into the Node and .NET SDK runtimes. The .NET SDK uses +`-p:MxcWithIsolationSession=true` (inbox) or `-p:MxcIsolationSessionLifted=true` +(lifted). + +Lifted mode fails the build if package download, integrity validation, metadata +generation, activation-payload staging, or native payload architecture +validation fails. It never silently produces an inbox-mode binary or package a +shim for the wrong target architecture. + +## Updating the lifted SDK + +1. Run `Update-IsoSessionSdk.ps1 -PackagePath `. The script + validates the package, updates `PACKAGE_VERSION` and `PACKAGE_SHA256`, and + regenerates `LIFTED_SDK_INFO.toml` without copying the package into Git. +2. Run the dependency-feed update pipeline, then build and test both feature + configurations. Set `ISOLATION_SESSION_SDK_PACKAGE` to the validated local + package while feed seeding is pending. + +`windows-bindgen` is pinned to `=0.62.1` in `src/mxc-sdk/Cargo.toml` +and must stay in lockstep with the workspace `windows` crate's major.minor +(`target_windows_crate` in both provenance files). If you bump the `windows` +crate, bump `windows-bindgen` to the matching version in the same change. + +`GENERATION_INFO.toml` describes the committed inbox projection and changes +only when those bindings are regenerated. `LIFTED_SDK_INFO.toml` describes the +pinned lifted package and is updated by `Update-IsoSessionSdk.ps1`. diff --git a/src/mxc-sdk/build/isolation_session_bindings/Update-IsoSessionSdk.ps1 b/src/mxc-sdk/build/isolation_session_bindings/Update-IsoSessionSdk.ps1 new file mode 100644 index 000000000..e6a7d744e --- /dev/null +++ b/src/mxc-sdk/build/isolation_session_bindings/Update-IsoSessionSdk.ps1 @@ -0,0 +1,312 @@ +#Requires -Version 5.1 + +<# +.SYNOPSIS + Updates MXC's pinned IsolationSession SDK package metadata. + +.DESCRIPTION + Validates the package payload and release identity, updates MXC's package + version and SHA-256 pin, and regenerates LIFTED_SDK_INFO.toml. The package + itself is not copied into the repository. The script fails before modifying + tracked files when the package is incomplete or internally inconsistent. + +.PARAMETER PackagePath + Path to Microsoft.AI.IsolationSession.SDK..nupkg. + +.PARAMETER DestinationDirectory + MXC's src\mxc-sdk\build\isolation_session_bindings directory. Defaults to + the directory containing this script. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] $PackagePath, + + [string] $DestinationDirectory = $PSScriptRoot +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$package = Get-Item -LiteralPath $PackagePath -ErrorAction Stop +if ($package.Extension -ine '.nupkg') { + throw "PackagePath must identify a .nupkg file: '$PackagePath'." +} + +if (-not (Test-Path -LiteralPath $DestinationDirectory -PathType Container)) { + throw "DestinationDirectory does not exist: '$DestinationDirectory'." +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem + +function Get-ZipEntryBytes { + param( + [Parameter(Mandatory = $true)] + [System.IO.Compression.ZipArchive] $Archive, + + [Parameter(Mandatory = $true)] + [string] $EntryName + ) + + $entry = $Archive.Entries | + Where-Object { $_.FullName.Replace('\', '/') -ieq $EntryName } | + Select-Object -First 1 + if (-not $entry) { + throw "Package is missing required entry '$EntryName'." + } + + $stream = $entry.Open() + try { + $memory = [System.IO.MemoryStream]::new() + try { + $stream.CopyTo($memory) + return $memory.ToArray() + } + finally { + $memory.Dispose() + } + } + finally { + $stream.Dispose() + } +} + +function Convert-BytesToText { + param([byte[]] $Bytes) + + return [System.Text.UTF8Encoding]::new($true).GetString($Bytes).TrimStart([char]0xFEFF) +} + +function Get-TomlString { + param( + [string] $Content, + [string[]] $Names, + [switch] $Optional + ) + + foreach ($name in $Names) { + $match = [regex]::Match( + $Content, + "(?m)^\s*$([regex]::Escape($name))\s*=\s*`"([^`"]*)`"\s*$") + if ($match.Success) { + return $match.Groups[1].Value + } + } + + if ($Optional) { + return '' + } + + throw "Package GENERATION_INFO.toml is missing '$($Names -join "' or '")'." +} + +function Get-Sha256 { + param([byte[]] $Bytes) + + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha256.ComputeHash($Bytes))).Replace('-', '') + } + finally { + $sha256.Dispose() + } +} + +$archive = [System.IO.Compression.ZipFile]::OpenRead($package.FullName) +try { + $nuspecEntry = $archive.Entries | + Where-Object { $_.FullName -like '*.nuspec' } | + Select-Object -First 1 + if (-not $nuspecEntry) { + throw 'Package is missing its .nuspec metadata.' + } + + $nuspecBytes = Get-ZipEntryBytes -Archive $archive -EntryName $nuspecEntry.FullName + [xml] $nuspec = Convert-BytesToText -Bytes $nuspecBytes + $packageId = [string] $nuspec.package.metadata.id + $packageVersion = [string] $nuspec.package.metadata.version + if ($packageId -ne 'Microsoft.AI.IsolationSession.SDK') { + throw "Unexpected package id '$packageId'." + } + if ($packageVersion -notmatch '^0\.(20\d{2})(0[1-9]|1[0-2])\.(\d+)$') { + throw "Unexpected package version '$packageVersion'; expected 0.YYYYMM.patch." + } + + $year = $Matches[1] + $yearShort = $year.Substring($year.Length - 2) + $month = $Matches[2] + $runtimeVersionExpected = "20${yearShort}_$month" + $canonicalFileName = "$packageId.$packageVersion.nupkg" + + $requiredEntries = @( + 'metadata/windows.ai.isolationsession.winmd', + 'metadata/windows.ai.isolationsession.preview.winmd', + 'metadata/GENERATION_INFO.toml', + 'runtime/IsoSessionApp.dll', + 'runtime/IsoSession.manifest' + ) + foreach ($entryName in $requiredEntries) { + [void] (Get-ZipEntryBytes -Archive $archive -EntryName $entryName) + } + + $runtimeVersion = $runtimeVersionExpected + $runtimeInstance = $runtimeVersion.Replace('_', '.') + $runtimeManifest = Convert-BytesToText ( + Get-ZipEntryBytes -Archive $archive -EntryName 'runtime/IsoSession.manifest') + if ($runtimeManifest -match '\$\(MonthId\)' -or + $runtimeManifest -notmatch [regex]::Escape("name=`"$runtimeInstance`"")) { + throw "Runtime manifest does not match package version '$packageVersion' (expected instance '$runtimeInstance')." + } + foreach ($requiredFragment in @( + ' String { + format!( + "{}.{}.nupkg", + package_id.to_ascii_lowercase(), + package_version + ) +} + +pub(super) fn package_runtime_instance(package_version: &str) -> Result { + let mut parts = package_version.split('.'); + let prefix = parts.next(); + let release = parts.next(); + let patch = parts.next(); + if prefix != Some("0") + || parts.next().is_some() + || !patch.is_some_and(|value| value.bytes().all(|byte| byte.is_ascii_digit())) + || !release.is_some_and(|value| { + value.len() == 6 && value.bytes().all(|byte| byte.is_ascii_digit()) + }) + { + return Err(format!( + "IsolationSession SDK package version must use 0.YYYYMM.patch, got {package_version:?}" + )); + } + + let release = release.unwrap(); + Ok(format!("{}.{}", &release[..4], &release[4..])) +} + +pub(super) fn validate_runtime_manifest(manifest: &[u8], instance: &str) -> Result<(), String> { + let content = std::str::from_utf8(manifest) + .map_err(|e| format!("{RUNTIME_MANIFEST} is not valid UTF-8: {e}"))?; + for required in [ + " Result<(), String> { + if target_arch != "x86_64" { + return Err(format!( + "IsolationSession lifted runtime supports only target architecture \"x86_64\"; \ + the pinned SDK package contains one AMD64 {APP_DLL}, so {target_arch:?} cannot be \ + built until an architecture-specific payload is published" + )); + } + + if binary.len() < 0x40 || &binary[..2] != b"MZ" { + return Err(format!("{APP_DLL} is not a valid PE image")); + } + let pe_offset = u32::from_le_bytes( + binary[0x3c..0x40] + .try_into() + .expect("slice length is checked"), + ) as usize; + let machine_end = pe_offset + .checked_add(6) + .ok_or_else(|| format!("{APP_DLL} has an invalid PE header offset"))?; + if machine_end > binary.len() || &binary[pe_offset..pe_offset + 4] != b"PE\0\0" { + return Err(format!("{APP_DLL} has an invalid PE header")); + } + + let actual_machine = u16::from_le_bytes( + binary[pe_offset + 4..machine_end] + .try_into() + .expect("slice length is checked"), + ); + if actual_machine != IMAGE_FILE_MACHINE_AMD64 { + return Err(format!( + "{APP_DLL} machine type 0x{actual_machine:04X} does not match Cargo target \ + architecture \"x86_64\" (expected 0x{IMAGE_FILE_MACHINE_AMD64:04X})" + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + package_file_name, package_runtime_instance, validate_runtime_architecture, + validate_runtime_manifest, IMAGE_FILE_MACHINE_AMD64, + }; + + const MANIFEST: &str = "\ + + + + +"; + + #[test] + fn package_version_maps_to_runtime_instance() { + assert_eq!( + package_runtime_instance("0.202610.5").unwrap(), + "2026.10" + ); + } + + #[test] + fn package_file_name_uses_nuget_packages_layout() { + assert_eq!( + package_file_name("Microsoft.AI.IsolationSession.SDK", "0.202610.5"), + "microsoft.ai.isolationsession.sdk.0.202610.5.nupkg" + ); + } + + #[test] + fn completed_runtime_manifest_is_accepted() { + validate_runtime_manifest(MANIFEST.as_bytes(), "2026.10").unwrap(); + } + + #[test] + fn mismatched_runtime_manifest_is_rejected() { + let error = validate_runtime_manifest(MANIFEST.as_bytes(), "2026.11").unwrap_err(); + assert!(error.contains("does not identify runtime instance")); + } + + fn pe_image(machine: u16) -> Vec { + let mut image = vec![0; 0x80]; + image[..2].copy_from_slice(b"MZ"); + image[0x3c..0x40].copy_from_slice(&0x40u32.to_le_bytes()); + image[0x40..0x44].copy_from_slice(b"PE\0\0"); + image[0x44..0x46].copy_from_slice(&machine.to_le_bytes()); + image + } + + #[test] + fn runtime_architecture_accepts_amd64_for_x86_64() { + validate_runtime_architecture(&pe_image(IMAGE_FILE_MACHINE_AMD64), "x86_64").unwrap(); + } + + #[test] + fn runtime_architecture_rejects_non_amd64_payload() { + let error = validate_runtime_architecture(&pe_image(0xAA64), "x86_64").unwrap_err(); + assert!(error.contains("does not match Cargo target architecture")); + } + + #[test] + fn runtime_architecture_rejects_unsupported_target() { + let error = + validate_runtime_architecture(&pe_image(IMAGE_FILE_MACHINE_AMD64), "aarch64") + .unwrap_err(); + assert!(error.contains("supports only target architecture")); + } + + #[test] + fn runtime_architecture_rejects_invalid_pe() { + let error = validate_runtime_architecture(b"not a PE", "x86_64").unwrap_err(); + assert!(error.contains("not a valid PE image")); + } + + #[test] + fn runtime_architecture_rejects_truncated_pe_header() { + let mut image = pe_image(IMAGE_FILE_MACHINE_AMD64); + image.truncate(0x44); + + let error = validate_runtime_architecture(&image, "x86_64").unwrap_err(); + assert!(error.contains("invalid PE header")); + } +} diff --git a/src/mxc-sdk/src/backends/isolation_session/bindings/mod.rs b/src/mxc-sdk/src/backends/isolation_session/bindings/mod.rs index c7c7076b3..a99fff2e7 100644 --- a/src/mxc-sdk/src/backends/isolation_session/bindings/mod.rs +++ b/src/mxc-sdk/src/backends/isolation_session/bindings/mod.rs @@ -3,11 +3,13 @@ //! Generated WinRT bindings for the IsolationSession Preview API. //! -//! This crate contains Rust projections generated from the -//! `Windows.AI.IsolationSession.Preview` WinMD using `windows-bindgen`. +//! Inbox builds use the committed OS-generated projection. Builds with the +//! `isolation_session_lifted` feature regenerate the projection from the pinned SDK package +//! restored through the configured MxcDependencies feed into an MXC-owned cache. //! //! See `build/isolation_session_bindings/GENERATION_INFO.toml` -//! for provenance details. +//! for inbox-binding provenance and `LIFTED_SDK_INFO.toml` for lifted-package +//! provenance. #[allow( non_snake_case, @@ -16,4 +18,13 @@ dead_code, clippy::all )] +#[cfg(feature = "isolation_session_lifted")] +pub mod bindings { + // Generated into OUT_DIR by build.rs from the SDK nuget's Preview WinMD. + // The outer #[allow] above covers the generated items (the file's own + // inner #![allow] does not propagate through include! into this module). + include!(concat!(env!("OUT_DIR"), "/bindings.rs")); +} + +#[cfg(not(feature = "isolation_session_lifted"))] pub mod bindings; diff --git a/src/mxc-sdk/src/backends/isolation_session/common/availability.rs b/src/mxc-sdk/src/backends/isolation_session/common/availability.rs index 7c4fd1c95..ae3128ce1 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/availability.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/availability.rs @@ -10,6 +10,8 @@ use std::sync::OnceLock; use crate::isolation_session_bindings::bindings::{IsoSessionFeature, IsoSessionOps}; +#[cfg(feature = "isolation_session_lifted")] +use windows::Win32::Foundation::REGDB_E_CLASSNOTREG; use windows_core::HRESULT; use super::owned_thread; @@ -37,9 +39,35 @@ fn available_from(probe: Result) -> bool { } fn probe_feature_level() -> Result { + // Report unavailable if the framework runtime is not verified, so callers + // never attempt a session they cannot complete. + #[cfg(feature = "isolation_session_lifted")] + if super::regfree::verify_framework().is_err() { + return Err(REGDB_E_CLASSNOTREG); + } + + #[cfg(feature = "isolation_session_lifted")] + let ops = match super::regfree::activate_from_adjacent_shim::() { + Some(result) => result.map_err(|error| { + eprintln!( + "[mxc isosession] lifted IsoSessionOps activation failed: {}", + error + ); + error.code() + })?, + None => return Err(REGDB_E_CLASSNOTREG), + }; + #[cfg(not(feature = "isolation_session_lifted"))] let ops = IsoSessionOps::new().map_err(|e| e.code())?; + ops.GetFeatureLevel(IsoSessionFeature::LocalAgentUser) - .map_err(|e| e.code()) + .map_err(|error| { + eprintln!( + "[mxc isosession] GetFeatureLevel(LocalAgentUser) failed: {}", + error + ); + error.code() + }) } #[cfg(test)] diff --git a/src/mxc-sdk/src/backends/isolation_session/common/error.rs b/src/mxc-sdk/src/backends/isolation_session/common/error.rs index ad6e3c9bf..c9ef30e0b 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/error.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/error.rs @@ -45,6 +45,11 @@ pub(super) mod op { pub(crate) const OPTIONS_REDIRECT_STDERR: &str = "IsoSessionProcessOptions.SetRedirectStandardError"; pub(crate) const OPTIONS_ENVIRONMENT: &str = "IsoSessionProcessOptions.Environment"; + + /// The staged shim's runtime-verification export, called before any session + /// work to confirm the IsolationSession framework is installed. + #[cfg(feature = "isolation_session_lifted")] + pub(crate) const VERIFY_FRAMEWORK: &str = "IsoSessionApp.VerifyIsoSessionFramework"; } /// `HRESULT_FROM_WIN32(ERROR_NOT_FOUND)`. Every non-provision lifecycle op @@ -61,6 +66,12 @@ const CLASS_E_CLASSNOTAVAILABLE_HRESULT: u32 = 0x80040111; /// `REGDB_E_CLASSNOTREG` — the runtime class is not registered at all. const REGDB_E_CLASSNOTREG_HRESULT: u32 = 0x80040154; +/// `E_NOINTERFACE` — the activator produced an object that does not implement +/// the requested interface. In lifted mode this is the signature of a +/// WinMD/runtime version-pin mismatch. Inbox activation does not have enough +/// information to make that diagnosis. See [`activation_error`]. +const E_NOINTERFACE_HRESULT: u32 = 0x80004002; + /// Renders an HRESULT for the wire `nativeCode` field. fn format_native_code(code: u32) -> String { format!("{code:#010x}") @@ -307,6 +318,22 @@ pub(super) fn activation_error(code: u32, detail: &str) -> IsolationSessionError "the in-proc IsolationSession runtime API is not available on this OS build. Ensure \ the OS feature gate is enabled and the platform supports isolation sessions." .to_string() + } else if code == E_NOINTERFACE_HRESULT { + #[cfg(feature = "isolation_session_lifted")] + { + "the co-located IsoSessionApp.dll activated but returned an object that does not \ + implement the expected IsolationSession interface. This is the classic WinMD/MSI \ + version-pin mismatch: the Preview WinMD the current MXC native module was built \ + against and the \ + MSI-installed IsolationSession runtime were produced from different OS versions, \ + so their interface IIDs differ. Rebuild the MSI and the \ + Microsoft.AI.IsolationSession.SDK NuGet from the same OS commit." + .to_string() + } + #[cfg(not(feature = "isolation_session_lifted"))] + { + format!("IsolationSession runtime API activation failed: {detail}") + } } else { format!("IsolationSession runtime API activation failed: {detail}") }; @@ -332,6 +359,56 @@ pub(super) fn identity_refusal(err: windows_core::Error) -> IsolationSessionErro }) } +/// The adjacent lifted activation payload is missing, so the version-pinned +/// MSI-installed IsolationSession runtime cannot be bound. +/// +/// Raised for the `None` arm of +/// [`super::regfree::activate_from_adjacent_shim`]. MXC deliberately does +/// **not** fall back to the inbox `System32` runtime here — silently binding a +/// different, unversioned binary set is exactly the failure mode this design +/// exists to prevent — so the missing payload surfaces as a hard, +/// actionable error instead. +#[cfg(feature = "isolation_session_lifted")] +pub(super) fn lifted_payload_missing(operation: &str) -> IsolationSessionError { + IsolationSessionError::ServiceUnavailable(IsoApiFailure::new( + operation, + Some(REGDB_E_CLASSNOTREG_HRESULT), + Some( + "IsolationSession lifted activation was not taken: IsoSessionApp.dll and \ + IsoSession.manifest are not co-located with this module or executable, or the \ + shim reported the runtime class as not registered, so the version-pinned \ + MSI-installed IsolationSession runtime could not be bound. MXC will \ + not silently fall back to the inbox System32 runtime." + .to_string(), + ), + Some( + "Rebuild against the Microsoft.AI.IsolationSession.SDK NuGet so \ + IsoSessionApp.dll and its stamped IsoSession.manifest are staged beside the host, \ + and install the matching runtime MSI (`winget install Microsoft.AI.IsolationSession`)." + .to_string(), + ), + )) +} + +/// The staged shim reports that the IsolationSession framework runtime is not +/// installed (or could not confirm it is), so no session work may proceed. +/// +/// The refusal's concise problem statement becomes the failure `message` (so it +/// surfaces in the one-shot `ScriptResponse` string and as the state-aware +/// `MxcError` message), and the shim's ready-to-display fix text, when present, +/// becomes the `remediation`. +#[cfg(feature = "isolation_session_lifted")] +pub(super) fn framework_unavailable( + refusal: super::regfree::FrameworkRefusal, +) -> IsolationSessionError { + IsolationSessionError::ServiceUnavailable(IsoApiFailure::new( + op::VERIFY_FRAMEWORK, + None, + Some(refusal.message), + refusal.remediation, + )) +} + /// Whether an `ERROR_NOT_FOUND` from this operation means "the sandbox is /// gone". #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -806,6 +883,50 @@ mod tests { assert!(mapped.message.contains("catastrophic failure")); } + /// `E_NOINTERFACE` from lifted activation is the WinMD/MSI version-pin mismatch + /// signature: the mapping must name that cause rather than echo the bare + /// COM detail, so the message points at rebuilding both from one commit. + #[cfg(feature = "isolation_session_lifted")] + #[test] + fn e_nointerface_activation_names_version_pin_mismatch() { + let mapped = map_lifecycle_error(activation_error(E_NOINTERFACE_HRESULT, "ignored")); + assert_eq!(mapped.code, MxcErrorCode::BackendUnavailable); + assert_eq!(mapped.native_code(), Some("0x80004002")); + assert!(mapped.message.contains("version-pin mismatch")); + assert!(mapped.message.contains("same OS commit")); + } + + #[cfg(not(feature = "isolation_session_lifted"))] + #[test] + fn inbox_e_nointerface_activation_preserves_platform_detail() { + let mapped = map_lifecycle_error(activation_error( + E_NOINTERFACE_HRESULT, + "platform activation detail", + )); + assert_eq!(mapped.code, MxcErrorCode::BackendUnavailable); + assert_eq!(mapped.native_code(), Some("0x80004002")); + assert!(mapped.message.contains("platform activation detail")); + assert!(!mapped.message.contains("version-pin mismatch")); + } + + /// The hard error raised when the lifted payload is absent must carry the + /// class-not-registered code, an operation, a message that refuses the + /// inbox fallback, and an actionable remediation. + #[cfg(feature = "isolation_session_lifted")] + #[test] + fn lifted_payload_missing_is_a_hard_actionable_error() { + let mapped = map_lifecycle_error(lifted_payload_missing(op::ACTIVATE)); + assert_eq!(mapped.code, MxcErrorCode::BackendUnavailable); + assert_eq!(mapped.operation(), Some("IsoSessionOps.ActivateInstance")); + assert_eq!(mapped.native_code(), Some("0x80040154")); + assert!(mapped.message.contains("not silently fall back")); + assert!(mapped + .remediation + .as_deref() + .is_some_and(|r| r.contains("IsolationSession.SDK NuGet") + && r.contains("winget install Microsoft.AI.IsolationSession"))); + } + #[test] fn mxc_internal_failure_carries_no_structured_fields() { let mapped = map_lifecycle_error(lifecycle_err("create stdout relay: out of memory")); diff --git a/src/mxc-sdk/src/backends/isolation_session/common/manager.rs b/src/mxc-sdk/src/backends/isolation_session/common/manager.rs index 7ec4c8b79..b1b7a63dc 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/manager.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/manager.rs @@ -33,6 +33,8 @@ use super::error::{ activation_error, check_result, format_iso_error, lifecycle_err, op, transport_err, IsolationSessionError, StalePromotion, }; +#[cfg(feature = "isolation_session_lifted")] +use super::error::{framework_unavailable, lifted_payload_missing}; use super::owned_thread::{self, Impersonation}; use super::pipe_relay::{ create_relay_thread, create_relay_thread_with_stop, duplicate_handle, PipeRelayWithStopParams, @@ -81,17 +83,37 @@ unsafe impl Send for MtaReference {} unsafe impl Sync for MtaReference {} /// Activates the in-proc IsolationSession runtime factory and returns the -/// instance. +/// instance. Activation goes through the `DllGetActivationFactory` export of +/// the co-located `IsoSessionApp.dll` shim (see [`super::regfree`]), binding +/// the version-pinned MSI-installed runtime. +/// +/// There is **no inbox fallback**: if the lifted payload is not staged beside +/// this module or executable, this returns a hard [`lifted_payload_missing`] +/// error rather than silently binding the inbox `System32` runtime. +#[cfg(feature = "isolation_session_lifted")] fn check_service_available_and_activate() -> Result { - match IsoSessionOps::new() { - Ok(ops) => Ok(ops), + // Refuse before touching the runtime if the framework is not verified. + super::regfree::verify_framework().map_err(framework_unavailable)?; + + match super::regfree::activate_from_adjacent_shim::() { + Some(Ok(ops)) => Ok(ops), // The HRESULT→error mapping lives in `activation_error` so it stays // testable without depending on whether this host can activate the // API at all. - Err(e) => Err(activation_error(e.code().0 as u32, &e.message())), + Some(Err(e)) => Err(activation_error(e.code().0 as u32, &e.message())), + // The lifted payload is absent: refuse to silently bind the inbox + // runtime, and surface an actionable hard error instead. + None => Err(lifted_payload_missing(op::ACTIVATE)), } } +/// Inbox mode: activates the system-registered OS API through normal WinRT +/// activation. +#[cfg(not(feature = "isolation_session_lifted"))] +fn check_service_available_and_activate() -> Result { + IsoSessionOps::new().map_err(|e| activation_error(e.code().0 as u32, &e.message())) +} + /// `GetFeatureLevel` fails on a host that does not recognize the feature /// value, so an error reads as not supported. fn feature_supported_from(level: windows_core::Result) -> bool { @@ -1527,18 +1549,35 @@ mod tests { Err(IsolationSessionError::ServiceUnavailable(failure)) => { // Service is NOT available. Verify the error is clean and // descriptive (not a panic or cryptic COM error), and that - // it names the activation operation it failed on. + // it names the operation it failed on. Lifted builds verify + // the adjacent framework before activation; inbox builds go + // directly to activation. assert!( failure.message.contains("not available") - || failure.message.contains("activation failed"), + || failure.message.contains("activation failed") + || failure.message.contains("lifted activation was not taken") + || failure + .message + .contains("does not support runtime verification") + || failure + .message + .contains("framework verification entry point could not be loaded"), "Expected descriptive error message, got: {}", failure.message ); - assert_eq!(failure.operation, op::ACTIVATE); - assert!( - failure.code.is_some(), - "activation failure carries no HRESULT" - ); + + match failure.operation.as_str() { + #[cfg(feature = "isolation_session_lifted")] + op::VERIFY_FRAMEWORK => assert!( + failure.code.is_none(), + "framework refusal unexpectedly carried an HRESULT" + ), + op::ACTIVATE => assert!( + failure.code.is_some(), + "activation failure carries no HRESULT" + ), + operation => panic!("unexpected availability operation: {operation}"), + } } Err(other) => { panic!("expected ServiceUnavailable variant, got: {:?}", other); diff --git a/src/mxc-sdk/src/backends/isolation_session/common/mod.rs b/src/mxc-sdk/src/backends/isolation_session/common/mod.rs index a07bcf159..6613d4e0e 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/mod.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/mod.rs @@ -41,6 +41,8 @@ mod process_options; mod sandbox; #[cfg(target_os = "windows")] pub use sandbox::{spawn_one_shot, spawn_one_shot_pty, OneShotSpawnFailure}; +#[cfg(all(target_os = "windows", feature = "isolation_session_lifted"))] +mod regfree; #[cfg(target_os = "windows")] mod sandbox_id; #[cfg(target_os = "windows")] diff --git a/src/mxc-sdk/src/backends/isolation_session/common/process_options.rs b/src/mxc-sdk/src/backends/isolation_session/common/process_options.rs index d40558ca9..96024c573 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/process_options.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/process_options.rs @@ -10,6 +10,8 @@ use crate::mxc_common::models::ExecutionRequest; use crate::isolation_session_bindings::bindings::IsoSessionProcessOptions; use windows_core::HSTRING; +#[cfg(feature = "isolation_session_lifted")] +use super::error::lifted_payload_missing; use super::error::{op, transport_err, IsolationSessionError}; const REDIRECT_STDIN: u32 = 0x1; @@ -167,8 +169,7 @@ pub(super) fn with_service_timeout_grace(mut options: ProcessOptions) -> Process pub(super) fn build_iso_process_options( options: &ProcessOptions, ) -> Result { - let proc_options = IsoSessionProcessOptions::new() - .map_err(|e| transport_err(op::OPTIONS_NEW, "activation failed", &e))?; + let proc_options = new_iso_process_options()?; proc_options .SetTimeoutMilliseconds(options.timeout_ms) @@ -215,6 +216,20 @@ pub(super) fn build_iso_process_options( Ok(proc_options) } +#[cfg(feature = "isolation_session_lifted")] +fn new_iso_process_options() -> Result { + match super::regfree::activate_from_adjacent_shim::() { + Some(result) => result.map_err(|e| transport_err(op::OPTIONS_NEW, "activation failed", &e)), + None => Err(lifted_payload_missing(op::OPTIONS_NEW)), + } +} + +#[cfg(not(feature = "isolation_session_lifted"))] +fn new_iso_process_options() -> Result { + IsoSessionProcessOptions::new() + .map_err(|e| transport_err(op::OPTIONS_NEW, "activation failed", &e)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/mxc-sdk/src/backends/isolation_session/common/regfree.rs b/src/mxc-sdk/src/backends/isolation_session/common/regfree.rs new file mode 100644 index 000000000..7c70440c4 --- /dev/null +++ b/src/mxc-sdk/src/backends/isolation_session/common/regfree.rs @@ -0,0 +1,414 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Activation for the lifted IsolationSession runtime. +//! +//! The lifted SDK places `IsoSessionApp.dll` and a stamped +//! `IsoSession.manifest` beside the host module (or executable). The shim exports +//! `DllGetActivationFactory`; loading that export directly prevents the inbox +//! WinRT catalog from shadowing the lifted implementation. + +#![allow(unsafe_code)] + +use std::os::windows::ffi::OsStrExt; +use std::path::PathBuf; +use std::sync::OnceLock; + +use windows::core::{s, HSTRING, PCSTR, PCWSTR, PWSTR}; +use windows::Win32::Foundation::{ + ERROR_NOT_SUPPORTED, ERROR_PROC_NOT_FOUND, REGDB_E_CLASSNOTREG, S_OK, +}; +use windows::Win32::System::Com::{ + CoInitializeEx, CoTaskMemFree, CoUninitialize, COINIT_MULTITHREADED, +}; +use windows::Win32::System::LibraryLoader::{ + GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR, LOAD_LIBRARY_SEARCH_SYSTEM32, +}; +use windows::Win32::System::WinRT::IActivationFactory; +use windows_core::{Interface, RuntimeName, HRESULT}; + +const SHIM_NAME: &str = "IsoSessionApp.dll"; +const MANIFEST_NAME: &str = "IsoSession.manifest"; +const RPC_E_CHANGED_MODE: u32 = 0x8001_0106; + +type DllGetActivationFactory = + unsafe extern "system" fn(*mut std::ffi::c_void, *mut *mut std::ffi::c_void) -> HRESULT; + +static GET_ACTIVATION_FACTORY: OnceLock = OnceLock::new(); + +struct ComApartment; + +impl Drop for ComApartment { + fn drop(&mut self) { + unsafe { CoUninitialize() }; + } +} + +thread_local! { + static COM_APARTMENT: Result, HRESULT> = { + let result = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }; + if result.is_ok() { + Ok(Some(ComApartment)) + } else if result.0 as u32 == RPC_E_CHANGED_MODE { + Ok(None) + } else { + Err(result) + } + }; +} + +/// Flat-C export on the staged shim that answers whether the matching +/// IsolationSession runtime is installed and loadable on this machine. The +/// out-param receives an owned, ready-to-display remediation string (which the +/// caller frees) when it is not; it is left null on success. +type VerifyIsoSessionFramework = unsafe extern "system" fn(*mut *mut u16) -> HRESULT; + +/// Resolved by name, so the name is the contract. +const VERIFY_FRAMEWORK_EXPORT: PCSTR = s!("VerifyIsoSessionFramework"); + +/// Verified once per process: the install state cannot change under us. +static FRAMEWORK_STATUS: OnceLock> = OnceLock::new(); + +/// Why framework verification refused, split to match the wire error fields. +/// +/// `message` is a concise problem statement (what is wrong); `remediation` is +/// the shim's own ready-to-display fix text, present only when the shim +/// supplied one. A synthetic refusal (verification unsupported, or a bare +/// failure status) carries no remediation. +#[derive(Debug, Clone)] +pub(crate) struct FrameworkRefusal { + pub(crate) message: String, + pub(crate) remediation: Option, +} + +impl FrameworkRefusal { + /// A problem statement with no accompanying fix text. + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + remediation: None, + } + } + + /// A problem statement paired with the shim's ready-to-display fix text. + fn with_remediation(message: impl Into, remediation: String) -> Self { + Self { + message: message.into(), + remediation: Some(remediation), + } + } +} + +/// Activates `T` through the lifted shim staged beside the current executable. +/// +/// Returns `None` only when the lifted activation payload is not present. +/// Other failures are returned to the caller and must not fall back to inbox +/// activation because that would silently mix the lifted WinMD with inbox code. +pub(crate) fn activate_from_adjacent_shim() -> Option> +where + T: Interface + RuntimeName, +{ + // Some callers enter through a native thread without initializing COM. + // Keep any initialization owned here alive until that same thread exits, + // then balance it with CoUninitialize. + if let Err(error) = ensure_com_apartment() { + return Some(Err(error)); + } + + let directory = adjacent_runtime_directory()?; + let factory = match load_activation_factory::(&directory.join(SHIM_NAME)) { + Ok(factory) => factory, + Err(error) if error.code() == REGDB_E_CLASSNOTREG => return None, + Err(error) => return Some(Err(error)), + }; + + Some(activate_from_factory(factory)) +} + +fn ensure_com_apartment() -> windows_core::Result<()> { + COM_APARTMENT.with(|apartment| match apartment { + Ok(_) => Ok(()), + Err(result) => Err(windows_core::Error::from_hresult(*result)), + }) +} + +/// Confirms the IsolationSession runtime is installed before any session work. +/// +/// `Ok(())` means proceed; `Err(refusal)` means block, carrying the text to +/// show the user. The result is cached for the process. +/// +/// Only `S_OK` from the shim proceeds; every other outcome is fail-closed. If +/// the shim itself is not staged beside the host, there is nothing to check +/// here and activation raises its own "payload missing" error, so this returns +/// `Ok(())`. +pub(crate) fn verify_framework() -> Result<(), FrameworkRefusal> { + FRAMEWORK_STATUS.get_or_init(run_framework_check).clone() +} + +fn run_framework_check() -> Result<(), FrameworkRefusal> { + let Some(directory) = adjacent_runtime_directory() else { + return Ok(()); + }; + + // The shim is staged beside us, so it must expose the verification entry + // point. If it cannot be loaded, the payload is broken — block. + let shim_verify_fn = resolve_verify_framework(&directory.join(SHIM_NAME)).map_err(|error| { + FrameworkRefusal::new(format!( + "the IsolationSession framework verification entry point could not be loaded: {error}" + )) + })?; + + let mut remediation: *mut u16 = std::ptr::null_mut(); + // SAFETY: the out-param is a valid pointer; per the export's contract we + // own any returned string and must free it with `CoTaskMemFree`. + let hresult = unsafe { shim_verify_fn(&mut remediation) }; + classify_framework_status(hresult, take_cotaskmem_string(remediation)) +} + +/// Pure decision split from the FFI call so it is unit-testable without the +/// shim. When the shim supplies its own fix text, that becomes the refusal's +/// remediation and the message is a concise problem statement; otherwise the +/// message stands alone. +fn classify_framework_status( + hresult: HRESULT, + remediation: Option, +) -> Result<(), FrameworkRefusal> { + if hresult == S_OK { + return Ok(()); + } + if let Some(remediation) = remediation { + return Err(FrameworkRefusal::with_remediation( + "the IsolationSession framework runtime is not installed", + remediation, + )); + } + if hresult == HRESULT::from_win32(ERROR_NOT_SUPPORTED.0) { + return Err(FrameworkRefusal::new( + "the installed IsolationSession framework does not support runtime verification, \ + so MXC cannot confirm the required runtime is present", + )); + } + Err(FrameworkRefusal::new(format!( + "IsolationSession framework verification failed (0x{:08X})", + hresult.0 as u32 + ))) +} + +fn resolve_verify_framework( + dll_path: &std::path::Path, +) -> windows_core::Result { + let source: Vec = dll_path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + // Same absolute-path, constrained-search load used for activation. + let module = unsafe { + LoadLibraryExW( + PCWSTR(source.as_ptr()), + None, + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32, + ) + }?; + let export = unsafe { GetProcAddress(module, VERIFY_FRAMEWORK_EXPORT) }.ok_or_else(|| { + windows_core::Error::from_hresult(HRESULT::from_win32(ERROR_PROC_NOT_FOUND.0)) + })?; + + // The module intentionally stays loaded; the process only verifies once. + let shim_verify_fn: VerifyIsoSessionFramework = unsafe { std::mem::transmute(export) }; + Ok(shim_verify_fn) +} + +/// Reads a shim-allocated wide string into an owned `String`, then frees it +/// with `CoTaskMemFree` as the contract requires. A null pointer yields `None`. +fn take_cotaskmem_string(pointer: *mut u16) -> Option { + if pointer.is_null() { + return None; + } + // SAFETY: the contract hands back a NUL-terminated wide string; we read it + // and then release it with the matching allocator. + let text = unsafe { PWSTR(pointer).to_string() }.ok(); + unsafe { CoTaskMemFree(Some(pointer as *const std::ffi::c_void)) }; + text +} + +fn load_activation_factory( + dll_path: &std::path::Path, +) -> windows_core::Result +where + T: Interface + RuntimeName, +{ + let get_factory = resolve_get_activation_factory(dll_path)?; + + let class_name = HSTRING::from(T::NAME); + // HSTRING is a transparent handle; pass the handle, not its UTF-16 buffer. + let class_name_handle = unsafe { std::mem::transmute_copy(&class_name) }; + let mut factory = std::ptr::null_mut(); + unsafe { get_factory(class_name_handle, &mut factory) }.ok()?; + if factory.is_null() { + return Err(windows_core::Error::from_hresult(HRESULT( + 0x8000_4003u32 as i32, + ))); + } + + // The module intentionally remains loaded so the returned vtable stays valid. + Ok(unsafe { IActivationFactory::from_raw(factory) }) +} + +fn resolve_get_activation_factory( + dll_path: &std::path::Path, +) -> windows_core::Result { + if let Some(get_factory) = GET_ACTIVATION_FACTORY.get() { + return Ok(*get_factory); + } + + let source: Vec = dll_path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + // The absolute path plus constrained dependency search prevents DLL planting. + let module = unsafe { + LoadLibraryExW( + PCWSTR(source.as_ptr()), + None, + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32, + ) + }?; + let export = + unsafe { GetProcAddress(module, s!("DllGetActivationFactory")) }.ok_or_else(|| { + windows_core::Error::from_hresult(HRESULT::from_win32(ERROR_PROC_NOT_FOUND.0)) + })?; + let get_factory: DllGetActivationFactory = unsafe { std::mem::transmute(export) }; + + // A racing activation may load the same module twice, but both handles remain + // valid for the process lifetime and every caller uses the cached export. + let _ = GET_ACTIVATION_FACTORY.set(get_factory); + Ok(*GET_ACTIVATION_FACTORY.get().unwrap_or(&get_factory)) +} + +/// Finds the staged payload beside the module hosting this code first, so +/// in-process hosts (node.exe, dotnet) resolve it beside `mxc_ffi.dll`, then +/// beside the executable or its parent (`target\\deps` tests). +fn adjacent_runtime_directory() -> Option { + let module_directory = current_module_path().and_then(|path| path.parent().map(PathBuf::from)); + let executable_directory = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(PathBuf::from)); + let executable_parent = executable_directory + .as_deref() + .and_then(|directory| directory.parent().map(PathBuf::from)); + + [module_directory, executable_directory, executable_parent] + .into_iter() + .flatten() + .find(|directory| { + directory.join(SHIM_NAME).is_file() && directory.join(MANIFEST_NAME).is_file() + }) +} + +fn current_module_path() -> Option { + use std::os::windows::ffi::OsStringExt; + use windows::Win32::Foundation::HMODULE; + use windows::Win32::System::LibraryLoader::{ + GetModuleFileNameW, GetModuleHandleExW, GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + }; + + let mut module = HMODULE::default(); + let address = current_module_path as *const () as *const u16; + unsafe { + GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + PCWSTR(address), + &mut module, + ) + } + .ok()?; + + let mut buffer = vec![0u16; 1024]; + loop { + let length = unsafe { GetModuleFileNameW(Some(module), &mut buffer) } as usize; + if length == 0 { + return None; + } + if length < buffer.len() { + buffer.truncate(length); + return Some(PathBuf::from(std::ffi::OsString::from_wide(&buffer))); + } + buffer.resize(buffer.len() * 2, 0); + } +} + +fn activate_from_factory(factory: IActivationFactory) -> windows_core::Result +where + T: Interface + RuntimeName, +{ + let instance = unsafe { factory.ActivateInstance() }.map_err(|error| { + eprintln!( + "[mxc isosession] ActivateInstance('{}') failed: {}", + T::NAME, + error + ); + error + })?; + + instance.cast::().map_err(|error| { + eprintln!( + "[mxc isosession] cast to '{}' failed (WinMD/MSI version mismatch?): {}", + T::NAME, + error + ); + error + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use windows::Win32::Foundation::ERROR_MOD_NOT_FOUND; + + #[test] + fn ok_status_proceeds() { + assert!(classify_framework_status(S_OK, None).is_ok()); + } + + #[test] + fn remediation_text_is_surfaced_verbatim() { + // The shim returns this fix text when the runtime is missing; it lands + // in the refusal's remediation, with a concise problem statement. + let fix = "IsoSession isn't found; download from aka.ms/foo".to_string(); + let refusal = classify_framework_status( + HRESULT::from_win32(ERROR_MOD_NOT_FOUND.0), + Some(fix.clone()), + ) + .unwrap_err(); + assert_eq!(refusal.remediation.as_deref(), Some(fix.as_str())); + assert!(refusal.message.contains("not installed")); + } + + #[test] + fn not_supported_is_fail_closed() { + // No fix text accompanies this status; MXC still blocks, message-only. + let refusal = classify_framework_status(HRESULT::from_win32(ERROR_NOT_SUPPORTED.0), None) + .unwrap_err(); + assert!( + refusal.message.contains("does not support"), + "expected the verification-unsupported wording" + ); + assert_eq!(refusal.remediation, None); + } + + #[test] + fn bare_failure_without_remediation_still_blocks() { + // Any other failure with no shim text falls back to a synthetic message. + let refusal = classify_framework_status(HRESULT(0x8000_4005u32 as i32), None).unwrap_err(); + assert!( + refusal.message.contains("verification failed"), + "expected the synthetic failure wording" + ); + assert_eq!(refusal.remediation, None); + } +} diff --git a/src/mxc-sdk/src/core/mxc_common/telemetry/consent.rs b/src/mxc-sdk/src/core/mxc_common/telemetry/consent.rs index 6833e83e8..75ca9d6ec 100644 --- a/src/mxc-sdk/src/core/mxc_common/telemetry/consent.rs +++ b/src/mxc-sdk/src/core/mxc_common/telemetry/consent.rs @@ -1578,7 +1578,7 @@ mod tests { let (sender, receiver) = std::sync::mpsc::channel(); let waker = std::task::Waker::from(std::sync::Arc::new(SignalWaker(sender))); let mut context = std::task::Context::from_waker(&waker); - let mut task = std::pin::pin!(BlockingTask::spawn(|| -> () { panic!("worker panic") })); + let mut task = std::pin::pin!(BlockingTask::spawn(|| panic!("worker panic"))); let propagated = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { if std::future::Future::poll(task.as_mut(), &mut context).is_pending() { diff --git a/src/mxc-sdk/tests/isolation_session_package_validation.rs b/src/mxc-sdk/tests/isolation_session_package_validation.rs new file mode 100644 index 000000000..610cba993 --- /dev/null +++ b/src/mxc-sdk/tests/isolation_session_package_validation.rs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod package_validation { + include!("../build/isolation_session_bindings/package_validation.rs"); +} diff --git a/src/tools/wxc/Cargo.toml b/src/tools/wxc/Cargo.toml index d76060083..e425c2489 100644 --- a/src/tools/wxc/Cargo.toml +++ b/src/tools/wxc/Cargo.toml @@ -26,6 +26,8 @@ winresource.workspace = true [build-dependencies] serde.workspace = true serde_json.workspace = true +sha2.workspace = true +zip = { version = "2", default-features = false, features = ["deflate"] } [features] default = [] @@ -34,5 +36,6 @@ microvm = ["mxc-sdk/microvm"] wslc = ["mxc-sdk/wslc"] hyperlight = ["mxc-sdk/hyperlight"] isolation_session = ["mxc-sdk/isolation_session"] +isolation_session_lifted = ["isolation_session", "mxc-sdk/isolation_session_lifted"] force-tier-testing = ["mxc-sdk/force-tier-testing"] tier2_bfs = ["mxc-sdk/tier2_bfs"] diff --git a/src/tools/wxc/build.rs b/src/tools/wxc/build.rs index cf8ac1f38..05f091e25 100644 --- a/src/tools/wxc/build.rs +++ b/src/tools/wxc/build.rs @@ -1,12 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Build script for wxc — embeds Windows VersionInfo. +//! Build script for wxc - embeds Windows VersionInfo and stages runtime assets. #[path = "../../mxc-sdk/build/build_mxc_build_common.rs"] mod mxc_build_common; fn main() { + #[cfg(feature = "isolation_session")] + reconcile_isolation_session_runtime(); + mxc_build_common::embed_version_info("MXC sandbox executor", "wxc-exec.exe"); #[cfg(windows)] @@ -24,29 +27,60 @@ fn main() { } } - // Re-run prerequisite checks when PATH changes (e.g., after installing Python). #[cfg(windows)] println!("cargo:rerun-if-env-changed=PATH"); } -/// Emit build warnings when E2E test prerequisites are missing or -/// misconfigured. These are non-blocking — the build succeeds regardless. +#[cfg(feature = "isolation_session")] +fn reconcile_isolation_session_runtime() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + + let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR")); + let target_dir = out_dir + .parent() + .and_then(|path| path.parent()) + .and_then(|path| path.parent()) + .expect("could not determine target directory from OUT_DIR"); + + // Feature configurations share the final profile directory but have + // separate build-script fingerprints. Force this script to reconcile the + // shared payload whenever Cargo is invoked. + println!( + "cargo:rerun-if-changed={}", + target_dir.join(".isosession-payload-state").display() + ); + + #[cfg(feature = "isolation_session_lifted")] + mxc_build_common::isolation_session_sdk::stage_runtime() + .unwrap_or_else(|e| panic!("IsolationSession SDK staging failed: {e}")); + + #[cfg(not(feature = "isolation_session_lifted"))] + for file_name in ["IsoSessionApp.dll", "IsoSession.manifest"] { + let path = target_dir.join(file_name); + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => panic!("remove stale lifted payload {}: {error}", path.display()), + } + } +} + #[cfg(windows)] fn check_test_prerequisites() { use std::process::Command; - // Check Python let python_ok = Command::new("where.exe") .arg("python.exe") .output() .ok() - .and_then(|o| { - if !o.status.success() { + .and_then(|output| { + if !output.status.success() { return None; } - let stdout = String::from_utf8_lossy(&o.stdout).to_string(); - let first = stdout.lines().next().unwrap_or("").to_string(); - Some(first) + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + Some(stdout.lines().next().unwrap_or("").to_string()) }); match python_ok { @@ -67,7 +101,6 @@ fn check_test_prerequisites() { _ => {} } - // Check pwsh at the expected install path (test configs use a hardcoded path) const PWSH_PATH: &str = r"C:\Program Files\PowerShell\7\pwsh.exe"; if !std::path::Path::new(PWSH_PATH).exists() { println!(