diff --git a/.azure-pipelines/templates/Rust.Build.Job.yml b/.azure-pipelines/templates/Rust.Build.Job.yml
index 6d1254308..ae1fe2e83 100644
--- a/.azure-pipelines/templates/Rust.Build.Job.yml
+++ b/.azure-pipelines/templates/Rust.Build.Job.yml
@@ -46,7 +46,19 @@ jobs:
workingDirectory: $(Build.SourcesDirectory)/src
# Explicit feature set (no --all-features): tier2_bfs must NOT ship
# on Win 11 25H2 (bfscfg.exe risks an OS hang).
- cargoFeatures: hyperlight isolation_session microvm wslc
+ # Keep the x64-only MicroVM feature coupled to its published NVX payload.
+ ${{ if eq(item.arch, 'x64') }}:
+ cargoFeatures: hyperlight isolation_session microvm wslc
+ microvmSignPattern: bin/openvmm.exe
+ microvmArtifactPattern: |
+ bin/openvmm.exe
+ guest/vmlinux
+ guest/initramfs.cpio.gz
+ images/**
+ ${{ else }}:
+ cargoFeatures: hyperlight isolation_session wslc
+ microvmSignPattern: ''
+ microvmArtifactPattern: ''
signPattern: |
wxc-exec.exe
plm.exe
@@ -65,6 +77,8 @@ jobs:
artifactPrefix: lxc-binaries
workingDirectory: $(Build.SourcesDirectory)/src/core/lxc
cargoFeatures: hyperlight
+ microvmSignPattern: ''
+ microvmArtifactPattern: ''
signPattern: |
lxc-exec
unix-test-proxy
@@ -169,6 +183,7 @@ jobs:
FolderPath: $(targetTripleDir)
Pattern: |
$(signPattern)
+ $(microvmSignPattern)
UseMinimatch: true
signConfigType: 'inlineSignParams'
inlineOperation: >-
@@ -201,6 +216,7 @@ jobs:
sourceFolder: $(targetTripleDir)
contents: |
$(signPattern)
+ $(microvmArtifactPattern)
targetFolder: $(outputDirectory)/$(targetTriple)
# Copy wslcsdk.dll
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 3e71099c1..c7988f19d 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -13,10 +13,10 @@ MXC (Microsoft eXecution Container) is a cross-platform sandboxed code execution
## Architecture invariants
- `wxc_common` is the cross-platform foundation. Do not move backend execution or enforcement into it, or add new backend implementation dependencies.
-- Backend crates generally depend on `wxc_common`; avoid cross-dependencies between backend crates. The existing optional `nanvix_common` dependency supplies shared MicroVM data/constants rather than backend dispatch.
+- Backend crates generally depend on `wxc_common`; avoid cross-dependencies between backend crates.
- `mxc_engine` is the single execution engine. Executor binaries and `mxc-sdk` delegate backend routing to it.
- Keep `wxc`, `lxc`, and `mxc_darwin` thin. Do not add backend-selection matches to the binaries.
-- Keep build-time staging in `mxc_build_common` or `nanvix_build_common`, not runtime crates.
+- Keep build-time staging in `mxc_build_common` or backend-specific build helpers such as `nvx_build_common`, not runtime crates.
- Use `#[cfg(target_os = "...")]` and existing Cargo feature gates for platform-specific code.
- Preserve the distinction between run-to-completion, streaming, and state-aware lifecycle APIs.
- Unsupported policy must fail closed. Do not accept a field that the selected backend cannot enforce.
@@ -37,6 +37,7 @@ The Rust toolchain is pinned by `src/rust-toolchain.toml`. Run Rust commands fro
```text
build.bat
+build.bat --with-microvm # Include the incomplete MicroVM (NVX) foundation (Windows x64)
./build.sh
diff --git a/.github/workflows/Build.Windows.Job.yml b/.github/workflows/Build.Windows.Job.yml
index 9c1b59d4c..ce94ecd7f 100644
--- a/.github/workflows/Build.Windows.Job.yml
+++ b/.github/workflows/Build.Windows.Job.yml
@@ -14,11 +14,11 @@ jobs:
runner: windows-2025
target: x86_64-pc-windows-msvc
# tier2_bfs must NOT ship on Win 11 25H2 (bfscfg.exe hangs the host).
- features: hyperlight isolation_session microvm wslc
+ features: hyperlight isolation_session wslc
- arch: arm64
runner: windows-11-arm
target: aarch64-pc-windows-msvc
- # Hyperlight and MicroVM runtimes are x64-only.
+ # Hyperlight is x64-only.
features: isolation_session wslc
runs-on: ${{ matrix.runner }}
defaults:
@@ -66,6 +66,14 @@ jobs:
--no-default-features
--features "${{ matrix.features }}"
+ # Keep MicroVM (NVX) out of the uploaded release executable while its workload
+ # image bundle is unavailable, but verify its opt-in build and staging.
+ - name: Check opt-in MicroVM packaging
+ if: matrix.arch == 'x64'
+ run: cargo check --locked --target ${{ matrix.target }} -p wxc
+ --no-default-features
+ --features microvm
+
- name: Test telemetry consent and policy in release mode
run: |
cargo test --locked --release --target ${{ matrix.target }} -p wxc_common telemetry::consent
diff --git a/.github/workflows/issue-triage.md b/.github/workflows/issue-triage.md
index 8843772f5..2a3022d61 100644
--- a/.github/workflows/issue-triage.md
+++ b/.github/workflows/issue-triage.md
@@ -90,7 +90,7 @@ Assign matching owner(s) with `assign_to_user` using this map:
| @mgudgin | AppContainer / BaseContainer / process isolation/container |
| @bbonaby | AppContainer / BaseContainer / process isolation/container / networking / firewall / DNS / proxy / iptables |
| @SohamDas2021 | Linux / LXC / WSLC / Bubblewrap (bwrap) / proxy on Linux / iptables |
-| @huzaifa-d | MicroVM / NanVix / Hyperlight / Windows Sandbox |
+| @huzaifa-d | MicroVM / NVX / Hyperlight / Windows Sandbox |
| @adpa-ms | IsolationSession / session isolation |
| @richiemsft | macOS / Seatbelt |
| @mgudgin | SDK configuration and policy (Area-SDK-Configuration, Area-SDK-Policy) |
diff --git a/.github/workflows/microvm-e2e.yml b/.github/workflows/microvm-e2e.yml
deleted file mode 100644
index 43e2940ba..000000000
--- a/.github/workflows/microvm-e2e.yml
+++ /dev/null
@@ -1,110 +0,0 @@
-name: Integration Tests
-
-# Retained until the unified test matrix has equivalent MicroVM coverage.
-
-on:
- push:
- branches: [main]
- pull_request:
- branches: [main]
- workflow_dispatch:
-
-permissions:
- actions: write
- contents: read
-
-jobs:
- microvm-e2e:
- name: WXC-Exec MicroVM
- runs-on: windows-latest
- timeout-minutes: 30
-
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-
- - name: Setup Rust toolchain
- run: |
- rustup update stable
- rustup target add x86_64-pc-windows-msvc
-
- - name: Point cargo at the MxcDependencies feed
- uses: ./.github/actions/setup-cargo-feed
-
- - name: Cache Rust build artifacts
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- with:
- workspaces: src
-
- - name: Build with MicroVM support
- working-directory: src
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: cargo build --features microvm --target x86_64-pc-windows-msvc
-
- - name: Exclude build output from Windows Defender
- shell: pwsh
- run: |
- $binDir = Join-Path $env:GITHUB_WORKSPACE "src\target\x86_64-pc-windows-msvc\debug"
- Add-MpPreference -ExclusionPath $binDir
- Write-Host "Added Defender exclusion for $binDir"
-
- - name: Verify MicroVM binaries
- shell: pwsh
- run: |
- $binDir = Join-Path $env:GITHUB_WORKSPACE "src\target\x86_64-pc-windows-msvc\debug"
- $required = @(
- "wxc-exec.exe",
- "nanvixd.exe",
- "nanvix_rootfs.img",
- "python3.initrd",
- "bin\kernel.elf",
- "snapshots\kernel.vmem",
- "snapshots\kernel.whp.cbor"
- )
- $missing = $required | Where-Object { -not (Test-Path (Join-Path $binDir $_)) }
- if ($missing) {
- Write-Host "::error::Missing binaries: $($missing -join ', ')"
- exit 1
- }
- $leaves = $required | ForEach-Object { Split-Path $_ -Leaf }
- Get-ChildItem $binDir -Include $leaves -Recurse | Format-Table FullName, Length
-
- - name: Diagnose hypervisor environment
- shell: pwsh
- run: ./scripts/ci/diagnose-whp.ps1
-
- - name: Check Windows Hypervisor Platform
- id: whp-check
- shell: pwsh
- run: ./scripts/ci/check-whp.ps1
-
- - name: Run MicroVM E2E Tests
- if: steps.whp-check.outputs.whp_available == 'true'
- shell: pwsh
- working-directory: src
- run: |
- cargo test -p wxc_e2e_tests --target x86_64-pc-windows-msvc test_microvm_ -- --nocapture
-
- - name: Upload logs on failure
- if: failure() || cancelled()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: microvm-e2e-logs-${{ github.event.pull_request.number || github.run_number }}
- retention-days: 7
- path: |
- logs/
- **/*.log
-
- - name: Print performance summary
- if: steps.whp-check.outputs.whp_available == 'true' && !cancelled()
- shell: pwsh
- run: ./scripts/ci/print-perf-summary.ps1 -JsonPath (Join-Path $env:GITHUB_WORKSPACE "microvm-perf-results.json")
-
- - name: Upload performance results
- if: steps.whp-check.outputs.whp_available == 'true' && !cancelled()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: microvm-perf-results-${{ github.event.pull_request.number || github.run_number }}
- retention-days: 30
- path: microvm-perf-results.json
diff --git a/.gitignore b/.gitignore
index 2feaea311..6f63c4527 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,7 +25,6 @@ bld/
# Test artifacts
*.etl
*.log
-microvm-perf-results*.json
# npm pack artifacts
*.tgz
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 83648ad81..f0bd81ff6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -150,7 +150,7 @@ MXC has a Rust core (under `src/`) and a TypeScript SDK (under `sdk/node/`). The
build.bat :: Release build for current architecture
build.bat --debug :: Debug build
build.bat --all :: Release build for both x64 and ARM64
-build.bat --with-microvm :: Include NanVix micro-VM binaries
+build.bat --with-microvm :: Include the MicroVM (NVX) foundation (Windows x64)
```
**Linux** — `./build.sh`:
diff --git a/README.md b/README.md
index eff50e39f..5a1a9aa5d 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ MXC is a **sandboxed code execution system** for running untrusted code (model o
- **Cross-platform**: Windows, Linux, and macOS support with platform-appropriate containment backends
- **JSON-based Configuration**: Define execution parameters and security policies via a versioned JSON schema
-- **Multiple Containment Backends**: ProcessContainer, Windows Sandbox, LXC, Bubblewrap, Seatbelt (macOS), MicroVM (NanVix), Hyperlight, IsolationSession, and WSLC
+- **Multiple Containment Backends**: ProcessContainer, Windows Sandbox, LXC, Bubblewrap, Seatbelt (macOS), MicroVM (NVX), Hyperlight, IsolationSession, and WSLC
- **Policy-driven Sandboxing**:
- **Filesystem Policy**: Read-only and read-write path lists (denied paths not yet supported on Windows)
- **Network Policy**: Proxy support (cooperative on Linux/macOS), allow/block outbound, and backend-dependent host filtering
@@ -27,7 +27,7 @@ MXC ships a native container wrapper plus a TypeScript SDK — see the [SDK READ
| Platform | Default backend | Other backends | Minimum build |
| --- | --- | --- | --- |
| Windows 11 24H2+ (verified on 25H2) | `processcontainer` | `windows_sandbox`, `wslc`, `microvm`, `hyperlight`, `isolation_session` | `processcontainer`: 26100 (24H2) `isolation_session`: 26340.9212 ([Insider Preview](https://learn.microsoft.com/en-us/windows-insider/release-notes/experimental/preview-build-26340-9212)) |
-| Linux x64 / ARM64 | `bubblewrap` | `lxc`, `microvm`, `hyperlight` | — |
+| Linux x64 / ARM64 | `bubblewrap` | `lxc`, `hyperlight` | — |
| macOS ARM64 / x64 (schema `0.7.0-alpha`+) | `seatbelt` | — | — |
@@ -70,9 +70,23 @@ layout, crate responsibilities, dependency direction, and execution surfaces.
build.bat # Release build for current architecture
build.bat --debug # Debug build
build.bat --all # Release build for both x64 and ARM64
-build.bat --with-microvm # Include NanVix micro-VM binaries
+build.bat --with-microvm # Include the incomplete MicroVM (NVX) foundation (Windows x64)
```
+`--with-microvm` acquires the pinned NVX platform archive at build time and verifies every staged file by
+SHA-256; sandbox launches never download artifacts. Set `NVX_BIN` to a
+pre-fetched, checksum-verifiable bundle directory for an offline build. The
+current pin, `v0.1.0-dev.5c86da3dff02`, contains only `openvmm.exe`, the guest
+kernel, and the guest initramfs, not the workload-image bundle.
+
+The exact `0.9.0-alpha` contract exposes the experimental `microvm` containment
+value, implemented internally by NVX. Build with `--with-microvm`; execution
+currently returns a typed backend-unavailable error, and capability probes do
+not advertise MicroVM while the runtime is incomplete.
+Runtime work remains blocked on NVX-produced distro/runtime EROFS images
+and scratch image, a proven combined managed-sandbox/virtio-fs contract, and the
+required WHP runner.
+
#### Linux
```bash
@@ -227,7 +241,7 @@ See the [SDK README](sdk/node/README.md) for full API documentation.
## Schema Versions
-Released, immutable stable schemas live in [`schemas/stable/`](schemas/stable); the in-progress dev schema (experimental backends, state-aware lifecycle) lives in [`schemas/dev/`](schemas/dev). The current stable and dev versions are tracked canonically in [`schemas/schema-version.json`](schemas/schema-version.json).
+Released, revision-locked stable schemas live in [`schemas/stable/`](schemas/stable); the in-progress dev schema (remaining development backends and fields) lives in [`schemas/dev/`](schemas/dev). The current stable and dev versions, plus auditable publication revisions, are tracked canonically in [`schemas/schema-version.json`](schemas/schema-version.json).
Pick the latest stable schema for new code on any supported platform. See [docs/versioning.md](docs/versioning.md) for the full versioning design.
diff --git a/build.bat b/build.bat
index 43676988b..2d7ffba33 100644
--- a/build.bat
+++ b/build.bat
@@ -5,7 +5,7 @@ setlocal enabledelayedexpansion
set "BUILD_CONFIG=release"
set "BUILD_ARCH="
set "BUILD_ALL=0"
-set "WITH_NANVIX=0"
+set "WITH_MICROVM=0"
set "WITH_WSLC=0"
set "WITH_ISOLATION_SESSION=0"
set "WITH_HYPERLIGHT=0"
@@ -18,7 +18,7 @@ if /i "%~1"=="--release" ( set "BUILD_CONFIG=release" & shift & goto :parse_arg
if /i "%~1"=="--x64" ( set "BUILD_ARCH=x86_64-pc-windows-msvc" & shift & goto :parse_args )
if /i "%~1"=="--arm64" ( set "BUILD_ARCH=aarch64-pc-windows-msvc" & shift & goto :parse_args )
if /i "%~1"=="--all" ( set "BUILD_ALL=1" & shift & goto :parse_args )
-if /i "%~1"=="--with-microvm" ( set "WITH_NANVIX=1" & shift & goto :parse_args )
+if /i "%~1"=="--with-microvm" ( set "WITH_MICROVM=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-hyperlight" ( set "WITH_HYPERLIGHT=1" & shift & goto :parse_args )
@@ -37,6 +37,17 @@ if "%BUILD_ALL%"=="0" if "%BUILD_ARCH%"=="" (
)
)
+if "%WITH_MICROVM%"=="1" (
+ if "%BUILD_ALL%"=="1" (
+ echo ERROR: --with-microvm supports x64 only and cannot be combined with --all.
+ exit /b 1
+ )
+ if /i not "%BUILD_ARCH%"=="x86_64-pc-windows-msvc" (
+ echo ERROR: --with-microvm supports x64 only. Use --x64 on an ARM64 host.
+ exit /b 1
+ )
+)
+
:: Build flags
set "CARGO_FLAGS=--target"
if "%BUILD_CONFIG%"=="release" set "CARGO_FLAGS=--release --target"
@@ -44,7 +55,7 @@ if "%BUILD_CONFIG%"=="release" set "CARGO_FLAGS=--release --target"
:: workspace feature flags above, so it uses its own profile/target-only flags.
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_MICROVM%"=="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_HYPERLIGHT%"=="1" set "CARGO_FLAGS=--features hyperlight %CARGO_FLAGS%"
@@ -126,24 +137,74 @@ for %%T in (x86_64-pc-windows-msvc aarch64-pc-windows-msvc) do (
copy /Y "!BIN_DIR!\mxc_ffi.dll" "sdk\node\bin\!SDK_ARCH!\" >nul
echo Copied !SDK_ARCH!\mxc_ffi.dll
)
- if "%WITH_NANVIX%"=="1" (
- for %%B in (nanvixd.exe nanvix_rootfs.img python3.initrd) do (
- if exist "!BIN_DIR!\%%B" (
- copy /Y "!BIN_DIR!\%%B" "sdk\node\bin\!SDK_ARCH!\" >nul
+ for %%B in (nanvixd.exe nanvix_rootfs.img python3.initrd bin\kernel.elf snapshots\kernel.vmem snapshots\kernel.whp.cbor) do (
+ if exist "sdk\node\bin\!SDK_ARCH!\%%B" del /Q "sdk\node\bin\!SDK_ARCH!\%%B"
+ )
+ if exist "sdk\node\bin\!SDK_ARCH!\snapshots" rd "sdk\node\bin\!SDK_ARCH!\snapshots" 2>nul
+ if exist "sdk\node\bin\!SDK_ARCH!\bin" rd "sdk\node\bin\!SDK_ARCH!\bin" 2>nul
+ if "%%T"=="x86_64-pc-windows-msvc" (
+ if "%WITH_MICROVM%"=="1" (
+ for %%B in (bin\openvmm.exe guest\vmlinux guest\initramfs.cpio.gz) do (
+ if not exist "!BIN_DIR!\%%B" (
+ echo ERROR: MicroVM ^(NVX^) Node runtime is missing !BIN_DIR!\%%B
+ exit /b 1
+ )
+ )
+ set "NVX_WORKLOAD_IMAGE_COUNT=0"
+ for %%B in (images\distro.erofs images\runtime.erofs images\scratch.ext4) do (
+ if exist "!BIN_DIR!\%%B" set /A NVX_WORKLOAD_IMAGE_COUNT+=1
+ )
+ if not "!NVX_WORKLOAD_IMAGE_COUNT!"=="0" if not "!NVX_WORKLOAD_IMAGE_COUNT!"=="3" (
+ echo ERROR: MicroVM ^(NVX^) Node runtime has an incomplete workload-image bundle.
+ exit /b 1
+ )
+ for %%B in (images\distro.erofs images\runtime.erofs images\scratch.ext4) do (
+ if exist "sdk\node\bin\!SDK_ARCH!\%%B" del /Q "sdk\node\bin\!SDK_ARCH!\%%B"
+ )
+ if exist "sdk\node\bin\!SDK_ARCH!\images" rd "sdk\node\bin\!SDK_ARCH!\images" 2>nul
+ for %%B in (bin\openvmm.exe guest\vmlinux guest\initramfs.cpio.gz) do (
+ for %%D in ("sdk\node\bin\!SDK_ARCH!\%%B") do (
+ if not exist "%%~dpD" (
+ mkdir "%%~dpD"
+ if errorlevel 1 (
+ echo ERROR: Failed to create NVX Node runtime directory %%~dpD
+ exit /b 1
+ )
+ )
+ )
+ copy /Y "!BIN_DIR!\%%B" "sdk\node\bin\!SDK_ARCH!\%%B" >nul
+ if errorlevel 1 (
+ echo ERROR: Failed to copy NVX Node runtime artifact %%B
+ exit /b 1
+ )
echo Copied !SDK_ARCH!\%%B
)
- )
- if exist "!BIN_DIR!\bin\kernel.elf" (
- if not exist "sdk\node\bin\!SDK_ARCH!\bin" mkdir "sdk\node\bin\!SDK_ARCH!\bin"
- copy /Y "!BIN_DIR!\bin\kernel.elf" "sdk\node\bin\!SDK_ARCH!\bin\" >nul
- echo Copied !SDK_ARCH!\bin\kernel.elf
- )
- for %%S in (kernel.vmem kernel.whp.cbor) do (
- if exist "!BIN_DIR!\snapshots\%%S" (
- if not exist "sdk\node\bin\!SDK_ARCH!\snapshots" mkdir "sdk\node\bin\!SDK_ARCH!\snapshots"
- copy /Y "!BIN_DIR!\snapshots\%%S" "sdk\node\bin\!SDK_ARCH!\snapshots\" >nul
- echo Copied !SDK_ARCH!\snapshots\%%S
+ if "!NVX_WORKLOAD_IMAGE_COUNT!"=="3" (
+ for %%B in (images\distro.erofs images\runtime.erofs images\scratch.ext4) do (
+ for %%D in ("sdk\node\bin\!SDK_ARCH!\%%B") do (
+ if not exist "%%~dpD" (
+ mkdir "%%~dpD"
+ if errorlevel 1 (
+ echo ERROR: Failed to create NVX Node runtime directory %%~dpD
+ exit /b 1
+ )
+ )
+ )
+ copy /Y "!BIN_DIR!\%%B" "sdk\node\bin\!SDK_ARCH!\%%B" >nul
+ if errorlevel 1 (
+ echo ERROR: Failed to copy NVX Node runtime artifact %%B
+ exit /b 1
+ )
+ echo Copied !SDK_ARCH!\%%B
+ )
+ )
+ ) else (
+ for %%B in (bin\openvmm.exe guest\vmlinux guest\initramfs.cpio.gz images\distro.erofs images\runtime.erofs images\scratch.ext4) do (
+ if exist "sdk\node\bin\!SDK_ARCH!\%%B" del /Q "sdk\node\bin\!SDK_ARCH!\%%B"
)
+ if exist "sdk\node\bin\!SDK_ARCH!\bin" rd "sdk\node\bin\!SDK_ARCH!\bin" 2>nul
+ if exist "sdk\node\bin\!SDK_ARCH!\guest" rd "sdk\node\bin\!SDK_ARCH!\guest" 2>nul
+ if exist "sdk\node\bin\!SDK_ARCH!\images" rd "sdk\node\bin\!SDK_ARCH!\images" 2>nul
)
)
if "!COPY_WSLC_RUNTIME!"=="1" (
@@ -277,7 +338,9 @@ echo --release Build release configuration
echo --x64 Build for x64 only
echo --arm64 Build for ARM64 only
echo --all Build for both x64 and ARM64
-echo --with-microvm Download and include NanVix micro-VM binaries
+echo --with-microvm Add the incomplete MicroVM (NVX) foundation and platform artifacts (x64)
+echo Runtime preflight remains unavailable until NVX publishes
+echo the workload image bundle
echo --with-wslc Build with WSL Container (WSLC SDK) support
echo --with-isolation-session Build with IsolationSession backend (IsoEnvBroker)
echo --with-hyperlight Build with Hyperlight (micro-VM) backend (x86_64 only)
diff --git a/build.sh b/build.sh
index 4b75b4893..1a204b56c 100644
--- a/build.sh
+++ b/build.sh
@@ -13,7 +13,6 @@ BUILD_TYPE="release"
BUILD_SDK=true
WITH_HYPERLIGHT=false
-WITH_MICROVM=false
while [[ $# -gt 0 ]]; do
case $1 in
@@ -29,10 +28,6 @@ while [[ $# -gt 0 ]]; do
WITH_HYPERLIGHT=true
shift
;;
- --with-microvm)
- WITH_MICROVM=true
- shift
- ;;
--help|-h)
echo "Usage: build.sh [OPTIONS]"
echo ""
@@ -40,7 +35,6 @@ while [[ $# -gt 0 ]]; do
echo " --debug Build in debug mode (default: release)"
echo " --rust-only Only build Rust binaries, skip SDK"
echo " --with-hyperlight Build with Hyperlight (micro-VM) backend (x86_64 only)"
- echo " --with-microvm Build with NanVix MicroVM backend (KVM required at runtime)"
echo " -h, --help Show this help message"
exit 0
;;
@@ -77,9 +71,6 @@ FEATURES_LIST=()
if [ "$WITH_HYPERLIGHT" = true ]; then
FEATURES_LIST+=(hyperlight)
fi
-if [ "$WITH_MICROVM" = true ]; then
- FEATURES_LIST+=(microvm)
-fi
if [ ${#FEATURES_LIST[@]} -gt 0 ]; then
CARGO_FEATURES=(--features "$(IFS=,; echo "${FEATURES_LIST[*]}")")
fi
diff --git a/docs/architecture.md b/docs/architecture.md
index f414879c1..5768b1a5b 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -40,8 +40,8 @@ The workspace members and shared Rust dependencies are declared in
`wxc_common` provides the backend-neutral foundation. Backend crates generally
depend on it, while `mxc_engine` depends on the platform backends and owns
-dispatch. The existing optional `wxc_common` dependency on `nanvix_common`
-provides shared MicroVM data and constants.
+dispatch. Backend-specific build helpers such as `nvx_build_common` own
+artifact staging and acquisition without creating runtime cross-dependencies.
## Backend layout
@@ -55,7 +55,7 @@ logic. Backends with additional processes use several crates:
| Windows Sandbox | `common/`, `lifecycle/`, `daemon/`, and `guest/` |
| WSLC | `common/` and `daemon/` |
| IsolationSession | bindings and `common/` |
-| NanVix | common data, build support, binaries, and runner crates |
+| NVX | common data, build support, binaries, and runner crates |
| Windows Learning Mode | Windows implementation over `learning_mode_core` |
Shared parsing and normalization live in `wxc_common`; backend-specific policy
diff --git a/docs/backend-support-probe-api-plan.md b/docs/backend-support-probe-api-plan.md
index 3f023a85d..86c609dde 100644
--- a/docs/backend-support-probe-api-plan.md
+++ b/docs/backend-support-probe-api-plan.md
@@ -126,7 +126,7 @@ for them **cannot be built just yet**.
| --- | --- | --- | --- |
| `windows_sandbox` | DISM/registry check of the *Containers-DisposableClientVM* optional feature | only a private "is the `.exe` on disk" check | reports available when the feature is off → launch fails |
| `isolation_session` | activation of the in-proc `Windows.AI.IsolationSession.Preview` `IsoSessionOps` runtime class succeeds (the API class is registered on the OS **and** its OS feature gate is on) **and** the backend feature is compiled | as of #761, detection queries whether the API class is registered rather than gating on a build number; a `CLASS_E_CLASSNOTAVAILABLE` / `REGDB_E_CLASSNOTREG` activation failure means unavailable | none for false-availability now — a machine without the API registered fails activation cleanly; still needs a cheap probe seam so callers don't have to attempt a real activation |
-| `microvm` | feature compiled, NanVix runtime files staged, and WHP usable on Windows or `/dev/kvm` readable/writable on Linux | nothing | checking only a hypervisor can report availability when required runtime files are missing |
+| `microvm` | feature compiled, pinned NVX runtime files staged, and WHP usable on Windows x64 | nothing | checking only a hypervisor can report availability when required runtime files are missing |
| `hyperlight` | hypervisor present + feature compiled | nothing | same VM-boot risk |
### 4.2 The parity rule: detect once, project into TS
diff --git a/docs/ci-validation-infrastructure.md b/docs/ci-validation-infrastructure.md
index 1a4560008..3b1a75129 100644
--- a/docs/ci-validation-infrastructure.md
+++ b/docs/ci-validation-infrastructure.md
@@ -106,14 +106,14 @@ Current platforms:
| Platform id | Family | x64 pool | arm64 pool | Declared backends (x64) |
|-------------|--------|----------|------------|--------------------------|
-| `windows-prerelease-process-container` | windows | `1es-mxc-windows-prerelease-t1-x64` | *(dormant)* | process-t1, process-t3, isolation-session, wslc, windows-sandbox, microvm, hyperlight |
+| `windows-prerelease-process-container` | windows | `1es-mxc-windows-prerelease-t1-x64` | *(dormant)* | process-t1, process-t3, isolation-session, wslc, windows-sandbox, hyperlight |
| `windows-prerelease-isolation-session` | windows | *(dormant)* | *(dormant)* | same as above |
-| `windows-canary` | windows | *(dormant)* | *(dormant)* | process-t1, process-t3, wslc, windows-sandbox, microvm, hyperlight |
+| `windows-canary` | windows | *(dormant)* | *(dormant)* | process-t1, process-t3, wslc, windows-sandbox, hyperlight |
| `windows-25h2` | windows | `1es-mxc-e2e-windows-25h2-pro-x64` | *(dormant)* | same as above |
| `windows-24h2` | windows | `1es-mxc-e2e-windows-24h2-pro-x64` | *(dormant)* | same as above |
-| `windows-23h2` | windows | `1es-mxc-e2e-windows-23h2-enterprise-x64` | *(dormant)* | process-t3, wslc, windows-sandbox, microvm, hyperlight |
+| `windows-23h2` | windows | `1es-mxc-e2e-windows-23h2-enterprise-x64` | *(dormant)* | process-t3, wslc, windows-sandbox, hyperlight |
| `ubuntu-26.04` | linux | `1es-mxc-e2e-ubuntu-26.04-x64` | *(dormant)* | bubblewrap, hyperlight, lxc |
-| `ubuntu-24.04` | linux | `1es-mxc-e2e-ubuntu-24.04-x64` | *(dormant)* | bubblewrap, microvm, hyperlight, lxc |
+| `ubuntu-24.04` | linux | `1es-mxc-e2e-ubuntu-24.04-x64` | *(dormant)* | bubblewrap, hyperlight, lxc |
| `rhel-10` | linux | `1es-mxc-e2e-rhel-10-x64` | *(dormant)* | bubblewrap, hyperlight, lxc |
| `debian-13` | linux | `1es-mxc-e2e-debian-13-x64` | *(dormant)* | bubblewrap, hyperlight, lxc |
| `macos-26` | macos | — | runner `macos-26` | seatbelt |
@@ -229,7 +229,6 @@ get fixed or wired.
| WSLC | ✅ Good | Might have to retry hung jobs - this is an issue with overzealous agent reclaiming. |
| IsolationSession | ✅ Good | Runs the one-shot suite plus state aware tests (provision/start/exec/stop/deprovision lifecycle). |
| Windows Sandbox | ⛔ Blocked | Images don't support `Containers-DisposableClientVM` opt. feature |
-| MicroVM | ⛔ Not working | Windows cold and warm starts hang; no Linux suite. The artifact payload is currently commented out in the build jobs. |
| Hyperlight | ⛔ Not implemented | No suite on any platform. |
| Seatbelt | ✅ Good | Failures are genuine MXC bugs. |
@@ -245,9 +244,6 @@ every entry.
`prepare-null-device --no-sacl`. T1 needs them too: the suite deliberately
drives the AppContainer fallback tiers, and an unprepared host fails those
launches with `WIN32_ERROR(5)` instead of reporting a policy result.
-- `microvm` — asserts the NanVix payload is in the artifact, adds a Defender
- exclusion for the binary directory, and requires the Windows Hypervisor
- Platform feature *and* a running hypervisor.
- `wslc` — asserts `wslcsdk.dll` shipped, requires the WSL and
VirtualMachinePlatform optional features to be baked into the image, then
installs/updates the WSL runtime (including the pre-release ring) up to the
@@ -279,7 +275,6 @@ a process-container job selects follows from that build.
for `lxcbr0`, enables bridge netfilter, and makes sure the bridge's NAT rule
is in place. On RHEL-likes it needs EPEL first, because Red Hat dropped LXC
after RHEL 7 and ships no replacement.
-- `microvm` — asserts the NanVix payload exists.
- `hyperlight` — no-op.
Every install above goes through two shared helpers rather than its own
@@ -497,7 +492,7 @@ cron *and* a job condition *and* a dispatch choice.
Set the ARM64 `pool` for the platform *and* remove or narrow
`suppressNonMacArm64` in the resolver. Note that the resolver rejects
-`hyperlight` and `microvm` on ARM64 outright (x64-only runtimes), and the WSLC
+`hyperlight` on ARM64 outright (x64-only runtime), and the WSLC
dispatcher still refuses non-x64.
## Testing Your Changes to the Validation Infrastructure
diff --git a/docs/isolation-session/oneshot.md b/docs/isolation-session/oneshot.md
index ff5945c5e..d9944b2c0 100644
--- a/docs/isolation-session/oneshot.md
+++ b/docs/isolation-session/oneshot.md
@@ -312,7 +312,7 @@ A test runner at `tests/scripts/run_isolation_session_tests.ps1` invokes
both configs via `wxc-exec.exe`, validates exit codes and
expected output substrings, and reports a pass/fail summary. Pattern
follows the existing per-backend integration scripts (e.g.
-`run_microvm_tests.ps1`, `run_wslc_all_tests.ps1`).
+`run_wslc_all_tests.ps1`).
The script must run **interactively** on the test host. The OS-side service's
calling-process identity check rejects network-logon tokens, so
diff --git a/docs/linux-wsl-roadmap-june-2026.md b/docs/linux-wsl-roadmap-june-2026.md
index 9b19ece87..1b723cce1 100644
--- a/docs/linux-wsl-roadmap-june-2026.md
+++ b/docs/linux-wsl-roadmap-june-2026.md
@@ -501,7 +501,7 @@ These items depend on the WSLC SDK team and are not unilaterally schedulable.
> **Why network enforcement must be container-scoped (host vs. VM vs. container).** Network policy can be enforced at three layers: the Windows **host** (Windows Firewall), the WSL2 **VM**, or the **container** network namespace inside the VM. GA decision **D6 (per-sandbox scoping)** requires every sandbox's policy to be independent — concurrent WSLC containers must not affect each other's access — and names the container network namespace as WSLC's scoping identity. A machine-wide **host** firewall can't attribute traffic to one container vs. another, so it violates D6 (and per **D8**, host firewalls apply *on top of* enforcement, never *as* it). A **VM-wide** rule fails the same way when one utility VM hosts multiple containers — sandbox A's rules would bleed into sandbox B. Only the **container namespace** is inherently per-sandbox, which is why it's the required enforcement point. The catch: MXC can't install rules into that namespace today (`Privileged` doesn't grant `CAP_NET_ADMIN`, and the VM may lack iptables tooling). Hence SDK dep #1 — a VM-level API that applies rules **scoped to a specific container's namespace**: physically enforced at the VM boundary, logically attributed to one container.
>
-> **Contrast with Hyperlight/Nanvix, and the state-aware wrinkle.** Hyperlight (host-proxied sockets, per-instance) and Nanvix (per-guest egress filter) get D6 scoping for free because each sandbox *is* its own VM instance/process — no shared surface to bleed across. WSLC today is also effectively 1 sandbox : 1 VM (the one-shot flow creates a session, one container, then tears it down), but the highest-value WSLC optimization — **state-aware session reuse** (Misc #29), keeping a warm VM to amortize startup cost — makes one VM host **multiple** containers, at which point a host- or VM-wide rule genuinely bleeds across co-resident sandboxes. That is exactly when namespace-scoped enforcement (SDK dep #1) stops being merely cleaner and becomes mandatory.
+> **Contrast with Hyperlight/NVX, and the state-aware wrinkle.** Hyperlight (host-proxied sockets, per-instance) and NVX (per-guest egress filter) get D6 scoping for free because each sandbox *is* its own VM instance/process — no shared surface to bleed across. WSLC today is also effectively 1 sandbox : 1 VM (the one-shot flow creates a session, one container, then tears it down), but the highest-value WSLC optimization — **state-aware session reuse** (Misc #29), keeping a warm VM to amortize startup cost — makes one VM host **multiple** containers, at which point a host- or VM-wide rule genuinely bleeds across co-resident sandboxes. That is exactly when namespace-scoped enforcement (SDK dep #1) stops being merely cleaner and becomes mandatory.
---
diff --git a/docs/nanvix-microvm/nanvix-integration-plan.md b/docs/nanvix-microvm/nanvix-integration-plan.md
deleted file mode 100644
index 9a065f1c5..000000000
--- a/docs/nanvix-microvm/nanvix-integration-plan.md
+++ /dev/null
@@ -1,397 +0,0 @@
-# MXC NanVix Integration — Design Document
-
-## Problem
-
-MXC (Microsoft eXecution Container) runs untrusted code in sandboxed environments. Today it supports multiple backends: **AppContainer** (process-level isolation), **Windows Sandbox** (full VM), **LXC** and **WSLC** (Linux containers via WSL).
-
-There is a need for a **micro-VM backend** that can execute various forms of code generated by agents — including Python, JavaScript, C, C++, and Rust applications — with full hardware isolation.
-
-## Proposed Solution
-
-Add a **NanVix micro-VM backend** directly into the existing `wxc-exec.exe` binary. When the JSON config specifies `"containment": "microvm"`, the binary routes to a new `NanVixScriptRunner` (NanVix is the implementation behind the `microvm` containment). The runner stages the user script into a temporary host directory, spawns `nanvixd.exe` with that directory bind-mounted into the guest, and relays stdout/stderr directly to the parent process. The guest's stdin is closed (`Stdio::null()`) — the script is loaded from the mounted staging directory, not piped over stdin.
-
-**NanVix** is a lightweight microkernel OS that runs inside a WHP (Windows Hypervisor Platform) virtual machine. It provides POSIX-compatible process execution with hardware-enforced isolation. NanVix supports multiple runtimes — the initial integration uses CPython 3.12 with a trimmed FAT32 stdlib filesystem, but the architecture supports JavaScript (QuickJS), C, C++, and Rust binaries as configurable runtimes.
-
-## How It Works
-
-```
-Path A — CLI (direct):
- User: wxc-exec.exe config.json
- └── Parses args → loads JSON → dispatches to NanVixScriptRunner
-
-Path B — SDK (programmatic):
- App calls: spawnSandbox("print('hello')", policy, { containment: "microvm" })
- ├── Builds JSON config with containment = "microvm"
- └── Spawns wxc-exec.exe with the config
-
-Both paths converge here:
- wxc-exec.exe
- ├── Parses JSON config → sees containment = "microvm"
- ├── Creates NanVixScriptRunner (via existing Box dispatch)
- ├── Validates paths next to wxc-exec.exe:
- │ nanvixd.exe, bin/kernel.elf, nanvix_rootfs.img, python3.initrd
- ├── Builds a temp staging directory containing bootstrap.py
- │ (user script wrapped in a small loader preamble) plus any
- │ readwrite/readonly host paths resolved by the policy.
- ├── Ensures a WHP warm-start snapshot exists under /snapshots/
- │ (cold-boots once to generate kernel.vmem + kernel.whp.cbor on
- │ first run; subsequent runs restore from the snapshot)
- ├── Spawns nanvixd.exe as a child process (cwd = snapshot home):
- │ nanvixd.exe -snapshot snapshots/kernel.whp.cbor
- │ -bin-dir /bin
- │ -ramfs /nanvix_rootfs.img
- │ -mount
- │ -- python3.initrd
- ├── stdin is set to Stdio::null() (guest never reads host stdin);
- │ stdout/stderr are streamed live to the parent via relay threads.
- ├── Starts watchdog thread
- ├── Waits for process exit or timeout
- ├── Signals watchdog to cancel, joins all threads
- └── Returns exit code (stdout/stderr relayed directly to parent)
-
-Inside the NanVix VM:
- nanvixd.exe restores (or cold-boots) a WHP virtual machine:
- ├── Loads kernel.elf (NanVix microkernel) from -bin-dir
- ├── Loads python3.initrd as initrd payload
- ├── Maps nanvix_rootfs.img (FAT32 stdlib) into guest memory
- ├── Bind-mounts the host staging directory into the guest
- │ (exposes bootstrap.py and any policy-mapped host paths)
- ├── Kernel splits cmdline on ';':
- │ argv = ["python3.initrd", "/mnt/bootstrap.py"]
- │ env = ["PYTHONHOME=/sysroot"]
- ├── Python executes bootstrap.py from the mounted staging dir
- ├── Script output → stdout (via IKC) → host stdout
- ├── Kernel traces → host stderr
- └── sys.exit(N) → nanvixd exits N (exit code propagated)
-```
-
-## Architecture:
-
-```
- wxc-exec.exe
- │
- config_parser.rs
- reads "containment" field
- │
- ┌──────────────┼──────────────┐
- │ │ │
- AppContainerScript Sandbox NanVix
- Runner (existing) ScriptRunner ScriptRunner (new)
- │ (existing) │
- AppContainer │ Spawn nanvixd.exe
- NTFS ACLs Windows ├── Mount staging dir
- WFP firewall Sandbox VM └── Relay stdout/stderr
-```
-
-## Key Design Decisions
-
-1. **Staging-directory mount for script delivery** — NanVix's cmdline has a 255-byte limit and splits on spaces, so the script cannot be passed as an argument. Instead, the runner writes a `bootstrap.py` (user script + small loader preamble) into a per-invocation temp directory and bind-mounts that directory into the guest via `nanvixd -mount `. Python then executes `bootstrap.py` from the mount. Host stdin is closed (`Stdio::null()`) — no stdin relay is involved. Zero changes to CPython or NanVix.
-
-2. **Raw Python source in `process.commandLine` field** — For AppContainer/Sandbox, `process.commandLine` is a shell command. For the microvm backend, it's raw Python source code. The runner handles interpreter invocation internally. This avoids users needing to understand NanVix's cmdline constraints.
-
-3. **Pre-built artifacts, not built from source** — NanVix binaries (`nanvixd.exe`, `kernel.elf`, `python3.initrd`, `nanvix_rootfs.img`) are downloaded from GitHub pre-releases. MXC does not compile or build NanVix components.
-
-
-4. **Unsupported policies are rejected** — If a config specifies `network` or `processContainer` policies with `containment: "microvm"`, the runner returns a clear error. (Filesystem readwrite/readonly paths are honored via the staging-dir mount.)
-
-5. **Host-side I/O risk mitigated by IKC framing** — `nanvixd.exe` parses guest I/O via IKC (Inter-Kernel Communication) messages using fixed-size frames with bounds checking. A crafted guest could attempt malformed messages. Mitigation: formal fuzzing (future work).
-
-6. **Artifact integrity via hash verification** — Guest artifacts (`python3.initrd`, `kernel.elf`, `nanvix_rootfs.img`) are loaded from disk. If an attacker can modify these files, they control the guest. Mitigation: hash verification at install time via setup scripts (future work).
-
-7. **Timeout = boot + script** — Total timeout is `boot_timeout_ms` (default 60000ms, grace for VM boot + Python init) + `script_timeout` (from JSON `timeout` field). A background watchdog thread terminates the process if the total expires.
-
-8. **Cleanup guarantee** — On normal exit, timeout, or crash: relay threads joined (stdout/stderr fully drained), watchdog cancelled and joined, `nanvixd.exe` termination releases the WHP partition. No orphaned VMs.
-
-## Workspace Changes
-
-```
-mxc/src/
-├── Cargo.toml # Add NanVix to workspace (no new deps)
-├── wxc/
-│ ├── Cargo.toml # UNCHANGED
-│ └── src/main.rs # Add NanVix match arm (2 lines)
-├── wxc_common/
-│ ├── Cargo.toml # UNCHANGED
-│ └── src/
-│ ├── lib.rs # Add: pub mod nanvix_runner (1 line)
-│ ├── models.rs # Add: NanVixConfig struct, ContainmentBackend::MicroVm
-│ ├── wire.rs # Add: MicroVm containment variant (schema source); regenerate schema
-│ ├── config_parser.rs # Add: map_wire_containment "microvm" arm
-│ ├── error.rs # Add: WxcError::NanVix variant
-│ ├── nanvix_runner.rs # NEW — NanVixScriptRunner implementation
-│ ├── appcontainer.rs # UNCHANGED
-│ ├── windows_sandbox_runner.rs # UNCHANGED
-│ ├── script_runner.rs # UNCHANGED
-│ └── ... # All other modules UNCHANGED
-├── wxc_test_driver/ # UNCHANGED
-├── wxc_windows_sandbox_guest/ # UNCHANGED
-└── wxc_windows_sandbox_daemon/ # UNCHANGED
-
-mxc/docs/nanvix-microvm/
-└── nanvix-integration-plan.md # NEW — this document
-
-mxc/tests/configs/
-└── microvm_hello.json # NEW — example microvm config
-```
-
-## Error Handling & Output Semantics
-
-### I/O Model
-
-**stdout/stderr** are relayed live between nanvixd and the parent via pipe relay threads — the runner does not capture or buffer either stream. **stdin is not relayed**: the runner spawns nanvixd with `stdin = Stdio::null()`, so the guest sees no host input.
-
-```
-wxc-exec stdin ──╳ (closed; not relayed into the guest)
-wxc-exec stdout ◀── nanvixd stdout ◀── guest python stdout
-wxc-exec stderr ◀── nanvixd stderr ◀── kernel traces
-```
-
-**Script delivery via mount, not stdin**: The runner writes `bootstrap.py` (the user script wrapped in a loader preamble) into a per-invocation temp staging directory and passes that directory to `nanvixd -mount `. Inside the guest, Python executes `bootstrap.py` from the mounted directory. The SDK does not write the script to wxc-exec's stdin.
-
-### Exit Code Propagation
-
-`sys.exit(42)` inside the guest → `nanvixd.exe` exits with code 42. The runner returns this directly as the process exit code.
-
-### Error Classification
-
-The runner classifies errors using a `NanVixError` enum, allowing consumers to match on error types:
-
-```rust
-enum NanVixError {
- Preflight(String), // Missing binaries, invalid paths
- Platform(String), // WHP unavailable, spawn failure
- Runtime(String), // Stdin broken pipe, VM crash
- Timeout { // Watchdog killed the process
- boot_timeout_ms: u32,
- script_timeout_ms: u32,
- total_ms: u64,
- },
-}
-```
-
-| Variant | Trigger | Example |
-|---------|---------|---------|
-| `Preflight` | Path validation before spawn | `nanvixd not found at /path/to/nanvixd.exe` |
-| `Platform` | `Command::new()` fails | `Failed to spawn nanvixd: The system cannot find the file specified` |
-| `Runtime` | Stdin write or process error | `Failed to write script to nanvixd stdin: Broken pipe` |
-| `Timeout` | Watchdog fires | `NanVix execution timed out after 90000ms` |
-
-All variants are surfaced via stderr output. Preflight and Platform errors prevent the process from spawning (exit code -1). Runtime errors surface as non-zero exit codes from nanvixd. On success, the nanvixd exit code (which reflects the guest Python exit code) is returned directly.
-
-## Configuration Semantics
-
-### JSON Config Format
-
-```json
-{
- "process": {
- "commandLine": "print('Hello from NanVix!')",
- "timeout": 30000
- },
- "containment": "microvm"
-}
-```
-
-### Config Field Mapping
-
-| JSON Field | Rust Field (`ExecutionRequest`) | NanVix Behavior |
-|------------|---------------------------|----------------|
-| `process.commandLine` | `script_code: String` | ✅ **Used** — raw Python source code (not a shell command) |
-| `process.timeout` | `script_timeout: u32` | ✅ **Used** — script execution timeout in ms |
-| `containment` | `containment: ContainmentBackend` | ✅ **Used** — must be `"microvm"` |
-| `workingDirectory` | `working_directory: String` | ❌ **Rejected** — guest has its own filesystem namespace |
-| `processContainer.*` | `policy: ContainerPolicy` | ❌ **Rejected** — not applicable to NanVix |
-| `filesystem.readwritePaths` / `readonlyPaths` | (part of `policy`) | ✅ **Used** — host paths bind-mounted into the guest via the staging dir |
-| `network.*` | (part of `policy`) | ❌ **Rejected** — no network stack in guest |
-| `sandbox.*` | `sandbox_config: WindowsSandboxConfig` | ❌ **Rejected** — microvm is not Windows Sandbox |
-
-**Policy validation**: If a config specifies `containment: "microvm"` alongside `network`, `processContainer`, or `workingDirectory` fields, the runner returns an error.
-
-## Binary Distribution & Versioning
-
-NanVix artifacts are **NOT bundled** in the MXC npm package. They are distributed as **pre-release binaries** from their respective GitHub repositories:
-
-| Artifact | Size | Source |
-|----------|------|--------|
-| `nanvixd.exe` | 7.5 MB | [nanvix/nanvix](https://github.com/nanvix/nanvix) releases |
-| `kernel.elf` | 10.5 MB | [nanvix/nanvix](https://github.com/nanvix/nanvix) releases |
-| `python3.initrd` | 9.1 MB | [nanvix/cpython](https://github.com/nanvix/cpython) releases |
-| `nanvix_rootfs.img` | 35.6 MB | [nanvix/cpython](https://github.com/nanvix/cpython) releases |
-
-Setup scripts (PowerShell & Bash) will download matching pre-release binaries and verify checksums.
-
-## Security Model
-
-### Isolation Comparison
-
-| Property | AppContainer | Windows Sandbox | NanVix |
-|----------|-------------|-----------------|--------|
-| **Isolation level** | Process | Full VM (Hyper-V) | Micro VM (WHP) |
-| **Host FS access** | Restricted by ACLs | Mapped folders only | None (read-only ramfs) |
-| **Network access** | Filtered by firewall | NAT/bridged | None |
-| **Writable storage** | Host FS (restricted) | VM disk | None (read-only) |
-| **Guest OS** | Windows (host) | Windows (guest) | NanVix microkernel |
-
-## Development Phases
-
-### Phase 1 — Backend Implementation
-
-**Goal:** Add NanVix as a functional containment backend in `wxc-exec.exe`.
-
-**What changed:**
-- `models.rs` — Added `MicroVm` variant to `ContainmentBackend`, added `NanVixConfig` struct, added `nanvix_config` field to `ExecutionRequest`
-- `config_parser.rs` — Added `"microvm"` containment parsing and NanVix config
- section parsing (originally via `Raw*` structs; exact adapters now construct
- `common_request_ir::CommonRequestIR`, and normalization maps its nested DTOs
- in `normalize_common_request_ir`)
-- `error.rs` — Added `WxcError::NanVix(String)` variant
-- `nanvix_runner.rs` — **NEW** — `NanVixScriptRunner` implementing `ScriptRunner` trait
-- `lib.rs` — Added `pub mod nanvix_runner`
-- `main.rs` — Added `ContainmentBackend::MicroVm` dispatch arm
-
-**Verified:** Build succeeds, all existing tests pass, E2E test produces correct output.
-
-### Phase 2 — SDK Types & Platform Detection
-
-**Goal:** Make the TypeScript SDK aware of NanVix as a backend option.
-
-**What changes:**
-- `sdk/node/src/types.ts` — Add `'microvm'` to `SandboxingMethod` type, add `NanVixConfig` interface
-- `sdk/node/src/platform.ts` — Detect NanVix availability via `wxc-exec.exe --check-platform`
-- `sdk/node/src/sandbox.ts` — Accept `containment: 'microvm'` in `SandboxSpawnOptions`
-- `wxc/src/main.rs` — Add `--check-platform` subcommand returning JSON capabilities
-
-### Phase 3 — CLI Flags & Setup Scripts
-
-**Goal:** Let users invoke NanVix from the CLI and set up the runtime.
-
-**What changes:**
-- `wxc/src/main.rs` — Add `--microvm` flag (sets `containment: "microvm"` automatically)
-- `scripts/setup-nanvix.ps1` — PowerShell script to download NanVix pre-release binaries
-- `scripts/setup-nanvix.sh` — Bash equivalent for WSL/Linux/CI
-
-### Phase 4 — Testing & Hardening
-
-**Goal:** Comprehensive test coverage for the NanVix backend.
-
-**What changes:**
-- Integration tests exercising full MXC→nanvixd→Python→output pipeline
-- Negative tests: missing modules, network I/O, file writes, timeout
-- WHP-conditional test execution (skip on runners without WHP)
-- Mock `nanvixd.exe` for fast unit testing of error/timeout paths
-
-## Supported Workloads
-
-### Supported
-
-| Workload | Example | Notes |
-|----------|---------|-------|
-| Pure computation | `sum(range(1000000))` | Full Python numeric stack |
-| String processing | `re.findall(r'\d+', text)` | Regex, string ops, encodings |
-| JSON/data manipulation | `json.loads(data)` | json, csv, collections |
-| Math/statistics | `math.factorial(100)` | math, decimal, fractions |
-| Date/time operations | `datetime.datetime.now()` | datetime, calendar |
-| Hash computation | `hashlib.sha256(b'data')` | hashlib |
-| Data structures | `dict`, `list`, `set`, `heapq` | All built-in data structures |
-| Multi-line scripts | Functions, classes, loops | Full Python syntax |
-
-### Not Supported
-
-| Workload | Why | Error User Sees |
-|----------|-----|----------------|
-| Network I/O (`urllib`, `socket`, `http`) | No network stack in guest | `OSError: Function not implemented` |
-| File writing (`open('f','w')`, `tempfile`) | Read-only FAT32 ramfs | `OSError: Read-only file system` |
-| Subprocess (`subprocess.run`, `os.system`) | `fork()` is stubbed | `OSError: Function not implemented` |
-| SSL/TLS (`import ssl`) | `_ssl` module not built | `ModuleNotFoundError: No module named '_ssl'` |
-| ctypes (`import ctypes`) | `_ctypes` module not built | `ModuleNotFoundError: No module named '_ctypes'` |
-| GUI (`tkinter`, `turtle`) | No display server | Module removed from ramfs |
-| Interactive input (`input()`) | guest stdin is closed (`Stdio::null()`); no host stdin relay | `EOFError: EOF when reading a line` |
-| Large memory (>128MB) | Default VM memory limit | Process killed by kernel OOM |
-
-## End-User Experience
-
-### CLI Usage
-
-```bash
-# Run a Python script in a NanVix micro-VM
-wxc-exec.exe microvm_config.json
-
-# With debug output
-wxc-exec.exe --debug microvm_config.json
-```
-
-### Example Config (`microvm_config.json`)
-
-```json
-{
- "process": {
- "commandLine": "import sys\nprint(f'Python {sys.version} on {sys.platform}')",
- "timeout": 30000
- },
- "containment": "microvm"
-}
-```
-
-### Expected Output
-
-```
-$ wxc-exec.exe --debug microvm_config.json
-Script code length: 58
-Working directory:
-Script timeout: 30000
-Container name: CLI
-NanVix: nanvixd="C:\\nanvix\\bin\\nanvixd.exe"
-NanVix: bin_dir="C:\\nanvix\\bin"
-NanVix: ramfs="C:\\nanvix\\bin\\nanvix_rootfs.img"
-NanVix: python="C:\\nanvix\\bin\\python3.initrd"
-NanVix: process exited with code 0
-Exit code: 0
-Python 3.12.3 (tags/0715636-nanvix-03bba66:0715636) on nanvix
-```
-
-### SDK Usage (After Phase 2)
-
-```typescript
-import { spawnSandboxAsync } from '@microsoft/mxc-sdk';
-
-const result = await spawnSandboxAsync(
- "print('Hello from NanVix!')",
- {}, // no policy needed — NanVix is isolated by design
- { containment: 'microvm' }
-);
-
-console.log(result.stdout); // "Hello from NanVix!"
-console.log(result.exitCode); // 0
-```
-
-
-## Testing Strategy
-
-### Unit Tests
-
-| Test | What It Validates |
-|------|------------------|
-| `default_config_values` | NanVixConfig defaults (python3.initrd, /sysroot, 60s) |
-| `total_timeout_adds_boot_and_script` | Timeout arithmetic |
-| `resolve_nanvixd_missing_returns_error` | Path resolution error handling |
-| Config parser: `"microvm"` containment | JSON parsing of microvm section |
-
-### Integration Tests (requires WHP + NanVix binaries)
-
-| Test | What It Validates |
-|------|------------------|
-| Hello world | Basic script → stdout pipeline (script delivered via mounted staging dir) |
-| Script with spaces | Mount-based delivery bypasses the cmdline space-splitting limit |
-| Exit code propagation | `sys.exit(42)` → exit code 42 |
-| Missing nanvixd | Preflight error message |
-| Timeout | Watchdog kills after deadline |
-| Large script | >255 bytes (would exceed cmdline limit if it weren't mounted) |
-
-## Open Design Questions
-
-| # | Question | Status |
-|---|----------|--------|
-| 1 | Should NanVix VMs be pooled/warm-started? | |
-| 2 | Should there be a size limit to the VM | |
-| 3 | Should there be a writable FS area in the guest? | Parked for Phase 2 |
-| 4 | Should we support TypeScript or other payloads? | Architecture supports it |
diff --git a/docs/nanvix-microvm/nanvix.md b/docs/nanvix-microvm/nanvix.md
deleted file mode 100644
index 708cae788..000000000
--- a/docs/nanvix-microvm/nanvix.md
+++ /dev/null
@@ -1,250 +0,0 @@
-# Nanvix MicroVM Backend
-
-Nanvix MicroVM is an experimental containment backend for MxC. It is powered by the
-[Nanvix OS/VM](https://aka.ms/nanvix) and runs untrusted code with hardware-enforced isolation
-via the Windows Hypervisor Platform (WHP) on Windows or KVM on Linux.
-
-## Key Features
-
-- **Fast cold-start** — ~100 ms from process spawn to guest code execution (Windows warm-start via WHP snapshot; Linux uses cold boot via KVM every run)
-- **Low Memory Footprint** — Resident memory size of ~100 MB
-- **Hardware-Enforced Isolation** — Runs guest code inside a lightweight virtual machine (VM)
-
-## Requirements
-
-### Windows
-
-- Windows with WHP enabled (`bcdedit /set hypervisorlaunchtype auto`)
-- Nanvix runtime binaries (`nanvixd.exe`, `kernel.elf`, `python3.initrd`, `nanvix_rootfs.img`) placed next to `wxc-exec.exe`
-- Build with `--with-microvm` (`build.bat --with-microvm` or `cargo build -p wxc --features microvm`)
-- `--experimental` flag (Nanvix MicroVM is an experimental feature)
-
-### Linux
-
-- Linux with KVM available at `/dev/kvm` (and the invoking user has read/write access to it)
-- Nanvix runtime binaries (`nanvixd.elf`, `kernel.elf`, `python3.initrd`, `nanvix_rootfs.img`) placed next to `lxc-exec` (the build script downloads and stages them automatically)
-- Build with `--with-microvm` (`./build.sh --with-microvm` or `cargo build -p lxc --features microvm`)
-- `--experimental` flag (Nanvix MicroVM is an experimental feature)
-
-> **Note:** On Linux, WHP snapshots are not used. Each invocation cold-boots
-> the VM via KVM. Snapshot-based warm-start is Windows-only.
-
-### Offline builds
-
-By default the `nanvix_binaries` build script downloads the NanVix release
-assets at compile time. For air-gapped or hermetic builds, pre-fetch the
-binaries and point the `NANVIX_BIN` environment variable at the directory
-containing them:
-
-```
-# Windows (PowerShell)
-$env:NANVIX_BIN = "C:\path\to\nanvix-binaries"
-
-# Linux / macOS
-export NANVIX_BIN=/path/to/nanvix-binaries
-```
-
-When `NANVIX_BIN` is set, the build performs no network downloads and uses the
-provided directory directly. The directory must contain the required binaries
-(the flat files plus the `bin/` subdirectory); their checksums are still
-verified against `checksums.json`. The easiest way to produce such a directory
-is to run a normal `--with-microvm` build once and copy the staged
-`nanvix-binaries` directory out of `OUT_DIR`.
-
-> **Snapshots are not trusted in offline mode.** WHP warm-start snapshots
-> (`snapshots/kernel.vmem`, `snapshots/kernel.whp.cbor`) are *not* covered by
-> `checksums.json` — in a normal build they are generated locally, not
-> downloaded. Any `snapshots/` directory inside `NANVIX_BIN` is therefore
-> ignored (never copied next to the executable), and the runtime cold-boots on
-> first use to regenerate a verified snapshot. This prevents an unverified VM
-> memory image from being warm-booted against otherwise-verified binaries.
-
-## Quick Start
-
-```json
-{
- "version": "0.10.0-alpha",
- "process": {
- "commandLine": "print('Hello from MicroVM!')",
- "timeout": 30000
- },
- "containment": "microvm"
-}
-```
-
-```bash
-wxc-exec.exe --experimental config.json
-```
-
-## SDK Usage
-
-Use `spawnSandboxFromConfig` with `usePty: false` for reliable exit codes and
-separate stdout/stderr streams:
-
-```typescript
-const child = spawnSandboxFromConfig(config, {
- experimental: true,
- usePty: false,
-});
-
-```
-
-## Filesystem Policy
-
-### readwrite_paths
-
-Host directories or files listed in `readwritePaths` are copied into a private
-per-run staging directory before boot. Nanvix mounts are snapshot-based — host
-files are **not** modified while the guest is running. No junctions or live host
-mounts are used.
-
-```json
-{
- "version": "0.10.0-alpha",
- "process": {
- "commandLine": "import os\npath = 'C:\\\\Users\\\\me\\\\work'\nwith open(os.path.join(path, 'result.txt'), 'w') as f:\n f.write('done')",
- "timeout": 30000
- },
- "containment": "microvm",
- "filesystem": {
- "readwritePaths": ["C:\\Users\\me\\work"]
- }
-}
-```
-
-Inside the guest, host paths in the script are transparently rewritten to their
-guest mount equivalents at staging time. The script uses the original host paths
-and the staging layer translates them before the code reaches the VM.
-
-| Host path | Guest path |
-| ------------------ | --------------------------- |
-| `C:\Users\me\work` | `/mnt/rw/c/Users/me/work` |
-| `C:\data\ref-data` | `/mnt/rw/c/data/ref-data` |
-
-**Copyback semantics:** After `nanvixd` exits normally, MXC copies the modified
-snapshot back to the original host paths. Copyback runs for both exit code `0`
-and non-zero guest exit codes. It is skipped for preflight failure, spawn
-failure, watchdog timeout, and runner/runtime errors — no partial state is
-leaked to the host.
-
-### readonly_paths
-
-Host directories listed in `readonlyPaths` are copied into the staging directory
-with read-only file attributes. Writes return `EACCES`. Read-only paths are
-never copied back to the host.
-
-```json
-{
- "filesystem": {
- "readonlyPaths": ["C:\\data\\reference"]
- }
-}
-```
-
-### denied_paths
-
-Not supported for MicroVM. If `deniedPaths` is specified, the config is rejected with an error.
-
-## Constraints
-
-| Constraint | Value |
-| --------------------------------------- | ------------------------------------------- |
-| Single file size | < 4 GB (FAT32 limit) |
-| Guest RAM | 256 MB |
-| Symlinks/reparse points in source paths | Not supported (rejected at preflight) |
-| Junctions for staging | Not used |
-| `workingDirectory` | Not supported (guest CWD is `/`) |
-| Network policy | v0.10 isolated or explicitly unrestricted host networking |
-
-## Networking
-
-Host networking is **opt-in**. Exact v0.10 supports two coherent directional
-postures: all three of egress default, ingress default and host-loopback deny
-leaves networking disabled; all three explicitly allow enables unrestricted
-host networking through `-allow-host-networking`. Mixed postures are rejected,
-including egress allow with omitted ingress defaults.
-
-```json
-{
- "version": "0.10.0-alpha",
- "containment": "microvm",
- "process": { "commandLine": "print('network enabled')" },
- "network": {
- "egress": { "default": "allow" },
- "ingress": { "default": "allow", "hostLoopback": "allow" }
- }
-}
-```
-
-Disabled networking prevents guest socket creation (`OSError: [Errno 134]`).
-Unrestricted networking includes host-backed bind/listen capabilities;
-NanVix cannot independently enforce ingress or host-loopback restrictions.
-Directional egress rules are explicitly rejected because the legacy IPv4
-filter does not implement their full semantics, including default-deny DNS.
-Runtime proxy configuration is also unsupported.
-
-### Legacy per-host filter implementation (compatibility/reference only)
-
-The following describes the retained legacy runtime filter, not accepted v0.10
-JSON vocabulary. The exact cutover does not silently translate directional
-rules into this weaker contract.
-
-`allowedHosts` and `blockedHosts` are supported and forwarded to the guest's
-host-side socket proxy, which enforces egress at `connect()`. The guest filter
-is **allow-XOR-block**, so the two lists are mutually exclusive (specifying both
-is rejected at preflight). Presence of either list implies host networking, so
-`defaultPolicy` is ignored when a list is set:
-
-| `allowedHosts` | `blockedHosts` | Effect |
-| -------------- | -------------- | ------ |
-| _(empty)_ | _(empty)_ | follows `defaultPolicy` (`block` = no egress, `allow` = unrestricted) |
-| `[A, ...]` | _(empty)_ | **allowlist** — only the listed destinations are reachable |
-| _(empty)_ | `[B, ...]` | **blocklist** — everything except the listed destinations is reachable |
-| `[A, ...]` | `[B, ...]` | rejected at preflight (mutually exclusive) |
-
-Entries may be IPv4 literals (`93.184.216.34`), IPv4 CIDR blocks
-(`10.0.0.0/8`), or hostnames. Hostnames are resolved to their IPv4 (A-record)
-addresses at preflight; IPv6 (AAAA) results are dropped because the guest filter
-is IPv4-only. Resolution failures are handled per direction so neither list ever
-fails open:
-
-- **allowlist** (deny-by-default): each dropped entry is logged as a warning and
- the run continues, since dropping an entry only *narrows* access. If the list
- resolves to **no** IPv4 address at all, the run is rejected at preflight rather
- than silently allowing all traffic.
-- **blocklist** (allow-by-default): **any** entry that resolves to no IPv4
- address rejects the run at preflight. Silently dropping a blocked host would
- let traffic the policy explicitly blocks flow freely, and the static preflight
- filter cannot enforce a name that does not resolve — so the blocklist
- fails closed.
-
-**DNS:** in allowlist mode the guest daemon automatically exempts the DNS port
-(53), so name resolution works without adding the resolver to `allowedHosts`.
-
-Network proxies (`network.proxy`) are not supported and are rejected at
-preflight.
-
-```jsonc
-{
- "containment": "microvm",
- "process": { "commandLine": "import urllib.request; ..." },
- // Historical legacy shape, not accepted by the exact v0.10 contract:
- "network": { "defaultPolicy": "allow" }
-}
-```
-
-```jsonc
-{
- "containment": "microvm",
- "process": { "commandLine": "import urllib.request; ..." },
- // Historical legacy shape, not accepted by the exact v0.10 contract:
- "network": { "allowedHosts": ["example.com", "10.0.0.0/8"] }
-}
-```
-
-## Not Supported
-
-| Workload | Error |
-| ------------------------------- | ----------------------------------- |
-| Both `allowedHosts` + `blockedHosts` | Rejected at preflight (mutually exclusive) |
-| File writing outside `/mnt/rw/` | `OSError: Read-only file system` |
diff --git a/docs/nvx-backend-gaps.md b/docs/nvx-backend-gaps.md
new file mode 100644
index 000000000..7c0806058
--- /dev/null
+++ b/docs/nvx-backend-gaps.md
@@ -0,0 +1,257 @@
+# NVX Backend: MXC policy compatibility and remaining gaps
+
+## Status and scope
+
+The original NVX prototype accepted a real MXC `0.9.0-dev` JSON, validated it
+against the schema, and adapted supported fields into typed NVX launch plans.
+The prototype first exercised those plans on Windows through OpenVMM and WHP.
+
+MicroVM remains the sole public MXC backend identity in the published exact
+`0.9.0-alpha` one-shot contract, selected with `containment: "microvm"`. NVX is
+the concrete implementation behind that abstraction. A MicroVM-enabled build
+returns a typed backend-unavailable error for execution, and capability probes
+do not advertise MicroVM while the runtime is incomplete.
+
+## Current architecture
+
+The intended MXC integration uses a direct ownership boundary:
+
+```mermaid
+flowchart LR
+ MXC[MXC] --> OpenVMM[OpenVMM / WHP]
+ OpenVMM --> Agent[NVX PID 1 guest agent]
+```
+
+MXC owns the OpenVMM process and the host side of the versioned guest-agent
+control channel; no additional host service sits between them. The NVX policy
+harness stands in for the future MXC adapter and proves this boundary end to
+end.
+
+## Implemented and validated capabilities
+
+| MXC policy or control | Validated behavior |
+| --- | --- |
+| `filesystem.readonlyPaths` | Host paths were exposed to the guest as read-only mappings. Guest writes were denied. |
+| `filesystem.readwritePaths` | Host paths were exposed as writable mappings and guest writes were reflected on the host. |
+| `filesystem.deniedPaths` | Denied subtrees are hidden by the host filesystem provider, including direct, parent-relative, symlink/junction alias, and second-mount access. Unsafe policies are rejected before boot. |
+| Mapping validation | Duplicate, overlapping, escaping, symlink, reparse-point, and containment-invalid mappings were rejected before launch. |
+| Undeclared-path isolation | The guest could not access undeclared host paths or bypass policy through raw virtio-fs exports. |
+| `process.commandLine` | The command was lowered exactly to `["/bin/sh", "-c", commandLine]`. |
+| `process.cwd` | The requested guest working directory was preserved. |
+| `process.env` | Non-reserved environment variables were preserved exactly. |
+| `process.timeout` | The timeout flowed from real MXC JSON into the OpenVMM process execution plan. |
+| Exec-time `runtimeConfig.networkProxy` | The sole supported proxy form was normalized and injected as controlled proxy environment variables during exec. |
+| `network.egress.default` | Both `allow` and `deny` are enforced by the portable network profile. |
+| `network.ingress.default: deny` | New inbound connections are denied while replies to guest-initiated traffic remain available. Unsupported unrestricted ingress is rejected before launch. |
+| `network.egress.allow` and `network.egress.deny` | IPv4/CIDR TCP and UDP destination rules are enforced, with deny precedence and default-deny behavior. |
+| `network.ingress.hostLoopback: deny` | General guest-to-host loopback and host-to-guest forwards are denied while the exact TCP runtime-proxy endpoint remains reachable. UDP on the proxy port remains denied. |
+| Explicit host-to-guest forwarding | Selected TCP or UDP localhost ports can be deliberately published with NVX-specific forward options. |
+| Fixed workload identity | Managed workloads run as the selected fixed non-root UID/GID with capabilities removed and `no_new_privs`. |
+| State-aware lifecycle | Provision, start, repeated exec, stop, and deprovision preserve warm guest state and reject invalid transitions. |
+| Bounded outcomes | Execution and VM-level reports expose bounded result categories, numeric status, operation IDs, and teardown outcomes without including command arguments, environment values, or credentials. |
+| Version, containment, phase, and IDs | Exact schema version, temporary `containment: "vm"`, lifecycle phase, `sandboxId`, and `containerId` constraints were enforced. |
+
+## Remaining NVX policy gaps
+
+| MXC policy or control | Current NVX behavior | Remaining decision or implementation |
+| --- | --- | --- |
+| `network.ingress.hostLoopback: allow` | The portable socket-NAT profile requires explicit per-port forwarding. Generic `allow` without forwards is rejected before VM resources are opened. | Implement true bidirectional host-loopback connectivity without an NVX-specific port list, or retain the fail-closed rejection as a documented backend limitation. |
+| Full schema 0.9 egress-rule vocabulary | NVX supports IPv4/CIDR rules with one TCP or UDP destination port. | Decide whether to implement IPv6, CIDR `except`, port ranges, ICMP, protocol `any`, and omitted destination/port selectors. Every unsupported form must be rejected before launch. |
+| `network.ingress.default: allow` | NVX implements fixed ingress denial and rejects unrestricted ingress. | Implement unrestricted inbound support only if it is required for the NVX backend; otherwise preserve and document the rejection. |
+| Caller-provided proxy environment variables | NVX rejects `HTTP_PROXY`, `HTTPS_PROXY`, lowercase variants, `NO_PROXY`, and mixed-case equivalents so `runtimeConfig.networkProxy` remains authoritative. | Decide whether this remains a permanent proxy-hygiene invariant or whether caller-provided values should be supported with explicit precedence and enforcement semantics. |
+| Detailed telemetry | NVX returns bounded execution and teardown outcomes. | Decide whether MXC needs additional structured lifecycle, startup, performance, and failure facts from NVX. MXC remains responsible for consent, administrative policy, per-request gating, and event emission. |
+
+## Intentionally unsupported policy
+
+These fields are not missing NVX mechanisms. The MXC adapter should reject
+them for this backend:
+
+| Policy | Required behavior |
+| --- | --- |
+| `lifecycle` on the one-shot surface | One-shot execution destroys the VM. Persistence and reuse belong to the explicit state-aware lifecycle. Requests for retained one-shot state must be rejected. |
+| Policy mutation in the wrong phase | Provision-only and exec-only fields are rejected outside their valid lifecycle phase. |
+| Wrong version, non-VM containment, or invalid lifecycle IDs | Invalid control-plane inputs are rejected before effects. |
+| `ui` | A Linux microVM has no equivalent MXC desktop-policy surface. |
+| `fallback` | NVX does not silently select a weaker containment backend. |
+
+## Accepted inert metadata
+
+| Field | Behavior |
+| --- | --- |
+| `$schema` | Accepted for schema tooling; it does not change the launch plan. |
+| `_comment` | Accepted as descriptive metadata; it does not change runtime behavior. |
+| Omitted optional parent sections | Accepted; their absence does not create unsupported policy or change the supported defaults. |
+
+## Schema changes
+
+The published exact `0.9.0-alpha` one-shot contract includes
+`containment: "microvm"` in its generated schema and TypeScript wire types.
+The broader `0.10.0-alpha` development contract accepts the same public value
+for forward compatibility. The internal `nvx` implementation name is not
+accepted as a public containment value. An `experimental.nvx.provision`
+section is necessary only if images remain caller-configurable.
+
+Future state-aware provision design:
+
+> **Not runnable in Phase 1.** The exact development state-aware registry does
+> not yet include NVX, so the current parser rejects this request before
+> dispatch. This example records the intended Phase 2 request shape only.
+
+```json
+{
+ "$schema": "https://aka.ms/mxc/schemas/0.10.0-alpha.json",
+ "version": "0.10.0-alpha",
+ "phase": "provision",
+ "containment": "microvm",
+ "filesystem": {
+ "readonlyPaths": [
+ "C:\\workspace\\source"
+ ],
+ "readwritePaths": [
+ "C:\\workspace\\output"
+ ],
+ "deniedPaths": []
+ },
+ "network": {
+ "egress": {
+ "default": "deny"
+ },
+ "ingress": {
+ "default": "deny",
+ "hostLoopback": "deny"
+ }
+ },
+ "experimental": {
+ "nvx": {
+ "provision": {
+ "layers": [
+ {
+ "role": "distro",
+ "path": "C:\\nvx\\images\\distro.erofs",
+ "uuid": "11111111-1111-1111-1111-111111111111"
+ },
+ {
+ "role": "runtime",
+ "path": "C:\\nvx\\images\\runtime.erofs",
+ "uuid": "22222222-2222-2222-2222-222222222222"
+ }
+ ],
+ "scratchPath": "C:\\nvx\\images\\scratch.ext4"
+ }
+ }
+ }
+}
+```
+
+- `distro` is the read-only base operating-system and userspace layer.
+- `runtime` is an optional read-only layer containing the workload runtime and
+ supporting files.
+- `scratchPath` is the writable ext4 image used for changes made while the
+ sandbox is running.
+
+## Binary acquisition and packaging
+
+`build.bat --with-microvm` enables the incomplete x64 Windows/WHP foundation. It is
+the only active micro-VM packaging path.
+
+The build pins the exact `microsoft/nvx` release tag, platform asset name, and
+per-file SHA-256 checksums in the repository. It downloads and verifies the
+platform archive during the build, reuses the verified Cargo-output cache, and
+stages the files beside the MXC executor. Sandbox execution never downloads
+artifacts. `NVX_BIN` is an offline override for a pre-fetched bundle directory;
+its files must pass the same checksum validation.
+
+The current published pin is `v0.1.0-dev.5c86da3dff02`. Its Windows/WHP asset
+contains only the platform files (`openvmm.exe`, `vmlinux`, and
+`initramfs.cpio.gz`); it does not contain the distro/runtime EROFS images or
+writable scratch image required to run a workload.
+
+## Remaining MXC integration work
+
+The schema/wire, policy/model, typed-unavailable dispatch, and pinned Windows
+artifact-acquisition foundations preserve MicroVM as the public abstraction
+while replacing the removed NanVix implementation with NVX.
+Phase 2 runtime remains blocked on all of the following:
+
+- NVX-produced distro and runtime EROFS images plus a writable scratch image;
+- a proven combined managed-sandbox/virtio-fs contract; and
+- the required WHP runner.
+
+After those inputs are available, MXC still needs the state-aware and one-shot
+OpenVMM runtime, guest streams and lifecycle outcomes, applicable SDK/FFI
+surfaces, capability advertisement, and MXC-native E2E/CI coverage.
+
+
+### E2E testing plan
+
+- Verify one-shot launch, command execution, output, exit status, and teardown.
+- Verify provision, start, repeated exec, stop, and deprovision, including
+ invalid lifecycle IDs and phase transitions.
+- Run positive and negative checks for every supported filesystem, network, and
+ process policy.
+- Prove every unsupported policy is rejected before VM or host effects.
+- Exercise timeout, cancellation, guest/OpenVMM failure, reconnect, stream, and
+ cleanup behavior.
+- Run applicable Rust, TypeScript, C#, and FFI paths on Windows x64 and ARM64 CI
+ hosts.
+
+## Appendix A: Current OpenVMM and NVX baseline
+
+This is the current direct OpenVMM/NVX capability baseline after the merged
+policy work. An MXC backend adapter still needs to select these controls from
+the exact `0.10.0-alpha` request and expose their outcomes through MXC APIs.
+
+| Area | Supported today | Limitation for MXC |
+| --- | --- | --- |
+| Filesystem | Virtio-fs mappings support `ro`/`rw`, safe common-root planning, undeclared-path isolation, and explicit denied subtrees. | Policies that cannot be represented safely are rejected before boot. |
+| Sandbox filesystem | The sandbox workload uses read-only EROFS layers, a writable ext4 scratch layer, private mount/PID/UTS namespaces, a private `/dev`, a fixed unprivileged identity, no Linux capabilities, and `no_new_privs`. | The MXC adapter must package and select the appropriate image and working layers. |
+| Lifecycle | The host and guest protocols support provision, start, repeated exec, stop, deprovision, reconnect, operation cancellation, and bounded outcomes. | MXC still needs to register and dispatch NVX on its one-shot and state-aware surfaces. |
+| Networking | The portable network profile works across WHP, KVM, and MSHV and enforces egress defaults, IPv4/CIDR TCP/UDP rules, deny ingress, host-loopback denial, exact proxy reachability, and explicit host-to-guest forwards. | Generic host-loopback allow, unrestricted ingress, and the broader schema 0.9 rule vocabulary remain unsupported. |
+| HTTP/HTTPS proxy | An exec-time `runtimeConfig.networkProxy` endpoint is injected as controlled proxy environment variables and allowed as one exact TCP endpoint under host-loopback denial. | Caller-provided proxy variables remain rejected; UDP to the same endpoint is not allowed. |
+| Outcomes and telemetry | Execution, lifecycle, and teardown return bounded status and failure categories without workload secrets. | Richer MXC telemetry facts are optional future work; MXC owns telemetry policy and emission. |
+
+See the NVX [run guide](https://github.com/microsoft/nvx/blob/dev/doc/run.md)
+and [command-line reference](https://github.com/microsoft/nvx/blob/dev/doc/usage.md)
+for the current direct runtime options.
+
+## Appendix B: NVX issue tracking
+
+The historical issues captured the original prototype gaps. Repository
+migration means some issue links may no longer resolve; the merged PRs above
+are the durable implementation references.
+
+| Historical issue | Current outcome |
+| --- | --- |
+| #37 - read-only/read-write mappings and mount isolation | Closed after prototype validation. |
+| #38 - directional ingress and egress defaults | Implemented by microsoft/nvx#51 and nanvix/openvmm#71. NVX supports both egress defaults and a deny ingress posture; unrestricted ingress remains rejected. |
+| #39 - fixed unprivileged workload identity | Implemented by microsoft/nvx#58 and nanvix/openvmm#75. |
+| #40 - sandbox lifecycle | Implemented by microsoft/nvx#58 and nanvix/openvmm#75. |
+| #41 - L3/L4 egress-rule filtering | Implemented for IPv4/CIDR TCP and UDP by microsoft/nvx#58 and nanvix/openvmm#75. The broader schema 0.9 vocabulary remains a compatibility decision. |
+| #42 - host-loopback network policy | Partially resolved by microsoft/nvx#61 and nanvix/openvmm#76. Deny is enforced, the exact TCP runtime proxy is preserved, UDP leakage is closed, and explicit forwards remain available. Generic `hostLoopback: allow` without forwards remains unsupported. |
+| #43 - denied filesystem paths | Implemented by microsoft/nvx#58 and nanvix/openvmm#75. |
+| #44 - exec-time proxy configuration | Closed after prototype validation; subsequent work preserved an exact TCP-only proxy exception under host-loopback denial. |
+| #45 - fail-closed backend selection and policy admission | Implemented across the policy and lifecycle changes; unsupported policy is rejected rather than weakened. |
+| #46 - typed execution/lifecycle outcomes | Implemented as bounded execution, operation, VM-exit, and teardown reports. |
+| #47 - detailed telemetry | Bounded outcomes are implemented. Richer telemetry facts remain optional follow-up work rather than a schema 0.9 blocker. |
+
+## Appendix C: GitHub Copilot CLI schema migration
+
+The current GitHub Copilot CLI builds MXC policy version `0.7.0-alpha` and
+emits the legacy `allowOutbound`, `allowLocalNetwork`, and `network.proxy`
+fields. It no longer provides a raw `sandbox.config` passthrough.
+
+Before the CLI can use the MicroVM (NVX) backend, it must migrate its generated
+policy to the exact published `0.9.0-alpha` contract:
+
+- map outbound allow/block to `network.egress.default`;
+- map local-network intent to `network.ingress.default` and
+ `network.ingress.hostLoopback`;
+- express direct egress exceptions through `network.egress.allow` and
+ `network.egress.deny`;
+- move the supported loopback HTTP/HTTPS proxy endpoint to
+ `runtimeConfig.networkProxy`;
+- update its sandbox settings, policy checks, telemetry, UI, and tests to use
+ the new directional model.
+
+This migration belongs to the GitHub Copilot CLI and is a prerequisite for NVX
+integration; NVX does not need to implement the legacy network contract.
diff --git a/docs/sandbox-policy/0.8.0/networking/networking.md b/docs/sandbox-policy/0.8.0/networking/networking.md
index 0d606cd19..c0ae6d35e 100644
--- a/docs/sandbox-policy/0.8.0/networking/networking.md
+++ b/docs/sandbox-policy/0.8.0/networking/networking.md
@@ -557,7 +557,7 @@ limit it hit; neither is silently truncated, and no partial policy is installed.
reserved for the backend migration work; until that lands, callers must continue using the legacy unrestricted
acknowledgment. Other network/proxy policy is rejected.
In GA for process isolation only (identity, lifecycle).
-- **Hyperlight, Nanvix:** Not in this GA scope doc. Additional follow up is needed to confirm their capabilities and whether they align with this doc.
+- **Hyperlight, NVX:** Not in this GA scope doc. Additional follow up is needed to confirm their capabilities and whether they align with this doc.
## Gaps and limitations
diff --git a/docs/schema-codegen.md b/docs/schema-codegen.md
index 036feafce..a9b94a125 100644
--- a/docs/schema-codegen.md
+++ b/docs/schema-codegen.md
@@ -7,12 +7,15 @@ MXC generates artifacts from exact registered configuration contracts:
| Exact `0.9.0-alpha` | `src/core/mxc_config_contract/src/published/v0_9_0_alpha/` | Authoritative closed published contract and versioned TypeScript oracle |
| Exact `0.10.0-alpha` | `src/core/mxc_config_contract/src/dev/` | Authoritative closed development contract and versioned TypeScript oracle |
-Published schemas under `schemas/stable/` are immutable release artifacts.
+Published schemas under `schemas/stable/` are revision-locked release artifacts.
`mxc_schema_gen` renders published v0.9 into temporary output so
`check-contract-codegen.js` can compare the enforcing Rust model with the
committed stable schema and TypeScript oracle. The gate also compares
pre-existing stable schemas with the merge base and validates their registry
-identities.
+identities. A deliberate published-contract amendment must increment that
+version's `stableRevisions` entry in `schemas/schema-version.json` exactly once;
+an artifact change without the bump, or a bump without an artifact change,
+fails validation.
## Sources of truth
@@ -23,9 +26,9 @@ and `string_marker!` macros implement `JsonSchema` so deserialization and
generated constants cannot drift.
`src/core/mxc_config_contract/src/published/v0_9_0_alpha/` defines the exact
-published v0.9 contract, including IsolationSession and WSLC one-shot and
-state-aware roots. It remains renderable for verification; generation does
-not make the stable artifact mutable.
+published v0.9 contract, including MicroVM one-shot and IsolationSession and
+WSLC one-shot and state-aware roots. It remains renderable for verification;
+generation does not by itself authorize a published-contract amendment.
`mxc_schema_support` owns shared integer normalization, deterministic root
rendering, and TypeScript emission. `mxc_schema_gen` uses those helpers for
diff --git a/docs/schema.md b/docs/schema.md
index 81e2b434f..aa1eb5b68 100644
--- a/docs/schema.md
+++ b/docs/schema.md
@@ -279,7 +279,8 @@ use:
| Windows ProcessContainer (AppContainer / BaseContainer) | First `readwritePaths` entry that is an existing directory, else the first such `readonlyPaths` entry, else the system drive root (`%SystemDrive%\`). Never `NULL`. |
| Seatbelt (macOS) | Same precedence, with `~` expanded as the profile expands it; falls back to `/`. |
| LXC / WSL Container | The container root — see [`docs/lxc-support/lxc-backend.md`](lxc-support/lxc-backend.md). |
-| MicroVM (NanVix) / Hyperlight | Not applicable — these backends reject a working directory outright. |
+| NVX | Guest root when omitted; a caller-supplied guest path is preserved. |
+| Hyperlight | Not applicable — this backend rejects a working directory outright. |
Policy entries that are blank, name a file, or do not exist yet are skipped:
a process cannot be launched in any of them.
@@ -383,7 +384,12 @@ force a particular backend.
|-------|------------|
| `"process"` | `processcontainer` on Windows, `bubblewrap` on Linux, `seatbelt` on macOS |
| `"vm"` | Full hardware-virtualised VM isolation. Resolves to `windows_sandbox` on Windows. |
-| `"microvm"` | MicroVM on Windows (NanVix via the Windows Hypervisor Platform). Experimental. |
+| `"microvm"` | Compatibility-preserved public MicroVM identity. The same wire value directly selects the concrete MicroVM backend, implemented internally by NVX. |
+
+The one-shot `microvm` value is available in the published exact
+`0.9.0-alpha` contract. It remains runtime-experimental and Windows x64-only;
+publication does not remove the `--experimental` authorization requirement.
+The abstract `vm` intent remains development-only in `0.10.0-alpha`.
#### Concrete backends
@@ -393,7 +399,7 @@ force a particular backend.
| `"windows_sandbox"` | Windows Sandbox VM isolation. Dual-mode: a transient **one-shot** runner that launches a fresh disposable VM per execution, and a **state-aware** lifecycle backed by a long-lived per-sandbox daemon. |
| `"wslc"` | Linux containers via the WSL Container SDK |
| `"lxc"` | Native LXC container isolation. No abstract intent resolves to LXC; request it explicitly. |
-| `"microvm"` | MicroVM isolation via Windows HyperV Platform (NanVix microkernel) |
+| `"microvm"` | MicroVM isolation implemented by NVX and hosted by OpenVMM/WHP (experimental, Windows x64 foundation; runtime unavailable in Phase 1) |
| `"hyperlight"` | MicroVM isolation via Hyperlight + Unikraft with an embedded CPython snapshot (experimental) |
| `"isolation_session"` | Windows isolation session — runs the workload as a freshly-provisioned, per-execution isolated user account in its own OS-managed session. Dual-mode: one-shot and state-aware. |
| `"seatbelt"` | macOS sandbox isolation (Seatbelt). Requires macOS 15 or later — see [`docs/seatbelt/seatbelt-backend.md`](seatbelt/seatbelt-backend.md). |
@@ -474,7 +480,7 @@ Registered contracts:
| `"0.6.0-alpha"` | Published; minimum supported |
| `"0.7.0-alpha"` | Published |
| `"0.8.0-alpha"` | Published |
-| `"0.9.0-alpha"` | Published; current stable |
+| `"0.9.0-alpha"` | Published; current stable; includes one-shot `microvm` |
| `"0.10.0-alpha"` | Mutable development contract |
An absent version, a retired version, or any unregistered spelling such as
diff --git a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api-overview.md b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api-overview.md
index aa7f4ff56..162004d03 100644
--- a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api-overview.md
+++ b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api-overview.md
@@ -20,7 +20,7 @@ function signatures throughout. One-line summaries; full definitions live in
| Type | Where | Role |
|---|---|---|
-| `ContainmentType` / `ContainmentBackend` | `sdk/node/src/types.ts` | Two-tier containment names: `ContainmentType` for abstract intents (`'process' \| 'vm' \| 'microvm'` today); `ContainmentBackend` for concrete runners (`'processcontainer' \| 'windows_sandbox' \| 'lxc' \| 'wslc' \| 'microvm' \| 'seatbelt' \| 'isolation_session'`). Wire `containment` accepts either. The deprecated alias `SandboxingMethod = ContainmentType \| ContainmentBackend` is retained for back-compat. |
+| `ContainmentType` / `ContainmentBackend` | `sdk/node/src/types.ts` | Two-tier containment names: `ContainmentType` for public intents (`'process' \| 'vm' \| 'microvm'`); `ContainmentBackend` for concrete runners (`'processcontainer' \| 'windows_sandbox' \| 'lxc' \| 'wslc' \| 'microvm' \| 'hyperlight' \| 'seatbelt' \| 'isolation_session'`). The compatibility-preserved `microvm` wire value appears in both because it is the public MicroVM identity and directly selects that backend; NVX remains internal. Wire `containment` accepts either. The deprecated alias `SandboxingMethod = ContainmentType \| ContainmentBackend` is retained for back-compat. |
| `ProcessConfig` | `sdk/node/src/types.ts` | Per-process settings: `commandLine`, `cwd`, `env`, `timeout`. Reused inside state-aware exec Configs. |
| `FilesystemConfig`, `NetworkConfig`, `UiConfig` | `sdk/node/src/types.ts` | Wire-format-aligned cross-cutting interfaces. Reused inline as field types inside the per-(backend, phase) state-aware Configs. |
| `SandboxSpawnOptions` | `sdk/node/src/sandbox.ts` | Options for experimental authorization, dry-run validation, and cancellation on promise-returning operations. Live `execInSandbox` callers use the returned process's `kill()` method. |
diff --git a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md
index 979320e0a..0e94c4418 100644
--- a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md
+++ b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md
@@ -1449,11 +1449,9 @@ that shape and reuses `ExecutionRequest` for five concrete reasons:
1. **The field-ignore precedent is established across every existing backend.** Every
`ScriptRunner` impl in the workspace today (`AppContainer`, `BaseContainer`,
- `NanVix`, `WindowsSandbox`, `IsolationSession`, `Lxc`, `Wslc`) takes
- `&ExecutionRequest` and reads only the fields it needs. `NanVix` and
- `IsolationSession` go further and actively reject fields they cannot honor (e.g.,
- `NanVixScriptRunner::validate_runner` rejects filesystem paths, network rules,
- network proxy, and a non-empty working directory). State-aware follows the same
+ `WindowsSandbox`, `IsolationSession`, `Lxc`, `Wslc`) takes
+ `&ExecutionRequest` and reads only the fields it needs. `IsolationSession`
+ goes further and actively rejects fields it cannot honor. State-aware follows the same
pattern, so the trait ergonomic stays consistent across one-shot and state-aware
surfaces.
diff --git a/docs/versioning.md b/docs/versioning.md
index 278dd1f3d..658ef8cee 100644
--- a/docs/versioning.md
+++ b/docs/versioning.md
@@ -92,9 +92,12 @@ mxc/schemas/
└── mxc-config.schema.0.10.0-alpha.json (exact closed development contract)
```
-Retired stable schema files are **kept as immutable historical artifacts** — the
-parser simply stops accepting those versions (the supported floor is
-`0.6.0-alpha`). Released schemas are never edited or deleted.
+Retired stable schema files are **kept as revision-locked historical
+artifacts** — the parser simply stops accepting those versions (the supported
+floor is `0.6.0-alpha`). Published schemas are never edited by hand or deleted.
+An exceptional contract amendment requires an intentional one-step publication
+revision bump in `schemas/schema-version.json`, and CI rejects either side of
+that change without the other.
The development artifact is generated from the exact
`mxc_config_contract::dev` model. It describes all eight closed one-shot and
@@ -106,9 +109,11 @@ contract registered for the declared version. Corpus validation selects the
exact registered schema from each document's `version`.
Only the v0.10 file under `schemas/dev/` is a generated development artifact.
-Published v0.9 is represented by its exact Rust contract and immutable stable
-schema. Exact fixtures and adapter/runtime tests remain ordinary mutable tests
-so they can gain regression coverage as implementations evolve. See
+Published v0.9 is represented by its exact Rust contract and revision-locked
+stable schema. Its second publication revision adds the experimental one-shot
+`microvm` value while preserving NVX as an internal implementation detail.
+Exact fixtures and adapter/runtime tests remain ordinary mutable tests so they
+can gain regression coverage as implementations evolve. See
[Schema Code Generation](schema-codegen.md) for the regeneration commands and
independent drift/history gates.
@@ -188,10 +193,11 @@ published JSON contract.
### Trust boundary vs schema defaults
-Schemas in `stable/` are immutable: they document the input shape that was
-promised at release. They are **not** authoritative for runtime security
-defaults. `wxc-exec` is the trust boundary and may apply stricter defaults
-than a stable schema declares when a security issue requires it.
+Schemas in `stable/` are revision-locked: they document the input shape
+promised by a specific publication revision. They are **not** authoritative
+for runtime security defaults. `wxc-exec` is the trust boundary and may apply
+stricter defaults than a stable schema declares when a security issue requires
+it.
For example, an older stable schema may declare
`network.defaultPolicy` defaulting to `"allow"`. The runtime may treat an
@@ -212,7 +218,8 @@ features which still require authorization; per-feature gating is under
consideration.
**Rules:**
-- **Published contract contents** — shipped, stable, and immutable.
+- **Published contract contents** — shipped, stable, and revision-locked.
+ Exceptional amendments require an explicit publication revision increment.
- **Development contract contents** — mutable fields and roots at their
permanent locations. Inclusion does not imply runtime authorization.
- **Promotion:** When a feature is ready to ship, include it in the published
@@ -223,8 +230,10 @@ consideration.
`scripts/versioning/check-contract-codegen.js` compares every stable schema
present at the merge base with the current tree. A published schema cannot be
-changed or removed. New stable schemas are allowed because they have no
-merge-base predecessor.
+removed. It can change only when its positive integer in
+`schema-version.json::stableRevisions` increments by exactly one in the same
+change; a revision-only bump also fails. New stable schemas are allowed because
+they have no merge-base predecessor.
The same gate requires exactly one development contract and verifies that
every supported stable schema has a published registry entry with the same
@@ -337,8 +346,9 @@ fn run(&mut self, request: &ExecutionRequest, logger: &mut Logger) -> ScriptResp
`normalize_common_request_ir` domain normalization
3. Remove the `if request.experimental_enabled` guard
4. Bump the minor version
-5. Preserve every published contract unchanged; older contracts continue to
- reject the field structurally.
+5. Preserve every published contract unchanged unless an exceptional amendment
+ follows the explicit publication-revision process; older contracts continue
+ to reject the field structurally.
Backend-section validation does not move during promotion: an experimental
backend's exact-contract section already occupies its permanent top-level
diff --git a/schemas/schema-version.json b/schemas/schema-version.json
index da9982021..4cf1cf0d9 100644
--- a/schemas/schema-version.json
+++ b/schemas/schema-version.json
@@ -1,9 +1,17 @@
{
- "$comment": "Canonical source of truth for MXC CONFIG SCHEMA versions. min/maxSupported define exact parser and SDK support; stableLatest is the newest immutable schema; stateAware is the IsolationSession lifecycle version; stateAwareWindowsSandbox and stateAwareWslc are the independently versioned backend lifecycle defaults.",
+ "$comment": "Canonical source of truth for MXC CONFIG SCHEMA versions. min/maxSupported define exact parser and SDK support; stableLatest is the newest published schema; stableRevisions makes deliberate published-contract amendments auditable while preserving history checks; stateAware is the IsolationSession lifecycle version; stateAwareWindowsSandbox and stateAwareWslc are the independently versioned backend lifecycle defaults.",
"min": "0.6.0-alpha",
"maxSupported": "0.10.0-alpha",
"stateAware": "0.9.0-alpha",
"stateAwareWindowsSandbox": "0.10.0-alpha",
"stateAwareWslc": "0.9.0-alpha",
- "stableLatest": "0.9.0-alpha"
+ "stableLatest": "0.9.0-alpha",
+ "stableRevisions": {
+ "0.4.0-alpha": 1,
+ "0.5.0-alpha": 1,
+ "0.6.0-alpha": 1,
+ "0.7.0-alpha": 1,
+ "0.8.0-alpha": 1,
+ "0.9.0-alpha": 2
+ }
}
diff --git a/schemas/stable/mxc-config.schema.0.9.0-alpha.json b/schemas/stable/mxc-config.schema.0.9.0-alpha.json
index 9e4455748..18d6833c2 100644
--- a/schemas/stable/mxc-config.schema.0.9.0-alpha.json
+++ b/schemas/stable/mxc-config.schema.0.9.0-alpha.json
@@ -741,6 +741,13 @@
],
"type": "string"
},
+ {
+ "description": "Microvm",
+ "enum": [
+ "microvm"
+ ],
+ "type": "string"
+ },
{
"description": "IsolationSession",
"enum": [
diff --git a/scripts/ci/prepare-linux-host.sh b/scripts/ci/prepare-linux-host.sh
index b9fbe6b6f..b6519f78a 100644
--- a/scripts/ci/prepare-linux-host.sh
+++ b/scripts/ci/prepare-linux-host.sh
@@ -6,7 +6,7 @@ set -euo pipefail
# matrix entry works on Ubuntu, Debian, and RHEL images.
usage() {
- echo "Usage: $0 " >&2
+ echo "Usage: $0 " >&2
}
if [[ $# -ne 2 ]]; then
@@ -316,11 +316,6 @@ case "$backend" in
start_lxc_bridge
ensure_bridge_nat
;;
- microvm)
- for file in nanvixd.elf nanvix_rootfs.img python3.initrd bin/kernel.elf; do
- test -f "$binary_directory/$file"
- done
- ;;
hyperlight)
echo "Hyperlight has no artifact-only Linux test prerequisites yet."
;;
diff --git a/scripts/ci/prepare-windows-host.ps1 b/scripts/ci/prepare-windows-host.ps1
index f440fe5fc..fba79e099 100644
--- a/scripts/ci/prepare-windows-host.ps1
+++ b/scripts/ci/prepare-windows-host.ps1
@@ -23,7 +23,6 @@ param(
'isolation-session',
'wslc',
'windows-sandbox',
- 'microvm',
'hyperlight'
)]
[string]$Backend,
@@ -461,27 +460,6 @@ function Install-PackagedTooling {
$global:LASTEXITCODE = 0
}
-function Initialize-MicroVmHost {
- # Staged next to wxc-exec.exe by the --features microvm build, so their
- # absence means a broken artifact rather than a host problem. Snapshots are
- # excluded: they are a warm-start cache the runner regenerates on demand.
- Assert-RequiredFile @(
- 'wxc-exec.exe',
- 'nanvixd.exe',
- 'nanvix_rootfs.img',
- 'python3.initrd',
- 'bin\kernel.elf'
- )
-
- # NanVix boots a VM from these images on every invocation; Defender scanning
- # them can push boot past its timeout.
- Add-MpPreference -ExclusionPath $BinaryDirectory
- Write-Host "Added Defender exclusion for $BinaryDirectory"
-
- Write-HypervisorDiagnostic
- Assert-HypervisorPlatform
-}
-
# The optional features must be baked into the pool image (enabling one needs a
# reboot this job cannot take), but the WSL runtime package is installed here if
# missing. Container images are pulled by the suite itself
@@ -640,7 +618,6 @@ Assert-WorkloadInterpreters
switch ($Backend) {
'process-t1' { Initialize-ProcessContainerHost }
'process-t3' { Initialize-ProcessContainerHost }
- 'microvm' { Initialize-MicroVmHost }
'wslc' { Initialize-WslcHost }
default { Write-Host "$Backend has no artifact-only Windows test prerequisites yet." }
}
diff --git a/scripts/ci/print-perf-summary.ps1 b/scripts/ci/print-perf-summary.ps1
deleted file mode 100644
index eb5ec52c6..000000000
--- a/scripts/ci/print-perf-summary.ps1
+++ /dev/null
@@ -1,19 +0,0 @@
-param(
- [Parameter(Mandatory = $true)]
- [string]$JsonPath
-)
-
-if (Test-Path $JsonPath) {
- $data = Get-Content $JsonPath -Raw | ConvertFrom-Json
- Write-Host "`n=== MicroVM Performance Summary ==="
- Write-Host "Commit: $($data.commit)"
- Write-Host "Timestamp: $($data.timestamp)"
- Write-Host ""
- Write-Host ("{0,-35} {1,10} {2,8}" -f "Test", "Time (ms)", "Status")
- Write-Host ("{0,-35} {1,10} {2,8}" -f "----", "---------", "------")
- foreach ($r in $data.results) {
- Write-Host ("{0,-35} {1,10} {2,8}" -f $r.description, $r.wall_time_ms, $r.status)
- }
-} else {
- Write-Host "::warning::No performance results found — perf JSON was not generated."
-}
diff --git a/scripts/ci/resolve-validation-test-matrix.mjs b/scripts/ci/resolve-validation-test-matrix.mjs
index 565fcec1d..0b16b0ca8 100644
--- a/scripts/ci/resolve-validation-test-matrix.mjs
+++ b/scripts/ci/resolve-validation-test-matrix.mjs
@@ -10,7 +10,7 @@ import process from 'node:process';
import { fileURLToPath } from 'node:url';
const FAMILIES = ['windows', 'linux', 'macos'];
-const ARM64_UNSUPPORTED_BACKENDS = new Set(['hyperlight', 'microvm']);
+const ARM64_UNSUPPORTED_BACKENDS = new Set(['hyperlight']);
function assertNonEmptyString(value, label) {
if (typeof value !== 'string' || value.trim() === '') {
diff --git a/scripts/ci/run_backend_validation_tests.ps1 b/scripts/ci/run_backend_validation_tests.ps1
index 679af7b40..838351d7b 100644
--- a/scripts/ci/run_backend_validation_tests.ps1
+++ b/scripts/ci/run_backend_validation_tests.ps1
@@ -15,7 +15,6 @@ param(
'isolation-session',
'windows-sandbox',
'wslc',
- 'microvm',
'hyperlight'
)]
[string]$Backend,
@@ -204,11 +203,6 @@ switch ($Backend) {
WxcExecPath = $wxc
}
}
- 'microvm' {
- Invoke-TestScript -Path (Join-Path $testScriptRoot 'run_microvm_tests.ps1') -Arguments @{
- BinDir = $binaryDirectoryPath
- }
- }
'hyperlight' {
# Keep unwired backends explicit so accidental activation fails loudly.
throw 'The Hyperlight CI backend is not wired to an existing test entry point yet.'
diff --git a/scripts/ci/run_backend_validation_tests.sh b/scripts/ci/run_backend_validation_tests.sh
index f1d631be1..3ac1c0b49 100644
--- a/scripts/ci/run_backend_validation_tests.sh
+++ b/scripts/ci/run_backend_validation_tests.sh
@@ -6,7 +6,7 @@ set -euo pipefail
# explicitly rather than reporting a false-success placeholder job.
usage() {
- echo "Usage: $0 " >&2
+ echo "Usage: $0 " >&2
}
if [[ $# -ne 2 ]]; then
@@ -22,12 +22,6 @@ test_script_root="$repo_root/tests/scripts"
release_directory="$repo_root/src/target/release"
case "$backend" in
- microvm)
- # Keep unwired commands explicit so accidental activation fails loudly.
- # Future test script: run_microvm_tests.sh
- echo "The MicroVM CI backend is not wired to an artifact-only Linux test entry point yet." >&2
- exit 2
- ;;
hyperlight)
# Keep unwired commands explicit so accidental activation fails loudly.
# Future test script: run_hyperlight_tests.sh
diff --git a/scripts/ci/validation-test-matrix.json b/scripts/ci/validation-test-matrix.json
index 2c829af62..c91b1689e 100644
--- a/scripts/ci/validation-test-matrix.json
+++ b/scripts/ci/validation-test-matrix.json
@@ -17,7 +17,6 @@
"isolation-session",
"wslc",
"windows-sandbox",
- "microvm",
"hyperlight"
]
},
@@ -51,7 +50,6 @@
"isolation-session",
"wslc",
"windows-sandbox",
- "microvm",
"hyperlight"
]
},
@@ -83,7 +81,6 @@
"process-t3",
"wslc",
"windows-sandbox",
- "microvm",
"hyperlight"
]
},
@@ -114,7 +111,6 @@
"process-t3",
"wslc",
"windows-sandbox",
- "microvm",
"hyperlight"
]
},
@@ -145,7 +141,6 @@
"process-t3",
"wslc",
"windows-sandbox",
- "microvm",
"hyperlight"
]
},
@@ -175,7 +170,6 @@
"process-t3",
"wslc",
"windows-sandbox",
- "microvm",
"hyperlight"
]
},
@@ -228,7 +222,6 @@
"pool": "1es-mxc-e2e-ubuntu-24.04-x64",
"backends": [
"bubblewrap",
- "microvm",
"hyperlight",
"lxc"
]
diff --git a/scripts/versioning/check-contract-codegen.js b/scripts/versioning/check-contract-codegen.js
index e0e318546..42da11a5d 100644
--- a/scripts/versioning/check-contract-codegen.js
+++ b/scripts/versioning/check-contract-codegen.js
@@ -79,14 +79,81 @@ function stableSchemaVersion(path) {
null;
}
-function validateStableHistory(baseSchemas, currentSchemas) {
+function stableRevision(revisions, version) {
+ const revision = revisions?.[version] ?? 1;
+ return Number.isSafeInteger(revision) && revision > 0 ? revision : null;
+}
+
+function validateStableHistory(
+ baseSchemas,
+ currentSchemas,
+ baseRevisions = {},
+ currentRevisions = {}
+) {
const errors = [];
+ const changedVersions = new Set();
for (const [path, before] of baseSchemas) {
const after = currentSchemas.get(path);
if (after === undefined) {
errors.push(`stable schema was removed: ${path}`);
} else if (normalize(before) !== normalize(after)) {
- errors.push(`stable schema changed after publication: ${path}`);
+ const version = stableSchemaVersion(path);
+ const beforeRevision = stableRevision(baseRevisions, version);
+ const afterRevision = stableRevision(currentRevisions, version);
+ if (beforeRevision === null || afterRevision === null) {
+ errors.push(`stable schema ${path} has an invalid publication revision`);
+ } else if (afterRevision !== beforeRevision + 1) {
+ errors.push(
+ `stable schema changed after publication without incrementing its ` +
+ `publication revision exactly once: ${path} ` +
+ `(${beforeRevision} -> ${afterRevision})`
+ );
+ } else {
+ changedVersions.add(version);
+ }
+ }
+ }
+
+ for (const [version, after] of Object.entries(currentRevisions ?? {})) {
+ const before = stableRevision(baseRevisions, version);
+ const current = stableRevision(currentRevisions, version);
+ if (before === null || current === null) {
+ errors.push(`stable schema ${version} has an invalid publication revision`);
+ } else if (current !== before && !changedVersions.has(version)) {
+ errors.push(
+ `stable schema publication revision changed without an artifact ` +
+ `amendment: ${version} (${before} -> ${after})`
+ );
+ }
+ }
+ return errors;
+}
+
+function validateStableRevisions(stableSchemas, revisions) {
+ const errors = [];
+ if (
+ revisions === null ||
+ typeof revisions !== "object" ||
+ Array.isArray(revisions)
+ ) {
+ return ["schema-version.json stableRevisions must be an object"];
+ }
+
+ const schemaVersions = new Set(
+ [...stableSchemas.keys()].map(stableSchemaVersion).filter(Boolean)
+ );
+ for (const version of schemaVersions) {
+ if (stableRevision(revisions, version) === null || !(version in revisions)) {
+ errors.push(
+ `stable schema ${version} has no positive integer publication revision`
+ );
+ }
+ }
+ for (const version of Object.keys(revisions)) {
+ if (!schemaVersions.has(version)) {
+ errors.push(
+ `publication revision ${version} has no matching stable schema`
+ );
}
}
return errors;
@@ -185,11 +252,26 @@ function validatePublishedHistory(registry) {
})
);
const currentSchemas = currentStableSchemas();
+ const baseSchemaVersionsText = readFileAtCommit(
+ repoRoot,
+ commit,
+ "schemas/schema-version.json"
+ );
+ if (baseSchemaVersionsText === null) {
+ fail(`could not read schemas/schema-version.json at ${commit}`);
+ }
+ const baseSchemaVersions = JSON.parse(baseSchemaVersionsText);
const schemaVersions = JSON.parse(
readFileSync(join(repoRoot, "schemas", "schema-version.json"), "utf8")
);
const errors = [
- ...validateStableHistory(baseSchemas, currentSchemas),
+ ...validateStableRevisions(currentSchemas, schemaVersions.stableRevisions),
+ ...validateStableHistory(
+ baseSchemas,
+ currentSchemas,
+ baseSchemaVersions.stableRevisions,
+ schemaVersions.stableRevisions
+ ),
...validatePublishedRegistry(registry, currentSchemas, schemaVersions.min),
];
if (errors.length > 0) {
@@ -469,6 +551,7 @@ module.exports = {
validateFixtures,
validatePublishedRegistry,
validateStableHistory,
+ validateStableRevisions,
};
if (require.main === module) {
try {
diff --git a/scripts/versioning/tests/check-contract-codegen.test.js b/scripts/versioning/tests/check-contract-codegen.test.js
index ae8ec4596..6f41c4793 100644
--- a/scripts/versioning/tests/check-contract-codegen.test.js
+++ b/scripts/versioning/tests/check-contract-codegen.test.js
@@ -19,6 +19,7 @@ const {
validateFixtures,
validatePublishedRegistry,
validateStableHistory,
+ validateStableRevisions,
} = require("../check-contract-codegen.js");
const roots = ["OneShotRequest", "WindowsSandboxProvisionRequest",
@@ -200,7 +201,7 @@ test("stable schema history allows initial publication", () => {
assert.deepEqual(validateStableHistory(base, unchanged), []);
});
-test("stable schema history rejects mutation and removal", () => {
+test("stable schema history requires an explicit publication revision", () => {
const base = new Map([
["schemas/stable/mxc-config.schema.0.8.0-alpha.json", "{\"v\":1}\r\n"],
]);
@@ -212,7 +213,27 @@ test("stable schema history rejects mutation and removal", () => {
["schemas/stable/mxc-config.schema.0.8.0-alpha.json", "{\"v\":2}\n"],
])
).join("\n"),
- /changed after publication/
+ /changed after publication without incrementing its publication revision/
+ );
+ assert.deepEqual(
+ validateStableHistory(
+ base,
+ new Map([
+ ["schemas/stable/mxc-config.schema.0.8.0-alpha.json", "{\"v\":2}\n"],
+ ]),
+ { "0.8.0-alpha": 1 },
+ { "0.8.0-alpha": 2 }
+ ),
+ []
+ );
+ assert.match(
+ validateStableHistory(
+ base,
+ base,
+ { "0.8.0-alpha": 1 },
+ { "0.8.0-alpha": 2 }
+ ).join("\n"),
+ /publication revision changed without an artifact amendment/
);
assert.match(
validateStableHistory(base, new Map()).join("\n"),
@@ -220,6 +241,32 @@ test("stable schema history rejects mutation and removal", () => {
);
});
+test("stable publication revisions cover exactly the stable artifacts", () => {
+ const stable = new Map([
+ ["schemas/stable/mxc-config.schema.0.8.0-alpha.json", "{}"],
+ ["schemas/stable/mxc-config.schema.0.9.0-alpha.json", "{}"],
+ ]);
+ assert.deepEqual(
+ validateStableRevisions(stable, {
+ "0.8.0-alpha": 1,
+ "0.9.0-alpha": 2,
+ }),
+ []
+ );
+ assert.match(
+ validateStableRevisions(stable, { "0.8.0-alpha": 1 }).join("\n"),
+ /0\.9\.0-alpha has no positive integer publication revision/
+ );
+ assert.match(
+ validateStableRevisions(stable, {
+ "0.8.0-alpha": 1,
+ "0.9.0-alpha": 2,
+ "1.0.0-alpha": 1,
+ }).join("\n"),
+ /1\.0\.0-alpha has no matching stable schema/
+ );
+});
+
test("CLI failures use the normal contract-codegen diagnostic", () => {
const output = formatFailure(new Error("could not parse stable schema"));
assert.equal(
diff --git a/sdk/node/README.md b/sdk/node/README.md
index 4b146ec82..94d23c6a2 100644
--- a/sdk/node/README.md
+++ b/sdk/node/README.md
@@ -65,14 +65,20 @@ Node.js 26.8.0 or later is recommended.
| `0.6.0-alpha` | Stable (minimum supported) | [`schemas/stable/mxc-config.schema.0.6.0-alpha.json`](https://github.com/microsoft/mxc/blob/main/schemas/stable/mxc-config.schema.0.6.0-alpha.json) |
| `0.7.0-alpha` | Stable | [`schemas/stable/mxc-config.schema.0.7.0-alpha.json`](https://github.com/microsoft/mxc/blob/main/schemas/stable/mxc-config.schema.0.7.0-alpha.json) |
| `0.8.0-alpha` | Stable | [`schemas/stable/mxc-config.schema.0.8.0-alpha.json`](https://github.com/microsoft/mxc/blob/main/schemas/stable/mxc-config.schema.0.8.0-alpha.json) |
-| `0.9.0-alpha` | Stable (includes IsolationSession and WSLC one-shot and state-aware lifecycle) | [`schemas/stable/mxc-config.schema.0.9.0-alpha.json`](https://github.com/microsoft/mxc/blob/main/schemas/stable/mxc-config.schema.0.9.0-alpha.json) |
+| `0.9.0-alpha` | Stable (includes MicroVM one-shot plus IsolationSession and WSLC one-shot and state-aware lifecycle) | [`schemas/stable/mxc-config.schema.0.9.0-alpha.json`](https://github.com/microsoft/mxc/blob/main/schemas/stable/mxc-config.schema.0.9.0-alpha.json) |
| `0.10.0-alpha` | Dev (remaining experimental backends and development fields) | [`schemas/dev/mxc-config.schema.0.10.0-alpha.json`](https://github.com/microsoft/mxc/blob/main/schemas/dev/mxc-config.schema.0.10.0-alpha.json) |
-Pick `0.9.0-alpha` for new code using current stable backends. Windows Sandbox,
-MicroVM, and Hyperlight require `0.10.0-alpha`; Seatbelt requires `0.7.0-alpha`
-or later.
+Pick `0.9.0-alpha` for new code using current stable backends or the
+experimental MicroVM one-shot backend. Windows Sandbox and Hyperlight require
+`0.10.0-alpha`; Seatbelt requires `0.7.0-alpha` or later.
-> **Stable schemas document only the non-experimental surface.** Experimental backends (`windows_sandbox`, `microvm`, `hyperlight`) and their permanent backend sections are defined by the mutable development contract. IsolationSession and WSLC, including their state-aware lifecycles, are part of exact v0.9 and do not require `--experimental`. Production executors dispatch through the exact contract selected by the declared version, whose adapter normalizes it into the private runtime input.
+> **Contract publication and runtime authorization are separate.** The
+> published v0.9 contract includes the experimental `microvm` one-shot wire
+> value, while `windows_sandbox`, `vm`, and `hyperlight` remain in the mutable
+> v0.10 development contract. MicroVM still requires `--experimental`;
+> IsolationSession and WSLC, including their state-aware lifecycles, do not.
+> Production executors dispatch through the exact contract selected by the
+> declared version, whose adapter normalizes it into the private runtime input.
> **Network host allow/block lists are not implemented on Windows.** Exact
> v0.9/v0.10 requests use `network.egress` / `network.ingress` for directional
@@ -293,7 +299,7 @@ console.log(result.stdout);
Table of all backends and links to per-backend guides — click to expand.
-`SandboxPolicy` is cross-platform. The backend is selected by the second argument to `createConfigFromPolicy(policy, containment)`. Pass an **abstract intent** (`"process"`, `"vm"`, `"microvm"`) whenever possible — the SDK and native binary resolve it to the right concrete backend for the host. Pass a **concrete backend name** when you need a specific runner.
+`SandboxPolicy` is cross-platform. The backend is selected by the second argument to `createConfigFromPolicy(policy, containment)`. Pass an **abstract intent** (`"process"`, `"vm"`, or the compatibility-preserved `"microvm"` public identity) whenever possible — the SDK and native binary resolve it to the right concrete backend for the host. Pass a **concrete backend name** when you need a specific runner.
| Backend | Intent | Platforms | Minimum schema | Stable? | Guide |
| --- | --- | --- | --- | --- | --- |
@@ -302,14 +308,15 @@ console.log(result.stdout);
| `lxc` | (concrete only) | Linux | `0.6.0-alpha` | ✅ | [`docs/lxc-support/lxc-backend.md`](https://github.com/microsoft/mxc/blob/main/docs/lxc-support/lxc-backend.md) |
| `seatbelt` | `process` | macOS | `0.7.0-alpha` | ✅ | [`docs/seatbelt/seatbelt-backend.md`](https://github.com/microsoft/mxc/blob/main/docs/seatbelt/seatbelt-backend.md) |
| `windows_sandbox` | `vm` | Windows | `0.10.0-alpha` | Experimental | [`docs/windows-sandbox/windows-sandbox.md`](https://github.com/microsoft/mxc/blob/main/docs/windows-sandbox/windows-sandbox.md) |
-| `microvm` | `microvm` | Windows | `0.10.0-alpha` | Experimental | [`docs/nanvix-microvm/nanvix.md`](https://github.com/microsoft/mxc/blob/main/docs/nanvix-microvm/nanvix.md) — MicroVM via NanVix on Windows Hypervisor Platform |
+| `microvm` | `microvm` | Windows x64 | `0.9.0-alpha` | Experimental | [`docs/nvx-backend-gaps.md`](https://github.com/microsoft/mxc/blob/main/docs/nvx-backend-gaps.md) — public MicroVM identity implemented internally by NVX; structural foundation only; runtime unavailable in Phase 1 |
| `hyperlight` | (concrete only) | Windows x64 / Linux x64 | `0.10.0-alpha` | Experimental | No dedicated guide |
| `wslc` | (concrete only) | Windows | `0.9.0-alpha` | Stable | [`docs/wsl/wsl-container-getting-started.md`](https://github.com/microsoft/mxc/blob/main/docs/wsl/wsl-container-getting-started.md) |
| `isolation_session` | (concrete only) | Windows | `0.9.0-alpha` | Stable | [`docs/isolation-session/oneshot.md`](https://github.com/microsoft/mxc/blob/main/docs/isolation-session/oneshot.md) |
The abstract `process` intent therefore requires `0.7.0-alpha` on macOS,
where it resolves to Seatbelt, but retains the `0.6.0-alpha` floor on Windows
-and Linux. The abstract `vm` intent requires `0.10.0-alpha`.
+and Linux. The compatibility-preserved `microvm` identity requires
+`0.9.0-alpha`; the abstract `vm` intent requires `0.10.0-alpha`.
Experimental backends require `{ experimental: true }` in `SandboxSpawnOptions`:
@@ -546,14 +553,14 @@ granting file content reads. It requires a BaseContainer host with PSEC 1.1
| --- | --- | --- |
| `MXC is not supported on this platform` | `getPlatformSupport()` returned `isSupported: false`. On Linux, neither LXC nor a usable Bubblewrap 0.5.0+ installation is available. On macOS, the Seatbelt platform probe could not find `/usr/bin/sandbox-exec`. | Inspect `support.reason`. On Linux, also inspect `support.unavailableReasons` and install LXC or Bubblewrap 0.5.0+. On macOS, verify that `/usr/bin/sandbox-exec` exists; its absence indicates an incomplete or unsupported macOS installation. |
| `wxc-exec.exe not found` / `lxc-exec not found` | The SDK couldn't locate the native binary. | Set `MXC_BIN_DIR=` so `//wxc-exec.exe` (or `lxc-exec`) exists, or pass `options.executablePath` explicitly. |
-| `Invalid containment value ''` | `containment` field doesn't match the parser's accepted values. | Use one of the abstract intents (`process`, `vm`, `microvm`) or a concrete backend listed in [Choosing a Backend](#choosing-a-backend). |
+| `Invalid containment value ''` | `containment` field doesn't match the parser's accepted values. | Use one of the public intents (`process`, `vm`, `microvm`) or a concrete backend listed in [Choosing a Backend](#choosing-a-backend). |
| `'' containment requires experimental mode` | A `windows_sandbox` / `microvm` / `hyperlight` backend was selected without the flag. | Pass `{ experimental: true }` in `SandboxSpawnOptions`. |
| `process.commandLine starts with an unquoted Windows path containing a space` | `wxc-exec` rejects unquoted paths with spaces at parse time. | Quote the executable: `'"C:\\Program Files\\…\\foo.exe" args'`. |
| `CreateProcessW(PROC_THREAD_ATTRIBUTE_SECURITY_ENVIRONMENT) failed: ...` | The process security environment launch returned an OS-level error. Backend-unavailable failures automatically fall through to an AppContainer tier during selection. | Check the Windows build requirements for the backend you selected. |
| Process exits `-1` / `4294967295` with no stdout | Native binary terminated abnormally. | Re-run with `options.debug: true` (or `options.logDir: ''`) to capture diagnostic logs. |
| `Policy version '' is older than supported` / `newer than supported` | Version is outside the supported version lines. | Use an exact registered version: `0.6.0-alpha`, `0.7.0-alpha`, `0.8.0-alpha`, `0.9.0-alpha`, or `0.10.0-alpha`. See [Compatibility](#compatibility). |
| `Policy version '' is not a registered schema contract` / `Unsupported contract version` | The declaration is not registered, even if it falls between supported versions (for example, `0.6.1-alpha`). | Use an exact version from [Compatibility](#compatibility); IsolationSession and WSLC state-aware requests use `0.9.0-alpha`, while Windows Sandbox uses `0.10.0-alpha`. |
-| `Schema does not support containment ''` | The selected backend was introduced after the declared schema version. | Use the backend's minimum version from [Choosing a Backend](#choosing-a-backend). Seatbelt requires `0.7.0-alpha`; IsolationSession and WSLC require `0.9.0-alpha`; Windows Sandbox, MicroVM, and Hyperlight require `0.10.0-alpha`. |
+| `Schema does not support containment ''` | The selected backend was introduced after the declared schema version. | Use the backend's minimum version from [Choosing a Backend](#choosing-a-backend). Seatbelt requires `0.7.0-alpha`; IsolationSession, WSLC, and MicroVM require `0.9.0-alpha`; Windows Sandbox and Hyperlight require `0.10.0-alpha`. |
For backend-specific errors, see the per-backend guide linked from the [Choosing a Backend](#choosing-a-backend) table.
diff --git a/sdk/node/src/generated/v0_9_0_alpha/wire.ts b/sdk/node/src/generated/v0_9_0_alpha/wire.ts
index e32d76aed..5da863275 100644
--- a/sdk/node/src/generated/v0_9_0_alpha/wire.ts
+++ b/sdk/node/src/generated/v0_9_0_alpha/wire.ts
@@ -354,7 +354,7 @@ export interface NetworkRule {
export type NonEmptyString = string;
-export type OneShotContainment = "process" | "processcontainer" | "appcontainer" | "lxc" | "bubblewrap" | "seatbelt" | "macos_sandbox" | "isolation_session" | "wslc";
+export type OneShotContainment = "process" | "processcontainer" | "appcontainer" | "lxc" | "bubblewrap" | "seatbelt" | "macos_sandbox" | "microvm" | "isolation_session" | "wslc";
/**
* A complete one-shot `0.9.0-alpha` configuration request.
diff --git a/sdk/node/src/helper.ts b/sdk/node/src/helper.ts
index 951e86613..21ae1dc4f 100644
--- a/sdk/node/src/helper.ts
+++ b/sdk/node/src/helper.ts
@@ -242,7 +242,7 @@ export function resolveExecutableAndArgs(
// bypass the experimental-mode gate (because they are not in
// ExperimentalBackends under their alias) and produce a confusing
// "not available on this platform" error instead.
- const rawContainment = config.containment;
+ const rawContainment: string | undefined = config.containment;
const effectiveContainment = rawContainment
? (LegacyContainmentAliases[rawContainment] ?? rawContainment)
: undefined;
@@ -252,7 +252,9 @@ export function resolveExecutableAndArgs(
// Check experimental mode before anything else so the caller gets a clear
// message about the missing flag rather than a platform/binary error.
- if (effectiveContainment && ExperimentalBackends.includes(effectiveContainment) && !options.experimental) {
+ if (effectiveContainment &&
+ (ExperimentalBackends as readonly string[]).includes(effectiveContainment) &&
+ !options.experimental) {
throw new Error(
`'${rawContainment}' containment requires experimental mode. Set 'experimental: true' in SandboxSpawnOptions.`
);
@@ -265,16 +267,17 @@ export function resolveExecutableAndArgs(
throw new Error(`MXC is not supported on this platform: ${platformSupport.reason}`);
}
- // Hard platform requirement: microvm needs WHP/Hyper-V on Windows. This guard
+ // Hard platform requirement: MicroVM (NVX) needs OpenVMM/WHP on Windows x64. This guard
// runs even when `skipPlatformCheck` is set because it's not a build-version
// check — the backend literally cannot run on non-Windows hosts.
- if (effectiveContainment === 'microvm' && os.platform() !== 'win32') {
- throw new Error('The microvm backend is only supported on Windows (requires WHP/Hyper-V).');
+ if (effectiveContainment === 'microvm' &&
+ (os.platform() !== 'win32' || os.arch() !== 'x64')) {
+ throw new Error('The microvm backend is only supported on Windows x64.');
}
// Validate containment against platform
if (effectiveContainment && !options.skipPlatformCheck) {
- // Abstract intents (process, microvm) are resolved by the native binary
+ // Abstract intents are resolved by the native binary
// at run time, so the SDK accepts them without checking against the
// host's concrete backend list.
const isIntent = (ContainmentTypes as readonly string[]).includes(effectiveContainment);
diff --git a/sdk/node/src/sandbox.ts b/sdk/node/src/sandbox.ts
index 8b4de2e20..ceaa10851 100644
--- a/sdk/node/src/sandbox.ts
+++ b/sdk/node/src/sandbox.ts
@@ -119,9 +119,9 @@ function validateContainmentVersion(
? '0.7.0-alpha'
: effectiveContainment === 'isolation_session'
|| effectiveContainment === 'wslc'
+ || effectiveContainment === 'microvm'
? '0.9.0-alpha'
: effectiveContainment === 'vm' ||
- effectiveContainment === 'microvm' ||
effectiveContainment === 'windows_sandbox' ||
effectiveContainment === 'hyperlight'
? '0.10.0-alpha'
@@ -341,66 +341,6 @@ function buildProcessBaseContainerConfig(
return config;
}
-/**
- * Builds the MicroVM (NanVix) portion of a ContainerConfig.
- * MicroVM is Windows-only and supports isolated or unrestricted networking.
- */
-function buildMicroVmConfig(
- config: ContainerConfig,
- policy: SandboxPolicy,
-): ContainerConfig {
- if (os.platform() !== 'win32') {
- throw new Error('The microvm backend is only supported on Windows (requires WHP/Hyper-V).');
- }
- if (policy.network && hasLegacyNetworkFields(policy.network)) {
- throw new Error(
- 'The microvm backend supports only directional network.egress/network.ingress configuration.'
- );
- }
- if (policy.runtimeConfig?.networkProxy !== undefined ||
- policy.processContainer?.network?.allowedProxyPeer !== undefined) {
- throw new Error('The microvm backend does not support network proxy configuration.');
- }
- if (policy.network?.egress?.allow?.length || policy.network?.egress?.deny?.length) {
- throw new Error(
- 'The microvm backend does not support directional network rules. ' +
- 'Use fully isolated or explicitly unrestricted networking without rules.'
- );
- }
- if (policy.network !== undefined) {
- const egressDefault = policy.network.egress?.default ?? 'deny';
- const ingressDefault = policy.network.ingress?.default ?? 'deny';
- const hostLoopback = policy.network.ingress?.hostLoopback ?? 'deny';
- if (egressDefault !== ingressDefault || ingressDefault !== hostLoopback) {
- throw new Error(
- 'The microvm backend requires network.egress.default, network.ingress.default, ' +
- 'and network.ingress.hostLoopback to be all deny or all allow.'
- );
- }
- config.network = {
- egress: policy.network.egress,
- ingress: policy.network.ingress,
- };
- }
- if (policy.filesystem?.readwritePaths?.length ||
- policy.filesystem?.readonlyPaths?.length ||
- policy.filesystem?.deniedPaths?.length) {
- config.filesystem = {
- readwritePaths: policy.filesystem?.readwritePaths,
- readonlyPaths: policy.filesystem?.readonlyPaths,
- deniedPaths: policy.filesystem?.deniedPaths,
- };
- }
- if (policy.processContainer?.filesystem?.enumeratePaths?.length) {
- throw new Error(
- 'The microvm backend does not support processContainer.filesystem.enumeratePaths. ' +
- 'Remove it or use the Windows ProcessContainer backend.'
- );
- }
- config.containment = 'microvm';
- return config;
-}
-
/**
* Creates a ContainerConfig from a SandboxPolicy and optional containment type.
*
@@ -441,6 +381,7 @@ export function createConfigFromPolicy(
validateTelemetryVersion(policy);
const directionalNetwork = selectDirectionalNetwork(policy);
const enumeratePaths = policy.processContainer?.filesystem?.enumeratePaths;
+ const allowedProxyPeer = policy.processContainer?.network?.allowedProxyPeer;
const containerId = containerName ?? generateRandomContainerName();
@@ -459,12 +400,6 @@ export function createConfigFromPolicy(
telemetry: policy.telemetry === undefined ? undefined : { ...policy.telemetry },
};
- // Microvm: delegate to dedicated builder
- if (containment === 'microvm') {
- diagLog(`createConfigFromPolicy: containment=microvm, id=${containerId}`);
- return buildMicroVmConfig(config, policy);
- }
-
if (enumeratePaths?.length) {
if (policy.version !== '0.9.0-alpha' && policy.version !== '0.10.0-alpha') {
throw new Error(
@@ -480,6 +415,15 @@ export function createConfigFromPolicy(
);
}
}
+ if (containment === 'microvm' && allowedProxyPeer !== undefined) {
+ throw new Error(
+ 'processContainer.network.allowedProxyPeer is supported only by the Windows ' +
+ 'ProcessContainer backend.'
+ );
+ }
+ if (containment === 'microvm' && policy.ui !== undefined) {
+ throw new Error('SandboxPolicy.ui is not supported by the MicroVM backend.');
+ }
config.filesystem = {
readwritePaths: [...(policy.filesystem?.readwritePaths ?? [])],
@@ -494,12 +438,14 @@ export function createConfigFromPolicy(
};
}
- // SandboxPolicy defaults are fail-closed, so omission still emits lockdown.
- config.ui = {
- disable: !(policy.ui?.allowWindows ?? false),
- clipboard: policy.ui?.clipboard ?? "none",
- injection: policy.ui?.allowInputInjection ?? false,
- };
+ if (containment !== 'microvm') {
+ // SandboxPolicy defaults are fail-closed, so omission still emits lockdown.
+ config.ui = {
+ disable: !(policy.ui?.allowWindows ?? false),
+ clipboard: policy.ui?.clipboard ?? "none",
+ injection: policy.ui?.allowInputInjection ?? false,
+ };
+ }
if (directionalNetwork) {
if ((requiresDirectionalNetwork(policy.version) &&
@@ -515,11 +461,11 @@ export function createConfigFromPolicy(
networkProxy: policy.runtimeConfig.networkProxy,
};
}
- if (policy.processContainer?.network?.allowedProxyPeer !== undefined) {
+ if (allowedProxyPeer !== undefined) {
config.processContainer = {
...config.processContainer,
network: {
- allowedProxyPeer: policy.processContainer.network.allowedProxyPeer,
+ allowedProxyPeer,
},
};
}
@@ -571,6 +517,12 @@ export function createConfigFromPolicy(
}
// Backend-specific config based on containment type
+ if (containment === 'microvm') {
+ diagLog(`createConfigFromPolicy: containment=microvm, id=${containerId}`);
+ config.containment = 'microvm';
+ return config;
+ }
+
if (containment === 'wslc') {
return buildWslcContainerConfig(config, policy, containerId);
}
diff --git a/sdk/node/src/types.ts b/sdk/node/src/types.ts
index b589de2d9..01e5c7858 100644
--- a/sdk/node/src/types.ts
+++ b/sdk/node/src/types.ts
@@ -64,15 +64,15 @@ export interface LifecycleConfig {
* - "vm": full hardware-virtualised VM isolation. Resolves to
* `windows_sandbox` on Windows; no concrete VM backend exists on other
* platforms today.
- * - "microvm": lightweight-VM isolation. Resolves to the current MicroVM
- * runner (Windows only, experimental); intended to expand as additional
- * microvm backends (e.g. NanVix) are added.
- *
+ * - "microvm": the public MicroVM identity. It is retained as an intent for
+ * source and runtime compatibility while NVX remains its internal
+ * implementation. The same wire value also appears in
+ * {@link ContainmentBackend} because it directly selects that backend.
* Concrete-only backends (such as `"wslc"`) live on
* {@link ContainmentBackend} until there is a meaningful abstraction over
* multiple implementations of the same kind.
*/
-export type ContainmentType = "process" | "vm" | "microvm";
+export type ContainmentType = 'process' | 'vm' | 'microvm';
/**
* Runtime list of {@link ContainmentType} values. Kept in sync with the
@@ -126,7 +126,11 @@ export type ContainmentBackend =
* Containment values (abstract intent or concrete backend) that require
* the `--experimental` flag.
*/
-export const ExperimentalBackends: readonly (ContainmentType | ContainmentBackend)[] = ['microvm', 'windows_sandbox', 'hyperlight'];
+export const ExperimentalBackends: readonly (ContainmentType | ContainmentBackend)[] = [
+ 'microvm',
+ 'windows_sandbox',
+ 'hyperlight',
+];
/**
* Clipboard access policy levels
diff --git a/sdk/node/tests/integration/microvm-filesystem.test.ts b/sdk/node/tests/integration/microvm-filesystem.test.ts
deleted file mode 100644
index 836c690eb..000000000
--- a/sdk/node/tests/integration/microvm-filesystem.test.ts
+++ /dev/null
@@ -1,468 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-// MicroVM SDK end-to-end tests — these tests spawn NanVix VMs via wxc-exec.exe.
-//
-// Requirements:
-// - Windows with WHP enabled (bcdedit /set hypervisorlaunchtype auto)
-// - wxc-exec.exe built (in src/target/debug/ or src/target/x86_64-pc-windows-msvc/debug/)
-// - NanVix binaries next to wxc-exec.exe: nanvixd.exe, kernel.elf, python3.12, nanvix_rootfs.img
-//
-// Run: cd sdk/tests/integration && npx tsc -p tsconfig.json && node --test dist/microvm-filesystem.test.js
-//
-// All tests use spawnSandboxFromConfig with usePty:false (non-PTY mode).
-// PTY mode is not supported for the MicroVM backend.
-
-import { describe, it } from 'node:test';
-import assert from 'node:assert';
-import fs from 'node:fs';
-import path from 'node:path';
-import os from 'os';
-import { execSync } from 'child_process';
-import { ChildProcess } from 'child_process';
-import { sdk } from './test-helpers.js';
-import type { ContainerConfig } from '@microsoft/mxc-sdk';
-
-function isWhpAvailable(): boolean {
- if (os.platform() !== 'win32') return false;
- // CI sets this when wxc-exec/nanvix binaries aren't available.
- if (process.env.MXC_SKIP_OS_BUILD_DEPENDENT_TESTS === '1') return false;
- try {
- const result = execSync(
- 'powershell -NoProfile -Command "(Get-CimInstance Win32_ComputerSystem).HypervisorPresent"',
- { encoding: 'utf8', timeout: 5000 }
- ).trim();
- return result === 'True';
- } catch {
- return false;
- }
-}
-
-const isMicrovmAvailable = isWhpAvailable();
-
-/** Escape backslashes for embedding a Windows path in a Python string literal. */
-function pyEscape(p: string): string {
- return p.replace(/\\/g, '\\\\');
-}
-
-/**
- * Spawn a microvm sandbox using spawnSandboxFromConfig with usePty:false.
- * Returns stdout, stderr, and exit code.
- */
-function runMicrovm(
- config: ContainerConfig,
- options: { timeoutMs?: number } = {},
-): Promise<{ stdout: string; stderr: string; exitCode: number }> {
- return new Promise((resolve, reject) => {
- const timeout = options.timeoutMs ?? 120_000;
-
- try {
- const child: ChildProcess = sdk.spawnSandboxFromConfig(config, {
- experimental: true,
- debug: true,
- usePty: false,
- });
-
- let stdout = '';
- let stderr = '';
-
- child.stdout?.on('data', (data: Buffer) => { stdout += data.toString(); });
- child.stderr?.on('data', (data: Buffer) => { stderr += data.toString(); });
-
- const timer = setTimeout(() => {
- child.kill();
- reject(new Error(`MicroVM test timed out after ${timeout}ms.\nstdout: ${stdout}\nstderr: ${stderr}`));
- }, timeout);
-
- child.on('error', (error: Error) => {
- clearTimeout(timer);
- reject(new Error(`Failed to spawn wxc-exec: ${error.message}`));
- });
-
- child.on('close', (code: number | null) => {
- clearTimeout(timer);
- resolve({ stdout, stderr, exitCode: code ?? -1 });
- });
- } catch (error) {
- reject(error);
- }
- });
-}
-
-describe('MicroVM SDK E2E — spawnSandboxFromConfig with containment: microvm', {
- skip: !isMicrovmAvailable ? 'MicroVM tests require Windows with WHP' : undefined,
-}, () => {
-
- it('should run a simple Python script and capture output', async () => {
- const config = {
- version: '0.10.0-alpha',
- containment: 'microvm' as const,
- process: {
- commandLine: "print('Hello from MicroVM SDK E2E!')",
- timeout: 30000,
- },
- };
-
- const { stdout, stderr, exitCode } = await runMicrovm(config);
- const combined = stdout + stderr;
- assert.strictEqual(exitCode, 0, `Expected exit code 0, got ${exitCode}.\nstdout: ${stdout}\nstderr: ${stderr}`);
- assert.ok(combined.includes('Hello from MicroVM SDK E2E!'), `Expected greeting in output:\n${combined}`);
- });
-
- it('should propagate non-zero exit codes', async () => {
- const config = {
- version: '0.10.0-alpha',
- containment: 'microvm' as const,
- process: {
- commandLine: "import sys; sys.exit(42)",
- timeout: 30000,
- },
- };
-
- const { exitCode } = await runMicrovm(config);
- assert.strictEqual(exitCode, 42, `Expected exit code 42, got ${exitCode}`);
- });
-
- it('should run multiline scripts with imports', async () => {
- const config = {
- version: '0.10.0-alpha',
- containment: 'microvm' as const,
- process: {
- commandLine: [
- "import sys",
- "import json",
- "result = {'python': f'{sys.version_info.major}.{sys.version_info.minor}', 'platform': sys.platform}",
- "print(json.dumps(result))",
- ].join('\n'),
- timeout: 30000,
- },
- };
-
- const { stdout, stderr, exitCode } = await runMicrovm(config);
- const combined = stdout + stderr;
- assert.strictEqual(exitCode, 0, `Expected exit code 0.\nstdout: ${stdout}\nstderr: ${stderr}`);
- assert.ok(combined.includes('"platform": "nanvix"'), `Expected nanvix platform in output:\n${combined}`);
- });
-
- it('should support readwritePaths with transparent path translation', async () => {
- const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-microvm-e2e-'));
- const rwDir = path.join(testDir, 'work');
- fs.mkdirSync(rwDir);
- fs.writeFileSync(path.join(rwDir, 'input.txt'), 'data from host');
-
- try {
- // Use the host path directly in the script — the staging layer rewrites
- // it to the guest mount path before the script reaches the VM.
- const config = {
- version: '0.10.0-alpha',
- containment: 'microvm' as const,
- process: {
- commandLine: [
- "import os",
- `path = '${pyEscape(rwDir)}'`,
- "print(f'Guest path: {path}')",
- "with open(os.path.join(path, 'input.txt')) as f:",
- " print(f'Read: {f.read().strip()}')",
- ].join('\n'),
- timeout: 30000,
- },
- filesystem: {
- readwritePaths: [rwDir],
- },
- };
-
- const { stdout, stderr, exitCode } = await runMicrovm(config);
- const combined = stdout + stderr;
- assert.strictEqual(exitCode, 0, `Expected exit code 0.\nstdout: ${stdout}\nstderr: ${stderr}`);
- assert.ok(combined.includes('Guest path: /mnt/rw/'), `Expected guest path starting with /mnt/rw/ in output:\n${combined}`);
- assert.ok(combined.includes('Read: data from host'), `Expected host data in output:\n${combined}`);
- } finally {
- fs.rmSync(testDir, { recursive: true, force: true });
- }
- });
-
- it('should reject denied_paths with an error', async () => {
- const config = {
- version: '0.10.0-alpha',
- containment: 'microvm' as const,
- process: {
- commandLine: "print('should not run')",
- timeout: 30000,
- },
- filesystem: {
- deniedPaths: ['/secret'],
- },
- };
-
- const { stdout, stderr, exitCode } = await runMicrovm(config);
- const combined = stdout + stderr;
- assert.notStrictEqual(exitCode, 0, `Expected non-zero exit code for denied_paths`);
- assert.ok(combined.includes('denied_paths'), `Expected denied_paths error in output:\n${combined}`);
- });
-
- it('should copy readwritePaths changes back to the host on clean exit', async () => {
- const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-microvm-copyback-'));
- const rwDir = path.join(testDir, 'work');
- fs.mkdirSync(rwDir);
- fs.writeFileSync(path.join(rwDir, 'input.txt'), 'before');
-
- try {
- const config = {
- version: '0.10.0-alpha',
- containment: 'microvm' as const,
- process: {
- commandLine: [
- "import os",
- `path = '${pyEscape(rwDir)}'`,
- "with open(os.path.join(path, 'input.txt'), 'w') as f:",
- " f.write('after')",
- "with open(os.path.join(path, 'created.txt'), 'w') as f:",
- " f.write('created by guest')",
- ].join('\n'),
- timeout: 30000,
- },
- filesystem: {
- readwritePaths: [rwDir],
- },
- };
-
- const { stdout, stderr, exitCode } = await runMicrovm(config);
- assert.strictEqual(exitCode, 0, `Expected exit code 0.\nstdout: ${stdout}\nstderr: ${stderr}`);
- assert.strictEqual(fs.readFileSync(path.join(rwDir, 'input.txt'), 'utf8'), 'after');
- assert.strictEqual(fs.readFileSync(path.join(rwDir, 'created.txt'), 'utf8'), 'created by guest');
- } finally {
- fs.rmSync(testDir, { recursive: true, force: true });
- }
- });
-
- it('should copy readwritePaths changes back after a normal non-zero guest exit', async () => {
- const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-microvm-copyback-nonzero-'));
- const rwDir = path.join(testDir, 'work');
- fs.mkdirSync(rwDir);
-
- try {
- const config = {
- version: '0.10.0-alpha',
- containment: 'microvm' as const,
- process: {
- commandLine: [
- "import os, sys",
- `path = '${pyEscape(rwDir)}'`,
- "with open(os.path.join(path, 'nonzero.txt'), 'w') as f:",
- " f.write('persisted before non-zero exit')",
- "sys.exit(7)",
- ].join('\n'),
- timeout: 30000,
- },
- filesystem: {
- readwritePaths: [rwDir],
- },
- };
-
- const { exitCode } = await runMicrovm(config);
- assert.strictEqual(exitCode, 7, `Expected exit code 7, got ${exitCode}`);
- assert.strictEqual(
- fs.readFileSync(path.join(rwDir, 'nonzero.txt'), 'utf8'),
- 'persisted before non-zero exit'
- );
- } finally {
- fs.rmSync(testDir, { recursive: true, force: true });
- }
- });
-
- it('should generate a PPTX file in a readwritePath and copy it back to the host', async () => {
- const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-microvm-pptx-'));
- const rwDir = path.join(testDir, 'output');
- fs.mkdirSync(rwDir);
-
- try {
- const rwDirPy = pyEscape(rwDir);
- const config = {
- version: '0.10.0-alpha',
- containment: 'microvm' as const,
- process: {
- // Generate a minimal valid PPTX using only stdlib (zipfile + xml).
- // A PPTX is an Office Open XML package — a zip with specific XML parts.
- commandLine: [
- "import zipfile, os",
- `outdir = '${rwDirPy}'`,
- "pptx_path = os.path.join(outdir, 'test.pptx')",
- "ct = ''",
- "rels = ''",
- "pres = ''",
- "with zipfile.ZipFile(pptx_path, 'w', zipfile.ZIP_DEFLATED) as z:",
- " z.writestr('[Content_Types].xml', ct)",
- " z.writestr('_rels/.rels', rels)",
- " z.writestr('ppt/presentation.xml', pres)",
- "print(f'PPTX size: {os.path.getsize(pptx_path)} bytes')",
- ].join('\n'),
- timeout: 30000,
- },
- filesystem: {
- readwritePaths: [rwDir],
- },
- };
-
- const { stdout, stderr, exitCode } = await runMicrovm(config);
- const combined = stdout + stderr;
- assert.strictEqual(exitCode, 0, `Expected exit code 0.\nstdout: ${stdout}\nstderr: ${stderr}`);
- assert.ok(combined.includes('PPTX size:'), `Expected PPTX size in output:\n${combined}`);
-
- // Verify the PPTX was copied back to the host.
- const pptxPath = path.join(rwDir, 'test.pptx');
- assert.ok(fs.existsSync(pptxPath), `Expected test.pptx at ${pptxPath}`);
- const size = fs.statSync(pptxPath).size;
- assert.ok(size > 0, `Expected non-empty PPTX, got ${size} bytes`);
-
- // Verify it's a valid zip (PPTX is zip-based).
- const header = Buffer.alloc(4);
- const fd = fs.openSync(pptxPath, 'r');
- fs.readSync(fd, header, 0, 4, 0);
- fs.closeSync(fd);
- assert.strictEqual(header[0], 0x50, 'Expected PK zip header byte 1');
- assert.strictEqual(header[1], 0x4B, 'Expected PK zip header byte 2');
- } finally {
- fs.rmSync(testDir, { recursive: true, force: true });
- }
- });
-
- it('should generate a dark-themed 3-slide PPTX about MXC and NanVix using python-pptx', async () => {
- const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-microvm-pptx-dark-'));
- const rwDir = path.join(testDir, 'output');
- fs.mkdirSync(rwDir);
-
- try {
- const rwDirPy = pyEscape(rwDir);
- const config = {
- version: '0.10.0-alpha',
- containment: 'microvm' as const,
- process: {
- commandLine: [
- // site-packages isn't on sys.path by default in this NanVix build
- "import sys; sys.path.insert(0, '/sysroot/lib/python3.12/site-packages')",
- "import os",
- "from pptx import Presentation",
- "from pptx.util import Inches, Pt, Emu",
- "from pptx.dml.color import RGBColor",
- "from pptx.enum.text import PP_ALIGN",
- "",
- "prs = Presentation()",
- "prs.slide_width = Emu(12192000)",
- "prs.slide_height = Emu(6858000)",
- "",
- "BG = RGBColor(0x1B, 0x1B, 0x2F)",
- "WHITE = RGBColor(0xFF, 0xFF, 0xFF)",
- "BLUE = RGBColor(0x56, 0x9C, 0xD6)",
- "TEAL = RGBColor(0x4E, 0xC9, 0xB0)",
- "GRAY = RGBColor(0xD4, 0xD4, 0xD4)",
- "",
- "def set_bg(slide):",
- " bg = slide.background",
- " fill = bg.fill",
- " fill.solid()",
- " fill.fore_color.rgb = BG",
- "",
- "def add_text(slide, left, top, width, height, text, font_size, color, bold=False, alignment=PP_ALIGN.LEFT):",
- " txBox = slide.shapes.add_textbox(left, top, width, height)",
- " tf = txBox.text_frame",
- " tf.word_wrap = True",
- " p = tf.paragraphs[0]",
- " p.text = text",
- " p.font.size = Pt(font_size)",
- " p.font.color.rgb = color",
- " p.font.bold = bold",
- " p.alignment = alignment",
- " return tf",
- "",
- "def add_para(tf, text, font_size, color, bold=False):",
- " p = tf.add_paragraph()",
- " p.text = text",
- " p.font.size = Pt(font_size)",
- " p.font.color.rgb = color",
- " p.font.bold = bold",
- " return tf",
- "",
- "# --- Slide 1: Title ---",
- "blank = prs.slide_layouts[6]",
- "s1 = prs.slides.add_slide(blank)",
- "set_bg(s1)",
- "add_text(s1, Inches(0.8), Inches(1.8), Inches(10), Inches(1.2),",
- " '\\U0001f680 MXC \\u00d7 NanVix', 44, WHITE, bold=True, alignment=PP_ALIGN.CENTER)",
- "tf1 = add_text(s1, Inches(0.8), Inches(3.2), Inches(10), Inches(1.5),",
- " 'Sandboxed Code Execution with Micro-VM Isolation', 24, BLUE, alignment=PP_ALIGN.CENTER)",
- "add_para(tf1, '', 12, WHITE)",
- "add_para(tf1, '\\U0001f512 Secure \\u2022 \\u26a1 Fast \\u2022 \\U0001f30d Cross-Platform', 20, TEAL)",
- "tf1.paragraphs[-1].alignment = PP_ALIGN.CENTER",
- "",
- "# --- Slide 2: Architecture ---",
- "s2 = prs.slides.add_slide(blank)",
- "set_bg(s2)",
- "add_text(s2, Inches(0.8), Inches(0.4), Inches(10), Inches(0.8),",
- " '\\U0001f527 How It Works', 32, WHITE, bold=True)",
- "tf2 = add_text(s2, Inches(0.8), Inches(1.4), Inches(10), Inches(5),",
- " '\\U0001f4e6 MXC (Microsoft eXecution Container)', 20, BLUE, bold=True)",
- "add_para(tf2, 'Orchestrates sandboxed execution across backends', 16, GRAY)",
- "add_para(tf2, '', 12, GRAY)",
- "add_para(tf2, '\\U0001f5a5 NanVix Micro-VM Backend', 20, BLUE, bold=True)",
- "add_para(tf2, 'Lightweight hypervisor isolation via WHP', 16, GRAY)",
- "add_para(tf2, 'CPython 3.12 inside a minimal microkernel', 16, GRAY)",
- "add_para(tf2, '', 12, GRAY)",
- "add_para(tf2, '\\U0001f504 The Flow', 20, BLUE, bold=True)",
- "add_para(tf2, 'SDK \\u2192 wxc-exec \\u2192 nanvixd \\u2192 kernel.elf \\u2192 Python \\U0001f40d', 16, TEAL)",
- "",
- "# --- Slide 3: What's Next ---",
- "s3 = prs.slides.add_slide(blank)",
- "set_bg(s3)",
- "add_text(s3, Inches(0.8), Inches(0.4), Inches(10), Inches(0.8),",
- " '\\u2728 What\\'s Next', 32, WHITE, bold=True)",
- "tf3 = add_text(s3, Inches(0.8), Inches(1.4), Inches(10), Inches(5),",
- " '\\u2705 Filesystem sharing via readwritePaths', 18, GRAY)",
- "add_para(tf3, '\\u2705 Stdout/stderr streaming back to host', 18, GRAY)",
- "add_para(tf3, '\\u2705 Exit code propagation', 18, GRAY)",
- "add_para(tf3, '\\U0001f6a7 Network isolation & proxy support', 18, GRAY)",
- "add_para(tf3, '\\U0001f6a7 Multi-language guest support', 18, GRAY)",
- "add_para(tf3, '\\U0001f6a7 GPU passthrough for AI workloads', 18, GRAY)",
- "add_para(tf3, '', 12, GRAY)",
- "add_para(tf3, '\\U0001f4ac \"Run untrusted code safely, at VM speed\"', 20, TEAL, bold=True)",
- "",
- `pptx_path = os.path.join('${rwDirPy}', 'mxc-nanvix.pptx')`,
- "prs.save(pptx_path)",
- "size = os.path.getsize(pptx_path)",
- "print(f'PPTX created: {size} bytes, 3 slides')",
- "print(f'Output: {pptx_path}')",
- ].join('\n'),
- timeout: 60000,
- },
- filesystem: {
- readwritePaths: [rwDir],
- },
- };
-
- const { stdout, stderr, exitCode } = await runMicrovm(config);
- const combined = stdout + stderr;
-
- assert.strictEqual(exitCode, 0, `Expected exit code 0.\nstdout: ${stdout}\nstderr: ${stderr}`);
- assert.ok(combined.includes('PPTX created:'), `Expected creation message in output:\n${combined}`);
- assert.ok(combined.includes('3 slides'), `Expected 3 slides in output:\n${combined}`);
-
- // Verify the PPTX was copied back to the host.
- const pptxPath = path.join(rwDir, 'mxc-nanvix.pptx');
- assert.ok(fs.existsSync(pptxPath), `Expected mxc-nanvix.pptx at ${pptxPath}`);
- const size = fs.statSync(pptxPath).size;
- assert.ok(size > 10000, `Expected substantial PPTX from python-pptx, got ${size} bytes`);
-
- // Verify valid zip with PK header.
- const header = Buffer.alloc(4);
- const fd = fs.openSync(pptxPath, 'r');
- fs.readSync(fd, header, 0, 4, 0);
- fs.closeSync(fd);
- assert.strictEqual(header[0], 0x50, 'Expected PK zip header byte 1');
- assert.strictEqual(header[1], 0x4B, 'Expected PK zip header byte 2');
-
- console.log(`Dark PPTX output: ${pptxPath} (${size} bytes)`);
- } finally {
- // Keep output for manual inspection — open in PowerPoint to verify.
- console.log(`Dark PPTX test dir persisted at: ${testDir}`);
- }
- });
-});
diff --git a/sdk/node/tests/integration/test-helpers.ts b/sdk/node/tests/integration/test-helpers.ts
index 4e10aac74..751faf4ed 100644
--- a/sdk/node/tests/integration/test-helpers.ts
+++ b/sdk/node/tests/integration/test-helpers.ts
@@ -79,9 +79,6 @@ export const EXPECTED_MACOS_BINARIES = [
const OPTIONAL_BINARIES = [
'wslcsdk.dll', // Only built with --with-wslc
'wxc-wslc-daemon.exe', // Only built with --with-wslc
- 'nanvixd.exe', // Only built with --with-microvm
- 'nanvix_rootfs.img', // Only built with --with-microvm
- 'python3.initrd', // Only built with --with-microvm
'plm.exe', // Permissive Learning Mode helper (Windows-only); staged
// only when the plm crate is included in the build.
// Test-only binaries. The GitHub build artifact carries them so the
diff --git a/sdk/node/tests/unit/sandbox.test.ts b/sdk/node/tests/unit/sandbox.test.ts
index 4960fbca7..953d6d655 100644
--- a/sdk/node/tests/unit/sandbox.test.ts
+++ b/sdk/node/tests/unit/sandbox.test.ts
@@ -10,7 +10,13 @@ import {
_setBwrapVersionRunner,
_setLxcAvailabilityProbe,
} from '../../src/platform.js';
-import { ContainerConfig, SandboxPolicy, SandboxingMethod } from '../../src/types.js';
+import {
+ ContainerConfig,
+ ContainmentTypes,
+ ExperimentalBackends,
+ SandboxPolicy,
+ SandboxingMethod,
+} from '../../src/types.js';
import { MxcError } from '../../src/errors.js';
import { platformSkip } from './test-helpers.js';
@@ -72,6 +78,13 @@ describe('exact-version network authoring', () => {
});
});
+describe('containment exports', () => {
+ it('exposes microvm and not the internal nvx implementation name', () => {
+ assert.deepStrictEqual(ContainmentTypes, ['process', 'vm', 'microvm']);
+ assert.deepStrictEqual(ExperimentalBackends, ['microvm', 'windows_sandbox', 'hyperlight']);
+ });
+});
+
describe('buildSandboxPayload', () => {
const defaultPolicy: SandboxPolicy = { version: '0.6.0-alpha' };
@@ -236,7 +249,7 @@ describe('buildSandboxPayload', () => {
}
});
- it('should enforce the minimum schema for every development-only containment', () => {
+ it('should enforce the minimum schema for each versioned containment', () => {
mockWindows();
try {
for (const containment of [
@@ -248,7 +261,9 @@ describe('buildSandboxPayload', () => {
'isolation_session',
] as const) {
const minimumVersion =
- containment === 'isolation_session' || containment === 'wslc'
+ containment === 'isolation_session' ||
+ containment === 'wslc' ||
+ containment === 'microvm'
? '0.9.0-alpha'
: '0.10.0-alpha';
assert.throws(
@@ -265,7 +280,7 @@ describe('buildSandboxPayload', () => {
}
});
- it('should accept each development containment at its exact boundary', () => {
+ it('should accept each versioned containment at its exact boundary', () => {
mockWindows();
try {
for (const containment of [
@@ -277,7 +292,9 @@ describe('buildSandboxPayload', () => {
'isolation_session',
] as const) {
const version =
- containment === 'isolation_session' || containment === 'wslc'
+ containment === 'isolation_session' ||
+ containment === 'wslc' ||
+ containment === 'microvm'
? '0.9.0-alpha'
: '0.10.0-alpha';
try {
@@ -286,7 +303,7 @@ describe('buildSandboxPayload', () => {
assert.doesNotMatch(
(error as Error).message,
/Schema .* does not support containment/,
- `${containment} must pass the 0.9 schema floor before backend-specific validation`,
+ `${containment} must pass its ${version} schema floor before backend-specific validation`,
);
}
}
@@ -517,50 +534,6 @@ describe('buildSandboxPayload', () => {
}
};
- it('should return minimal config for microvm without filesystem', () => {
- mockWindows();
- try {
- const payload = buildSandboxPayload('print(42)', developmentPolicy, undefined, undefined, 'microvm');
- assert.strictEqual(payload.containment, 'microvm');
- assert.strictEqual(payload.filesystem, undefined);
- assert.strictEqual(payload.processContainer, undefined);
- } finally {
- restore();
- }
- });
-
- it('should map clearPolicyOnExit to lifecycle.preservePolicy for microvm when policy has paths', () => {
- mockWindows();
- try {
- const policy: SandboxPolicy = {
- version: '0.10.0-alpha',
- filesystem: { readwritePaths: ['/tmp'] },
- };
- const payload = buildSandboxPayload('print(42)', policy, undefined, undefined, 'microvm');
- assert.strictEqual(payload.containment, 'microvm');
- assert.deepStrictEqual(payload.filesystem!.readwritePaths, ['/tmp']);
- // clearPolicyOnExit is not a wire `filesystem` field; the intent is
- // carried canonically by lifecycle.preservePolicy (default clear => not preserved).
- assert.strictEqual(payload.lifecycle!.preservePolicy, false);
- } finally {
- restore();
- }
- });
-
- it('should honor clearPolicyOnExit false for microvm (via lifecycle.preservePolicy)', () => {
- mockWindows();
- try {
- const policy: SandboxPolicy = {
- version: '0.10.0-alpha',
- filesystem: { readwritePaths: ['/tmp'], clearPolicyOnExit: false },
- };
- const payload = buildSandboxPayload('print(42)', policy, undefined, undefined, 'microvm');
- assert.strictEqual(payload.lifecycle!.preservePolicy, true);
- } finally {
- restore();
- }
- });
-
it('should build processcontainer config on Windows with default process containment', () => {
mockWindows();
try {
@@ -576,159 +549,102 @@ describe('buildSandboxPayload', () => {
}
});
- it('should forward coherent directional network policies for microvm', () => {
+ it('should build an NVX payload for the native backend', () => {
mockWindows();
try {
- for (const action of ['allow', 'deny'] as const) {
- const policy: SandboxPolicy = {
- version: '0.10.0-alpha',
- network: {
- egress: { default: action },
- ingress: { default: action, hostLoopback: action },
- },
- };
- const payload = buildSandboxPayload(
- 'print(42)',
- policy,
- undefined,
- undefined,
- 'microvm',
- );
- assert.deepStrictEqual(payload.network, policy.network);
- }
+ const policy: SandboxPolicy = {
+ version: '0.9.0-alpha',
+ filesystem: {
+ readonlyPaths: ['C:\\workspace\\source'],
+ readwritePaths: ['C:\\workspace\\output'],
+ },
+ network: {
+ egress: { default: 'deny' },
+ ingress: { default: 'deny', hostLoopback: 'deny' },
+ },
+ };
+
+ const payload = buildSandboxPayload(
+ 'echo hello',
+ policy,
+ '/',
+ 'microvm-test',
+ 'microvm',
+ );
+
+ assert.strictEqual(payload.containment, 'microvm');
+ assert.strictEqual(payload.containerId, 'microvm-test');
+ assert.deepStrictEqual(payload.process, {
+ commandLine: 'echo hello',
+ timeout: 0,
+ cwd: '/',
+ });
+ assert.deepStrictEqual(payload.filesystem, {
+ readonlyPaths: ['C:\\workspace\\source'],
+ readwritePaths: ['C:\\workspace\\output'],
+ deniedPaths: [],
+ });
+ assert.deepStrictEqual(payload.network, policy.network);
+ assert.strictEqual(payload.ui, undefined);
+ assert.strictEqual(payload.processContainer, undefined);
+ assert.strictEqual(payload.lxc, undefined);
} finally {
restore();
}
});
- it('should reject unsupported network policies for microvm', () => {
+ it('should reject ProcessContainer proxy peer policy for MicroVM', () => {
mockWindows();
try {
assert.throws(
() => buildSandboxPayload(
- 'print(42)',
- {
- version: '0.10.0-alpha',
- runtimeConfig: { networkProxy: 'http://127.0.0.1:8080' },
- },
- undefined,
- undefined,
- 'microvm',
- ),
- { message: /does not support network proxy configuration/ },
- );
- assert.throws(
- () => buildSandboxPayload(
- 'print(42)',
+ 'echo hello',
{
- version: '0.10.0-alpha',
- processContainer: {
- network: { allowedProxyPeer: 'Contoso.Proxy_123' },
+ version: '0.9.0-alpha',
+ runtimeConfig: {
+ networkProxy: 'http://127.0.0.1:8080',
},
- },
- undefined,
- undefined,
- 'microvm',
- ),
- { message: /does not support network proxy configuration/ },
- );
- assert.throws(
- () => buildSandboxPayload(
- 'print(42)',
- {
- version: '0.10.0-alpha',
- network: {
- egress: {
- default: 'allow',
- allow: [{ to: [{ cidr: '203.0.113.0/24' }] }],
+ processContainer: {
+ network: {
+ allowedProxyPeer: 'Contoso.Proxy_1234567890abc',
},
- ingress: { default: 'allow', hostLoopback: 'allow' },
},
},
undefined,
undefined,
'microvm',
),
- { message: /does not support directional network rules/ },
- );
- assert.throws(
- () => buildSandboxPayload(
- 'print(42)',
- {
- version: '0.10.0-alpha',
- network: {
- egress: { default: 'allow' },
- ingress: { default: 'deny', hostLoopback: 'deny' },
- },
- },
- undefined,
- undefined,
- 'microvm',
- ),
- { message: /to be all deny or all allow/ },
+ {
+ message: /processContainer\.network\.allowedProxyPeer is supported only by the Windows ProcessContainer backend/,
+ },
);
} finally {
restore();
}
});
- it('should reject ProcessContainer enumeration policy for microvm', () => {
+ it('should reject UI policy for MicroVM', () => {
mockWindows();
try {
assert.throws(
() => buildSandboxPayload(
- 'print(42)',
+ 'echo hello',
{
- version: '0.10.0-alpha',
- processContainer: {
- filesystem: { enumeratePaths: ['C:\\tools'] },
+ version: '0.9.0-alpha',
+ ui: {
+ allowWindows: false,
+ clipboard: 'none',
+ allowInputInjection: false,
},
},
undefined,
undefined,
'microvm',
),
- { message: /does not support processContainer\.filesystem\.enumeratePaths/ },
- );
- } finally {
- restore();
- }
- });
-
- it('should reject microvm on non-Windows platforms', () => {
- const orig = Object.getOwnPropertyDescriptor(process, 'platform');
- Object.defineProperty(process, 'platform', { value: 'linux' });
- try {
- assert.throws(
- () => buildSandboxPayload('print(42)', developmentPolicy, undefined, undefined, 'microvm'),
- { message: /only supported on Windows/ },
+ {
+ message: /SandboxPolicy\.ui is not supported by the MicroVM backend/,
+ },
);
- } finally {
- if (orig) Object.defineProperty(process, 'platform', orig);
- }
- });
-
- it('should preserve lifecycle config for microvm', () => {
- mockWindows();
- try {
- const policy: SandboxPolicy = {
- version: '0.10.0-alpha',
- filesystem: { clearPolicyOnExit: false },
- };
- const payload = buildSandboxPayload('print(42)', policy, undefined, undefined, 'microvm');
- assert.strictEqual(payload.lifecycle!.destroyOnExit, true);
- assert.strictEqual(payload.lifecycle!.preservePolicy, true);
- } finally {
- restore();
- }
- });
-
- it('should set process commandLine and containerId for microvm', () => {
- mockWindows();
- try {
- const payload = buildSandboxPayload('print(42)', developmentPolicy, undefined, 'my-container', 'microvm');
- assert.strictEqual(payload.process!.commandLine, 'print(42)');
- assert.strictEqual(payload.containerId, 'my-container');
} finally {
restore();
}
@@ -2104,9 +2020,11 @@ describe('resolveExecutableAndArgs (containment validation)', { skip: platformSk
function makeConfig(containment: string): ContainerConfig {
const version =
- containment === 'isolation_session' || containment === 'wslc'
+ containment === 'isolation_session' ||
+ containment === 'wslc' ||
+ containment === 'microvm'
? '0.9.0-alpha'
- : ['microvm', 'vm', 'hyperlight', 'windows_sandbox'].includes(containment)
+ : ['vm', 'hyperlight', 'windows_sandbox'].includes(containment)
? '0.10.0-alpha'
: ['seatbelt', 'macos_sandbox'].includes(containment)
? '0.7.0-alpha'
@@ -2127,9 +2045,19 @@ describe('resolveExecutableAndArgs (containment validation)', { skip: platformSk
);
});
- it('should accept the abstract intent "microvm" with experimental flag (Windows only)', function (this: { skip: (reason?: string) => void }) {
- if (process.platform !== 'win32') {
- this.skip('microvm is Windows-only');
+ it('should reject the internal nvx implementation name', () => {
+ assert.throws(
+ () => resolveExecutableAndArgs(makeConfig('nvx'), {
+ executablePath: fakeExe,
+ experimental: true,
+ }),
+ { message: /nvx.*not available/i },
+ );
+ });
+
+ it('should accept microvm with experimental mode on Windows x64', function (this: { skip: (reason?: string) => void }) {
+ if (process.platform !== 'win32' || process.arch !== 'x64') {
+ this.skip('microvm is Windows x64-only');
return;
}
assert.doesNotThrow(() =>
diff --git a/src/Cargo.lock b/src/Cargo.lock
index 9bc918aee..a200f5960 100644
--- a/src/Cargo.lock
+++ b/src/Cargo.lock
@@ -1374,8 +1374,6 @@ dependencies = [
"lxc_common",
"mxc_build_common",
"mxc_engine",
- "nanvix_binaries",
- "nanvix_build_common",
"wxc_common",
]
@@ -1526,7 +1524,7 @@ dependencies = [
"learning_mode_core",
"lxc_common",
"mxc_config_contract",
- "nanvix_runner",
+ "nvx_runner",
"plm",
"process_container_common",
"seatbelt_common",
@@ -1587,40 +1585,6 @@ dependencies = [
"uuid",
]
-[[package]]
-name = "nanvix_binaries"
-version = "0.8.0"
-dependencies = [
- "nanvix_build_common",
- "nanvix_common",
-]
-
-[[package]]
-name = "nanvix_build_common"
-version = "0.8.0"
-dependencies = [
- "nanvix_common",
-]
-
-[[package]]
-name = "nanvix_common"
-version = "0.8.0"
-dependencies = [
- "serde",
- "serde_json",
-]
-
-[[package]]
-name = "nanvix_runner"
-version = "0.8.0"
-dependencies = [
- "libc",
- "nanvix_common",
- "uuid",
- "windows",
- "wxc_common",
-]
-
[[package]]
name = "nix"
version = "0.29.0"
@@ -1664,6 +1628,40 @@ dependencies = [
"syn",
]
+[[package]]
+name = "nvx_binaries"
+version = "0.8.0"
+dependencies = [
+ "nvx_build_common",
+ "nvx_common",
+ "serde_json",
+ "sha2 0.10.9",
+]
+
+[[package]]
+name = "nvx_build_common"
+version = "0.8.0"
+dependencies = [
+ "nvx_common",
+ "tempfile",
+]
+
+[[package]]
+name = "nvx_common"
+version = "0.8.0"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "nvx_runner"
+version = "0.8.0"
+dependencies = [
+ "nvx_binaries",
+ "wxc_common",
+]
+
[[package]]
name = "oci-spec"
version = "0.10.0"
@@ -3191,8 +3189,8 @@ dependencies = [
"learning_mode_core",
"mxc_build_common",
"mxc_engine",
- "nanvix_binaries",
- "nanvix_build_common",
+ "nvx_binaries",
+ "nvx_build_common",
"plm",
"process_container_common",
"serde_json",
@@ -3213,7 +3211,6 @@ dependencies = [
"libc",
"mxc_config_contract",
"mxc_telemetry",
- "nanvix_common",
"serde",
"serde_json",
"serde_path_to_error",
@@ -3222,7 +3219,6 @@ dependencies = [
"thiserror",
"unicode-general-category",
"url",
- "uuid",
"widestring",
"windows",
"windows-core",
diff --git a/src/Cargo.toml b/src/Cargo.toml
index ab5b51662..b0afe9546 100644
--- a/src/Cargo.toml
+++ b/src/Cargo.toml
@@ -6,10 +6,10 @@ members = [
"backends/isolation_session/bindings",
"backends/isolation_session/common",
"backends/lxc/common",
- "backends/nanvix/binaries",
- "backends/nanvix/build_common",
- "backends/nanvix/common",
- "backends/nanvix/runner",
+ "backends/nvx/binaries",
+ "backends/nvx/build_common",
+ "backends/nvx/common",
+ "backends/nvx/runner",
"backends/seatbelt/common",
"backends/windows_sandbox/common",
"backends/windows_sandbox/daemon",
@@ -85,8 +85,11 @@ mxc_pty = { path = "core/mxc_pty" }
mxc_schema_support = { path = "core/mxc_schema_support" }
mxc-sdk = { path = "core/mxc-sdk" }
mxc_telemetry = { path = "mxc_telemetry" }
-nanvix_runner = { path = "backends/nanvix/runner" }
nix = { version = "0.29", features = ["fs", "mount", "sched", "signal", "net", "process", "user", "term"] }
+nvx_binaries = { path = "backends/nvx/binaries" }
+nvx_build_common = { path = "backends/nvx/build_common" }
+nvx_common = { path = "backends/nvx/common" }
+nvx_runner = { path = "backends/nvx/runner" }
process_security_environment_spec = { path = "core/generated/process_security_environment_specification" }
plm = { path = "host/plm" }
quick-xml = "0.41"
diff --git a/src/backends/nanvix/binaries/Cargo.toml b/src/backends/nanvix/binaries/Cargo.toml
deleted file mode 100644
index 8b71c06e3..000000000
--- a/src/backends/nanvix/binaries/Cargo.toml
+++ /dev/null
@@ -1,25 +0,0 @@
-[package]
-name = "nanvix_binaries"
-version.workspace = true
-edition.workspace = true
-license.workspace = true
-description = "Build-time download of NanVix binaries from GitHub releases"
-links = "nanvix_binaries"
-
-[lib]
-path = "src/lib.rs"
-
-[features]
-# Gates the build script's expensive work (downloading NanVix release assets
-# and verifying their checksums). OFF by default so a plain `cargo build` —
-# which still compiles this crate as a workspace member — performs no network
-# or hashing work. Enabled transitively via the `microvm` feature of `wxc` and
-# `lxc`.
-microvm = []
-
-[dependencies]
-nanvix_common = { path = "../common" }
-
-[build-dependencies]
-nanvix_common = { path = "../common" }
-nanvix_build_common = { path = "../build_common" }
diff --git a/src/backends/nanvix/binaries/build.rs b/src/backends/nanvix/binaries/build.rs
deleted file mode 100644
index c1bb66955..000000000
--- a/src/backends/nanvix/binaries/build.rs
+++ /dev/null
@@ -1,837 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-//! Build script that downloads NanVix binaries from GitHub releases.
-//!
-//! Uses system tools (`curl.exe`, `tar.exe`, `certutil`) instead of Rust
-//! crates. Zero HTTP/zip/crypto build-dependencies — only `nanvix_common`
-//! for shared constants and serde-based config parsing.
-//!
-//! ## Configuration files
-//!
-//! - `versions.json` — pinned release tags and exact asset names
-//! - `checksums.json` — SHA256 hashes for integrity verification
-//!
-//! ## Environment variables
-//!
-//! - `GITHUB_TOKEN` / `GH_TOKEN` — optional; increases API rate limit
-//! - `NANVIX_BIN` — optional; path to a directory of pre-fetched NanVix
-//! binaries. When set, the build uses that directory directly and performs
-//! no network downloads, enabling fully offline builds where all dynamic
-//! build inputs are pre-fetched. The directory must already contain the
-//! required binaries (flat files plus the `bin/` subdirectory); checksums
-//! are still verified against `checksums.json`.
-//!
-//! ## Caching
-//!
-//! Binaries are cached in OUT_DIR (or read from `NANVIX_BIN` when set).
-//! Checksums are verified whenever this build script runs (triggered by
-//! changes to build.rs, versions.json, or checksums.json) to catch corrupted
-//! or truncated files.
-//!
-//! # TODO(security): NanVix binaries are not ESRP-signed. Before shipping in
-//! # official MXC releases, either extend ESRP to cover these binaries or
-//! # establish an internal mirror with supply-chain controls.
-
-use std::collections::HashMap;
-use std::fs;
-use std::path::{Path, PathBuf};
-use std::process::Command;
-
-use nanvix_common::{github_download_url, load_checksums, load_json, ReleaseConfig, RepoConfig};
-
-fn main() {
- // The build script's output (`NANVIX_BIN_DIR` and whether the download /
- // verify path runs) depends on the `microvm` feature, surfaced here as the
- // `CARGO_FEATURE_MICROVM` env var. Declare it as a rerun trigger so toggling
- // the feature between builds re-runs this script instead of reusing stale
- // output.
- println!("cargo:rerun-if-env-changed=CARGO_FEATURE_MICROVM");
-
- // The expensive work in this build script — downloading NanVix release
- // assets and verifying their checksums via `certutil` — is only needed
- // when the micro-VM backend is actually being built. Gate it behind this
- // crate's `microvm` feature so that a default `cargo build` (which still
- // compiles this crate as a workspace member) performs no network or hashing
- // work. `wxc` and `lxc` enable `nanvix_binaries/microvm` through their own
- // `microvm` features.
- //
- // `NANVIX_BIN_DIR` must still be emitted in every configuration because
- // `lib.rs` references it via `env!`.
- if std::env::var_os("CARGO_FEATURE_MICROVM").is_none() {
- let out_dir = std::env::var("OUT_DIR").unwrap();
- println!("cargo:rustc-env=NANVIX_BIN_DIR={}", out_dir);
- println!("cargo:rerun-if-changed=build.rs");
- return;
- }
-
- // Check the TARGET platform (not host). NanVix binaries are only needed when
- // the output binary will run on Windows or Linux with KVM.
- let target = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
- if target != "windows" && target != "linux" {
- let out_dir = std::env::var("OUT_DIR").unwrap();
- println!("cargo:rustc-env=NANVIX_BIN_DIR={}", out_dir);
- println!("cargo:rerun-if-changed=build.rs");
- return;
- }
-
- let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
-
- // When `NANVIX_BIN` is set, use the caller-provided directory of
- // pre-fetched binaries and skip all network downloads (offline builds);
- // otherwise download into a subdirectory of OUT_DIR as before. The
- // resolution lives in the build-only `nanvix_build_common` crate.
- let (bin_dir, use_prefetched_binaries) = nanvix_build_common::resolve_bin_dir(&out_dir);
-
- let versions: ReleaseConfig = load_json("versions.json");
- let checksums: HashMap = load_checksums("checksums.json", &target);
-
- if target == "linux" {
- // Linux: download tar.gz and extract Linux binaries.
- let asset = versions
- .nanvix_python
- .asset_linux
- .as_deref()
- .unwrap_or("microvm-standalone-256mb.tar.gz");
- let binaries: Vec<&str> = versions
- .nanvix_python
- .binaries_linux
- .as_ref()
- .map(|v| v.iter().map(|s| s.as_str()).collect())
- .unwrap_or_else(|| nanvix_common::REQUIRED_BINARIES.to_vec());
-
- let needs_download =
- !use_prefetched_binaries && needs_download_linux(&binaries, &bin_dir, &checksums);
- if needs_download {
- eprintln!(
- "nanvix_binaries: downloading nanvix/nanvix-python {} (Linux)...",
- versions.nanvix_python.tag
- );
- download_and_extract_linux(&versions.nanvix_python.tag, asset, &binaries, &bin_dir);
- } else if use_prefetched_binaries {
- eprintln!(
- "nanvix_binaries: offline — verifying pre-fetched Linux binaries in '{}'",
- bin_dir.display()
- );
- } else {
- eprintln!("nanvix_binaries: all Linux binaries cached and verified");
- }
-
- verify_checksums_linux(&binaries, &bin_dir, &checksums);
- verify_bin_subdir_checksums_linux(&bin_dir, &checksums);
-
- if use_prefetched_binaries {
- nanvix_build_common::emit_rerun_for_copied_artifacts(&bin_dir);
- }
- } else {
- // Windows: original logic
- let all_binaries: Vec<&str> = versions
- .nanvix_python
- .binaries
- .iter()
- .map(|s| s.as_str())
- .collect();
-
- let needs_nanvix_python = !use_prefetched_binaries
- && needs_download(&versions.nanvix_python, &bin_dir, &checksums);
-
- if needs_nanvix_python {
- eprintln!(
- "nanvix_binaries: downloading nanvix/nanvix-python {}...",
- versions.nanvix_python.tag
- );
- download_and_extract(&versions.nanvix_python, "nanvix/nanvix-python", &bin_dir);
- } else if use_prefetched_binaries {
- eprintln!(
- "nanvix_binaries: offline — verifying pre-fetched binaries in '{}'",
- bin_dir.display()
- );
- } else {
- eprintln!("nanvix_binaries: all binaries cached and verified");
- }
-
- verify_checksums(&all_binaries, &bin_dir, &checksums);
- verify_bin_subdir_checksums(&bin_dir, &checksums);
-
- if use_prefetched_binaries {
- nanvix_build_common::emit_rerun_for_copied_artifacts(&bin_dir);
- }
-
- // Generate host-local WHP snapshots at build time so even the first
- // runtime execution uses warm start. The runtime fallback in
- // nanvix_runner.rs handles the case where snapshots are missing.
- //
- // Skip on non-x86_64 hosts: `nanvixd.exe` is an x86_64 Windows binary
- // and launching it on (e.g.) ARM64 Windows fails with
- // STATUS_INVALID_IMAGE_FORMAT (0xc000007b). Snapshot pre-generation is
- // a warm-start cache only — the runtime fallback covers cold boot on
- // hosts where this build step is skipped.
- let host = std::env::var("HOST").unwrap_or_default();
- let host_is_x86_64 = host.starts_with("x86_64-");
- if !host_is_x86_64 {
- eprintln!(
- "nanvix_binaries: skipping host-local snapshot generation \
- (host '{}' is not x86_64; nanvixd.exe cannot run here). \
- Runtime will cold-boot on first use.",
- host
- );
- } else {
- let snapshots_dir = bin_dir.join(nanvix_common::SNAPSHOTS_SUBDIR);
- let snapshots_present = nanvix_common::SNAPSHOT_FILES
- .iter()
- .all(|name| snapshots_dir.join(name).exists());
- if snapshots_present {
- eprintln!("nanvix_binaries: host-local snapshots already present");
- } else if use_prefetched_binaries {
- // In offline mode the NANVIX_BIN directory is treated as an
- // immutable, pre-fetched input — it may be a read-only or
- // shared cache. Do not run nanvixd.exe to generate snapshots
- // into it. The runtime fallback in nanvix_runner.rs cold-boots
- // when snapshots are absent.
- eprintln!(
- "nanvix_binaries: offline — snapshots absent in NANVIX_BIN; \
- skipping generation (runtime will cold-boot on first use)."
- );
- } else {
- fs::create_dir_all(&snapshots_dir).expect("failed to create snapshots dir");
- eprintln!("nanvix_binaries: generating host-local snapshots (cold boot)...");
- generate_snapshots_locally(&bin_dir);
- }
- }
- }
-
- println!("cargo:rustc-env=NANVIX_BIN_DIR={}", bin_dir.display());
- println!("cargo:BIN_DIR={}", bin_dir.display());
- // Propagate whether these binaries came from an externally supplied
- // (prefetched) directory. Consumers read this as
- // `DEP_NANVIX_BINARIES_PREFETCHED` and must not trust prefetched WHP
- // snapshots (they are not covered by checksums.json).
- println!(
- "cargo:PREFETCHED={}",
- if use_prefetched_binaries { "1" } else { "0" }
- );
- println!("cargo:rerun-if-changed=build.rs");
- println!("cargo:rerun-if-changed=versions.json");
- println!("cargo:rerun-if-changed=checksums.json");
- println!("cargo:rerun-if-env-changed=GITHUB_TOKEN");
- println!("cargo:rerun-if-env-changed=GH_TOKEN");
- println!("cargo:rerun-if-env-changed=NANVIX_BIN");
-}
-
-// -- Download logic ----------------------------------------------------------
-
-fn needs_download(
- config: &RepoConfig,
- bin_dir: &Path,
- checksums: &HashMap,
-) -> bool {
- // Check flat binaries.
- let flat_missing = config.binaries.iter().any(|name| {
- let path = bin_dir.join(name);
- if !path.exists() {
- return true;
- }
- if let Some(expected) = checksums.get(name.as_str()) {
- certutil_sha256(&path) != *expected
- } else {
- false
- }
- });
- if flat_missing {
- return true;
- }
-
- // Check bin/ subdir files.
- let bin_subdir = bin_dir.join(nanvix_common::BIN_SUBDIR);
- for name in nanvix_common::BIN_SUBDIR_FILES {
- let path = bin_subdir.join(name);
- if !path.exists() {
- return true;
- }
- if let Some(expected) = checksums.get(*name) {
- if certutil_sha256(&path) != *expected {
- return true;
- }
- }
- }
-
- false
-}
-
-fn download_and_extract(config: &RepoConfig, repo: &str, bin_dir: &Path) {
- let url = github_download_url(repo, &config.tag, &config.asset);
- let zip_path = bin_dir.join(&config.asset);
-
- // Cleanup helper: remove zip on failure.
- let cleanup = |zip: &Path| {
- let _ = fs::remove_file(zip);
- };
-
- eprintln!(" downloading {}...", config.asset);
- if let Err(msg) = try_curl_download(&url, &zip_path) {
- cleanup(&zip_path);
- panic!("nanvix_binaries: {}", msg);
- }
-
- let size = zip_path.metadata().map(|m| m.len()).unwrap_or(0);
- eprintln!(" downloaded {} bytes, extracting...", size);
-
- let binaries: Vec<&str> = config.binaries.iter().map(|s| s.as_str()).collect();
-
- // Extract flat binaries (nanvixd.exe from bin/, rootfs + initrd from root).
- if let Err(msg) = try_tar_extract(&zip_path, bin_dir, &binaries) {
- cleanup(&zip_path);
- panic!("nanvix_binaries: {}", msg);
- }
-
- // Extract bin/ subdir files (kernel.elf stays in bin/ as nanvixd expects).
- let bin_subdir = bin_dir.join(nanvix_common::BIN_SUBDIR);
- fs::create_dir_all(&bin_subdir).expect("failed to create bin subdir");
- if let Err(msg) = try_tar_extract_bin_subdir(&zip_path, &bin_subdir) {
- cleanup(&zip_path);
- panic!("nanvix_binaries: {}", msg);
- }
-
- let _ = fs::remove_file(&zip_path);
-}
-
-// -- Snapshot generation -----------------------------------------------------
-
-fn generate_snapshots_locally(bin_dir: &Path) {
- let nanvixd = bin_dir.join("nanvixd.exe");
- let ramfs = bin_dir.join("nanvix_rootfs.img");
- let initrd = bin_dir.join("python3.initrd");
- let bin_subdir = bin_dir.join(nanvix_common::BIN_SUBDIR);
-
- if !nanvixd.exists() || !ramfs.exists() || !initrd.exists() {
- panic!(
- "nanvix_binaries: cannot generate snapshots — required binaries missing:\n\
- \x20 nanvixd.exe: {}\n\
- \x20 nanvix_rootfs.img: {}\n\
- \x20 python3.initrd: {}",
- nanvixd.exists(),
- ramfs.exists(),
- initrd.exists()
- );
- }
-
- nanvix_common::generate_snapshot(bin_dir, &nanvixd, &bin_subdir, &ramfs, &initrd)
- .unwrap_or_else(|e| panic!("nanvix_binaries: {}", e));
-
- // Log generated file sizes.
- let snapshots_dir = bin_dir.join(nanvix_common::SNAPSHOTS_SUBDIR);
- for name in nanvix_common::SNAPSHOT_FILES {
- let path = snapshots_dir.join(name);
- let size = path.metadata().map(|m| m.len()).unwrap_or(0);
- eprintln!(" snapshots/{} -- generated ({} bytes)", name, size);
- }
-}
-
-// -- curl.exe ----------------------------------------------------------------
-
-fn try_curl_download(url: &str, dest: &Path) -> Result<(), String> {
- let mut cmd = Command::new("curl");
- cmd.args([
- "--silent",
- "--show-error",
- "--fail",
- "--location",
- "--retry",
- "5",
- "--retry-delay",
- "5",
- "--retry-all-errors",
- "--output",
- ]);
- cmd.arg(dest);
- cmd.args(["--header", "User-Agent: mxc-nanvix-build/0.1"]);
-
- if let Some(token) = github_token() {
- cmd.arg("--header");
- cmd.arg(format!("Authorization: Bearer {}", token));
- }
-
- cmd.arg(url);
-
- let output = cmd.output().map_err(|e| {
- format!(
- "curl not found: {}\n\
- Ensure curl is in PATH.",
- e
- )
- })?;
-
- if !output.status.success() {
- return Err(format!(
- "curl failed for {}\n exit code: {}\n stderr: {}",
- url,
- output.status,
- String::from_utf8_lossy(&output.stderr)
- ));
- }
-
- Ok(())
-}
-
-// -- tar.exe -----------------------------------------------------------------
-
-fn try_tar_extract(zip_path: &Path, dest_dir: &Path, files: &[&str]) -> Result<(), String> {
- // The nanvix-python zip has a top-level directory with two sub-layouts:
- // bin/nanvixd.exe → strip 2 components
- // nanvix_rootfs.img, python3.initrd → strip 1 component
-
- const ARCHIVE_PREFIX: &str = "microvm-standalone-256mb";
- const BIN_DIR_FILES: &[&str] = &["nanvixd.exe"];
-
- let (bin_files, root_files): (Vec<&&str>, Vec<&&str>) =
- files.iter().partition(|f| BIN_DIR_FILES.contains(f));
-
- // Pass 1: files under /bin/ — strip 2 path components.
- if !bin_files.is_empty() {
- let mut cmd = Command::new("tar");
- cmd.arg("-xf").arg(zip_path).arg("-C").arg(dest_dir);
- cmd.args(["--strip-components", "2"]);
- for f in &bin_files {
- cmd.arg(format!("{}/bin/{}", ARCHIVE_PREFIX, f));
- }
- let output = cmd.output().map_err(|e| {
- format!(
- "tar.exe not found: {}\n\
- tar.exe ships with Windows 10 1803+. Ensure it's in PATH.",
- e
- )
- })?;
- if !output.status.success() {
- return Err(format!(
- "tar extraction failed (bin files)\n exit code: {}\n stderr: {}",
- output.status,
- String::from_utf8_lossy(&output.stderr)
- ));
- }
- }
-
- // Pass 2: files at / root — strip 1 path component.
- if !root_files.is_empty() {
- let mut cmd = Command::new("tar");
- cmd.arg("-xf").arg(zip_path).arg("-C").arg(dest_dir);
- cmd.args(["--strip-components", "1"]);
- for f in &root_files {
- cmd.arg(format!("{}/{}", ARCHIVE_PREFIX, f));
- }
- let output = cmd.output().map_err(|e| {
- format!(
- "tar.exe not found: {}\n\
- tar.exe ships with Windows 10 1803+. Ensure it's in PATH.",
- e
- )
- })?;
- if !output.status.success() {
- return Err(format!(
- "tar extraction failed (root files)\n exit code: {}\n stderr: {}",
- output.status,
- String::from_utf8_lossy(&output.stderr)
- ));
- }
- }
-
- for f in files {
- let path = dest_dir.join(f);
- if path.exists() {
- let size = path.metadata().map(|m| m.len()).unwrap_or(0);
- eprintln!(" {} -- extracted ({} bytes)", f, size);
- } else {
- return Err(format!("'{}' not found in zip after extraction", f));
- }
- }
-
- Ok(())
-}
-
-fn try_tar_extract_bin_subdir(zip_path: &Path, dest_dir: &Path) -> Result<(), String> {
- const ARCHIVE_PREFIX: &str = "microvm-standalone-256mb";
-
- for name in nanvix_common::BIN_SUBDIR_FILES {
- let mut cmd = Command::new("tar");
- cmd.arg("-xf").arg(zip_path).arg("-C").arg(dest_dir);
- cmd.args(["--strip-components", "2"]);
- cmd.arg(format!("{}/bin/{}", ARCHIVE_PREFIX, name));
- let output = cmd
- .output()
- .map_err(|e| format!("tar.exe not found: {}", e))?;
- if !output.status.success() {
- return Err(format!(
- "tar extraction failed (bin/{})\n exit code: {}\n stderr: {}",
- name,
- output.status,
- String::from_utf8_lossy(&output.stderr)
- ));
- }
- let path = dest_dir.join(name);
- if path.exists() {
- let size = path.metadata().map(|m| m.len()).unwrap_or(0);
- eprintln!(" bin/{} -- extracted ({} bytes)", name, size);
- } else {
- return Err(format!("'bin/{}' not found in zip after extraction", name));
- }
- }
-
- Ok(())
-}
-
-// -- Helpers -----------------------------------------------------------------
-
-fn github_token() -> Option {
- std::env::var("GITHUB_TOKEN")
- .or_else(|_| std::env::var("GH_TOKEN"))
- .ok()
-}
-
-// -- certutil SHA256 ---------------------------------------------------------
-
-fn certutil_sha256(path: &Path) -> String {
- let output = Command::new("certutil")
- .args(["-hashfile"])
- .arg(path)
- .arg("SHA256")
- .output()
- .unwrap_or_else(|e| {
- panic!("nanvix_binaries: failed to run certutil: {}", e);
- });
-
- if !output.status.success() {
- panic!(
- "nanvix_binaries: certutil -hashfile failed for {}: {}",
- path.display(),
- String::from_utf8_lossy(&output.stderr)
- );
- }
-
- // certutil output format:
- // SHA256 hash of :
- //
- // CertUtil: -hashfile command completed successfully.
- //
- // Use a lossy conversion because the localized header/footer lines are
- // emitted in the console's OEM code page (e.g. CP850 on French Windows),
- // not UTF-8 -- a strict `from_utf8` would panic on those bytes. The hash
- // line itself is pure ASCII hex, so it survives the lossy conversion. We
- // locate the hash by scanning for a 64-character hex line rather than
- // relying on a fixed line index, which keeps this locale-independent.
- let stdout = String::from_utf8_lossy(&output.stdout);
- stdout
- .lines()
- .map(|line| line.trim().replace(' ', "").to_lowercase())
- .find(|line| line.len() == 64 && line.bytes().all(|b| b.is_ascii_hexdigit()))
- .unwrap_or_else(|| panic!("nanvix_binaries: unexpected certutil output: {}", stdout))
-}
-
-fn verify_checksums(binaries: &[&str], bin_dir: &Path, checksums: &HashMap) {
- for name in binaries {
- let path = bin_dir.join(name);
- if !path.exists() {
- panic!("nanvix_binaries: {} not found after download/extract", name);
- }
-
- if let Some(expected) = checksums.get(*name) {
- let actual = certutil_sha256(&path);
- if actual != *expected {
- panic!(
- "nanvix_binaries: SHA256 mismatch for '{}'!\n\
- \x20 expected: {}\n\
- \x20 actual: {}\n\
- This may indicate a corrupted download or a NanVix version update.\n\
- Update checksums.json with the new hashes.",
- name, expected, actual
- );
- }
- eprintln!(" {} -- checksum OK", name);
- } else {
- panic!(
- "nanvix_binaries: '{}' has no entry in checksums.json — \
- every binary must be hash-verified",
- name
- );
- }
- }
-}
-
-fn verify_bin_subdir_checksums(bin_dir: &Path, checksums: &HashMap) {
- let bin_subdir = bin_dir.join(nanvix_common::BIN_SUBDIR);
- for name in nanvix_common::BIN_SUBDIR_FILES {
- let path = bin_subdir.join(name);
- if !path.exists() {
- panic!(
- "nanvix_binaries: bin/{} not found after download/extract",
- name
- );
- }
-
- if let Some(expected) = checksums.get(*name) {
- let actual = certutil_sha256(&path);
- if actual != *expected {
- panic!(
- "nanvix_binaries: SHA256 mismatch for 'bin/{}'!\n\
- \x20 expected: {}\n\
- \x20 actual: {}\n\
- Update checksums.json with the new hashes.",
- name, expected, actual
- );
- }
- eprintln!(" bin/{} -- checksum OK", name);
- } else {
- panic!(
- "nanvix_binaries: 'bin/{}' has no entry in checksums.json — \
- every binary must be hash-verified",
- name
- );
- }
- }
-}
-
-// -- Linux-specific functions ------------------------------------------------
-
-fn needs_download_linux(
- binaries: &[&str],
- bin_dir: &Path,
- checksums: &HashMap,
-) -> bool {
- // Check flat binaries.
- for name in binaries {
- let path = bin_dir.join(name);
- if !path.exists() {
- return true;
- }
- if let Some(expected) = checksums.get(*name) {
- if sha256sum(&path) != *expected {
- return true;
- }
- }
- }
-
- // Check bin/ subdir files.
- let bin_subdir = bin_dir.join(nanvix_common::BIN_SUBDIR);
- for name in nanvix_common::BIN_SUBDIR_FILES {
- let path = bin_subdir.join(name);
- if !path.exists() {
- return true;
- }
- if let Some(expected) = checksums.get(*name) {
- if sha256sum(&path) != *expected {
- return true;
- }
- }
- }
-
- false
-}
-
-fn download_and_extract_linux(tag: &str, asset: &str, binaries: &[&str], bin_dir: &Path) {
- let url = github_download_url("nanvix/nanvix-python", tag, asset);
- let tar_path = bin_dir.join(asset);
-
- let cleanup = |p: &Path| {
- let _ = fs::remove_file(p);
- };
-
- eprintln!(" downloading {}...", asset);
- if let Err(msg) = try_curl_download(&url, &tar_path) {
- cleanup(&tar_path);
- panic!("nanvix_binaries: {}", msg);
- }
-
- let size = tar_path.metadata().map(|m| m.len()).unwrap_or(0);
- eprintln!(" downloaded {} bytes, extracting...", size);
-
- // Extract flat binaries from / using tar on Linux.
- if let Err(msg) = try_tar_extract_linux(&tar_path, bin_dir, binaries) {
- cleanup(&tar_path);
- panic!("nanvix_binaries: {}", msg);
- }
-
- // Extract bin/ subdir files (kernel.elf, nanvixd.elf).
- let bin_subdir = bin_dir.join(nanvix_common::BIN_SUBDIR);
- fs::create_dir_all(&bin_subdir).expect("failed to create bin subdir");
- if let Err(msg) = try_tar_extract_bin_subdir_linux(&tar_path, &bin_subdir) {
- cleanup(&tar_path);
- panic!("nanvix_binaries: {}", msg);
- }
-
- let _ = fs::remove_file(&tar_path);
-}
-
-fn try_tar_extract_linux(tar_path: &Path, dest_dir: &Path, files: &[&str]) -> Result<(), String> {
- const ARCHIVE_PREFIX: &str = "microvm-standalone-256mb";
-
- // nanvixd.elf lives under /bin/, other flat binaries under /.
- let bin_dir_files: &[&str] = &["nanvixd.elf"];
- let (bin_files, root_files): (Vec<&&str>, Vec<&&str>) =
- files.iter().partition(|f| bin_dir_files.contains(f));
-
- // Pass 1: files under /bin/ — strip 2 path components.
- if !bin_files.is_empty() {
- let mut cmd = Command::new("tar");
- cmd.arg("-xzf").arg(tar_path).arg("-C").arg(dest_dir);
- cmd.args(["--strip-components", "2"]);
- for f in &bin_files {
- cmd.arg(format!("{}/bin/{}", ARCHIVE_PREFIX, f));
- }
- let output = cmd.output().map_err(|e| format!("tar not found: {}", e))?;
- if !output.status.success() {
- return Err(format!(
- "tar extraction failed (bin files)\n exit code: {}\n stderr: {}",
- output.status,
- String::from_utf8_lossy(&output.stderr)
- ));
- }
- }
-
- // Pass 2: files at / root — strip 1 path component.
- if !root_files.is_empty() {
- let mut cmd = Command::new("tar");
- cmd.arg("-xzf").arg(tar_path).arg("-C").arg(dest_dir);
- cmd.args(["--strip-components", "1"]);
- for f in &root_files {
- cmd.arg(format!("{}/{}", ARCHIVE_PREFIX, f));
- }
- let output = cmd.output().map_err(|e| format!("tar not found: {}", e))?;
- if !output.status.success() {
- return Err(format!(
- "tar extraction failed (root files)\n exit code: {}\n stderr: {}",
- output.status,
- String::from_utf8_lossy(&output.stderr)
- ));
- }
- }
-
- for f in files {
- let path = dest_dir.join(f);
- if path.exists() {
- let size = path.metadata().map(|m| m.len()).unwrap_or(0);
- eprintln!(" {} -- extracted ({} bytes)", f, size);
- } else {
- return Err(format!("'{}' not found in archive after extraction", f));
- }
- }
-
- Ok(())
-}
-
-fn try_tar_extract_bin_subdir_linux(tar_path: &Path, dest_dir: &Path) -> Result<(), String> {
- const ARCHIVE_PREFIX: &str = "microvm-standalone-256mb";
-
- for name in nanvix_common::BIN_SUBDIR_FILES {
- let mut cmd = Command::new("tar");
- cmd.arg("-xzf").arg(tar_path).arg("-C").arg(dest_dir);
- cmd.args(["--strip-components", "2"]);
- cmd.arg(format!("{}/bin/{}", ARCHIVE_PREFIX, name));
- let output = cmd.output().map_err(|e| format!("tar not found: {}", e))?;
- if !output.status.success() {
- return Err(format!(
- "tar extraction failed (bin/{})\n exit code: {}\n stderr: {}",
- name,
- output.status,
- String::from_utf8_lossy(&output.stderr)
- ));
- }
- let path = dest_dir.join(name);
- if path.exists() {
- let size = path.metadata().map(|m| m.len()).unwrap_or(0);
- eprintln!(" bin/{} -- extracted ({} bytes)", name, size);
- } else {
- return Err(format!(
- "'bin/{}' not found in archive after extraction",
- name
- ));
- }
- }
-
- Ok(())
-}
-
-/// Compute SHA256 hash using the `sha256sum` command (Linux).
-fn sha256sum(path: &Path) -> String {
- let output = Command::new("sha256sum")
- .arg(path)
- .output()
- .unwrap_or_else(|e| {
- panic!("nanvix_binaries: failed to run sha256sum: {}", e);
- });
-
- if !output.status.success() {
- panic!(
- "nanvix_binaries: sha256sum failed for {}: {}",
- path.display(),
- String::from_utf8_lossy(&output.stderr)
- );
- }
-
- let stdout = String::from_utf8(output.stdout).expect("sha256sum output not UTF-8");
- stdout
- .split_whitespace()
- .next()
- .unwrap_or_else(|| panic!("nanvix_binaries: unexpected sha256sum output: {}", stdout))
- .to_lowercase()
-}
-
-fn verify_checksums_linux(binaries: &[&str], bin_dir: &Path, checksums: &HashMap) {
- for name in binaries {
- let path = bin_dir.join(name);
- if !path.exists() {
- panic!("nanvix_binaries: {} not found after download/extract", name);
- }
-
- if let Some(expected) = checksums.get(*name) {
- let actual = sha256sum(&path);
- if actual != *expected {
- panic!(
- "nanvix_binaries: SHA256 mismatch for '{}'!\n\
- \x20 expected: {}\n\
- \x20 actual: {}\n\
- This may indicate a corrupted download or a NanVix version update.\n\
- Update checksums.json with the new hashes.",
- name, expected, actual
- );
- }
- eprintln!(" {} -- checksum OK", name);
- } else {
- panic!(
- "nanvix_binaries: '{}' has no entry in checksums.json — \
- every binary must be hash-verified",
- name
- );
- }
- }
-}
-
-fn verify_bin_subdir_checksums_linux(bin_dir: &Path, checksums: &HashMap) {
- let bin_subdir = bin_dir.join(nanvix_common::BIN_SUBDIR);
- for name in nanvix_common::BIN_SUBDIR_FILES {
- let path = bin_subdir.join(name);
- if !path.exists() {
- panic!(
- "nanvix_binaries: bin/{} not found after download/extract",
- name
- );
- }
-
- if let Some(expected) = checksums.get(*name) {
- let actual = sha256sum(&path);
- if actual != *expected {
- panic!(
- "nanvix_binaries: SHA256 mismatch for 'bin/{}'!\n\
- \x20 expected: {}\n\
- \x20 actual: {}\n\
- Update checksums.json with the new hashes.",
- name, expected, actual
- );
- }
- eprintln!(" bin/{} -- checksum OK", name);
- } else {
- panic!(
- "nanvix_binaries: 'bin/{}' has no entry in checksums.json — \
- every binary must be hash-verified",
- name
- );
- }
- }
-}
diff --git a/src/backends/nanvix/binaries/checksums.json b/src/backends/nanvix/binaries/checksums.json
deleted file mode 100644
index 68d5fb082..000000000
--- a/src/backends/nanvix/binaries/checksums.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "windows": {
- "nanvixd.exe": "1c6f985da5f7bd914e737a7bff01d3589bdf23912258a5d27284c2ccf36bbe89",
- "nanvix_rootfs.img": "a1e34f4658c4740351fca0147d6061d75ff67e29a586be1accf3bde96753b036",
- "python3.initrd": "b5f545510e3a5f94b39632f38493e20fa938a7a649a8d9d18b62eabc16386686",
- "kernel.elf": "49c9a1a184b1fec4a437488b3e8145ed1247fa5692799c8eb8499531d6f98c7a"
- },
- "linux": {
- "nanvixd.elf": "49de37a6002fd88acf890eaef1b7a5fc8259ff1469a635261948e386b4012a8e",
- "nanvix_rootfs.img": "a1e34f4658c4740351fca0147d6061d75ff67e29a586be1accf3bde96753b036",
- "python3.initrd": "b5f545510e3a5f94b39632f38493e20fa938a7a649a8d9d18b62eabc16386686",
- "kernel.elf": "dff28c4668083e7b0148d8d66b8e2a72ba6d9d2515562931bb062f0a74c32882"
- }
-}
diff --git a/src/backends/nanvix/binaries/src/lib.rs b/src/backends/nanvix/binaries/src/lib.rs
deleted file mode 100644
index 50b725f6b..000000000
--- a/src/backends/nanvix/binaries/src/lib.rs
+++ /dev/null
@@ -1,10 +0,0 @@
-/// Path to the directory containing downloaded NanVix binaries.
-///
-/// Set by build.rs via `cargo:rustc-env`. This points to the build-time
-/// OUT_DIR and is used by wxc/build.rs to copy binaries next to the final
-/// executable. At runtime, the NanVix runner discovers binaries via
-/// `std::env::current_exe()` — it does NOT use this constant.
-pub const NANVIX_BIN_DIR: &str = env!("NANVIX_BIN_DIR");
-
-// Re-export shared constants from nanvix_common.
-pub use nanvix_common::REQUIRED_BINARIES;
diff --git a/src/backends/nanvix/binaries/versions.json b/src/backends/nanvix/binaries/versions.json
deleted file mode 100644
index bead419bd..000000000
--- a/src/backends/nanvix/binaries/versions.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "nanvix_python": {
- "tag": "3.12.3-nanvix-0.16.65-fce3620",
- "asset": "microvm-standalone-256mb.zip",
- "asset_linux": "microvm-standalone-256mb.tar.gz",
- "binaries": [
- "nanvixd.exe",
- "nanvix_rootfs.img",
- "python3.initrd"
- ],
- "binaries_linux": [
- "nanvixd.elf",
- "nanvix_rootfs.img",
- "python3.initrd"
- ]
- }
-}
diff --git a/src/backends/nanvix/build_common/Cargo.toml b/src/backends/nanvix/build_common/Cargo.toml
deleted file mode 100644
index 30f31a2bd..000000000
--- a/src/backends/nanvix/build_common/Cargo.toml
+++ /dev/null
@@ -1,9 +0,0 @@
-[package]
-name = "nanvix_build_common"
-version.workspace = true
-edition.workspace = true
-license.workspace = true
-description = "Build-time helpers for staging NanVix micro-VM binaries (build-only; never linked into runtime)"
-
-[dependencies]
-nanvix_common = { path = "../common" }
diff --git a/src/backends/nanvix/build_common/src/lib.rs b/src/backends/nanvix/build_common/src/lib.rs
deleted file mode 100644
index 52f190657..000000000
--- a/src/backends/nanvix/build_common/src/lib.rs
+++ /dev/null
@@ -1,363 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-//! Build-time helpers for staging NanVix micro-VM binaries next to the
-//! consuming executable.
-//!
-//! This crate is **build-only**: it is consumed exclusively as a
-//! `[build-dependencies]` entry by the `nanvix_binaries`, `wxc`, and `lxc`
-//! build scripts and is never linked into the shipping runtime binary. The
-//! file-staging logic lives here (rather than in the runtime `nanvix_common`
-//! crate) so it adds no weight to mainline code.
-
-use std::io;
-use std::path::{Path, PathBuf};
-
-use nanvix_common::{
- BIN_SUBDIR, BIN_SUBDIR_FILES, REQUIRED_BINARIES, SNAPSHOTS_SUBDIR, SNAPSHOT_FILES,
-};
-
-/// Category of a staged NanVix artifact.
-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
-pub enum ArtifactKind {
- /// Flat binary next to the executable (e.g. `nanvixd.exe`).
- Binary,
- /// File under the `bin/` subdirectory (e.g. `bin/kernel.elf`).
- BinFile,
- /// WHP warm-start snapshot under `snapshots/` (used by the Windows runner).
- Snapshot,
-}
-
-/// Relative paths (from the nanvix binary directory) of every artifact the
-/// backend stages, paired with its [`ArtifactKind`].
-///
-/// This is the single source of truth that drives both copying
-/// ([`copy_artifacts_to_target`]) and `cargo:rerun-if-changed` emission
-/// ([`emit_rerun_for_copied_artifacts`]) so the two can never drift apart.
-pub fn artifact_rel_paths() -> Vec<(ArtifactKind, PathBuf)> {
- let mut paths = Vec::new();
- for name in REQUIRED_BINARIES {
- paths.push((ArtifactKind::Binary, PathBuf::from(name)));
- }
- for name in BIN_SUBDIR_FILES {
- paths.push((ArtifactKind::BinFile, Path::new(BIN_SUBDIR).join(name)));
- }
- for name in SNAPSHOT_FILES {
- paths.push((
- ArtifactKind::Snapshot,
- Path::new(SNAPSHOTS_SUBDIR).join(name),
- ));
- }
- paths
-}
-
-/// Resolve the directory of NanVix binaries to use for a build, and report
-/// whether it came from an externally supplied (prefetched) location.
-///
-/// When `NANVIX_BIN` is set to a non-empty value, that directory is used
-/// directly (no network downloads) and the returned boolean is `true`. An
-/// empty `NANVIX_BIN` is treated as unset, matching the online/cached default.
-/// Otherwise a `nanvix-binaries` subdirectory of `out_dir` is created and
-/// returned with `false`.
-///
-/// The prefetched directory is made absolute via [`std::path::absolute`] (not
-/// `fs::canonicalize`) so it is stable regardless of the build script's working
-/// directory while still letting an atomic symlink swap of the cache be noticed
-/// by Cargo.
-pub fn resolve_bin_dir(out_dir: &Path) -> (PathBuf, bool) {
- let prefetched = std::env::var_os("NANVIX_BIN")
- .filter(|v| !v.is_empty())
- .map(PathBuf::from);
-
- match prefetched {
- Some(dir) => {
- if !dir.is_dir() {
- panic!(
- "nanvix_binaries: NANVIX_BIN is set to '{}', but that directory \
- does not exist. Point NANVIX_BIN at a directory containing the \
- pre-fetched NanVix binaries.",
- dir.display()
- );
- }
- let dir = std::path::absolute(&dir).unwrap_or_else(|e| {
- panic!(
- "nanvix_binaries: failed to resolve NANVIX_BIN '{}' to an \
- absolute path: {}",
- dir.display(),
- e
- )
- });
- eprintln!(
- "nanvix_binaries: NANVIX_BIN set — using pre-fetched binaries from '{}' (offline)",
- dir.display()
- );
- (dir, true)
- }
- None => {
- let dir = out_dir.join("nanvix-binaries");
- std::fs::create_dir_all(&dir).expect("failed to create nanvix-binaries dir");
- (dir, false)
- }
- }
-}
-
-/// Copy NanVix artifacts from the build cache (`src_dir`) to the target
-/// directory next to the output executable.
-///
-/// Binaries and `bin/` files are copied whenever the source exists (the build
-/// script only re-runs when a tracked input changed, so this is not a
-/// per-build cost — and a modification-time comparison would silently skip a
-/// legitimate override or rollback to an older cache). A failure to copy a
-/// binary is logged as a warning and does not abort the build (preserving the
-/// historical behavior).
-///
-/// `trust_snapshots` governs WHP warm-start snapshots, which are **not**
-/// covered by `checksums.json` (they are normally host-generated, not pinned
-/// release artifacts):
-/// - `true` (normal online build, snapshots produced locally): snapshots are
-/// mirrored — present ones are copied and target snapshots absent from the
-/// source are purged, so a stale warm-start image is never used against
-/// mismatched binaries.
-/// - `false` (source is an externally supplied `NANVIX_BIN` prefetch dir):
-/// snapshots are never copied and any stale target snapshot is removed, so
-/// the runtime falls back to a verified cold boot instead of warm-booting an
-/// unverified VM memory image.
-///
-/// Returns `Err` on any snapshot integrity failure (a snapshot could not be
-/// copied or a stale snapshot could not be removed) so the caller can decide
-/// how to react. Build scripts should treat such an error as fatal — leaving a
-/// mismatched/unverified snapshot next to the executable is unsafe.
-pub fn copy_artifacts_to_target(
- src_dir: &Path,
- target_dir: &Path,
- trust_snapshots: bool,
-) -> io::Result<()> {
- use std::fs;
-
- for (kind, rel) in artifact_rel_paths() {
- let src = src_dir.join(&rel);
- let dst = target_dir.join(&rel);
-
- // Snapshots from an untrusted (prefetched) source are never copied; we
- // must also guarantee no stale snapshot is left behind so the runtime
- // does not warm-boot an unverified image.
- if kind == ArtifactKind::Snapshot && !trust_snapshots {
- remove_stale_snapshot(&dst)?;
- continue;
- }
-
- if src.exists() {
- if let Some(parent) = dst.parent() {
- let _ = fs::create_dir_all(parent);
- }
- eprintln!("nanvix: copying {} -> {}", src.display(), dst.display());
- if let Err(e) = fs::copy(&src, &dst) {
- // Never leave a partial/stale file behind.
- let _ = fs::remove_file(&dst);
- if kind == ArtifactKind::Snapshot {
- return Err(io::Error::new(
- e.kind(),
- format!("nanvix: failed to copy {}: {}", rel.display(), e),
- ));
- }
- eprintln!("nanvix: WARNING: failed to copy {}: {}", rel.display(), e);
- }
- } else if kind == ArtifactKind::Snapshot {
- // Trusted source is missing this snapshot — purge any stale target
- // copy so an incomplete set forces a clean cold boot.
- remove_stale_snapshot(&dst)?;
- }
- }
- Ok(())
-}
-
-/// Remove a target snapshot file if present, returning an error if it cannot be
-/// removed. A leftover stale snapshot would be warm-booted by the runner
-/// (which trusts a complete exe-side snapshot set on presence alone) against
-/// mismatched binaries, so a failure here must be surfaced rather than ignored.
-fn remove_stale_snapshot(dst: &Path) -> io::Result<()> {
- if dst.exists() {
- eprintln!(
- "nanvix: removing stale {} (absent or untrusted in source)",
- dst.display()
- );
- std::fs::remove_file(dst).map_err(|e| {
- io::Error::new(
- e.kind(),
- format!(
- "nanvix: failed to remove stale snapshot {}: {} — refusing to \
- leave an unverified warm-start image that would be booted \
- against mismatched binaries",
- dst.display(),
- e
- ),
- )
- })?;
- }
- Ok(())
-}
-
-/// Emit `cargo:rerun-if-changed` for every artifact that
-/// [`copy_artifacts_to_target`] reads from `src_dir`. Call this from a
-/// consuming crate's build script so the copy reruns when the source contents
-/// change in place — for example when an offline `NANVIX_BIN` prefetch
-/// directory is updated at the same path. Without it, the consumer only reruns
-/// when the source *path* changes, leaving stale artifacts next to the exe.
-pub fn emit_rerun_for_copied_artifacts(src_dir: &Path) {
- for (_, rel) in artifact_rel_paths() {
- println!("cargo:rerun-if-changed={}", src_dir.join(rel).display());
- }
-}
-
-/// Stage NanVix artifacts from `nanvix_bin_dir` next to the executable being
-/// built and emit the appropriate `cargo:rerun-*` triggers.
-///
-/// Intended to be called from a consumer (`wxc` / `lxc`) build script. The
-/// target directory is derived from `OUT_DIR` (the binary lands in
-/// `target//`), and snapshot trust is read from the
-/// `DEP_NANVIX_BINARIES_PREFETCHED` link var the `nanvix_binaries` build script
-/// exports (defaulting to trusted when absent). Panics on a snapshot integrity
-/// failure — acceptable in the build path, where leaving an unverified
-/// warm-start image next to the executable must abort the build.
-pub fn stage_artifacts_next_to_exe(nanvix_bin_dir: &Path) {
- // Cargo puts the output binary in OUT_DIR/../../.. (target//).
- let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set");
- let target_dir = Path::new(&out_dir)
- .parent()
- .and_then(|p| p.parent())
- .and_then(|p| p.parent())
- .expect("could not determine target dir from OUT_DIR");
-
- // WHP snapshots from a prefetched (externally supplied) directory are not
- // covered by checksums.json, so they must not be trusted/copied. Default to
- // trusting (online build) when the flag is absent.
- let trust_snapshots = std::env::var("DEP_NANVIX_BINARIES_PREFETCHED")
- .map(|v| v != "1")
- .unwrap_or(true);
-
- copy_artifacts_to_target(nanvix_bin_dir, target_dir, trust_snapshots)
- .expect("nanvix: failed to stage artifacts next to the executable");
-
- // Re-run when the source path changes (detected via nanvix_binaries
- // rebuild) and when the source artifacts themselves change in place (e.g.
- // an offline NANVIX_BIN prefetch dir updated at the same path).
- emit_rerun_for_copied_artifacts(nanvix_bin_dir);
- println!("cargo:rerun-if-env-changed=DEP_NANVIX_BINARIES_BIN_DIR");
- println!("cargo:rerun-if-env-changed=DEP_NANVIX_BINARIES_PREFETCHED");
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use nanvix_common::{SNAPSHOT_CBOR, SNAPSHOT_VMEM};
- use std::fs;
- use std::time::{SystemTime, UNIX_EPOCH};
-
- /// Create a unique, empty scratch directory under the OS temp dir.
- fn scratch(tag: &str) -> PathBuf {
- let nanos = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .unwrap()
- .as_nanos();
- let dir = std::env::temp_dir().join(format!(
- "nanvix_copy_test_{}_{}_{}",
- tag,
- std::process::id(),
- nanos
- ));
- fs::create_dir_all(&dir).unwrap();
- dir
- }
-
- fn write_snapshot(root: &Path, name: &str, contents: &[u8]) {
- let dir = root.join(SNAPSHOTS_SUBDIR);
- fs::create_dir_all(&dir).unwrap();
- fs::write(dir.join(name), contents).unwrap();
- }
-
- fn snapshot_path(root: &Path, name: &str) -> PathBuf {
- root.join(SNAPSHOTS_SUBDIR).join(name)
- }
-
- #[test]
- fn trusted_source_lacking_snapshot_purges_stale_target() {
- let src = scratch("purge_src");
- let target = scratch("purge_tgt");
- // Target has both snapshots; source has none.
- write_snapshot(&target, SNAPSHOT_VMEM, b"stale-vmem");
- write_snapshot(&target, SNAPSHOT_CBOR, b"stale-cbor");
-
- copy_artifacts_to_target(&src, &target, /* trust_snapshots = */ true).unwrap();
-
- assert!(!snapshot_path(&target, SNAPSHOT_VMEM).exists());
- assert!(!snapshot_path(&target, SNAPSHOT_CBOR).exists());
-
- fs::remove_dir_all(&src).ok();
- fs::remove_dir_all(&target).ok();
- }
-
- #[test]
- fn trusted_source_with_snapshots_copies_into_target() {
- let src = scratch("copy_src");
- let target = scratch("copy_tgt");
- write_snapshot(&src, SNAPSHOT_VMEM, b"new-vmem");
- write_snapshot(&src, SNAPSHOT_CBOR, b"new-cbor");
-
- copy_artifacts_to_target(&src, &target, true).unwrap();
-
- assert_eq!(
- fs::read(snapshot_path(&target, SNAPSHOT_VMEM)).unwrap(),
- b"new-vmem"
- );
- assert_eq!(
- fs::read(snapshot_path(&target, SNAPSHOT_CBOR)).unwrap(),
- b"new-cbor"
- );
-
- fs::remove_dir_all(&src).ok();
- fs::remove_dir_all(&target).ok();
- }
-
- #[test]
- fn trusted_partial_source_copies_present_and_purges_absent() {
- let src = scratch("partial_src");
- let target = scratch("partial_tgt");
- // Source has only vmem; target starts with both.
- write_snapshot(&src, SNAPSHOT_VMEM, b"fresh-vmem");
- write_snapshot(&target, SNAPSHOT_VMEM, b"old-vmem");
- write_snapshot(&target, SNAPSHOT_CBOR, b"old-cbor");
-
- copy_artifacts_to_target(&src, &target, true).unwrap();
-
- // Present-in-source file is overwritten; absent-in-source file purged.
- assert_eq!(
- fs::read(snapshot_path(&target, SNAPSHOT_VMEM)).unwrap(),
- b"fresh-vmem"
- );
- assert!(!snapshot_path(&target, SNAPSHOT_CBOR).exists());
-
- fs::remove_dir_all(&src).ok();
- fs::remove_dir_all(&target).ok();
- }
-
- #[test]
- fn untrusted_source_never_copies_snapshots_and_purges_target() {
- let src = scratch("untrusted_src");
- let target = scratch("untrusted_tgt");
- // Source ships snapshots, but they are untrusted (prefetched).
- write_snapshot(&src, SNAPSHOT_VMEM, b"attacker-vmem");
- write_snapshot(&src, SNAPSHOT_CBOR, b"attacker-cbor");
- // Target has stale snapshots from a prior build.
- write_snapshot(&target, SNAPSHOT_VMEM, b"stale-vmem");
- write_snapshot(&target, SNAPSHOT_CBOR, b"stale-cbor");
-
- copy_artifacts_to_target(&src, &target, /* trust_snapshots = */ false).unwrap();
-
- // Untrusted snapshots are neither copied nor left behind.
- assert!(!snapshot_path(&target, SNAPSHOT_VMEM).exists());
- assert!(!snapshot_path(&target, SNAPSHOT_CBOR).exists());
-
- fs::remove_dir_all(&src).ok();
- fs::remove_dir_all(&target).ok();
- }
-}
diff --git a/src/backends/nanvix/common/src/lib.rs b/src/backends/nanvix/common/src/lib.rs
deleted file mode 100644
index 67c4ea2ce..000000000
--- a/src/backends/nanvix/common/src/lib.rs
+++ /dev/null
@@ -1,293 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-//! Shared constants and configuration types for NanVix micro-VM binaries.
-//!
-//! This crate is the single source of truth for binary filenames, release
-//! configuration, and checksum data. It is consumed as a `[build-dependency]`
-//! by `nanvix_binaries` (download) and `wxc` (copy to output dir).
-
-use std::collections::HashMap;
-use std::path::Path;
-
-use serde::Deserialize;
-
-/// All required NanVix binary filenames (flat, next to wxc-exec) — Windows.
-#[cfg(target_os = "windows")]
-pub const REQUIRED_BINARIES: &[&str] = &["nanvixd.exe", "nanvix_rootfs.img", "python3.initrd"];
-
-/// All required NanVix binary filenames (flat, next to lxc-exec) — Linux.
-#[cfg(target_os = "linux")]
-pub const REQUIRED_BINARIES: &[&str] = &["nanvixd.elf", "nanvix_rootfs.img", "python3.initrd"];
-
-/// NanVix daemon binary name (platform-conditional).
-#[cfg(target_os = "windows")]
-pub const NANVIXD_BINARY: &str = "nanvixd.exe";
-
-/// NanVix daemon binary name (platform-conditional).
-#[cfg(target_os = "linux")]
-pub const NANVIXD_BINARY: &str = "nanvixd.elf";
-
-/// Multi-binary initrd (daemons + CPython) loaded by NanVix at warm start.
-pub const INITRD_BINARY: &str = "python3.initrd";
-
-/// Combined rootfs image (NanVix kernel userspace + CPython stdlib).
-pub const RAMFS_IMAGE: &str = "nanvix_rootfs.img";
-
-/// Pre-built VM state snapshot (CBOR) for warm start (Windows/WHP only).
-pub const SNAPSHOT_CBOR: &str = "kernel.whp.cbor";
-
-/// Pre-built VM memory snapshot for warm start (Windows/WHP only).
-pub const SNAPSHOT_VMEM: &str = "kernel.vmem";
-
-/// Subdirectory holding kernel binary (nanvixd expects `./bin/kernel.elf`).
-pub const BIN_SUBDIR: &str = "bin";
-
-/// Subdirectory holding WHP snapshot files.
-pub const SNAPSHOTS_SUBDIR: &str = "snapshots";
-
-/// Files that live in a `bin/` subdirectory (nanvixd expects ./bin/kernel.elf).
-pub const BIN_SUBDIR_FILES: &[&str] = &["kernel.elf"];
-
-/// Snapshot files that live in a `snapshots/` subdirectory next to the exe.
-pub const SNAPSHOT_FILES: &[&str] = &[SNAPSHOT_VMEM, SNAPSHOT_CBOR];
-
-/// Binaries sourced from the `nanvix/nanvix-python` GitHub release.
-pub const NANVIX_PYTHON_REPO_BINARIES: &[&str] = REQUIRED_BINARIES;
-
-/// Number of bytes to retain from the end of nanvixd stderr when capturing
-/// it for diagnostics. Bounds host memory growth in the face of an
-/// untrusted/verbose child (availability / DoS hardening).
-pub const STDERR_TAIL_BYTES: usize = 8 * 1024;
-
-/// Render a bounded tail of nanvixd stderr bytes as a UTF-8 string,
-/// prefixed with `...(truncated)` when truncation occurred.
-///
-/// `bytes` may be the full stderr buffer (post-hoc trim) or a buffer that
-/// the caller already capped while streaming. The `truncated` flag tells
-/// us that streaming-time bytes were dropped even when the resulting
-/// buffer length is at or below [`STDERR_TAIL_BYTES`].
-pub fn format_stderr_tail(bytes: &[u8], truncated: bool) -> String {
- if bytes.len() > STDERR_TAIL_BYTES {
- // Trim at the byte level *before* UTF-8 decoding. Slicing into the
- // `String` produced by `from_utf8_lossy` would panic if the byte
- // offset fell inside a multi-byte codepoint; `from_utf8_lossy` on
- // a raw byte slice tolerates a partial leading codepoint by
- // emitting U+FFFD replacement characters instead.
- let start = bytes.len() - STDERR_TAIL_BYTES;
- let tail = String::from_utf8_lossy(&bytes[start..]);
- format!("...(truncated){}", tail)
- } else if truncated {
- let text = String::from_utf8_lossy(bytes);
- format!("...(truncated){}", text)
- } else {
- String::from_utf8_lossy(bytes).into_owned()
- }
-}
-
-/// Drain `reader` to completion, retaining only the last
-/// [`STDERR_TAIL_BYTES`] bytes. Returns `(tail, truncated)` where
-/// `truncated` is set when any earlier bytes were dropped.
-///
-/// Used to bound host memory growth when capturing nanvixd stderr from a
-/// potentially untrusted / verbose child (availability / DoS hardening).
-/// Read errors terminate the drain and return whatever was captured so
-/// far; this mirrors `read_to_end` failure semantics for our use case
-/// where stderr is best-effort diagnostic data.
-pub fn drain_stderr_tail(mut reader: R) -> (Vec, bool) {
- let mut tail: Vec = Vec::new();
- let mut chunk = [0u8; 8 * 1024];
- let mut truncated = false;
- loop {
- match reader.read(&mut chunk) {
- Ok(0) => break,
- Ok(n) => {
- tail.extend_from_slice(&chunk[..n]);
- if tail.len() > STDERR_TAIL_BYTES {
- let drop = tail.len() - STDERR_TAIL_BYTES;
- tail.drain(..drop);
- truncated = true;
- }
- }
- Err(_) => break,
- }
- }
- (tail, truncated)
-}
-
-/// Release configuration loaded from `versions.json`.
-#[derive(Debug, Deserialize)]
-pub struct ReleaseConfig {
- /// Configuration for the `nanvix/nanvix-python` GitHub repo.
- pub nanvix_python: RepoConfig,
-}
-
-/// Configuration for a single upstream GitHub repo release.
-#[derive(Debug, Deserialize)]
-pub struct RepoConfig {
- /// Git tag of the pinned release (e.g., "v0.12.291").
- pub tag: String,
- /// Exact filename of the zip asset in the GitHub release (Windows).
- pub asset: String,
- /// Exact filename of the tar.gz asset in the GitHub release (Linux).
- #[serde(default)]
- pub asset_linux: Option,
- /// List of binary filenames to extract from the zip (Windows).
- pub binaries: Vec,
- /// List of binary filenames to extract from the tar.gz (Linux).
- #[serde(default)]
- pub binaries_linux: Option>,
-}
-
-/// Load and deserialize a JSON file.
-pub fn load_json(path: &str) -> T {
- let content = std::fs::read_to_string(Path::new(path))
- .unwrap_or_else(|e| panic!("nanvix_common: failed to read {}: {}", path, e));
- serde_json::from_str(&content)
- .unwrap_or_else(|e| panic!("nanvix_common: failed to parse {}: {}", path, e))
-}
-
-/// Load checksums from `checksums.json`.
-///
-/// The file is a platform-keyed map of the form
-/// `{ "windows": { "name": "hash", ... }, "linux": { ... } }`; `platform`
-/// selects which sub-map to return.
-pub fn load_checksums(path: &str, platform: &str) -> HashMap {
- let mut value: HashMap> = load_json(path);
- value.remove(platform).unwrap_or_else(|| {
- panic!(
- "nanvix_common: {} does not contain a '{}' section",
- path, platform
- )
- })
-}
-
-/// Construct a deterministic GitHub release download URL.
-///
-/// Format: `https://github.com/{repo}/releases/download/{tag}/{asset}`
-pub fn github_download_url(repo: &str, tag: &str, asset: &str) -> String {
- format!(
- "https://github.com/{}/releases/download/{}/{}",
- repo, tag, asset
- )
-}
-
-/// Generate WHP snapshots by cold-booting nanvixd.
-///
-/// `snapshot_home` is used as the process working directory. nanvixd writes
-/// snapshot files to `/snapshots/`, so the resulting files end up at
-/// `/snapshots/kernel.vmem` and `kernel.whp.cbor`.
-///
-/// `bin_dir` is the directory containing `kernel.elf` (passed as `-bin-dir`).
-///
-/// Returns `Ok(())` on success. On failure, returns a human-readable error
-/// message suitable for both build scripts (which panic) and runtime callers
-/// (which wrap in their own error type).
-pub fn generate_snapshot(
- snapshot_home: &Path,
- nanvixd: &Path,
- bin_dir: &Path,
- ramfs: &Path,
- initrd: &Path,
-) -> Result<(), String> {
- use std::process::{Command, Stdio};
-
- let output = Command::new(nanvixd)
- .current_dir(snapshot_home)
- .arg("-bin-dir")
- .arg(bin_dir)
- .arg("-ramfs")
- .arg(ramfs)
- .arg("-kernel-args")
- .arg("snapshot")
- .arg("--")
- .arg(initrd)
- .stdin(Stdio::null())
- .stdout(Stdio::null())
- .stderr(Stdio::piped())
- .output()
- .map_err(|e| format!("failed to run nanvixd for snapshot generation: {}", e))?;
-
- if !output.status.success() {
- // Include a bounded tail of stderr so callers (build script panic
- // or runtime preflight error) can surface actionable diagnostics
- // without growing host memory unboundedly.
- let tail = format_stderr_tail(&output.stderr, false);
- let trimmed = tail.trim_end();
- if trimmed.is_empty() {
- return Err(format!(
- "snapshot generation failed (exit code: {})",
- output.status
- ));
- }
- return Err(format!(
- "snapshot generation failed (exit code: {})\nnanvixd stderr:\n{}",
- output.status, trimmed
- ));
- }
-
- let snap_dir = snapshot_home.join(SNAPSHOTS_SUBDIR);
- for name in SNAPSHOT_FILES {
- if !snap_dir.join(name).exists() {
- return Err(format!(
- "snapshot generation completed but '{}' not found in {:?}",
- name, snap_dir
- ));
- }
- }
-
- Ok(())
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn format_stderr_tail_short_buffer_no_prefix() {
- let out = format_stderr_tail(b"hello", false);
- assert_eq!(out, "hello");
- }
-
- #[test]
- fn format_stderr_tail_short_buffer_with_truncated_flag() {
- let out = format_stderr_tail(b"hello", true);
- assert_eq!(out, "...(truncated)hello");
- }
-
- #[test]
- fn format_stderr_tail_trims_oversized_ascii_buffer() {
- let big = vec![b'A'; STDERR_TAIL_BYTES + 16];
- let out = format_stderr_tail(&big, false);
- assert!(out.starts_with("...(truncated)"));
- // 14 chars for the prefix + STDERR_TAIL_BYTES trailing ASCII bytes.
- assert_eq!(out.len(), "...(truncated)".len() + STDERR_TAIL_BYTES);
- }
-
- /// Regression test for PR review comment r3283559877: an oversized
- /// buffer containing multi-byte UTF-8 must not panic when the
- /// truncation byte offset falls inside a codepoint.
- #[test]
- fn format_stderr_tail_oversized_multibyte_does_not_panic() {
- // 4-byte UTF-8 emoji repeated until well past the cap.
- let unit = "🦀"; // 4 bytes
- let repeats = (STDERR_TAIL_BYTES / unit.len()) + 64;
- let big = unit.repeat(repeats).into_bytes();
- assert!(big.len() > STDERR_TAIL_BYTES);
- let out = format_stderr_tail(&big, false);
- assert!(out.starts_with("...(truncated)"));
- // Ensure the result is still valid UTF-8 (String guarantees this);
- // partial leading codepoints become U+FFFD via `from_utf8_lossy`.
- assert!(out.len() >= "...(truncated)".len());
- }
-
- #[test]
- fn format_stderr_tail_oversized_with_truncated_flag_uses_byte_trim() {
- // When the buffer is oversized, the explicit `truncated` flag
- // should not change behavior — byte-level trim still applies.
- let big = vec![b'B'; STDERR_TAIL_BYTES + 1];
- let out = format_stderr_tail(&big, true);
- assert!(out.starts_with("...(truncated)"));
- assert_eq!(out.len(), "...(truncated)".len() + STDERR_TAIL_BYTES);
- }
-}
diff --git a/src/backends/nanvix/runner/Cargo.toml b/src/backends/nanvix/runner/Cargo.toml
deleted file mode 100644
index 081942e21..000000000
--- a/src/backends/nanvix/runner/Cargo.toml
+++ /dev/null
@@ -1,16 +0,0 @@
-[package]
-name = "nanvix_runner"
-version.workspace = true
-edition.workspace = true
-license.workspace = true
-
-[dependencies]
-wxc_common = { workspace = true, features = ["microvm"] }
-nanvix_common = { path = "../common" }
-uuid = { workspace = true }
-
-[target.'cfg(target_os = "windows")'.dependencies]
-windows = { workspace = true }
-
-[target.'cfg(target_os = "linux")'.dependencies]
-libc = { workspace = true }
diff --git a/src/backends/nanvix/runner/src/lib.rs b/src/backends/nanvix/runner/src/lib.rs
deleted file mode 100644
index 1491893bf..000000000
--- a/src/backends/nanvix/runner/src/lib.rs
+++ /dev/null
@@ -1,1845 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-//! `NanVixScriptRunner` -- executes code inside a NanVix micro-VM.
-//!
-//! The initial runtime is CPython 3.12 with a trimmed FAT32 stdlib filesystem.
-//! The architecture supports additional runtimes (QuickJS, C, C++, Rust binaries).
-//!
-//! ## I/O model
-//!
-//! - **stdin**: set to `Stdio::null()` (NanVix guest does not read host stdin)
-//! - **stdout**: inherited from parent via `Stdio::inherit()` (not captured)
-//! - **stderr**: inherited from parent by default (kernel traces stream
-//! straight to the parent terminal). When the `MXC_NANVIX_TRACE` env var
-//! is truthy, stderr is piped and captured so it can be embedded in the
-//! wxc-exec log on non-zero exit.
-//!
-//! **Note for SDK consumers:** Use `usePty: false` (non-PTY mode) for the MicroVM
-//! backend. PTY mode is not supported. Because stdout/stderr are inherited,
-//! `ScriptResponse.standard_out` and `standard_err` are always empty strings.
-//! Output is streamed directly to the parent's pipes.
-//!
-//! ## Diagnostics
-//!
-//! By default the runner sets `RUST_LOG=off` in nanvixd's environment, which
-//! suppresses the per-run `%LOCALAPPDATA%\nanvix\logs\nanvixd_*.log` trace
-//! file and noticeably reduces warm-start latency. Set `MXC_NANVIX_TRACE=1`
-//! (or `true`/`yes`, case-insensitive) before invoking wxc-exec to let
-//! nanvixd use its own `RUST_LOG` default and to capture nanvixd's stderr
-//! for inclusion in the wxc-exec log.
-//!
-//! ## Exit codes
-//!
-//! `nanvixd` propagates the guest process exit code directly.
-//!
-//! ## Networking
-//!
-//! Host networking is **off by default** and is enabled per-run by passing
-//! `-allow-host-networking` to `nanvixd`. The runner adds that flag when the
-//! request sets `network.defaultPolicy = "allow"`, or when `allowedHosts` /
-//! `blockedHosts` is present (forwarded as `-allow-host` / `-block-host`).
-//! Network proxies are not supported and are rejected at validation time.
-//!
-//! Auto-discovery
-//!
-//! All required binaries (`nanvixd.exe`, `python3.initrd`, `nanvix_rootfs.img`)
-//! are discovered next to the running executable. No configuration is needed.
-
-use std::fmt::Write;
-use std::net::ToSocketAddrs;
-use std::path::{Path, PathBuf};
-use std::process::{Child, Command, Stdio};
-use std::sync::atomic::{AtomicBool, Ordering};
-use std::sync::{Arc, Condvar, Mutex};
-use std::thread;
-use std::thread::JoinHandle;
-use std::time::{Duration, Instant};
-
-use wxc_common::logger::Logger;
-use wxc_common::models::{ExecutionRequest, NetworkAction, NetworkPolicy, ScriptResponse};
-use wxc_common::script_runner::ScriptRunner;
-use wxc_common::validator::{validate_network_policy_support, NetworkPolicySupport};
-
-/// Multi-binary initrd (daemons + CPython) loaded by NanVix at warm start.
-const INITRD_BINARY: &str = nanvix_common::INITRD_BINARY;
-/// NanVix daemon binary launched by the host runner (platform-conditional).
-const NANVIXD_BINARY: &str = nanvix_common::NANVIXD_BINARY;
-/// Combined rootfs image (NanVix kernel userspace + CPython stdlib).
-const RAMFS_IMAGE: &str = nanvix_common::RAMFS_IMAGE;
-/// Pre-built VM state snapshot (CBOR) for warm start (Windows/WHP only).
-#[cfg(target_os = "windows")]
-const SNAPSHOT_CBOR: &str = nanvix_common::SNAPSHOT_CBOR;
-/// Subdirectory holding snapshot files next to the exe (Windows/WHP only).
-#[cfg(target_os = "windows")]
-const SNAPSHOTS_DIR: &str = nanvix_common::SNAPSHOTS_SUBDIR;
-/// Subdirectory holding kernel binary.
-const BIN_DIR: &str = nanvix_common::BIN_SUBDIR;
-/// Env var override for the NanVix snapshot home directory. Set this to
-/// force a specific location; otherwise the runner uses a standard
-/// OS-local data path or falls back to `/snapshots/`.
-#[cfg(target_os = "windows")]
-const NANVIX_HOME_ENV: &str = "NANVIX_HOME";
-/// Env var that opts in to nanvixd's verbose tracing (and captured stderr).
-/// When unset (the default), the runner forces `RUST_LOG=off` for nanvixd
-/// and inherits stderr, which saves ~25–30 ms per warm execution by
-/// avoiding nanvixd's per-run log file and the host-side stderr drain.
-const NANVIX_TRACE_ENV: &str = "MXC_NANVIX_TRACE";
-/// Final component of the default OS-local data path (Windows only).
-#[cfg(target_os = "windows")]
-const DEFAULT_HOME_LEAF: &str = "nanvix";
-/// Boot grace period that is always enforced.
-const BOOT_TIMEOUT_MS: u64 = 60_000;
-/// Generic error exit code returned to host callers.
-const ERROR_EXIT_CODE: i32 = -1;
-/// Maximum age of orphaned staging dirs before cleanup (1 hour).
-const ORPHAN_SWEEP_MAX_AGE_SECS: u64 = 3600;
-const ERR_DENIED_PATHS: &str = concat!(
- "denied_paths is not meaningful for the microvm backend ",
- "-- the guest has no host filesystem visibility. ",
- "Only readwrite_paths and readonly_paths are supported",
-);
-const ERR_NETWORK_HOSTS: &str = concat!(
- "allowedHosts and blockedHosts are mutually exclusive for the NanVix backend -- ",
- "the guest egress filter is allow-XOR-block. Specify an allowlist (allowedHosts) ",
- "or a blocklist (blockedHosts), not both",
-);
-const ERR_HOSTS_UNRESOLVED: &str = concat!(
- "none of the specified allowedHosts/blockedHosts resolved to an IPv4 address -- ",
- "the NanVix guest filter is IPv4-only; use IPv4 literals/CIDR or hosts with A records",
-);
-const ERR_BLOCKED_HOST_UNRESOLVED: &str = concat!(
- "a blockedHosts entry did not resolve to any IPv4 address -- ",
- "the NanVix guest egress filter is static (resolved once at preflight) and IPv4-only, ",
- "so an unresolvable blocked host cannot be enforced. Silently dropping it would let ",
- "traffic the policy explicitly blocks flow freely, so the run is rejected (fail-closed). ",
- "Use an IPv4 literal/CIDR or a host with A records",
-);
-const ERR_PROXY_POLICY: &str = "network proxy is not supported by the NanVix backend";
-const ERR_DIRECTIONAL_NETWORK: &str = "NanVix supports only fully isolated or explicitly \
- unrestricted directional networking: egress.default, ingress.default and ingress.hostLoopback \
- must all be deny or all be allow; independent ingress or host-loopback restrictions are not supported";
-const ERR_DIRECTIONAL_FILTERS: &str = "NanVix cannot enforce directional egress rules: its legacy \
- IPv4 filter has implicit exceptions and does not implement the directional rule contract; \
- use fully isolated networking or explicitly unrestricted networking without rules";
-const ERR_WORKDIR: &str = "workingDirectory is not supported by the NanVix backend -- guest has its own filesystem namespace";
-
-/// Outcome of resolving a request's egress host lists.
-///
-/// `allow`/`block` are the IPv4/CIDR literals handed to nanvixd; at most one is
-/// non-empty. `warnings` carries human-readable notices for allowlist entries
-/// that were dropped during resolution (blocklist drops are a hard error and
-/// never reach here).
-#[derive(Debug)]
-struct ResolvedHostLists {
- allow: Vec,
- block: Vec,
- warnings: Vec,
-}
-
-/// Maps a finished child's [`ExitStatus`] to a host-visible exit code.
-///
-/// On Unix, processes terminated by a signal have no exit code (`status.code()`
-/// returns `None`); we surface them as the negated signal number (e.g. SIGKILL
-/// → `-9`) so callers can distinguish them from normal exits and from the
-/// generic [`ERROR_EXIT_CODE`] sentinel. On Windows, `status.code()` always
-/// returns `Some(_)`.
-fn exit_code_from_status(status: &std::process::ExitStatus) -> i32 {
- if let Some(code) = status.code() {
- return code;
- }
- #[cfg(unix)]
- {
- use std::os::unix::process::ExitStatusExt;
- if let Some(signal) = status.signal() {
- return -signal;
- }
- }
- ERROR_EXIT_CODE
-}
-
-// -- NanVix error classification ---------------------------------------------
-
-/// Classifies NanVix runner errors for structured error handling.
-#[derive(Debug)]
-enum NanVixError {
- /// Pre-spawn validation failures (missing artifacts, invalid config, unsupported policy).
- Preflight(String),
- /// OS/platform failures while spawning/managing the NanVix process (WHP/spawn/handles).
- Platform(String),
- /// Stdin broken pipe, VM crash.
- Runtime(String),
- /// Watchdog killed the process.
- Timeout {
- script_timeout_ms: u32,
- total_ms: u64,
- },
-}
-
-impl std::fmt::Display for NanVixError {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- match self {
- NanVixError::Preflight(msg) => write!(f, "NanVix preflight error: {}", msg),
- NanVixError::Platform(msg) => write!(f, "NanVix platform error: {}", msg),
- NanVixError::Runtime(msg) => write!(f, "NanVix runtime error: {}", msg),
- NanVixError::Timeout {
- script_timeout_ms,
- total_ms,
- } => write!(
- f,
- "NanVix execution timed out after {}ms \
- (boot_timeout={}ms, script_timeout={}ms)",
- total_ms, BOOT_TIMEOUT_MS, script_timeout_ms
- ),
- }
- }
-}
-
-impl NanVixError {
- fn to_response(&self) -> ScriptResponse {
- ScriptResponse {
- exit_code: ERROR_EXIT_CODE,
- error_message: self.to_string(),
- ..Default::default()
- }
- }
-}
-
-/// Returns the directory containing the current executable.
-///
-/// Inlined (rather than reusing `wxc_common::process_util::exe_dir`) because
-/// `process_util` is gated to `target_os = "windows"`.
-fn exe_dir() -> Result {
- std::env::current_exe()
- .map_err(|e| NanVixError::Preflight(format!("cannot determine exe path: {}", e)))
- .and_then(|exe| {
- exe.parent()
- .map(|p| p.to_path_buf())
- .ok_or_else(|| NanVixError::Preflight("exe has no parent directory".to_string()))
- })
-}
-
-/// Returns `true` when [`NANVIX_TRACE_ENV`] is set to a truthy value
-/// (`"1"`, `"true"`, or `"yes"`, case-insensitive). Any other value
-/// (including unset, empty, or `"0"`/`"false"`/`"no"`) means trace is off.
-fn nanvix_trace_enabled() -> bool {
- match std::env::var(NANVIX_TRACE_ENV) {
- Ok(v) => matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"),
- Err(_) => false,
- }
-}
-
-/// Watchdog thread: waits for timeout or cancellation, then terminates the process.
-///
-/// On Windows, `process_id_or_handle` is a duplicated process HANDLE (as usize).
-/// On Linux, `process_id_or_handle` is the child PID (as usize).
-fn watchdog_thread_fn(
- process_id_or_handle: usize,
- duration: Duration,
- cancel_pair: Arc<(Mutex, Condvar)>,
- timed_out: Arc,
-) {
- let (lock, cvar) = &*cancel_pair;
- let mut cancelled = lock.lock().unwrap_or_else(|e| e.into_inner());
- let start = Instant::now();
- let mut remaining = duration;
- loop {
- let result = cvar
- .wait_timeout(cancelled, remaining)
- .unwrap_or_else(|e| e.into_inner());
- cancelled = result.0;
- if *cancelled || result.1.timed_out() {
- break;
- }
- let elapsed = start.elapsed();
- if elapsed >= duration {
- break;
- }
- remaining = duration.saturating_sub(elapsed);
- }
-
- #[cfg(target_os = "windows")]
- {
- // Always close the duplicated handle to avoid leaks.
- let close_handle = |handle_raw: usize| {
- use windows::Win32::Foundation::{CloseHandle, HANDLE};
- let handle = HANDLE(handle_raw as *mut std::ffi::c_void);
- // SAFETY: `handle` was returned by `DuplicateHandle` in this
- // process and is closed exactly once by this watchdog thread.
- let _ = unsafe { CloseHandle(handle) };
- };
-
- if *cancelled {
- close_handle(process_id_or_handle);
- return;
- }
-
- timed_out.store(true, Ordering::SeqCst);
-
- use windows::Win32::Foundation::HANDLE;
- use windows::Win32::System::Threading::TerminateProcess;
-
- let handle = HANDLE(process_id_or_handle as *mut std::ffi::c_void);
- // SAFETY: `handle` is a valid duplicated process handle owned by
- // this thread, and passing exit code 1 is valid for termination.
- let _ = unsafe { TerminateProcess(handle, 1) };
- close_handle(process_id_or_handle);
- }
-
- #[cfg(target_os = "linux")]
- {
- if *cancelled {
- return;
- }
-
- timed_out.store(true, Ordering::SeqCst);
-
- // Kill the child process by PID using SIGKILL.
- let pid = process_id_or_handle as i32;
- // SAFETY: sending SIGKILL to a known child PID is always valid.
- // If the process already exited, `kill()` returns ESRCH which we ignore.
- unsafe {
- libc::kill(pid, libc::SIGKILL);
- }
- }
-}
-
-/// Components returned by [`NanVixScriptRunner::setup_watchdog`]: the watchdog
-/// thread handle (if a finite timeout was requested), the cancellation pair
-/// used to signal early completion, and the `timed_out` flag.
-type WatchdogState = (
- Option>,
- Arc<(Mutex, Condvar)>,
- Arc,
-);
-
-// -- NanVixScriptRunner ------------------------------------------------------
-
-/// Script runner that executes Python code inside a NanVix microkernel VM.
-///
-/// All binaries are auto-discovered next to the running executable.
-pub struct NanVixScriptRunner {
- _private: (),
-}
-
-impl Default for NanVixScriptRunner {
- fn default() -> Self {
- Self::new()
- }
-}
-
-/// Resolved paths for NanVix invocation.
-#[derive(Debug)]
-struct ResolvedPaths {
- nanvixd: PathBuf,
- ramfs: PathBuf,
- initrd: PathBuf,
- /// Directory holding the `bin/` subdir next to the exe.
- exe_dir: PathBuf,
- /// Snapshot home directory — used as cwd for nanvixd so it can locate
- /// `snapshots/kernel.vmem` relative to cwd (nanvixd constraint).
- snapshot_home: PathBuf,
-}
-
-impl NanVixScriptRunner {
- pub fn new() -> Self {
- Self { _private: () }
- }
-
- /// Resolve and validate all required paths next to the running executable.
- fn resolve_paths(&self) -> Result {
- let dir = exe_dir()?;
-
- let nanvixd = dir.join(NANVIXD_BINARY);
- if !nanvixd.exists() {
- return Err(NanVixError::Preflight(format!(
- "{} not found in {:?}",
- NANVIXD_BINARY, dir
- )));
- }
-
- let ramfs = dir.join(RAMFS_IMAGE);
- if !ramfs.exists() {
- return Err(NanVixError::Preflight(format!(
- "{} not found in {:?}",
- RAMFS_IMAGE, dir
- )));
- }
-
- let initrd = dir.join(INITRD_BINARY);
- if !initrd.exists() {
- return Err(NanVixError::Preflight(format!(
- "{} not found in {:?}",
- INITRD_BINARY, dir
- )));
- }
-
- // Preflight-check bin/ subdir contents (nanvixd loads `./bin/kernel.elf`
- // via `-bin-dir`; missing the file here yields a clearer error than
- // letting nanvixd fail at boot time).
- let bin_subdir = dir.join(BIN_DIR);
- for name in nanvix_common::BIN_SUBDIR_FILES {
- let path = bin_subdir.join(name);
- if !path.exists() {
- return Err(NanVixError::Preflight(format!(
- "{}/{} not found in {:?}",
- BIN_DIR, name, dir
- )));
- }
- }
-
- // Snapshot resolution — Windows only (WHP snapshots for warm start).
- // Linux uses cold boot via KVM every time.
- #[cfg(target_os = "windows")]
- let snapshot_home = {
- let home = Self::resolve_snapshot_home(&dir)?;
- // Warm start requires *all* snapshot files (kernel.vmem + kernel.whp.cbor);
- // a partial/corrupt set must trigger regeneration instead of a late failure
- // inside nanvixd.
- let snapshots_present = nanvix_common::SNAPSHOT_FILES
- .iter()
- .all(|name| home.join(SNAPSHOTS_DIR).join(name).exists());
- if !snapshots_present {
- // No (complete) snapshot yet — generate one via cold boot
- // (one-time cost, ~400–500 ms). Subsequent runs restore directly.
- Self::generate_snapshot(&dir, &home, &nanvixd, &ramfs, &initrd)?;
- }
- home
- };
-
- #[cfg(target_os = "linux")]
- let snapshot_home = dir.clone();
-
- Ok(ResolvedPaths {
- nanvixd,
- ramfs,
- initrd,
- exe_dir: dir,
- snapshot_home,
- })
- }
-
- /// Resolve the snapshot home directory (Windows only — WHP snapshots).
- ///
- /// Discovery chain (first match wins):
- /// 1. `$NANVIX_HOME` env var (if set and non-empty)
- /// 2. `` directory itself, when a complete set of pre-generated
- /// snapshots already lives in `/snapshots/` (build-time output
- /// or shipped artifacts) — using it avoids a redundant cold boot.
- /// 3. OS-local data path (`%LOCALAPPDATA%\nanvix` on Windows)
- /// 4. `` directory itself as a last-resort fallback (dev builds —
- /// nanvixd will write snapshots into `/snapshots/`).
- #[cfg(target_os = "windows")]
- fn resolve_snapshot_home(exe_dir: &Path) -> Result {
- // 1. Env var override.
- if let Some(val) = std::env::var_os(NANVIX_HOME_ENV) {
- let p = PathBuf::from(val);
- if !p.as_os_str().is_empty() {
- std::fs::create_dir_all(&p).map_err(|e| {
- NanVixError::Preflight(format!(
- "cannot create ${} directory {:?}: {}",
- NANVIX_HOME_ENV, p, e
- ))
- })?;
- return Ok(p);
- }
- }
-
- // 2. Prefer exe-side snapshots when they're already complete, so
- // build-time-generated artifacts shipped next to wxc-exec are
- // actually used instead of triggering a fresh cold boot in
- // %LOCALAPPDATA%.
- let exe_snapshots = exe_dir.join(SNAPSHOTS_DIR);
- let exe_snapshots_complete = nanvix_common::SNAPSHOT_FILES
- .iter()
- .all(|name| exe_snapshots.join(name).exists());
- if exe_snapshots_complete {
- return Ok(exe_dir.to_path_buf());
- }
-
- // 3. OS-local data path.
- if let Some(home) = Self::default_home() {
- if home.exists() || std::fs::create_dir_all(&home).is_ok() {
- return Ok(home);
- }
- }
-
- // 4. Fallback: exe directory itself (nanvixd writes to /snapshots/).
- Ok(exe_dir.to_path_buf())
- }
-
- /// Default OS-local snapshot home path.
- #[cfg(target_os = "windows")]
- fn default_home() -> Option {
- std::env::var_os("LOCALAPPDATA").map(|d| PathBuf::from(d).join(DEFAULT_HOME_LEAF))
- }
-
- /// Generate a WHP snapshot via cold boot (one-time cost, Windows only).
- ///
- /// Delegates to `nanvix_common::generate_snapshot` which runs nanvixd with
- /// `-kernel-args snapshot` and cwd set to `snapshot_home`. nanvixd writes
- /// snapshot files to `/snapshots/` directly. Subsequent runs
- /// restore from the snapshot (~20 ms vs ~430 ms cold boot).
- #[cfg(target_os = "windows")]
- fn generate_snapshot(
- exe_dir: &Path,
- snapshot_home: &Path,
- nanvixd: &Path,
- ramfs: &Path,
- initrd: &Path,
- ) -> Result<(), NanVixError> {
- std::fs::create_dir_all(snapshot_home).map_err(|e| {
- NanVixError::Preflight(format!("failed to create snapshot home: {}", e))
- })?;
-
- eprintln!("nanvix: no snapshot found — generating via cold boot (one-time cost)...");
-
- let start = Instant::now();
- nanvix_common::generate_snapshot(
- snapshot_home,
- nanvixd,
- &exe_dir.join(BIN_DIR),
- ramfs,
- initrd,
- )
- .map_err(NanVixError::Preflight)?;
-
- eprintln!(
- "nanvix: snapshot generated in {:.0?} — subsequent runs will use warm start",
- start.elapsed()
- );
- Ok(())
- }
-
- /// Compute total timeout: boot grace + staging overhead + script timeout.
- /// When `script_timeout == 0` the caller intends "no limit", so the watchdog
- /// is disabled entirely (returns `u64::MAX`). Boot and staging time are
- /// unbounded in this case — this is by design for interactive/exploratory use.
- fn total_timeout_ms(script_timeout: u32, staging_overhead_ms: u64) -> u64 {
- if script_timeout == 0 {
- u64::MAX
- } else {
- BOOT_TIMEOUT_MS
- .saturating_add(staging_overhead_ms)
- .saturating_add(script_timeout as u64)
- }
- }
-
- /// Compatibility lowering for legacy requests without directional policy.
- ///
- /// Host networking is enabled when `network.defaultPolicy = "allow"` OR when
- /// a per-host allow/block list is present (a list always implies networking,
- /// regardless of `defaultPolicy`). When enabled, the runner passes
- /// `-allow-host-networking` to nanvixd; per-host lists are additionally
- /// forwarded as `-allow-host`/`-block-host` (see [`Self::spawn_nanvixd`]).
- fn legacy_host_networking_enabled(request: &ExecutionRequest) -> bool {
- request.policy.default_network_policy == NetworkPolicy::Allow
- || !request.policy.allowed_hosts.is_empty()
- || !request.policy.blocked_hosts.is_empty()
- }
-
- fn resolve_networking_mode(request: &ExecutionRequest) -> Result {
- let policy = &request.policy;
- if policy.network_egress.is_none() && policy.network_ingress.is_none() {
- return Ok(Self::legacy_host_networking_enabled(request));
- }
- if !policy.allowed_hosts.is_empty()
- || !policy.blocked_hosts.is_empty()
- || policy
- .network_egress
- .as_ref()
- .is_some_and(|egress| !egress.allow.is_empty() || !egress.deny.is_empty())
- {
- return Err(NanVixError::Preflight(ERR_DIRECTIONAL_FILTERS.to_string()));
- }
- let egress = policy
- .network_egress
- .as_ref()
- .map(|egress| egress.default)
- .unwrap_or(NetworkAction::Deny);
- let ingress = policy
- .network_ingress
- .as_ref()
- .map(|ingress| ingress.default)
- .unwrap_or(NetworkAction::Deny);
- let host_loopback = policy
- .network_ingress
- .as_ref()
- .map(|ingress| ingress.host_loopback)
- .unwrap_or(NetworkAction::Deny);
- if egress != ingress || ingress != host_loopback {
- return Err(NanVixError::Preflight(ERR_DIRECTIONAL_NETWORK.to_string()));
- }
- Ok(egress == NetworkAction::Allow)
- }
-
- /// Resolves a host entry list into IPv4/CIDR literals for nanvixd's
- /// `-allow-host`/`-block-host` flags, alongside the entries that resolved
- /// to nothing.
- ///
- /// - `a.b.c.d` and `a.b.c.d/n` literals pass through unchanged.
- /// - Hostnames resolve to their IPv4 (A-record) addresses; AAAA results are
- /// dropped because the guest filter is IPv4-only.
- /// - Entries that fail to parse or resolve to any IPv4 address contribute
- /// nothing to the resolved list and are collected into the second return
- /// value so callers can warn (allowlist) or reject (blocklist).
- ///
- /// Mirrors `lxc::network_iptables::resolve_host` for the hostname-to-IPv4
- /// mapping; unlike that helper this also preserves IPv4/CIDR literals.
- ///
- /// Returns `(resolved_ips, unresolved_entries)`. Empty/whitespace entries
- /// are ignored entirely and appear in neither list.
- fn resolve_hosts_detailed(hosts: &[String]) -> (Vec, Vec) {
- let mut out: Vec = Vec::new();
- let mut unresolved: Vec = Vec::new();
- for host in hosts {
- let entry = host.trim();
- if entry.is_empty() {
- continue;
- }
- let before = out.len();
- // CIDR literal: pass through only when the address is IPv4 and the
- // prefix is in range. nanvixd parses CIDR directly.
- if let Some((addr, prefix)) = entry.split_once('/') {
- let addr_ok = addr.trim().parse::().is_ok();
- let prefix_ok = prefix
- .trim()
- .parse::()
- .map(|p| p <= 32)
- .unwrap_or(false);
- if addr_ok && prefix_ok {
- out.push(entry.to_string());
- }
- } else if let Ok(addr) = entry.parse::() {
- // Bare IP literal: keep IPv4, drop IPv6.
- if addr.is_ipv4() {
- out.push(entry.to_string());
- }
- } else if let Ok(addrs) = format!("{}:0", entry).to_socket_addrs() {
- // Hostname: resolve to IPv4 A records.
- for ip in addrs.map(|a| a.ip()).filter(|ip| ip.is_ipv4()) {
- out.push(ip.to_string());
- }
- }
- if out.len() == before {
- unresolved.push(entry.to_string());
- }
- }
- (out, unresolved)
- }
-
- /// Resolves the request's allow/block host lists, failing closed.
- ///
- /// Returns the resolved allow/block IPv4 lists plus human-readable
- /// warnings for any allowlist entries that were dropped. At most one list
- /// is non-empty (the mutual-exclusion check in [`Self::validate_policies`]
- /// runs first).
- ///
- /// Fail-closed semantics differ by list direction:
- /// - **Allowlist** (deny-by-default): a fully unresolvable allowlist is an
- /// error, because emitting `-allow-host-networking` with no filter would
- /// fail open (nanvixd treats no list as allow-all). Partially dropped
- /// entries only narrow access, so they are reported as warnings and the
- /// run continues.
- /// - **Blocklist** (allow-by-default): *any* unresolvable entry is an
- /// error. Silently dropping a blocked host would let traffic the policy
- /// explicitly blocks flow freely (fail-open), and the static preflight
- /// filter cannot enforce a name that does not resolve.
- fn resolve_host_lists(request: &ExecutionRequest) -> Result {
- let (allow, allow_unresolved) = Self::resolve_hosts_detailed(&request.policy.allowed_hosts);
- if !request.policy.allowed_hosts.is_empty() && allow.is_empty() {
- return Err(NanVixError::Preflight(ERR_HOSTS_UNRESOLVED.to_string()));
- }
-
- let (block, block_unresolved) = Self::resolve_hosts_detailed(&request.policy.blocked_hosts);
- if let Some(first) = block_unresolved.first() {
- return Err(NanVixError::Preflight(format!(
- "{} (entry: '{}')",
- ERR_BLOCKED_HOST_UNRESOLVED, first
- )));
- }
-
- let warnings = allow_unresolved
- .iter()
- .map(|h| {
- format!(
- "Warning: could not resolve allowedHosts entry '{}' to an IPv4 address; skipping",
- h
- )
- })
- .collect();
-
- Ok(ResolvedHostLists {
- allow,
- block,
- warnings,
- })
- }
-
- fn validate_policies(request: &ExecutionRequest) -> Result<(), NanVixError> {
- // denied_paths is explicitly rejected — microvm has no host visibility.
- if !request.policy.denied_paths.is_empty() {
- return Err(NanVixError::Preflight(ERR_DENIED_PATHS.to_string()));
- }
- // Per-host filtering is supported (forwarded to nanvixd as
- // -allow-host/-block-host). The guest egress filter is allow-XOR-block,
- // so the two lists are mutually exclusive; defaultPolicy is ignored when
- // either list is present.
- if !request.policy.allowed_hosts.is_empty() && !request.policy.blocked_hosts.is_empty() {
- return Err(NanVixError::Preflight(ERR_NETWORK_HOSTS.to_string()));
- }
- if request.policy.network_proxy.is_enabled() {
- return Err(NanVixError::Preflight(ERR_PROXY_POLICY.to_string()));
- }
- if !request.working_directory.is_empty() {
- return Err(NanVixError::Preflight(ERR_WORKDIR.to_string()));
- }
- Self::resolve_networking_mode(request)?;
-
- Ok(())
- }
-
- fn nanvixd_command(
- paths: &ResolvedPaths,
- staging_dir: &Path,
- request: &ExecutionRequest,
- allow_hosts: &[String],
- block_hosts: &[String],
- ) -> Result {
- let host_networking = Self::resolve_networking_mode(request)?;
- let trace = nanvix_trace_enabled();
- // Default: silence nanvixd and inherit stderr so kernel traces (if
- // any) stream straight to the parent terminal without a per-run
- // host-side drain. Diagnostic mode pipes stderr so the runner can
- // attach it to the wxc-exec log on failure.
- let stderr = if trace {
- Stdio::piped()
- } else {
- Stdio::inherit()
- };
-
- let mut cmd = Command::new(&paths.nanvixd);
-
- // Host networking is opt-in. When enabled, attach the host network
- // backend; nanvixd parses this flag regardless of argument order, so
- // it is added up front for both the Windows (snapshot) and Linux
- // (cold-boot) invocations below. Verified to work on warm-start
- // snapshot restore as well as cold boot.
- if host_networking {
- cmd.arg("-allow-host-networking");
- }
-
- // Per-host egress filtering. The two lists are mutually exclusive
- // (validated upstream), so at most one of these loops emits flags.
- // nanvixd requires `-allow-host-networking` for these to take effect,
- // which is guaranteed because a non-empty list forces host_networking
- // on (see `legacy_host_networking_enabled`). The guest daemon auto-exempts the
- // DNS port in allowlist mode, so no resolver IPs are added here.
- for host in allow_hosts {
- cmd.arg("-allow-host").arg(host);
- }
- for host in block_hosts {
- cmd.arg("-block-host").arg(host);
- }
-
- #[cfg(target_os = "windows")]
- {
- // nanvixd loads kernel.vmem from /snapshots/ so cwd must be
- // the snapshot home. All other paths are passed as absolute.
- // nanvixd.exe [-allow-host-networking] -snapshot snapshots/kernel.whp.cbor
- // -bin-dir /bin -ramfs -mount -- python3.initrd
- let snapshot_rel = Path::new(SNAPSHOTS_DIR).join(SNAPSHOT_CBOR);
- cmd.current_dir(&paths.snapshot_home)
- .arg("-snapshot")
- .arg(&snapshot_rel)
- .arg("-bin-dir")
- .arg(paths.exe_dir.join(BIN_DIR))
- .arg("-ramfs")
- .arg(&paths.ramfs)
- .arg("-mount")
- .arg(staging_dir)
- .arg("--")
- .arg(&paths.initrd);
- }
-
- #[cfg(target_os = "linux")]
- {
- // Linux invocation (cold boot via KVM):
- // nanvixd.elf [-allow-host-networking] -ramfs -mount -- python3.initrd
- cmd.current_dir(&paths.exe_dir)
- .arg("-ramfs")
- .arg(&paths.ramfs)
- .arg("-mount")
- .arg(staging_dir)
- .arg("--")
- .arg(&paths.initrd);
- }
-
- cmd.stdin(Stdio::null())
- .stdout(Stdio::inherit())
- .stderr(stderr);
- if !trace {
- // Suppress nanvixd's env_logger output and per-run log file.
- cmd.env("RUST_LOG", "off");
- }
- Ok(cmd)
- }
-
- fn spawn_nanvixd(
- paths: &ResolvedPaths,
- staging_dir: &Path,
- request: &ExecutionRequest,
- allow_hosts: &[String],
- block_hosts: &[String],
- ) -> Result {
- Self::nanvixd_command(paths, staging_dir, request, allow_hosts, block_hosts)?
- .spawn()
- .map_err(|e| {
- NanVixError::Platform(format!("failed to spawn {}: {}", NANVIXD_BINARY, e))
- })
- }
-
- fn start_watchdog(
- child: &std::process::Child,
- timeout_ms: u64,
- cancel_pair: Arc<(Mutex, Condvar)>,
- timed_out: Arc,
- ) -> Option> {
- if timeout_ms == u64::MAX {
- return None;
- }
-
- let duration = Duration::from_millis(timeout_ms);
-
- #[cfg(target_os = "windows")]
- {
- // Duplicate the process handle at spawn time (safe against PID reuse).
- use std::os::windows::io::AsRawHandle;
- use windows::Win32::Foundation::{DuplicateHandle, DUPLICATE_SAME_ACCESS, HANDLE};
- use windows::Win32::System::Threading::GetCurrentProcess;
-
- let raw = child.as_raw_handle();
- let mut dup_handle = HANDLE::default();
- let dup_ok = unsafe {
- // SAFETY: `raw` is the live process HANDLE from `std::process::Child`.
- // We duplicate it into the current process with same access rights so
- // the watchdog thread can safely terminate/close it independently.
- DuplicateHandle(
- GetCurrentProcess(),
- HANDLE(raw),
- GetCurrentProcess(),
- &mut dup_handle,
- 0,
- false,
- DUPLICATE_SAME_ACCESS,
- )
- };
- if dup_ok.is_err() {
- return None;
- }
- let process_id_or_handle = dup_handle.0 as usize;
-
- Some(thread::spawn(move || {
- watchdog_thread_fn(process_id_or_handle, duration, cancel_pair, timed_out);
- }))
- }
-
- #[cfg(target_os = "linux")]
- {
- // On Linux, use the child PID directly for kill-based termination.
- let pid = child.id() as usize;
-
- Some(thread::spawn(move || {
- watchdog_thread_fn(pid, duration, cancel_pair, timed_out);
- }))
- }
- }
-
- fn setup_watchdog(
- child: &mut std::process::Child,
- timeout_ms: u64,
- logger: &mut Logger,
- ) -> Result {
- let timed_out = Arc::new(AtomicBool::new(false));
- let cancel_pair = Arc::new((Mutex::new(false), Condvar::new()));
-
- let watchdog = if timeout_ms < u64::MAX {
- match Self::start_watchdog(
- child,
- timeout_ms,
- Arc::clone(&cancel_pair),
- Arc::clone(&timed_out),
- ) {
- Some(handle) => Some(handle),
- None => {
- let err = NanVixError::Platform(format!(
- "failed to duplicate {} process handle",
- NANVIXD_BINARY
- ));
- let _ = writeln!(logger, "{}", err);
- if let Err(e) = child.kill() {
- let _ = writeln!(
- logger,
- "NanVix: failed to kill child after handle dup failure: {}",
- e
- );
- }
- if let Err(e) = child.wait() {
- let _ = writeln!(
- logger,
- "NanVix: failed to wait for child after handle dup failure: {}",
- e
- );
- }
- return Err(err.to_response());
- }
- }
- } else {
- None
- };
-
- Ok((watchdog, cancel_pair, timed_out))
- }
-
- fn log_resolved_paths(logger: &mut Logger, paths: &ResolvedPaths) {
- let _ = writeln!(logger, "NanVix: nanvixd={:?}", paths.nanvixd);
- let _ = writeln!(logger, "NanVix: ramfs={:?}", paths.ramfs);
- let _ = writeln!(logger, "NanVix: initrd={:?}", paths.initrd);
- let _ = writeln!(logger, "NanVix: snapshot_home={:?}", paths.snapshot_home);
- }
-
- fn wait_and_respond(
- child: &mut Child,
- watchdog: Option>,
- cancel_pair: &Arc<(Mutex, Condvar)>,
- timed_out: &AtomicBool,
- timeout_ms: u64,
- script_timeout: u32,
- logger: &mut Logger,
- ) -> ScriptResponse {
- // Drain stderr concurrently with `wait()` so a verbose child cannot
- // block on a full pipe buffer. We retain only the last
- // [`nanvix_common::STDERR_TAIL_BYTES`] bytes so an untrusted guest
- // emitting unbounded stderr cannot cause host memory growth
- // (availability / DoS hardening). In the default (non-trace) mode
- // stderr is inherited and `child.stderr` is `None`, so the join
- // returns the empty string immediately.
- let stderr_handle = child
- .stderr
- .take()
- .map(|s| thread::spawn(move || nanvix_common::drain_stderr_tail(s)));
-
- let exit_status = child.wait();
-
- let stderr_output = stderr_handle
- .and_then(|h| h.join().ok())
- .map(|(bytes, truncated)| nanvix_common::format_stderr_tail(&bytes, truncated))
- .unwrap_or_default();
-
- {
- let (lock, cvar) = &**cancel_pair;
- let mut cancelled = lock.lock().unwrap_or_else(|e| e.into_inner());
- *cancelled = true;
- cvar.notify_one();
- }
-
- if let Some(handle) = watchdog {
- let _ = handle.join();
- }
-
- if timed_out.load(Ordering::SeqCst) {
- let _ = child.kill();
- let err = NanVixError::Timeout {
- script_timeout_ms: script_timeout,
- total_ms: timeout_ms,
- };
- let _ = writeln!(logger, "{}", err);
- return err.to_response();
- }
-
- match exit_status {
- Ok(status) => {
- let exit_code = exit_code_from_status(&status);
- let _ = writeln!(logger, "NanVix: process exited with code {}", exit_code);
- if exit_code != 0 && !stderr_output.is_empty() {
- let _ = writeln!(logger, "NanVix stderr:\n{}", stderr_output);
- }
- ScriptResponse {
- exit_code,
- ..Default::default()
- }
- }
- Err(e) => {
- if !stderr_output.is_empty() {
- let _ = writeln!(logger, "NanVix stderr:\n{}", stderr_output);
- }
- let err =
- NanVixError::Runtime(format!("failed to wait for {}: {}", NANVIXD_BINARY, e));
- let _ = writeln!(logger, "{}", err);
- err.to_response()
- }
- }
- }
- /// Returns `true` when filesystem copyback should run.
- /// Copyback runs on any normal process exit (including non-zero exit codes).
- /// It is skipped for preflight, spawn, runtime, and timeout errors, and for
- /// OS crashes (negative exit codes from NTSTATUS values).
- fn should_copy_back(response: &ScriptResponse) -> bool {
- response.error_message.is_empty() && response.exit_code >= 0
- }
-}
-
-impl ScriptRunner for NanVixScriptRunner {
- fn validate_runner(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> {
- Self::validate_policies(request).map_err(|e| e.to_response())?;
- validate_network_policy_support(
- request,
- NetworkPolicySupport::EGRESS_DEFAULT
- | NetworkPolicySupport::INGRESS_DEFAULT
- | NetworkPolicySupport::HOST_LOOPBACK,
- )?;
- Ok(())
- }
-
- fn execute(&mut self, request: &ExecutionRequest, logger: &mut Logger) -> ScriptResponse {
- let host_networking = match Self::resolve_networking_mode(request) {
- Ok(enabled) => enabled,
- Err(error) => return error.to_response(),
- };
- let paths = match self.resolve_paths() {
- Ok(p) => p,
- Err(e) => return e.to_response(),
- };
-
- // Build staging directory with script and filesystem policy paths.
- let staging_root = std::env::temp_dir().join("mxc-microvm");
- // Sweep orphaned staging dirs from previous crashed runs (older than 1 hour).
- wxc_common::microvm_staging::sweep_orphaned_staging_dirs(
- &staging_root,
- std::time::Duration::from_secs(ORPHAN_SWEEP_MAX_AGE_SECS),
- );
- let mut staging = match wxc_common::microvm_staging::StagingDir::new(
- staging_root,
- &request.script_code,
- &request.policy.readwrite_paths,
- &request.policy.readonly_paths,
- ) {
- Ok(s) => s,
- Err(e) => {
- let err = NanVixError::Preflight(e.to_string());
- let _ = writeln!(logger, "{}", err);
- return err.to_response();
- }
- };
-
- Self::log_resolved_paths(logger, &paths);
- let _ = writeln!(logger, "NanVix: staging_dir={:?}", staging.path());
-
- if host_networking {
- let _ = writeln!(logger, "NanVix: host networking enabled");
- }
- let (allow_hosts, block_hosts) = match Self::resolve_host_lists(request) {
- Ok(resolved) => {
- for warning in &resolved.warnings {
- let _ = writeln!(logger, "NanVix: {}", warning);
- }
- (resolved.allow, resolved.block)
- }
- Err(e) => {
- let _ = writeln!(logger, "{}", e);
- return e.to_response();
- }
- };
- if !allow_hosts.is_empty() {
- let _ = writeln!(logger, "NanVix: egress allowlist={:?}", allow_hosts);
- }
- if !block_hosts.is_empty() {
- let _ = writeln!(logger, "NanVix: egress blocklist={:?}", block_hosts);
- }
- let mut child = match Self::spawn_nanvixd(
- &paths,
- staging.path(),
- request,
- &allow_hosts,
- &block_hosts,
- ) {
- Ok(c) => c,
- Err(e) => {
- let _ = writeln!(logger, "{}", e);
- return e.to_response();
- }
- };
-
- let staging_overhead = staging.staging_overhead_ms();
- let timeout_ms = Self::total_timeout_ms(request.script_timeout, staging_overhead);
- let (watchdog, cancel_pair, timed_out) =
- match Self::setup_watchdog(&mut child, timeout_ms, logger) {
- Ok(v) => v,
- Err(resp) => return resp,
- };
-
- let response = Self::wait_and_respond(
- &mut child,
- watchdog,
- &cancel_pair,
- timed_out.as_ref(),
- timeout_ms,
- request.script_timeout,
- logger,
- );
-
- // Copy back RW filesystem changes on normal process exit.
- if Self::should_copy_back(&response) {
- if let Err(e) = staging.copy_back_to_host() {
- let preserved = staging
- .preserved_path()
- .map(|p| p.display().to_string())
- .unwrap_or_default();
- let err = NanVixError::Runtime(format!(
- "failed to copy back microvm filesystem changes: {}. \
- Staged files preserved at: {}",
- e, preserved
- ));
- let _ = writeln!(logger, "{}", err);
- return err.to_response();
- }
- }
-
- response
- // staging is dropped here → cleanup
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use wxc_common::logger::{Logger, Mode};
- use wxc_common::models::{ContainerPolicy, NetworkPolicy};
-
- #[test]
- fn total_timeout_adds_boot_staging_and_script() {
- // script_timeout=0 => infinite script timeout sentinel.
- assert_eq!(NanVixScriptRunner::total_timeout_ms(0, 0), u64::MAX);
- // script_timeout=30000, staging_overhead=500 -> 30s + 500ms + 60s boot = 90.5s
- assert_eq!(NanVixScriptRunner::total_timeout_ms(30_000, 500), 90_500);
- // script_timeout=30000, no staging -> 30s + 60s boot = 90s
- assert_eq!(NanVixScriptRunner::total_timeout_ms(30_000, 0), 90_000);
- }
-
- #[test]
- fn resolve_paths_fails_when_exe_dir_has_no_binaries() {
- let runner = NanVixScriptRunner::new();
- let err = runner.resolve_paths().unwrap_err();
- assert!(err.to_string().contains("not found"), "got: {}", err);
- }
-
- // -- Policy validation tests -------------------------------------------------
-
- fn directional_request(
- egress: NetworkAction,
- ingress: NetworkAction,
- host_loopback: NetworkAction,
- ) -> ExecutionRequest {
- ExecutionRequest {
- policy: ContainerPolicy {
- network_egress: Some(wxc_common::models::NetworkEgressPolicy {
- default: egress,
- allow: Vec::new(),
- deny: Vec::new(),
- }),
- network_ingress: Some(wxc_common::models::NetworkIngressPolicy {
- default: ingress,
- host_loopback,
- }),
- network_specified: true,
- network_mode_specified: true,
- ..Default::default()
- },
- ..Default::default()
- }
- }
-
- fn command_arguments(
- request: &ExecutionRequest,
- allow_hosts: &[String],
- block_hosts: &[String],
- ) -> Result, NanVixError> {
- let root = PathBuf::from("nanvix-command-test");
- let paths = ResolvedPaths {
- nanvixd: root.join(NANVIXD_BINARY),
- ramfs: root.join("rootfs.img"),
- initrd: root.join("python3.initrd"),
- exe_dir: root.clone(),
- snapshot_home: root,
- };
- let command = NanVixScriptRunner::nanvixd_command(
- &paths,
- Path::new("staging"),
- request,
- allow_hosts,
- block_hosts,
- )?;
- Ok(command
- .get_args()
- .map(|argument| argument.to_string_lossy().into_owned())
- .collect())
- }
-
- #[test]
- fn directional_networking_requires_a_coherent_explicit_posture() {
- let runner = NanVixScriptRunner::new();
- for egress in [NetworkAction::Deny, NetworkAction::Allow] {
- for ingress in [NetworkAction::Deny, NetworkAction::Allow] {
- for host_loopback in [NetworkAction::Deny, NetworkAction::Allow] {
- let request = directional_request(egress, ingress, host_loopback);
- if egress == ingress && ingress == host_loopback {
- runner.validate_runner(&request).unwrap();
- assert_eq!(
- NanVixScriptRunner::resolve_networking_mode(&request).unwrap(),
- egress == NetworkAction::Allow
- );
- let arguments = command_arguments(&request, &[], &[]).unwrap();
- assert_eq!(
- arguments
- .iter()
- .filter(|arg| *arg == "-allow-host-networking")
- .count(),
- usize::from(egress == NetworkAction::Allow)
- );
- } else {
- let error = runner.validate_runner(&request).unwrap_err();
- assert!(error.error_message.contains(ERR_DIRECTIONAL_NETWORK));
- assert!(command_arguments(&request, &[], &[]).is_err());
- }
- }
- }
- }
- }
-
- #[test]
- fn directional_egress_allow_does_not_implicitly_allow_ingress() {
- let mut request = directional_request(
- NetworkAction::Allow,
- NetworkAction::Allow,
- NetworkAction::Allow,
- );
- request.policy.network_ingress = None;
- assert!(NanVixScriptRunner::resolve_networking_mode(&request).is_err());
- }
-
- #[test]
- fn directional_filter_requests_are_rejected_before_execution() {
- for rules in ["allow", "deny"] {
- let source = format!(
- r#"{{"version":"0.10.0-alpha","containment":"microvm",
- "process":{{"commandLine":"print(1)"}},
- "network":{{"egress":{{"default":"allow","{rules}":[{{"to":[{{"cidr":"203.0.113.0/24"}}]}}]}},
- "ingress":{{"default":"allow","hostLoopback":"allow"}}}}}}"#
- );
- let mut logger = Logger::new(Mode::Buffer);
- let parsed =
- wxc_common::config_parser::load_mxc_request_from_json(&source, &mut logger)
- .unwrap();
- let wxc_common::state_aware_request::MxcRequest::OneShot(request) = parsed else {
- panic!("expected one-shot");
- };
- let error = NanVixScriptRunner::new()
- .validate_runner(&request)
- .unwrap_err();
- assert!(error.error_message.contains(ERR_DIRECTIONAL_FILTERS));
- assert!(command_arguments(&request, &[], &[]).is_err());
- }
- }
-
- #[test]
- fn public_directional_fixtures_enable_host_networking() {
- for source in [
- include_str!("../../../../../tests/configs/microvm_network.json"),
- include_str!("../../../../../tests/configs/microvm_network_linux.json"),
- ] {
- let mut logger = Logger::new(Mode::Buffer);
- let parsed =
- wxc_common::config_parser::load_mxc_request_from_json(source, &mut logger).unwrap();
- let wxc_common::state_aware_request::MxcRequest::OneShot(request) = parsed else {
- panic!("expected one-shot");
- };
- NanVixScriptRunner::new().validate_runner(&request).unwrap();
- assert!(NanVixScriptRunner::resolve_networking_mode(&request).unwrap());
- assert!(!NanVixScriptRunner::legacy_host_networking_enabled(
- &request
- ));
- let arguments = command_arguments(&request, &[], &[]).unwrap();
- assert_eq!(
- arguments.first().map(String::as_str),
- Some("-allow-host-networking")
- );
- assert!(!arguments
- .iter()
- .any(|arg| arg == "-allow-host" || arg == "-block-host"));
- }
- }
-
- #[test]
- fn legacy_launch_arguments_preserve_network_defaults_and_host_filters() {
- for compatibility in [
- wxc_common::models::NetworkEnforcementCompatibility::LegacyCompatible,
- wxc_common::models::NetworkEnforcementCompatibility::Strict,
- ] {
- for (default, allow, block, expected_prefix) in [
- (NetworkPolicy::Block, vec![], vec![], vec![]),
- (
- NetworkPolicy::Allow,
- vec![],
- vec![],
- vec!["-allow-host-networking"],
- ),
- (
- NetworkPolicy::Block,
- vec!["192.0.2.1"],
- vec![],
- vec!["-allow-host-networking", "-allow-host", "192.0.2.1"],
- ),
- (
- NetworkPolicy::Block,
- vec![],
- vec!["192.0.2.0/24"],
- vec!["-allow-host-networking", "-block-host", "192.0.2.0/24"],
- ),
- ] {
- let request = ExecutionRequest {
- network_enforcement_compatibility: compatibility,
- policy: ContainerPolicy {
- default_network_policy: default,
- allowed_hosts: allow.into_iter().map(str::to_owned).collect(),
- blocked_hosts: block.into_iter().map(str::to_owned).collect(),
- ..Default::default()
- },
- ..Default::default()
- };
- let arguments = command_arguments(
- &request,
- &request.policy.allowed_hosts,
- &request.policy.blocked_hosts,
- )
- .unwrap();
- let expected: Vec =
- expected_prefix.into_iter().map(str::to_owned).collect();
- assert!(
- arguments.starts_with(&expected),
- "{compatibility:?}: {arguments:?}"
- );
- assert_eq!(
- arguments.iter().any(|arg| arg == "-allow-host-networking"),
- !expected.is_empty(),
- "{compatibility:?}: {arguments:?}"
- );
- }
- }
- }
-
- #[test]
- fn policy_accepts_readwrite_paths() {
- // Validation passes; the runner fails later on path resolution.
- let request = ExecutionRequest {
- script_code: "echo test".to_string(),
- policy: ContainerPolicy {
- readwrite_paths: vec!["/tmp".to_string()],
- ..Default::default()
- },
- ..Default::default()
- };
- let result = NanVixScriptRunner::validate_policies(&request);
- assert!(result.is_ok(), "readwrite_paths accepted");
- }
-
- #[test]
- fn policy_accepts_readonly_paths() {
- let request = ExecutionRequest {
- script_code: "echo test".to_string(),
- policy: ContainerPolicy {
- readonly_paths: vec!["/data".to_string()],
- ..Default::default()
- },
- ..Default::default()
- };
- let result = NanVixScriptRunner::validate_policies(&request);
- assert!(result.is_ok(), "readonly_paths accepted");
- }
-
- #[test]
- fn policy_rejects_denied_paths() {
- let request = ExecutionRequest {
- policy: ContainerPolicy {
- denied_paths: vec!["/secret".to_string()],
- ..Default::default()
- },
- ..Default::default()
- };
- let result = NanVixScriptRunner::validate_policies(&request);
- assert!(result.is_err(), "denied_paths should be rejected");
- let err = result.unwrap_err().to_string();
- assert!(
- err.contains(ERR_DENIED_PATHS),
- "expected denied_paths error, got: {}",
- err
- );
- }
-
- #[test]
- fn policy_accepts_allowlist_only() {
- // A bare allowlist is now supported (forwarded as -allow-host).
- let request = ExecutionRequest {
- script_code: "echo test".to_string(),
- policy: ContainerPolicy {
- allowed_hosts: vec!["93.184.216.34".to_string()],
- ..Default::default()
- },
- ..Default::default()
- };
- assert!(
- NanVixScriptRunner::validate_policies(&request).is_ok(),
- "a bare allowlist should pass validation"
- );
- // A list implies host networking regardless of defaultPolicy (Block).
- assert!(NanVixScriptRunner::legacy_host_networking_enabled(&request));
- }
-
- #[test]
- fn policy_accepts_blocklist_only() {
- // A bare blocklist is now supported (forwarded as -block-host).
- let request = ExecutionRequest {
- script_code: "echo test".to_string(),
- policy: ContainerPolicy {
- blocked_hosts: vec!["93.184.216.34".to_string()],
- ..Default::default()
- },
- ..Default::default()
- };
- assert!(
- NanVixScriptRunner::validate_policies(&request).is_ok(),
- "a bare blocklist should pass validation"
- );
- assert!(NanVixScriptRunner::legacy_host_networking_enabled(&request));
- }
-
- #[test]
- fn policy_rejects_both_host_lists() {
- // allow + block are mutually exclusive (the guest filter is allow-XOR-block).
- let request = ExecutionRequest {
- script_code: "echo test".to_string(),
- policy: ContainerPolicy {
- allowed_hosts: vec!["10.0.0.1".to_string()],
- blocked_hosts: vec!["10.0.0.2".to_string()],
- ..Default::default()
- },
- ..Default::default()
- };
- let err = NanVixScriptRunner::validate_policies(&request).unwrap_err();
- assert!(
- err.to_string().contains(ERR_NETWORK_HOSTS),
- "both lists should be rejected, got: {}",
- err
- );
- }
-
- // -- Host resolution / decision-matrix tests --------------------------------
-
- #[test]
- fn resolve_hosts_passes_ipv4_and_cidr_literals() {
- let hosts = vec![
- "1.2.3.4".to_string(),
- "10.0.0.0/8".to_string(),
- "192.168.1.1/32".to_string(),
- ];
- let (resolved, unresolved) = NanVixScriptRunner::resolve_hosts_detailed(&hosts);
- assert_eq!(resolved, vec!["1.2.3.4", "10.0.0.0/8", "192.168.1.1/32"]);
- assert!(unresolved.is_empty());
- }
-
- #[test]
- fn resolve_hosts_drops_ipv6_and_bad_entries() {
- let hosts = vec![
- "::1".to_string(), // IPv6 literal -> dropped
- "2001:db8::/32".to_string(), // IPv6 CIDR -> dropped (addr not IPv4)
- "1.2.3.4/33".to_string(), // out-of-range prefix -> dropped
- " ".to_string(), // blank -> skipped
- "5.6.7.8".to_string(), // valid -> kept
- ];
- let (resolved, _) = NanVixScriptRunner::resolve_hosts_detailed(&hosts);
- assert_eq!(resolved, vec!["5.6.7.8"]);
- }
-
- #[test]
- fn resolve_host_lists_returns_resolved_allow() {
- let request = ExecutionRequest {
- policy: ContainerPolicy {
- allowed_hosts: vec!["1.1.1.1".to_string(), "8.8.8.8/32".to_string()],
- ..Default::default()
- },
- ..Default::default()
- };
- let resolved = NanVixScriptRunner::resolve_host_lists(&request).unwrap();
- assert_eq!(resolved.allow, vec!["1.1.1.1", "8.8.8.8/32"]);
- assert!(resolved.block.is_empty());
- assert!(resolved.warnings.is_empty());
- }
-
- #[test]
- fn resolve_host_lists_fails_closed_when_allowlist_unresolvable() {
- // A non-empty allowlist that resolves to nothing must error rather than
- // silently fall through to allow-all.
- let request = ExecutionRequest {
- policy: ContainerPolicy {
- // IPv6-only literal resolves to no IPv4 entry.
- allowed_hosts: vec!["::1".to_string()],
- ..Default::default()
- },
- ..Default::default()
- };
- let err = NanVixScriptRunner::resolve_host_lists(&request).unwrap_err();
- assert!(
- err.to_string().contains(ERR_HOSTS_UNRESOLVED),
- "unresolvable allowlist should fail closed, got: {}",
- err
- );
- }
-
- #[test]
- fn resolve_hosts_detailed_reports_unresolved_entries() {
- // IPv4/CIDR pass through; IPv6 + malformed entries are reported as
- // unresolved; blanks are ignored entirely.
- let hosts = vec![
- "5.6.7.8".to_string(), // valid -> kept
- "::1".to_string(), // IPv6 literal -> unresolved
- "2001:db8::/32".to_string(), // IPv6 CIDR -> unresolved
- "1.2.3.4/33".to_string(), // out-of-range prefix -> unresolved
- "not_a_host.invalid".to_string(), // no A record -> unresolved
- " ".to_string(), // blank -> ignored (neither list)
- ];
- let (resolved, unresolved) = NanVixScriptRunner::resolve_hosts_detailed(&hosts);
- assert_eq!(resolved, vec!["5.6.7.8"]);
- assert_eq!(
- unresolved,
- vec!["::1", "2001:db8::/32", "1.2.3.4/33", "not_a_host.invalid"]
- );
- }
-
- #[test]
- fn resolve_host_lists_warns_on_dropped_allowlist_entries() {
- // A partially-resolvable allowlist narrows access (fail-safe): the run
- // continues and each dropped entry produces a warning.
- let request = ExecutionRequest {
- policy: ContainerPolicy {
- allowed_hosts: vec![
- "9.9.9.9".to_string(),
- "::1".to_string(),
- "dropme.invalid".to_string(),
- ],
- ..Default::default()
- },
- ..Default::default()
- };
- let resolved = NanVixScriptRunner::resolve_host_lists(&request).unwrap();
- assert_eq!(resolved.allow, vec!["9.9.9.9"]);
- assert!(resolved.block.is_empty());
- assert_eq!(resolved.warnings.len(), 2, "two entries were dropped");
- assert!(resolved.warnings.iter().any(|w| w.contains("::1")));
- assert!(resolved
- .warnings
- .iter()
- .any(|w| w.contains("dropme.invalid")));
- }
-
- #[test]
- fn resolve_host_lists_fails_closed_on_unresolvable_blocklist_entry() {
- // A blocklist (allow-by-default) must fail closed if ANY entry cannot
- // be resolved -- silently dropping it would let blocked traffic flow.
- let request = ExecutionRequest {
- policy: ContainerPolicy {
- blocked_hosts: vec!["10.0.0.1".to_string(), "::1".to_string()],
- ..Default::default()
- },
- ..Default::default()
- };
- let err = NanVixScriptRunner::resolve_host_lists(&request).unwrap_err();
- assert!(
- err.to_string().contains(ERR_BLOCKED_HOST_UNRESOLVED),
- "unresolvable blocklist entry should fail closed, got: {}",
- err
- );
- assert!(
- err.to_string().contains("::1"),
- "error should name the offending entry, got: {}",
- err
- );
- }
-
- #[test]
- fn resolve_host_lists_accepts_fully_resolved_blocklist() {
- let request = ExecutionRequest {
- policy: ContainerPolicy {
- blocked_hosts: vec!["10.0.0.1".to_string(), "192.168.0.0/16".to_string()],
- ..Default::default()
- },
- ..Default::default()
- };
- let resolved = NanVixScriptRunner::resolve_host_lists(&request).unwrap();
- assert!(resolved.allow.is_empty());
- assert_eq!(resolved.block, vec!["10.0.0.1", "192.168.0.0/16"]);
- assert!(resolved.warnings.is_empty());
- }
-
- #[test]
- fn default_block_no_lists_disables_host_networking() {
- // The default posture (block, no lists) keeps networking off.
- let request = ExecutionRequest::default();
- assert!(!NanVixScriptRunner::legacy_host_networking_enabled(
- &request
- ));
- let resolved = NanVixScriptRunner::resolve_host_lists(&request).unwrap();
- assert!(resolved.allow.is_empty() && resolved.block.is_empty());
- assert!(resolved.warnings.is_empty());
- }
-
- #[test]
- fn allow_policy_enables_host_networking() {
- // `network.defaultPolicy = "allow"` maps to host networking and must
- // pass validation (the run later fails on missing nanvixd binaries,
- // not on policy). Per-host filtering is absent, so it is accepted.
- let request = ExecutionRequest {
- script_code: "echo test".to_string(),
- policy: ContainerPolicy {
- default_network_policy: NetworkPolicy::Allow,
- ..Default::default()
- },
- ..Default::default()
- };
- assert!(NanVixScriptRunner::legacy_host_networking_enabled(&request));
- assert!(
- NanVixScriptRunner::validate_policies(&request).is_ok(),
- "allow posture without per-host filtering should pass validation"
- );
-
- let mut runner = NanVixScriptRunner::new();
- let mut logger = Logger::new(Mode::Buffer);
- let resp = runner.run(&request, &mut logger);
- assert_eq!(resp.exit_code, ERROR_EXIT_CODE);
- assert!(
- !resp.error_message.contains(ERR_NETWORK_HOSTS),
- "allow posture must not trigger a network policy rejection, got: {}",
- resp.error_message
- );
- }
-
- #[test]
- fn policy_rejects_working_directory() {
- let mut runner = NanVixScriptRunner::new();
- let request = ExecutionRequest {
- script_code: "echo test".to_string(),
- working_directory: "/home/user".to_string(),
- ..Default::default()
- };
- let mut logger = Logger::new(Mode::Buffer);
- let resp = runner.run(&request, &mut logger);
- assert_eq!(resp.exit_code, ERROR_EXIT_CODE);
- assert!(resp.error_message.contains(ERR_WORKDIR));
- }
-
- #[test]
- fn policy_allows_defaults() {
- // NanVix accepts a default (deny-by-default) policy. With no host
- // networking requested, the run later fails on missing nanvixd
- // binaries, not on policy.
- let mut runner = NanVixScriptRunner::new();
- let request = ExecutionRequest {
- script_code: "echo test".to_string(),
- ..Default::default()
- };
- assert!(!NanVixScriptRunner::legacy_host_networking_enabled(
- &request
- ));
- let mut logger = Logger::new(Mode::Buffer);
- let resp = runner.run(&request, &mut logger);
- assert_eq!(resp.exit_code, ERROR_EXIT_CODE);
- assert!(
- !resp.error_message.contains(ERR_NETWORK_HOSTS),
- "default request should not trigger network policy rejection"
- );
- assert!(
- !resp.error_message.contains(ERR_WORKDIR),
- "default request should not trigger workingDirectory rejection"
- );
- }
-
- #[test]
- fn policy_rejects_network_proxy() {
- let mut runner = NanVixScriptRunner::new();
- let request = ExecutionRequest {
- script_code: "echo test".to_string(),
- policy: ContainerPolicy {
- network_proxy: wxc_common::models::ProxyConfig {
- address: Some(wxc_common::models::ProxyAddress::new(
- "127.0.0.1".to_string(),
- 8080,
- )),
- builtin_test_server: false,
- },
- ..Default::default()
- },
- ..Default::default()
- };
- let mut logger = Logger::new(Mode::Buffer);
- let resp = runner.run(&request, &mut logger);
- assert_eq!(resp.exit_code, ERROR_EXIT_CODE);
- assert!(resp.error_message.contains(ERR_PROXY_POLICY));
- }
-
- #[test]
- fn resolve_paths_checks_for_snapshot() {
- let runner = NanVixScriptRunner::new();
- let err = runner.resolve_paths().unwrap_err();
- // Should fail on missing binaries (not on snapshot specifically,
- // since nanvixd.exe is checked first).
- assert!(err.to_string().contains("not found"), "got: {}", err);
- }
-
- // -- Copyback decision tests ------------------------------------------------
-
- #[test]
- fn copyback_allowed_for_zero_exit() {
- let response = ScriptResponse {
- exit_code: 0,
- ..Default::default()
- };
- assert!(NanVixScriptRunner::should_copy_back(&response));
- }
-
- #[test]
- fn copyback_allowed_for_nonzero_normal_exit() {
- let response = ScriptResponse {
- exit_code: 42,
- ..Default::default()
- };
- assert!(NanVixScriptRunner::should_copy_back(&response));
- }
-
- #[test]
- fn copyback_skipped_for_runner_error() {
- let response = ScriptResponse {
- exit_code: ERROR_EXIT_CODE,
- error_message: "NanVix execution timed out after 90000ms".to_string(),
- ..Default::default()
- };
- assert!(!NanVixScriptRunner::should_copy_back(&response));
- }
-
- #[test]
- fn copyback_skipped_for_os_crash() {
- // STATUS_ACCESS_VIOLATION = 0xC0000005 → interpreted as i32 = -1073741819.
- // This is a nanvixd OS crash — copyback must be suppressed.
- let response = ScriptResponse {
- exit_code: -1073741819_i32,
- error_message: String::new(),
- ..Default::default()
- };
- assert!(
- !NanVixScriptRunner::should_copy_back(&response),
- "copyback must be skipped for NTSTATUS crash exit codes"
- );
- }
-
- #[test]
- fn copyback_skipped_for_signal_killed() {
- // On Linux, SIGKILL results in exit code -9 (negative signal number).
- let response = ScriptResponse {
- exit_code: -9,
- error_message: String::new(),
- ..Default::default()
- };
- assert!(
- !NanVixScriptRunner::should_copy_back(&response),
- "copyback must be skipped for signal-killed processes"
- );
- }
-
- // -- Platform-specific constant tests ---------------------------------------
-
- #[test]
- fn nanvixd_binary_matches_platform() {
- #[cfg(target_os = "linux")]
- assert_eq!(NANVIXD_BINARY, "nanvixd.elf");
- #[cfg(target_os = "windows")]
- assert_eq!(NANVIXD_BINARY, "nanvixd.exe");
- }
-
- #[test]
- fn total_timeout_infinite_when_zero() {
- assert_eq!(NanVixScriptRunner::total_timeout_ms(0, 0), u64::MAX);
- assert_eq!(NanVixScriptRunner::total_timeout_ms(0, 500), u64::MAX);
- }
-
- #[test]
- fn total_timeout_saturates_on_overflow() {
- // With values that would cause u64 overflow, should saturate at u64::MAX.
- let result = NanVixScriptRunner::total_timeout_ms(u32::MAX, u64::MAX - 1);
- assert_eq!(result, u64::MAX);
- }
-
- // -- Watchdog timeout state tests ------------------------------------------
-
- #[test]
- fn watchdog_state_no_thread_when_infinite_timeout() {
- // When timeout is u64::MAX, start_watchdog should return None.
- // We can't test this directly without a real child process, but we can
- // verify the total_timeout_ms sentinel logic.
- let timeout = NanVixScriptRunner::total_timeout_ms(0, 0);
- assert_eq!(
- timeout,
- u64::MAX,
- "zero script_timeout should yield infinite"
- );
- }
-
- // -- NanVixError display tests ---------------------------------------------
-
- #[test]
- fn error_display_preflight() {
- let err = NanVixError::Preflight("missing binary".to_string());
- assert!(err.to_string().contains("preflight"));
- assert!(err.to_string().contains("missing binary"));
- }
-
- #[test]
- fn error_display_platform() {
- let err = NanVixError::Platform("spawn failed".to_string());
- assert!(err.to_string().contains("platform"));
- assert!(err.to_string().contains("spawn failed"));
- }
-
- #[test]
- fn error_display_runtime() {
- let err = NanVixError::Runtime("VM crashed".to_string());
- assert!(err.to_string().contains("runtime"));
- assert!(err.to_string().contains("VM crashed"));
- }
-
- #[test]
- fn error_display_timeout() {
- let err = NanVixError::Timeout {
- script_timeout_ms: 5000,
- total_ms: 65000,
- };
- let msg = err.to_string();
- assert!(msg.contains("timed out"));
- assert!(msg.contains("65000"));
- assert!(msg.contains("5000"));
- }
-
- #[test]
- fn error_to_response_has_error_exit_code() {
- let err = NanVixError::Preflight("test".to_string());
- let resp = err.to_response();
- assert_eq!(resp.exit_code, ERROR_EXIT_CODE);
- assert!(!resp.error_message.is_empty());
- }
-}
diff --git a/src/backends/nvx/binaries/Cargo.toml b/src/backends/nvx/binaries/Cargo.toml
new file mode 100644
index 000000000..378684319
--- /dev/null
+++ b/src/backends/nvx/binaries/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "nvx_binaries"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+description = "Build-time acquisition of pinned NVX release artifacts"
+links = "nvx_binaries"
+
+[lib]
+path = "src/lib.rs"
+
+[features]
+# Expensive download and verification work is disabled for default workspace
+# builds and enabled only by future NVX backend consumers.
+nvx = []
+
+[dependencies]
+nvx_common = { workspace = true }
+
+[build-dependencies]
+nvx_build_common = { workspace = true }
+nvx_common = { workspace = true }
+sha2 = { workspace = true }
+
+[dev-dependencies]
+serde_json = { workspace = true }
diff --git a/src/backends/nvx/binaries/build.rs b/src/backends/nvx/binaries/build.rs
new file mode 100644
index 000000000..08bc82bca
--- /dev/null
+++ b/src/backends/nvx/binaries/build.rs
@@ -0,0 +1,280 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Downloads and verifies pinned NVX artifacts at build time.
+
+use std::collections::HashMap;
+use std::fs;
+use std::io::{self, Read};
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use nvx_common::{
+ github_download_url, load_checksums, load_json, ReleaseConfig, WINDOWS_PLATFORM_ARTIFACTS,
+ WORKLOAD_IMAGE_ARTIFACTS,
+};
+use sha2::{Digest, Sha256};
+
+fn main() {
+ println!("cargo:rerun-if-env-changed=CARGO_FEATURE_NVX");
+
+ if std::env::var_os("CARGO_FEATURE_NVX").is_none() {
+ emit_disabled_metadata();
+ return;
+ }
+
+ ensure_supported_target();
+
+ let out_dir = PathBuf::from(
+ std::env::var_os("OUT_DIR").expect("nvx_binaries: OUT_DIR is not set by Cargo"),
+ );
+ let (bin_dir, prefetched) =
+ nvx_build_common::resolve_bin_dir(&out_dir).unwrap_or_else(|error| {
+ panic!("nvx_binaries: failed to resolve artifact directory: {error}")
+ });
+ let release: ReleaseConfig = load_json("versions.json");
+ let checksums = load_checksums("checksums.json");
+ let workload_images_available = release.workload_image_asset.is_some();
+
+ if prefetched {
+ eprintln!(
+ "nvx_binaries: NVX_BIN set; using pre-fetched artifacts from '{}' (offline)",
+ bin_dir.display()
+ );
+ } else {
+ ensure_asset(
+ &release,
+ &release.windows_whp_asset,
+ &WINDOWS_PLATFORM_ARTIFACTS,
+ &bin_dir,
+ &checksums,
+ );
+
+ if let Some(asset) = release.workload_image_asset.as_deref() {
+ ensure_asset(
+ &release,
+ asset,
+ &WORKLOAD_IMAGE_ARTIFACTS,
+ &bin_dir,
+ &checksums,
+ );
+ }
+ }
+
+ validate_artifacts(
+ &bin_dir,
+ nvx_build_common::available_artifact_rel_paths(workload_images_available),
+ &checksums,
+ )
+ .unwrap_or_else(|error| panic!("nvx_binaries: {error}"));
+
+ nvx_build_common::emit_rerun_for_artifacts(&bin_dir, workload_images_available);
+
+ emit_metadata(&bin_dir, workload_images_available);
+}
+
+fn ensure_supported_target() {
+ let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
+ let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
+ let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default();
+ let target = std::env::var("TARGET").expect("nvx_binaries: TARGET is not set by Cargo");
+ nvx_build_common::validate_nvx_target(&target, &target_os, &target_arch, &target_env)
+ .unwrap_or_else(|error| panic!("nvx_binaries: {error}"));
+}
+
+fn emit_disabled_metadata() {
+ let out_dir = std::env::var("OUT_DIR").expect("nvx_binaries: OUT_DIR is not set by Cargo");
+ println!("cargo:rustc-env=NVX_BIN_DIR={out_dir}");
+ println!("cargo:rustc-env=NVX_WORKLOAD_IMAGES_AVAILABLE=0");
+ println!("cargo:BIN_DIR={out_dir}");
+ println!("cargo:WORKLOAD_IMAGES_AVAILABLE=0");
+ println!("cargo:rerun-if-changed=build.rs");
+}
+
+fn emit_metadata(bin_dir: &Path, workload_images_available: bool) {
+ let available = if workload_images_available { "1" } else { "0" };
+ println!("cargo:rustc-env=NVX_BIN_DIR={}", bin_dir.display());
+ println!("cargo:rustc-env=NVX_WORKLOAD_IMAGES_AVAILABLE={available}");
+ println!("cargo:BIN_DIR={}", bin_dir.display());
+ println!("cargo:WORKLOAD_IMAGES_AVAILABLE={available}");
+ println!("cargo:rerun-if-changed=build.rs");
+ println!("cargo:rerun-if-changed=versions.json");
+ println!("cargo:rerun-if-changed=checksums.json");
+ println!("cargo:rerun-if-env-changed=NVX_BIN");
+}
+
+fn ensure_asset(
+ release: &ReleaseConfig,
+ asset: &str,
+ relative_paths: &[&str],
+ bin_dir: &Path,
+ checksums: &HashMap,
+) {
+ if validate_artifacts(bin_dir, relative_paths.iter().copied(), checksums).is_ok() {
+ eprintln!("nvx_binaries: '{asset}' artifacts are cached and verified");
+ return;
+ }
+
+ let url = github_download_url(&release.repository, &release.tag, asset);
+ let archive = bin_dir.join(asset);
+ download(&url, &archive).unwrap_or_else(|error| {
+ let _ = fs::remove_file(&archive);
+ panic!("nvx_binaries: failed to download '{asset}': {error}");
+ });
+
+ extract_and_stage(&archive, asset, relative_paths, bin_dir, checksums).unwrap_or_else(
+ |error| {
+ let _ = fs::remove_file(&archive);
+ panic!("nvx_binaries: failed to extract '{asset}': {error}");
+ },
+ );
+ let _ = fs::remove_file(archive);
+}
+
+fn download(url: &str, destination: &Path) -> Result<(), String> {
+ let mut command = Command::new("curl");
+ command.args([
+ "--silent",
+ "--show-error",
+ "--fail",
+ "--location",
+ "--retry",
+ "5",
+ "--retry-delay",
+ "5",
+ "--retry-all-errors",
+ "--output",
+ ]);
+ command.arg(destination);
+ command.args(["--header", "User-Agent: mxc-nvx-build/0.1"]);
+
+ command.arg(url);
+
+ let output = command
+ .output()
+ .map_err(|error| format!("could not start curl: {error}"))?;
+ if !output.status.success() {
+ return Err(format!(
+ "curl exited with {}\nstderr: {}",
+ output.status,
+ String::from_utf8_lossy(&output.stderr)
+ ));
+ }
+ Ok(())
+}
+
+fn extract_and_stage(
+ archive: &Path,
+ asset: &str,
+ relative_paths: &[&str],
+ bin_dir: &Path,
+ checksums: &HashMap,
+) -> Result<(), String> {
+ let archive_root = archive_root(asset)?;
+ let extraction_dir = bin_dir.join(".nvx-extract");
+ if extraction_dir.exists() {
+ fs::remove_dir_all(&extraction_dir).map_err(|error| {
+ format!(
+ "failed to clean extraction directory '{}': {error}",
+ extraction_dir.display()
+ )
+ })?;
+ }
+ fs::create_dir_all(&extraction_dir).map_err(|error| {
+ format!(
+ "failed to create extraction directory '{}': {error}",
+ extraction_dir.display()
+ )
+ })?;
+
+ let archive_paths: Vec = relative_paths
+ .iter()
+ .map(|path| format!("{archive_root}/{path}"))
+ .collect();
+ let mut command = Command::new("tar");
+ command
+ .arg("-xf")
+ .arg(archive)
+ .arg("-C")
+ .arg(&extraction_dir);
+ command.args(&archive_paths);
+ let output = command
+ .output()
+ .map_err(|error| format!("could not start tar: {error}"))?;
+ if !output.status.success() {
+ let _ = fs::remove_dir_all(&extraction_dir);
+ return Err(format!(
+ "tar exited with {}\nstderr: {}",
+ output.status,
+ String::from_utf8_lossy(&output.stderr)
+ ));
+ }
+
+ let extracted_root = extraction_dir.join(archive_root);
+ let result = validate_artifacts(&extracted_root, relative_paths.iter().copied(), checksums)
+ .and_then(|()| {
+ nvx_build_common::copy_artifact_paths(
+ &extracted_root,
+ bin_dir,
+ relative_paths.iter().copied(),
+ )
+ .map_err(|error| error.to_string())
+ });
+ let cleanup_result = fs::remove_dir_all(&extraction_dir);
+
+ result?;
+ cleanup_result.map_err(|error| {
+ format!(
+ "failed to remove extraction directory '{}': {error}",
+ extraction_dir.display()
+ )
+ })
+}
+
+fn archive_root(asset: &str) -> Result<&str, String> {
+ asset
+ .strip_suffix(".zip")
+ .or_else(|| asset.strip_suffix(".tar.gz"))
+ .ok_or_else(|| format!("unsupported NVX archive name '{asset}'"))
+}
+
+fn validate_artifacts<'a>(
+ root: &Path,
+ relative_paths: impl IntoIterator,
+ checksums: &HashMap,
+) -> Result<(), String> {
+ for relative_path in relative_paths {
+ let artifact = root.join(relative_path);
+ if !artifact.is_file() {
+ return Err(format!(
+ "required artifact '{}' is missing",
+ artifact.display()
+ ));
+ }
+ let expected = checksums.get(relative_path).ok_or_else(|| {
+ format!("artifact '{relative_path}' has no checksum entry; refusing unverified input")
+ })?;
+ let actual = sha256(&artifact)
+ .map_err(|error| format!("failed to hash '{}': {error}", artifact.display()))?;
+ if actual != *expected {
+ return Err(format!(
+ "SHA-256 mismatch for '{relative_path}': expected {expected}, actual {actual}"
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn sha256(path: &Path) -> io::Result {
+ let mut file = fs::File::open(path)?;
+ let mut hasher = Sha256::new();
+ let mut buffer = [0_u8; 64 * 1024];
+ loop {
+ let bytes_read = file.read(&mut buffer)?;
+ if bytes_read == 0 {
+ break;
+ }
+ hasher.update(&buffer[..bytes_read]);
+ }
+ Ok(format!("{:x}", hasher.finalize()))
+}
diff --git a/src/backends/nvx/binaries/checksums.json b/src/backends/nvx/binaries/checksums.json
new file mode 100644
index 000000000..888b8d4a5
--- /dev/null
+++ b/src/backends/nvx/binaries/checksums.json
@@ -0,0 +1,5 @@
+{
+ "bin/openvmm.exe": "9ded05b56389ebd9fd2c7fc245779ecea203ce1b1e16488fd275cf31bc442913",
+ "guest/vmlinux": "1678a788ce5329db3e0e97ebc8703c1263da553dd2c682b502d9d5e6fbd0f3bb",
+ "guest/initramfs.cpio.gz": "0f0324d6a1a959f5156f2e8ab94195b3befe34523892d31933bf6e3d7bf7bae4"
+}
diff --git a/src/backends/nvx/binaries/src/lib.rs b/src/backends/nvx/binaries/src/lib.rs
new file mode 100644
index 000000000..a95df2700
--- /dev/null
+++ b/src/backends/nvx/binaries/src/lib.rs
@@ -0,0 +1,59 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Build-time location and availability metadata for pinned NVX artifacts.
+
+/// Directory containing the acquired NVX artifacts.
+pub const NVX_BIN_DIR: &str = env!("NVX_BIN_DIR");
+
+/// `"1"` when workload images are included in the configured release.
+pub const NVX_WORKLOAD_IMAGES_AVAILABLE: &str = env!("NVX_WORKLOAD_IMAGES_AVAILABLE");
+
+pub use nvx_common::{WINDOWS_PLATFORM_ARTIFACTS, WORKLOAD_IMAGE_ARTIFACTS};
+
+#[cfg(test)]
+mod tests {
+ use std::collections::HashMap;
+
+ use super::*;
+ use nvx_common::ReleaseConfig;
+
+ const VERSIONS_JSON: &str = include_str!("../versions.json");
+ const CHECKSUMS_JSON: &str = include_str!("../checksums.json");
+
+ #[test]
+ fn pinned_release_manifest_is_complete_for_platform_archive() {
+ let release: ReleaseConfig =
+ serde_json::from_str(VERSIONS_JSON).expect("versions.json must be valid");
+
+ assert_eq!(release.repository, "microsoft/nvx");
+ assert_eq!(release.tag, "v0.1.0-dev.5c86da3dff02");
+ assert_eq!(release.windows_whp_asset, "nvx-0.1.0-windows-whp.zip");
+ assert_eq!(release.workload_image_asset, None);
+ }
+
+ #[test]
+ fn checksums_cover_only_published_platform_artifacts() {
+ let checksums: HashMap =
+ serde_json::from_str(CHECKSUMS_JSON).expect("checksums.json must be valid");
+
+ assert_eq!(checksums.len(), WINDOWS_PLATFORM_ARTIFACTS.len());
+ for relative_path in WINDOWS_PLATFORM_ARTIFACTS {
+ let checksum = checksums
+ .get(relative_path)
+ .expect("platform artifact must have a checksum");
+ assert_eq!(checksum.len(), 64);
+ assert!(checksum
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)));
+ }
+ for relative_path in WORKLOAD_IMAGE_ARTIFACTS {
+ assert!(!checksums.contains_key(relative_path));
+ }
+ }
+
+ #[test]
+ fn release_without_workload_asset_reports_images_unavailable() {
+ assert_eq!(NVX_WORKLOAD_IMAGES_AVAILABLE, "0");
+ }
+}
diff --git a/src/backends/nvx/binaries/versions.json b/src/backends/nvx/binaries/versions.json
new file mode 100644
index 000000000..d404445b6
--- /dev/null
+++ b/src/backends/nvx/binaries/versions.json
@@ -0,0 +1,6 @@
+{
+ "repository": "microsoft/nvx",
+ "tag": "v0.1.0-dev.5c86da3dff02",
+ "windows_whp_asset": "nvx-0.1.0-windows-whp.zip",
+ "workload_image_asset": null
+}
diff --git a/src/backends/nvx/build_common/Cargo.toml b/src/backends/nvx/build_common/Cargo.toml
new file mode 100644
index 000000000..b06fe6ebe
--- /dev/null
+++ b/src/backends/nvx/build_common/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "nvx_build_common"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+description = "Build-only helpers for staging NVX artifacts"
+
+[dependencies]
+nvx_common = { workspace = true }
+
+[dev-dependencies]
+tempfile = { workspace = true }
diff --git a/src/backends/nvx/build_common/src/lib.rs b/src/backends/nvx/build_common/src/lib.rs
new file mode 100644
index 000000000..8fde0214d
--- /dev/null
+++ b/src/backends/nvx/build_common/src/lib.rs
@@ -0,0 +1,429 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Build-only helpers for locating and staging NVX artifacts.
+
+use std::io;
+use std::path::{Path, PathBuf};
+
+use nvx_common::{WINDOWS_PLATFORM_ARTIFACTS, WORKLOAD_IMAGE_ARTIFACTS};
+
+/// The only supported NVX package target triple.
+pub const SUPPORTED_NVX_TARGET_TRIPLE: &str = "x86_64-pc-windows-msvc";
+
+/// Category of an NVX release artifact.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ArtifactKind {
+ /// OpenVMM and guest boot artifacts.
+ Platform,
+ /// Optional workload filesystem images.
+ WorkloadImage,
+}
+
+/// Returns every relative path in a complete NVX artifact bundle.
+pub fn artifact_rel_paths() -> impl Iterator {
+ WINDOWS_PLATFORM_ARTIFACTS
+ .into_iter()
+ .map(|path| (ArtifactKind::Platform, path))
+ .chain(
+ WORKLOAD_IMAGE_ARTIFACTS
+ .into_iter()
+ .map(|path| (ArtifactKind::WorkloadImage, path)),
+ )
+}
+
+/// Returns whether the NVX artifact pipeline supports the given target.
+///
+/// NVX packaging is intentionally restricted to the Windows x86_64 MSVC
+/// target. That is the only target that is allowed to download, verify, and
+/// stage the pinned WHP artifacts.
+pub fn target_supports_nvx(target_os: &str, target_arch: &str, target_env: &str) -> bool {
+ target_os == "windows" && target_arch == "x86_64" && target_env == "msvc"
+}
+
+/// Returns `Ok(())` for the only supported NVX target, or a clear error
+/// message for anything else.
+pub fn validate_nvx_target(
+ target: &str,
+ target_os: &str,
+ target_arch: &str,
+ target_env: &str,
+) -> Result<(), String> {
+ if target == SUPPORTED_NVX_TARGET_TRIPLE
+ && target_supports_nvx(target_os, target_arch, target_env)
+ {
+ Ok(())
+ } else {
+ Err(unsupported_target_message(target))
+ }
+}
+
+/// Determines whether an executor build should stage NVX artifacts.
+///
+/// Non-Windows targets do not package NVX. Windows targets must be the exact
+/// supported x64 MSVC triple; unsupported Windows targets fail instead of
+/// silently producing an incomplete NVX-enabled executor.
+pub fn should_stage_nvx(
+ target: &str,
+ target_os: &str,
+ target_arch: &str,
+ target_env: &str,
+) -> Result {
+ if target_os != "windows" {
+ return Ok(false);
+ }
+
+ validate_nvx_target(target, target_os, target_arch, target_env)?;
+ Ok(true)
+}
+
+/// Formats the explicit error message used when NVX is requested for an
+/// unsupported target.
+pub fn unsupported_target_message(target: &str) -> String {
+ format!(
+ "nvx packaging is only supported for target {SUPPORTED_NVX_TARGET_TRIPLE}; current target is {target}"
+ )
+}
+
+/// Returns the artifacts required by the currently configured release.
+pub fn available_artifact_rel_paths(
+ workload_images_available: bool,
+) -> impl Iterator {
+ artifact_rel_paths().filter_map(move |(kind, path)| {
+ (kind == ArtifactKind::Platform
+ || (kind == ArtifactKind::WorkloadImage && workload_images_available))
+ .then_some(path)
+ })
+}
+
+/// Resolves the artifact cache, honouring the `NVX_BIN` offline override.
+///
+/// The returned boolean is `true` when the caller supplied `NVX_BIN`.
+pub fn resolve_bin_dir(out_dir: &Path) -> io::Result<(PathBuf, bool)> {
+ let prefetched = std::env::var_os("NVX_BIN")
+ .filter(|value| !value.is_empty())
+ .map(PathBuf::from);
+
+ if let Some(dir) = prefetched {
+ if !dir.is_dir() {
+ return Err(io::Error::new(
+ io::ErrorKind::NotFound,
+ format!(
+ "NVX_BIN is set to '{}', but that directory does not exist",
+ dir.display()
+ ),
+ ));
+ }
+ return std::path::absolute(&dir).map(|absolute| (absolute, true));
+ }
+
+ let dir = out_dir.join("nvx-binaries");
+ std::fs::create_dir_all(&dir)?;
+ Ok((dir, false))
+}
+
+/// Copies selected artifacts while preserving their release-relative paths.
+///
+/// Missing artifacts and copy failures are returned as explicit errors. The
+/// destination file is removed after a failed copy so a partial artifact is
+/// never left staged.
+pub fn copy_artifact_paths<'a>(
+ src_dir: &Path,
+ target_dir: &Path,
+ relative_paths: impl IntoIterator,
+) -> io::Result<()> {
+ let relative_paths: Vec<&str> = relative_paths.into_iter().collect();
+ validate_artifact_paths(src_dir, relative_paths.iter().copied())?;
+ copy_validated_artifact_paths(src_dir, target_dir, relative_paths)
+}
+
+fn validate_artifact_paths<'a>(
+ src_dir: &Path,
+ relative_paths: impl IntoIterator,
+) -> io::Result<()> {
+ for relative_path in relative_paths {
+ let source = src_dir.join(relative_path);
+ if !source.is_file() {
+ return Err(io::Error::new(
+ io::ErrorKind::NotFound,
+ format!("required NVX artifact '{}' is missing", source.display()),
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn copy_validated_artifact_paths<'a>(
+ src_dir: &Path,
+ target_dir: &Path,
+ relative_paths: impl IntoIterator,
+) -> io::Result<()> {
+ for relative_path in relative_paths {
+ let source = src_dir.join(relative_path);
+ let destination = target_dir.join(relative_path);
+ let parent = destination.parent().ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::InvalidInput,
+ format!(
+ "NVX artifact '{}' has no destination parent",
+ destination.display()
+ ),
+ )
+ })?;
+ std::fs::create_dir_all(parent)?;
+
+ if let Err(error) = std::fs::copy(&source, &destination) {
+ let _ = std::fs::remove_file(&destination);
+ return Err(io::Error::new(
+ error.kind(),
+ format!(
+ "failed to copy NVX artifact '{}' to '{}': {error}",
+ source.display(),
+ destination.display()
+ ),
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn remove_artifact_paths<'a>(
+ target_dir: &Path,
+ relative_paths: impl IntoIterator,
+) -> io::Result<()> {
+ for relative_path in relative_paths {
+ let destination = target_dir.join(relative_path);
+ match std::fs::remove_file(&destination) {
+ Ok(()) => {}
+ Err(error) if error.kind() == io::ErrorKind::NotFound => {}
+ Err(error) => {
+ return Err(io::Error::new(
+ error.kind(),
+ format!(
+ "failed to remove stale NVX artifact '{}': {error}",
+ destination.display()
+ ),
+ ))
+ }
+ }
+ }
+ Ok(())
+}
+
+/// Stages all artifacts available in the configured release.
+pub fn copy_artifacts_to_target(
+ src_dir: &Path,
+ target_dir: &Path,
+ workload_images_available: bool,
+) -> io::Result<()> {
+ let relative_paths: Vec<&str> =
+ available_artifact_rel_paths(workload_images_available).collect();
+ validate_artifact_paths(src_dir, relative_paths.iter().copied())?;
+
+ if !workload_images_available {
+ remove_artifact_paths(target_dir, WORKLOAD_IMAGE_ARTIFACTS)?;
+ }
+
+ copy_validated_artifact_paths(src_dir, target_dir, relative_paths)
+}
+
+/// Emits Cargo change tracking for every artifact available in this release.
+pub fn emit_rerun_for_artifacts(src_dir: &Path, workload_images_available: bool) {
+ for relative_path in available_artifact_rel_paths(workload_images_available) {
+ println!(
+ "cargo:rerun-if-changed={}",
+ src_dir.join(relative_path).display()
+ );
+ }
+}
+
+/// Stages NVX artifacts beside the consuming executable.
+pub fn stage_artifacts_next_to_exe(nvx_bin_dir: &Path) -> io::Result<()> {
+ let workload_images_available =
+ match std::env::var("DEP_NVX_BINARIES_WORKLOAD_IMAGES_AVAILABLE").as_deref() {
+ Ok("1") => true,
+ Ok("0") => false,
+ Ok(value) => {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!(
+ "DEP_NVX_BINARIES_WORKLOAD_IMAGES_AVAILABLE has invalid value '{value}'"
+ ),
+ ))
+ }
+ Err(std::env::VarError::NotPresent) => {
+ return Err(io::Error::new(
+ io::ErrorKind::NotFound,
+ "DEP_NVX_BINARIES_WORKLOAD_IMAGES_AVAILABLE is not set",
+ ))
+ }
+ Err(error) => {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!("cannot read DEP_NVX_BINARIES_WORKLOAD_IMAGES_AVAILABLE: {error}"),
+ ))
+ }
+ };
+ let out_dir = PathBuf::from(
+ std::env::var_os("OUT_DIR")
+ .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "OUT_DIR is not set"))?,
+ );
+ let target_dir = out_dir
+ .parent()
+ .and_then(Path::parent)
+ .and_then(Path::parent)
+ .ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::InvalidInput,
+ format!(
+ "cannot determine target directory from '{}'",
+ out_dir.display()
+ ),
+ )
+ })?;
+
+ copy_artifacts_to_target(nvx_bin_dir, target_dir, workload_images_available)?;
+ emit_rerun_for_artifacts(nvx_bin_dir, workload_images_available);
+ println!("cargo:rerun-if-env-changed=DEP_NVX_BINARIES_BIN_DIR");
+ println!("cargo:rerun-if-env-changed=DEP_NVX_BINARIES_WORKLOAD_IMAGES_AVAILABLE");
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn write_artifact(root: &Path, relative_path: &str, contents: &[u8]) {
+ let path = root.join(relative_path);
+ std::fs::create_dir_all(path.parent().expect("artifact must have a parent"))
+ .expect("failed to create artifact parent");
+ std::fs::write(path, contents).expect("failed to write artifact");
+ }
+
+ #[test]
+ fn artifact_paths_cover_complete_bundle_in_release_order() {
+ assert_eq!(
+ artifact_rel_paths().collect::>(),
+ vec![
+ (ArtifactKind::Platform, "bin/openvmm.exe"),
+ (ArtifactKind::Platform, "guest/vmlinux"),
+ (ArtifactKind::Platform, "guest/initramfs.cpio.gz"),
+ (ArtifactKind::WorkloadImage, "images/distro.erofs"),
+ (ArtifactKind::WorkloadImage, "images/runtime.erofs"),
+ (ArtifactKind::WorkloadImage, "images/scratch.ext4"),
+ ]
+ );
+ }
+
+ #[test]
+ fn copy_artifacts_preserves_release_layout() {
+ let source = tempfile::tempdir().expect("failed to create source directory");
+ let target = tempfile::tempdir().expect("failed to create target directory");
+ for relative_path in WINDOWS_PLATFORM_ARTIFACTS {
+ write_artifact(source.path(), relative_path, relative_path.as_bytes());
+ }
+ for relative_path in WORKLOAD_IMAGE_ARTIFACTS {
+ write_artifact(target.path(), relative_path, b"stale");
+ }
+
+ copy_artifacts_to_target(source.path(), target.path(), false)
+ .expect("platform-only staging failed");
+
+ for relative_path in WINDOWS_PLATFORM_ARTIFACTS {
+ assert_eq!(
+ std::fs::read(target.path().join(relative_path))
+ .expect("staged artifact is missing"),
+ relative_path.as_bytes()
+ );
+ }
+ for relative_path in WORKLOAD_IMAGE_ARTIFACTS {
+ assert!(!target.path().join(relative_path).exists());
+ }
+ }
+
+ #[test]
+ fn complete_bundle_requires_every_workload_image() {
+ let source = tempfile::tempdir().expect("failed to create source directory");
+ let target = tempfile::tempdir().expect("failed to create target directory");
+ for relative_path in WINDOWS_PLATFORM_ARTIFACTS {
+ write_artifact(source.path(), relative_path, b"platform");
+ }
+ for relative_path in WORKLOAD_IMAGE_ARTIFACTS {
+ write_artifact(target.path(), relative_path, b"existing");
+ }
+
+ let error = copy_artifacts_to_target(source.path(), target.path(), true)
+ .expect_err("missing workload images must fail");
+
+ assert_eq!(error.kind(), io::ErrorKind::NotFound);
+ assert!(error.to_string().contains("images/distro.erofs"));
+ for relative_path in WINDOWS_PLATFORM_ARTIFACTS {
+ assert!(!target.path().join(relative_path).exists());
+ }
+ for relative_path in WORKLOAD_IMAGE_ARTIFACTS {
+ assert_eq!(
+ std::fs::read(target.path().join(relative_path))
+ .expect("failed staging must preserve existing artifacts"),
+ b"existing"
+ );
+ }
+ }
+
+ #[test]
+ fn target_support_only_allows_windows_x86_64_msvc() {
+ assert!(target_supports_nvx("windows", "x86_64", "msvc"));
+ assert!(!target_supports_nvx("windows", "x86_64", "gnu"));
+ assert!(!target_supports_nvx("windows", "aarch64", "msvc"));
+ assert!(!target_supports_nvx("linux", "x86_64", "gnu"));
+ }
+
+ #[test]
+ fn unsupported_target_message_names_supported_triple() {
+ let message = unsupported_target_message("aarch64-pc-windows-msvc");
+
+ assert!(message.contains(SUPPORTED_NVX_TARGET_TRIPLE));
+ assert!(message.contains("aarch64-pc-windows-msvc"));
+ }
+
+ #[test]
+ fn validate_nvx_target_rejects_unsupported_target() {
+ let error = validate_nvx_target("aarch64-pc-windows-msvc", "windows", "aarch64", "msvc")
+ .expect_err("unsupported target must fail closed");
+
+ assert!(error.contains(SUPPORTED_NVX_TARGET_TRIPLE));
+ assert!(error.contains("aarch64-pc-windows-msvc"));
+ }
+
+ #[test]
+ fn validate_nvx_target_rejects_custom_target_with_matching_cfg_values() {
+ let target = "custom-windows-x64-msvc";
+ let error = validate_nvx_target(target, "windows", "x86_64", "msvc")
+ .expect_err("only the exact supported target triple may stage NVX");
+
+ assert!(error.contains(SUPPORTED_NVX_TARGET_TRIPLE));
+ assert!(error.contains(target));
+ }
+
+ #[test]
+ fn staging_decision_uses_target_not_build_host() {
+ assert_eq!(
+ should_stage_nvx("x86_64-pc-windows-msvc", "windows", "x86_64", "msvc"),
+ Ok(true)
+ );
+ assert_eq!(
+ should_stage_nvx("x86_64-unknown-linux-gnu", "linux", "x86_64", "gnu"),
+ Ok(false)
+ );
+
+ for (target, arch, env) in [
+ ("aarch64-pc-windows-msvc", "aarch64", "msvc"),
+ ("x86_64-pc-windows-gnu", "x86_64", "gnu"),
+ ("custom-windows-x64-msvc", "x86_64", "msvc"),
+ ] {
+ let error = should_stage_nvx(target, "windows", arch, env)
+ .expect_err("unsupported Windows targets must fail closed");
+ assert!(error.contains(SUPPORTED_NVX_TARGET_TRIPLE));
+ assert!(error.contains(target));
+ }
+ }
+}
diff --git a/src/backends/nanvix/common/Cargo.toml b/src/backends/nvx/common/Cargo.toml
similarity index 60%
rename from src/backends/nanvix/common/Cargo.toml
rename to src/backends/nvx/common/Cargo.toml
index 264a1c287..3eebd587f 100644
--- a/src/backends/nanvix/common/Cargo.toml
+++ b/src/backends/nvx/common/Cargo.toml
@@ -1,9 +1,9 @@
[package]
-name = "nanvix_common"
+name = "nvx_common"
version.workspace = true
edition.workspace = true
license.workspace = true
-description = "Shared constants and configuration types for NanVix micro-VM binaries"
+description = "Shared constants and release configuration for NVX artifacts"
[dependencies]
serde = { workspace = true }
diff --git a/src/backends/nvx/common/src/lib.rs b/src/backends/nvx/common/src/lib.rs
new file mode 100644
index 000000000..3c162af7f
--- /dev/null
+++ b/src/backends/nvx/common/src/lib.rs
@@ -0,0 +1,84 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Shared artifact paths and release configuration for the NVX backend.
+
+use std::collections::HashMap;
+use std::path::Path;
+
+use serde::Deserialize;
+
+/// Platform artifacts required to launch NVX with OpenVMM on Windows/WHP.
+pub const WINDOWS_PLATFORM_ARTIFACTS: [&str; 3] = [
+ "bin/openvmm.exe",
+ "guest/vmlinux",
+ "guest/initramfs.cpio.gz",
+];
+
+/// Optional workload image artifacts used by a complete NVX bundle.
+pub const WORKLOAD_IMAGE_ARTIFACTS: [&str; 3] = [
+ "images/distro.erofs",
+ "images/runtime.erofs",
+ "images/scratch.ext4",
+];
+
+/// Pinned upstream release configuration loaded from `versions.json`.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
+pub struct ReleaseConfig {
+ /// GitHub repository in `owner/name` form.
+ pub repository: String,
+ /// Published release tag.
+ pub tag: String,
+ /// Windows/WHP platform archive name.
+ pub windows_whp_asset: String,
+ /// Optional workload image archive name.
+ #[serde(default)]
+ pub workload_image_asset: Option,
+}
+
+/// Loads and deserializes a JSON file, failing with path-specific context.
+pub fn load_json(path: &str) -> T {
+ let content = std::fs::read_to_string(Path::new(path))
+ .unwrap_or_else(|error| panic!("nvx_common: failed to read {path}: {error}"));
+ serde_json::from_str(&content)
+ .unwrap_or_else(|error| panic!("nvx_common: failed to parse {path}: {error}"))
+}
+
+/// Loads the artifact checksum map from `checksums.json`.
+pub fn load_checksums(path: &str) -> HashMap {
+ load_json(path)
+}
+
+/// Constructs a deterministic GitHub release asset URL.
+pub fn github_download_url(repository: &str, tag: &str, asset: &str) -> String {
+ format!("https://github.com/{repository}/releases/download/{tag}/{asset}")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn windows_platform_artifacts_match_release_layout() {
+ assert_eq!(
+ WINDOWS_PLATFORM_ARTIFACTS,
+ [
+ "bin/openvmm.exe",
+ "guest/vmlinux",
+ "guest/initramfs.cpio.gz",
+ ]
+ );
+ }
+
+ #[test]
+ fn workload_image_artifacts_match_staged_layout() {
+ assert_eq!(
+ WORKLOAD_IMAGE_ARTIFACTS,
+ [
+ "images/distro.erofs",
+ "images/runtime.erofs",
+ "images/scratch.ext4",
+ ]
+ );
+ }
+}
diff --git a/src/backends/nvx/runner/Cargo.toml b/src/backends/nvx/runner/Cargo.toml
new file mode 100644
index 000000000..59339f05e
--- /dev/null
+++ b/src/backends/nvx/runner/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "nvx_runner"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+description = "NVX backend preflight checks for incomplete runtime builds"
+
+[features]
+default = []
+nvx = ["nvx_binaries/nvx"]
+
+[dependencies]
+nvx_binaries = { workspace = true }
+wxc_common = { workspace = true }
diff --git a/src/backends/nvx/runner/src/lib.rs b/src/backends/nvx/runner/src/lib.rs
new file mode 100644
index 000000000..1cad495f6
--- /dev/null
+++ b/src/backends/nvx/runner/src/lib.rs
@@ -0,0 +1,48 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! NVX backend preflight checks.
+//!
+//! PR1 intentionally does not include an executable NVX runtime runner. This
+//! crate currently provides only typed availability checks that the engine can
+//! call before dispatch.
+
+use wxc_common::mxc_error::MxcError;
+
+/// Typed backend-unavailable message for pinned releases without the NVX
+/// workload-image archive.
+pub const ERR_WORKLOAD_IMAGE_ASSET_UNAVAILABLE: &str =
+ "NVX workload image asset is not available in the pinned release";
+
+/// Verifies whether the pinned NVX release includes workload images required
+/// by the runtime implementation.
+///
+/// The current pinned release does not ship those images, so preflight returns
+/// a typed backend-unavailable error.
+pub fn preflight() -> Result<(), MxcError> {
+ if nvx_binaries::NVX_WORKLOAD_IMAGES_AVAILABLE != "1" {
+ return Err(MxcError::backend_unavailable(
+ ERR_WORKLOAD_IMAGE_ASSET_UNAVAILABLE,
+ ));
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::preflight;
+ use super::ERR_WORKLOAD_IMAGE_ASSET_UNAVAILABLE;
+ use wxc_common::mxc_error::MxcErrorCode;
+
+ #[test]
+ fn pinned_release_marks_workload_images_unavailable() {
+ assert_eq!(nvx_binaries::NVX_WORKLOAD_IMAGES_AVAILABLE, "0");
+ }
+
+ #[test]
+ fn preflight_returns_typed_backend_unavailable_error() {
+ let err = preflight().expect_err("preflight must fail in PR1");
+ assert_eq!(err.code, MxcErrorCode::BackendUnavailable);
+ assert_eq!(err.message, ERR_WORKLOAD_IMAGE_ASSET_UNAVAILABLE);
+ }
+}
diff --git a/src/core/lxc/Cargo.toml b/src/core/lxc/Cargo.toml
index 6b0b692d7..05bcb33ae 100644
--- a/src/core/lxc/Cargo.toml
+++ b/src/core/lxc/Cargo.toml
@@ -10,11 +10,9 @@ path = "src/main.rs"
[features]
hyperlight = ["dep:hyperlight_common", "hyperlight_common/hyperlight", "mxc_engine/hyperlight"]
-microvm = ["dep:nanvix_binaries", "nanvix_binaries/microvm", "mxc_engine/microvm"]
[build-dependencies]
mxc_build_common.workspace = true
-nanvix_build_common = { path = "../../backends/nanvix/build_common" }
[dependencies]
wxc_common = { workspace = true }
@@ -22,7 +20,5 @@ mxc_engine = { workspace = true }
lxc_common = { workspace = true }
anyhow = { workspace = true }
clap = { workspace = true }
-nanvix_binaries = { path = "../../backends/nanvix/binaries", optional = true }
-
[target.'cfg(target_arch = "x86_64")'.dependencies]
hyperlight_common = { workspace = true, optional = true }
diff --git a/src/core/lxc/build.rs b/src/core/lxc/build.rs
index 1d5d5da99..d0f33bcf2 100644
--- a/src/core/lxc/build.rs
+++ b/src/core/lxc/build.rs
@@ -1,16 +1,11 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-//! Build script for lxc — embeds Windows VersionInfo (no-op on non-Windows)
-//! and copies NanVix binaries next to the output executable when the
-//! `microvm` feature is enabled.
+//! Build script for lxc — embeds Windows VersionInfo (no-op on non-Windows).
fn main() {
mxc_build_common::embed_version_info("LXC container executor (Linux stub)", "lxc-exec.exe");
- #[cfg(all(target_os = "linux", feature = "microvm"))]
- copy_nanvix_binaries();
-
// Delay-load winhvplatform.dll so WHP-less hosts don't crash before main().
// CARGO_CFG_TARGET_* (not #[cfg]) because build.rs cfg gates are host, not target.
#[cfg(feature = "hyperlight")]
@@ -25,21 +20,3 @@ fn main() {
println!("cargo:rerun-if-changed=build.rs");
}
-
-#[cfg(all(target_os = "linux", feature = "microvm"))]
-fn copy_nanvix_binaries() {
- use std::path::Path;
-
- let nanvix_bin_dir = match std::env::var("DEP_NANVIX_BINARIES_BIN_DIR") {
- Ok(dir) => dir,
- Err(_) => {
- eprintln!("lxc build.rs: DEP_NANVIX_BINARIES_BIN_DIR not set, skipping copy");
- return;
- }
- };
-
- // Stage the artifacts next to the executable and emit rerun triggers. All
- // of the staging logic (target-dir derivation, snapshot trust, copy/purge,
- // rerun emission) lives in the build-only `nanvix_build_common` crate.
- nanvix_build_common::stage_artifacts_next_to_exe(Path::new(&nanvix_bin_dir));
-}
diff --git a/src/core/lxc/src/main.rs b/src/core/lxc/src/main.rs
index 182857a3c..27bada0e1 100644
--- a/src/core/lxc/src/main.rs
+++ b/src/core/lxc/src/main.rs
@@ -272,7 +272,7 @@ fn main() {
// Dispatch by containment backend. Backend selection and runner
// construction — Bubblewrap (the Linux default for abstract intents), LXC
// (explicit `containment: "lxc"`, plus the catch-all for anything else such
- // as `processcontainer`), and the experimental Hyperlight / MicroVM
+ // as `processcontainer`), and the experimental Hyperlight
// backends — live in `mxc_engine::run`, the single home for one-shot backend
// dispatch. It runs the selected backend to completion and returns the
// response; experimental backends that require `--experimental` (or that
diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md
index 6e6f01594..338156587 100644
--- a/src/core/mxc-sdk/README.md
+++ b/src/core/mxc-sdk/README.md
@@ -517,9 +517,10 @@ dropping the handle tears the session down synchronously rather than in the
background. Reach its multi-call lifecycle through
`run_state_aware_json` plus `exec_attached` or `exec_sandbox`.
-Backends with no variant at all — Windows Sandbox, MicroVM, and Hyperlight —
-cannot be named from this crate; use the executor binaries. Windows Sandbox is
-still reachable here through the state-aware lifecycle.
+Backends with no variant at all — Windows Sandbox, MicroVM (implemented by
+NVX), and Hyperlight — cannot be named from this crate; use the executor
+binaries. Windows Sandbox is still reachable here through the state-aware
+lifecycle.
`Containment::Lxc` models explicit LXC distribution settings, but `run` and
`spawn_sandbox` reject it because the LXC backend does not expose captured
diff --git a/src/core/mxc_config_contract/src/dev/one_shot.rs b/src/core/mxc_config_contract/src/dev/one_shot.rs
index e1d9d96ed..af045b560 100644
--- a/src/core/mxc_config_contract/src/dev/one_shot.rs
+++ b/src/core/mxc_config_contract/src/dev/one_shot.rs
@@ -26,12 +26,12 @@ string_enum! {
/// Select the macOS Seatbelt backend.
Seatbelt => ["seatbelt", "macos_sandbox"],
- // Development-only values.
+ // Additional values carried by the development contract.
/// Select the host's VM-class containment backend.
Vm => ["vm"],
/// Select the Windows Sandbox backend.
WindowsSandbox => ["windows_sandbox"],
- /// Select the NanVix micro-VM backend.
+ /// Select the MicroVM backend implemented by NVX.
Microvm => ["microvm"],
/// Select the Hyperlight micro-VM backend.
Hyperlight => ["hyperlight"],
diff --git a/src/core/mxc_config_contract/src/published/v0_9_0_alpha/one_shot.rs b/src/core/mxc_config_contract/src/published/v0_9_0_alpha/one_shot.rs
index 2e2b1838a..10172163e 100644
--- a/src/core/mxc_config_contract/src/published/v0_9_0_alpha/one_shot.rs
+++ b/src/core/mxc_config_contract/src/published/v0_9_0_alpha/one_shot.rs
@@ -25,6 +25,8 @@ string_enum! {
Bubblewrap => ["bubblewrap"],
/// Select the macOS Seatbelt backend.
Seatbelt => ["seatbelt", "macos_sandbox"],
+ /// Select the MicroVM backend implemented by NVX.
+ Microvm => ["microvm"],
/// Select the Windows IsolationSession backend.
IsolationSession => ["isolation_session"],
diff --git a/src/core/mxc_config_contract/tests/v0_10_0_alpha/enums.rs b/src/core/mxc_config_contract/tests/v0_10_0_alpha/enums.rs
index 586725221..f3cc8a027 100644
--- a/src/core/mxc_config_contract/tests/v0_10_0_alpha/enums.rs
+++ b/src/core/mxc_config_contract/tests/v0_10_0_alpha/enums.rs
@@ -42,6 +42,17 @@ fn rejects_invalid_containment_value() {
);
}
+#[test]
+fn rejects_internal_nvx_containment_value() {
+ assert_invalid(
+ r#"{
+ "version": "0.10.0-alpha",
+ "containment": "nvx",
+ "process": {"commandLine": "echo"}
+ }"#,
+ );
+}
+
#[test]
fn rejects_every_removed_default_network_policy_value() {
for default_network_policy in ["allow", "block"] {
diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/enums.rs b/src/core/mxc_config_contract/tests/v0_9_0_alpha/enums.rs
index 8c392c0c1..3c9b58762 100644
--- a/src/core/mxc_config_contract/tests/v0_9_0_alpha/enums.rs
+++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/enums.rs
@@ -12,6 +12,7 @@ fn accepts_every_containment_value() {
"lxc",
"bubblewrap",
"seatbelt",
+ "microvm",
"isolation_session",
"wslc",
] {
@@ -38,6 +39,17 @@ fn rejects_invalid_containment_value() {
);
}
+#[test]
+fn rejects_internal_nvx_containment_value() {
+ assert_invalid(
+ r#"{
+ "version": "0.9.0-alpha",
+ "containment": "nvx",
+ "process": {"commandLine": "echo"}
+ }"#,
+ );
+}
+
#[test]
fn rejects_every_removed_default_network_policy_value() {
for default_network_policy in ["allow", "block"] {
diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/fixtures/one_shot/invalid/nvx_internal_name.json b/src/core/mxc_config_contract/tests/v0_9_0_alpha/fixtures/one_shot/invalid/nvx_internal_name.json
new file mode 100644
index 000000000..12b59e65c
--- /dev/null
+++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/fixtures/one_shot/invalid/nvx_internal_name.json
@@ -0,0 +1,7 @@
+{
+ "version": "0.9.0-alpha",
+ "containment": "nvx",
+ "process": {
+ "commandLine": "echo nvx"
+ }
+}
diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/fixtures/one_shot/valid/microvm.json b/src/core/mxc_config_contract/tests/v0_9_0_alpha/fixtures/one_shot/valid/microvm.json
new file mode 100644
index 000000000..d1eb04778
--- /dev/null
+++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/fixtures/one_shot/valid/microvm.json
@@ -0,0 +1,7 @@
+{
+ "version": "0.9.0-alpha",
+ "containment": "microvm",
+ "process": {
+ "commandLine": "echo microvm"
+ }
+}
diff --git a/src/core/mxc_config_contract/tests/version_boundaries/containment.rs b/src/core/mxc_config_contract/tests/version_boundaries/containment.rs
index fee1fa3c2..e98f4f593 100644
--- a/src/core/mxc_config_contract/tests/version_boundaries/containment.rs
+++ b/src/core/mxc_config_contract/tests/version_boundaries/containment.rs
@@ -13,9 +13,14 @@ fn wslc_containment_is_introduced_in_v09() {
assert_v09_introduces(r#""containment": "wslc""#);
}
+#[test]
+fn microvm_containment_is_introduced_in_v09() {
+ assert_v09_introduces(r#""containment": "microvm""#);
+}
+
#[test]
fn development_containment_values_are_introduced_in_v010() {
- for containment in ["vm", "windows_sandbox", "microvm", "hyperlight"] {
+ for containment in ["vm", "windows_sandbox", "hyperlight"] {
assert_v10_introduces(&format!(r#""containment": "{containment}""#));
}
}
diff --git a/src/core/mxc_engine/Cargo.toml b/src/core/mxc_engine/Cargo.toml
index 7a46d6a79..0b1da9684 100644
--- a/src/core/mxc_engine/Cargo.toml
+++ b/src/core/mxc_engine/Cargo.toml
@@ -14,13 +14,13 @@ wxc_common.workspace = true
mxc_config_contract.workspace = true
serde = { workspace = true }
serde_json = { workspace = true }
-# MicroVM runner — used by the Windows and Linux run-to-completion bodies under
-# the `microvm` feature. Never built on hosts/features that don't select it.
-nanvix_runner = { workspace = true, optional = true }
[target.'cfg(target_arch = "x86_64")'.dependencies]
hyperlight_common = { workspace = true, optional = true }
+[target.'cfg(all(target_os = "windows", target_arch = "x86_64"))'.dependencies]
+nvx_runner = { workspace = true, optional = true }
+
[target.'cfg(target_os = "windows")'.dependencies]
process_container_common = { workspace = true }
windows_sandbox_lifecycle = { workspace = true }
@@ -50,9 +50,8 @@ seatbelt_common = { workspace = true }
default = []
# Enables constructing the run-to-completion runner for optional backends.
# Mirrors the executor binaries' feature set so backend selection can live in
-# one place. The build-time binary-staging that some backends also need (e.g.
-# NanVix's `nanvix_binaries`) stays with the executor crates.
-microvm = ["dep:nanvix_runner"]
+# one place. Build-time binary staging remains with the executor crates.
+microvm = ["dep:nvx_runner", "nvx_runner/nvx"]
wslc = ["dep:wslc_common", "wslc_common/link-wslcsdk"]
hyperlight = ["dep:hyperlight_common", "hyperlight_common/hyperlight"]
isolation_session = [
diff --git a/src/core/mxc_engine/src/dispatch.rs b/src/core/mxc_engine/src/dispatch.rs
index 99ea31761..84c9a0059 100644
--- a/src/core/mxc_engine/src/dispatch.rs
+++ b/src/core/mxc_engine/src/dispatch.rs
@@ -15,11 +15,12 @@
//! run-to-completion path via `process_container_common::dispatcher`), Bubblewrap
//! (Linux), Seatbelt (macOS), WSLC, and IsolationSession (Windows,
//! behind the `wslc` and `isolation_session` features). Every other backend —
-//! including the remaining experimental ones (Windows Sandbox, MicroVM,
-//! Hyperlight) and LXC (no streaming path suitable for the library) — returns
-//! [`MxcError::unsupported_containment`]; callers that need those must drive the
-//! standalone executor binaries (whose run-to-completion path will, in a later
-//! increment, also route through this engine).
+//! including the remaining experimental ones (Windows Sandbox, MicroVM
+//! implemented by NVX, and Hyperlight) and LXC (no streaming path suitable for
+//! the library) — returns [`MxcError::unsupported_containment`]; callers that
+//! need those must drive the standalone executor binaries (whose
+//! run-to-completion path will, in a later increment, also route through this
+//! engine).
use wxc_common::logger::Logger;
use wxc_common::models::{ContainmentBackend, ExecutionRequest, ScriptResponse};
diff --git a/src/core/mxc_engine/src/platform.rs b/src/core/mxc_engine/src/platform.rs
index 4b9a029f6..9b86de018 100644
--- a/src/core/mxc_engine/src/platform.rs
+++ b/src/core/mxc_engine/src/platform.rs
@@ -237,7 +237,7 @@ mod tests {
(ContainmentBackend::Wslc, "wslc"),
(ContainmentBackend::Lxc, "lxc"),
(ContainmentBackend::Vm, "vm"),
- (ContainmentBackend::MicroVm, "microvm"),
+ (ContainmentBackend::Microvm, "microvm"),
(ContainmentBackend::Hyperlight, "hyperlight"),
(ContainmentBackend::WindowsSandbox, "windows_sandbox"),
(ContainmentBackend::IsolationSession, "isolation_session"),
@@ -310,6 +310,29 @@ mod tests {
}
}
+ fn assert_microvm_method_is_omitted() {
+ let support = platform_support();
+ assert!(
+ support
+ .available_methods
+ .iter()
+ .all(|method| method != "microvm"),
+ "platform_support must not advertise MicroVM while runtime is incomplete"
+ );
+ }
+
+ #[cfg(not(feature = "microvm"))]
+ #[test]
+ fn microvm_is_not_advertised_without_feature() {
+ assert_microvm_method_is_omitted();
+ }
+
+ #[cfg(feature = "microvm")]
+ #[test]
+ fn microvm_is_not_advertised_with_feature_enabled() {
+ assert_microvm_method_is_omitted();
+ }
+
#[cfg(target_os = "linux")]
#[test]
fn linux_support_reports_bubblewrap_when_probe_succeeds() {
diff --git a/src/core/mxc_engine/src/probe.rs b/src/core/mxc_engine/src/probe.rs
index 7fab5565f..1781a72bb 100644
--- a/src/core/mxc_engine/src/probe.rs
+++ b/src/core/mxc_engine/src/probe.rs
@@ -276,7 +276,7 @@ mod tests {
(ContainmentBackend::Wslc, "wslc"),
(ContainmentBackend::Lxc, "lxc"),
(ContainmentBackend::Vm, "vm"),
- (ContainmentBackend::MicroVm, "microvm"),
+ (ContainmentBackend::Microvm, "microvm"),
(ContainmentBackend::Hyperlight, "hyperlight"),
(ContainmentBackend::WindowsSandbox, "windows_sandbox"),
(ContainmentBackend::IsolationSession, "isolation_session"),
@@ -408,6 +408,26 @@ mod tests {
}
}
+ fn assert_microvm_backend_is_omitted() {
+ assert!(
+ available_backends()
+ .iter()
+ .all(|backend| backend.backend != "microvm"),
+ "available_backends must not advertise MicroVM while runtime is incomplete"
+ );
+ }
+
+ #[cfg(not(feature = "microvm"))]
+ #[test]
+ fn microvm_is_not_advertised_without_feature() {
+ assert_microvm_backend_is_omitted();
+ }
+
+ #[cfg(feature = "microvm")]
+ #[test]
+ fn microvm_is_not_advertised_with_feature_enabled() {
+ assert_microvm_backend_is_omitted();
+ }
#[test]
fn every_reported_tier_is_a_canonical_tier_string() {
for entry in available_backends() {
diff --git a/src/core/mxc_engine/src/run.rs b/src/core/mxc_engine/src/run.rs
index baf8ed5c7..ab9dca78e 100644
--- a/src/core/mxc_engine/src/run.rs
+++ b/src/core/mxc_engine/src/run.rs
@@ -35,6 +35,23 @@ use wxc_common::script_runner::ScriptRunner;
use crate::error::Error;
+#[cfg(target_os = "windows")]
+const ERR_MICROVM_EXPERIMENTAL_OPT_IN_REQUIRED: &str =
+ "MicroVM (NVX) is an experimental feature. Use --experimental flag.";
+#[cfg(all(
+ not(feature = "microvm"),
+ target_os = "windows",
+ target_arch = "x86_64"
+))]
+const ERR_MICROVM_FEATURE_REQUIRED: &str =
+ "MicroVM backend not compiled in (build with --features microvm)";
+#[cfg(all(feature = "microvm", target_os = "windows", target_arch = "x86_64"))]
+const ERR_MICROVM_RUNTIME_IMPLEMENTATION_MISSING: &str =
+ "MicroVM (NVX) runtime implementation is not present in this build";
+#[cfg(all(target_os = "windows", not(target_arch = "x86_64")))]
+const ERR_MICROVM_ARCHITECTURE_UNSUPPORTED: &str =
+ "MicroVM (NVX) requires Windows x64; this Windows architecture is unsupported";
+
/// A backend runner resolved for an [`ExecutionRequest`], ready to run.
///
/// On Windows, `dacl_manager` — when present — is the guard for the
@@ -289,25 +306,7 @@ fn resolve_runner_inner_windows(
ContainmentBackend::Vm => Err(MxcError::unsupported_containment(
"VM backend not yet implemented",
)),
- ContainmentBackend::MicroVm => {
- if !request.experimental_enabled {
- return Err(MxcError::malformed_request(
- "MicroVM is an experimental feature. Use --experimental flag.",
- ));
- }
- #[cfg(feature = "microvm")]
- {
- Ok(ResolvedRunner::without_guard(Box::new(
- nanvix_runner::NanVixScriptRunner::new(),
- )))
- }
- #[cfg(not(feature = "microvm"))]
- {
- Err(MxcError::unsupported_containment(
- "MicroVM backend not compiled in (build with --features microvm)",
- ))
- }
- }
+ ContainmentBackend::Microvm => resolve_microvm_backend(request),
ContainmentBackend::Hyperlight => resolve_hyperlight(request),
ContainmentBackend::WindowsSandbox => {
if !request.experimental_enabled {
@@ -350,7 +349,7 @@ fn resolve_runner_inner_windows(
}
// ---------------------------------------------------------------------------
-// Linux: Bubblewrap, LXC, and the experimental Hyperlight / MicroVM backends.
+// Linux: Bubblewrap, LXC, and the experimental Hyperlight backend.
// A concrete backend selected for another host must fail closed.
// ---------------------------------------------------------------------------
@@ -363,25 +362,6 @@ fn resolve_runner_inner(
match request.containment {
ContainmentBackend::Hyperlight => resolve_hyperlight(request),
- ContainmentBackend::MicroVm => {
- if !request.experimental_enabled {
- return Err(MxcError::malformed_request(
- "MicroVM is an experimental feature. Use --experimental flag.",
- ));
- }
- #[cfg(feature = "microvm")]
- {
- Ok(ResolvedRunner::without_guard(Box::new(
- nanvix_runner::NanVixScriptRunner::new(),
- )))
- }
- #[cfg(not(feature = "microvm"))]
- {
- Err(MxcError::unsupported_containment(
- "MicroVM backend not compiled in (build with --features microvm)",
- ))
- }
- }
ContainmentBackend::Bubblewrap => Ok(ResolvedRunner::without_guard(Box::new(Runner::new(
bwrap_common::bwrap_runner::BubblewrapScriptRunner::new(),
)))),
@@ -446,6 +426,30 @@ mod linux_tests {
);
assert!(error.message.contains("seatbelt"));
}
+
+ #[test]
+ fn microvm_is_rejected_as_unavailable_on_linux() {
+ let request = ExecutionRequest {
+ containment: ContainmentBackend::Microvm,
+ experimental_enabled: true,
+ ..Default::default()
+ };
+ let mut logger = Logger::new(Mode::Buffer);
+
+ let error = match resolve_runner_inner(&request, &mut logger) {
+ Ok(_) => panic!("NVX-backed MicroVM must remain Windows-only"),
+ Err(error) => error,
+ };
+
+ assert_eq!(
+ error.code,
+ wxc_common::mxc_error::MxcErrorCode::UnsupportedContainment
+ );
+ assert_eq!(
+ error.message,
+ "the 'microvm' backend is not available on Linux"
+ );
+ }
}
#[cfg(all(test, target_os = "macos"))]
@@ -472,6 +476,30 @@ mod macos_tests {
);
assert!(error.message.contains("lxc"));
}
+
+ #[test]
+ fn microvm_is_rejected_as_unavailable_on_macos() {
+ let request = ExecutionRequest {
+ containment: ContainmentBackend::Microvm,
+ experimental_enabled: true,
+ ..Default::default()
+ };
+ let mut logger = Logger::new(Mode::Buffer);
+
+ let error = match resolve_runner_inner(&request, &mut logger) {
+ Ok(_) => panic!("NVX-backed MicroVM must remain Windows-only"),
+ Err(error) => error,
+ };
+
+ assert_eq!(
+ error.code,
+ wxc_common::mxc_error::MxcErrorCode::UnsupportedContainment
+ );
+ assert_eq!(
+ error.message,
+ "the 'microvm' backend is not available on macOS"
+ );
+ }
}
// ---------------------------------------------------------------------------
@@ -489,6 +517,45 @@ fn resolve_runner_inner(
))
}
+#[cfg(target_os = "windows")]
+fn resolve_microvm_backend(request: &ExecutionRequest) -> Result {
+ if !request.experimental_enabled {
+ return Err(MxcError::malformed_request(
+ ERR_MICROVM_EXPERIMENTAL_OPT_IN_REQUIRED,
+ ));
+ }
+
+ #[cfg(all(feature = "microvm", target_arch = "x86_64"))]
+ {
+ resolve_microvm_backend_with_preflight(nvx_runner::preflight)
+ }
+
+ #[cfg(all(not(feature = "microvm"), target_arch = "x86_64"))]
+ {
+ Err(MxcError::unsupported_containment(
+ ERR_MICROVM_FEATURE_REQUIRED,
+ ))
+ }
+
+ #[cfg(not(target_arch = "x86_64"))]
+ {
+ Err(MxcError::unsupported_containment(
+ ERR_MICROVM_ARCHITECTURE_UNSUPPORTED,
+ ))
+ }
+}
+
+#[cfg(all(feature = "microvm", target_os = "windows", target_arch = "x86_64"))]
+fn resolve_microvm_backend_with_preflight(preflight: F) -> Result
+where
+ F: FnOnce() -> Result<(), MxcError>,
+{
+ preflight()?;
+ Err(MxcError::backend_unavailable(
+ ERR_MICROVM_RUNTIME_IMPLEMENTATION_MISSING,
+ ))
+}
+
/// Construct the Hyperlight runner, shared by the Windows and Linux bodies.
/// Requires x86_64 (Hyperlight needs KVM or WHP) and the `hyperlight` feature.
/// On Windows, pre-checks that `winhvplatform.dll` is loadable so a missing
@@ -529,6 +596,7 @@ mod tests {
use super::*;
use wxc_common::logger::Mode;
use wxc_common::models::WindowsSandboxConfig;
+ use wxc_common::mxc_error::MxcErrorCode;
fn windows_sandbox_request(config: Option) -> ExecutionRequest {
ExecutionRequest {
@@ -539,6 +607,14 @@ mod tests {
}
}
+ fn microvm_request(experimental_enabled: bool) -> ExecutionRequest {
+ ExecutionRequest {
+ containment: ContainmentBackend::Microvm,
+ experimental_enabled,
+ ..Default::default()
+ }
+ }
+
#[test]
fn policy_hash_identity_matches_runner_identity_rules() {
assert_eq!(policy_hash_identity(""), "CLI");
@@ -591,4 +667,78 @@ mod tests {
assert!(!logger.get_buffer().contains("experimental"));
}
+
+ #[test]
+ fn microvm_without_experimental_opt_in_is_rejected_as_malformed_request() {
+ let request = microvm_request(false);
+ let mut logger = Logger::new(Mode::Buffer);
+
+ let err = match resolve_runner_inner_windows(&request, &mut logger) {
+ Ok(_) => panic!("expected malformed_request"),
+ Err(err) => err,
+ };
+
+ assert_eq!(err.code, MxcErrorCode::MalformedRequest);
+ assert_eq!(err.message, ERR_MICROVM_EXPERIMENTAL_OPT_IN_REQUIRED);
+ }
+
+ #[cfg(all(not(feature = "microvm"), target_arch = "x86_64"))]
+ #[test]
+ fn microvm_without_feature_returns_typed_unsupported_containment() {
+ let request = microvm_request(true);
+ let mut logger = Logger::new(Mode::Buffer);
+
+ let err = match resolve_runner_inner_windows(&request, &mut logger) {
+ Ok(_) => panic!("expected unsupported_containment"),
+ Err(err) => err,
+ };
+
+ assert_eq!(err.code, MxcErrorCode::UnsupportedContainment);
+ assert_eq!(err.message, ERR_MICROVM_FEATURE_REQUIRED);
+ }
+
+ #[cfg(all(feature = "microvm", target_arch = "x86_64"))]
+ #[test]
+ fn microvm_with_feature_propagates_preflight_backend_unavailable() {
+ let request = microvm_request(true);
+ let mut logger = Logger::new(Mode::Buffer);
+
+ let err = match resolve_runner_inner_windows(&request, &mut logger) {
+ Ok(_) => panic!("expected backend_unavailable"),
+ Err(err) => err,
+ };
+
+ assert_eq!(err.code, MxcErrorCode::BackendUnavailable);
+ assert_eq!(
+ err.message,
+ nvx_runner::ERR_WORKLOAD_IMAGE_ASSET_UNAVAILABLE
+ );
+ }
+
+ #[cfg(all(feature = "microvm", target_arch = "x86_64"))]
+ #[test]
+ fn microvm_without_runtime_returns_backend_unavailable_even_if_preflight_succeeds() {
+ let err = match resolve_microvm_backend_with_preflight(|| Ok(())) {
+ Ok(_) => panic!("runtime stub must not resolve a runner"),
+ Err(err) => err,
+ };
+
+ assert_eq!(err.code, MxcErrorCode::BackendUnavailable);
+ assert_eq!(err.message, ERR_MICROVM_RUNTIME_IMPLEMENTATION_MISSING);
+ }
+
+ #[cfg(not(target_arch = "x86_64"))]
+ #[test]
+ fn microvm_is_rejected_on_unsupported_windows_architecture() {
+ let request = microvm_request(true);
+ let mut logger = Logger::new(Mode::Buffer);
+
+ let err = match resolve_runner_inner_windows(&request, &mut logger) {
+ Ok(_) => panic!("expected unsupported_containment"),
+ Err(err) => err,
+ };
+
+ assert_eq!(err.code, MxcErrorCode::UnsupportedContainment);
+ assert_eq!(err.message, ERR_MICROVM_ARCHITECTURE_UNSUPPORTED);
+ }
}
diff --git a/src/core/wxc/Cargo.toml b/src/core/wxc/Cargo.toml
index a3beaea0d..56c7b35e9 100644
--- a/src/core/wxc/Cargo.toml
+++ b/src/core/wxc/Cargo.toml
@@ -21,7 +21,7 @@ hyperlight_common = { workspace = true, optional = true }
[target.'cfg(windows)'.dependencies]
process_container_common = { workspace = true }
-nanvix_binaries = { path = "../../backends/nanvix/binaries", optional = true }
+nvx_binaries = { workspace = true, optional = true }
wslc_common = { workspace = true, optional = true }
# Shared PLM dep: coordination, guarded lifecycle, and restricted WPR control.
# wxc-exec never bypasses PLM's public singleton.
@@ -31,14 +31,17 @@ tempfile.workspace = true
[build-dependencies]
mxc_build_common.workspace = true
-
-[target.'cfg(windows)'.build-dependencies]
-nanvix_build_common = { path = "../../backends/nanvix/build_common" }
+nvx_build_common = { workspace = true, optional = true }
[features]
default = []
test-support = ["wxc_common/test-support"]
-microvm = ["dep:nanvix_binaries", "nanvix_binaries/microvm", "mxc_engine/microvm"]
+microvm = [
+ "dep:nvx_binaries",
+ "nvx_binaries/nvx",
+ "dep:nvx_build_common",
+ "mxc_engine/microvm",
+]
wslc = ["dep:wslc_common", "wslc_common/link-wslcsdk", "mxc_engine/wslc"]
hyperlight = ["dep:hyperlight_common", "hyperlight_common/hyperlight", "mxc_engine/hyperlight"]
isolation_session = ["mxc_engine/isolation_session"]
diff --git a/src/core/wxc/build.rs b/src/core/wxc/build.rs
index 28cc26f04..685abd089 100644
--- a/src/core/wxc/build.rs
+++ b/src/core/wxc/build.rs
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-//! Build script for wxc — embeds Windows VersionInfo and copies NanVix binaries.
+//! Build script for wxc — embeds Windows VersionInfo and stages backend artifacts.
fn main() {
mxc_build_common::embed_version_info("MXC sandbox executor", "wxc-exec.exe");
@@ -9,8 +9,8 @@ fn main() {
#[cfg(windows)]
check_test_prerequisites();
- #[cfg(all(windows, feature = "microvm"))]
- copy_nanvix_binaries();
+ #[cfg(feature = "microvm")]
+ stage_nvx_for_target();
// Delay-load winhvplatform.dll so WHP-less hosts don't crash before main().
// CARGO_CFG_TARGET_* (not #[cfg]) because build.rs cfg gates are host, not target.
@@ -79,20 +79,31 @@ fn check_test_prerequisites() {
}
}
-#[cfg(all(windows, feature = "microvm"))]
-fn copy_nanvix_binaries() {
+#[cfg(feature = "microvm")]
+fn stage_nvx_for_target() {
+ let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
+ let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
+ let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default();
+
+ let target = std::env::var("TARGET").expect("wxc build.rs: TARGET is not set by Cargo");
+ let should_stage =
+ nvx_build_common::should_stage_nvx(&target, &target_os, &target_arch, &target_env)
+ .unwrap_or_else(|error| panic!("wxc build.rs: {error}"));
+ if should_stage {
+ copy_nvx_binaries();
+ }
+}
+
+#[cfg(feature = "microvm")]
+fn copy_nvx_binaries() {
use std::path::Path;
- let nanvix_bin_dir = match std::env::var("DEP_NANVIX_BINARIES_BIN_DIR") {
- Ok(dir) => dir,
- Err(_) => {
- eprintln!("wxc build.rs: DEP_NANVIX_BINARIES_BIN_DIR not set, skipping copy");
- return;
- }
- };
+ let nvx_bin_dir = std::env::var("DEP_NVX_BINARIES_BIN_DIR").unwrap_or_else(|error| {
+ panic!(
+ "wxc build.rs: DEP_NVX_BINARIES_BIN_DIR is required for the microvm feature: {error}"
+ )
+ });
- // Stage the artifacts next to the executable and emit rerun triggers. All
- // of the staging logic (target-dir derivation, snapshot trust, copy/purge,
- // rerun emission) lives in the build-only `nanvix_build_common` crate.
- nanvix_build_common::stage_artifacts_next_to_exe(Path::new(&nanvix_bin_dir));
+ nvx_build_common::stage_artifacts_next_to_exe(Path::new(&nvx_bin_dir))
+ .unwrap_or_else(|error| panic!("wxc build.rs: failed to stage NVX artifacts: {error}"));
}
diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs
index 47a48cc1a..43961d0e3 100644
--- a/src/core/wxc/src/main.rs
+++ b/src/core/wxc/src/main.rs
@@ -2022,7 +2022,7 @@ mod tests {
ContainmentBackend::WindowsSandbox,
ContainmentBackend::Wslc,
ContainmentBackend::IsolationSession,
- ContainmentBackend::MicroVm,
+ ContainmentBackend::Microvm,
] {
let containment_name = containment.wire_name();
request.containment = containment;
@@ -2578,6 +2578,18 @@ mod tests {
assert_eq!(command_override, "echo 'safe&whoami'");
}
+ #[test]
+ fn microvm_cli_command_uses_posix_shell_quoting() {
+ let cli = parse_cli(&["wxc-exec", "policy.json", "--", "echo", "safe&whoami"]);
+ let command_override = cmdline_from_argv_for_context(
+ &cli.command,
+ CommandLineContext::for_backend(&ContainmentBackend::Microvm),
+ )
+ .unwrap();
+
+ assert_eq!(command_override, "echo 'safe&whoami'");
+ }
+
#[test]
fn state_aware_exec_cli_command_overrides_policy_command_line() {
let argv = &["wxc-exec", "policy.json", "--", "echo", "hi"];
diff --git a/src/core/wxc_common/Cargo.toml b/src/core/wxc_common/Cargo.toml
index f06efc1e6..ea91ac535 100644
--- a/src/core/wxc_common/Cargo.toml
+++ b/src/core/wxc_common/Cargo.toml
@@ -5,7 +5,6 @@ edition.workspace = true
license.workspace = true
[features]
-microvm = ["dep:nanvix_common", "dep:uuid"]
# Exposes telemetry test harnesses to downstream integration tests.
test-support = []
@@ -20,8 +19,6 @@ base64 = { workspace = true }
cidr = { workspace = true }
url = { workspace = true }
getrandom = { workspace = true }
-nanvix_common = { path = "../../backends/nanvix/common", optional = true }
-uuid = { workspace = true, optional = true }
mxc_config_contract = { workspace = true }
mxc_telemetry = { workspace = true }
diff --git a/src/core/wxc_common/src/cmdline.rs b/src/core/wxc_common/src/cmdline.rs
index 4eeb79d76..456c8aed5 100644
--- a/src/core/wxc_common/src/cmdline.rs
+++ b/src/core/wxc_common/src/cmdline.rs
@@ -45,8 +45,8 @@ impl CommandLineContext {
| ContainmentBackend::Bubblewrap => Self::PosixShell,
ContainmentBackend::ProcessContainer
| ContainmentBackend::Vm
- | ContainmentBackend::MicroVm
| ContainmentBackend::Hyperlight => Self::WindowsCreateProcess,
+ ContainmentBackend::Microvm => Self::PosixShell,
}
}
}
@@ -381,6 +381,14 @@ mod tests {
);
}
+ #[test]
+ fn microvm_backend_uses_posix_shell_context() {
+ assert_eq!(
+ CommandLineContext::for_backend(&ContainmentBackend::Microvm),
+ CommandLineContext::PosixShell
+ );
+ }
+
#[test]
fn context_renderer_rejects_null_bytes() {
let err = cmdline_from_argv_for_context(
diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs
index 7b2918b29..06d2a2e67 100644
--- a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs
+++ b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs
@@ -151,14 +151,14 @@ const DEVELOPMENT_CONTAINMENT_CASES: &[DevelopmentContainmentCase] = &[
input: "vm",
expected: "vm",
},
- DevelopmentContainmentCase {
- input: "windows_sandbox",
- expected: "windows_sandbox",
- },
DevelopmentContainmentCase {
input: "microvm",
expected: "microvm",
},
+ DevelopmentContainmentCase {
+ input: "windows_sandbox",
+ expected: "windows_sandbox",
+ },
DevelopmentContainmentCase {
input: "hyperlight",
expected: "hyperlight",
diff --git a/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot.rs b/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot.rs
index b0f09d25d..3189416af 100644
--- a/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot.rs
+++ b/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot.rs
@@ -14,6 +14,7 @@ fn convert_containment(value: contract::OneShotContainment) -> wire::Containment
contract::OneShotContainment::Lxc => wire::Containment::Lxc,
contract::OneShotContainment::Bubblewrap => wire::Containment::Bubblewrap,
contract::OneShotContainment::Seatbelt => wire::Containment::Seatbelt,
+ contract::OneShotContainment::Microvm => wire::Containment::Microvm,
contract::OneShotContainment::IsolationSession => wire::Containment::IsolationSession,
contract::OneShotContainment::Wslc => wire::Containment::Wslc,
}
diff --git a/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot_tests/microvm.rs b/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot_tests/microvm.rs
new file mode 100644
index 000000000..52eeadb66
--- /dev/null
+++ b/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot_tests/microvm.rs
@@ -0,0 +1,22 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use super::{contract, into_common_request_ir, wire};
+
+#[test]
+fn microvm_maps_to_the_public_wire_identity() {
+ let request = serde_json::from_str::(
+ r#"{
+ "version": "0.9.0-alpha",
+ "containment": "microvm",
+ "process": {"commandLine": "echo hello"}
+ }"#,
+ )
+ .unwrap();
+
+ let adapted = into_common_request_ir(request);
+ assert!(matches!(
+ adapted.containment,
+ Some(wire::Containment::Microvm)
+ ));
+}
diff --git a/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot_tests/mod.rs b/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot_tests/mod.rs
index c92c2eae2..8d8e99983 100644
--- a/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot_tests/mod.rs
+++ b/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot_tests/mod.rs
@@ -5,5 +5,6 @@ use super::{contract, into_common_request_ir, wire};
mod common;
mod isolation_session;
+mod microvm;
mod stable_candidate;
mod wslc;
diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs
index a16fd25eb..ac36849f8 100644
--- a/src/core/wxc_common/src/config_parser.rs
+++ b/src/core/wxc_common/src/config_parser.rs
@@ -2272,6 +2272,36 @@ mod tests {
}
}
+ #[test]
+ fn containment_microvm_accepted() {
+ let json = r#"{
+ "version": "0.9.0-alpha",
+ "process": {"commandLine": "/bin/true"},
+ "containment": "microvm"
+ }"#;
+
+ let request = parse_exact_for_test(json).unwrap();
+ let MxcRequest::OneShot(request) = request else {
+ panic!("expected one-shot MicroVM request");
+ };
+ assert_eq!(request.containment, ContainmentBackend::Microvm);
+ }
+
+ #[test]
+ fn containment_nvx_gets_normal_unknown_enum_rejection() {
+ let json = r#"{
+ "version": "0.9.0-alpha",
+ "process": {"commandLine": "/bin/true"},
+ "containment": "nvx"
+ }"#;
+
+ let error = parse_exact_for_test(json)
+ .expect_err("internal NVX implementation name must not be accepted");
+ assert!(matches!(error, ParseError::OneShot(_)));
+ assert!(error.message().contains("unknown variant `nvx`"));
+ assert!(!error.message().contains("use"));
+ }
+
struct DevelopmentStateAwareRootCase {
name: &'static str,
json: &'static str,
diff --git a/src/core/wxc_common/src/lib.rs b/src/core/wxc_common/src/lib.rs
index adf4e7a3d..0594b33d5 100644
--- a/src/core/wxc_common/src/lib.rs
+++ b/src/core/wxc_common/src/lib.rs
@@ -18,8 +18,6 @@ pub mod filesystem_resolve;
pub mod id;
pub mod log_symbols;
pub mod logger;
-#[cfg(all(feature = "microvm", any(target_os = "windows", target_os = "linux")))]
-pub mod microvm_staging;
pub mod models;
pub mod mxc_error;
pub mod network_blocks;
diff --git a/src/core/wxc_common/src/microvm_staging.rs b/src/core/wxc_common/src/microvm_staging.rs
deleted file mode 100644
index 48618fc30..000000000
--- a/src/core/wxc_common/src/microvm_staging.rs
+++ /dev/null
@@ -1,1102 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-//! MicroVM staging directory builder for mount-based script delivery.
-
-use std::fs;
-use std::path::{Path, PathBuf};
-use std::time::{Duration, SystemTime};
-
-use thiserror::Error;
-use uuid::Uuid;
-
-/// Entry point filename (warm-start protocol executes this automatically).
-pub const BOOTSTRAP_FILENAME: &str = "bootstrap.py";
-/// Subdirectory for read-write staged host paths.
-pub const RW_DIR: &str = "rw";
-/// Subdirectory for read-only staged host paths.
-pub const RO_DIR: &str = "ro";
-/// Guest mount root inside the NanVix VM.
-const GUEST_MOUNT_ROOT: &str = "/mnt";
-
-/// Builds the guest-visible path for a staged host directory.
-fn build_guest_path(category: &str, name: &str) -> String {
- format!("{}/{}/{}", GUEST_MOUNT_ROOT, category, name)
-}
-
-/// Preamble prepended to the user script in bootstrap.py.
-///
-/// Composed from [`GUEST_MOUNT_ROOT`] and [`BOOTSTRAP_FILENAME`] so that
-/// any future change to either constant flows through automatically.
-fn bootstrap_preamble() -> String {
- format!(
- "import sys\nsys.argv = ['{}/{}']\n",
- GUEST_MOUNT_ROOT, BOOTSTRAP_FILENAME
- )
-}
-
-/// Errors produced while creating or validating a staging directory.
-#[derive(Debug, Error)]
-pub enum StagingError {
- /// A requested host path does not exist.
- #[error("host path does not exist: {0}")]
- PathNotFound(String),
- /// A symlink was found in a source path.
- #[error("symlink found in source path: {0}")]
- SymlinkFound(String),
- /// I/O failure.
- #[error("staging I/O error: {0}")]
- Io(#[from] std::io::Error),
-}
-
-/// Intermediate result from the staging build closure.
-struct StagingBuildOutput {
- /// Read-write directory mappings for copyback.
- rw_mappings: Vec,
- /// Total bytes written to the staging directory.
- size_bytes: u64,
-}
-
-/// Return type of the staging build closure.
-type StagingBuildResult = Result;
-
-/// Whether the original host path was a file or directory.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum HostPathKind {
- File,
- Directory,
-}
-
-/// Tracks the relationship between a host path, its staged copy, and its type.
-#[derive(Debug)]
-struct RwMapping {
- host_path: PathBuf,
- staged_path: PathBuf,
- kind: HostPathKind,
-}
-
-/// RAII wrapper over a temporary staging directory.
-#[derive(Debug)]
-pub struct StagingDir {
- path: PathBuf,
- rw_mappings: Vec,
- size_bytes: u64,
- /// When true, `Drop` skips cleanup so the staging dir can be recovered.
- preserve: bool,
-}
-
-impl StagingDir {
- /// Creates and populates a staging directory under `root`.
- pub fn new(
- root: PathBuf,
- script: &str,
- readwrite_paths: &[String],
- readonly_paths: &[String],
- ) -> Result {
- // Validate source paths up front (existence, no symlinks/reparse points,
- // no `..` components). Validation is done once here; per-entry
- // symlink/reparse rejection in `copy_dir_recursive` closes the TOCTOU
- // window between validation and copy, so the per-source loops below
- // don't need to re-validate.
- for source in readwrite_paths.iter().chain(readonly_paths.iter()) {
- validate_source_path(Path::new(source), source)?;
- }
-
- fs::create_dir_all(&root)?;
- let path = root.join(format!("mxc-staging-{}", Uuid::new_v4().simple()));
- fs::create_dir_all(&path)?;
-
- let build_result = || -> StagingBuildResult {
- // Collect host→guest path mappings for script rewriting.
- let mut rewrite_map: Vec<(String, String)> = Vec::new();
- let mut rw_mappings: Vec = Vec::new();
- // Track staged bytes incrementally to avoid a full directory walk
- // at the end (which scaled with total staged content size).
- let mut size_bytes: u64 = 0;
-
- if !readwrite_paths.is_empty() {
- fs::create_dir_all(path.join(RW_DIR))?;
- }
- for source in readwrite_paths {
- let host_path = PathBuf::from(source);
-
- let relative = host_path_to_guest_relative(&host_path);
- let slot_dir = path.join(RW_DIR).join(&relative);
- let (kind, bytes) = stage_host_path(&host_path, &slot_dir)?;
- size_bytes = size_bytes.saturating_add(bytes);
- let guest_path = build_guest_path(RW_DIR, &relative);
- rewrite_map.push((source.clone(), guest_path));
- rw_mappings.push(RwMapping {
- host_path,
- staged_path: slot_dir,
- kind,
- });
- }
-
- if !readonly_paths.is_empty() {
- fs::create_dir_all(path.join(RO_DIR))?;
- }
- for source in readonly_paths {
- let host_path = PathBuf::from(source);
-
- let relative = host_path_to_guest_relative(&host_path);
- let slot_dir = path.join(RO_DIR).join(&relative);
- let bytes = if host_path.is_dir() {
- copy_dir_recursive(&host_path, &slot_dir)?
- } else {
- fs::create_dir_all(&slot_dir)?;
- let file_name = host_path
- .file_name()
- .ok_or_else(|| StagingError::PathNotFound(source.clone()))?;
- fs::copy(&host_path, slot_dir.join(file_name))?
- };
- size_bytes = size_bytes.saturating_add(bytes);
- set_readonly_recursive(&slot_dir)?;
- let guest_path = build_guest_path(RO_DIR, &relative);
- rewrite_map.push((source.clone(), guest_path));
- }
-
- // Rewrite host paths in the user script so callers don't need to
- // know about the guest mount layout. Both backslash and forward-slash
- // variants of each host path are replaced.
- let rewritten_script = rewrite_paths_in_script(script, &rewrite_map);
- let bootstrap_content = format!("{}{}", bootstrap_preamble(), rewritten_script);
- fs::write(path.join(BOOTSTRAP_FILENAME), &bootstrap_content)?;
- size_bytes = size_bytes.saturating_add(bootstrap_content.len() as u64);
-
- Ok(StagingBuildOutput {
- rw_mappings,
- size_bytes,
- })
- }();
-
- let StagingBuildOutput {
- rw_mappings,
- size_bytes,
- } = match build_result {
- Ok(result) => result,
- Err(err) => {
- let _ = remove_dir_all_force(&path);
- return Err(err);
- }
- };
-
- Ok(Self {
- path,
- rw_mappings,
- size_bytes,
- preserve: false,
- })
- }
-
- /// Returns the host path to the staging directory.
- pub fn path(&self) -> &Path {
- &self.path
- }
-
- /// Copies all read-write staged paths back to their original host locations.
- /// Attempts all mappings even if one fails; returns the first error encountered.
- /// On any failure, marks the staging dir as preserved so `Drop` won't delete it.
- pub fn copy_back_to_host(&mut self) -> Result<(), StagingError> {
- let mut first_err: Option = None;
- // Preserve before starting — if any copy fails, staging must survive Drop.
- self.preserve = true;
- for mapping in &self.rw_mappings {
- if let Err(e) = copy_back_mapping(mapping) {
- if first_err.is_none() {
- first_err = Some(e);
- }
- }
- }
- if first_err.is_none() {
- // All copies succeeded — allow normal cleanup on Drop.
- self.preserve = false;
- }
- first_err.map_or(Ok(()), Err)
- }
-
- /// Returns the staging directory path (useful for recovery messages).
- pub fn preserved_path(&self) -> Option<&Path> {
- if self.preserve {
- Some(&self.path)
- } else {
- None
- }
- }
-
- /// Returns additional staging overhead in milliseconds.
- pub fn staging_overhead_ms(&self) -> u64 {
- let ms = ((self.size_bytes as f64 / (1024.0 * 1024.0)) * 100.0) as u64;
- ms.min(30_000)
- }
-}
-
-impl Drop for StagingDir {
- fn drop(&mut self) {
- if self.preserve {
- return;
- }
- let _ = remove_dir_all_force(&self.path);
- }
-}
-
-/// Converts a host path to a guest-relative path by stripping the drive letter prefix
-/// and normalizing separators. E.g. `C:\Users\me\work` → `c/Users/me/work`.
-fn host_path_to_guest_relative(host_path: &Path) -> String {
- let s = host_path.to_string_lossy();
- // Strip drive letter prefix (e.g. "C:\") and normalize to forward slashes.
- let stripped = if s.len() >= 3
- && s.as_bytes()[1] == b':'
- && (s.as_bytes()[2] == b'\\' || s.as_bytes()[2] == b'/')
- {
- let drive = s.as_bytes()[0].to_ascii_lowercase() as char;
- format!("{}/{}", drive, s[3..].replace('\\', "/"))
- } else {
- // UNC or relative path — just normalize slashes.
- s.replace('\\', "/")
- };
- // Trim leading and trailing slashes to ensure the result is always relative.
- // On Linux, absolute paths like `/tmp/xyz` become `tmp/xyz`.
- stripped
- .trim_start_matches('/')
- .trim_end_matches('/')
- .to_string()
-}
-
-/// Replaces host paths in the script source with their guest mount equivalents.
-/// Both backslash (`C:\Users\work`) and forward-slash (`C:/Users/work`) variants
-/// are replaced. Longer paths are replaced first to avoid partial prefix matches.
-fn rewrite_paths_in_script(script: &str, mappings: &[(String, String)]) -> String {
- let mut result = script.to_string();
- // Sort by host path length descending so longer prefixes match first.
- let mut sorted: Vec<_> = mappings.to_vec();
- sorted.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
- for (host_path, guest_path) in &sorted {
- // Replace escaped backslash variant first (Python string literals: C:\\Users\\work).
- let escaped = host_path.replace('\\', "\\\\");
- if escaped != *host_path {
- result = result.replace(&escaped, guest_path);
- }
- // Replace native backslash variant (C:\Users\work).
- result = result.replace(host_path, guest_path);
- // Replace forward-slash variant (C:/Users/work).
- let forward = host_path.replace('\\', "/");
- if forward != *host_path {
- result = result.replace(&forward, guest_path);
- }
- }
- result
-}
-
-/// Stages a single host path in a target slot directory using a private copy.
-/// Returns the host-path kind and the total number of bytes copied.
-fn stage_host_path(host_path: &Path, slot_dir: &Path) -> Result<(HostPathKind, u64), StagingError> {
- if host_path.is_dir() {
- let bytes = copy_dir_recursive(host_path, slot_dir)?;
- return Ok((HostPathKind::Directory, bytes));
- }
-
- fs::create_dir_all(slot_dir)?;
- let file_name = host_path
- .file_name()
- .ok_or_else(|| StagingError::PathNotFound(host_path.display().to_string()))?;
- let bytes = fs::copy(host_path, slot_dir.join(file_name))?;
- Ok((HostPathKind::File, bytes))
-}
-
-/// Copies staged RW content back to the original host path.
-fn copy_back_mapping(mapping: &RwMapping) -> Result<(), StagingError> {
- match mapping.kind {
- HostPathKind::Directory => mirror_directory(&mapping.staged_path, &mapping.host_path),
- HostPathKind::File => {
- let file_name = mapping.host_path.file_name().ok_or_else(|| {
- StagingError::PathNotFound(mapping.host_path.display().to_string())
- })?;
- let staged_file = mapping.staged_path.join(file_name);
- fs::copy(staged_file, &mapping.host_path)?;
- Ok(())
- }
- }
-}
-
-/// Replaces the destination directory with the source directory contents.
-/// Uses a rename-based backup to avoid permanent data loss if the copy fails mid-way.
-fn mirror_directory(src: &Path, dst: &Path) -> Result<(), StagingError> {
- // Build a sibling backup path on the same volume as dst — rename is atomic.
- // Include the PID to avoid collision with stale backups from prior interrupted runs.
- let backup = dst.with_extension(format!("__mxc_bak_{}", std::process::id()));
- // Clean up any pre-existing backup with the same name (e.g. from a previous run
- // of this process that was interrupted between the rename and cleanup).
- if backup.exists() {
- let _ = remove_dir_all_force(&backup);
- }
- if dst.exists() {
- fs::rename(dst, &backup)?;
- }
- match copy_dir_recursive(src, dst) {
- Ok(_) => {
- // Copy succeeded — best-effort removal of the backup.
- if backup.exists() {
- let _ = remove_dir_all_force(&backup);
- }
- Ok(())
- }
- Err(e) => {
- // Copy failed — attempt to restore the backup to the original path.
- if backup.exists() {
- if dst.exists() {
- let _ = remove_dir_all_force(dst);
- }
- let _ = fs::rename(&backup, dst);
- }
- Err(e)
- }
- }
-}
-
-/// Copies a directory recursively. Returns the total number of bytes copied so
-/// callers can track staging size without an extra full-tree walk afterward.
-/// Uses `symlink_metadata` per entry to reject symlinks/reparse points during
-/// traversal, closing the TOCTOU window between upfront validation and the actual copy.
-fn copy_dir_recursive(src: &Path, dst: &Path) -> Result {
- fs::create_dir_all(dst)?;
- let mut total: u64 = 0;
- for entry in fs::read_dir(src)? {
- let entry = entry?;
- let metadata = fs::symlink_metadata(entry.path())?;
- if metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
- return Err(StagingError::SymlinkFound(
- entry.path().display().to_string(),
- ));
- }
- let target = dst.join(entry.file_name());
- if metadata.file_type().is_dir() {
- total = total.saturating_add(copy_dir_recursive(&entry.path(), &target)?);
- } else {
- total = total.saturating_add(fs::copy(entry.path(), target)?);
- }
- }
- Ok(total)
-}
-
-/// Sets the read-only attribute recursively for all files in `dir`.
-fn set_readonly_recursive(dir: &Path) -> Result<(), StagingError> {
- if dir.is_file() {
- let mut perms = fs::metadata(dir)?.permissions();
- perms.set_readonly(true);
- fs::set_permissions(dir, perms)?;
- return Ok(());
- }
-
- for entry in fs::read_dir(dir)? {
- let entry = entry?;
- let path = entry.path();
- if path.is_dir() {
- set_readonly_recursive(&path)?;
- } else {
- let mut perms = fs::metadata(&path)?.permissions();
- perms.set_readonly(true);
- fs::set_permissions(&path, perms)?;
- }
- }
- Ok(())
-}
-
-/// Validates that a source host path exists, has a filename, and contains no reparse points.
-fn validate_source_path(path: &Path, original: &str) -> Result<(), StagingError> {
- if !path.exists() {
- return Err(StagingError::PathNotFound(original.to_string()));
- }
- if path.file_name().is_none() {
- return Err(StagingError::PathNotFound(format!(
- "root paths are not supported for microvm filesystem staging: {}",
- original
- )));
- }
- // Reject paths with `..` components to prevent path-traversal attacks that
- // could write outside the staging directory (e.g., `C:\a\..\b`).
- for component in path.components() {
- if matches!(component, std::path::Component::ParentDir) {
- return Err(StagingError::PathNotFound(format!(
- "paths with '..' components are not supported: {}",
- original
- )));
- }
- }
- check_no_reparse_points(path)
-}
-
-/// Ensures no symlink or Windows reparse point is present in `path` or descendants.
-fn check_no_reparse_points(path: &Path) -> Result<(), StagingError> {
- let metadata = fs::symlink_metadata(path)?;
- if metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
- return Err(StagingError::SymlinkFound(path.display().to_string()));
- }
-
- if metadata.is_dir() {
- for entry in fs::read_dir(path)? {
- check_no_reparse_points(&entry?.path())?;
- }
- }
-
- Ok(())
-}
-
-#[cfg(target_os = "windows")]
-fn is_reparse_point(metadata: &fs::Metadata) -> bool {
- use std::os::windows::fs::MetadataExt;
- use windows::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT;
- metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0
-}
-
-#[cfg(not(target_os = "windows"))]
-fn is_reparse_point(_metadata: &fs::Metadata) -> bool {
- false
-}
-
-/// Removes a directory tree, clearing read-only attributes first.
-#[allow(clippy::permissions_set_readonly_false)]
-fn remove_dir_all_force(path: &Path) -> Result<(), StagingError> {
- if !path.exists() {
- return Ok(());
- }
- clear_readonly_recursive(path)?;
- fs::remove_dir_all(path)?;
- Ok(())
-}
-
-#[allow(clippy::permissions_set_readonly_false)]
-fn clear_readonly_recursive(path: &Path) -> Result<(), StagingError> {
- if path.is_file() {
- let mut perms = fs::metadata(path)?.permissions();
- if perms.readonly() {
- perms.set_readonly(false);
- fs::set_permissions(path, perms)?;
- }
- return Ok(());
- }
-
- for entry in fs::read_dir(path)? {
- clear_readonly_recursive(&entry?.path())?;
- }
-
- let mut perms = fs::metadata(path)?.permissions();
- if perms.readonly() {
- perms.set_readonly(false);
- fs::set_permissions(path, perms)?;
- }
- Ok(())
-}
-
-/// Removes orphaned `mxc-staging-*` directories under `root` that are older than `max_age`.
-/// Called at the start of each run to prevent temp dir accumulation on process crash.
-pub fn sweep_orphaned_staging_dirs(root: &Path, max_age: Duration) {
- let Ok(entries) = fs::read_dir(root) else {
- return;
- };
- let now = SystemTime::now();
- for entry in entries.flatten() {
- let name = entry.file_name();
- let name_str = name.to_string_lossy();
- if !name_str.starts_with("mxc-staging-") || !entry.path().is_dir() {
- continue;
- }
- let is_old = entry
- .metadata()
- .ok()
- .and_then(|m| m.modified().ok())
- .and_then(|modified| now.duration_since(modified).ok())
- .map(|age| age >= max_age)
- .unwrap_or(false);
- if is_old {
- let _ = remove_dir_all_force(&entry.path());
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use tempfile::tempdir;
-
- fn write_file(path: &Path, content: &str) {
- fs::write(path, content).unwrap();
- }
-
- /// Helper: compute the staged directory path for a host path.
- fn staged_rw(staging: &StagingDir, host_path: &Path) -> PathBuf {
- staging
- .path()
- .join(RW_DIR)
- .join(host_path_to_guest_relative(host_path))
- }
-
- #[test]
- fn staging_creates_bootstrap() {
- let root = tempdir().unwrap();
- let script = "print('hello')";
- let staging = StagingDir::new(root.path().to_path_buf(), script, &[], &[]).unwrap();
-
- let bootstrap = staging.path().join(BOOTSTRAP_FILENAME);
- assert!(bootstrap.exists());
- let content = fs::read_to_string(bootstrap).unwrap();
- assert!(content.starts_with(&bootstrap_preamble()));
- assert!(content.contains(script));
- }
-
- #[test]
- fn staging_empty_policy() {
- let root = tempdir().unwrap();
- let staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &[]).unwrap();
- assert!(!staging.path().join(RW_DIR).exists());
- assert!(!staging.path().join(RO_DIR).exists());
- }
-
- #[test]
- fn staging_single_rw_path() {
- let root = tempdir().unwrap();
- let source_root = tempdir().unwrap();
- let source = source_root.path().join("sample");
- fs::create_dir_all(&source).unwrap();
- write_file(&source.join("data.txt"), "abc");
-
- let host_path = source.display().to_string();
- let script = format!("open('{}')", host_path);
- let rw = vec![host_path.clone()];
- let staging = StagingDir::new(root.path().to_path_buf(), &script, &rw, &[]).unwrap();
- let guest_rel = host_path_to_guest_relative(&PathBuf::from(&host_path));
- assert!(staging.path().join(RW_DIR).join(&guest_rel).exists());
- // Verify the script was rewritten with the guest path.
- let rewritten = fs::read_to_string(staging.path().join(BOOTSTRAP_FILENAME)).unwrap();
- let expected_guest = build_guest_path(RW_DIR, &guest_rel);
- assert!(
- rewritten.contains(&expected_guest),
- "expected guest path in rewritten script, got: {}",
- rewritten
- );
- }
-
- #[test]
- fn staging_ro_path_has_readonly_attribute() {
- let root = tempdir().unwrap();
- let source_root = tempdir().unwrap();
- let source = source_root.path().join("readonly");
- fs::create_dir_all(&source).unwrap();
- write_file(&source.join("data.txt"), "abc");
-
- let ro = vec![source.display().to_string()];
- let staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &ro).unwrap();
- let guest_rel = host_path_to_guest_relative(&source);
- let staged_file = staging
- .path()
- .join(RO_DIR)
- .join(&guest_rel)
- .join("data.txt");
- let metadata = fs::metadata(staged_file).unwrap();
- assert!(metadata.permissions().readonly());
- }
-
- #[test]
- fn staging_two_rw_paths_get_distinct_guest_dirs() {
- let root = tempdir().unwrap();
- let source_root = tempdir().unwrap();
- let first = source_root.path().join("input");
- let second_parent = source_root.path().join("other");
- let second = second_parent.join("input");
- fs::create_dir_all(&first).unwrap();
- fs::create_dir_all(&second).unwrap();
-
- let rw = vec![first.display().to_string(), second.display().to_string()];
- let staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &[]).unwrap();
- // Full path mirroring means both exist at distinct paths.
- let rel1 = host_path_to_guest_relative(&first);
- let rel2 = host_path_to_guest_relative(&second);
- assert!(staging.path().join(RW_DIR).join(&rel1).exists());
- assert!(staging.path().join(RW_DIR).join(&rel2).exists());
- assert_ne!(rel1, rel2);
- }
-
- #[test]
- fn staging_script_rewrite_replaces_host_paths() {
- let root = tempdir().unwrap();
- let source_root = tempdir().unwrap();
- let source = source_root.path().join("sample");
- fs::create_dir_all(&source).unwrap();
-
- let host_path = source.display().to_string();
- let forward_path = host_path.replace('\\', "/");
- let script = format!("a = '{}'\nb = '{}'", host_path, forward_path);
- let rw = vec![host_path.clone()];
- let staging = StagingDir::new(root.path().to_path_buf(), &script, &rw, &[]).unwrap();
-
- let rewritten = fs::read_to_string(staging.path().join(BOOTSTRAP_FILENAME)).unwrap();
- let guest_rel = host_path_to_guest_relative(&PathBuf::from(&host_path));
- let expected_guest = build_guest_path(RW_DIR, &guest_rel);
- assert!(
- rewritten.contains(&expected_guest),
- "expected guest path in rewritten script, got: {}",
- rewritten
- );
- // On Windows, verify the forward-slash variant was replaced too.
- // On Linux, host_path == forward_path and the guest path contains
- // the original as a substring (/mnt/rw/tmp/xyz contains /tmp/xyz),
- // so we verify replacement by counting occurrences of the guest path.
- #[cfg(target_os = "windows")]
- assert!(
- !rewritten.contains(&forward_path),
- "forward-slash host path should have been replaced"
- );
- #[cfg(target_os = "linux")]
- {
- // Both occurrences in the script (a='...' and b='...') should
- // have been replaced with the guest path.
- let count = rewritten.matches(&expected_guest).count();
- assert!(
- count >= 2,
- "expected at least 2 occurrences of guest path, got {}: {}",
- count,
- rewritten
- );
- }
- }
-
- #[test]
- fn staging_bootstrap_is_stable() {
- let root = tempdir().unwrap();
- let a = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &[]).unwrap();
- let b = StagingDir::new(root.path().to_path_buf(), "print(2)", &[], &[]).unwrap();
-
- let left = fs::read_to_string(a.path().join(BOOTSTRAP_FILENAME)).unwrap();
- let right = fs::read_to_string(b.path().join(BOOTSTRAP_FILENAME)).unwrap();
- // The preamble (loader boilerplate) must be identical regardless of script content.
- let preamble = bootstrap_preamble();
- assert!(left.starts_with(&preamble));
- assert!(right.starts_with(&preamble));
- let left_preamble = &left[..preamble.len()];
- let right_preamble = &right[..preamble.len()];
- assert_eq!(left_preamble, right_preamble);
- }
-
- #[test]
- fn staging_cleanup_on_drop() {
- let root = tempdir().unwrap();
- let path_to_check = {
- let staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &[]).unwrap();
- let p = staging.path().to_path_buf();
- assert!(p.exists());
- p
- };
- assert!(!path_to_check.exists());
- }
-
- #[test]
- fn staging_missing_path_returns_error() {
- let root = tempdir().unwrap();
- let rw = vec![root.path().join("missing").display().to_string()];
- let err = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &[]).unwrap_err();
- assert!(matches!(err, StagingError::PathNotFound(_)));
- }
-
- #[test]
- fn staging_single_file_rw_wrapped_in_slot() {
- let root = tempdir().unwrap();
- let source_root = tempdir().unwrap();
- let source_file = source_root.path().join("payload");
- write_file(&source_file, "data");
-
- let rw = vec![source_file.display().to_string()];
- let staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &[]).unwrap();
- let staged_file = staged_rw(&staging, &source_file).join("payload");
- assert!(staged_file.exists());
- }
-
- #[test]
- fn host_path_to_guest_relative_strips_drive() {
- let p = PathBuf::from(r"C:\Users\me\work");
- assert_eq!(host_path_to_guest_relative(&p), "c/Users/me/work");
- }
-
- #[test]
- fn host_path_to_guest_relative_normalizes_slashes() {
- let p = PathBuf::from(r"D:\data\ref-data");
- assert_eq!(host_path_to_guest_relative(&p), "d/data/ref-data");
- }
-
- #[test]
- fn staging_rw_directory_is_private_until_copyback() {
- let root = tempdir().unwrap();
- let source_root = tempdir().unwrap();
- let source = source_root.path().join("work");
- fs::create_dir_all(&source).unwrap();
- write_file(&source.join("data.txt"), "before");
-
- let rw = vec![source.display().to_string()];
- let mut staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &[]).unwrap();
- let staged_file = staged_rw(&staging, &source).join("data.txt");
-
- // Mutate the staged copy — original must remain unchanged.
- fs::write(&staged_file, "after").unwrap();
- assert_eq!(
- fs::read_to_string(source.join("data.txt")).unwrap(),
- "before"
- );
-
- // After explicit copyback, original should reflect the staged changes.
- staging.copy_back_to_host().unwrap();
- assert_eq!(
- fs::read_to_string(source.join("data.txt")).unwrap(),
- "after"
- );
- }
-
- #[test]
- fn staging_rw_file_copyback_updates_original_file() {
- let root = tempdir().unwrap();
- let source_root = tempdir().unwrap();
- let source_file = source_root.path().join("payload.txt");
- write_file(&source_file, "before");
-
- let rw = vec![source_file.display().to_string()];
- let mut staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &[]).unwrap();
- let staged_file = staged_rw(&staging, &source_file).join("payload.txt");
-
- fs::write(&staged_file, "after").unwrap();
- assert_eq!(fs::read_to_string(&source_file).unwrap(), "before");
-
- staging.copy_back_to_host().unwrap();
- assert_eq!(fs::read_to_string(&source_file).unwrap(), "after");
- }
-
- #[test]
- fn staging_rw_directory_copyback_mirrors_deletions() {
- let root = tempdir().unwrap();
- let source_root = tempdir().unwrap();
- let source = source_root.path().join("work");
- fs::create_dir_all(&source).unwrap();
- write_file(&source.join("kept.txt"), "before");
- write_file(&source.join("deleted.txt"), "remove me");
-
- let rw = vec![source.display().to_string()];
- let mut staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &[]).unwrap();
- let staged_dir = staged_rw(&staging, &source);
-
- fs::remove_file(staged_dir.join("deleted.txt")).unwrap();
- fs::write(staged_dir.join("kept.txt"), "after").unwrap();
- fs::write(staged_dir.join("created.txt"), "new").unwrap();
-
- staging.copy_back_to_host().unwrap();
-
- assert_eq!(
- fs::read_to_string(source.join("kept.txt")).unwrap(),
- "after"
- );
- assert_eq!(
- fs::read_to_string(source.join("created.txt")).unwrap(),
- "new"
- );
- assert!(!source.join("deleted.txt").exists());
- }
-
- #[test]
- fn staging_preserve_on_copyback_failure() {
- let root = tempdir().unwrap();
- let source_root = tempdir().unwrap();
- let source = source_root.path().join("work");
- fs::create_dir_all(&source).unwrap();
- write_file(&source.join("data.txt"), "original");
-
- let rw = vec![source.display().to_string()];
- let mut staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &[]).unwrap();
- let staging_path = staging.path().to_path_buf();
-
- // Delete the staged directory to make copyback fail.
- fs::remove_dir_all(staging.path().join(RW_DIR)).unwrap();
-
- let result = staging.copy_back_to_host();
- assert!(result.is_err(), "expected copyback error");
-
- // The staging dir must still exist (preserve=true) so the user can inspect it.
- assert!(
- staging_path.exists(),
- "staging dir must be preserved on copyback failure"
- );
-
- // The original host directory is unchanged.
- assert_eq!(
- fs::read_to_string(source.join("data.txt")).unwrap(),
- "original"
- );
- }
-
- #[test]
- fn staging_allows_large_content() {
- // Verify that staging succeeds for large source content (~64 MB).
- let root = tempdir().unwrap();
- let source_root = tempdir().unwrap();
- let source = source_root.path().join("big");
- fs::create_dir_all(&source).unwrap();
- // Sparse file ~64 MB. Sparse so we don't actually consume the disk
- // space on test runners.
- let big_file = source.join("large.bin");
- let big_size: u64 = 64 * 1024 * 1024;
- {
- let f = fs::File::create(&big_file).unwrap();
- f.set_len(big_size).unwrap();
- }
-
- let rw = vec![source.display().to_string()];
- let staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &[])
- .expect("staging should succeed for large content");
-
- // The large file was staged into the RW slot.
- let rel = host_path_to_guest_relative(&source);
- let staged = staging.path().join(RW_DIR).join(&rel).join("large.bin");
- assert!(staged.exists(), "expected staged file at {staged:?}");
- assert_eq!(fs::metadata(&staged).unwrap().len(), big_size);
- }
-
- #[test]
- #[allow(clippy::permissions_set_readonly_false)]
- fn staging_readonly_paths_not_copied_back() {
- let root = tempdir().unwrap();
- let source_root = tempdir().unwrap();
- let source = source_root.path().join("reference");
- fs::create_dir_all(&source).unwrap();
- write_file(&source.join("data.txt"), "original");
-
- let ro = vec![source.display().to_string()];
- let mut staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &ro).unwrap();
-
- // Mutate the staged read-only copy.
- let guest_rel = host_path_to_guest_relative(&source);
- let staged_file = staging
- .path()
- .join(RO_DIR)
- .join(&guest_rel)
- .join("data.txt");
- // Clear read-only flag so we can write to the staged copy.
- let mut perms = fs::metadata(&staged_file).unwrap().permissions();
- perms.set_readonly(false);
- fs::set_permissions(&staged_file, perms).unwrap();
- fs::write(&staged_file, "mutated").unwrap();
-
- // copy_back_to_host only copies RW mappings — RO should NOT be copied back.
- staging.copy_back_to_host().unwrap();
- assert_eq!(
- fs::read_to_string(source.join("data.txt")).unwrap(),
- "original",
- "read-only paths must not be copied back to host"
- );
- }
-
- #[test]
- fn sweep_removes_old_staging_dirs() {
- let root = tempdir().unwrap();
- let old_dir = root.path().join("mxc-staging-aabbccdd");
- let fresh_dir = root.path().join("mxc-staging-11223344");
- let unrelated = root.path().join("other-dir");
- fs::create_dir_all(&old_dir).unwrap();
- fs::create_dir_all(&fresh_dir).unwrap();
- fs::create_dir_all(&unrelated).unwrap();
-
- // Backdate the old_dir modification time via a workaround: zero-age threshold
- // sweeps everything older than 0 seconds (all of them qualify in CI).
- sweep_orphaned_staging_dirs(root.path(), Duration::from_secs(0));
-
- // Both staging dirs should be removed; unrelated must stay.
- assert!(!old_dir.exists(), "old staging dir should be swept");
- assert!(!fresh_dir.exists(), "staging dir should be swept at age 0");
- assert!(unrelated.exists(), "unrelated dir must not be swept");
- }
-
- #[test]
- fn staging_rejects_path_with_parent_dir_component() {
- let root = tempdir().unwrap();
- let source = root.path().join("legit");
- fs::create_dir_all(&source).unwrap();
- // Construct a path with `..` to attempt traversal.
- let traversal = source.join("..").join("legit");
- let rw = vec![traversal.display().to_string()];
- let err = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &[]).unwrap_err();
- assert!(
- matches!(err, StagingError::PathNotFound(ref msg) if msg.contains("..")),
- "expected PathNotFound with '..' mention, got: {err}"
- );
- }
-
- #[test]
- fn staging_mixed_rw_and_ro_paths() {
- let root = tempdir().unwrap();
- let source_root = tempdir().unwrap();
- let rw_dir = source_root.path().join("editable");
- let ro_dir = source_root.path().join("reference");
- fs::create_dir_all(&rw_dir).unwrap();
- fs::create_dir_all(&ro_dir).unwrap();
- write_file(&rw_dir.join("a.txt"), "rw-content");
- write_file(&ro_dir.join("b.txt"), "ro-content");
-
- let rw = vec![rw_dir.display().to_string()];
- let ro = vec![ro_dir.display().to_string()];
- let staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &ro).unwrap();
-
- // Both subdirectories must exist.
- assert!(staging.path().join(RW_DIR).exists());
- assert!(staging.path().join(RO_DIR).exists());
- // Verify file content was staged.
- let rw_rel = host_path_to_guest_relative(&rw_dir);
- let ro_rel = host_path_to_guest_relative(&ro_dir);
- let staged_rw_file = staging.path().join(RW_DIR).join(&rw_rel).join("a.txt");
- let staged_ro_file = staging.path().join(RO_DIR).join(&ro_rel).join("b.txt");
- assert_eq!(fs::read_to_string(staged_rw_file).unwrap(), "rw-content");
- assert_eq!(fs::read_to_string(staged_ro_file).unwrap(), "ro-content");
- }
-
- #[test]
- fn staging_overhead_ms_scales_with_size() {
- let root = tempdir().unwrap();
- let staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &[]).unwrap();
- // A minimal staging dir should have near-zero overhead.
- assert!(staging.staging_overhead_ms() < 5);
- }
-
- #[test]
- fn staging_overhead_ms_capped_at_30s() {
- let root = tempdir().unwrap();
- let mut staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &[]).unwrap();
- // Simulate a huge staging size (won't actually allocate).
- staging.size_bytes = 500 * 1024 * 1024; // 500 MB
- assert_eq!(staging.staging_overhead_ms(), 30_000);
- }
-
- #[test]
- fn preserved_path_none_when_not_preserved() {
- let root = tempdir().unwrap();
- let staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &[]).unwrap();
- assert!(staging.preserved_path().is_none());
- }
-
- #[test]
- fn host_path_to_guest_relative_handles_trailing_slash() {
- let p = PathBuf::from(r"C:\Users\me\work\");
- assert_eq!(host_path_to_guest_relative(&p), "c/Users/me/work");
- }
-
- #[test]
- fn host_path_to_guest_relative_lowercase_drive() {
- let p = PathBuf::from(r"E:\Projects\src");
- let result = host_path_to_guest_relative(&p);
- assert!(result.starts_with('e'), "drive letter should be lowercase");
- assert_eq!(result, "e/Projects/src");
- }
-
- #[test]
- fn host_path_to_guest_relative_strips_leading_slash() {
- // On Linux, absolute paths like /tmp/xyz should become relative (tmp/xyz).
- let p = PathBuf::from("/tmp/my-dir/payload");
- let result = host_path_to_guest_relative(&p);
- assert_eq!(result, "tmp/my-dir/payload");
- assert!(
- !result.starts_with('/'),
- "result must be relative: {}",
- result
- );
- }
-
- #[test]
- fn host_path_to_guest_relative_linux_root_path() {
- // A path like /home/user/project → home/user/project.
- let p = PathBuf::from("/home/user/project");
- assert_eq!(host_path_to_guest_relative(&p), "home/user/project");
- }
-
- #[test]
- fn rewrite_paths_handles_escaped_backslashes() {
- let host = r"C:\Users\me\work".to_string();
- let guest = "/mnt/rw/c/Users/me/work".to_string();
- let script = r#"path = "C:\\Users\\me\\work""#;
- let result = rewrite_paths_in_script(script, &[(host, guest.clone())]);
- assert!(
- result.contains(&guest),
- "escaped backslashes not rewritten: {result}"
- );
- }
-
- #[test]
- fn rewrite_paths_longer_prefix_first() {
- let short_host = r"C:\data".to_string();
- let short_guest = "/mnt/rw/c/data".to_string();
- let long_host = r"C:\data\subdir".to_string();
- let long_guest = "/mnt/rw/c/data/subdir".to_string();
- let script = r"C:\data\subdir\file.txt";
- let mappings = vec![
- (short_host, short_guest.clone()),
- (long_host, long_guest.clone()),
- ];
- let result = rewrite_paths_in_script(script, &mappings);
- // The longer path must match first so we don't get a partial replacement.
- assert!(
- result.contains("/mnt/rw/c/data/subdir"),
- "longer prefix should match first: {result}"
- );
- }
-
- #[test]
- fn build_guest_path_format() {
- assert_eq!(build_guest_path("rw", "c/Users/me"), "/mnt/rw/c/Users/me");
- assert_eq!(build_guest_path("ro", "d/ref"), "/mnt/ro/d/ref");
- }
-
- #[test]
- fn staging_empty_script() {
- let root = tempdir().unwrap();
- let staging = StagingDir::new(root.path().to_path_buf(), "", &[], &[]).unwrap();
- let content = fs::read_to_string(staging.path().join(BOOTSTRAP_FILENAME)).unwrap();
- // Should only contain the preamble.
- assert_eq!(content, bootstrap_preamble());
- }
-
- #[test]
- fn staging_nested_directory_rw_copyback() {
- let root = tempdir().unwrap();
- let source_root = tempdir().unwrap();
- let source = source_root.path().join("nested");
- let sub = source.join("sub").join("deep");
- fs::create_dir_all(&sub).unwrap();
- write_file(&sub.join("deep.txt"), "original");
- write_file(&source.join("top.txt"), "top-original");
-
- let rw = vec![source.display().to_string()];
- let mut staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &[]).unwrap();
- let staged_dir = staged_rw(&staging, &source);
-
- // Modify deep file and add a new file.
- fs::write(
- staged_dir.join("sub").join("deep").join("deep.txt"),
- "modified",
- )
- .unwrap();
- fs::write(staged_dir.join("new.txt"), "added").unwrap();
-
- staging.copy_back_to_host().unwrap();
- assert_eq!(
- fs::read_to_string(sub.join("deep.txt")).unwrap(),
- "modified"
- );
- assert_eq!(fs::read_to_string(source.join("new.txt")).unwrap(), "added");
- }
-
- #[test]
- fn sweep_ignores_nonexistent_root() {
- let nonexistent = PathBuf::from(r"C:\nonexistent_mxc_test_dir_12345");
- // Should not panic or error.
- sweep_orphaned_staging_dirs(&nonexistent, Duration::from_secs(0));
- }
-
- #[test]
- fn staging_dir_has_unique_names() {
- let root = tempdir().unwrap();
- let a = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &[]).unwrap();
- let b = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &[]).unwrap();
- assert_ne!(a.path(), b.path());
- }
-}
diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs
index 4400bdda9..3aa6aa663 100644
--- a/src/core/wxc_common/src/models.rs
+++ b/src/core/wxc_common/src/models.rs
@@ -25,9 +25,8 @@ pub enum ContainmentBackend {
Lxc,
/// VM-based isolation.
Vm,
- /// MicroVM isolation via Windows Hypervisor Platform (internally powered by NanVix).
- #[serde(rename = "microvm")]
- MicroVm,
+ /// MicroVM isolation implemented by NVX and hosted by OpenVMM.
+ Microvm,
/// MicroVM isolation via Hyperlight + Unikraft, using an embedded
/// warmed-up CPython snapshot. ~100 ms cold start per invocation,
/// hermetic via snapshot restore. Experimental — requires
@@ -57,7 +56,7 @@ impl ContainmentBackend {
ContainmentBackend::Wslc => "wslc",
ContainmentBackend::Lxc => "lxc",
ContainmentBackend::Vm => "vm",
- ContainmentBackend::MicroVm => "microvm",
+ ContainmentBackend::Microvm => "microvm",
ContainmentBackend::Hyperlight => "hyperlight",
ContainmentBackend::WindowsSandbox => "windows_sandbox",
ContainmentBackend::IsolationSession => "isolation_session",
@@ -79,7 +78,7 @@ impl ContainmentBackend {
ContainmentBackend::IsolationSession => Some("isolationSession"),
ContainmentBackend::Bubblewrap
| ContainmentBackend::Hyperlight
- | ContainmentBackend::MicroVm
+ | ContainmentBackend::Microvm
| ContainmentBackend::Vm => None,
}
}
@@ -124,7 +123,7 @@ impl From for ContainmentBackend {
}
W::WindowsSandbox => Self::WindowsSandbox,
W::Lxc => Self::Lxc,
- W::Microvm => Self::MicroVm,
+ W::Microvm => Self::Microvm,
W::Hyperlight => Self::Hyperlight,
W::Wslc => Self::Wslc,
W::Seatbelt => Self::Seatbelt,
diff --git a/src/core/wxc_common/src/wire.rs b/src/core/wxc_common/src/wire.rs
index 329484cd2..0b36bf438 100644
--- a/src/core/wxc_common/src/wire.rs
+++ b/src/core/wxc_common/src/wire.rs
@@ -33,7 +33,7 @@ pub enum Containment {
WindowsSandbox,
/// Full Linux container.
Lxc,
- /// NanVix micro-VM (experimental).
+ /// MicroVM isolation implemented by NVX (experimental).
Microvm,
/// Hyperlight micro-VM (experimental).
Hyperlight,
@@ -78,8 +78,9 @@ pub struct Process {
/// Windows ProcessContainer picks the first `readwritePaths` entry that is
/// an existing directory, else the first such `readonlyPaths` entry, else
/// the system drive root; Seatbelt applies the same precedence with a `/`
- /// fallback; LXC/WSL use the container root; NanVix and Hyperlight reject a
- /// working directory outright. See `docs/schema.md` ("Working Directory").
+ /// fallback; LXC/WSL use the container root; NVX uses the guest root and
+ /// preserves a caller-supplied guest path; Hyperlight rejects a working
+ /// directory outright. See `docs/schema.md` ("Working Directory").
pub cwd: Option,
/// Environment variables as `"KEY=VALUE"` strings.
///
diff --git a/src/testing/fuzz/Cargo.toml b/src/testing/fuzz/Cargo.toml
index bcdda08b9..036c04ebd 100644
--- a/src/testing/fuzz/Cargo.toml
+++ b/src/testing/fuzz/Cargo.toml
@@ -18,15 +18,17 @@ cargo-fuzz = true
# These pull in heavier deps so they're opt-in for local dev.
hyperlight = ["dep:hyperlight_common", "hyperlight_common/hyperlight"]
isolation_session = ["dep:isolation_session_common"]
-microvm = ["dep:nanvix_runner", "wxc_common/microvm"]
+microvm = ["dep:nvx_runner", "nvx_runner/nvx"]
[dependencies]
libfuzzer-sys = "0.4"
wxc_common = { path = "../../core/wxc_common" }
-nanvix_runner = { path = "../../backends/nanvix/runner", optional = true }
hyperlight_common = { path = "../../backends/hyperlight/common", optional = true }
isolation_session_common = { path = "../../backends/isolation_session/common", optional = true }
+[target.'cfg(all(target_os = "windows", target_arch = "x86_64"))'.dependencies]
+nvx_runner = { path = "../../backends/nvx/runner", optional = true }
+
[[bin]]
name = "config_parser"
path = "fuzz_targets/config_parser.rs"
diff --git a/src/testing/fuzz/corpus/base64_decode/microvm_hello.b64.txt b/src/testing/fuzz/corpus/base64_decode/microvm_hello.b64.txt
deleted file mode 100644
index ec4a40528..000000000
--- a/src/testing/fuzz/corpus/base64_decode/microvm_hello.b64.txt
+++ /dev/null
@@ -1 +0,0 @@
-ew0KICAgICJwcm9jZXNzIjogew0KICAgICAgICAiY29tbWFuZExpbmUiOiAieCA9IDQyXG55ID0gNThcbnByaW50KCdIZWxsbyBmcm9tIE1pY3JvVk0hIHN1bT0lZCcgJSAoeCArIHkpKSIsDQogICAgICAgICJ0aW1lb3V0IjogMzAwMDANCiAgICB9LA0KICAgICJjb250YWlubWVudCI6ICJtaWNyb3ZtIg0KfQ0K
\ No newline at end of file
diff --git a/src/testing/fuzz/fuzz_targets/validator.rs b/src/testing/fuzz/fuzz_targets/validator.rs
index c8ec1872b..7aac4935b 100644
--- a/src/testing/fuzz/fuzz_targets/validator.rs
+++ b/src/testing/fuzz/fuzz_targets/validator.rs
@@ -7,7 +7,7 @@
// path that `--dry-run` takes through the binary.
//
// Runner-specific validation coverage:
-// - NanVix (MicroVm): always (no extra features)
+// - MicroVM (NVX): requires `--features microvm`
// - Hyperlight: requires `--features hyperlight`
// - IsolationSession: requires `--features isolation_session`
// - Seatbelt: macOS-only, not available in Windows fuzz builds
@@ -19,6 +19,10 @@ use libfuzzer_sys::fuzz_target;
use wxc_common::config_parser::load_mxc_request;
use wxc_common::logger::{Logger, Mode};
use wxc_common::models::ContainmentBackend;
+#[cfg(all(
+ target_os = "windows",
+ any(feature = "hyperlight", feature = "isolation_session")
+))]
use wxc_common::script_runner::ScriptRunner;
use wxc_common::state_aware_request::MxcRequest;
use wxc_common::validator::validate_common;
@@ -34,10 +38,9 @@ fuzz_target!(|data: &[u8]| {
// Dispatch to runner-specific validation based on backend.
#[cfg(target_os = "windows")]
match req.containment {
- #[cfg(feature = "microvm")]
- ContainmentBackend::MicroVm => {
- let runner = nanvix_runner::NanVixScriptRunner::new();
- let _ = runner.validate_runner(&req);
+ #[cfg(all(feature = "microvm", target_arch = "x86_64"))]
+ ContainmentBackend::Microvm => {
+ let _ = nvx_runner::preflight();
}
#[cfg(feature = "hyperlight")]
ContainmentBackend::Hyperlight => {
diff --git a/src/testing/wxc_e2e_tests/src/lib.rs b/src/testing/wxc_e2e_tests/src/lib.rs
index f8a42776d..51e7d721c 100644
--- a/src/testing/wxc_e2e_tests/src/lib.rs
+++ b/src/testing/wxc_e2e_tests/src/lib.rs
@@ -158,27 +158,6 @@ pub fn has_daemon() -> bool {
}
}
-/// Return whether the NanVix runtime binaries are available next to wxc-exec.
-pub fn has_nanvix_binaries() -> bool {
- let Some(exe) = find_binary("wxc-exec.exe") else {
- return false;
- };
- let exe_dir = exe.parent().unwrap_or(Path::new("."));
- // Flat binaries staged next to wxc-exec.exe by `nanvix_binaries`.
- let flat_present = ["nanvixd.exe", "nanvix_rootfs.img", "python3.initrd"]
- .iter()
- .all(|name| exe_dir.join(name).exists());
- // Kernel binary now lives under `bin/` (nanvixd locates it via -bin-dir).
- let bin_present = ["kernel.elf"]
- .iter()
- .all(|name| exe_dir.join("bin").join(name).exists());
- let present = flat_present && bin_present;
- if !present {
- println!("SKIPPED: NanVix binaries not found next to wxc-exec.exe");
- }
- present
-}
-
/// Return whether `lxc-exec` is available for direct E2E execution.
pub fn has_lxc_exe() -> bool {
match find_binary("lxc-exec") {
@@ -187,7 +166,7 @@ pub fn has_lxc_exe() -> bool {
true
}
None => {
- println!("SKIPPED: lxc-exec not found — build with `cargo build -p lxc --features microvm` first");
+ println!("SKIPPED: lxc-exec not found — build with `cargo build -p lxc` first");
false
}
}
@@ -214,37 +193,6 @@ pub fn has_lxc_host() -> bool {
}
}
}
-
-/// Return whether the NanVix runtime binaries are available next to lxc-exec (Linux).
-pub fn has_lxc_nanvix_binaries() -> bool {
- let Some(exe) = find_binary("lxc-exec") else {
- return false;
- };
- let exe_dir = exe.parent().unwrap_or(Path::new("."));
- // Flat binaries staged next to lxc-exec by `nanvix_binaries`.
- let flat_present = ["nanvixd.elf", "nanvix_rootfs.img", "python3.initrd"]
- .iter()
- .all(|name| exe_dir.join(name).exists());
- // Kernel binary under `bin/` (nanvixd locates it relative to cwd).
- let bin_present = ["kernel.elf"]
- .iter()
- .all(|name| exe_dir.join("bin").join(name).exists());
- let present = flat_present && bin_present;
- if !present {
- println!("SKIPPED: NanVix binaries not found next to lxc-exec — build with `cargo build -p lxc --features microvm`");
- }
- present
-}
-
-/// Return whether `/dev/kvm` is available for KVM-based execution.
-pub fn has_kvm() -> bool {
- let available = Path::new("/dev/kvm").exists();
- if !available {
- println!("SKIPPED: /dev/kvm not available — KVM required for NanVix on Linux");
- }
- available
-}
-
/// Run `lxc-exec` with the supplied config file and extra arguments.
pub fn run_lxc_config(config_file: &str, extra_args: &[&str]) -> CommandResult {
let exe = find_binary("lxc-exec").expect("lxc-exec should be available");
diff --git a/src/testing/wxc_e2e_tests/tests/e2e_linux_microvm.rs b/src/testing/wxc_e2e_tests/tests/e2e_linux_microvm.rs
deleted file mode 100644
index ac67c9aa3..000000000
--- a/src/testing/wxc_e2e_tests/tests/e2e_linux_microvm.rs
+++ /dev/null
@@ -1,318 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-//! Linux MicroVM (NanVix/KVM) E2E integration tests.
-//!
-//! These tests mirror the Windows MicroVM E2E suite in `e2e_windows.rs` and
-//! invoke `lxc-exec` directly with the `microvm` containment backend.
-//! Tests skip gracefully when prerequisites (binaries, KVM) are missing.
-
-use std::sync::OnceLock;
-use std::time::{SystemTime, UNIX_EPOCH};
-
-use serde::Serialize;
-use wxc_e2e_tests::{
- has_kvm, has_lxc_exe, has_lxc_nanvix_binaries, repo_root, run_lxc_config, test_configs_dir,
- CommandResult,
-};
-
-static HAS_LXC_EXE: OnceLock = OnceLock::new();
-static HAS_LXC_NANVIX: OnceLock = OnceLock::new();
-static HAS_KVM: OnceLock = OnceLock::new();
-
-fn cached_has_lxc_exe() -> bool {
- *HAS_LXC_EXE.get_or_init(has_lxc_exe)
-}
-
-fn cached_has_lxc_nanvix() -> bool {
- *HAS_LXC_NANVIX.get_or_init(has_lxc_nanvix_binaries)
-}
-
-fn cached_has_kvm() -> bool {
- *HAS_KVM.get_or_init(has_kvm)
-}
-
-/// Guard: skip test if prerequisites are missing.
-fn skip_unless_ready() -> bool {
- if !cached_has_lxc_exe() {
- return false;
- }
- if !cached_has_lxc_nanvix() {
- return false;
- }
- if !cached_has_kvm() {
- return false;
- }
- true
-}
-
-// ---------------------------------------------------------------------------
-// Individual tests (mirrors microvm_basic on Windows)
-// ---------------------------------------------------------------------------
-
-#[test]
-fn test_microvm_hello() {
- if !skip_unless_ready() {
- return;
- }
- let result = run_lxc_config("microvm_hello_linux.json", &["--debug", "--experimental"]);
- assert_eq!(
- result.code,
- Some(0),
- "expected exit 0, got {:?}\nstdout: {}\nstderr: {}",
- result.code,
- result.stdout,
- result.stderr
- );
- let combined = format!("{}\n{}", result.stdout, result.stderr);
- assert!(
- combined.contains("sum=100"),
- "output missing 'sum=100'\ncombined: {}",
- combined
- );
-}
-
-#[test]
-fn test_microvm_network() {
- if !skip_unless_ready() {
- return;
- }
- // Exercises the `-allow-host-networking` path on Linux/KVM: the guest runs
- // a loopback TCP ping/pong round-trip and prints NET_OK. Without host
- // networking the guest socket() fails (errno 134), so a clean exit plus
- // the marker confirms the flag is wired through for the cold-boot path.
- let result = run_lxc_config("microvm_network_linux.json", &["--debug", "--experimental"]);
- assert_eq!(
- result.code,
- Some(0),
- "expected exit 0, got {:?}\nstdout: {}\nstderr: {}",
- result.code,
- result.stdout,
- result.stderr
- );
- assert!(
- result
- .combined_output_with_decoded_base64()
- .contains("NET_OK"),
- "guest network round-trip marker missing\nstdout: {}\nstderr: {}",
- result.stdout,
- result.stderr
- );
-}
-
-// ---------------------------------------------------------------------------
-// Full microvm suite (mirrors test_microvm_suite on Windows)
-// ---------------------------------------------------------------------------
-
-#[derive(Debug)]
-struct MicrovmCase {
- config: &'static str,
- expected_exit: Option,
- description: &'static str,
- output_contains: Option<&'static str>,
- expect_non_zero: bool,
-}
-
-#[derive(Debug, Serialize)]
-struct MicrovmPerfOutput {
- commit: String,
- timestamp: String,
- results: Vec,
-}
-
-#[derive(Debug, Serialize)]
-struct MicrovmPerfEntry {
- test: String,
- description: String,
- wall_time_ms: u128,
- exit_code: Option,
- status: String,
-}
-
-#[test]
-fn test_microvm_suite() {
- if !skip_unless_ready() {
- return;
- }
- microvm_suite();
-}
-
-fn microvm_suite() {
- let cases = [
- MicrovmCase {
- config: "microvm_hello_linux.json",
- expected_exit: Some(0),
- description: "Hello world",
- output_contains: Some("sum=100"),
- expect_non_zero: false,
- },
- MicrovmCase {
- config: "microvm_exit_code_linux.json",
- expected_exit: Some(42),
- description: "Exit code propagation",
- output_contains: None,
- expect_non_zero: false,
- },
- MicrovmCase {
- config: "microvm_multiline_linux.json",
- expected_exit: Some(0),
- description: "Multi-line script (fibonacci)",
- output_contains: Some("fib("),
- expect_non_zero: false,
- },
- MicrovmCase {
- config: "microvm_stdlib_linux.json",
- expected_exit: Some(0),
- description: "Stdlib (json, math, hashlib)",
- output_contains: Some("pi"),
- expect_non_zero: false,
- },
- MicrovmCase {
- config: "microvm_large_output_linux.json",
- expected_exit: Some(0),
- description: "Large stdout (1000 lines)",
- output_contains: Some("line 999"),
- expect_non_zero: false,
- },
- MicrovmCase {
- config: "microvm_error_linux.json",
- expected_exit: Some(1),
- description: "Python exception",
- output_contains: Some("ValueError"),
- expect_non_zero: false,
- },
- MicrovmCase {
- config: "microvm_timeout_linux.json",
- expected_exit: None,
- description: "Timeout kills VM",
- output_contains: None,
- expect_non_zero: true,
- },
- ];
-
- let mut perf_entries = Vec::new();
- let mut failures = Vec::new();
-
- for case in cases {
- let config_path = test_configs_dir().join(case.config);
- if !config_path.exists() {
- println!("SKIPPED: config not found: {}", config_path.display());
- continue;
- }
-
- println!("--- {} ({}) ---", case.description, case.config);
- let result = run_lxc_config(case.config, &["--debug", "--experimental"]);
- let status = if command_matches(&result, &case) {
- "PASS"
- } else {
- failures.push(format!(
- "{} expected {}, got {:?}",
- case.config,
- expected_exit_description(&case),
- result.code
- ));
- "FAIL"
- };
-
- perf_entries.push(MicrovmPerfEntry {
- test: case.config.to_string(),
- description: case.description.to_string(),
- wall_time_ms: result.wall_time_ms,
- exit_code: result.code,
- status: status.to_string(),
- });
-
- if status == "FAIL" {
- println!(
- "--- stdout ---\n{}\n--- stderr ---\n{}",
- result.stdout, result.stderr
- );
- } else {
- println!(" PASS ({} ms)", result.wall_time_ms);
- }
- }
-
- write_microvm_perf_results(perf_entries);
-
- if !failures.is_empty() {
- panic!("MicroVM Linux E2E failures:\n{}", failures.join("\n"));
- }
-}
-
-fn command_matches(result: &CommandResult, case: &MicrovmCase) -> bool {
- if case.expect_non_zero {
- if result.code == Some(0) {
- return false;
- }
- } else if result.code != case.expected_exit {
- return false;
- }
-
- let Some(expected) = case.output_contains else {
- return true;
- };
-
- result
- .combined_output_with_decoded_base64()
- .contains(expected)
-}
-
-fn expected_exit_description(case: &MicrovmCase) -> String {
- if case.expect_non_zero {
- "non-zero exit".to_string()
- } else {
- format!("exit {}", case.expected_exit.unwrap_or(0))
- }
-}
-
-// ---------------------------------------------------------------------------
-// Perf results output
-// ---------------------------------------------------------------------------
-
-fn write_microvm_perf_results(results: Vec) {
- let output = MicrovmPerfOutput {
- commit: std::env::var("GITHUB_SHA").unwrap_or_else(|_| "local".to_string()),
- timestamp: SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map(|duration| duration.as_secs().to_string())
- .unwrap_or_else(|_| "unknown".to_string()),
- results,
- };
- let json = serde_json::to_string_pretty(&output)
- .expect("microvm performance results should serialize");
- let path = repo_root().join("microvm-perf-results-linux.json");
- std::fs::write(&path, json)
- .unwrap_or_else(|error| panic!("failed to write {}: {error}", path.display()));
- println!("Performance results written to {}", path.display());
-}
-
-// ---------------------------------------------------------------------------
-// Stress tests (run_on_repeat — ignored by default)
-// ---------------------------------------------------------------------------
-
-#[test]
-#[ignore]
-fn test_microvm_run_on_repeat() {
- if !skip_unless_ready() {
- return;
- }
-
- const ITERATIONS: u32 = 10;
- let mut failures = Vec::new();
-
- for i in 0..ITERATIONS {
- let result = run_lxc_config("microvm_hello_linux.json", &["--experimental"]);
- if result.code != Some(0) {
- failures.push(format!("iteration {}: exit {:?}", i, result.code));
- }
- }
-
- if !failures.is_empty() {
- panic!(
- "MicroVM repeat test: {}/{} failures:\n{}",
- failures.len(),
- ITERATIONS,
- failures.join("\n")
- );
- }
-}
diff --git a/src/testing/wxc_e2e_tests/tests/e2e_windows.rs b/src/testing/wxc_e2e_tests/tests/e2e_windows.rs
index eb0c18d4c..08d53bfd4 100644
--- a/src/testing/wxc_e2e_tests/tests/e2e_windows.rs
+++ b/src/testing/wxc_e2e_tests/tests/e2e_windows.rs
@@ -10,18 +10,15 @@
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
-use serde::Serialize;
use wxc_e2e_tests::{
assert_exit, assert_pwsh, assert_python, assert_success,
assert_success_or_skip_missing_prerequisite, examples_dir, has_hyperlight_snapshot,
- has_nanvix_binaries, has_test_driver, has_windows_sandbox_feature, has_wxc_exe, repo_root,
- run_test_driver, run_wxc_config, run_wxc_config_value, run_wxc_example, run_wxc_state_aware,
- test_configs_dir, TempDirs,
+ has_test_driver, has_windows_sandbox_feature, has_wxc_exe, run_test_driver, run_wxc_config,
+ run_wxc_config_value, run_wxc_example, run_wxc_state_aware, test_configs_dir, TempDirs,
};
static HAS_WXC_EXE: OnceLock = OnceLock::new();
static HAS_TEST_DRIVER: OnceLock = OnceLock::new();
-static HAS_NANVIX_BINARIES: OnceLock = OnceLock::new();
static HAS_WINDOWS_SANDBOX: OnceLock = OnceLock::new();
static HAS_HYPERLIGHT: OnceLock = OnceLock::new();
static TEST_LOCK: OnceLock> = OnceLock::new();
@@ -37,11 +34,6 @@ fn cached_has_test_driver() -> bool {
*HAS_TEST_DRIVER.get_or_init(has_test_driver)
}
-/// Caches the NanVix binary probe to avoid repeated prerequisite work.
-fn cached_has_nanvix_binaries() -> bool {
- *HAS_NANVIX_BINARIES.get_or_init(has_nanvix_binaries)
-}
-
/// Caches the Windows Sandbox feature probe.
fn cached_has_windows_sandbox_feature() -> bool {
*HAS_WINDOWS_SANDBOX.get_or_init(has_windows_sandbox_feature)
@@ -122,99 +114,6 @@ fn examples() {
assert_success(&result);
}
-fn microvm_basic() {
- assert_wxc_success("microvm_hello.json", &["--debug", "--experimental"]);
-}
-
-fn microvm_network() {
- // Drives the `-allow-host-networking` path: the guest opens a loopback TCP
- // socket, completes a ping/pong round-trip, and prints NET_OK. Without host
- // networking enabled the guest's socket() call fails (errno 134) and the
- // process exits non-zero, so a clean exit + marker proves the flag wiring.
- let result = run_wxc_config("microvm_network.json", &["--debug", "--experimental"]);
- assert_eq!(
- result.code,
- Some(0),
- "expected exit 0, got {:?}\nstdout: {}\nstderr: {}",
- result.code,
- result.stdout,
- result.stderr
- );
- let combined = result.combined_output_with_decoded_base64();
- assert!(
- combined.contains("NET_OK"),
- "guest network round-trip marker missing\ncombined: {}",
- combined
- );
-}
-
-/// Full network isolation is supported; directional filtering is separately
-/// rejected because the legacy guest filter does not implement that contract.
-fn microvm_network_blocked() {
- let source = "import _socket\n\
- try:\n\
- \x20 s = _socket.socket(2, 1, 0)\n\
- \x20 print('UNEXPECTED_NETWORK', flush=True)\n\
- except OSError as e:\n\
- \x20 print('RESULT ERRNO %d' % (e.errno,), flush=True)\n";
- let blocked = serde_json::json!({
- "version": "0.10.0-alpha",
- "process": { "commandLine": source, "timeout": 30000 },
- "containment": "microvm",
- "network": {
- "egress": { "default": "deny" },
- "ingress": { "default": "deny", "hostLoopback": "deny" }
- }
- });
- let blocked_result = run_wxc_config_value(
- "microvm_network_blocked",
- &blocked,
- &["--debug", "--experimental"],
- );
- let blocked_out = blocked_result.combined_output_with_decoded_base64();
- assert_eq!(
- blocked_result.code,
- Some(0),
- "isolated run should exit cleanly (the guest catches the error)\ncombined: {}",
- blocked_out
- );
- assert!(
- blocked_out.contains("RESULT ERRNO 134"),
- "isolated guest sockets should remain unavailable (errno 134)\ncombined: {}",
- blocked_out
- );
-
- let filtered = serde_json::json!({
- "version": "0.10.0-alpha",
- "process": { "commandLine": "print('UNEXPECTED_EXECUTION')", "timeout": 30000 },
- "containment": "microvm",
- "network": {
- "egress": {
- "default": "allow",
- "deny": [{ "to": [{ "cidr": "203.0.113.0/24" }] }]
- },
- "ingress": { "default": "allow", "hostLoopback": "allow" }
- }
- });
- let filtered_result = run_wxc_config_value(
- "microvm_directional_filter_rejected",
- &filtered,
- &["--debug", "--experimental"],
- );
- let filtered_out = filtered_result.combined_output_with_decoded_base64();
- assert_ne!(
- filtered_result.code,
- Some(0),
- "unsupported directional filtering must fail before execution\ncombined: {}",
- filtered_out
- );
- assert!(
- filtered_out.contains("NanVix cannot enforce directional egress rules"),
- "expected the backend's explicit unsupported-filtering error\ncombined: {}",
- filtered_out
- );
-}
-
fn processcontainer_proxy() {
let config = test_configs_dir().join("processcontainer_proxy_builtin_test.json");
if !config.exists() {
@@ -608,39 +507,6 @@ fn test_examples() {
with_test_lock(examples);
}
-#[test]
-fn test_microvm_basic() {
- if !cached_has_wxc_exe() {
- return;
- }
- if !cached_has_nanvix_binaries() {
- return;
- }
- with_test_lock(microvm_basic);
-}
-
-#[test]
-fn test_microvm_network() {
- if !cached_has_wxc_exe() {
- return;
- }
- if !cached_has_nanvix_binaries() {
- return;
- }
- with_test_lock(microvm_network);
-}
-
-#[test]
-fn test_microvm_network_blocked() {
- if !cached_has_wxc_exe() {
- return;
- }
- if !cached_has_nanvix_binaries() {
- return;
- }
- with_test_lock(microvm_network_blocked);
-}
-
#[test]
fn test_windows_sandbox() {
if !cached_has_wxc_exe() {
@@ -653,17 +519,6 @@ fn test_windows_sandbox() {
with_test_lock(windows_sandbox_suite);
}
-#[test]
-fn test_microvm_suite() {
- if !cached_has_wxc_exe() {
- return;
- }
- if !cached_has_nanvix_binaries() {
- return;
- }
- with_test_lock(microvm_suite);
-}
-
#[test]
#[ignore] // Requires velocity key 61714527 (BFS deadlock fix) enabled and elevation
fn test_processcontainer_proxy() {
@@ -819,161 +674,6 @@ fn run_sandbox_case(case: &SandboxCase) {
);
}
-// ---------------------------------------------------------------------------
-// MicroVM suite
-// ---------------------------------------------------------------------------
-
-#[derive(Debug)]
-struct MicrovmCase {
- config: &'static str,
- expected_exit: Option,
- description: &'static str,
- output_contains: Option<&'static str>,
- expect_non_zero: bool,
-}
-
-#[derive(Debug, Serialize)]
-struct MicrovmPerfOutput {
- commit: String,
- timestamp: String,
- results: Vec,
-}
-
-#[derive(Debug, Serialize)]
-struct MicrovmPerfEntry {
- test: String,
- description: String,
- wall_time_ms: u128,
- exit_code: Option,
- status: String,
-}
-
-fn microvm_suite() {
- let cases = [
- MicrovmCase {
- config: "microvm_hello.json",
- expected_exit: Some(0),
- description: "Hello world",
- output_contains: Some("sum=100"),
- expect_non_zero: false,
- },
- MicrovmCase {
- config: "microvm_exit_code.json",
- expected_exit: Some(42),
- description: "Exit code propagation",
- output_contains: None,
- expect_non_zero: false,
- },
- MicrovmCase {
- config: "microvm_multiline.json",
- expected_exit: Some(0),
- description: "Multi-line script (fibonacci)",
- output_contains: Some("fib("),
- expect_non_zero: false,
- },
- MicrovmCase {
- config: "microvm_stdlib.json",
- expected_exit: Some(0),
- description: "Stdlib (json, math, hashlib)",
- output_contains: Some("pi"),
- expect_non_zero: false,
- },
- MicrovmCase {
- config: "microvm_large_output.json",
- expected_exit: Some(0),
- description: "Large stdout (1000 lines)",
- output_contains: Some("line 999"),
- expect_non_zero: false,
- },
- MicrovmCase {
- config: "microvm_error.json",
- expected_exit: Some(1),
- description: "Python exception",
- output_contains: Some("ValueError"),
- expect_non_zero: false,
- },
- MicrovmCase {
- config: "microvm_timeout.json",
- expected_exit: None,
- description: "Timeout kills VM",
- output_contains: None,
- expect_non_zero: true,
- },
- ];
-
- let mut perf_entries = Vec::new();
- let mut failures = Vec::new();
-
- for case in cases {
- let config_path = test_configs_dir().join(case.config);
- if !config_path.exists() {
- println!("SKIPPED: config not found: {}", config_path.display());
- continue;
- }
-
- println!("--- {} ({}) ---", case.description, case.config);
- let result = run_wxc_config(case.config, &["--debug", "--experimental"]);
- let status = if command_matches(&result, &case) {
- "PASS"
- } else {
- failures.push(format!(
- "{} expected {}, got {:?}",
- case.config,
- expected_exit_description(&case),
- result.code
- ));
- "FAIL"
- };
-
- perf_entries.push(MicrovmPerfEntry {
- test: case.config.to_string(),
- description: case.description.to_string(),
- wall_time_ms: result.wall_time_ms,
- exit_code: result.code,
- status: status.to_string(),
- });
-
- if status == "FAIL" {
- println!(
- "--- stdout ---\n{}\n--- stderr ---\n{}",
- result.stdout, result.stderr
- );
- }
- }
-
- write_microvm_perf_results(perf_entries);
-
- if !failures.is_empty() {
- panic!("MicroVM E2E failures:\n{}", failures.join("\n"));
- }
-}
-
-fn command_matches(result: &wxc_e2e_tests::CommandResult, case: &MicrovmCase) -> bool {
- if case.expect_non_zero {
- if result.code == Some(0) {
- return false;
- }
- } else if result.code != case.expected_exit {
- return false;
- }
-
- let Some(expected) = case.output_contains else {
- return true;
- };
-
- result
- .combined_output_with_decoded_base64()
- .contains(expected)
-}
-
-fn expected_exit_description(case: &MicrovmCase) -> String {
- if case.expect_non_zero {
- "non-zero exit".to_string()
- } else {
- format!("exit {}", case.expected_exit.unwrap_or(0))
- }
-}
-
// ---------------------------------------------------------------------------
// Hyperlight suite
// ---------------------------------------------------------------------------
@@ -1188,24 +888,3 @@ fn test_hyperlight_suite() {
}
with_test_lock(hyperlight_suite);
}
-
-// ---------------------------------------------------------------------------
-// MicroVM perf results
-// ---------------------------------------------------------------------------
-
-fn write_microvm_perf_results(results: Vec) {
- let output = MicrovmPerfOutput {
- commit: std::env::var("GITHUB_SHA").unwrap_or_else(|_| "local".to_string()),
- timestamp: SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map(|duration| duration.as_secs().to_string())
- .unwrap_or_else(|_| "unknown".to_string()),
- results,
- };
- let json = serde_json::to_string_pretty(&output)
- .expect("microvm performance results should serialize");
- let path = repo_root().join("microvm-perf-results.json");
- std::fs::write(&path, json)
- .unwrap_or_else(|error| panic!("failed to write {}: {error}", path.display()));
- println!("Performance results written to {}", path.display());
-}
diff --git a/tests/configs/microvm_error.json b/tests/configs/microvm_error.json
deleted file mode 100644
index c1aefe02d..000000000
--- a/tests/configs/microvm_error.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "version": "0.10.0-alpha",
- "process": {
- "commandLine": "raise ValueError('intentional test error')",
- "timeout": 30000
- },
- "containment": "microvm"
-}
diff --git a/tests/configs/microvm_error_linux.json b/tests/configs/microvm_error_linux.json
deleted file mode 100644
index c1aefe02d..000000000
--- a/tests/configs/microvm_error_linux.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "version": "0.10.0-alpha",
- "process": {
- "commandLine": "raise ValueError('intentional test error')",
- "timeout": 30000
- },
- "containment": "microvm"
-}
diff --git a/tests/configs/microvm_exit_code.json b/tests/configs/microvm_exit_code.json
deleted file mode 100644
index c7d00cbc9..000000000
--- a/tests/configs/microvm_exit_code.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "version": "0.10.0-alpha",
- "process": {
- "commandLine": "import sys; sys.exit(42)",
- "timeout": 30000
- },
- "containment": "microvm"
-}
diff --git a/tests/configs/microvm_exit_code_linux.json b/tests/configs/microvm_exit_code_linux.json
deleted file mode 100644
index c7d00cbc9..000000000
--- a/tests/configs/microvm_exit_code_linux.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "version": "0.10.0-alpha",
- "process": {
- "commandLine": "import sys; sys.exit(42)",
- "timeout": 30000
- },
- "containment": "microvm"
-}
diff --git a/tests/configs/microvm_hello.json b/tests/configs/microvm_hello.json
deleted file mode 100644
index 07354485d..000000000
--- a/tests/configs/microvm_hello.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "version": "0.10.0-alpha",
- "process": {
- "commandLine": "x = 42\ny = 58\nprint('Hello from MicroVM! sum=%d' % (x + y))",
- "timeout": 30000
- },
- "containment": "microvm"
-}
diff --git a/tests/configs/microvm_hello_linux.json b/tests/configs/microvm_hello_linux.json
deleted file mode 100644
index 4d0d6cdd0..000000000
--- a/tests/configs/microvm_hello_linux.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "version": "0.10.0-alpha",
- "process": {
- "commandLine": "x = 42\ny = 58\nprint('Hello from NanVix/KVM on Linux! sum=%d' % (x + y))",
- "timeout": 30000
- },
- "containment": "microvm"
-}
diff --git a/tests/configs/microvm_large_output.json b/tests/configs/microvm_large_output.json
deleted file mode 100644
index 6f82f6298..000000000
--- a/tests/configs/microvm_large_output.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "version": "0.10.0-alpha",
- "process": {
- "commandLine": "for i in range(1000):\n print(f'line {i}: ' + 'x' * 80)",
- "timeout": 30000
- },
- "containment": "microvm"
-}
diff --git a/tests/configs/microvm_large_output_linux.json b/tests/configs/microvm_large_output_linux.json
deleted file mode 100644
index 6f82f6298..000000000
--- a/tests/configs/microvm_large_output_linux.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "version": "0.10.0-alpha",
- "process": {
- "commandLine": "for i in range(1000):\n print(f'line {i}: ' + 'x' * 80)",
- "timeout": 30000
- },
- "containment": "microvm"
-}
diff --git a/tests/configs/microvm_multiline.json b/tests/configs/microvm_multiline.json
deleted file mode 100644
index 3373fd3ec..000000000
--- a/tests/configs/microvm_multiline.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "version": "0.10.0-alpha",
- "process": {
- "commandLine": "def fib(n):\n a, b = 0, 1\n for _ in range(n):\n a, b = b, a + b\n return a\n\nfor i in range(10):\n print(f'fib({i}) = {fib(i)}')",
- "timeout": 30000
- },
- "containment": "microvm"
-}
diff --git a/tests/configs/microvm_multiline_linux.json b/tests/configs/microvm_multiline_linux.json
deleted file mode 100644
index 3373fd3ec..000000000
--- a/tests/configs/microvm_multiline_linux.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "version": "0.10.0-alpha",
- "process": {
- "commandLine": "def fib(n):\n a, b = 0, 1\n for _ in range(n):\n a, b = b, a + b\n return a\n\nfor i in range(10):\n print(f'fib({i}) = {fib(i)}')",
- "timeout": 30000
- },
- "containment": "microvm"
-}
diff --git a/tests/configs/microvm_network.json b/tests/configs/microvm_network.json
deleted file mode 100644
index 2d045acc9..000000000
--- a/tests/configs/microvm_network.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "version": "0.10.0-alpha",
- "process": {
- "commandLine": "import _socket\nsrv = _socket.socket(2, 1, 0)\nsrv.bind(('127.0.0.1', 8080))\nsrv.listen(1)\ncli = _socket.socket(2, 1, 0)\ncli.connect(('127.0.0.1', 8080))\nfd, addr = srv._accept()\nconn = _socket.socket(2, 1, 0, fd)\ncli.send(b'ping')\nassert conn.recv(64) == b'ping'\nconn.send(b'pong')\nassert cli.recv(64) == b'pong'\nprint('NET_OK loopback roundtrip', flush=True)",
- "timeout": 30000
- },
- "containment": "microvm",
- "network": {
- "egress": { "default": "allow" },
- "ingress": { "default": "allow", "hostLoopback": "allow" }
- }
-}
diff --git a/tests/configs/microvm_network_linux.json b/tests/configs/microvm_network_linux.json
deleted file mode 100644
index 2d045acc9..000000000
--- a/tests/configs/microvm_network_linux.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "version": "0.10.0-alpha",
- "process": {
- "commandLine": "import _socket\nsrv = _socket.socket(2, 1, 0)\nsrv.bind(('127.0.0.1', 8080))\nsrv.listen(1)\ncli = _socket.socket(2, 1, 0)\ncli.connect(('127.0.0.1', 8080))\nfd, addr = srv._accept()\nconn = _socket.socket(2, 1, 0, fd)\ncli.send(b'ping')\nassert conn.recv(64) == b'ping'\nconn.send(b'pong')\nassert cli.recv(64) == b'pong'\nprint('NET_OK loopback roundtrip', flush=True)",
- "timeout": 30000
- },
- "containment": "microvm",
- "network": {
- "egress": { "default": "allow" },
- "ingress": { "default": "allow", "hostLoopback": "allow" }
- }
-}
diff --git a/tests/configs/microvm_stdlib.json b/tests/configs/microvm_stdlib.json
deleted file mode 100644
index 84bb2c38b..000000000
--- a/tests/configs/microvm_stdlib.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "version": "0.10.0-alpha",
- "process": {
- "commandLine": "import json, math, hashlib\ndata = {'pi': math.pi, 'e': math.e, 'hash': hashlib.sha256(b'nanvix').hexdigest()[:16]}\nprint(json.dumps(data))",
- "timeout": 30000
- },
- "containment": "microvm"
-}
diff --git a/tests/configs/microvm_stdlib_linux.json b/tests/configs/microvm_stdlib_linux.json
deleted file mode 100644
index 84bb2c38b..000000000
--- a/tests/configs/microvm_stdlib_linux.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "version": "0.10.0-alpha",
- "process": {
- "commandLine": "import json, math, hashlib\ndata = {'pi': math.pi, 'e': math.e, 'hash': hashlib.sha256(b'nanvix').hexdigest()[:16]}\nprint(json.dumps(data))",
- "timeout": 30000
- },
- "containment": "microvm"
-}
diff --git a/tests/configs/microvm_timeout.json b/tests/configs/microvm_timeout.json
deleted file mode 100644
index 555988dbb..000000000
--- a/tests/configs/microvm_timeout.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "version": "0.10.0-alpha",
- "_comment": "Timeout test. Note: MicroVM adds a 60s boot grace to the 5s script timeout, so actual wall time is ~65s.",
- "process": {
- "commandLine": "import time; time.sleep(120); print('should not reach here')",
- "timeout": 5000
- },
- "containment": "microvm"
-}
diff --git a/tests/configs/microvm_timeout_linux.json b/tests/configs/microvm_timeout_linux.json
deleted file mode 100644
index 6fd675794..000000000
--- a/tests/configs/microvm_timeout_linux.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "version": "0.10.0-alpha",
- "_comment": "Timeout test. NanVix adds a 60s boot grace to the 5s script timeout, so actual wall time is ~65s.",
- "process": {
- "commandLine": "import time; time.sleep(120); print('should not reach here')",
- "timeout": 5000
- },
- "containment": "microvm"
-}
diff --git a/tests/playground/package.json b/tests/playground/package.json
index b80a6738f..7e9ad4e19 100644
--- a/tests/playground/package.json
+++ b/tests/playground/package.json
@@ -54,30 +54,6 @@
"from": "../src/target/x86_64-pc-windows-msvc/release/wxc-sandbox-agent.exe",
"to": "bin/x64/wxc-sandbox-agent.exe"
},
- {
- "from": "../src/target/x86_64-pc-windows-msvc/release/nanvixd.exe",
- "to": "bin/x64/nanvixd.exe"
- },
- {
- "from": "../src/target/x86_64-pc-windows-msvc/release/bin/kernel.elf",
- "to": "bin/x64/bin/kernel.elf"
- },
- {
- "from": "../src/target/x86_64-pc-windows-msvc/release/python3.initrd",
- "to": "bin/x64/python3.initrd"
- },
- {
- "from": "../src/target/x86_64-pc-windows-msvc/release/nanvix_rootfs.img",
- "to": "bin/x64/nanvix_rootfs.img"
- },
- {
- "from": "../src/target/x86_64-pc-windows-msvc/release/snapshots/kernel.vmem",
- "to": "bin/x64/snapshots/kernel.vmem"
- },
- {
- "from": "../src/target/x86_64-pc-windows-msvc/release/snapshots/kernel.whp.cbor",
- "to": "bin/x64/snapshots/kernel.whp.cbor"
- },
{
"from": "../sdk/node/bin/x64/vcruntime140.dll",
"to": "bin/x64/vcruntime140.dll"
diff --git a/tests/playground/src/main/main.ts b/tests/playground/src/main/main.ts
index 1d2af9dce..f60df509b 100644
--- a/tests/playground/src/main/main.ts
+++ b/tests/playground/src/main/main.ts
@@ -521,45 +521,11 @@ ipcMain.handle('run-sandbox-raw', (_event, configJson: string, debug: boolean, e
const config = JSON.parse(configJson);
const execPath = resolveExecutablePath();
- // MicroVM (nanvixd) requires CWD to be the binary directory
- let workingDir: string | undefined;
- if (config.containment === 'microvm') {
- const fs = require('fs');
- // Match the SDK's binary discovery layout (sdk/src/platform.ts):
- // npm-packaged binaries live under sdk/bin/, local dev builds
- // under src/target//{release,debug}.
- const arch = process.arch === 'arm64' ? 'arm64' : 'x64';
- const triple = process.arch === 'arm64' ? 'aarch64-pc-windows-msvc' : 'x86_64-pc-windows-msvc';
- const repoRoot = path.join(__dirname, '..', '..', '..');
- const candidates = [
- execPath,
- path.join(repoRoot, 'sdk', 'bin', arch),
- path.join(repoRoot, 'src', 'target', triple, 'release'),
- path.join(repoRoot, 'src', 'target', triple, 'debug'),
- path.join(repoRoot, 'src', 'target', 'release'),
- path.join(repoRoot, 'src', 'target', 'debug'),
- ].filter(Boolean);
- for (const c of candidates) {
- const dir = c!.endsWith('.exe') ? path.dirname(c!) : c!;
- if (fs.existsSync(path.join(dir, 'nanvixd.exe'))) {
- workingDir = dir;
- break;
- }
- }
- if (!workingDir) {
- return {
- success: false,
- error: `nanvixd.exe not found for arch '${arch}'. Looked in: ${candidates.join('; ')}. ` +
- `MicroVM requires nanvixd.exe to be co-located with wxc-exec (and CWD must point at it).`,
- };
- }
- }
-
const ptyProcess = sdk.spawnSandboxFromConfig(config, {
debug,
experimental,
executablePath: execPath, skipPlatformCheck: true,
- }, workingDir);
+ });
attachPtyListeners(ptyProcess);
return { success: true, config };
diff --git a/tests/playground/src/renderer/app.ts b/tests/playground/src/renderer/app.ts
index a884576d1..0b94d8053 100644
--- a/tests/playground/src/renderer/app.ts
+++ b/tests/playground/src/renderer/app.ts
@@ -18,7 +18,7 @@ interface Scenario {
script: string;
policy: any;
shell: 'cmd' | 'ps51' | 'ps7' | 'python' | 'networking' | 'filesystem';
- containment?: 'appcontainer' | 'windows_sandbox' | 'microvm' | 'hyperlight';
+ containment?: 'appcontainer' | 'windows_sandbox' | 'hyperlight';
requiresV05?: boolean;
/** If set, output must contain this string for a PASS verdict */
successMarker?: string;
@@ -377,77 +377,6 @@ var SCENARIOS: Scenario[] = [
script: 'ping -n 30 127.0.0.1',
policy: { timeoutMs: 5000 } },
- // ========== MicroVM (NanVix) ==========
- { id: 'mv-hello', name: 'Hello from MicroVM', category: 'Quick Tests', categoryIcon: '🎯', shell: 'python',
- containment: 'microvm',
- description: 'Runs a simple Python script inside the NanVix micro-VM.',
- expectedOutcome: 'succeed', expectedLabel: 'Should succeed',
- script: "x = 42\ny = 58\nprint('Hello from MicroVM! sum=%d' % (x + y))",
- policy: {}, successMarker: 'Hello from MicroVM!' },
- { id: 'mv-stdlib', name: 'Stdlib (json, math, hashlib)', category: 'Quick Tests', categoryIcon: '🎯', shell: 'python',
- containment: 'microvm',
- description: 'Imports json, math, hashlib to verify the CPython stdlib is available.',
- expectedOutcome: 'succeed', expectedLabel: 'Should succeed',
- script: "import json, math, hashlib\ndata = {'pi': math.pi, 'e': math.e, 'hash': hashlib.sha256(b'nanvix').hexdigest()[:16]}\nprint(json.dumps(data))",
- policy: {}, successMarker: 'pi' },
- { id: 'mv-multiline', name: 'Fibonacci (multiline)', category: 'Quick Tests', categoryIcon: '🎯', shell: 'python',
- containment: 'microvm',
- description: 'Runs a multi-line Fibonacci function to verify complex scripts work.',
- expectedOutcome: 'succeed', expectedLabel: 'Should succeed',
- script: "def fib(n):\n a, b = 0, 1\n for _ in range(n):\n a, b = b, a + b\n return a\n\nfor i in range(10):\n print(f'fib({i}) = {fib(i)}')",
- policy: {}, successMarker: 'fib(9) = 34' },
- { id: 'mv-large-output', name: 'Large output (1000 lines)', category: 'Quick Tests', categoryIcon: '🎯', shell: 'python',
- containment: 'microvm',
- description: 'Prints 1000 lines to verify large output streaming works.',
- expectedOutcome: 'succeed', expectedLabel: 'Should succeed',
- script: "for i in range(1000):\n print(f'line {i}: ' + 'x' * 80)",
- policy: {}, successMarker: 'line 999' },
- { id: 'mv-exit-code', name: 'Exit code 42', category: 'Error Cases', categoryIcon: '⚠️', shell: 'python',
- containment: 'microvm',
- description: 'Exits with code 42. Verifies exit codes propagate from the micro-VM.',
- expectedOutcome: 'show-error', expectedLabel: 'Should exit 42',
- script: 'import sys; sys.exit(42)',
- policy: {} },
- { id: 'mv-error', name: 'Python error', category: 'Error Cases', categoryIcon: '⚠️', shell: 'python',
- containment: 'microvm',
- description: 'Raises a ValueError. Verifies stderr capture from the micro-VM.',
- expectedOutcome: 'show-error', expectedLabel: 'Should show error',
- script: "raise ValueError('intentional test error')",
- policy: {} },
- { id: 'mv-timeout', name: 'Timeout', category: 'Error Cases', categoryIcon: '⚠️', shell: 'python',
- containment: 'microvm',
- description: 'Sleeps for 120s with a 5s timeout. MicroVM adds 60s boot grace, so actual ~65s.',
- expectedOutcome: 'be-blocked', expectedLabel: 'Should be terminated',
- script: "import time; time.sleep(120); print('should not reach here')",
- policy: { timeoutMs: 5000 } },
-
- // ========== MicroVM — Filesystem ==========
- { id: 'mv-fs-write-read', name: 'Write & read file (FS mount)', category: 'Filesystem', categoryIcon: '📁', shell: 'filesystem',
- containment: 'microvm',
- description: 'Writes a file to a readwritePaths mount and reads it back. Verifies staging dir works.',
- expectedOutcome: 'succeed', expectedLabel: 'Should succeed',
- script: "import os\ntest_dir = 'C:\\\\Users\\\\Public\\\\MXCPlaygroundTests'\nfpath = os.path.join(test_dir, 'mxc-mv-test.txt')\ntry:\n with open(fpath, 'w') as f:\n f.write('hello from microvm')\n with open(fpath) as f:\n data = f.read()\n print('Read back:', data)\n assert data == 'hello from microvm', 'mismatch!'\n print('MV FS write/read OK')\nfinally:\n try: os.remove(fpath)\n except OSError: pass",
- policy: { filesystem: { readwritePaths: ['C:\\Users\\Public\\MXCPlaygroundTests'] } }, successMarker: 'MV FS write/read OK' },
- { id: 'mv-fs-list-dir', name: 'List directory contents', category: 'Filesystem', categoryIcon: '📁', shell: 'filesystem',
- containment: 'microvm',
- description: 'Creates and lists files in a readwritePaths mount. Verifies directory operations.',
- expectedOutcome: 'succeed', expectedLabel: 'Should succeed',
- script: "import os\ntest_dir = 'C:\\\\Users\\\\Public\\\\MXCPlaygroundTests'\nnames = ['mxc-a.txt', 'mxc-b.txt', 'mxc-c.txt']\ntry:\n for name in names:\n with open(os.path.join(test_dir, name), 'w') as f:\n f.write(name)\n entries = [e for e in os.listdir(test_dir) if e.startswith('mxc-')]\n print('MXC files:', sorted(entries))\n assert 'mxc-a.txt' in entries and 'mxc-c.txt' in entries\n print('MV dir listing OK')\nfinally:\n for name in names:\n try: os.remove(os.path.join(test_dir, name))\n except OSError: pass",
- policy: { filesystem: { readwritePaths: ['C:\\Users\\Public\\MXCPlaygroundTests'] } }, successMarker: 'MV dir listing OK' },
-
- // ========== MicroVM — Stdlib ==========
- { id: 'mv-stdlib-broad', name: 'Stdlib (re, datetime, collections)', category: 'Quick Tests', categoryIcon: '🎯', shell: 'python',
- containment: 'microvm',
- description: 'Imports re, datetime, collections to verify broader stdlib availability.',
- expectedOutcome: 'succeed', expectedLabel: 'Should succeed',
- script: "import re, datetime, collections\nm = re.match(r'(\\w+)@(\\w+)', 'user@host')\nprint('regex:', m.group(1), m.group(2))\nnow = datetime.datetime(2025, 1, 15, 12, 0)\nprint('datetime:', now.isoformat())\nc = collections.Counter('abracadabra')\nprint('counter:', c.most_common(3))\nprint('broad stdlib OK')",
- policy: {}, successMarker: 'broad stdlib OK' },
- { id: 'mv-memory', name: 'Memory stress (10MB list)', category: 'Quick Tests', categoryIcon: '🎯', shell: 'python',
- containment: 'microvm',
- description: 'Allocates a 10MB list to verify the VM handles non-trivial memory.',
- expectedOutcome: 'succeed', expectedLabel: 'Should succeed',
- script: "data = list(range(1_000_000))\nprint('Allocated', len(data), 'items')\nprint('Sum:', sum(data[:100]))\nprint('Memory OK')",
- policy: {}, successMarker: 'Memory OK' },
{ id: 'hl-hello', name: 'Hello from Hyperlight', category: 'Quick Tests', categoryIcon: '🎯', shell: 'python',
containment: 'hyperlight',
description: 'Runs a simple Python script inside the Hyperlight+Unikraft micro-VM.',
@@ -753,7 +682,7 @@ function getCurrentScript(): string {
// Replace bare 'python' with resolved full path for BaseContainer compatibility
// (skip for Windows Sandbox — it uses mapped Python from the host)
var containment = $sel('containmentSelect').value;
- if (containment !== 'windows_sandbox' && containment !== 'microvm' && containment !== 'hyperlight' && shellPaths.python?.exe && script.match(/^python\s/)) {
+ if (containment !== 'windows_sandbox' && containment !== 'hyperlight' && shellPaths.python?.exe && script.match(/^python\s/)) {
script = '"' + shellPaths.python.exe + '"' + script.substring(6);
}
@@ -767,7 +696,6 @@ function getCurrentScript(): string {
var CONTAINMENT_LABELS: Record = {
appcontainer: 'Base Process Container',
windows_sandbox: 'Windows Sandbox',
- microvm: 'MicroVM (NanVix)',
hyperlight: 'Hyperlight',
wslc: 'WSLC',
lxc: 'LXC',
@@ -806,8 +734,8 @@ var cachedPlatformSupport: { isSupported: boolean; reason?: string } | null = nu
/** Update the platform badge based on the selected containment backend. */
function updatePlatformBadgeForContainment(containment: string): void {
- if (containment === 'microvm' || containment === 'hyperlight') {
- // HL/MV don't depend on a specific Windows build version
+ if (containment === 'hyperlight') {
+ // Hyperlight does not depend on a specific Windows build version.
var label = CONTAINMENT_LABELS[containment] || containment;
$('platformBadge').textContent = '✓ ' + label + ' (no OS version requirement)';
} else if (cachedPlatformSupport) {
@@ -824,7 +752,7 @@ function updatePlatformBadgeForContainment(containment: string): void {
function isVmBackend(): boolean {
var c = $sel('containmentSelect').value;
- return c === 'microvm' || c === 'hyperlight' || c === 'windows_sandbox';
+ return c === 'hyperlight' || c === 'windows_sandbox';
}
function getPermsSummary(): string {
@@ -1043,7 +971,7 @@ function populateScenarios(): void {
select.innerHTML = '';
var containment = $sel('containmentSelect').value;
- var isRawBackend = containment === 'windows_sandbox' || containment === 'microvm' || containment === 'hyperlight';
+ var isRawBackend = containment === 'windows_sandbox' || containment === 'hyperlight';
var filtered = SCENARIOS.filter(function(s) {
if (s.shell !== shell) return false;
if (isRawBackend) return s.containment === containment;
@@ -1223,13 +1151,7 @@ function buildRawBackendConfig(
}
// Hyperlight has no valid v0.10 network section. Omission keeps the
// default-deny posture, which the backend maps to no networking.
- if (containment === 'microvm') {
- var action = scenarioPolicy.network.enabled ? 'allow' : 'deny';
- config.network = {
- egress: { default: action },
- ingress: { default: action, hostLoopback: action },
- };
- } else if (containment !== 'hyperlight' && scenarioPolicy.network.enabled) {
+ if (containment !== 'hyperlight' && scenarioPolicy.network.enabled) {
config.network = { egress: { default: 'allow' } };
}
}
@@ -1242,6 +1164,11 @@ function buildRawBackendConfig(
config.filesystem.readonlyPaths = scenarioPolicy.filesystem.readonlyPaths;
}
}
+ // Hyperlight defaults to network-allowed when `network` is omitted; explicitly
+ // block unless the scenario opts in.
+ if (containment === 'hyperlight' && !config.network) {
+ config.network = { defaultPolicy: 'block' };
+ }
if (scenarioPolicy.timeoutMs) {
config.process.timeout = scenarioPolicy.timeoutMs;
}
@@ -1289,9 +1216,9 @@ async function runSandbox(): Promise {
return;
}
- // Windows Sandbox / MicroVM mode — build raw wxc-exec JSON config and use runSandboxRaw
+ // VM backends build raw wxc-exec JSON config and use runSandboxRaw.
var currentContainment = $sel('containmentSelect').value;
- if (currentContainment === 'windows_sandbox' || currentContainment === 'microvm' || currentContainment === 'hyperlight') {
+ if (currentContainment === 'windows_sandbox' || currentContainment === 'hyperlight') {
var rawScript = state.selectedScenario ? state.selectedScenario.script : (state.customScript || '').trim();
if (!rawScript) {
termError('No script specified');
@@ -1305,31 +1232,6 @@ async function runSandbox(): Promise {
state.timeoutSeconds,
);
- // MicroVM staging requires readwritePaths to exist on the host. Pre-create
- // any dirs the scenario mounts (e.g. C:\Users\Public\MXCPlaygroundTests).
- if (currentContainment === 'microvm' && rawConfig.filesystem && rawConfig.filesystem.readwritePaths) {
- try {
- var ensureDirsResult = await mxc.ensureDirs(rawConfig.filesystem.readwritePaths);
- if (ensureDirsResult && typeof ensureDirsResult === 'object') {
- if ((ensureDirsResult as any).success === false) {
- throw new Error((ensureDirsResult as any).error || (ensureDirsResult as any).message || 'Unknown error');
- }
- if (Array.isArray(ensureDirsResult)) {
- var failedEnsureDir = ensureDirsResult.find(function (entry: any) {
- return entry && typeof entry === 'object' && entry.success === false;
- });
- if (failedEnsureDir) {
- throw new Error(failedEnsureDir.error || failedEnsureDir.message || 'Unknown error');
- }
- }
- }
- } catch (e: any) {
- termError('[Playground] Failed to pre-create RW mount dirs: ' + (e && e.message ? e.message : String(e)));
- onSandboxExit(-1);
- return;
- }
- }
-
state.running = true;
if (!runAllInProgress) {
($('btnRun') as HTMLButtonElement).disabled = true;
@@ -1349,8 +1251,6 @@ async function runSandbox(): Promise {
termInfo('[MXC] API: spawnSandboxFromConfig (raw config)');
if (currentContainment === 'windows_sandbox') {
termDim('[MXC] Note: First run may take 3-5 minutes while the sandbox VM boots.');
- } else if (currentContainment === 'microvm') {
- termDim('[MXC] Note: MicroVM boot adds ~60s grace period to the script timeout.');
} else if (currentContainment === 'hyperlight') {
termDim('[MXC] Note: First run may take longer while Hyperlight warms up the snapshot.');
}
@@ -1516,9 +1416,9 @@ async function runAllScenarios(): Promise {
// Filter scenarios: current shell, containment, available runtimes, version-appropriate
var currentContainment = $sel('containmentSelect').value;
- var isRawBackend = currentContainment === 'windows_sandbox' || currentContainment === 'microvm' || currentContainment === 'hyperlight';
+ var isRawBackend = currentContainment === 'windows_sandbox' || currentContainment === 'hyperlight';
var scenariosToRun = SCENARIOS.filter(function(s) {
- // When the user picks "python" on a raw backend (MicroVM/Hyperlight), also
+ // When the user picks "python" on a raw backend (Hyperlight), also
// pull in the pseudo-shell categories (networking, filesystem) since those
// scenarios are Python-backed and would otherwise be silently skipped.
var shellMatch = (s.shell === currentShell);
@@ -1528,7 +1428,7 @@ async function runAllScenarios(): Promise {
}
if (!shellMatch) { return false; }
if (isRawBackend) { if (s.containment !== currentContainment) return false; }
- else { if (s.containment === 'windows_sandbox' || s.containment === 'microvm' || s.containment === 'hyperlight') return false; }
+ else { if (s.containment === 'windows_sandbox' || s.containment === 'hyperlight') return false; }
if (s.shell === 'ps7' && !shellAvailability['ps7']) { return false; }
if (s.shell === 'python' && !shellAvailability['python']) { return false; }
if (s.requiresV05 && version !== '0.5.0-dev') { return false; }
@@ -1903,7 +1803,7 @@ function showJsonPanel(tab: string): void {
if (tab === 'policy') {
var containment = $sel('containmentSelect').value;
- if (containment === 'windows_sandbox' || containment === 'microvm' || containment === 'hyperlight') {
+ if (containment === 'windows_sandbox' || containment === 'hyperlight') {
var rawScript = state.selectedScenario ? state.selectedScenario.script : (state.customScript || '').trim();
var rawConfig = buildRawBackendConfig(
containment,
@@ -1918,7 +1818,7 @@ function showJsonPanel(tab: string): void {
}
} else {
var containment2 = $sel('containmentSelect').value;
- if (containment2 === 'windows_sandbox' || containment2 === 'microvm' || containment2 === 'hyperlight') {
+ if (containment2 === 'windows_sandbox' || containment2 === 'hyperlight') {
var rawScript2 = state.selectedScenario ? state.selectedScenario.script : (state.customScript || '').trim();
var rawConfig2 = buildRawBackendConfig(
containment2,
@@ -1968,8 +1868,8 @@ function updateDevSidebar(): void {
var currentShell = $sel('shellSelect').value;
var currentContainment = $sel('containmentSelect').value;
- // Windows Sandbox / MicroVM / Hyperlight mode — show the raw config
- if ((currentContainment === 'windows_sandbox' || currentContainment === 'microvm' || currentContainment === 'hyperlight') && currentShell !== 'rawjson') {
+ // Windows Sandbox / Hyperlight mode — show the raw config
+ if ((currentContainment === 'windows_sandbox' || currentContainment === 'hyperlight') && currentShell !== 'rawjson') {
var rawScript = state.selectedScenario ? state.selectedScenario.script : (state.customScript || '').trim();
var rawConfig = buildRawBackendConfig(
currentContainment,
@@ -2187,27 +2087,6 @@ function init(): void {
} else {
populateScenarios();
}
- } else if (containment === 'microvm') {
- // MicroVM (NanVix) — the policy-generator UI is hidden because MicroVM
- // does not consume the SandboxPolicy schema; scenarios may still set
- // filesystem (and other) fields in the raw config sent to wxc-exec.
- $('runtimeRow').classList.remove('hidden');
- $sel('shellSelect').disabled = false;
- $('experimentalCaution').classList.add('hidden');
- $('policySectionWrapper').classList.add('hidden');
- $('advancedSectionWrapper').classList.add('hidden');
- $('uiGroupWrapper').classList.add('hidden');
- ($('experimentalToggle') as HTMLInputElement).checked = true;
- ($('experimentalToggle') as HTMLInputElement).disabled = true;
- // MicroVM supports Python + Filesystem test categories
- var mvOpts = $sel('shellSelect').options;
- for (var mi = 0; mi < mvOpts.length; mi++) {
- var v = mvOpts[mi].value;
- (mvOpts[mi] as HTMLOptionElement).hidden = (v !== 'python' && v !== 'filesystem' && v !== 'custom' && v !== 'rawjson');
- }
- $sel('shellSelect').value = 'python';
- $sel('shellSelect').dispatchEvent(new Event('change'));
- updatePlatformBadgeForContainment(containment);
} else if (containment === 'hyperlight') {
// Hyperlight — the policy-generator UI is hidden because Hyperlight
// does not consume the SandboxPolicy schema; scenarios may still set
@@ -2235,7 +2114,7 @@ function init(): void {
$('categoryRow').classList.add('hidden');
$('policySectionWrapper').classList.remove('hidden');
// Restore PowerShell runtimes (but keep pseudo-shells hidden — they only
- // apply to MicroVM/Hyperlight, where Python is the runtime).
+ // apply to Hyperlight, where Python is the runtime).
var shellOpts2 = $sel('shellSelect').options;
for (var j = 0; j < shellOpts2.length; j++) {
var sv2 = shellOpts2[j].value;
@@ -2253,7 +2132,7 @@ function init(): void {
$('policySectionWrapper').classList.remove('hidden');
($('experimentalToggle') as HTMLInputElement).disabled = false;
// Restore PowerShell runtimes (but keep pseudo-shells hidden — they only
- // apply to MicroVM/Hyperlight, where Python is the runtime).
+ // apply to Hyperlight, where Python is the runtime).
var shellOpts3 = $sel('shellSelect').options;
for (var k = 0; k < shellOpts3.length; k++) {
var sv3 = shellOpts3[k].value;
@@ -2279,7 +2158,7 @@ function init(): void {
$('categoryRow').classList.add('hidden');
$('scriptSection').classList.remove('hidden');
$('rawJsonSection').classList.add('hidden');
- if ($sel('containmentSelect').value !== 'windows_sandbox' && $sel('containmentSelect').value !== 'microvm' && $sel('containmentSelect').value !== 'hyperlight') {
+ if ($sel('containmentSelect').value !== 'windows_sandbox' && $sel('containmentSelect').value !== 'hyperlight') {
$('policySectionWrapper').classList.remove('hidden');
}
$('btnRun').classList.remove('hidden');
@@ -2314,7 +2193,7 @@ function init(): void {
populateScenarios();
$('btnRun').classList.remove('hidden');
$('btnRunAll').classList.remove('hidden');
- if ($sel('containmentSelect').value !== 'windows_sandbox' && $sel('containmentSelect').value !== 'microvm' && $sel('containmentSelect').value !== 'hyperlight') {
+ if ($sel('containmentSelect').value !== 'windows_sandbox' && $sel('containmentSelect').value !== 'hyperlight') {
$('policySectionWrapper').classList.remove('hidden');
}
if (document.getElementById('advancedSectionWrapper')) {
diff --git a/tests/playground/src/renderer/index.html b/tests/playground/src/renderer/index.html
index 8bcecdf16..405244d1b 100644
--- a/tests/playground/src/renderer/index.html
+++ b/tests/playground/src/renderer/index.html
@@ -107,7 +107,6 @@
Scenario
@@ -450,7 +449,7 @@
Containment Types
🛡️ Base Process Container — Uses the Windows process security environment API.
🛡️ AppContainer — Default for 0.4.0-alpha. Standard Windows AppContainer isolation.
-
🧪 Windows Sandbox / MicroVM / WSLC — Experimental. Select from the Containment dropdown — these only support MXC JSON Config mode (paste your own config).
+
🧪 Windows Sandbox / Hyperlight / WSLC — Experimental. Select from the Containment dropdown — these only support MXC JSON Config mode (paste your own config).
diff --git a/tests/scripts/README.md b/tests/scripts/README.md
index 0b8b9edf1..7fd3544ab 100644
--- a/tests/scripts/README.md
+++ b/tests/scripts/README.md
@@ -37,8 +37,6 @@ Linux / macOS (`.sh`):
| `run_filesystem_bfs_spaces_test.ps1` | BFS path-with-spaces test | `wxc-exec.exe` |
| `run_test_configs.ps1` | All test configs via wxc-test-driver | `wxc-test-driver.exe` |
| `run_examples.ps1` | All examples via wxc-test-driver | `wxc-test-driver.exe` |
-| `run_microvm_basic_test.ps1` | MicroVM smoke test | `wxc-exec.exe`, NanVix binaries |
-| `run_microvm_tests.ps1` | Full MicroVM E2E suite | WHP enabled, NanVix binaries |
| `run_windows_sandbox_one_shot_tests.ps1` | Windows Sandbox one-shot E2E suite (fresh disposable VM per test) | Windows Sandbox enabled |
| `run_windows_sandbox_state_aware_tests.ps1` | Windows Sandbox state-aware lifecycle E2E (single VM held across provision/start/exec*/stop/deprovision) | Windows Sandbox enabled |
| `run_isolation_session_tests.ps1` | IsolationSession one-shot E2E suite | Interactive local session; OS-side IsolationSession service |
@@ -95,8 +93,8 @@ these dispatchers, which map a matrix backend id to the suites above:
| Dispatcher | Platforms | Backend ids |
|------------|-----------|-------------|
-| `scripts/ci/run_backend_validation_tests.ps1` | Windows | `process-t1`, `process-t3`, `isolation-session`, `windows-sandbox`, `wslc`, `microvm`, `hyperlight` |
-| `scripts/ci/run_backend_validation_tests.sh` | Linux, macOS | `bubblewrap`, `lxc`, `seatbelt`, `microvm`, `hyperlight` |
+| `scripts/ci/run_backend_validation_tests.ps1` | Windows | `process-t1`, `process-t3`, `isolation-session`, `windows-sandbox`, `wslc`, `hyperlight` |
+| `scripts/ci/run_backend_validation_tests.sh` | Linux, macOS | `bubblewrap`, `lxc`, `seatbelt`, `hyperlight` |
Pass the backend id exactly as it appears in the catalog — there is no separate
handler name. Ids that share a suite have their own case in the dispatcher:
@@ -220,22 +218,3 @@ Run them explicitly on capable machines with
| `test_examples` | Requires velocity key 61714527 (BFS deadlock fix) |
| `test_processcontainer_proxy` | Requires velocity key 61714527 (BFS deadlock fix) and elevation |
| `test_on_repeat` | Stress test (loops BFS tests) |
-
-## MicroVM E2E
-
-### Build
-
-```powershell
-cd src
-cargo build --features microvm --target x86_64-pc-windows-msvc
-```
-
-### Run
-
-```powershell
-cd src
-cargo test -p wxc_e2e_tests --target x86_64-pc-windows-msvc test_microvm_suite -- --nocapture
-```
-
-The MicroVM suite runs 6 functional tests + 1 timeout behavior test.
-It generates `microvm-perf-results.json` with per-test timing and status data (uploaded as CI artifact).
diff --git a/tests/scripts/run_microvm_basic_test.ps1 b/tests/scripts/run_microvm_basic_test.ps1
deleted file mode 100644
index 5dffe2214..000000000
--- a/tests/scripts/run_microvm_basic_test.ps1
+++ /dev/null
@@ -1,44 +0,0 @@
-# Copyright (c) Microsoft Corporation.
-# Licensed under the MIT License.
-
-# MicroVM basic smoke test runner.
-#
-# Usage:
-# .\run_microvm_basic_test.ps1 # debug build
-# .\run_microvm_basic_test.ps1 -Release # release build
-
-param(
- [switch]$Release,
- [string]$BinDir
-)
-
-$ErrorActionPreference = "Stop"
-$RepoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
-
-if (-not $BinDir) {
- if ($Release) {
- $BinDir = Join-Path $RepoRoot "src\target\release"
- } else {
- $BinDir = Join-Path $RepoRoot "src\target\debug"
- }
-}
-
-$WxcExec = Join-Path $BinDir "wxc-exec.exe"
-$TestConfig = Join-Path $RepoRoot "tests\configs\microvm_hello.json"
-
-if (-not (Test-Path $WxcExec)) {
- Write-Host "ERROR: wxc-exec.exe not found at $WxcExec" -ForegroundColor Red
- Write-Host "Run 'cargo build$(if ($Release) { ' --release' })' first." -ForegroundColor Yellow
- exit 1
-}
-
-Write-Host "Running MicroVM basic smoke test..." -ForegroundColor Cyan
-& $WxcExec --debug --experimental $TestConfig
-$exitCode = $LASTEXITCODE
-
-if ($exitCode -ne 0) {
- Write-Host "FAILED: wxc-exec exited with code $exitCode" -ForegroundColor Red
- exit $exitCode
-}
-
-Write-Host "PASSED: MicroVM basic smoke test" -ForegroundColor Green
diff --git a/tests/scripts/run_microvm_tests.ps1 b/tests/scripts/run_microvm_tests.ps1
deleted file mode 100644
index dcf6002b5..000000000
--- a/tests/scripts/run_microvm_tests.ps1
+++ /dev/null
@@ -1,235 +0,0 @@
-# Copyright (c) Microsoft Corporation.
-# Licensed under the MIT License.
-
-<#
-.SYNOPSIS
- Runs MicroVM E2E tests. Requires WHP and Nanvix binaries next to wxc-exec.exe.
-
-.DESCRIPTION
- - Locates wxc-exec.exe (built with --features microvm)
- - Verifies Nanvix binaries are present
- - Runs each test config via wxc-exec, validates exit codes and stdout content
- - Reports pass/fail summary with per-test performance timing
- - Writes microvm-perf-results.json for CI artifact consumption
-
-.PARAMETER Release
- Use release build (default: debug)
-
-.PARAMETER BinDir
- Explicit binary directory. Overrides -Release logic when provided.
-
-.PARAMETER ConfigDir
- Path to test configs directory. Defaults to \tests\configs
-
-.EXAMPLE
- .\run_microvm_tests.ps1
- .\run_microvm_tests.ps1 -Release
- .\run_microvm_tests.ps1 -BinDir C:\build\output
-#>
-
-param(
- [switch]$Release,
- [string]$BinDir,
- [string]$ConfigDir
-)
-
-$ErrorActionPreference = "Stop"
-$RepoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
-
-if (-not $BinDir) {
- if ($Release) {
- $BinDir = Join-Path $RepoRoot "src\target\release"
- } else {
- $BinDir = Join-Path $RepoRoot "src\target\debug"
- }
-}
-
-if (-not $ConfigDir) {
- $ConfigDir = Join-Path $RepoRoot "tests\configs"
-}
-
-$WxcExePath = Join-Path $BinDir "wxc-exec.exe"
-
-# -- WHP check (local runs only) ---------------------------------------------
-# In CI, the workflow checks WHP and fails before reaching this script.
-# For local runs, check here and skip gracefully if WHP is unavailable.
-
-if (-not $env:CI) {
- function Test-WhpAvailable {
- if (-not (Test-Path "$env:SystemRoot\System32\WinHvPlatform.dll")) {
- return $false
- }
- try {
- $cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction SilentlyContinue
- return ($cs -and $cs.HypervisorPresent)
- } catch {
- return $false
- }
- }
-
- if (-not (Test-WhpAvailable)) {
- Write-Host "SKIP: Windows Hypervisor Platform (WHP) is not available." -ForegroundColor Yellow
- Write-Host " Enable it with: Enable-WindowsOptionalFeature -Online -FeatureName HypervisorPlatform"
- exit 0
- }
-}
-
-Write-Host "`n=== MicroVM E2E Tests ===" -ForegroundColor Cyan
-
-# -- Locate wxc-exec.exe -----------------------------------------------------
-
-if (-not (Test-Path $WxcExePath)) {
- Write-Host "ERROR: wxc-exec.exe not found at: $WxcExePath" -ForegroundColor Red
- Write-Host " Build with: cd src && cargo build --features microvm"
- exit 1
-}
-
-$wxcExe = Resolve-Path $WxcExePath
-
-# -- Verify MicroVM binaries --------------------------------------------------
-
-$requiredBinaries = @(
- "nanvixd.exe",
- "nanvix_rootfs.img",
- "python3.initrd",
- "bin\kernel.elf",
- "snapshots\kernel.vmem",
- "snapshots\kernel.whp.cbor"
-)
-$binDir = Split-Path $wxcExe
-$missing = $requiredBinaries | Where-Object { -not (Test-Path (Join-Path $binDir $_)) }
-
-if ($missing) {
- Write-Host "ERROR: Missing MicroVM binaries in ${binDir}:" -ForegroundColor Red
- $missing | ForEach-Object { Write-Host " - $_" }
- Write-Host " Build with: cd src && cargo build --features microvm"
- exit 1
-}
-
-Write-Host "wxc-exec: $wxcExe"
-Write-Host "binaries: $binDir"
-
-# -- Test definitions ---------------------------------------------------------
-
-$tests = @(
- @{ Config = "microvm_hello.json"; ExpectedExit = 0; Description = "Hello world"; OutputContains = "sum=100" },
- @{ Config = "microvm_exit_code.json"; ExpectedExit = 42; Description = "Exit code propagation" },
- @{ Config = "microvm_multiline.json"; ExpectedExit = 0; Description = "Multi-line script (fibonacci)"; OutputContains = "fib(" },
- @{ Config = "microvm_stdlib.json"; ExpectedExit = 0; Description = "Stdlib (json, math, hashlib)"; OutputContains = "pi" },
- @{ Config = "microvm_large_output.json"; ExpectedExit = 0; Description = "Large stdout (1000 lines)"; OutputContains = "line 999" },
- @{ Config = "microvm_error.json"; ExpectedExit = 1; Description = "Python exception"; OutputContains = "ValueError" },
- @{ Config = "microvm_timeout.json"; ExpectedExit = -1; Description = "Timeout kills VM" }
-)
-
-# -- Run tests ----------------------------------------------------------------
-
-$passed = 0
-$failed = 0
-$results = @()
-
-foreach ($test in $tests) {
- $configPath = Join-Path $ConfigDir $test.Config
- if (-not (Test-Path $configPath)) {
- Write-Host " SKIP $($test.Config) (file not found)" -ForegroundColor Yellow
- continue
- }
-
- Write-Host "`n--- $($test.Description) ($($test.Config)) ---" -ForegroundColor White
-
- $sw = [System.Diagnostics.Stopwatch]::StartNew()
- $stdoutFile = [System.IO.Path]::GetTempFileName()
- $stderrFile = [System.IO.Path]::GetTempFileName()
- $process = Start-Process -FilePath $wxcExe `
- -ArgumentList "--debug", "--experimental", $configPath `
- -PassThru -Wait `
- -RedirectStandardOutput $stdoutFile `
- -RedirectStandardError $stderrFile
- $sw.Stop()
-
- $actualExit = $process.ExitCode
- $expectedExit = $test.ExpectedExit
- $elapsedMs = $sw.ElapsedMilliseconds
- $stdout = Get-Content $stdoutFile -Raw -ErrorAction SilentlyContinue
- $stderr = Get-Content $stderrFile -Raw -ErrorAction SilentlyContinue
- Remove-Item $stdoutFile, $stderrFile -ErrorAction SilentlyContinue
-
- $pass = ($actualExit -eq $expectedExit)
- $reason = ""
-
- if (-not $pass) {
- $reason = "expected exit=$expectedExit, got exit=$actualExit"
- }
-
- # Check stdout content if OutputContains is specified. Not every test
- # defines the key, and StrictMode makes a missing hashtable key throw, so
- # test for its presence rather than accessing it directly.
- if ($pass -and $test.ContainsKey('OutputContains')) {
- $combined = "$stdout`n$stderr"
- if ($combined -notmatch [regex]::Escape($test.OutputContains)) {
- $pass = $false
- $reason = "output missing '$($test.OutputContains)'"
- }
- }
-
- if ($pass) {
- Write-Host " PASS (exit=$actualExit, ${elapsedMs}ms)" -ForegroundColor Green
- $passed++
- $results += @{ Test = $test.Config; Status = "PASS"; Exit = $actualExit; WallTimeMs = $elapsedMs; Description = $test.Description }
- } else {
- Write-Host " FAIL ($reason, ${elapsedMs}ms)" -ForegroundColor Red
- $combined = "$stdout`n$stderr"
- $combined -split "`n" | Where-Object { $_.Trim() } | Select-Object -Last 3 | ForEach-Object {
- Write-Host " > $($_.TrimEnd())" -ForegroundColor Gray
- }
- $failed++
- $results += @{ Test = $test.Config; Status = "FAIL"; Exit = $actualExit; WallTimeMs = $elapsedMs; Description = $test.Description }
- }
-}
-
-# -- Performance summary ------------------------------------------------------
-
-Write-Host "`n=== Performance ===" -ForegroundColor Cyan
-Write-Host (" {0,-35} {1,10} {2,8}" -f "Test", "Time (ms)", "Status")
-Write-Host (" {0,-35} {1,10} {2,8}" -f "----", "---------", "------")
-foreach ($r in $results) {
- $color = if ($r.Status -eq "PASS") { "Green" } else { "Red" }
- Write-Host (" {0,-35} {1,10} {2,8}" -f $r.Description, $r.WallTimeMs, $r.Status) -ForegroundColor $color
-}
-
-# Write JSON results for CI artifact consumption
-$perfOutput = @{
- commit = if ($env:GITHUB_SHA) { $env:GITHUB_SHA } else { "local" }
- timestamp = (Get-Date -Format "o")
- results = $results | ForEach-Object {
- @{
- test = $_.Test
- description = $_.Description
- wall_time_ms = $_.WallTimeMs
- exit_code = $_.Exit
- status = $_.Status
- }
- }
-}
-$perfJsonPath = Join-Path $ConfigDir "..\microvm-perf-results.json"
-$perfOutput | ConvertTo-Json -Depth 3 | Set-Content $perfJsonPath -Encoding UTF8
-Write-Host "`n Performance results written to: $perfJsonPath"
-
-# -- Summary ------------------------------------------------------------------
-
-$total = $passed + $failed
-Write-Host "`n=== Results ===" -ForegroundColor Cyan
-if ($total -eq 0) {
- Write-Host " ERROR: No tests were executed. Check -ConfigDir path." -ForegroundColor Red
- exit 1
-}
-Write-Host " Passed: $passed / $total"
-if ($failed -gt 0) {
- Write-Host " Failed: $failed / $total" -ForegroundColor Red
- $results | Where-Object { $_.Status -eq "FAIL" } | ForEach-Object {
- Write-Host " - $($_.Test) (exit=$($_.Exit))" -ForegroundColor Red
- }
- exit 1
-} else {
- Write-Host " All MicroVM E2E tests passed!" -ForegroundColor Green
- exit 0
-}